feat: implement hooks, plugins, session forks, and vim motions
Add 4 major features to codetyper-cli:
- Hooks System: Lifecycle hooks (PreToolUse, PostToolUse, SessionStart,
SessionEnd, UserPromptSubmit, Stop) with exit code control flow
- Plugin System: Custom tools, commands, and hooks via plugin manifest
- Session Forking: Snapshots, rewind, fork, and switch between branches
- Vim Motions: Normal/Insert/Command/Visual modes with keyboard navigation
New files:
- src/types/{hooks,plugin,session-fork,vim}.ts
- src/constants/{hooks,plugin,session-fork,vim}.ts
- src/services/{hooks-service,plugin-loader,plugin-service,session-fork-service}.ts
- src/stores/vim-store.ts (vanilla)
- src/tui/hooks/{useVimMode,useVimStore,useTodoStore,useThemeStore}.ts
- src/tui/components/VimStatusLine.tsx
Modified:
- src/services/agent.ts (hook integration)
- src/tools/index.ts (plugin tool registration)
- src/stores/{todo-store,theme-store}.ts (converted to vanilla)
- TUI components (updated hook imports)
This commit is contained in:
107
README.md
107
README.md
@@ -90,6 +90,28 @@ Full-screen terminal interface with real-time streaming responses.
|
||||
- `Shift+Up/Down` - Scroll log panel
|
||||
- `Ctrl+C` (twice) - Exit
|
||||
|
||||
### Vim Motions
|
||||
|
||||
Optional vim-style keyboard navigation for power users. Enable in settings.
|
||||
|
||||
**Normal Mode:**
|
||||
- `j/k` - Scroll down/up
|
||||
- `gg/G` - Jump to top/bottom
|
||||
- `Ctrl+d/u` - Half page scroll
|
||||
- `/` - Search, `n/N` - Next/prev match
|
||||
- `i` - Enter insert mode
|
||||
- `:` - Command mode (`:q` quit, `:w` save)
|
||||
|
||||
**Configuration:**
|
||||
```json
|
||||
{
|
||||
"vim": {
|
||||
"enabled": true,
|
||||
"startInNormalMode": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Command Menu
|
||||
|
||||
Press `/` to access all commands organized by category.
|
||||
@@ -250,6 +272,10 @@ CodeTyper has access to these built-in tools:
|
||||
| `read` | Read file contents |
|
||||
| `write` | Create or overwrite files |
|
||||
| `edit` | Find and replace in files |
|
||||
| `glob` | Find files by pattern |
|
||||
| `grep` | Search file contents |
|
||||
| `lsp` | Language Server Protocol operations |
|
||||
| `web_search` | Search the web |
|
||||
| `todo-read` | Read current todo list |
|
||||
| `todo-write` | Update todo list |
|
||||
|
||||
@@ -263,6 +289,87 @@ Connect external MCP (Model Context Protocol) servers for extended capabilities:
|
||||
# Then add a new server
|
||||
```
|
||||
|
||||
## Extensibility
|
||||
|
||||
### Hooks System
|
||||
|
||||
Lifecycle hooks for intercepting tool execution and session events.
|
||||
|
||||
**Hook Events:**
|
||||
- `PreToolUse` - Validate/modify before tool execution
|
||||
- `PostToolUse` - Side effects after tool execution
|
||||
- `SessionStart` - At session initialization
|
||||
- `SessionEnd` - At session termination
|
||||
- `UserPromptSubmit` - When user submits input
|
||||
- `Stop` - When execution stops
|
||||
|
||||
**Configuration** (`.codetyper/hooks.json`):
|
||||
```json
|
||||
{
|
||||
"hooks": [
|
||||
{ "event": "PreToolUse", "script": ".codetyper/hooks/validate.sh", "timeout": 5000 },
|
||||
{ "event": "PostToolUse", "script": ".codetyper/hooks/notify.sh" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Exit Codes:**
|
||||
- `0` - Allow (optionally output `{"updatedInput": {...}}` to modify args)
|
||||
- `1` - Warn but continue
|
||||
- `2` - Block execution
|
||||
|
||||
### Plugin System
|
||||
|
||||
Extend CodeTyper with custom tools, commands, and hooks.
|
||||
|
||||
**Plugin Structure:**
|
||||
```
|
||||
.codetyper/plugins/{name}/
|
||||
├── plugin.json # Manifest
|
||||
├── tools/
|
||||
│ └── *.ts # Custom tool definitions
|
||||
├── commands/
|
||||
│ └── *.md # Slash commands
|
||||
└── hooks/
|
||||
└── *.sh # Plugin-specific hooks
|
||||
```
|
||||
|
||||
**Manifest** (`plugin.json`):
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"version": "1.0.0",
|
||||
"tools": [{ "name": "custom_tool", "file": "tool.ts" }],
|
||||
"commands": [{ "name": "mycommand", "file": "cmd.md" }]
|
||||
}
|
||||
```
|
||||
|
||||
**Custom Tool Definition:**
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
export default {
|
||||
name: "custom_tool",
|
||||
description: "Does something",
|
||||
parameters: z.object({ input: z.string() }),
|
||||
execute: async (args, ctx) => ({ success: true, title: "Done", output: "..." }),
|
||||
};
|
||||
```
|
||||
|
||||
### Session Forking
|
||||
|
||||
Branch and rewind session history for experimentation.
|
||||
|
||||
**Commands:**
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/snapshot [name]` | Create checkpoint |
|
||||
| `/rewind [n\|name]` | Go back to snapshot |
|
||||
| `/fork [name]` | Branch current session |
|
||||
| `/forks` | List all forks |
|
||||
| `/switch [name]` | Switch to fork |
|
||||
|
||||
Sessions are stored in `.codetyper/sessions/` with automatic commit message suggestions.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
||||
@@ -9,6 +9,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Hooks System**: Lifecycle hooks for extensibility
|
||||
- 6 hook events: PreToolUse, PostToolUse, SessionStart, SessionEnd, UserPromptSubmit, Stop
|
||||
- Exit code control flow (0=allow, 1=warn, 2=block)
|
||||
- JSON input/output via stdin/stdout
|
||||
- Modified arguments via `updatedInput`
|
||||
- Global + local configuration support
|
||||
- Configurable timeout per hook
|
||||
|
||||
- **Plugin System**: Custom tools, commands, and hooks
|
||||
- Plugin manifest with version and capabilities
|
||||
- Custom tool definitions via TypeScript
|
||||
- Custom slash commands via Markdown with frontmatter
|
||||
- Plugin-specific hooks
|
||||
- Global (~/.config/codetyper/plugins/) + local (.codetyper/plugins/)
|
||||
- Dynamic tool/command registration
|
||||
|
||||
- **Session Forking/Rewind**: Branch and time-travel session history
|
||||
- Named snapshots at any point in conversation
|
||||
- Rewind to any snapshot by name or index
|
||||
- Fork branches from any snapshot
|
||||
- Switch between forks
|
||||
- Suggested commit messages based on session content
|
||||
- Commands: /snapshot, /rewind, /fork, /forks, /switch
|
||||
|
||||
- **Vim Motions**: Vim-style keyboard navigation
|
||||
- 4 modes: Normal, Insert, Command, Visual
|
||||
- Scroll navigation (j/k, gg/G, Ctrl+d/u)
|
||||
- Search with highlighting (/, n/N)
|
||||
- Command mode (:q, :w, :wq, :nohl)
|
||||
- Mode indicator in status line
|
||||
- Configurable via settings.json
|
||||
|
||||
- **Home Screen**: New welcome screen with centered gradient logo
|
||||
- Displays version, provider, and model info
|
||||
- Transitions to session view on first message
|
||||
|
||||
@@ -135,11 +135,21 @@ docs: update README with new CLI options
|
||||
src/
|
||||
├── index.ts # Entry point only
|
||||
├── commands/ # CLI command implementations
|
||||
├── constants/ # Centralized constants
|
||||
├── interfaces/ # Interface definitions
|
||||
├── providers/ # LLM provider integrations
|
||||
├── services/ # Business logic services
|
||||
│ ├── hooks-service.ts # Lifecycle hooks
|
||||
│ ├── plugin-service.ts # Plugin management
|
||||
│ ├── plugin-loader.ts # Plugin discovery
|
||||
│ └── session-fork-service.ts # Session forking
|
||||
├── stores/ # Zustand state stores
|
||||
│ └── vim-store.ts # Vim mode state
|
||||
├── tools/ # Agent tools (bash, read, write, edit)
|
||||
├── tui/ # Terminal UI components
|
||||
│ └── components/ # Reusable UI components
|
||||
└── types.ts # Shared type definitions
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ └── hooks/ # React hooks (useVimMode, etc.)
|
||||
└── types/ # Type definitions
|
||||
```
|
||||
|
||||
### Testing
|
||||
@@ -170,11 +180,16 @@ describe('PermissionManager', () => {
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.ts` | CLI entry point, command registration |
|
||||
| `src/agent.ts` | Agent loop, tool orchestration |
|
||||
| `src/permissions.ts` | Permission system |
|
||||
| `src/services/agent.ts` | Agent loop, tool orchestration |
|
||||
| `src/services/permissions.ts` | Permission system |
|
||||
| `src/services/hooks-service.ts` | Lifecycle hooks |
|
||||
| `src/services/plugin-service.ts` | Plugin management |
|
||||
| `src/services/session-fork-service.ts` | Session forking |
|
||||
| `src/commands/chat-tui.tsx` | Main TUI command |
|
||||
| `src/tui/App.tsx` | Root TUI component |
|
||||
| `src/tui/store.ts` | Zustand state management |
|
||||
| `src/stores/vim-store.ts` | Vim mode state |
|
||||
| `src/tui/hooks/useVimMode.ts` | Vim keyboard handling |
|
||||
|
||||
### Adding a New Provider
|
||||
|
||||
@@ -192,6 +207,28 @@ describe('PermissionManager', () => {
|
||||
4. Register in `src/tools/index.ts`
|
||||
5. Add permission handling if needed
|
||||
|
||||
### Adding a Hook Event
|
||||
|
||||
1. Add event type to `src/types/hooks.ts`
|
||||
2. Add constants to `src/constants/hooks.ts`
|
||||
3. Add input type for the event
|
||||
4. Implement execution in `src/services/hooks-service.ts`
|
||||
5. Call hook from appropriate location
|
||||
|
||||
### Creating a Plugin
|
||||
|
||||
1. Create directory in `.codetyper/plugins/{name}/`
|
||||
2. Add `plugin.json` manifest
|
||||
3. Add tools in `tools/*.ts`
|
||||
4. Add commands in `commands/*.md`
|
||||
5. Add hooks in `hooks/*.sh`
|
||||
|
||||
### Adding Vim Bindings
|
||||
|
||||
1. Add binding to `VIM_DEFAULT_BINDINGS` in `src/constants/vim.ts`
|
||||
2. Add action handler in `src/tui/hooks/useVimMode.ts`
|
||||
3. Update documentation
|
||||
|
||||
## Questions?
|
||||
|
||||
- Open a GitHub issue for questions
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@utils/terminal";
|
||||
import { createCallbacks } from "@commands/chat-tui";
|
||||
import { agentLoader } from "@services/agent-loader";
|
||||
import { projectSetupService } from "@services/project-setup-service";
|
||||
|
||||
interface ExecuteContext {
|
||||
state: ChatServiceState | null;
|
||||
@@ -150,6 +151,9 @@ const execute = async (options: ChatTUIOptions): Promise<void> => {
|
||||
baseSystemPrompt: null,
|
||||
};
|
||||
|
||||
// Setup project on startup (add .codetyper to gitignore, create default agents)
|
||||
await projectSetupService.setupProject(process.cwd());
|
||||
|
||||
const { state, session } = await initializeChatService(options);
|
||||
ctx.state = state;
|
||||
// Store the original system prompt before any agent modifications
|
||||
|
||||
80
src/constants/hooks.ts
Normal file
80
src/constants/hooks.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Hook System Constants
|
||||
*
|
||||
* Constants for lifecycle hooks
|
||||
*/
|
||||
|
||||
import type { HookEventType } from "@/types/hooks";
|
||||
|
||||
/**
|
||||
* Hook configuration file name
|
||||
*/
|
||||
export const HOOKS_CONFIG_FILE = "hooks.json";
|
||||
|
||||
/**
|
||||
* Default hook timeout in milliseconds
|
||||
*/
|
||||
export const DEFAULT_HOOK_TIMEOUT = 30000;
|
||||
|
||||
/**
|
||||
* Hook exit codes and their meanings
|
||||
*/
|
||||
export const HOOK_EXIT_CODES = {
|
||||
/** Allow execution to continue */
|
||||
ALLOW: 0,
|
||||
/** Warn but continue execution */
|
||||
WARN: 1,
|
||||
/** Block execution */
|
||||
BLOCK: 2,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Hook event type labels for display
|
||||
*/
|
||||
export const HOOK_EVENT_LABELS: Record<HookEventType, string> = {
|
||||
PreToolUse: "Pre-Tool Use",
|
||||
PostToolUse: "Post-Tool Use",
|
||||
SessionStart: "Session Start",
|
||||
SessionEnd: "Session End",
|
||||
UserPromptSubmit: "User Prompt Submit",
|
||||
Stop: "Stop",
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook event type descriptions
|
||||
*/
|
||||
export const HOOK_EVENT_DESCRIPTIONS: Record<HookEventType, string> = {
|
||||
PreToolUse: "Runs before a tool is executed. Can modify args or block execution.",
|
||||
PostToolUse: "Runs after a tool completes. For notifications or logging.",
|
||||
SessionStart: "Runs when a new session begins.",
|
||||
SessionEnd: "Runs when a session ends.",
|
||||
UserPromptSubmit: "Runs when user submits a prompt. Can modify or block.",
|
||||
Stop: "Runs when execution is stopped (interrupt, complete, or error).",
|
||||
};
|
||||
|
||||
/**
|
||||
* All available hook event types
|
||||
*/
|
||||
export const HOOK_EVENT_TYPES: readonly HookEventType[] = [
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Maximum output size from hook script in bytes
|
||||
*/
|
||||
export const MAX_HOOK_OUTPUT_SIZE = 1024 * 1024; // 1MB
|
||||
|
||||
/**
|
||||
* Hook script execution shell
|
||||
*/
|
||||
export const HOOK_SHELL = "/bin/bash";
|
||||
|
||||
/**
|
||||
* Environment variables passed to hooks
|
||||
*/
|
||||
export const HOOK_ENV_PREFIX = "CODETYPER_HOOK_";
|
||||
109
src/constants/plugin.ts
Normal file
109
src/constants/plugin.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Plugin System Constants
|
||||
*
|
||||
* Constants for the plugin architecture
|
||||
*/
|
||||
|
||||
import type { PluginCapability } from "@/types/plugin";
|
||||
|
||||
/**
|
||||
* Plugin directory name
|
||||
*/
|
||||
export const PLUGINS_DIR = "plugins";
|
||||
|
||||
/**
|
||||
* Plugin manifest file name
|
||||
*/
|
||||
export const PLUGIN_MANIFEST_FILE = "plugin.json";
|
||||
|
||||
/**
|
||||
* Plugin subdirectories
|
||||
*/
|
||||
export const PLUGIN_SUBDIRS = {
|
||||
tools: "tools",
|
||||
commands: "commands",
|
||||
hooks: "hooks",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Plugin tool name prefix separator
|
||||
*/
|
||||
export const PLUGIN_TOOL_SEPARATOR = ":";
|
||||
|
||||
/**
|
||||
* Maximum plugin load timeout in milliseconds
|
||||
*/
|
||||
export const PLUGIN_LOAD_TIMEOUT = 5000;
|
||||
|
||||
/**
|
||||
* Maximum number of plugins to load
|
||||
*/
|
||||
export const MAX_PLUGINS = 50;
|
||||
|
||||
/**
|
||||
* Default plugin capabilities
|
||||
*/
|
||||
export const DEFAULT_PLUGIN_CAPABILITIES: PluginCapability[] = [];
|
||||
|
||||
/**
|
||||
* All available plugin capabilities
|
||||
*/
|
||||
export const ALL_PLUGIN_CAPABILITIES: PluginCapability[] = [
|
||||
"filesystem",
|
||||
"network",
|
||||
"shell",
|
||||
"mcp",
|
||||
];
|
||||
|
||||
/**
|
||||
* Plugin capability labels
|
||||
*/
|
||||
export const PLUGIN_CAPABILITY_LABELS: Record<PluginCapability, string> = {
|
||||
filesystem: "File System Access",
|
||||
network: "Network Access",
|
||||
shell: "Shell Execution",
|
||||
mcp: "MCP Integration",
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugin capability descriptions
|
||||
*/
|
||||
export const PLUGIN_CAPABILITY_DESCRIPTIONS: Record<PluginCapability, string> = {
|
||||
filesystem: "Can read and write files on disk",
|
||||
network: "Can make network requests",
|
||||
shell: "Can execute shell commands",
|
||||
mcp: "Can interact with MCP servers",
|
||||
};
|
||||
|
||||
/**
|
||||
* Command file extension
|
||||
*/
|
||||
export const COMMAND_FILE_EXTENSION = ".md";
|
||||
|
||||
/**
|
||||
* Tool file extension
|
||||
*/
|
||||
export const TOOL_FILE_EXTENSION = ".ts";
|
||||
|
||||
/**
|
||||
* Hook script extensions
|
||||
*/
|
||||
export const HOOK_SCRIPT_EXTENSIONS = [".sh", ".bash"];
|
||||
|
||||
/**
|
||||
* Command frontmatter delimiter
|
||||
*/
|
||||
export const COMMAND_FRONTMATTER_DELIMITER = "---";
|
||||
|
||||
/**
|
||||
* Plugin load errors
|
||||
*/
|
||||
export const PLUGIN_ERRORS = {
|
||||
MANIFEST_NOT_FOUND: "Plugin manifest not found",
|
||||
MANIFEST_INVALID: "Plugin manifest is invalid",
|
||||
TOOL_LOAD_FAILED: "Failed to load tool",
|
||||
COMMAND_LOAD_FAILED: "Failed to load command",
|
||||
HOOK_LOAD_FAILED: "Failed to load hook",
|
||||
DUPLICATE_TOOL: "Tool name already exists",
|
||||
DUPLICATE_COMMAND: "Command name already exists",
|
||||
} as const;
|
||||
104
src/constants/session-fork.ts
Normal file
104
src/constants/session-fork.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Session Fork Constants
|
||||
*
|
||||
* Constants for session snapshots and forks
|
||||
*/
|
||||
|
||||
/**
|
||||
* File extension for fork files
|
||||
*/
|
||||
export const FORK_FILE_EXTENSION = ".fork.json";
|
||||
|
||||
/**
|
||||
* Main fork name
|
||||
*/
|
||||
export const MAIN_FORK_NAME = "main";
|
||||
|
||||
/**
|
||||
* Default snapshot name prefix
|
||||
*/
|
||||
export const DEFAULT_SNAPSHOT_PREFIX = "snapshot";
|
||||
|
||||
/**
|
||||
* Maximum snapshots per fork
|
||||
*/
|
||||
export const MAX_SNAPSHOTS_PER_FORK = 100;
|
||||
|
||||
/**
|
||||
* Maximum forks per session
|
||||
*/
|
||||
export const MAX_FORKS_PER_SESSION = 50;
|
||||
|
||||
/**
|
||||
* Fork file version for migration
|
||||
*/
|
||||
export const FORK_FILE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Session fork directory name
|
||||
*/
|
||||
export const FORKS_SUBDIR = "sessions";
|
||||
|
||||
/**
|
||||
* Auto-snapshot triggers
|
||||
*/
|
||||
export const AUTO_SNAPSHOT_TRIGGERS = {
|
||||
/** Messages since last snapshot to trigger auto-snapshot */
|
||||
MESSAGE_THRESHOLD: 10,
|
||||
/** Time in ms since last snapshot to trigger auto-snapshot */
|
||||
TIME_THRESHOLD: 300000, // 5 minutes
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Commit message templates
|
||||
*/
|
||||
export const COMMIT_MESSAGE_TEMPLATES = {
|
||||
/** Template for code changes */
|
||||
CODE: "feat(session): {summary} [{count} messages]",
|
||||
/** Template for fix changes */
|
||||
FIX: "fix(session): {summary} [{count} messages]",
|
||||
/** Template for refactor changes */
|
||||
REFACTOR: "refactor(session): {summary} [{count} messages]",
|
||||
/** Template for docs changes */
|
||||
DOCS: "docs(session): {summary} [{count} messages]",
|
||||
/** Default template */
|
||||
DEFAULT: "chore(session): {summary} [{count} messages]",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Keywords for detecting commit types
|
||||
*/
|
||||
export const COMMIT_TYPE_KEYWORDS = {
|
||||
CODE: ["implement", "add", "create", "build", "feature"],
|
||||
FIX: ["fix", "bug", "resolve", "correct", "patch"],
|
||||
REFACTOR: ["refactor", "restructure", "reorganize", "improve"],
|
||||
DOCS: ["document", "readme", "comment", "explain"],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Fork command names
|
||||
*/
|
||||
export const FORK_COMMANDS = {
|
||||
SNAPSHOT: "/snapshot",
|
||||
REWIND: "/rewind",
|
||||
FORK: "/fork",
|
||||
FORKS: "/forks",
|
||||
SWITCH: "/switch",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Error messages for fork operations
|
||||
*/
|
||||
export const FORK_ERRORS = {
|
||||
SESSION_NOT_FOUND: "Session not found",
|
||||
SNAPSHOT_NOT_FOUND: "Snapshot not found",
|
||||
FORK_NOT_FOUND: "Fork not found",
|
||||
MAX_SNAPSHOTS_REACHED: "Maximum snapshots per fork reached",
|
||||
MAX_FORKS_REACHED: "Maximum forks per session reached",
|
||||
INVALID_SNAPSHOT_NAME: "Invalid snapshot name",
|
||||
INVALID_FORK_NAME: "Invalid fork name",
|
||||
DUPLICATE_SNAPSHOT_NAME: "Snapshot name already exists",
|
||||
DUPLICATE_FORK_NAME: "Fork name already exists",
|
||||
CANNOT_REWIND_TO_FUTURE: "Cannot rewind to a future snapshot",
|
||||
NO_SNAPSHOTS_TO_REWIND: "No snapshots to rewind to",
|
||||
} as const;
|
||||
155
src/constants/vim.ts
Normal file
155
src/constants/vim.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Vim Mode Constants
|
||||
*
|
||||
* Constants for vim-style navigation and editing
|
||||
*/
|
||||
|
||||
import type { VimMode, VimKeyBinding, VimConfig } from "@/types/vim";
|
||||
|
||||
/**
|
||||
* Mode labels for display
|
||||
*/
|
||||
export const VIM_MODE_LABELS: Record<VimMode, string> = {
|
||||
normal: "NORMAL",
|
||||
insert: "INSERT",
|
||||
command: "COMMAND",
|
||||
visual: "VISUAL",
|
||||
};
|
||||
|
||||
/**
|
||||
* Mode colors for display
|
||||
*/
|
||||
export const VIM_MODE_COLORS: Record<VimMode, string> = {
|
||||
normal: "blue",
|
||||
insert: "green",
|
||||
command: "yellow",
|
||||
visual: "magenta",
|
||||
};
|
||||
|
||||
/**
|
||||
* Mode hints for status line
|
||||
*/
|
||||
export const VIM_MODE_HINTS: Record<VimMode, string> = {
|
||||
normal: "j/k scroll, i insert, : command",
|
||||
insert: "Esc to normal",
|
||||
command: "Enter to execute, Esc to cancel",
|
||||
visual: "y yank, d delete, Esc cancel",
|
||||
};
|
||||
|
||||
/**
|
||||
* Default key bindings
|
||||
*/
|
||||
export const VIM_DEFAULT_BINDINGS: VimKeyBinding[] = [
|
||||
// Normal mode - Navigation
|
||||
{ key: "j", mode: "normal", action: "scroll_down", description: "Scroll down" },
|
||||
{ key: "k", mode: "normal", action: "scroll_up", description: "Scroll up" },
|
||||
{ key: "d", mode: "normal", action: "scroll_half_down", ctrl: true, description: "Half page down" },
|
||||
{ key: "u", mode: "normal", action: "scroll_half_up", ctrl: true, description: "Half page up" },
|
||||
{ key: "g", mode: "normal", action: "goto_top", description: "Go to top (gg)" },
|
||||
{ key: "G", mode: "normal", action: "goto_bottom", shift: true, description: "Go to bottom" },
|
||||
|
||||
// Normal mode - Mode switching
|
||||
{ key: "i", mode: "normal", action: "enter_insert", description: "Enter insert mode" },
|
||||
{ key: "a", mode: "normal", action: "enter_insert", description: "Append (enter insert)" },
|
||||
{ key: ":", mode: "normal", action: "enter_command", description: "Enter command mode" },
|
||||
{ key: "v", mode: "normal", action: "enter_visual", description: "Enter visual mode" },
|
||||
|
||||
// Normal mode - Search
|
||||
{ key: "/", mode: "normal", action: "search_start", description: "Start search" },
|
||||
{ key: "n", mode: "normal", action: "search_next", description: "Next search match" },
|
||||
{ key: "N", mode: "normal", action: "search_prev", shift: true, description: "Previous search match" },
|
||||
|
||||
// Normal mode - Word navigation
|
||||
{ key: "w", mode: "normal", action: "word_forward", description: "Next word" },
|
||||
{ key: "b", mode: "normal", action: "word_backward", description: "Previous word" },
|
||||
{ key: "0", mode: "normal", action: "line_start", description: "Line start" },
|
||||
{ key: "$", mode: "normal", action: "line_end", description: "Line end" },
|
||||
|
||||
// Normal mode - Edit operations
|
||||
{ key: "y", mode: "normal", action: "yank", description: "Yank (copy)" },
|
||||
{ key: "p", mode: "normal", action: "paste", description: "Paste" },
|
||||
{ key: "u", mode: "normal", action: "undo", description: "Undo" },
|
||||
{ key: "r", mode: "normal", action: "redo", ctrl: true, description: "Redo" },
|
||||
|
||||
// Insert mode
|
||||
{ key: "escape", mode: "insert", action: "exit_mode", description: "Exit to normal mode" },
|
||||
|
||||
// Command mode
|
||||
{ key: "escape", mode: "command", action: "cancel", description: "Cancel command" },
|
||||
{ key: "return", mode: "command", action: "execute_command", description: "Execute command" },
|
||||
|
||||
// Visual mode
|
||||
{ key: "escape", mode: "visual", action: "exit_mode", description: "Exit visual mode" },
|
||||
{ key: "y", mode: "visual", action: "yank", description: "Yank selection" },
|
||||
{ key: "d", mode: "visual", action: "delete", description: "Delete selection" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Vim commands (: commands)
|
||||
*/
|
||||
export const VIM_COMMANDS = {
|
||||
QUIT: "q",
|
||||
QUIT_FORCE: "q!",
|
||||
WRITE: "w",
|
||||
WRITE_QUIT: "wq",
|
||||
HELP: "help",
|
||||
SET: "set",
|
||||
NOHL: "nohl",
|
||||
SEARCH: "/",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Vim command aliases
|
||||
*/
|
||||
export const VIM_COMMAND_ALIASES: Record<string, string> = {
|
||||
quit: "q",
|
||||
exit: "q",
|
||||
write: "w",
|
||||
save: "w",
|
||||
wq: "wq",
|
||||
x: "wq",
|
||||
};
|
||||
|
||||
/**
|
||||
* Default vim configuration
|
||||
*/
|
||||
export const DEFAULT_VIM_CONFIG: VimConfig = {
|
||||
enabled: true,
|
||||
startInNormalMode: true,
|
||||
showModeIndicator: true,
|
||||
searchHighlights: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll amounts
|
||||
*/
|
||||
export const VIM_SCROLL_AMOUNTS = {
|
||||
/** Lines to scroll with j/k */
|
||||
LINE: 1,
|
||||
/** Lines to scroll with Ctrl+d/u */
|
||||
HALF_PAGE: 10,
|
||||
/** Lines to scroll with Ctrl+f/b */
|
||||
FULL_PAGE: 20,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Settings key in config
|
||||
*/
|
||||
export const VIM_SETTINGS_KEY = "vim";
|
||||
|
||||
/**
|
||||
* Escape key codes
|
||||
*/
|
||||
export const ESCAPE_KEYS = ["escape", "\x1b", "\u001b"];
|
||||
|
||||
/**
|
||||
* Special key names
|
||||
*/
|
||||
export const SPECIAL_KEYS = {
|
||||
ESCAPE: "escape",
|
||||
RETURN: "return",
|
||||
BACKSPACE: "backspace",
|
||||
DELETE: "delete",
|
||||
TAB: "tab",
|
||||
SPACE: "space",
|
||||
} as const;
|
||||
41
src/constants/web-search.ts
Normal file
41
src/constants/web-search.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Web Search Tool Constants
|
||||
*/
|
||||
|
||||
export const WEB_SEARCH_DEFAULTS = {
|
||||
MAX_RESULTS: 10,
|
||||
TIMEOUT_MS: 15000,
|
||||
USER_AGENT:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
} as const;
|
||||
|
||||
export const WEB_SEARCH_MESSAGES = {
|
||||
SEARCHING: (query: string) => `Searching: "${query}"`,
|
||||
NO_RESULTS: "No results found",
|
||||
SEARCH_ERROR: (error: string) => `Search failed: ${error}`,
|
||||
TIMEOUT: "Search timed out",
|
||||
} as const;
|
||||
|
||||
export const WEB_SEARCH_TITLES = {
|
||||
SEARCHING: (query: string) => `Searching: ${query}`,
|
||||
RESULTS: (count: number) => `Found ${count} result(s)`,
|
||||
FAILED: "Search failed",
|
||||
NO_RESULTS: "No results",
|
||||
} as const;
|
||||
|
||||
export const WEB_SEARCH_DESCRIPTION = `Search the web for information.
|
||||
|
||||
Use this tool to:
|
||||
- Find documentation
|
||||
- Look up error messages
|
||||
- Research libraries and APIs
|
||||
- Get current information not in your training data
|
||||
|
||||
Parameters:
|
||||
- query: The search query string
|
||||
- maxResults: Maximum number of results to return (default: 5)
|
||||
|
||||
Example:
|
||||
- Search for "TypeScript generics tutorial"
|
||||
- Search for "React useEffect cleanup function"
|
||||
- Search for "bun test framework documentation"`;
|
||||
@@ -5,6 +5,12 @@
|
||||
*/
|
||||
|
||||
import type { AgentConfig } from "@/types/agent-config";
|
||||
import type { PastedImage } from "@/types/image";
|
||||
|
||||
export interface SubmitOptions {
|
||||
/** Pasted images to include with the message */
|
||||
images?: PastedImage[];
|
||||
}
|
||||
|
||||
export interface AppProps {
|
||||
/** Unique session identifier */
|
||||
@@ -18,7 +24,7 @@ export interface AppProps {
|
||||
/** Application version */
|
||||
version: string;
|
||||
/** Called when user submits a message */
|
||||
onSubmit: (message: string) => Promise<void>;
|
||||
onSubmit: (message: string, options?: SubmitOptions) => Promise<void>;
|
||||
/** Called when user exits the application */
|
||||
onExit: () => void;
|
||||
/** Called when user executes a slash command */
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Interface for tracking pasted content with virtual text
|
||||
*/
|
||||
|
||||
import type { PastedImage } from "@/types/image";
|
||||
|
||||
export interface PastedContent {
|
||||
/** Unique identifier for the pasted block */
|
||||
id: string;
|
||||
@@ -22,9 +24,12 @@ export interface PasteState {
|
||||
pastedBlocks: Map<string, PastedContent>;
|
||||
/** Counter for generating unique IDs */
|
||||
pasteCounter: number;
|
||||
/** List of pasted images */
|
||||
pastedImages: PastedImage[];
|
||||
}
|
||||
|
||||
export const createInitialPasteState = (): PasteState => ({
|
||||
pastedBlocks: new Map(),
|
||||
pasteCounter: 0,
|
||||
pastedImages: [],
|
||||
});
|
||||
|
||||
@@ -181,6 +181,7 @@ You have access to these tools - use them in the EXPLORE phase:
|
||||
## Search Tools (Use First)
|
||||
- **glob**: Find files by pattern. Use for exploring project structure.
|
||||
- **grep**: Search file contents. Use for finding code patterns and implementations.
|
||||
- **web_search**: Search the web. Use for documentation, error messages, library info.
|
||||
|
||||
## File Tools
|
||||
- **read**: Read file contents. ALWAYS read before editing.
|
||||
@@ -192,6 +193,21 @@ You have access to these tools - use them in the EXPLORE phase:
|
||||
- **todowrite**: Track multi-step tasks. Use for complex work.
|
||||
- **todoread**: Check task progress.
|
||||
|
||||
## Web Search Guidelines
|
||||
Use web_search when:
|
||||
- You need documentation for a library or API
|
||||
- You encounter an unfamiliar error message
|
||||
- You need current information not in your training data
|
||||
- The user asks about external resources
|
||||
|
||||
Example:
|
||||
\`\`\`
|
||||
<thinking>
|
||||
I need to find documentation for the Bun test framework
|
||||
</thinking>
|
||||
[Uses web_search with query "bun test framework documentation"]
|
||||
\`\`\`
|
||||
|
||||
## Tool Guidelines
|
||||
|
||||
1. **Think first**: Always output <thinking> before your first tool call
|
||||
|
||||
@@ -193,6 +193,7 @@ Read-only tools only:
|
||||
- **grep**: Search file contents for patterns
|
||||
- **read**: Read file contents
|
||||
- **todo_read**: View existing task lists
|
||||
- **web_search**: Search the web for documentation, error messages, library info
|
||||
|
||||
# Tone and Style
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ Read-only tools:
|
||||
- **grep**: Search for related code
|
||||
- **read**: Read file contents
|
||||
- **todo_read**: View task lists
|
||||
- **web_search**: Search for documentation, security advisories, best practices
|
||||
|
||||
# Tone and Style
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ import { chat as providerChat } from "@providers/index";
|
||||
import { getTool, getToolsForApi, refreshMCPTools } from "@tools/index";
|
||||
import type { ToolContext, ToolCall, ToolResult } from "@/types/tools";
|
||||
import { initializePermissions } from "@services/permissions";
|
||||
import {
|
||||
loadHooks,
|
||||
executePreToolUseHooks,
|
||||
executePostToolUseHooks,
|
||||
} from "@services/hooks-service";
|
||||
import { MAX_ITERATIONS } from "@constants/agent";
|
||||
import { usageStore } from "@stores/usage-store";
|
||||
|
||||
@@ -130,12 +135,40 @@ const callLLM = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a tool call
|
||||
* Execute a tool call with hook support
|
||||
*/
|
||||
const executeTool = async (
|
||||
state: AgentState,
|
||||
toolCall: ToolCall,
|
||||
): Promise<ToolResult> => {
|
||||
// Execute PreToolUse hooks
|
||||
const hookResult = await executePreToolUseHooks(
|
||||
state.sessionId,
|
||||
toolCall.name,
|
||||
toolCall.arguments,
|
||||
state.workingDir,
|
||||
);
|
||||
|
||||
// Handle hook results
|
||||
if (hookResult.action === "block") {
|
||||
return {
|
||||
success: false,
|
||||
title: "Blocked by hook",
|
||||
output: "",
|
||||
error: hookResult.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (hookResult.action === "warn") {
|
||||
state.options.onWarning?.(hookResult.message);
|
||||
}
|
||||
|
||||
// Apply modified arguments if hook returned them
|
||||
const effectiveArgs =
|
||||
hookResult.action === "modify"
|
||||
? { ...toolCall.arguments, ...hookResult.updatedInput }
|
||||
: toolCall.arguments;
|
||||
|
||||
const tool = getTool(toolCall.name);
|
||||
|
||||
if (!tool) {
|
||||
@@ -160,19 +193,34 @@ const executeTool = async (
|
||||
},
|
||||
};
|
||||
|
||||
let result: ToolResult;
|
||||
|
||||
try {
|
||||
// Validate arguments
|
||||
const validatedArgs = tool.parameters.parse(toolCall.arguments);
|
||||
return await tool.execute(validatedArgs, ctx);
|
||||
const validatedArgs = tool.parameters.parse(effectiveArgs);
|
||||
result = await tool.execute(validatedArgs, ctx);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
result = {
|
||||
success: false,
|
||||
title: "Tool error",
|
||||
output: "",
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
// Execute PostToolUse hooks (fire-and-forget, don't block on result)
|
||||
executePostToolUseHooks(
|
||||
state.sessionId,
|
||||
toolCall.name,
|
||||
effectiveArgs,
|
||||
result,
|
||||
state.workingDir,
|
||||
).catch(() => {
|
||||
// Silently ignore post-hook errors
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -190,6 +238,9 @@ export const runAgentLoop = async (
|
||||
// Initialize permissions
|
||||
await initializePermissions();
|
||||
|
||||
// Load hooks
|
||||
await loadHooks(state.workingDir);
|
||||
|
||||
// Refresh MCP tools if available
|
||||
await refreshMCPTools();
|
||||
|
||||
|
||||
248
src/services/clipboard-service.ts
Normal file
248
src/services/clipboard-service.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Clipboard Service - Reads images from system clipboard
|
||||
*
|
||||
* Platform-specific clipboard reading:
|
||||
* - macOS: Uses pbpaste and osascript for images
|
||||
* - Linux: Uses xclip or wl-paste
|
||||
* - Windows: Uses PowerShell
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { readFile, unlink } from "fs/promises";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { ImageMediaType, PastedImage } from "@/types/image";
|
||||
|
||||
/** Supported image formats for clipboard operations */
|
||||
export const SUPPORTED_IMAGE_FORMATS: ImageMediaType[] = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
const detectPlatform = (): "darwin" | "linux" | "win32" | "unsupported" => {
|
||||
const platform = process.platform;
|
||||
if (platform === "darwin" || platform === "linux" || platform === "win32") {
|
||||
return platform;
|
||||
}
|
||||
return "unsupported";
|
||||
};
|
||||
|
||||
const runCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
): Promise<{ stdout: Buffer; stderr: string }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(command, args);
|
||||
const stdout: Buffer[] = [];
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (data) => stdout.push(data));
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout: Buffer.concat(stdout), stderr });
|
||||
} else {
|
||||
reject(new Error(`Command failed with code ${code}: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", reject);
|
||||
});
|
||||
};
|
||||
|
||||
const detectImageType = (buffer: Buffer): ImageMediaType | null => {
|
||||
// PNG: 89 50 4E 47
|
||||
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
||||
return "image/png";
|
||||
}
|
||||
// JPEG: FF D8 FF
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
// GIF: 47 49 46 38
|
||||
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
|
||||
return "image/gif";
|
||||
}
|
||||
// WebP: 52 49 46 46 ... 57 45 42 50
|
||||
if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) {
|
||||
if (buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
|
||||
return "image/webp";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const readClipboardImageMacOS = async (): Promise<PastedImage | null> => {
|
||||
const tempFile = join(tmpdir(), `clipboard-${uuidv4()}.png`);
|
||||
|
||||
try {
|
||||
// Use osascript to save clipboard image to temp file
|
||||
const script = `
|
||||
set theFile to POSIX file "${tempFile}"
|
||||
try
|
||||
set imageData to the clipboard as «class PNGf»
|
||||
set fileRef to open for access theFile with write permission
|
||||
write imageData to fileRef
|
||||
close access fileRef
|
||||
return "success"
|
||||
on error
|
||||
return "no image"
|
||||
end try
|
||||
`;
|
||||
|
||||
const { stdout } = await runCommand("osascript", ["-e", script]);
|
||||
const result = stdout.toString().trim();
|
||||
|
||||
if (result !== "success") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read the temp file
|
||||
const imageBuffer = await readFile(tempFile);
|
||||
const mediaType = detectImageType(imageBuffer);
|
||||
|
||||
if (!mediaType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base64Data = imageBuffer.toString("base64");
|
||||
|
||||
return {
|
||||
id: uuidv4(),
|
||||
mediaType,
|
||||
data: base64Data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
// Cleanup temp file
|
||||
try {
|
||||
await unlink(tempFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const readClipboardImageLinux = async (): Promise<PastedImage | null> => {
|
||||
// Try xclip first, then wl-paste for Wayland
|
||||
const commands = [
|
||||
{ cmd: "xclip", args: ["-selection", "clipboard", "-t", "image/png", "-o"] },
|
||||
{ cmd: "wl-paste", args: ["--type", "image/png"] },
|
||||
];
|
||||
|
||||
for (const { cmd, args } of commands) {
|
||||
try {
|
||||
const { stdout } = await runCommand(cmd, args);
|
||||
|
||||
if (stdout.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mediaType = detectImageType(stdout);
|
||||
if (!mediaType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
id: uuidv4(),
|
||||
mediaType,
|
||||
data: stdout.toString("base64"),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} catch {
|
||||
// Try next command
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const readClipboardImageWindows = async (): Promise<PastedImage | null> => {
|
||||
const tempFile = join(tmpdir(), `clipboard-${uuidv4()}.png`);
|
||||
|
||||
try {
|
||||
// PowerShell script to save clipboard image
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$image = [System.Windows.Forms.Clipboard]::GetImage()
|
||||
if ($image -ne $null) {
|
||||
$image.Save('${tempFile.replace(/\\/g, "\\\\")}', [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
Write-Output "success"
|
||||
} else {
|
||||
Write-Output "no image"
|
||||
}
|
||||
`;
|
||||
|
||||
const { stdout } = await runCommand("powershell", ["-Command", script]);
|
||||
const result = stdout.toString().trim();
|
||||
|
||||
if (result !== "success") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageBuffer = await readFile(tempFile);
|
||||
const mediaType = detectImageType(imageBuffer);
|
||||
|
||||
if (!mediaType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: uuidv4(),
|
||||
mediaType,
|
||||
data: imageBuffer.toString("base64"),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
try {
|
||||
await unlink(tempFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const readClipboardImage = async (): Promise<PastedImage | null> => {
|
||||
const platform = detectPlatform();
|
||||
|
||||
const platformHandlers: Record<string, () => Promise<PastedImage | null>> = {
|
||||
darwin: readClipboardImageMacOS,
|
||||
linux: readClipboardImageLinux,
|
||||
win32: readClipboardImageWindows,
|
||||
unsupported: async () => null,
|
||||
};
|
||||
|
||||
const handler = platformHandlers[platform];
|
||||
return handler();
|
||||
};
|
||||
|
||||
export const hasClipboardImage = async (): Promise<boolean> => {
|
||||
const image = await readClipboardImage();
|
||||
return image !== null;
|
||||
};
|
||||
|
||||
export const formatImageSize = (bytes: number): string => {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes}B`;
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
};
|
||||
|
||||
export const getImageSizeFromBase64 = (base64: string): number => {
|
||||
// Base64 encoding increases size by ~33%
|
||||
return Math.ceil((base64.length * 3) / 4);
|
||||
};
|
||||
446
src/services/hooks-service.ts
Normal file
446
src/services/hooks-service.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* Hooks Service
|
||||
*
|
||||
* Manages lifecycle hooks for tool execution and session events
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import { readFile, access, constants } from "fs/promises";
|
||||
import { join, isAbsolute, resolve } from "path";
|
||||
import type {
|
||||
HookDefinition,
|
||||
HooksConfig,
|
||||
HookEventType,
|
||||
HookResult,
|
||||
HookInput,
|
||||
PreToolUseHookInput,
|
||||
PostToolUseHookInput,
|
||||
HookExecutionError,
|
||||
} from "@/types/hooks";
|
||||
import type { ToolResult } from "@/types/tools";
|
||||
import {
|
||||
HOOKS_CONFIG_FILE,
|
||||
DEFAULT_HOOK_TIMEOUT,
|
||||
HOOK_EXIT_CODES,
|
||||
HOOK_SHELL,
|
||||
MAX_HOOK_OUTPUT_SIZE,
|
||||
HOOK_ENV_PREFIX,
|
||||
} from "@constants/hooks";
|
||||
import { DIRS, LOCAL_CONFIG_DIR } from "@constants/paths";
|
||||
|
||||
/**
|
||||
* Cached hooks configuration
|
||||
*/
|
||||
interface HooksCache {
|
||||
global: HookDefinition[];
|
||||
local: HookDefinition[];
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
const hooksCache: HooksCache = {
|
||||
global: [],
|
||||
local: [],
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Load hooks configuration from a file
|
||||
*/
|
||||
const loadHooksFromFile = async (filePath: string): Promise<HookDefinition[]> => {
|
||||
try {
|
||||
await access(filePath, constants.R_OK);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const config: HooksConfig = JSON.parse(content);
|
||||
|
||||
if (!Array.isArray(config.hooks)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return config.hooks.filter(
|
||||
(hook) => hook.enabled !== false && hook.event && hook.script
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load all hooks from global and local configurations
|
||||
*/
|
||||
export const loadHooks = async (workingDir: string): Promise<void> => {
|
||||
const globalPath = join(DIRS.config, HOOKS_CONFIG_FILE);
|
||||
const localPath = join(workingDir, LOCAL_CONFIG_DIR, HOOKS_CONFIG_FILE);
|
||||
|
||||
const [globalHooks, localHooks] = await Promise.all([
|
||||
loadHooksFromFile(globalPath),
|
||||
loadHooksFromFile(localPath),
|
||||
]);
|
||||
|
||||
hooksCache.global = globalHooks;
|
||||
hooksCache.local = localHooks;
|
||||
hooksCache.loaded = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh hooks cache
|
||||
*/
|
||||
export const refreshHooks = async (workingDir: string): Promise<void> => {
|
||||
hooksCache.loaded = false;
|
||||
await loadHooks(workingDir);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get hooks for a specific event type
|
||||
*/
|
||||
export const getHooksForEvent = (event: HookEventType): HookDefinition[] => {
|
||||
if (!hooksCache.loaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allHooks = [...hooksCache.global, ...hooksCache.local];
|
||||
return allHooks.filter((hook) => hook.event === event);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve script path to absolute path
|
||||
*/
|
||||
const resolveScriptPath = (script: string, workingDir: string): string => {
|
||||
if (isAbsolute(script)) {
|
||||
return script;
|
||||
}
|
||||
return resolve(workingDir, script);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a single hook script
|
||||
*/
|
||||
const executeHookScript = async (
|
||||
hook: HookDefinition,
|
||||
input: HookInput,
|
||||
workingDir: string
|
||||
): Promise<HookResult> => {
|
||||
const scriptPath = resolveScriptPath(hook.script, workingDir);
|
||||
const timeout = hook.timeout ?? DEFAULT_HOOK_TIMEOUT;
|
||||
|
||||
// Verify script exists
|
||||
try {
|
||||
await access(scriptPath, constants.X_OK);
|
||||
} catch {
|
||||
return {
|
||||
action: "warn",
|
||||
message: `Hook script not found or not executable: ${scriptPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resolvePromise) => {
|
||||
const env = {
|
||||
...process.env,
|
||||
[`${HOOK_ENV_PREFIX}EVENT`]: hook.event,
|
||||
[`${HOOK_ENV_PREFIX}WORKING_DIR`]: workingDir,
|
||||
};
|
||||
|
||||
const child = spawn(HOOK_SHELL, [scriptPath], {
|
||||
cwd: workingDir,
|
||||
env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let outputSize = 0;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
resolvePromise({
|
||||
action: "warn",
|
||||
message: `Hook timed out after ${timeout}ms: ${hook.name || hook.script}`,
|
||||
});
|
||||
}, timeout);
|
||||
|
||||
child.stdout.on("data", (data: Buffer) => {
|
||||
outputSize += data.length;
|
||||
if (outputSize <= MAX_HOOK_OUTPUT_SIZE) {
|
||||
stdout += data.toString();
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on("data", (data: Buffer) => {
|
||||
outputSize += data.length;
|
||||
if (outputSize <= MAX_HOOK_OUTPUT_SIZE) {
|
||||
stderr += data.toString();
|
||||
}
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const exitCode = code ?? HOOK_EXIT_CODES.ALLOW;
|
||||
|
||||
if (exitCode === HOOK_EXIT_CODES.ALLOW) {
|
||||
// Check if stdout contains modified input
|
||||
if (stdout.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
if (parsed.updatedInput) {
|
||||
resolvePromise({
|
||||
action: "modify",
|
||||
updatedInput: parsed.updatedInput,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON or no updatedInput, just allow
|
||||
}
|
||||
}
|
||||
resolvePromise({ action: "allow" });
|
||||
} else if (exitCode === HOOK_EXIT_CODES.WARN) {
|
||||
resolvePromise({
|
||||
action: "warn",
|
||||
message: stderr.trim() || `Hook warning: ${hook.name || hook.script}`,
|
||||
});
|
||||
} else if (exitCode === HOOK_EXIT_CODES.BLOCK) {
|
||||
resolvePromise({
|
||||
action: "block",
|
||||
message: stderr.trim() || `Blocked by hook: ${hook.name || hook.script}`,
|
||||
});
|
||||
} else {
|
||||
resolvePromise({
|
||||
action: "warn",
|
||||
message: `Hook exited with unexpected code ${exitCode}: ${hook.name || hook.script}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolvePromise({
|
||||
action: "warn",
|
||||
message: `Hook execution error: ${error.message}`,
|
||||
});
|
||||
});
|
||||
|
||||
// Send input to stdin
|
||||
child.stdin.write(JSON.stringify(input));
|
||||
child.stdin.end();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute all hooks for a specific event
|
||||
*/
|
||||
const executeHooks = async (
|
||||
event: HookEventType,
|
||||
input: HookInput,
|
||||
workingDir: string
|
||||
): Promise<HookResult> => {
|
||||
const hooks = getHooksForEvent(event);
|
||||
|
||||
if (hooks.length === 0) {
|
||||
return { action: "allow" };
|
||||
}
|
||||
|
||||
const errors: HookExecutionError[] = [];
|
||||
let modifiedInput: Record<string, unknown> | null = null;
|
||||
|
||||
for (const hook of hooks) {
|
||||
const result = await executeHookScript(hook, input, workingDir);
|
||||
|
||||
if (result.action === "block") {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.action === "warn") {
|
||||
errors.push({
|
||||
hook,
|
||||
error: result.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.action === "modify") {
|
||||
modifiedInput = {
|
||||
...(modifiedInput ?? {}),
|
||||
...result.updatedInput,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (modifiedInput) {
|
||||
return {
|
||||
action: "modify",
|
||||
updatedInput: modifiedInput,
|
||||
};
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
action: "warn",
|
||||
message: errors.map((e) => e.error).join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
return { action: "allow" };
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute PreToolUse hooks
|
||||
*/
|
||||
export const executePreToolUseHooks = async (
|
||||
sessionId: string,
|
||||
toolName: string,
|
||||
toolArgs: Record<string, unknown>,
|
||||
workingDir: string
|
||||
): Promise<HookResult> => {
|
||||
if (!hooksCache.loaded) {
|
||||
await loadHooks(workingDir);
|
||||
}
|
||||
|
||||
const input: PreToolUseHookInput = {
|
||||
sessionId,
|
||||
toolName,
|
||||
toolArgs,
|
||||
workingDir,
|
||||
};
|
||||
|
||||
return executeHooks("PreToolUse", input, workingDir);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute PostToolUse hooks
|
||||
*/
|
||||
export const executePostToolUseHooks = async (
|
||||
sessionId: string,
|
||||
toolName: string,
|
||||
toolArgs: Record<string, unknown>,
|
||||
result: ToolResult,
|
||||
workingDir: string
|
||||
): Promise<void> => {
|
||||
if (!hooksCache.loaded) {
|
||||
await loadHooks(workingDir);
|
||||
}
|
||||
|
||||
const input: PostToolUseHookInput = {
|
||||
sessionId,
|
||||
toolName,
|
||||
toolArgs,
|
||||
result: {
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
error: result.error,
|
||||
},
|
||||
workingDir,
|
||||
};
|
||||
|
||||
// PostToolUse hooks don't block, just execute them
|
||||
await executeHooks("PostToolUse", input, workingDir);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute SessionStart hooks
|
||||
*/
|
||||
export const executeSessionStartHooks = async (
|
||||
sessionId: string,
|
||||
workingDir: string,
|
||||
provider: string,
|
||||
model: string
|
||||
): Promise<void> => {
|
||||
if (!hooksCache.loaded) {
|
||||
await loadHooks(workingDir);
|
||||
}
|
||||
|
||||
const input = {
|
||||
sessionId,
|
||||
workingDir,
|
||||
provider,
|
||||
model,
|
||||
};
|
||||
|
||||
await executeHooks("SessionStart", input, workingDir);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute SessionEnd hooks
|
||||
*/
|
||||
export const executeSessionEndHooks = async (
|
||||
sessionId: string,
|
||||
workingDir: string,
|
||||
duration: number,
|
||||
messageCount: number
|
||||
): Promise<void> => {
|
||||
if (!hooksCache.loaded) {
|
||||
await loadHooks(workingDir);
|
||||
}
|
||||
|
||||
const input = {
|
||||
sessionId,
|
||||
workingDir,
|
||||
duration,
|
||||
messageCount,
|
||||
};
|
||||
|
||||
await executeHooks("SessionEnd", input, workingDir);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute UserPromptSubmit hooks
|
||||
*/
|
||||
export const executeUserPromptSubmitHooks = async (
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
workingDir: string
|
||||
): Promise<HookResult> => {
|
||||
if (!hooksCache.loaded) {
|
||||
await loadHooks(workingDir);
|
||||
}
|
||||
|
||||
const input = {
|
||||
sessionId,
|
||||
prompt,
|
||||
workingDir,
|
||||
};
|
||||
|
||||
return executeHooks("UserPromptSubmit", input, workingDir);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute Stop hooks
|
||||
*/
|
||||
export const executeStopHooks = async (
|
||||
sessionId: string,
|
||||
workingDir: string,
|
||||
reason: "interrupt" | "complete" | "error"
|
||||
): Promise<void> => {
|
||||
if (!hooksCache.loaded) {
|
||||
await loadHooks(workingDir);
|
||||
}
|
||||
|
||||
const input = {
|
||||
sessionId,
|
||||
workingDir,
|
||||
reason,
|
||||
};
|
||||
|
||||
await executeHooks("Stop", input, workingDir);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if hooks are loaded
|
||||
*/
|
||||
export const isHooksLoaded = (): boolean => {
|
||||
return hooksCache.loaded;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all loaded hooks
|
||||
*/
|
||||
export const getAllHooks = (): HookDefinition[] => {
|
||||
return [...hooksCache.global, ...hooksCache.local];
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear hooks cache
|
||||
*/
|
||||
export const clearHooksCache = (): void => {
|
||||
hooksCache.global = [];
|
||||
hooksCache.local = [];
|
||||
hooksCache.loaded = false;
|
||||
};
|
||||
431
src/services/lsp/client.ts
Normal file
431
src/services/lsp/client.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* LSP Client Implementation
|
||||
*
|
||||
* Handles LSP protocol communication with language servers
|
||||
*/
|
||||
|
||||
import type { ChildProcess } from "child_process";
|
||||
import { createInterface } from "readline";
|
||||
import { EventEmitter } from "events";
|
||||
import { getLanguageId } from "@services/lsp/language";
|
||||
|
||||
export interface Position {
|
||||
line: number;
|
||||
character: number;
|
||||
}
|
||||
|
||||
export interface Range {
|
||||
start: Position;
|
||||
end: Position;
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
uri: string;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export interface Diagnostic {
|
||||
range: Range;
|
||||
severity?: 1 | 2 | 3 | 4; // Error, Warning, Info, Hint
|
||||
code?: string | number;
|
||||
source?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CompletionItem {
|
||||
label: string;
|
||||
kind?: number;
|
||||
detail?: string;
|
||||
documentation?: string | { kind: string; value: string };
|
||||
insertText?: string;
|
||||
}
|
||||
|
||||
export interface DocumentSymbol {
|
||||
name: string;
|
||||
kind: number;
|
||||
range: Range;
|
||||
selectionRange: Range;
|
||||
children?: DocumentSymbol[];
|
||||
}
|
||||
|
||||
export interface Hover {
|
||||
contents: string | { kind: string; value: string } | Array<string | { kind: string; value: string }>;
|
||||
range?: Range;
|
||||
}
|
||||
|
||||
export interface LSPClientInfo {
|
||||
serverId: string;
|
||||
root: string;
|
||||
capabilities: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LSPClientEvents {
|
||||
diagnostics: (uri: string, diagnostics: Diagnostic[]) => void;
|
||||
error: (error: Error) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
type RequestId = number;
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (result: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
export class LSPClient extends EventEmitter {
|
||||
private process: ChildProcess;
|
||||
private serverId: string;
|
||||
private root: string;
|
||||
private requestId: RequestId = 0;
|
||||
private pendingRequests: Map<RequestId, PendingRequest> = new Map();
|
||||
private initialized: boolean = false;
|
||||
private capabilities: Record<string, unknown> = {};
|
||||
private openFiles: Map<string, number> = new Map(); // uri -> version
|
||||
private diagnosticsMap: Map<string, Diagnostic[]> = new Map();
|
||||
private buffer: string = "";
|
||||
|
||||
constructor(process: ChildProcess, serverId: string, root: string) {
|
||||
super();
|
||||
this.process = process;
|
||||
this.serverId = serverId;
|
||||
this.root = root;
|
||||
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
private setupHandlers(): void {
|
||||
const rl = createInterface({
|
||||
input: this.process.stdout!,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let contentLength = 0;
|
||||
let headers = true;
|
||||
|
||||
rl.on("line", (line) => {
|
||||
if (headers) {
|
||||
if (line.startsWith("Content-Length:")) {
|
||||
contentLength = parseInt(line.slice(15).trim(), 10);
|
||||
} else if (line === "") {
|
||||
headers = false;
|
||||
this.buffer = "";
|
||||
}
|
||||
} else {
|
||||
this.buffer += line;
|
||||
if (this.buffer.length >= contentLength) {
|
||||
try {
|
||||
const message = JSON.parse(this.buffer);
|
||||
this.handleMessage(message);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
headers = true;
|
||||
contentLength = 0;
|
||||
this.buffer = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.process.on("close", () => {
|
||||
this.emit("close");
|
||||
});
|
||||
|
||||
this.process.on("error", (err) => {
|
||||
this.emit("error", err);
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(message: {
|
||||
id?: RequestId;
|
||||
method?: string;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string };
|
||||
params?: unknown;
|
||||
}): void {
|
||||
// Response to our request
|
||||
if (message.id !== undefined && this.pendingRequests.has(message.id)) {
|
||||
const pending = this.pendingRequests.get(message.id)!;
|
||||
this.pendingRequests.delete(message.id);
|
||||
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message));
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Notification from server
|
||||
if (message.method) {
|
||||
this.handleNotification(message.method, message.params);
|
||||
}
|
||||
}
|
||||
|
||||
private handleNotification(method: string, params: unknown): void {
|
||||
if (method === "textDocument/publishDiagnostics") {
|
||||
const { uri, diagnostics } = params as { uri: string; diagnostics: Diagnostic[] };
|
||||
this.diagnosticsMap.set(uri, diagnostics);
|
||||
this.emit("diagnostics", uri, diagnostics);
|
||||
}
|
||||
// Handle other notifications as needed
|
||||
}
|
||||
|
||||
private send(message: Record<string, unknown>): void {
|
||||
const content = JSON.stringify(message);
|
||||
const header = `Content-Length: ${Buffer.byteLength(content)}\r\n\r\n`;
|
||||
this.process.stdin!.write(header + content);
|
||||
}
|
||||
|
||||
private async request<T>(method: string, params?: unknown): Promise<T> {
|
||||
const id = ++this.requestId;
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: resolve as (result: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
|
||||
this.send({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
});
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`Request ${method} timed out`));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
private notify(method: string, params?: unknown): void {
|
||||
this.send({
|
||||
jsonrpc: "2.0",
|
||||
method,
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
const result = await this.request<{ capabilities: Record<string, unknown> }>("initialize", {
|
||||
processId: process.pid,
|
||||
rootUri: `file://${this.root}`,
|
||||
rootPath: this.root,
|
||||
capabilities: {
|
||||
textDocument: {
|
||||
synchronization: {
|
||||
didSave: true,
|
||||
didOpen: true,
|
||||
didClose: true,
|
||||
didChange: 2, // Incremental
|
||||
},
|
||||
completion: {
|
||||
completionItem: {
|
||||
snippetSupport: true,
|
||||
documentationFormat: ["markdown", "plaintext"],
|
||||
},
|
||||
},
|
||||
hover: {
|
||||
contentFormat: ["markdown", "plaintext"],
|
||||
},
|
||||
definition: {
|
||||
linkSupport: true,
|
||||
},
|
||||
references: {},
|
||||
documentSymbol: {
|
||||
hierarchicalDocumentSymbolSupport: true,
|
||||
},
|
||||
publishDiagnostics: {
|
||||
relatedInformation: true,
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
workspaceFolders: true,
|
||||
didChangeConfiguration: {
|
||||
dynamicRegistration: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceFolders: [
|
||||
{
|
||||
uri: `file://${this.root}`,
|
||||
name: this.root.split("/").pop(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.capabilities = result.capabilities;
|
||||
this.initialized = true;
|
||||
|
||||
this.notify("initialized", {});
|
||||
}
|
||||
|
||||
async openFile(filePath: string, content: string): Promise<void> {
|
||||
const uri = `file://${filePath}`;
|
||||
const languageId = getLanguageId(filePath) ?? "plaintext";
|
||||
const version = 1;
|
||||
|
||||
this.openFiles.set(uri, version);
|
||||
|
||||
this.notify("textDocument/didOpen", {
|
||||
textDocument: {
|
||||
uri,
|
||||
languageId,
|
||||
version,
|
||||
text: content,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateFile(filePath: string, content: string): Promise<void> {
|
||||
const uri = `file://${filePath}`;
|
||||
const currentVersion = this.openFiles.get(uri) ?? 0;
|
||||
const newVersion = currentVersion + 1;
|
||||
|
||||
this.openFiles.set(uri, newVersion);
|
||||
|
||||
this.notify("textDocument/didChange", {
|
||||
textDocument: { uri, version: newVersion },
|
||||
contentChanges: [{ text: content }],
|
||||
});
|
||||
}
|
||||
|
||||
async closeFile(filePath: string): Promise<void> {
|
||||
const uri = `file://${filePath}`;
|
||||
this.openFiles.delete(uri);
|
||||
this.diagnosticsMap.delete(uri);
|
||||
|
||||
this.notify("textDocument/didClose", {
|
||||
textDocument: { uri },
|
||||
});
|
||||
}
|
||||
|
||||
async getHover(filePath: string, position: Position): Promise<Hover | null> {
|
||||
const uri = `file://${filePath}`;
|
||||
|
||||
try {
|
||||
return await this.request<Hover | null>("textDocument/hover", {
|
||||
textDocument: { uri },
|
||||
position,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getDefinition(filePath: string, position: Position): Promise<Location | Location[] | null> {
|
||||
const uri = `file://${filePath}`;
|
||||
|
||||
try {
|
||||
return await this.request<Location | Location[] | null>("textDocument/definition", {
|
||||
textDocument: { uri },
|
||||
position,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getReferences(filePath: string, position: Position, includeDeclaration = true): Promise<Location[]> {
|
||||
const uri = `file://${filePath}`;
|
||||
|
||||
try {
|
||||
const result = await this.request<Location[] | null>("textDocument/references", {
|
||||
textDocument: { uri },
|
||||
position,
|
||||
context: { includeDeclaration },
|
||||
});
|
||||
return result ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getCompletions(filePath: string, position: Position): Promise<CompletionItem[]> {
|
||||
const uri = `file://${filePath}`;
|
||||
|
||||
try {
|
||||
const result = await this.request<{ items: CompletionItem[] } | CompletionItem[] | null>(
|
||||
"textDocument/completion",
|
||||
{
|
||||
textDocument: { uri },
|
||||
position,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result) return [];
|
||||
return Array.isArray(result) ? result : result.items;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getDocumentSymbols(filePath: string): Promise<DocumentSymbol[]> {
|
||||
const uri = `file://${filePath}`;
|
||||
|
||||
try {
|
||||
const result = await this.request<DocumentSymbol[] | null>("textDocument/documentSymbol", {
|
||||
textDocument: { uri },
|
||||
});
|
||||
return result ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getDiagnostics(filePath?: string): Diagnostic[] {
|
||||
if (filePath) {
|
||||
const uri = `file://${filePath}`;
|
||||
return this.diagnosticsMap.get(uri) ?? [];
|
||||
}
|
||||
|
||||
// Return all diagnostics
|
||||
const all: Diagnostic[] = [];
|
||||
for (const diagnostics of this.diagnosticsMap.values()) {
|
||||
all.push(...diagnostics);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
getAllDiagnostics(): Map<string, Diagnostic[]> {
|
||||
return new Map(this.diagnosticsMap);
|
||||
}
|
||||
|
||||
getInfo(): LSPClientInfo {
|
||||
return {
|
||||
serverId: this.serverId,
|
||||
root: this.root,
|
||||
capabilities: this.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
isFileOpen(filePath: string): boolean {
|
||||
const uri = `file://${filePath}`;
|
||||
return this.openFiles.has(uri);
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
this.request("shutdown", null)
|
||||
.then(() => {
|
||||
this.notify("exit");
|
||||
this.process.kill();
|
||||
})
|
||||
.catch(() => {
|
||||
this.process.kill();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const createLSPClient = (
|
||||
process: ChildProcess,
|
||||
serverId: string,
|
||||
root: string,
|
||||
): LSPClient => {
|
||||
return new LSPClient(process, serverId, root);
|
||||
};
|
||||
357
src/services/lsp/index.ts
Normal file
357
src/services/lsp/index.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* LSP Service - Main entry point for language server functionality
|
||||
*
|
||||
* Provides:
|
||||
* - Language detection
|
||||
* - Server startup/shutdown management
|
||||
* - Real-time diagnostics
|
||||
* - Code completion
|
||||
* - Document symbols
|
||||
* - References finding
|
||||
* - Definition jumping
|
||||
* - Hover information
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { EventEmitter } from "events";
|
||||
import {
|
||||
LSPClient,
|
||||
createLSPClient,
|
||||
type Diagnostic,
|
||||
type Position,
|
||||
type Location,
|
||||
type CompletionItem,
|
||||
type DocumentSymbol,
|
||||
type Hover,
|
||||
} from "@services/lsp/client";
|
||||
import {
|
||||
getServersForFile,
|
||||
findRootForServer,
|
||||
spawnServer,
|
||||
type ServerInfo,
|
||||
} from "@services/lsp/server";
|
||||
import { getLanguageId } from "@services/lsp/language";
|
||||
|
||||
interface LSPState {
|
||||
clients: Map<string, LSPClient>; // key: `${root}:${serverId}`
|
||||
spawning: Map<string, Promise<LSPClient | null>>;
|
||||
broken: Set<string>;
|
||||
}
|
||||
|
||||
const state: LSPState = {
|
||||
clients: new Map(),
|
||||
spawning: new Map(),
|
||||
broken: new Set(),
|
||||
};
|
||||
|
||||
const events = new EventEmitter();
|
||||
|
||||
const getClientKey = (root: string, serverId: string): string => {
|
||||
return `${root}:${serverId}`;
|
||||
};
|
||||
|
||||
const getClientsForFile = async (filePath: string): Promise<LSPClient[]> => {
|
||||
const servers = getServersForFile(filePath);
|
||||
const clients: LSPClient[] = [];
|
||||
|
||||
for (const server of servers) {
|
||||
const root = await findRootForServer(filePath, server);
|
||||
if (!root) continue;
|
||||
|
||||
const key = getClientKey(root, server.id);
|
||||
|
||||
// Skip broken servers
|
||||
if (state.broken.has(key)) continue;
|
||||
|
||||
// Check for existing client
|
||||
if (state.clients.has(key)) {
|
||||
clients.push(state.clients.get(key)!);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for in-flight spawn
|
||||
if (state.spawning.has(key)) {
|
||||
const client = await state.spawning.get(key);
|
||||
if (client) clients.push(client);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Spawn new client
|
||||
const spawnPromise = spawnClient(server, root);
|
||||
state.spawning.set(key, spawnPromise);
|
||||
|
||||
try {
|
||||
const client = await spawnPromise;
|
||||
if (client) {
|
||||
clients.push(client);
|
||||
}
|
||||
} finally {
|
||||
state.spawning.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return clients;
|
||||
};
|
||||
|
||||
const spawnClient = async (
|
||||
server: ServerInfo,
|
||||
root: string,
|
||||
): Promise<LSPClient | null> => {
|
||||
const key = getClientKey(root, server.id);
|
||||
|
||||
try {
|
||||
const handle = await spawnServer(server, root);
|
||||
if (!handle) {
|
||||
state.broken.add(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
const client = createLSPClient(handle.process, server.id, root);
|
||||
|
||||
client.on("close", () => {
|
||||
state.clients.delete(key);
|
||||
events.emit("clientClosed", { serverId: server.id, root });
|
||||
});
|
||||
|
||||
client.on("error", () => {
|
||||
state.clients.delete(key);
|
||||
state.broken.add(key);
|
||||
});
|
||||
|
||||
client.on("diagnostics", (uri: string, diagnostics: Diagnostic[]) => {
|
||||
events.emit("diagnostics", { uri, diagnostics, serverId: server.id });
|
||||
});
|
||||
|
||||
await client.initialize();
|
||||
state.clients.set(key, client);
|
||||
|
||||
events.emit("clientConnected", { serverId: server.id, root });
|
||||
|
||||
return client;
|
||||
} catch {
|
||||
state.broken.add(key);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Public API
|
||||
|
||||
export const openFile = async (filePath: string): Promise<void> => {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const clients = await getClientsForFile(absolutePath);
|
||||
|
||||
if (clients.length === 0) return;
|
||||
|
||||
const content = await fs.readFile(absolutePath, "utf-8");
|
||||
|
||||
for (const client of clients) {
|
||||
if (!client.isFileOpen(absolutePath)) {
|
||||
await client.openFile(absolutePath, content);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const updateFile = async (filePath: string, content: string): Promise<void> => {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const clients = await getClientsForFile(absolutePath);
|
||||
|
||||
for (const client of clients) {
|
||||
if (client.isFileOpen(absolutePath)) {
|
||||
await client.updateFile(absolutePath, content);
|
||||
} else {
|
||||
await client.openFile(absolutePath, content);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const closeFile = async (filePath: string): Promise<void> => {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const clients = await getClientsForFile(absolutePath);
|
||||
|
||||
for (const client of clients) {
|
||||
if (client.isFileOpen(absolutePath)) {
|
||||
await client.closeFile(absolutePath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getHover = async (filePath: string, position: Position): Promise<Hover | null> => {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const clients = await getClientsForFile(absolutePath);
|
||||
|
||||
for (const client of clients) {
|
||||
const hover = await client.getHover(absolutePath, position);
|
||||
if (hover) return hover;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getDefinition = async (
|
||||
filePath: string,
|
||||
position: Position,
|
||||
): Promise<Location | Location[] | null> => {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const clients = await getClientsForFile(absolutePath);
|
||||
|
||||
for (const client of clients) {
|
||||
const definition = await client.getDefinition(absolutePath, position);
|
||||
if (definition) return definition;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getReferences = async (
|
||||
filePath: string,
|
||||
position: Position,
|
||||
includeDeclaration = true,
|
||||
): Promise<Location[]> => {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const clients = await getClientsForFile(absolutePath);
|
||||
|
||||
const allRefs: Location[] = [];
|
||||
for (const client of clients) {
|
||||
const refs = await client.getReferences(absolutePath, position, includeDeclaration);
|
||||
allRefs.push(...refs);
|
||||
}
|
||||
|
||||
// Deduplicate by URI and range
|
||||
const seen = new Set<string>();
|
||||
return allRefs.filter((loc) => {
|
||||
const key = `${loc.uri}:${loc.range.start.line}:${loc.range.start.character}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const getCompletions = async (
|
||||
filePath: string,
|
||||
position: Position,
|
||||
): Promise<CompletionItem[]> => {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const clients = await getClientsForFile(absolutePath);
|
||||
|
||||
const allCompletions: CompletionItem[] = [];
|
||||
for (const client of clients) {
|
||||
const completions = await client.getCompletions(absolutePath, position);
|
||||
allCompletions.push(...completions);
|
||||
}
|
||||
|
||||
return allCompletions;
|
||||
};
|
||||
|
||||
export const getDocumentSymbols = async (filePath: string): Promise<DocumentSymbol[]> => {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const clients = await getClientsForFile(absolutePath);
|
||||
|
||||
for (const client of clients) {
|
||||
const symbols = await client.getDocumentSymbols(absolutePath);
|
||||
if (symbols.length > 0) return symbols;
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getDiagnostics = (filePath?: string): Map<string, Diagnostic[]> => {
|
||||
const allDiagnostics = new Map<string, Diagnostic[]>();
|
||||
|
||||
for (const client of state.clients.values()) {
|
||||
const clientDiagnostics = client.getAllDiagnostics();
|
||||
for (const [uri, diagnostics] of clientDiagnostics) {
|
||||
if (filePath) {
|
||||
const expectedUri = `file://${path.resolve(filePath)}`;
|
||||
if (uri !== expectedUri) continue;
|
||||
}
|
||||
|
||||
const existing = allDiagnostics.get(uri) ?? [];
|
||||
allDiagnostics.set(uri, [...existing, ...diagnostics]);
|
||||
}
|
||||
}
|
||||
|
||||
return allDiagnostics;
|
||||
};
|
||||
|
||||
export const getStatus = (): {
|
||||
connected: Array<{ serverId: string; root: string }>;
|
||||
broken: string[];
|
||||
} => {
|
||||
const connected = Array.from(state.clients.values()).map((client) => client.getInfo());
|
||||
const broken = Array.from(state.broken);
|
||||
|
||||
return { connected, broken };
|
||||
};
|
||||
|
||||
export const hasSupport = (filePath: string): boolean => {
|
||||
const servers = getServersForFile(filePath);
|
||||
return servers.length > 0;
|
||||
};
|
||||
|
||||
export const getLanguage = (filePath: string): string | null => {
|
||||
return getLanguageId(filePath);
|
||||
};
|
||||
|
||||
export const shutdown = (): void => {
|
||||
for (const client of state.clients.values()) {
|
||||
client.shutdown();
|
||||
}
|
||||
state.clients.clear();
|
||||
state.spawning.clear();
|
||||
state.broken.clear();
|
||||
};
|
||||
|
||||
export const onDiagnostics = (
|
||||
callback: (data: { uri: string; diagnostics: Diagnostic[]; serverId: string }) => void,
|
||||
): (() => void) => {
|
||||
events.on("diagnostics", callback);
|
||||
return () => events.off("diagnostics", callback);
|
||||
};
|
||||
|
||||
export const onClientConnected = (
|
||||
callback: (data: { serverId: string; root: string }) => void,
|
||||
): (() => void) => {
|
||||
events.on("clientConnected", callback);
|
||||
return () => events.off("clientConnected", callback);
|
||||
};
|
||||
|
||||
export const onClientClosed = (
|
||||
callback: (data: { serverId: string; root: string }) => void,
|
||||
): (() => void) => {
|
||||
events.on("clientClosed", callback);
|
||||
return () => events.off("clientClosed", callback);
|
||||
};
|
||||
|
||||
export const lspService = {
|
||||
openFile,
|
||||
updateFile,
|
||||
closeFile,
|
||||
getHover,
|
||||
getDefinition,
|
||||
getReferences,
|
||||
getCompletions,
|
||||
getDocumentSymbols,
|
||||
getDiagnostics,
|
||||
getStatus,
|
||||
hasSupport,
|
||||
getLanguage,
|
||||
shutdown,
|
||||
onDiagnostics,
|
||||
onClientConnected,
|
||||
onClientClosed,
|
||||
};
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
Diagnostic,
|
||||
Position,
|
||||
Range,
|
||||
Location,
|
||||
CompletionItem,
|
||||
DocumentSymbol,
|
||||
Hover,
|
||||
} from "@services/lsp/client";
|
||||
|
||||
export { getLanguageId, getSupportedExtensions } from "@services/lsp/language";
|
||||
export { SERVERS, getAvailableServers } from "@services/lsp/server";
|
||||
182
src/services/lsp/language.ts
Normal file
182
src/services/lsp/language.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Language Detection and Extension Mapping
|
||||
*
|
||||
* Maps file extensions to LSP language IDs
|
||||
*/
|
||||
|
||||
export const LANGUAGE_EXTENSIONS: Record<string, string> = {
|
||||
// TypeScript/JavaScript
|
||||
".ts": "typescript",
|
||||
".tsx": "typescriptreact",
|
||||
".js": "javascript",
|
||||
".jsx": "javascriptreact",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".mts": "typescript",
|
||||
".cts": "typescript",
|
||||
|
||||
// Web
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
".css": "css",
|
||||
".scss": "scss",
|
||||
".sass": "sass",
|
||||
".less": "less",
|
||||
".vue": "vue",
|
||||
".svelte": "svelte",
|
||||
".astro": "astro",
|
||||
|
||||
// Python
|
||||
".py": "python",
|
||||
".pyi": "python",
|
||||
".pyw": "python",
|
||||
|
||||
// Go
|
||||
".go": "go",
|
||||
".mod": "go.mod",
|
||||
".sum": "go.sum",
|
||||
|
||||
// Rust
|
||||
".rs": "rust",
|
||||
|
||||
// C/C++
|
||||
".c": "c",
|
||||
".h": "c",
|
||||
".cpp": "cpp",
|
||||
".cxx": "cpp",
|
||||
".cc": "cpp",
|
||||
".hpp": "cpp",
|
||||
".hxx": "cpp",
|
||||
".hh": "cpp",
|
||||
|
||||
// Java/Kotlin
|
||||
".java": "java",
|
||||
".kt": "kotlin",
|
||||
".kts": "kotlin",
|
||||
|
||||
// C#/F#
|
||||
".cs": "csharp",
|
||||
".fs": "fsharp",
|
||||
".fsx": "fsharp",
|
||||
|
||||
// Ruby
|
||||
".rb": "ruby",
|
||||
".rake": "ruby",
|
||||
".gemspec": "ruby",
|
||||
|
||||
// PHP
|
||||
".php": "php",
|
||||
|
||||
// Swift
|
||||
".swift": "swift",
|
||||
|
||||
// Lua
|
||||
".lua": "lua",
|
||||
|
||||
// Shell
|
||||
".sh": "shellscript",
|
||||
".bash": "shellscript",
|
||||
".zsh": "shellscript",
|
||||
".fish": "fish",
|
||||
|
||||
// Data formats
|
||||
".json": "json",
|
||||
".jsonc": "jsonc",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".toml": "toml",
|
||||
".xml": "xml",
|
||||
|
||||
// Markdown/Docs
|
||||
".md": "markdown",
|
||||
".mdx": "mdx",
|
||||
".rst": "restructuredtext",
|
||||
|
||||
// SQL
|
||||
".sql": "sql",
|
||||
|
||||
// Docker
|
||||
Dockerfile: "dockerfile",
|
||||
".dockerfile": "dockerfile",
|
||||
|
||||
// Config
|
||||
".env": "dotenv",
|
||||
".ini": "ini",
|
||||
".conf": "conf",
|
||||
|
||||
// Elixir
|
||||
".ex": "elixir",
|
||||
".exs": "elixir",
|
||||
|
||||
// Zig
|
||||
".zig": "zig",
|
||||
|
||||
// Dart
|
||||
".dart": "dart",
|
||||
|
||||
// Haskell
|
||||
".hs": "haskell",
|
||||
".lhs": "haskell",
|
||||
|
||||
// OCaml
|
||||
".ml": "ocaml",
|
||||
".mli": "ocaml",
|
||||
|
||||
// Clojure
|
||||
".clj": "clojure",
|
||||
".cljs": "clojurescript",
|
||||
".cljc": "clojure",
|
||||
|
||||
// Scala
|
||||
".scala": "scala",
|
||||
".sc": "scala",
|
||||
|
||||
// Erlang
|
||||
".erl": "erlang",
|
||||
".hrl": "erlang",
|
||||
|
||||
// Nix
|
||||
".nix": "nix",
|
||||
|
||||
// Terraform
|
||||
".tf": "terraform",
|
||||
".tfvars": "terraform",
|
||||
|
||||
// Prisma
|
||||
".prisma": "prisma",
|
||||
|
||||
// GraphQL
|
||||
".graphql": "graphql",
|
||||
".gql": "graphql",
|
||||
|
||||
// Protobuf
|
||||
".proto": "proto",
|
||||
|
||||
// Makefile
|
||||
Makefile: "makefile",
|
||||
".mk": "makefile",
|
||||
|
||||
// Gleam
|
||||
".gleam": "gleam",
|
||||
|
||||
// Typst
|
||||
".typ": "typst",
|
||||
};
|
||||
|
||||
export const getLanguageId = (filePath: string): string | null => {
|
||||
const ext = filePath.includes(".")
|
||||
? "." + filePath.split(".").pop()
|
||||
: filePath.split("/").pop() ?? "";
|
||||
|
||||
return LANGUAGE_EXTENSIONS[ext] ?? LANGUAGE_EXTENSIONS[filePath.split("/").pop() ?? ""] ?? null;
|
||||
};
|
||||
|
||||
export const getExtensionsForLanguage = (languageId: string): string[] => {
|
||||
return Object.entries(LANGUAGE_EXTENSIONS)
|
||||
.filter(([_, lang]) => lang === languageId)
|
||||
.map(([ext]) => ext);
|
||||
};
|
||||
|
||||
export const getSupportedExtensions = (): string[] => {
|
||||
return Object.keys(LANGUAGE_EXTENSIONS);
|
||||
};
|
||||
267
src/services/lsp/server.ts
Normal file
267
src/services/lsp/server.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* LSP Server Definitions
|
||||
*
|
||||
* Defines how to find and spawn language servers
|
||||
*/
|
||||
|
||||
import { spawn, execSync, type ChildProcess } from "child_process";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
|
||||
export interface ServerHandle {
|
||||
process: ChildProcess;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
extensions: string[];
|
||||
rootPatterns: string[];
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
const fileExists = async (filePath: string): Promise<boolean> => {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const findProjectRoot = async (
|
||||
startDir: string,
|
||||
patterns: string[],
|
||||
): Promise<string | null> => {
|
||||
let currentDir = startDir;
|
||||
const root = path.parse(currentDir).root;
|
||||
|
||||
while (currentDir !== root) {
|
||||
for (const pattern of patterns) {
|
||||
const checkPath = path.join(currentDir, pattern);
|
||||
if (await fileExists(checkPath)) {
|
||||
return currentDir;
|
||||
}
|
||||
}
|
||||
currentDir = path.dirname(currentDir);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const findBinary = async (name: string): Promise<string | null> => {
|
||||
try {
|
||||
const command = process.platform === "win32" ? `where ${name}` : `which ${name}`;
|
||||
const result = execSync(command, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
||||
return result.trim().split("\n")[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const SERVERS: Record<string, ServerInfo> = {
|
||||
typescript: {
|
||||
id: "typescript",
|
||||
name: "TypeScript Language Server",
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
||||
rootPatterns: ["package.json", "tsconfig.json", "jsconfig.json"],
|
||||
command: "typescript-language-server",
|
||||
args: ["--stdio"],
|
||||
},
|
||||
deno: {
|
||||
id: "deno",
|
||||
name: "Deno Language Server",
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx"],
|
||||
rootPatterns: ["deno.json", "deno.jsonc"],
|
||||
command: "deno",
|
||||
args: ["lsp"],
|
||||
},
|
||||
python: {
|
||||
id: "python",
|
||||
name: "Pyright",
|
||||
extensions: [".py", ".pyi"],
|
||||
rootPatterns: ["pyproject.toml", "setup.py", "requirements.txt", "pyrightconfig.json"],
|
||||
command: "pyright-langserver",
|
||||
args: ["--stdio"],
|
||||
},
|
||||
gopls: {
|
||||
id: "gopls",
|
||||
name: "Go Language Server",
|
||||
extensions: [".go"],
|
||||
rootPatterns: ["go.mod", "go.work"],
|
||||
command: "gopls",
|
||||
args: ["serve"],
|
||||
},
|
||||
rust: {
|
||||
id: "rust-analyzer",
|
||||
name: "Rust Analyzer",
|
||||
extensions: [".rs"],
|
||||
rootPatterns: ["Cargo.toml"],
|
||||
command: "rust-analyzer",
|
||||
},
|
||||
clangd: {
|
||||
id: "clangd",
|
||||
name: "Clangd",
|
||||
extensions: [".c", ".cpp", ".h", ".hpp", ".cc", ".cxx"],
|
||||
rootPatterns: ["compile_commands.json", "CMakeLists.txt", ".clangd"],
|
||||
command: "clangd",
|
||||
},
|
||||
lua: {
|
||||
id: "lua-language-server",
|
||||
name: "Lua Language Server",
|
||||
extensions: [".lua"],
|
||||
rootPatterns: [".luarc.json", ".luarc.jsonc"],
|
||||
command: "lua-language-server",
|
||||
},
|
||||
bash: {
|
||||
id: "bash-language-server",
|
||||
name: "Bash Language Server",
|
||||
extensions: [".sh", ".bash", ".zsh"],
|
||||
rootPatterns: [".bashrc", ".zshrc"],
|
||||
command: "bash-language-server",
|
||||
args: ["start"],
|
||||
},
|
||||
yaml: {
|
||||
id: "yaml-language-server",
|
||||
name: "YAML Language Server",
|
||||
extensions: [".yaml", ".yml"],
|
||||
rootPatterns: [".yamllint", ".yaml-lint.yml"],
|
||||
command: "yaml-language-server",
|
||||
args: ["--stdio"],
|
||||
},
|
||||
json: {
|
||||
id: "vscode-json-language-server",
|
||||
name: "JSON Language Server",
|
||||
extensions: [".json", ".jsonc"],
|
||||
rootPatterns: ["package.json", "tsconfig.json"],
|
||||
command: "vscode-json-language-server",
|
||||
args: ["--stdio"],
|
||||
},
|
||||
html: {
|
||||
id: "vscode-html-language-server",
|
||||
name: "HTML Language Server",
|
||||
extensions: [".html", ".htm"],
|
||||
rootPatterns: ["package.json", "index.html"],
|
||||
command: "vscode-html-language-server",
|
||||
args: ["--stdio"],
|
||||
},
|
||||
css: {
|
||||
id: "vscode-css-language-server",
|
||||
name: "CSS Language Server",
|
||||
extensions: [".css", ".scss", ".less"],
|
||||
rootPatterns: ["package.json"],
|
||||
command: "vscode-css-language-server",
|
||||
args: ["--stdio"],
|
||||
},
|
||||
eslint: {
|
||||
id: "eslint",
|
||||
name: "ESLint Language Server",
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx"],
|
||||
rootPatterns: [".eslintrc", ".eslintrc.js", ".eslintrc.json", "eslint.config.js"],
|
||||
command: "vscode-eslint-language-server",
|
||||
args: ["--stdio"],
|
||||
},
|
||||
svelte: {
|
||||
id: "svelte-language-server",
|
||||
name: "Svelte Language Server",
|
||||
extensions: [".svelte"],
|
||||
rootPatterns: ["svelte.config.js", "svelte.config.ts"],
|
||||
command: "svelteserver",
|
||||
args: ["--stdio"],
|
||||
},
|
||||
vue: {
|
||||
id: "vue-language-server",
|
||||
name: "Vue Language Server",
|
||||
extensions: [".vue"],
|
||||
rootPatterns: ["vue.config.js", "vite.config.ts", "nuxt.config.ts"],
|
||||
command: "vue-language-server",
|
||||
args: ["--stdio"],
|
||||
},
|
||||
prisma: {
|
||||
id: "prisma-language-server",
|
||||
name: "Prisma Language Server",
|
||||
extensions: [".prisma"],
|
||||
rootPatterns: ["schema.prisma"],
|
||||
command: "prisma-language-server",
|
||||
args: ["--stdio"],
|
||||
},
|
||||
terraform: {
|
||||
id: "terraform-ls",
|
||||
name: "Terraform Language Server",
|
||||
extensions: [".tf", ".tfvars"],
|
||||
rootPatterns: [".terraform", "main.tf"],
|
||||
command: "terraform-ls",
|
||||
args: ["serve"],
|
||||
},
|
||||
docker: {
|
||||
id: "docker-langserver",
|
||||
name: "Dockerfile Language Server",
|
||||
extensions: [".dockerfile"],
|
||||
rootPatterns: ["Dockerfile", "docker-compose.yml"],
|
||||
command: "docker-langserver",
|
||||
args: ["--stdio"],
|
||||
},
|
||||
};
|
||||
|
||||
export const getServersForFile = (filePath: string): ServerInfo[] => {
|
||||
const ext = "." + (filePath.split(".").pop() ?? "");
|
||||
const fileName = path.basename(filePath);
|
||||
|
||||
return Object.values(SERVERS).filter((server) => {
|
||||
return (
|
||||
server.extensions.includes(ext) ||
|
||||
server.extensions.includes(fileName)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const findRootForServer = async (
|
||||
filePath: string,
|
||||
server: ServerInfo,
|
||||
): Promise<string | null> => {
|
||||
const dir = path.dirname(filePath);
|
||||
return findProjectRoot(dir, server.rootPatterns);
|
||||
};
|
||||
|
||||
export const spawnServer = async (
|
||||
server: ServerInfo,
|
||||
root: string,
|
||||
): Promise<ServerHandle | null> => {
|
||||
const binary = await findBinary(server.command);
|
||||
|
||||
if (!binary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const proc = spawn(binary, server.args ?? [], {
|
||||
cwd: root,
|
||||
env: { ...process.env, ...server.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (!proc.pid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { process: proc };
|
||||
};
|
||||
|
||||
export const isServerAvailable = async (server: ServerInfo): Promise<boolean> => {
|
||||
const binary = await findBinary(server.command);
|
||||
return binary !== null;
|
||||
};
|
||||
|
||||
export const getAvailableServers = async (): Promise<ServerInfo[]> => {
|
||||
const available: ServerInfo[] = [];
|
||||
|
||||
for (const server of Object.values(SERVERS)) {
|
||||
if (await isServerAvailable(server)) {
|
||||
available.push(server);
|
||||
}
|
||||
}
|
||||
|
||||
return available;
|
||||
};
|
||||
350
src/services/plugin-loader.ts
Normal file
350
src/services/plugin-loader.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Plugin Loader Service
|
||||
*
|
||||
* Discovers and parses plugin manifests and files
|
||||
*/
|
||||
|
||||
import { readdir, readFile, access, constants, stat } from "fs/promises";
|
||||
import { join, extname, basename } from "path";
|
||||
import type {
|
||||
PluginManifest,
|
||||
PluginDiscoveryResult,
|
||||
PluginToolDefinition,
|
||||
PluginCommandDefinition,
|
||||
} from "@/types/plugin";
|
||||
import type { HookDefinition } from "@/types/hooks";
|
||||
import {
|
||||
PLUGINS_DIR,
|
||||
PLUGIN_MANIFEST_FILE,
|
||||
PLUGIN_SUBDIRS,
|
||||
COMMAND_FILE_EXTENSION,
|
||||
COMMAND_FRONTMATTER_DELIMITER,
|
||||
HOOK_SCRIPT_EXTENSIONS,
|
||||
MAX_PLUGINS,
|
||||
} from "@constants/plugin";
|
||||
import { DIRS, LOCAL_CONFIG_DIR } from "@constants/paths";
|
||||
|
||||
/**
|
||||
* Discover plugins in a directory
|
||||
*/
|
||||
const discoverPluginsInDir = async (
|
||||
baseDir: string
|
||||
): Promise<PluginDiscoveryResult[]> => {
|
||||
const pluginsPath = join(baseDir, PLUGINS_DIR);
|
||||
const results: PluginDiscoveryResult[] = [];
|
||||
|
||||
try {
|
||||
await access(pluginsPath, constants.R_OK);
|
||||
const entries = await readdir(pluginsPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (results.length >= MAX_PLUGINS) break;
|
||||
|
||||
const pluginPath = join(pluginsPath, entry.name);
|
||||
const manifestPath = join(pluginPath, PLUGIN_MANIFEST_FILE);
|
||||
|
||||
try {
|
||||
await access(manifestPath, constants.R_OK);
|
||||
results.push({
|
||||
name: entry.name,
|
||||
path: pluginPath,
|
||||
manifestPath,
|
||||
});
|
||||
} catch {
|
||||
// No manifest, skip this directory
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist or not readable
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
/**
|
||||
* Discover all plugins from global and local directories
|
||||
*/
|
||||
export const discoverPlugins = async (
|
||||
workingDir: string
|
||||
): Promise<PluginDiscoveryResult[]> => {
|
||||
const [globalPlugins, localPlugins] = await Promise.all([
|
||||
discoverPluginsInDir(DIRS.config),
|
||||
discoverPluginsInDir(join(workingDir, LOCAL_CONFIG_DIR)),
|
||||
]);
|
||||
|
||||
// Local plugins override global ones with same name
|
||||
const pluginMap = new Map<string, PluginDiscoveryResult>();
|
||||
|
||||
for (const plugin of globalPlugins) {
|
||||
pluginMap.set(plugin.name, plugin);
|
||||
}
|
||||
|
||||
for (const plugin of localPlugins) {
|
||||
pluginMap.set(plugin.name, plugin);
|
||||
}
|
||||
|
||||
return Array.from(pluginMap.values());
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse plugin manifest
|
||||
*/
|
||||
export const parseManifest = async (
|
||||
manifestPath: string
|
||||
): Promise<PluginManifest | null> => {
|
||||
try {
|
||||
const content = await readFile(manifestPath, "utf-8");
|
||||
const manifest: PluginManifest = JSON.parse(content);
|
||||
|
||||
// Validate required fields
|
||||
if (!manifest.name || !manifest.version) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return manifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse command file with frontmatter
|
||||
*/
|
||||
export const parseCommandFile = async (
|
||||
filePath: string
|
||||
): Promise<PluginCommandDefinition | null> => {
|
||||
try {
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
// Check for frontmatter
|
||||
if (lines[0]?.trim() !== COMMAND_FRONTMATTER_DELIMITER) {
|
||||
// No frontmatter, treat entire content as prompt
|
||||
const name = basename(filePath, COMMAND_FILE_EXTENSION);
|
||||
return {
|
||||
name,
|
||||
description: `Custom command: ${name}`,
|
||||
prompt: content,
|
||||
};
|
||||
}
|
||||
|
||||
// Find closing frontmatter delimiter
|
||||
let endIndex = -1;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i]?.trim() === COMMAND_FRONTMATTER_DELIMITER) {
|
||||
endIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex === -1) {
|
||||
// Malformed frontmatter
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse frontmatter as YAML-like key-value pairs
|
||||
const frontmatterLines = lines.slice(1, endIndex);
|
||||
const frontmatter: Record<string, string> = {};
|
||||
|
||||
for (const line of frontmatterLines) {
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex > 0) {
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
const value = line.slice(colonIndex + 1).trim();
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Rest is the prompt
|
||||
const prompt = lines.slice(endIndex + 1).join("\n").trim();
|
||||
|
||||
const name = frontmatter.name || basename(filePath, COMMAND_FILE_EXTENSION);
|
||||
const description = frontmatter.description || `Custom command: ${name}`;
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
prompt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load tool module dynamically
|
||||
*/
|
||||
export const loadToolModule = async (
|
||||
filePath: string
|
||||
): Promise<PluginToolDefinition | null> => {
|
||||
try {
|
||||
// For Bun, we can use dynamic import
|
||||
const module = await import(filePath);
|
||||
const toolDef = module.default || module;
|
||||
|
||||
// Validate tool definition
|
||||
if (!toolDef.name || !toolDef.description || !toolDef.parameters || !toolDef.execute) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toolDef as PluginToolDefinition;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load hooks from plugin hooks directory
|
||||
*/
|
||||
export const loadPluginHooks = async (
|
||||
pluginPath: string,
|
||||
manifest: PluginManifest
|
||||
): Promise<HookDefinition[]> => {
|
||||
const hooks: HookDefinition[] = [];
|
||||
|
||||
// Load hooks from manifest
|
||||
if (manifest.hooks) {
|
||||
for (const hookRef of manifest.hooks) {
|
||||
const scriptPath = join(pluginPath, PLUGIN_SUBDIRS.hooks, hookRef.script);
|
||||
|
||||
try {
|
||||
await access(scriptPath, constants.X_OK);
|
||||
hooks.push({
|
||||
event: hookRef.event as HookDefinition["event"],
|
||||
script: scriptPath,
|
||||
timeout: hookRef.timeout,
|
||||
name: `${manifest.name}:${hookRef.event}`,
|
||||
});
|
||||
} catch {
|
||||
// Script not found or not executable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also discover hooks by convention
|
||||
const hooksDir = join(pluginPath, PLUGIN_SUBDIRS.hooks);
|
||||
|
||||
try {
|
||||
await access(hooksDir, constants.R_OK);
|
||||
const entries = await readdir(hooksDir);
|
||||
|
||||
for (const entry of entries) {
|
||||
const ext = extname(entry);
|
||||
if (!HOOK_SCRIPT_EXTENSIONS.includes(ext)) continue;
|
||||
|
||||
const scriptPath = join(hooksDir, entry);
|
||||
const scriptStat = await stat(scriptPath);
|
||||
|
||||
if (!scriptStat.isFile()) continue;
|
||||
|
||||
// Try to determine event type from filename
|
||||
const baseName = basename(entry, ext);
|
||||
const eventTypes = [
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
];
|
||||
|
||||
for (const eventType of eventTypes) {
|
||||
if (baseName.toLowerCase().includes(eventType.toLowerCase())) {
|
||||
// Check if already added from manifest
|
||||
const alreadyAdded = hooks.some(
|
||||
(h) => h.script === scriptPath && h.event === eventType
|
||||
);
|
||||
|
||||
if (!alreadyAdded) {
|
||||
hooks.push({
|
||||
event: eventType as HookDefinition["event"],
|
||||
script: scriptPath,
|
||||
name: `${manifest.name}:${baseName}`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Hooks directory doesn't exist
|
||||
}
|
||||
|
||||
return hooks;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load commands from plugin commands directory
|
||||
*/
|
||||
export const loadPluginCommands = async (
|
||||
pluginPath: string,
|
||||
manifest: PluginManifest
|
||||
): Promise<Map<string, PluginCommandDefinition>> => {
|
||||
const commands = new Map<string, PluginCommandDefinition>();
|
||||
|
||||
// Load commands from manifest
|
||||
if (manifest.commands) {
|
||||
for (const cmdRef of manifest.commands) {
|
||||
const cmdPath = join(pluginPath, PLUGIN_SUBDIRS.commands, cmdRef.file);
|
||||
const cmdDef = await parseCommandFile(cmdPath);
|
||||
|
||||
if (cmdDef) {
|
||||
cmdDef.name = cmdRef.name; // Override with manifest name
|
||||
commands.set(cmdRef.name, cmdDef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also discover commands by convention
|
||||
const commandsDir = join(pluginPath, PLUGIN_SUBDIRS.commands);
|
||||
|
||||
try {
|
||||
await access(commandsDir, constants.R_OK);
|
||||
const entries = await readdir(commandsDir);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (extname(entry) !== COMMAND_FILE_EXTENSION) continue;
|
||||
|
||||
const cmdPath = join(commandsDir, entry);
|
||||
const cmdStat = await stat(cmdPath);
|
||||
|
||||
if (!cmdStat.isFile()) continue;
|
||||
|
||||
const cmdDef = await parseCommandFile(cmdPath);
|
||||
|
||||
if (cmdDef && !commands.has(cmdDef.name)) {
|
||||
commands.set(cmdDef.name, cmdDef);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Commands directory doesn't exist
|
||||
}
|
||||
|
||||
return commands;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load tools from plugin tools directory
|
||||
*/
|
||||
export const loadPluginTools = async (
|
||||
pluginPath: string,
|
||||
manifest: PluginManifest
|
||||
): Promise<Map<string, PluginToolDefinition>> => {
|
||||
const tools = new Map<string, PluginToolDefinition>();
|
||||
|
||||
// Load tools from manifest
|
||||
if (manifest.tools) {
|
||||
for (const toolRef of manifest.tools) {
|
||||
const toolPath = join(pluginPath, PLUGIN_SUBDIRS.tools, toolRef.file);
|
||||
const toolDef = await loadToolModule(toolPath);
|
||||
|
||||
if (toolDef) {
|
||||
toolDef.name = toolRef.name; // Override with manifest name
|
||||
tools.set(toolRef.name, toolDef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tools;
|
||||
};
|
||||
278
src/services/plugin-service.ts
Normal file
278
src/services/plugin-service.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Plugin Service
|
||||
*
|
||||
* Manages plugin lifecycle and provides access to plugin tools and commands
|
||||
*/
|
||||
|
||||
import type {
|
||||
LoadedPlugin,
|
||||
PluginRegistry,
|
||||
PluginCommandDefinition,
|
||||
PluginLoadResult,
|
||||
} from "@/types/plugin";
|
||||
import type { FunctionDefinition, ToolDefinition } from "@tools/types";
|
||||
import type { HookDefinition } from "@/types/hooks";
|
||||
import {
|
||||
discoverPlugins,
|
||||
parseManifest,
|
||||
loadPluginTools,
|
||||
loadPluginCommands,
|
||||
loadPluginHooks,
|
||||
} from "@services/plugin-loader";
|
||||
import {
|
||||
PLUGIN_TOOL_SEPARATOR,
|
||||
PLUGIN_ERRORS,
|
||||
} from "@constants/plugin";
|
||||
|
||||
/**
|
||||
* Plugin registry singleton
|
||||
*/
|
||||
const registry: PluginRegistry = {
|
||||
plugins: new Map(),
|
||||
tools: new Map(),
|
||||
commands: new Map(),
|
||||
initialized: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Load a single plugin
|
||||
*/
|
||||
const loadPlugin = async (
|
||||
_name: string,
|
||||
path: string,
|
||||
manifestPath: string
|
||||
): Promise<PluginLoadResult> => {
|
||||
const manifest = await parseManifest(manifestPath);
|
||||
|
||||
if (!manifest) {
|
||||
return {
|
||||
success: false,
|
||||
error: PLUGIN_ERRORS.MANIFEST_INVALID,
|
||||
};
|
||||
}
|
||||
|
||||
const [tools, commands, hooks] = await Promise.all([
|
||||
loadPluginTools(path, manifest),
|
||||
loadPluginCommands(path, manifest),
|
||||
loadPluginHooks(path, manifest),
|
||||
]);
|
||||
|
||||
const plugin: LoadedPlugin = {
|
||||
manifest,
|
||||
path,
|
||||
tools,
|
||||
commands,
|
||||
hooks,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
plugin,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the plugin system
|
||||
*/
|
||||
export const initializePlugins = async (workingDir: string): Promise<void> => {
|
||||
if (registry.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const discoveredPlugins = await discoverPlugins(workingDir);
|
||||
|
||||
for (const discovered of discoveredPlugins) {
|
||||
const result = await loadPlugin(
|
||||
discovered.name,
|
||||
discovered.path,
|
||||
discovered.manifestPath
|
||||
);
|
||||
|
||||
if (result.success && result.plugin) {
|
||||
registry.plugins.set(discovered.name, result.plugin);
|
||||
|
||||
// Register tools with prefixed names
|
||||
for (const [toolName, toolDef] of result.plugin.tools) {
|
||||
const prefixedName = `${discovered.name}${PLUGIN_TOOL_SEPARATOR}${toolName}`;
|
||||
registry.tools.set(prefixedName, toolDef);
|
||||
}
|
||||
|
||||
// Register commands
|
||||
for (const [cmdName, cmdDef] of result.plugin.commands) {
|
||||
registry.commands.set(cmdName, cmdDef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.initialized = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh plugins (reload all)
|
||||
*/
|
||||
export const refreshPlugins = async (workingDir: string): Promise<void> => {
|
||||
registry.plugins.clear();
|
||||
registry.tools.clear();
|
||||
registry.commands.clear();
|
||||
registry.initialized = false;
|
||||
|
||||
await initializePlugins(workingDir);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a tool is a plugin tool
|
||||
*/
|
||||
export const isPluginTool = (name: string): boolean => {
|
||||
return registry.tools.has(name);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a plugin tool by name
|
||||
*/
|
||||
export const getPluginTool = (name: string): ToolDefinition | undefined => {
|
||||
const pluginTool = registry.tools.get(name);
|
||||
|
||||
if (!pluginTool) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return pluginTool as unknown as ToolDefinition;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all plugin tools for API
|
||||
*/
|
||||
export const getPluginToolsForApi = (): {
|
||||
type: "function";
|
||||
function: FunctionDefinition;
|
||||
}[] => {
|
||||
const tools: {
|
||||
type: "function";
|
||||
function: FunctionDefinition;
|
||||
}[] = [];
|
||||
|
||||
for (const [name, tool] of registry.tools) {
|
||||
tools.push({
|
||||
type: "function",
|
||||
function: {
|
||||
name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tools;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a plugin command by name
|
||||
*/
|
||||
export const getPluginCommand = (
|
||||
name: string
|
||||
): PluginCommandDefinition | undefined => {
|
||||
return registry.commands.get(name);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a command is a plugin command
|
||||
*/
|
||||
export const isPluginCommand = (name: string): boolean => {
|
||||
return registry.commands.has(name);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all plugin commands
|
||||
*/
|
||||
export const getAllPluginCommands = (): PluginCommandDefinition[] => {
|
||||
return Array.from(registry.commands.values());
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all plugin hooks
|
||||
*/
|
||||
export const getAllPluginHooks = (): HookDefinition[] => {
|
||||
const hooks: HookDefinition[] = [];
|
||||
|
||||
for (const plugin of registry.plugins.values()) {
|
||||
if (plugin.enabled) {
|
||||
hooks.push(...plugin.hooks);
|
||||
}
|
||||
}
|
||||
|
||||
return hooks;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all loaded plugins
|
||||
*/
|
||||
export const getAllPlugins = (): LoadedPlugin[] => {
|
||||
return Array.from(registry.plugins.values());
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a specific plugin by name
|
||||
*/
|
||||
export const getPlugin = (name: string): LoadedPlugin | undefined => {
|
||||
return registry.plugins.get(name);
|
||||
};
|
||||
|
||||
/**
|
||||
* Enable a plugin
|
||||
*/
|
||||
export const enablePlugin = (name: string): boolean => {
|
||||
const plugin = registry.plugins.get(name);
|
||||
|
||||
if (!plugin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
plugin.enabled = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Disable a plugin
|
||||
*/
|
||||
export const disablePlugin = (name: string): boolean => {
|
||||
const plugin = registry.plugins.get(name);
|
||||
|
||||
if (!plugin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
plugin.enabled = false;
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if plugins are initialized
|
||||
*/
|
||||
export const isPluginsInitialized = (): boolean => {
|
||||
return registry.initialized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get plugin count
|
||||
*/
|
||||
export const getPluginCount = (): number => {
|
||||
return registry.plugins.size;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get plugin tool count
|
||||
*/
|
||||
export const getPluginToolCount = (): number => {
|
||||
return registry.tools.size;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get plugin command count
|
||||
*/
|
||||
export const getPluginCommandCount = (): number => {
|
||||
return registry.commands.size;
|
||||
};
|
||||
392
src/services/project-setup-service.ts
Normal file
392
src/services/project-setup-service.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Project Setup Service
|
||||
*
|
||||
* Automatically configures the project on startup:
|
||||
* - Adds .codetyper to .gitignore if .git exists
|
||||
* - Creates default agent configurations in .codetyper/agents/
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const CODETYPER_DIR = ".codetyper";
|
||||
const AGENTS_DIR = "agents";
|
||||
const GITIGNORE_ENTRY = ".codetyper/";
|
||||
|
||||
interface SetupResult {
|
||||
gitignoreUpdated: boolean;
|
||||
agentsCreated: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const fileExists = async (filePath: string): Promise<boolean> => {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isGitRepository = async (workingDir: string): Promise<boolean> => {
|
||||
return fileExists(path.join(workingDir, ".git"));
|
||||
};
|
||||
|
||||
const ensureDirectoryExists = async (dirPath: string): Promise<void> => {
|
||||
try {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
} catch {
|
||||
// Directory might already exist
|
||||
}
|
||||
};
|
||||
|
||||
const addToGitignore = async (workingDir: string): Promise<boolean> => {
|
||||
const gitignorePath = path.join(workingDir, ".gitignore");
|
||||
|
||||
try {
|
||||
let content = "";
|
||||
const exists = await fileExists(gitignorePath);
|
||||
|
||||
if (exists) {
|
||||
content = await fs.readFile(gitignorePath, "utf-8");
|
||||
|
||||
// Check if already present
|
||||
const lines = content.split("\n").map((line) => line.trim());
|
||||
if (lines.includes(GITIGNORE_ENTRY) || lines.includes(CODETYPER_DIR)) {
|
||||
return false; // Already configured
|
||||
}
|
||||
}
|
||||
|
||||
// Add .codetyper to gitignore
|
||||
const newContent = content.endsWith("\n") || content === ""
|
||||
? `${content}${GITIGNORE_ENTRY}\n`
|
||||
: `${content}\n${GITIGNORE_ENTRY}\n`;
|
||||
|
||||
await fs.writeFile(gitignorePath, newContent, "utf-8");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
interface AgentDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
mode: "primary" | "subagent" | "all";
|
||||
color: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
const DEFAULT_AGENTS: AgentDefinition[] = [
|
||||
{
|
||||
id: "explore",
|
||||
name: "Explore",
|
||||
description: "Fast codebase exploration specialist",
|
||||
mode: "subagent",
|
||||
color: "cyan",
|
||||
prompt: `You are an expert codebase explorer. Your role is to quickly navigate and understand codebases.
|
||||
|
||||
## Capabilities
|
||||
- Find files by patterns and naming conventions
|
||||
- Search code for keywords, functions, classes, and patterns
|
||||
- Answer questions about codebase structure and architecture
|
||||
- Identify key files and entry points
|
||||
|
||||
## Guidelines
|
||||
- Use Glob to find files by pattern
|
||||
- Use Grep to search file contents
|
||||
- Use Read to examine specific files
|
||||
- Be thorough but efficient - explore multiple locations
|
||||
- Report findings with exact file paths and line numbers
|
||||
- Summarize patterns and conventions you discover
|
||||
|
||||
## Output Format
|
||||
Always include:
|
||||
1. Files found (with paths)
|
||||
2. Relevant code snippets
|
||||
3. Summary of findings
|
||||
4. Suggestions for further exploration if needed`,
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Software architect for designing implementation plans",
|
||||
mode: "subagent",
|
||||
color: "yellow",
|
||||
prompt: `You are a software architect specializing in implementation planning.
|
||||
|
||||
## Role
|
||||
Design comprehensive implementation plans for features, refactors, and bug fixes.
|
||||
|
||||
## Approach
|
||||
1. Analyze requirements thoroughly
|
||||
2. Explore relevant codebase areas
|
||||
3. Identify affected components
|
||||
4. Consider architectural trade-offs
|
||||
5. Create step-by-step implementation plans
|
||||
|
||||
## Output Format
|
||||
Your plans should include:
|
||||
|
||||
### Summary
|
||||
Brief overview of the change
|
||||
|
||||
### Affected Files
|
||||
List of files that will be created, modified, or deleted
|
||||
|
||||
### Implementation Steps
|
||||
1. Step-by-step instructions
|
||||
2. Each step should be atomic and testable
|
||||
3. Include code snippets where helpful
|
||||
|
||||
### Considerations
|
||||
- Potential risks or edge cases
|
||||
- Testing requirements
|
||||
- Performance implications
|
||||
- Backwards compatibility
|
||||
|
||||
### Dependencies
|
||||
Any prerequisites or blocking tasks`,
|
||||
},
|
||||
{
|
||||
id: "bash",
|
||||
name: "Bash",
|
||||
description: "Command execution specialist for terminal operations",
|
||||
mode: "subagent",
|
||||
color: "green",
|
||||
prompt: `You are a command execution specialist for terminal operations.
|
||||
|
||||
## Expertise
|
||||
- Git operations (commit, push, branch, rebase, etc.)
|
||||
- Package management (npm, yarn, pnpm, pip, etc.)
|
||||
- Build tools and scripts
|
||||
- System commands
|
||||
- Docker and container operations
|
||||
|
||||
## Guidelines
|
||||
- Always explain what commands will do before executing
|
||||
- Use safe defaults and avoid destructive operations
|
||||
- Quote paths with spaces properly
|
||||
- Handle errors gracefully
|
||||
- Provide clear output and status
|
||||
|
||||
## Safety Rules
|
||||
- NEVER run destructive commands without explicit confirmation
|
||||
- NEVER modify git config without permission
|
||||
- NEVER force push to main/master
|
||||
- NEVER skip safety hooks unless requested
|
||||
- Always check command exit codes
|
||||
|
||||
## Output
|
||||
Include:
|
||||
- Command being executed
|
||||
- Expected outcome
|
||||
- Actual output or error
|
||||
- Next steps if needed`,
|
||||
},
|
||||
{
|
||||
id: "code-reviewer",
|
||||
name: "Code Reviewer",
|
||||
description: "Expert code reviewer for quality and best practices",
|
||||
mode: "subagent",
|
||||
color: "magenta",
|
||||
prompt: `You are an expert code reviewer focused on quality and best practices.
|
||||
|
||||
## Review Areas
|
||||
1. **Correctness** - Does the code do what it's supposed to?
|
||||
2. **Security** - Are there vulnerabilities or unsafe patterns?
|
||||
3. **Performance** - Are there inefficiencies or bottlenecks?
|
||||
4. **Maintainability** - Is the code readable and well-organized?
|
||||
5. **Testing** - Is the code testable and properly tested?
|
||||
|
||||
## Review Process
|
||||
1. Understand the change's purpose
|
||||
2. Check for correctness and edge cases
|
||||
3. Look for security issues (OWASP top 10)
|
||||
4. Assess code style and conventions
|
||||
5. Verify error handling
|
||||
6. Check test coverage
|
||||
|
||||
## Output Format
|
||||
### Summary
|
||||
Brief assessment of the change
|
||||
|
||||
### Issues Found
|
||||
- **Critical**: Must fix before merge
|
||||
- **Major**: Should fix, significant impact
|
||||
- **Minor**: Nice to fix, low impact
|
||||
- **Nitpick**: Style/preference suggestions
|
||||
|
||||
### Positive Aspects
|
||||
What's done well
|
||||
|
||||
### Suggestions
|
||||
Specific improvements with code examples`,
|
||||
},
|
||||
{
|
||||
id: "architect",
|
||||
name: "Code Architect",
|
||||
description: "Design implementation plans and architectural decisions",
|
||||
mode: "subagent",
|
||||
color: "blue",
|
||||
prompt: `You are a code architect specializing in system design and implementation strategy.
|
||||
|
||||
## Responsibilities
|
||||
- Design scalable and maintainable solutions
|
||||
- Make architectural decisions with clear trade-offs
|
||||
- Create implementation roadmaps
|
||||
- Identify patterns and anti-patterns
|
||||
|
||||
## Approach
|
||||
1. **Understand Context**
|
||||
- Current system architecture
|
||||
- Constraints and requirements
|
||||
- Team capabilities and preferences
|
||||
|
||||
2. **Explore Options**
|
||||
- Consider multiple approaches
|
||||
- Evaluate trade-offs
|
||||
- Document pros and cons
|
||||
|
||||
3. **Design Solution**
|
||||
- Clear component structure
|
||||
- Interface definitions
|
||||
- Data flow diagrams
|
||||
- Integration points
|
||||
|
||||
4. **Plan Implementation**
|
||||
- Phased approach if needed
|
||||
- Risk mitigation
|
||||
- Testing strategy
|
||||
|
||||
## Output
|
||||
- Architecture overview
|
||||
- Component breakdown
|
||||
- Interface contracts
|
||||
- Implementation phases
|
||||
- Risk assessment`,
|
||||
},
|
||||
{
|
||||
id: "general",
|
||||
name: "General Purpose",
|
||||
description: "Multi-step research and complex task execution",
|
||||
mode: "subagent",
|
||||
color: "white",
|
||||
prompt: `You are a general-purpose agent for researching complex questions and executing multi-step tasks.
|
||||
|
||||
## Capabilities
|
||||
- Search codebases for information
|
||||
- Read and analyze files
|
||||
- Execute multi-step research tasks
|
||||
- Synthesize findings from multiple sources
|
||||
- Answer complex questions about code
|
||||
|
||||
## Approach
|
||||
1. Break down complex tasks into steps
|
||||
2. Gather information systematically
|
||||
3. Cross-reference findings
|
||||
4. Synthesize and summarize
|
||||
|
||||
## Guidelines
|
||||
- Be thorough in research
|
||||
- Cite sources with file paths and line numbers
|
||||
- Acknowledge uncertainty when present
|
||||
- Provide actionable insights
|
||||
|
||||
## Output
|
||||
- Clear, structured answers
|
||||
- Supporting evidence
|
||||
- Confidence level
|
||||
- Further research suggestions if needed`,
|
||||
},
|
||||
];
|
||||
|
||||
const generateAgentFile = (agent: AgentDefinition): string => {
|
||||
return `---
|
||||
name: "${agent.name}"
|
||||
description: "${agent.description}"
|
||||
mode: "${agent.mode}"
|
||||
color: "${agent.color}"
|
||||
---
|
||||
|
||||
${agent.prompt}
|
||||
`;
|
||||
};
|
||||
|
||||
const createDefaultAgents = async (workingDir: string): Promise<string[]> => {
|
||||
const agentsDir = path.join(workingDir, CODETYPER_DIR, AGENTS_DIR);
|
||||
const created: string[] = [];
|
||||
|
||||
await ensureDirectoryExists(agentsDir);
|
||||
|
||||
for (const agent of DEFAULT_AGENTS) {
|
||||
const filePath = path.join(agentsDir, `${agent.id}.agent.md`);
|
||||
|
||||
// Skip if already exists
|
||||
if (await fileExists(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = generateAgentFile(agent);
|
||||
await fs.writeFile(filePath, content, "utf-8");
|
||||
created.push(agent.id);
|
||||
} catch {
|
||||
// Skip on error
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
};
|
||||
|
||||
export const setupProject = async (workingDir: string): Promise<SetupResult> => {
|
||||
const result: SetupResult = {
|
||||
gitignoreUpdated: false,
|
||||
agentsCreated: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Check if this is a git repository
|
||||
const isGit = await isGitRepository(workingDir);
|
||||
|
||||
if (isGit) {
|
||||
// Add .codetyper to gitignore
|
||||
result.gitignoreUpdated = await addToGitignore(workingDir);
|
||||
}
|
||||
|
||||
// Create default agents
|
||||
result.agentsCreated = await createDefaultAgents(workingDir);
|
||||
} catch (error) {
|
||||
result.errors.push(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getSetupStatus = async (workingDir: string): Promise<{
|
||||
hasGit: boolean;
|
||||
hasCodetyperDir: boolean;
|
||||
agentCount: number;
|
||||
}> => {
|
||||
const hasGit = await isGitRepository(workingDir);
|
||||
const hasCodetyperDir = await fileExists(path.join(workingDir, CODETYPER_DIR));
|
||||
|
||||
let agentCount = 0;
|
||||
if (hasCodetyperDir) {
|
||||
const agentsDir = path.join(workingDir, CODETYPER_DIR, AGENTS_DIR);
|
||||
if (await fileExists(agentsDir)) {
|
||||
const files = await fs.readdir(agentsDir);
|
||||
agentCount = files.filter((f) => f.endsWith(".agent.md")).length;
|
||||
}
|
||||
}
|
||||
|
||||
return { hasGit, hasCodetyperDir, agentCount };
|
||||
};
|
||||
|
||||
export const projectSetupService = {
|
||||
setupProject,
|
||||
getSetupStatus,
|
||||
isGitRepository,
|
||||
};
|
||||
347
src/services/security-service.ts
Normal file
347
src/services/security-service.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Security Service - Pattern detection and validation
|
||||
*
|
||||
* Provides:
|
||||
* - Command injection detection
|
||||
* - XSS pattern detection
|
||||
* - Permission explainer
|
||||
* - Shell continuation validation
|
||||
* - OAuth token filtering
|
||||
* - Security pattern hooks
|
||||
*/
|
||||
|
||||
export type SecurityRisk = "critical" | "high" | "medium" | "low" | "info";
|
||||
|
||||
export interface SecurityIssue {
|
||||
type: string;
|
||||
risk: SecurityRisk;
|
||||
description: string;
|
||||
location?: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface SecurityReport {
|
||||
issues: SecurityIssue[];
|
||||
hasCritical: boolean;
|
||||
hasHigh: boolean;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
// Command injection patterns
|
||||
const COMMAND_INJECTION_PATTERNS = [
|
||||
// Shell metacharacters
|
||||
{ pattern: /[;&|`$]/, description: "Shell metacharacter detected" },
|
||||
// Subshell execution
|
||||
{ pattern: /\$\([^)]+\)/, description: "Subshell execution detected" },
|
||||
// Backtick execution
|
||||
{ pattern: /`[^`]+`/, description: "Backtick command execution detected" },
|
||||
// Pipe chains
|
||||
{ pattern: /\|(?!\|)/, description: "Pipe character detected" },
|
||||
// Redirections
|
||||
{ pattern: /[<>]/, description: "Redirection operator detected" },
|
||||
// Newline injection
|
||||
{ pattern: /[\n\r]/, description: "Newline character in command" },
|
||||
// Null byte injection
|
||||
{ pattern: /\x00/, description: "Null byte detected" },
|
||||
// Environment variable expansion
|
||||
{ pattern: /\$\{[^}]+\}/, description: "Environment variable expansion" },
|
||||
{ pattern: /\$[A-Za-z_][A-Za-z0-9_]*/, description: "Variable reference detected" },
|
||||
];
|
||||
|
||||
// XSS patterns
|
||||
const XSS_PATTERNS = [
|
||||
// Script tags
|
||||
{ pattern: /<script[\s>]/i, description: "Script tag detected" },
|
||||
// Event handlers
|
||||
{ pattern: /on\w+\s*=/i, description: "Event handler attribute detected" },
|
||||
// JavaScript protocol
|
||||
{ pattern: /javascript:/i, description: "JavaScript protocol detected" },
|
||||
// Data URLs with script content
|
||||
{ pattern: /data:[^,]*;base64/i, description: "Data URL with base64 encoding" },
|
||||
// Expression/eval
|
||||
{ pattern: /expression\s*\(/i, description: "CSS expression detected" },
|
||||
// SVG with script
|
||||
{ pattern: /<svg[\s>].*?<script/i, description: "SVG with embedded script" },
|
||||
// Template literals in HTML
|
||||
{ pattern: /\{\{.*?\}\}/i, description: "Template literal detected" },
|
||||
// HTML entities that could be script
|
||||
{ pattern: /&#x?[0-9a-f]+;/i, description: "HTML entity encoding detected" },
|
||||
];
|
||||
|
||||
// SQL injection patterns
|
||||
const SQL_INJECTION_PATTERNS = [
|
||||
{ pattern: /(['"])\s*;\s*--/i, description: "SQL comment injection" },
|
||||
{ pattern: /union\s+select/i, description: "UNION SELECT statement" },
|
||||
{ pattern: /'\s*or\s+'?1'?\s*=\s*'?1/i, description: "OR 1=1 pattern" },
|
||||
{ pattern: /drop\s+table/i, description: "DROP TABLE statement" },
|
||||
{ pattern: /insert\s+into/i, description: "INSERT INTO statement" },
|
||||
{ pattern: /delete\s+from/i, description: "DELETE FROM statement" },
|
||||
];
|
||||
|
||||
// Dangerous system calls
|
||||
const DANGEROUS_CALLS_PATTERNS = [
|
||||
{ pattern: /eval\s*\(/i, description: "eval() usage detected" },
|
||||
{ pattern: /exec\s*\(/i, description: "exec() usage detected" },
|
||||
{ pattern: /system\s*\(/i, description: "system() call detected" },
|
||||
{ pattern: /os\.system\s*\(/i, description: "os.system() call detected" },
|
||||
{ pattern: /subprocess\.call\s*\(/i, description: "subprocess.call() detected" },
|
||||
{ pattern: /child_process/i, description: "child_process module usage" },
|
||||
{ pattern: /pickle\.loads?\s*\(/i, description: "Pickle deserialization detected" },
|
||||
{ pattern: /yaml\.unsafe_load\s*\(/i, description: "Unsafe YAML loading" },
|
||||
{ pattern: /unserialize\s*\(/i, description: "PHP unserialize() detected" },
|
||||
];
|
||||
|
||||
// Shell continuation patterns (dangerous when user-controlled)
|
||||
const SHELL_CONTINUATION_PATTERNS = [
|
||||
{ pattern: /\\\s*$/, description: "Line continuation at end" },
|
||||
{ pattern: /;\s*$/, description: "Command separator at end" },
|
||||
{ pattern: /\|\s*$/, description: "Pipe at end (awaiting next command)" },
|
||||
{ pattern: /&&\s*$/, description: "AND operator at end" },
|
||||
{ pattern: /\|\|\s*$/, description: "OR operator at end" },
|
||||
];
|
||||
|
||||
// OAuth/API token patterns (for filtering)
|
||||
const TOKEN_PATTERNS = [
|
||||
// Generic API keys
|
||||
{ pattern: /api[_-]?key[=:]["']?[a-zA-Z0-9_-]{20,}["']?/i, type: "API Key" },
|
||||
// OAuth tokens
|
||||
{ pattern: /bearer\s+[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/i, type: "JWT Token" },
|
||||
{ pattern: /oauth[_-]?token[=:]["']?[a-zA-Z0-9_-]{20,}["']?/i, type: "OAuth Token" },
|
||||
// AWS credentials
|
||||
{ pattern: /AKIA[0-9A-Z]{16}/i, type: "AWS Access Key" },
|
||||
{ pattern: /aws[_-]?secret[_-]?access[_-]?key[=:]["']?[a-zA-Z0-9/+=]{40}["']?/i, type: "AWS Secret Key" },
|
||||
// GitHub tokens
|
||||
{ pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/i, type: "GitHub Token" },
|
||||
// Generic secrets
|
||||
{ pattern: /password[=:]["']?[^\s"']{8,}["']?/i, type: "Password" },
|
||||
{ pattern: /secret[=:]["']?[^\s"']{8,}["']?/i, type: "Secret" },
|
||||
// Private keys
|
||||
{ pattern: /-----BEGIN\s+(?:RSA|DSA|EC|OPENSSH)?\s*PRIVATE\s+KEY-----/i, type: "Private Key" },
|
||||
];
|
||||
|
||||
const checkPatterns = (
|
||||
content: string,
|
||||
patterns: Array<{ pattern: RegExp; description: string }>,
|
||||
type: string,
|
||||
risk: SecurityRisk,
|
||||
): SecurityIssue[] => {
|
||||
const issues: SecurityIssue[] = [];
|
||||
|
||||
for (const { pattern, description } of patterns) {
|
||||
const match = content.match(pattern);
|
||||
if (match) {
|
||||
issues.push({
|
||||
type,
|
||||
risk,
|
||||
description,
|
||||
location: match[0].slice(0, 50) + (match[0].length > 50 ? "..." : ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
export const detectCommandInjection = (command: string): SecurityIssue[] => {
|
||||
return checkPatterns(
|
||||
command,
|
||||
COMMAND_INJECTION_PATTERNS,
|
||||
"command_injection",
|
||||
"critical",
|
||||
);
|
||||
};
|
||||
|
||||
export const detectXSS = (content: string): SecurityIssue[] => {
|
||||
return checkPatterns(content, XSS_PATTERNS, "xss", "high");
|
||||
};
|
||||
|
||||
export const detectSQLInjection = (content: string): SecurityIssue[] => {
|
||||
return checkPatterns(content, SQL_INJECTION_PATTERNS, "sql_injection", "critical");
|
||||
};
|
||||
|
||||
export const detectDangerousCalls = (code: string): SecurityIssue[] => {
|
||||
return checkPatterns(code, DANGEROUS_CALLS_PATTERNS, "dangerous_call", "high");
|
||||
};
|
||||
|
||||
export const detectShellContinuation = (command: string): SecurityIssue[] => {
|
||||
return checkPatterns(
|
||||
command,
|
||||
SHELL_CONTINUATION_PATTERNS,
|
||||
"shell_continuation",
|
||||
"medium",
|
||||
);
|
||||
};
|
||||
|
||||
export const findSensitiveTokens = (
|
||||
content: string,
|
||||
): Array<{ type: string; match: string; masked: string }> => {
|
||||
const tokens: Array<{ type: string; match: string; masked: string }> = [];
|
||||
|
||||
for (const { pattern, type } of TOKEN_PATTERNS) {
|
||||
const matches = content.matchAll(new RegExp(pattern, "gi"));
|
||||
for (const match of matches) {
|
||||
const value = match[0];
|
||||
// Mask the token, keeping first and last 4 characters
|
||||
const masked =
|
||||
value.length > 12
|
||||
? value.slice(0, 4) + "*".repeat(value.length - 8) + value.slice(-4)
|
||||
: "*".repeat(value.length);
|
||||
|
||||
tokens.push({ type, match: value, masked });
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
};
|
||||
|
||||
export const filterSensitiveTokens = (content: string): string => {
|
||||
let filtered = content;
|
||||
|
||||
for (const { pattern } of TOKEN_PATTERNS) {
|
||||
filtered = filtered.replace(new RegExp(pattern, "gi"), (match) => {
|
||||
if (match.length > 12) {
|
||||
return match.slice(0, 4) + "*".repeat(match.length - 8) + match.slice(-4);
|
||||
}
|
||||
return "*".repeat(match.length);
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
export const validateCommand = (command: string): SecurityReport => {
|
||||
const issues: SecurityIssue[] = [
|
||||
...detectCommandInjection(command),
|
||||
...detectShellContinuation(command),
|
||||
];
|
||||
|
||||
return {
|
||||
issues,
|
||||
hasCritical: issues.some((i) => i.risk === "critical"),
|
||||
hasHigh: issues.some((i) => i.risk === "high"),
|
||||
summary:
|
||||
issues.length === 0
|
||||
? "No security issues detected"
|
||||
: `Found ${issues.length} potential security issue(s)`,
|
||||
};
|
||||
};
|
||||
|
||||
export const validateCode = (code: string): SecurityReport => {
|
||||
const issues: SecurityIssue[] = [
|
||||
...detectDangerousCalls(code),
|
||||
...detectXSS(code),
|
||||
...detectSQLInjection(code),
|
||||
];
|
||||
|
||||
return {
|
||||
issues,
|
||||
hasCritical: issues.some((i) => i.risk === "critical"),
|
||||
hasHigh: issues.some((i) => i.risk === "high"),
|
||||
summary:
|
||||
issues.length === 0
|
||||
? "No security issues detected"
|
||||
: `Found ${issues.length} potential security issue(s)`,
|
||||
};
|
||||
};
|
||||
|
||||
export const explainPermission = (
|
||||
tool: string,
|
||||
args: Record<string, unknown>,
|
||||
): { explanation: string; risks: string[]; recommendation: string } => {
|
||||
const explanations: Record<
|
||||
string,
|
||||
(args: Record<string, unknown>) => {
|
||||
explanation: string;
|
||||
risks: string[];
|
||||
recommendation: string;
|
||||
}
|
||||
> = {
|
||||
bash: (args) => {
|
||||
const command = (args.command as string) ?? "";
|
||||
const report = validateCommand(command);
|
||||
|
||||
return {
|
||||
explanation: `Execute shell command: ${command.slice(0, 100)}${command.length > 100 ? "..." : ""}`,
|
||||
risks: report.issues.map((i) => `${i.risk.toUpperCase()}: ${i.description}`),
|
||||
recommendation: report.hasCritical
|
||||
? "DENY - Critical security risk detected"
|
||||
: report.hasHigh
|
||||
? "REVIEW CAREFULLY - High risk patterns detected"
|
||||
: "ALLOW - No obvious security issues",
|
||||
};
|
||||
},
|
||||
|
||||
write: (args) => {
|
||||
const filePath = (args.path as string) ?? (args.file_path as string) ?? "";
|
||||
const content = (args.content as string) ?? "";
|
||||
const tokens = findSensitiveTokens(content);
|
||||
|
||||
return {
|
||||
explanation: `Write to file: ${filePath}`,
|
||||
risks: [
|
||||
...(filePath.includes("..") ? ["Path traversal attempt"] : []),
|
||||
...(tokens.length > 0
|
||||
? [`Contains ${tokens.length} potential sensitive token(s)`]
|
||||
: []),
|
||||
],
|
||||
recommendation:
|
||||
filePath.includes("..") || tokens.length > 0
|
||||
? "REVIEW CAREFULLY - Potential security concerns"
|
||||
: "ALLOW - File write operation",
|
||||
};
|
||||
},
|
||||
|
||||
edit: (args) => {
|
||||
const filePath = (args.path as string) ?? (args.file_path as string) ?? "";
|
||||
|
||||
return {
|
||||
explanation: `Edit file: ${filePath}`,
|
||||
risks: filePath.includes("..") ? ["Path traversal attempt"] : [],
|
||||
recommendation: filePath.includes("..")
|
||||
? "DENY - Path traversal detected"
|
||||
: "ALLOW - File edit operation",
|
||||
};
|
||||
},
|
||||
|
||||
read: (args) => {
|
||||
const filePath = (args.path as string) ?? (args.file_path as string) ?? "";
|
||||
|
||||
return {
|
||||
explanation: `Read file: ${filePath}`,
|
||||
risks: [
|
||||
...(filePath.includes("..") ? ["Path traversal attempt"] : []),
|
||||
...(filePath.match(/\.(env|pem|key|secret)$/i)
|
||||
? ["Reading potentially sensitive file"]
|
||||
: []),
|
||||
],
|
||||
recommendation: filePath.includes("..")
|
||||
? "DENY - Path traversal detected"
|
||||
: "ALLOW - File read operation",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const explainer = explanations[tool];
|
||||
if (explainer) {
|
||||
return explainer(args);
|
||||
}
|
||||
|
||||
return {
|
||||
explanation: `Execute tool: ${tool}`,
|
||||
risks: [],
|
||||
recommendation: "ALLOW - Standard tool operation",
|
||||
};
|
||||
};
|
||||
|
||||
export const securityService = {
|
||||
detectCommandInjection,
|
||||
detectXSS,
|
||||
detectSQLInjection,
|
||||
detectDangerousCalls,
|
||||
detectShellContinuation,
|
||||
findSensitiveTokens,
|
||||
filterSensitiveTokens,
|
||||
validateCommand,
|
||||
validateCode,
|
||||
explainPermission,
|
||||
};
|
||||
462
src/services/session-fork-service.ts
Normal file
462
src/services/session-fork-service.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* Session Fork Service
|
||||
*
|
||||
* Manages session snapshots, forks, and rewind functionality
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir, access, constants } from "fs/promises";
|
||||
import { join, dirname } from "path";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type {
|
||||
SessionSnapshot,
|
||||
SessionSnapshotState,
|
||||
SessionFork,
|
||||
SessionForkFile,
|
||||
SnapshotCreateResult,
|
||||
RewindResult,
|
||||
ForkCreateResult,
|
||||
ForkSwitchResult,
|
||||
ForkSummary,
|
||||
SnapshotSummary,
|
||||
SnapshotOptions,
|
||||
ForkOptions,
|
||||
SessionMessage,
|
||||
} from "@/types/session-fork";
|
||||
import type { TodoItem } from "@/types/todo";
|
||||
import {
|
||||
FORK_FILE_EXTENSION,
|
||||
MAIN_FORK_NAME,
|
||||
DEFAULT_SNAPSHOT_PREFIX,
|
||||
MAX_SNAPSHOTS_PER_FORK,
|
||||
MAX_FORKS_PER_SESSION,
|
||||
FORK_FILE_VERSION,
|
||||
FORKS_SUBDIR,
|
||||
COMMIT_MESSAGE_TEMPLATES,
|
||||
COMMIT_TYPE_KEYWORDS,
|
||||
FORK_ERRORS,
|
||||
} from "@constants/session-fork";
|
||||
import { LOCAL_CONFIG_DIR } from "@constants/paths";
|
||||
|
||||
/**
|
||||
* In-memory state for current session
|
||||
*/
|
||||
interface SessionForkState {
|
||||
sessionId: string | null;
|
||||
file: SessionForkFile | null;
|
||||
filePath: string | null;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
const state: SessionForkState = {
|
||||
sessionId: null,
|
||||
file: null,
|
||||
filePath: null,
|
||||
dirty: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate suggested commit message from messages
|
||||
*/
|
||||
const generateCommitMessage = (messages: SessionMessage[]): string => {
|
||||
const userMessages = messages.filter((m) => m.role === "user");
|
||||
const count = messages.length;
|
||||
|
||||
if (userMessages.length === 0) {
|
||||
return COMMIT_MESSAGE_TEMPLATES.DEFAULT
|
||||
.replace("{summary}", "session checkpoint")
|
||||
.replace("{count}", String(count));
|
||||
}
|
||||
|
||||
// Get first user message as summary base
|
||||
const firstMessage = userMessages[0]?.content || "";
|
||||
const summary = firstMessage.slice(0, 50).replace(/\n/g, " ").trim();
|
||||
|
||||
// Detect commit type from messages
|
||||
const allContent = userMessages.map((m) => m.content.toLowerCase()).join(" ");
|
||||
|
||||
for (const [type, keywords] of Object.entries(COMMIT_TYPE_KEYWORDS)) {
|
||||
for (const keyword of keywords) {
|
||||
if (allContent.includes(keyword)) {
|
||||
const template = COMMIT_MESSAGE_TEMPLATES[type as keyof typeof COMMIT_MESSAGE_TEMPLATES];
|
||||
return template
|
||||
.replace("{summary}", summary || keyword)
|
||||
.replace("{count}", String(count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return COMMIT_MESSAGE_TEMPLATES.DEFAULT
|
||||
.replace("{summary}", summary || "session changes")
|
||||
.replace("{count}", String(count));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get fork file path for a session
|
||||
*/
|
||||
const getForkFilePath = (sessionId: string, workingDir: string): string => {
|
||||
const localPath = join(workingDir, LOCAL_CONFIG_DIR, FORKS_SUBDIR);
|
||||
return join(localPath, `${sessionId}${FORK_FILE_EXTENSION}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create empty fork file
|
||||
*/
|
||||
const createEmptyForkFile = (sessionId: string): SessionForkFile => {
|
||||
const mainFork: SessionFork = {
|
||||
id: uuidv4(),
|
||||
name: MAIN_FORK_NAME,
|
||||
snapshots: [],
|
||||
currentSnapshotId: "",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
return {
|
||||
version: FORK_FILE_VERSION,
|
||||
sessionId,
|
||||
forks: [mainFork],
|
||||
currentForkId: mainFork.id,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Load fork file for a session
|
||||
*/
|
||||
const loadForkFile = async (
|
||||
sessionId: string,
|
||||
workingDir: string
|
||||
): Promise<SessionForkFile> => {
|
||||
const filePath = getForkFilePath(sessionId, workingDir);
|
||||
|
||||
try {
|
||||
await access(filePath, constants.R_OK);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
return JSON.parse(content) as SessionForkFile;
|
||||
} catch {
|
||||
return createEmptyForkFile(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save fork file
|
||||
*/
|
||||
const saveForkFile = async (
|
||||
file: SessionForkFile,
|
||||
filePath: string
|
||||
): Promise<void> => {
|
||||
const dir = dirname(filePath);
|
||||
|
||||
try {
|
||||
await access(dir, constants.W_OK);
|
||||
} catch {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
await writeFile(filePath, JSON.stringify(file, null, 2), "utf-8");
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize fork service for a session
|
||||
*/
|
||||
export const initializeForkService = async (
|
||||
sessionId: string,
|
||||
workingDir: string
|
||||
): Promise<void> => {
|
||||
const filePath = getForkFilePath(sessionId, workingDir);
|
||||
const file = await loadForkFile(sessionId, workingDir);
|
||||
|
||||
state.sessionId = sessionId;
|
||||
state.file = file;
|
||||
state.filePath = filePath;
|
||||
state.dirty = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current fork
|
||||
*/
|
||||
const getCurrentFork = (): SessionFork | null => {
|
||||
if (!state.file) return null;
|
||||
return state.file.forks.find((f) => f.id === state.file?.currentForkId) || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a snapshot
|
||||
*/
|
||||
export const createSnapshot = async (
|
||||
messages: SessionMessage[],
|
||||
todoItems: TodoItem[],
|
||||
contextFiles: string[],
|
||||
metadata: { provider: string; model: string; agent: string; workingDir: string },
|
||||
options: SnapshotOptions = {}
|
||||
): Promise<SnapshotCreateResult> => {
|
||||
if (!state.file || !state.filePath) {
|
||||
return { success: false, error: FORK_ERRORS.SESSION_NOT_FOUND };
|
||||
}
|
||||
|
||||
const fork = getCurrentFork();
|
||||
if (!fork) {
|
||||
return { success: false, error: FORK_ERRORS.FORK_NOT_FOUND };
|
||||
}
|
||||
|
||||
if (fork.snapshots.length >= MAX_SNAPSHOTS_PER_FORK) {
|
||||
return { success: false, error: FORK_ERRORS.MAX_SNAPSHOTS_REACHED };
|
||||
}
|
||||
|
||||
// Generate snapshot name
|
||||
const name = options.name || `${DEFAULT_SNAPSHOT_PREFIX}-${fork.snapshots.length + 1}`;
|
||||
|
||||
// Check for duplicate name
|
||||
if (fork.snapshots.some((s) => s.name === name)) {
|
||||
return { success: false, error: FORK_ERRORS.DUPLICATE_SNAPSHOT_NAME };
|
||||
}
|
||||
|
||||
const snapshotState: SessionSnapshotState = {
|
||||
messages: [...messages],
|
||||
todoItems: options.includeTodos !== false ? [...todoItems] : [],
|
||||
contextFiles: options.includeContextFiles !== false ? [...contextFiles] : [],
|
||||
metadata,
|
||||
};
|
||||
|
||||
const snapshot: SessionSnapshot = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
timestamp: Date.now(),
|
||||
parentId: fork.currentSnapshotId || null,
|
||||
state: snapshotState,
|
||||
suggestedCommitMessage: generateCommitMessage(messages),
|
||||
};
|
||||
|
||||
fork.snapshots.push(snapshot);
|
||||
fork.currentSnapshotId = snapshot.id;
|
||||
fork.updatedAt = Date.now();
|
||||
|
||||
state.dirty = true;
|
||||
await saveForkFile(state.file, state.filePath);
|
||||
|
||||
return { success: true, snapshot };
|
||||
};
|
||||
|
||||
/**
|
||||
* Rewind to a snapshot
|
||||
*/
|
||||
export const rewindToSnapshot = async (
|
||||
target: string | number
|
||||
): Promise<RewindResult> => {
|
||||
if (!state.file || !state.filePath) {
|
||||
return { success: false, messagesRestored: 0, error: FORK_ERRORS.SESSION_NOT_FOUND };
|
||||
}
|
||||
|
||||
const fork = getCurrentFork();
|
||||
if (!fork) {
|
||||
return { success: false, messagesRestored: 0, error: FORK_ERRORS.FORK_NOT_FOUND };
|
||||
}
|
||||
|
||||
if (fork.snapshots.length === 0) {
|
||||
return { success: false, messagesRestored: 0, error: FORK_ERRORS.NO_SNAPSHOTS_TO_REWIND };
|
||||
}
|
||||
|
||||
let snapshot: SessionSnapshot | undefined;
|
||||
|
||||
if (typeof target === "number") {
|
||||
// Rewind by count (e.g., 1 = previous snapshot)
|
||||
const currentIndex = fork.snapshots.findIndex(
|
||||
(s) => s.id === fork.currentSnapshotId
|
||||
);
|
||||
const targetIndex = currentIndex - target;
|
||||
|
||||
if (targetIndex < 0) {
|
||||
snapshot = fork.snapshots[0];
|
||||
} else {
|
||||
snapshot = fork.snapshots[targetIndex];
|
||||
}
|
||||
} else {
|
||||
// Rewind by name
|
||||
snapshot = fork.snapshots.find((s) => s.name === target || s.id === target);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return { success: false, messagesRestored: 0, error: FORK_ERRORS.SNAPSHOT_NOT_FOUND };
|
||||
}
|
||||
|
||||
fork.currentSnapshotId = snapshot.id;
|
||||
fork.updatedAt = Date.now();
|
||||
|
||||
state.dirty = true;
|
||||
await saveForkFile(state.file, state.filePath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
snapshot,
|
||||
messagesRestored: snapshot.state.messages.length,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new fork
|
||||
*/
|
||||
export const createFork = async (
|
||||
options: ForkOptions = {}
|
||||
): Promise<ForkCreateResult> => {
|
||||
if (!state.file || !state.filePath) {
|
||||
return { success: false, error: FORK_ERRORS.SESSION_NOT_FOUND };
|
||||
}
|
||||
|
||||
if (state.file.forks.length >= MAX_FORKS_PER_SESSION) {
|
||||
return { success: false, error: FORK_ERRORS.MAX_FORKS_REACHED };
|
||||
}
|
||||
|
||||
const currentFork = getCurrentFork();
|
||||
if (!currentFork) {
|
||||
return { success: false, error: FORK_ERRORS.FORK_NOT_FOUND };
|
||||
}
|
||||
|
||||
// Generate fork name
|
||||
const name = options.name || `fork-${state.file.forks.length + 1}`;
|
||||
|
||||
// Check for duplicate name
|
||||
if (state.file.forks.some((f) => f.name === name)) {
|
||||
return { success: false, error: FORK_ERRORS.DUPLICATE_FORK_NAME };
|
||||
}
|
||||
|
||||
// Determine which snapshot to branch from
|
||||
let branchFromId = currentFork.currentSnapshotId;
|
||||
if (options.fromSnapshot) {
|
||||
const snapshot = currentFork.snapshots.find(
|
||||
(s) => s.name === options.fromSnapshot || s.id === options.fromSnapshot
|
||||
);
|
||||
if (!snapshot) {
|
||||
return { success: false, error: FORK_ERRORS.SNAPSHOT_NOT_FOUND };
|
||||
}
|
||||
branchFromId = snapshot.id;
|
||||
}
|
||||
|
||||
// Copy snapshots up to branch point
|
||||
const branchIndex = currentFork.snapshots.findIndex((s) => s.id === branchFromId);
|
||||
const copiedSnapshots = currentFork.snapshots.slice(0, branchIndex + 1).map((s) => ({
|
||||
...s,
|
||||
id: uuidv4(), // New IDs for copied snapshots
|
||||
}));
|
||||
|
||||
const newFork: SessionFork = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
snapshots: copiedSnapshots,
|
||||
currentSnapshotId: copiedSnapshots[copiedSnapshots.length - 1]?.id || "",
|
||||
parentForkId: currentFork.id,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
state.file.forks.push(newFork);
|
||||
state.file.currentForkId = newFork.id;
|
||||
|
||||
state.dirty = true;
|
||||
await saveForkFile(state.file, state.filePath);
|
||||
|
||||
return { success: true, fork: newFork };
|
||||
};
|
||||
|
||||
/**
|
||||
* Switch to a different fork
|
||||
*/
|
||||
export const switchFork = async (name: string): Promise<ForkSwitchResult> => {
|
||||
if (!state.file || !state.filePath) {
|
||||
return { success: false, error: FORK_ERRORS.SESSION_NOT_FOUND };
|
||||
}
|
||||
|
||||
const fork = state.file.forks.find((f) => f.name === name || f.id === name);
|
||||
if (!fork) {
|
||||
return { success: false, error: FORK_ERRORS.FORK_NOT_FOUND };
|
||||
}
|
||||
|
||||
state.file.currentForkId = fork.id;
|
||||
|
||||
state.dirty = true;
|
||||
await saveForkFile(state.file, state.filePath);
|
||||
|
||||
return { success: true, fork };
|
||||
};
|
||||
|
||||
/**
|
||||
* List all forks
|
||||
*/
|
||||
export const listForks = (): ForkSummary[] => {
|
||||
if (!state.file) return [];
|
||||
|
||||
return state.file.forks.map((fork) => {
|
||||
const currentSnapshot = fork.snapshots.find(
|
||||
(s) => s.id === fork.currentSnapshotId
|
||||
);
|
||||
|
||||
return {
|
||||
id: fork.id,
|
||||
name: fork.name,
|
||||
snapshotCount: fork.snapshots.length,
|
||||
currentSnapshotName: currentSnapshot?.name || "(no snapshots)",
|
||||
createdAt: fork.createdAt,
|
||||
updatedAt: fork.updatedAt,
|
||||
isCurrent: fork.id === state.file?.currentForkId,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List snapshots in current fork
|
||||
*/
|
||||
export const listSnapshots = (): SnapshotSummary[] => {
|
||||
const fork = getCurrentFork();
|
||||
if (!fork) return [];
|
||||
|
||||
return fork.snapshots.map((snapshot) => ({
|
||||
id: snapshot.id,
|
||||
name: snapshot.name,
|
||||
timestamp: snapshot.timestamp,
|
||||
messageCount: snapshot.state.messages.length,
|
||||
isCurrent: snapshot.id === fork.currentSnapshotId,
|
||||
suggestedCommitMessage: snapshot.suggestedCommitMessage,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current snapshot
|
||||
*/
|
||||
export const getCurrentSnapshot = (): SessionSnapshot | null => {
|
||||
const fork = getCurrentFork();
|
||||
if (!fork) return null;
|
||||
|
||||
return fork.snapshots.find((s) => s.id === fork.currentSnapshotId) || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get snapshot by name or ID
|
||||
*/
|
||||
export const getSnapshot = (nameOrId: string): SessionSnapshot | null => {
|
||||
const fork = getCurrentFork();
|
||||
if (!fork) return null;
|
||||
|
||||
return fork.snapshots.find((s) => s.name === nameOrId || s.id === nameOrId) || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if fork service is initialized
|
||||
*/
|
||||
export const isForkServiceInitialized = (): boolean => {
|
||||
return state.file !== null && state.sessionId !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current session ID
|
||||
*/
|
||||
export const getCurrentSessionId = (): string | null => {
|
||||
return state.sessionId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear fork service state
|
||||
*/
|
||||
export const clearForkService = (): void => {
|
||||
state.sessionId = null;
|
||||
state.file = null;
|
||||
state.filePath = null;
|
||||
state.dirty = false;
|
||||
};
|
||||
453
src/services/snapshot-service.ts
Normal file
453
src/services/snapshot-service.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Snapshot Service - Git-based differential snapshots
|
||||
*
|
||||
* Provides:
|
||||
* - Git-based differential snapshots
|
||||
* - Automatic 7-day retention pruning
|
||||
* - Patch generation and validation
|
||||
* - FileDiff tracking (additions, deletions, files changed)
|
||||
*/
|
||||
|
||||
import { execSync, exec } from "child_process";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const SNAPSHOTS_DIR = ".codetyper/snapshots";
|
||||
const RETENTION_DAYS = 7;
|
||||
const SNAPSHOT_BRANCH_PREFIX = "codetyper-snapshot-";
|
||||
|
||||
export interface FileDiff {
|
||||
path: string;
|
||||
status: "added" | "modified" | "deleted" | "renamed";
|
||||
additions: number;
|
||||
deletions: number;
|
||||
oldPath?: string; // For renamed files
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
commitHash: string;
|
||||
parentHash: string | null;
|
||||
files: FileDiff[];
|
||||
stats: {
|
||||
filesChanged: number;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SnapshotMetadata {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
commitHash: string;
|
||||
}
|
||||
|
||||
const fileExists = async (filePath: string): Promise<boolean> => {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isGitRepository = async (workingDir: string): Promise<boolean> => {
|
||||
return fileExists(path.join(workingDir, ".git"));
|
||||
};
|
||||
|
||||
const runGitCommand = (
|
||||
command: string,
|
||||
cwd: string,
|
||||
): { success: boolean; output: string; error?: string } => {
|
||||
try {
|
||||
const output = execSync(command, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
return { success: true, output: output.trim() };
|
||||
} catch (err) {
|
||||
const error = err as { stderr?: string; message?: string };
|
||||
return {
|
||||
success: false,
|
||||
output: "",
|
||||
error: error.stderr ?? error.message ?? "Unknown error",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const runGitCommandAsync = (
|
||||
command: string,
|
||||
cwd: string,
|
||||
): Promise<{ success: boolean; output: string; error?: string }> => {
|
||||
return new Promise((resolve) => {
|
||||
exec(command, { cwd, encoding: "utf-8" }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
resolve({ success: false, output: "", error: stderr || err.message });
|
||||
} else {
|
||||
resolve({ success: true, output: stdout.trim() });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const ensureSnapshotsDir = async (workingDir: string): Promise<void> => {
|
||||
const snapshotsDir = path.join(workingDir, SNAPSHOTS_DIR);
|
||||
await fs.mkdir(snapshotsDir, { recursive: true });
|
||||
};
|
||||
|
||||
const parseGitDiff = (diffOutput: string): FileDiff[] => {
|
||||
const files: FileDiff[] = [];
|
||||
const lines = diffOutput.split("\n").filter((l) => l.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
// Format: 1 2 path or A - path (for additions)
|
||||
const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
||||
if (match) {
|
||||
const additions = match[1] === "-" ? 0 : parseInt(match[1], 10);
|
||||
const deletions = match[2] === "-" ? 0 : parseInt(match[2], 10);
|
||||
const filePath = match[3];
|
||||
|
||||
// Check for rename (old => new)
|
||||
const renameMatch = filePath.match(/^(.+) => (.+)$/);
|
||||
if (renameMatch) {
|
||||
files.push({
|
||||
path: renameMatch[2],
|
||||
oldPath: renameMatch[1],
|
||||
status: "renamed",
|
||||
additions,
|
||||
deletions,
|
||||
});
|
||||
} else {
|
||||
// Determine status based on additions/deletions
|
||||
let status: FileDiff["status"] = "modified";
|
||||
if (additions > 0 && deletions === 0) {
|
||||
status = "added";
|
||||
} else if (deletions > 0 && additions === 0) {
|
||||
status = "deleted";
|
||||
}
|
||||
|
||||
files.push({
|
||||
path: filePath,
|
||||
status,
|
||||
additions,
|
||||
deletions,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const getCommitDiff = (
|
||||
workingDir: string,
|
||||
commitHash: string,
|
||||
parentHash: string | null,
|
||||
): FileDiff[] => {
|
||||
const compareTarget = parentHash ?? `${commitHash}^`;
|
||||
const result = runGitCommand(
|
||||
`git diff --numstat ${compareTarget} ${commitHash}`,
|
||||
workingDir,
|
||||
);
|
||||
|
||||
if (!result.success || !result.output) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parseGitDiff(result.output);
|
||||
};
|
||||
|
||||
const getCurrentCommitHash = (workingDir: string): string | null => {
|
||||
const result = runGitCommand("git rev-parse HEAD", workingDir);
|
||||
return result.success ? result.output : null;
|
||||
};
|
||||
|
||||
/** Get the most recent commit message */
|
||||
export const getHeadCommitMessage = (workingDir: string): string => {
|
||||
const result = runGitCommand("git log -1 --format=%s", workingDir);
|
||||
return result.success ? result.output : "No message";
|
||||
};
|
||||
|
||||
export const createSnapshot = async (
|
||||
workingDir: string,
|
||||
message?: string,
|
||||
): Promise<Snapshot | null> => {
|
||||
if (!(await isGitRepository(workingDir))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await ensureSnapshotsDir(workingDir);
|
||||
|
||||
const id = uuidv4();
|
||||
const timestamp = Date.now();
|
||||
const snapshotMessage = message ?? `Snapshot ${new Date(timestamp).toISOString()}`;
|
||||
|
||||
// Get current state
|
||||
const currentCommit = getCurrentCommitHash(workingDir);
|
||||
if (!currentCommit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there are uncommitted changes
|
||||
const statusResult = runGitCommand("git status --porcelain", workingDir);
|
||||
const hasChanges = statusResult.success && statusResult.output.length > 0;
|
||||
|
||||
let snapshotCommit = currentCommit;
|
||||
let parentHash: string | null = null;
|
||||
|
||||
if (hasChanges) {
|
||||
// Stash current changes, create snapshot, then restore
|
||||
const stashResult = runGitCommand(
|
||||
`git stash push -m "codetyper-temp-${id}"`,
|
||||
workingDir,
|
||||
);
|
||||
|
||||
if (!stashResult.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the parent commit (before stash)
|
||||
parentHash = getCurrentCommitHash(workingDir);
|
||||
} else {
|
||||
// Get parent of current commit
|
||||
const parentResult = runGitCommand("git rev-parse HEAD^", workingDir);
|
||||
parentHash = parentResult.success ? parentResult.output : null;
|
||||
}
|
||||
|
||||
// Calculate diff
|
||||
const files = getCommitDiff(workingDir, snapshotCommit, parentHash);
|
||||
const stats = {
|
||||
filesChanged: files.length,
|
||||
additions: files.reduce((sum, f) => sum + f.additions, 0),
|
||||
deletions: files.reduce((sum, f) => sum + f.deletions, 0),
|
||||
};
|
||||
|
||||
// Restore stashed changes if any
|
||||
if (hasChanges) {
|
||||
runGitCommand("git stash pop", workingDir);
|
||||
}
|
||||
|
||||
// Save snapshot metadata
|
||||
const snapshot: Snapshot = {
|
||||
id,
|
||||
timestamp,
|
||||
message: snapshotMessage,
|
||||
commitHash: snapshotCommit,
|
||||
parentHash,
|
||||
files,
|
||||
stats,
|
||||
};
|
||||
|
||||
const snapshotPath = path.join(workingDir, SNAPSHOTS_DIR, `${id}.json`);
|
||||
await fs.writeFile(snapshotPath, JSON.stringify(snapshot, null, 2));
|
||||
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
export const getSnapshot = async (
|
||||
workingDir: string,
|
||||
snapshotId: string,
|
||||
): Promise<Snapshot | null> => {
|
||||
const snapshotPath = path.join(workingDir, SNAPSHOTS_DIR, `${snapshotId}.json`);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(snapshotPath, "utf-8");
|
||||
return JSON.parse(content) as Snapshot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const listSnapshots = async (workingDir: string): Promise<SnapshotMetadata[]> => {
|
||||
const snapshotsDir = path.join(workingDir, SNAPSHOTS_DIR);
|
||||
|
||||
if (!(await fileExists(snapshotsDir))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(snapshotsDir);
|
||||
const snapshots: SnapshotMetadata[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(path.join(snapshotsDir, file), "utf-8");
|
||||
const snapshot = JSON.parse(content) as Snapshot;
|
||||
snapshots.push({
|
||||
id: snapshot.id,
|
||||
timestamp: snapshot.timestamp,
|
||||
message: snapshot.message,
|
||||
commitHash: snapshot.commitHash,
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid snapshot files
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (newest first)
|
||||
return snapshots.sort((a, b) => b.timestamp - a.timestamp);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSnapshot = async (
|
||||
workingDir: string,
|
||||
snapshotId: string,
|
||||
): Promise<boolean> => {
|
||||
const snapshotPath = path.join(workingDir, SNAPSHOTS_DIR, `${snapshotId}.json`);
|
||||
|
||||
try {
|
||||
await fs.unlink(snapshotPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const pruneOldSnapshots = async (workingDir: string): Promise<number> => {
|
||||
const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
const snapshots = await listSnapshots(workingDir);
|
||||
let deleted = 0;
|
||||
|
||||
for (const snapshot of snapshots) {
|
||||
if (snapshot.timestamp < cutoff) {
|
||||
if (await deleteSnapshot(workingDir, snapshot.id)) {
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
};
|
||||
|
||||
export const generatePatch = async (
|
||||
workingDir: string,
|
||||
snapshotId: string,
|
||||
): Promise<string | null> => {
|
||||
const snapshot = await getSnapshot(workingDir, snapshotId);
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const compareTarget = snapshot.parentHash ?? `${snapshot.commitHash}^`;
|
||||
const result = runGitCommand(
|
||||
`git diff ${compareTarget} ${snapshot.commitHash}`,
|
||||
workingDir,
|
||||
);
|
||||
|
||||
return result.success ? result.output : null;
|
||||
};
|
||||
|
||||
export const validatePatch = async (
|
||||
workingDir: string,
|
||||
patch: string,
|
||||
): Promise<{ valid: boolean; errors: string[] }> => {
|
||||
// Write patch to temp file
|
||||
const tempPatchPath = path.join(workingDir, SNAPSHOTS_DIR, `temp-${Date.now()}.patch`);
|
||||
|
||||
try {
|
||||
await fs.writeFile(tempPatchPath, patch);
|
||||
|
||||
// Try to apply patch with --check (dry run)
|
||||
const result = await runGitCommandAsync(
|
||||
`git apply --check "${tempPatchPath}"`,
|
||||
workingDir,
|
||||
);
|
||||
|
||||
return {
|
||||
valid: result.success,
|
||||
errors: result.error ? [result.error] : [],
|
||||
};
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
try {
|
||||
await fs.unlink(tempPatchPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const restoreSnapshot = async (
|
||||
workingDir: string,
|
||||
snapshotId: string,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
const snapshot = await getSnapshot(workingDir, snapshotId);
|
||||
if (!snapshot) {
|
||||
return { success: false, error: "Snapshot not found" };
|
||||
}
|
||||
|
||||
// Check if commit exists
|
||||
const result = runGitCommand(
|
||||
`git cat-file -t ${snapshot.commitHash}`,
|
||||
workingDir,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: "Snapshot commit no longer exists" };
|
||||
}
|
||||
|
||||
// Create a new branch from the snapshot
|
||||
const branchName = `${SNAPSHOT_BRANCH_PREFIX}${snapshotId.slice(0, 8)}`;
|
||||
const branchResult = runGitCommand(
|
||||
`git checkout -b ${branchName} ${snapshot.commitHash}`,
|
||||
workingDir,
|
||||
);
|
||||
|
||||
if (!branchResult.success) {
|
||||
return { success: false, error: branchResult.error };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const getWorkingDirectoryDiff = async (
|
||||
workingDir: string,
|
||||
): Promise<FileDiff[]> => {
|
||||
if (!(await isGitRepository(workingDir))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get diff between HEAD and working directory
|
||||
const stagedResult = runGitCommand("git diff --numstat --cached", workingDir);
|
||||
const unstagedResult = runGitCommand("git diff --numstat", workingDir);
|
||||
|
||||
const files: FileDiff[] = [];
|
||||
|
||||
if (stagedResult.success && stagedResult.output) {
|
||||
files.push(...parseGitDiff(stagedResult.output));
|
||||
}
|
||||
|
||||
if (unstagedResult.success && unstagedResult.output) {
|
||||
const unstagedFiles = parseGitDiff(unstagedResult.output);
|
||||
// Merge with staged, preferring staged status
|
||||
for (const file of unstagedFiles) {
|
||||
if (!files.some((f) => f.path === file.path)) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
export const snapshotService = {
|
||||
createSnapshot,
|
||||
getSnapshot,
|
||||
listSnapshots,
|
||||
deleteSnapshot,
|
||||
pruneOldSnapshots,
|
||||
generatePatch,
|
||||
validatePatch,
|
||||
restoreSnapshot,
|
||||
getWorkingDirectoryDiff,
|
||||
isGitRepository,
|
||||
};
|
||||
@@ -48,3 +48,7 @@ export const themeActions = {
|
||||
|
||||
subscribe: store.subscribe,
|
||||
};
|
||||
|
||||
// Export store for React hooks (in tui/hooks/useThemeStore.ts)
|
||||
export { store as themeStoreVanilla };
|
||||
export type { ThemeState };
|
||||
|
||||
@@ -216,3 +216,7 @@ export const todoStore = {
|
||||
getHistory: () => store.getState().history,
|
||||
subscribe: store.subscribe,
|
||||
};
|
||||
|
||||
// Export store for React hooks (in tui/hooks/useTodoStore.ts)
|
||||
export { store as todoStoreVanilla };
|
||||
export type { TodoState };
|
||||
|
||||
292
src/stores/vim-store.ts
Normal file
292
src/stores/vim-store.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Vim Mode Store (Vanilla)
|
||||
*
|
||||
* Zustand vanilla store for vim mode state management.
|
||||
* React hooks are provided separately in tui/hooks/useVimStore.ts
|
||||
*/
|
||||
|
||||
import { createStore } from "zustand/vanilla";
|
||||
import type {
|
||||
VimState,
|
||||
VimMode,
|
||||
VimSearchMatch,
|
||||
VimConfig,
|
||||
} from "@/types/vim";
|
||||
import { DEFAULT_VIM_CONFIG } from "@constants/vim";
|
||||
|
||||
/**
|
||||
* Vim store state with actions
|
||||
*/
|
||||
export interface VimStoreState extends VimState {
|
||||
/** Configuration */
|
||||
config: VimConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vim store actions
|
||||
*/
|
||||
export interface VimStoreActions {
|
||||
/** Set vim mode */
|
||||
setMode: (mode: VimMode) => void;
|
||||
/** Enable vim mode */
|
||||
enable: () => void;
|
||||
/** Disable vim mode */
|
||||
disable: () => void;
|
||||
/** Toggle vim mode */
|
||||
toggle: () => void;
|
||||
/** Set search pattern */
|
||||
setSearchPattern: (pattern: string) => void;
|
||||
/** Set command buffer */
|
||||
setCommandBuffer: (buffer: string) => void;
|
||||
/** Append to command buffer */
|
||||
appendCommandBuffer: (char: string) => void;
|
||||
/** Clear command buffer */
|
||||
clearCommandBuffer: () => void;
|
||||
/** Set visual start position */
|
||||
setVisualStart: (position: number | null) => void;
|
||||
/** Set count prefix */
|
||||
setCount: (count: number) => void;
|
||||
/** Reset count prefix */
|
||||
resetCount: () => void;
|
||||
/** Set pending operator */
|
||||
setPendingOperator: (operator: string | null) => void;
|
||||
/** Set search direction */
|
||||
setSearchDirection: (direction: "forward" | "backward") => void;
|
||||
/** Set register content */
|
||||
setRegister: (content: string) => void;
|
||||
/** Set search matches */
|
||||
setSearchMatches: (matches: VimSearchMatch[]) => void;
|
||||
/** Set current match index */
|
||||
setCurrentMatchIndex: (index: number) => void;
|
||||
/** Go to next match */
|
||||
nextMatch: () => void;
|
||||
/** Go to previous match */
|
||||
prevMatch: () => void;
|
||||
/** Clear search */
|
||||
clearSearch: () => void;
|
||||
/** Set configuration */
|
||||
setConfig: (config: Partial<VimConfig>) => void;
|
||||
/** Reset to initial state */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export type VimStore = VimStoreState & VimStoreActions;
|
||||
|
||||
/**
|
||||
* Initial vim state
|
||||
*/
|
||||
const initialState: VimStoreState = {
|
||||
mode: "insert",
|
||||
enabled: false,
|
||||
searchPattern: "",
|
||||
commandBuffer: "",
|
||||
visualStart: null,
|
||||
count: 0,
|
||||
pendingOperator: null,
|
||||
searchDirection: "forward",
|
||||
register: "",
|
||||
searchMatches: [],
|
||||
currentMatchIndex: -1,
|
||||
config: DEFAULT_VIM_CONFIG,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create vim store (vanilla)
|
||||
*/
|
||||
export const vimStore = createStore<VimStore>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setMode: (mode: VimMode) => {
|
||||
set({ mode });
|
||||
},
|
||||
|
||||
enable: () => {
|
||||
const { config } = get();
|
||||
set({
|
||||
enabled: true,
|
||||
mode: config.startInNormalMode ? "normal" : "insert",
|
||||
});
|
||||
},
|
||||
|
||||
disable: () => {
|
||||
set({
|
||||
enabled: false,
|
||||
mode: "insert",
|
||||
});
|
||||
},
|
||||
|
||||
toggle: () => {
|
||||
const { enabled } = get();
|
||||
if (enabled) {
|
||||
get().disable();
|
||||
} else {
|
||||
get().enable();
|
||||
}
|
||||
},
|
||||
|
||||
setSearchPattern: (pattern: string) => {
|
||||
set({ searchPattern: pattern });
|
||||
},
|
||||
|
||||
setCommandBuffer: (buffer: string) => {
|
||||
set({ commandBuffer: buffer });
|
||||
},
|
||||
|
||||
appendCommandBuffer: (char: string) => {
|
||||
set((state) => ({
|
||||
commandBuffer: state.commandBuffer + char,
|
||||
}));
|
||||
},
|
||||
|
||||
clearCommandBuffer: () => {
|
||||
set({ commandBuffer: "" });
|
||||
},
|
||||
|
||||
setVisualStart: (position: number | null) => {
|
||||
set({ visualStart: position });
|
||||
},
|
||||
|
||||
setCount: (count: number) => {
|
||||
set({ count });
|
||||
},
|
||||
|
||||
resetCount: () => {
|
||||
set({ count: 0 });
|
||||
},
|
||||
|
||||
setPendingOperator: (operator: string | null) => {
|
||||
set({ pendingOperator: operator });
|
||||
},
|
||||
|
||||
setSearchDirection: (direction: "forward" | "backward") => {
|
||||
set({ searchDirection: direction });
|
||||
},
|
||||
|
||||
setRegister: (content: string) => {
|
||||
set({ register: content });
|
||||
},
|
||||
|
||||
setSearchMatches: (matches: VimSearchMatch[]) => {
|
||||
set({
|
||||
searchMatches: matches,
|
||||
currentMatchIndex: matches.length > 0 ? 0 : -1,
|
||||
});
|
||||
},
|
||||
|
||||
setCurrentMatchIndex: (index: number) => {
|
||||
set({ currentMatchIndex: index });
|
||||
},
|
||||
|
||||
nextMatch: () => {
|
||||
const { searchMatches, currentMatchIndex } = get();
|
||||
if (searchMatches.length === 0) return;
|
||||
|
||||
const nextIndex = (currentMatchIndex + 1) % searchMatches.length;
|
||||
set({ currentMatchIndex: nextIndex });
|
||||
},
|
||||
|
||||
prevMatch: () => {
|
||||
const { searchMatches, currentMatchIndex } = get();
|
||||
if (searchMatches.length === 0) return;
|
||||
|
||||
const prevIndex =
|
||||
currentMatchIndex <= 0
|
||||
? searchMatches.length - 1
|
||||
: currentMatchIndex - 1;
|
||||
set({ currentMatchIndex: prevIndex });
|
||||
},
|
||||
|
||||
clearSearch: () => {
|
||||
set({
|
||||
searchPattern: "",
|
||||
searchMatches: [],
|
||||
currentMatchIndex: -1,
|
||||
});
|
||||
},
|
||||
|
||||
setConfig: (config: Partial<VimConfig>) => {
|
||||
set((state) => ({
|
||||
config: { ...state.config, ...config },
|
||||
}));
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set(initialState);
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Vim store actions for non-React access
|
||||
*/
|
||||
export const vimActions = {
|
||||
setMode: (mode: VimMode) => vimStore.getState().setMode(mode),
|
||||
enable: () => vimStore.getState().enable(),
|
||||
disable: () => vimStore.getState().disable(),
|
||||
toggle: () => vimStore.getState().toggle(),
|
||||
setSearchPattern: (pattern: string) => vimStore.getState().setSearchPattern(pattern),
|
||||
setCommandBuffer: (buffer: string) => vimStore.getState().setCommandBuffer(buffer),
|
||||
appendCommandBuffer: (char: string) => vimStore.getState().appendCommandBuffer(char),
|
||||
clearCommandBuffer: () => vimStore.getState().clearCommandBuffer(),
|
||||
setVisualStart: (position: number | null) => vimStore.getState().setVisualStart(position),
|
||||
setCount: (count: number) => vimStore.getState().setCount(count),
|
||||
resetCount: () => vimStore.getState().resetCount(),
|
||||
setPendingOperator: (operator: string | null) => vimStore.getState().setPendingOperator(operator),
|
||||
setSearchDirection: (direction: "forward" | "backward") =>
|
||||
vimStore.getState().setSearchDirection(direction),
|
||||
setRegister: (content: string) => vimStore.getState().setRegister(content),
|
||||
setSearchMatches: (matches: VimSearchMatch[]) => vimStore.getState().setSearchMatches(matches),
|
||||
setCurrentMatchIndex: (index: number) => vimStore.getState().setCurrentMatchIndex(index),
|
||||
nextMatch: () => vimStore.getState().nextMatch(),
|
||||
prevMatch: () => vimStore.getState().prevMatch(),
|
||||
clearSearch: () => vimStore.getState().clearSearch(),
|
||||
setConfig: (config: Partial<VimConfig>) => vimStore.getState().setConfig(config),
|
||||
reset: () => vimStore.getState().reset(),
|
||||
getState: () => vimStore.getState(),
|
||||
subscribe: vimStore.subscribe,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current vim mode
|
||||
*/
|
||||
export const getVimMode = (): VimMode => {
|
||||
return vimStore.getState().mode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if vim mode is enabled
|
||||
*/
|
||||
export const isVimEnabled = (): boolean => {
|
||||
return vimStore.getState().enabled;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if in normal mode
|
||||
*/
|
||||
export const isNormalMode = (): boolean => {
|
||||
const state = vimStore.getState();
|
||||
return state.enabled && state.mode === "normal";
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if in insert mode
|
||||
*/
|
||||
export const isInsertMode = (): boolean => {
|
||||
const state = vimStore.getState();
|
||||
return !state.enabled || state.mode === "insert";
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if in command mode
|
||||
*/
|
||||
export const isCommandMode = (): boolean => {
|
||||
const state = vimStore.getState();
|
||||
return state.enabled && state.mode === "command";
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if in visual mode
|
||||
*/
|
||||
export const isVisualMode = (): boolean => {
|
||||
const state = vimStore.getState();
|
||||
return state.enabled && state.mode === "visual";
|
||||
};
|
||||
@@ -11,6 +11,8 @@ export { todoWriteTool } from "@tools/todo-write";
|
||||
export { todoReadTool } from "@tools/todo-read";
|
||||
export { globToolDefinition } from "@tools/glob/definition";
|
||||
export { grepToolDefinition } from "@tools/grep/definition";
|
||||
export { webSearchTool } from "@tools/web-search";
|
||||
export { lspTool } from "@tools/lsp";
|
||||
|
||||
import type { ToolDefinition, FunctionDefinition } from "@tools/types";
|
||||
import { toolToFunction } from "@tools/types";
|
||||
@@ -22,11 +24,18 @@ import { todoWriteTool } from "@tools/todo-write";
|
||||
import { todoReadTool } from "@tools/todo-read";
|
||||
import { globToolDefinition } from "@tools/glob/definition";
|
||||
import { grepToolDefinition } from "@tools/grep/definition";
|
||||
import { webSearchTool } from "@tools/web-search";
|
||||
import { lspTool } from "@tools/lsp";
|
||||
import {
|
||||
isMCPTool,
|
||||
executeMCPTool,
|
||||
getMCPToolsForApi,
|
||||
} from "@services/mcp/tools";
|
||||
import {
|
||||
isPluginTool,
|
||||
getPluginTool,
|
||||
getPluginToolsForApi,
|
||||
} from "@services/plugin-service";
|
||||
import { z } from "zod";
|
||||
|
||||
// All available tools
|
||||
@@ -39,6 +48,8 @@ export const tools: ToolDefinition[] = [
|
||||
grepToolDefinition,
|
||||
todoWriteTool,
|
||||
todoReadTool,
|
||||
webSearchTool,
|
||||
lspTool,
|
||||
];
|
||||
|
||||
// Tools that are read-only (allowed in chat mode)
|
||||
@@ -47,6 +58,8 @@ const READ_ONLY_TOOLS = new Set([
|
||||
"glob",
|
||||
"grep",
|
||||
"todo_read",
|
||||
"web_search",
|
||||
"lsp",
|
||||
]);
|
||||
|
||||
// Map of tools by name
|
||||
@@ -58,7 +71,7 @@ export const toolMap: Map<string, ToolDefinition> = new Map(
|
||||
let mcpToolsCache: Awaited<ReturnType<typeof getMCPToolsForApi>> | null = null;
|
||||
|
||||
/**
|
||||
* Get tool by name (including MCP tools)
|
||||
* Get tool by name (including MCP tools and plugin tools)
|
||||
*/
|
||||
export function getTool(name: string): ToolDefinition | undefined {
|
||||
// Check built-in tools first
|
||||
@@ -67,6 +80,11 @@ export function getTool(name: string): ToolDefinition | undefined {
|
||||
return builtInTool;
|
||||
}
|
||||
|
||||
// Check if it's a plugin tool
|
||||
if (isPluginTool(name)) {
|
||||
return getPluginTool(name);
|
||||
}
|
||||
|
||||
// Check if it's an MCP tool
|
||||
if (isMCPTool(name)) {
|
||||
// Return a wrapper tool definition for MCP tools
|
||||
@@ -132,13 +150,15 @@ export async function getToolsForApiAsync(
|
||||
return builtInTools;
|
||||
}
|
||||
|
||||
// Get MCP tools (uses cache if available)
|
||||
// Get MCP tools and plugin tools
|
||||
try {
|
||||
mcpToolsCache = await getMCPToolsForApi();
|
||||
return [...builtInTools, ...mcpToolsCache];
|
||||
const pluginTools = getPluginToolsForApi();
|
||||
return [...builtInTools, ...pluginTools, ...mcpToolsCache];
|
||||
} catch {
|
||||
// If MCP tools fail to load, just return built-in tools
|
||||
return builtInTools;
|
||||
// If MCP tools fail to load, still include plugin tools
|
||||
const pluginTools = getPluginToolsForApi();
|
||||
return [...builtInTools, ...pluginTools];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,12 +183,15 @@ export function getToolsForApi(
|
||||
return builtInTools;
|
||||
}
|
||||
|
||||
// Include plugin tools
|
||||
const pluginTools = getPluginToolsForApi();
|
||||
|
||||
// Include cached MCP tools if available
|
||||
if (mcpToolsCache) {
|
||||
return [...builtInTools, ...mcpToolsCache];
|
||||
return [...builtInTools, ...pluginTools, ...mcpToolsCache];
|
||||
}
|
||||
|
||||
return builtInTools;
|
||||
return [...builtInTools, ...pluginTools];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
253
src/tools/lsp.ts
Normal file
253
src/tools/lsp.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* LSP Tool - Provides code intelligence capabilities to the agent
|
||||
*
|
||||
* Operations:
|
||||
* - hover: Get hover information at a position
|
||||
* - definition: Jump to definition
|
||||
* - references: Find all references
|
||||
* - symbols: Get document symbols
|
||||
* - diagnostics: Get file diagnostics
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import {
|
||||
lspService,
|
||||
type Diagnostic,
|
||||
type Location,
|
||||
type DocumentSymbol,
|
||||
type Hover,
|
||||
} from "@services/lsp/index";
|
||||
import type { ToolDefinition } from "@tools/types";
|
||||
import fs from "fs/promises";
|
||||
|
||||
const PositionSchema = z.object({
|
||||
line: z.number().describe("Zero-based line number"),
|
||||
character: z.number().describe("Zero-based character offset"),
|
||||
});
|
||||
|
||||
const parametersSchema = z.object({
|
||||
operation: z
|
||||
.enum(["hover", "definition", "references", "symbols", "diagnostics"])
|
||||
.describe("The LSP operation to perform"),
|
||||
file: z.string().describe("Path to the file"),
|
||||
position: PositionSchema.optional().describe(
|
||||
"Position in the file (required for hover, definition, references)",
|
||||
),
|
||||
});
|
||||
|
||||
type LSPParams = z.infer<typeof parametersSchema>;
|
||||
|
||||
const formatDiagnostics = (diagnostics: Diagnostic[]): string => {
|
||||
if (diagnostics.length === 0) {
|
||||
return "No diagnostics found.";
|
||||
}
|
||||
|
||||
const severityNames = ["", "Error", "Warning", "Info", "Hint"];
|
||||
|
||||
return diagnostics
|
||||
.map((d) => {
|
||||
const severity = severityNames[d.severity ?? 1];
|
||||
const location = `${d.range.start.line + 1}:${d.range.start.character + 1}`;
|
||||
const source = d.source ? `[${d.source}] ` : "";
|
||||
return `${severity} at ${location}: ${source}${d.message}`;
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const formatLocation = (loc: Location): string => {
|
||||
const file = loc.uri.replace("file://", "");
|
||||
const line = loc.range.start.line + 1;
|
||||
const char = loc.range.start.character + 1;
|
||||
return `${file}:${line}:${char}`;
|
||||
};
|
||||
|
||||
const formatLocations = (locations: Location | Location[] | null): string => {
|
||||
if (!locations) {
|
||||
return "No locations found.";
|
||||
}
|
||||
|
||||
const locs = Array.isArray(locations) ? locations : [locations];
|
||||
if (locs.length === 0) {
|
||||
return "No locations found.";
|
||||
}
|
||||
|
||||
return locs.map(formatLocation).join("\n");
|
||||
};
|
||||
|
||||
const formatSymbols = (symbols: DocumentSymbol[], indent = 0): string => {
|
||||
const kindNames: Record<number, string> = {
|
||||
1: "File",
|
||||
2: "Module",
|
||||
3: "Namespace",
|
||||
4: "Package",
|
||||
5: "Class",
|
||||
6: "Method",
|
||||
7: "Property",
|
||||
8: "Field",
|
||||
9: "Constructor",
|
||||
10: "Enum",
|
||||
11: "Interface",
|
||||
12: "Function",
|
||||
13: "Variable",
|
||||
14: "Constant",
|
||||
15: "String",
|
||||
16: "Number",
|
||||
17: "Boolean",
|
||||
18: "Array",
|
||||
19: "Object",
|
||||
20: "Key",
|
||||
21: "Null",
|
||||
22: "EnumMember",
|
||||
23: "Struct",
|
||||
24: "Event",
|
||||
25: "Operator",
|
||||
26: "TypeParameter",
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
const prefix = " ".repeat(indent);
|
||||
|
||||
for (const symbol of symbols) {
|
||||
const kind = kindNames[symbol.kind] ?? "Unknown";
|
||||
const line = symbol.range.start.line + 1;
|
||||
lines.push(`${prefix}${kind}: ${symbol.name} (line ${line})`);
|
||||
|
||||
if (symbol.children && symbol.children.length > 0) {
|
||||
lines.push(formatSymbols(symbol.children, indent + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const formatHover = (hover: Hover | null): string => {
|
||||
if (!hover) {
|
||||
return "No hover information available.";
|
||||
}
|
||||
|
||||
const contents = hover.contents;
|
||||
|
||||
if (typeof contents === "string") {
|
||||
return contents;
|
||||
}
|
||||
|
||||
if (Array.isArray(contents)) {
|
||||
return contents
|
||||
.map((c) => (typeof c === "string" ? c : c.value))
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
return contents.value;
|
||||
};
|
||||
|
||||
export const lspTool: ToolDefinition = {
|
||||
name: "lsp",
|
||||
description: `Get code intelligence information using Language Server Protocol.
|
||||
|
||||
Operations:
|
||||
- hover: Get type information and documentation at a position
|
||||
- definition: Find where a symbol is defined
|
||||
- references: Find all references to a symbol
|
||||
- symbols: Get all symbols in a document (classes, functions, etc.)
|
||||
- diagnostics: Get errors and warnings for a file
|
||||
|
||||
Examples:
|
||||
- Get hover info: { "operation": "hover", "file": "src/app.ts", "position": { "line": 10, "character": 5 } }
|
||||
- Find definition: { "operation": "definition", "file": "src/app.ts", "position": { "line": 10, "character": 5 } }
|
||||
- Get symbols: { "operation": "symbols", "file": "src/app.ts" }
|
||||
- Get diagnostics: { "operation": "diagnostics", "file": "src/app.ts" }`,
|
||||
parameters: parametersSchema,
|
||||
execute: async (args: LSPParams) => {
|
||||
const { operation, file, position } = args;
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(file);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
title: "File not found",
|
||||
output: `File not found: ${file}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if LSP support is available
|
||||
if (!lspService.hasSupport(file)) {
|
||||
return {
|
||||
success: false,
|
||||
title: "No LSP support",
|
||||
output: `No language server available for this file type.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Open file in LSP
|
||||
await lspService.openFile(file);
|
||||
|
||||
const operationHandlers: Record<string, () => Promise<{ title: string; output: string }>> = {
|
||||
hover: async () => {
|
||||
if (!position) {
|
||||
return { title: "Error", output: "Position required for hover operation" };
|
||||
}
|
||||
const hover = await lspService.getHover(file, position);
|
||||
return { title: "Hover Info", output: formatHover(hover) };
|
||||
},
|
||||
|
||||
definition: async () => {
|
||||
if (!position) {
|
||||
return { title: "Error", output: "Position required for definition operation" };
|
||||
}
|
||||
const definition = await lspService.getDefinition(file, position);
|
||||
return { title: "Definition", output: formatLocations(definition) };
|
||||
},
|
||||
|
||||
references: async () => {
|
||||
if (!position) {
|
||||
return { title: "Error", output: "Position required for references operation" };
|
||||
}
|
||||
const references = await lspService.getReferences(file, position);
|
||||
return {
|
||||
title: `References (${references.length})`,
|
||||
output: formatLocations(references.length > 0 ? references : null),
|
||||
};
|
||||
},
|
||||
|
||||
symbols: async () => {
|
||||
const symbols = await lspService.getDocumentSymbols(file);
|
||||
if (symbols.length === 0) {
|
||||
return { title: "Document Symbols", output: "No symbols found." };
|
||||
}
|
||||
return {
|
||||
title: `Document Symbols (${symbols.length})`,
|
||||
output: formatSymbols(symbols),
|
||||
};
|
||||
},
|
||||
|
||||
diagnostics: async () => {
|
||||
const diagnosticsMap = lspService.getDiagnostics(file);
|
||||
const allDiagnostics: Diagnostic[] = [];
|
||||
for (const diags of diagnosticsMap.values()) {
|
||||
allDiagnostics.push(...diags);
|
||||
}
|
||||
return {
|
||||
title: `Diagnostics (${allDiagnostics.length})`,
|
||||
output: formatDiagnostics(allDiagnostics),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const handler = operationHandlers[operation];
|
||||
if (!handler) {
|
||||
return {
|
||||
success: false,
|
||||
title: "Unknown operation",
|
||||
output: `Unknown LSP operation: ${operation}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await handler();
|
||||
return {
|
||||
success: true,
|
||||
...result,
|
||||
};
|
||||
},
|
||||
};
|
||||
7
src/tools/web-search.ts
Normal file
7
src/tools/web-search.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Web Search tool for searching the web
|
||||
*/
|
||||
|
||||
export { webSearchParams, type WebSearchParamsSchema } from "@tools/web-search/params";
|
||||
export { executeWebSearch, webSearchTool } from "@tools/web-search/execute";
|
||||
export type { SearchResult } from "@tools/web-search/execute";
|
||||
242
src/tools/web-search/execute.ts
Normal file
242
src/tools/web-search/execute.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Web Search Tool Execution
|
||||
*
|
||||
* Uses DuckDuckGo HTML search (no API key required)
|
||||
*/
|
||||
|
||||
import {
|
||||
WEB_SEARCH_DEFAULTS,
|
||||
WEB_SEARCH_MESSAGES,
|
||||
WEB_SEARCH_TITLES,
|
||||
WEB_SEARCH_DESCRIPTION,
|
||||
} from "@constants/web-search";
|
||||
import { webSearchParams } from "@tools/web-search/params";
|
||||
import type { ToolDefinition, ToolContext, ToolResult } from "@/types/tools";
|
||||
import type { WebSearchParams } from "@tools/web-search/params";
|
||||
|
||||
export interface SearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
const createErrorResult = (error: string): ToolResult => ({
|
||||
success: false,
|
||||
title: WEB_SEARCH_TITLES.FAILED,
|
||||
output: "",
|
||||
error,
|
||||
});
|
||||
|
||||
const createNoResultsResult = (query: string): ToolResult => ({
|
||||
success: true,
|
||||
title: WEB_SEARCH_TITLES.NO_RESULTS,
|
||||
output: `No results found for: "${query}"`,
|
||||
});
|
||||
|
||||
const createSuccessResult = (
|
||||
results: SearchResult[],
|
||||
query: string,
|
||||
): ToolResult => {
|
||||
const formattedResults = results
|
||||
.map(
|
||||
(r, i) =>
|
||||
`${i + 1}. **${r.title}**\n ${r.url}\n ${r.snippet}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
title: WEB_SEARCH_TITLES.RESULTS(results.length),
|
||||
output: `Search results for "${query}":\n\n${formattedResults}`,
|
||||
metadata: {
|
||||
query,
|
||||
resultCount: results.length,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse DuckDuckGo HTML search results
|
||||
*/
|
||||
const parseSearchResults = (html: string, maxResults: number): SearchResult[] => {
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
// DuckDuckGo lite HTML structure parsing
|
||||
// Look for result links and snippets
|
||||
const resultPattern =
|
||||
/<a[^>]+class="result-link"[^>]*href="([^"]+)"[^>]*>([^<]+)<\/a>[\s\S]*?<td[^>]*class="result-snippet"[^>]*>([^<]+)/gi;
|
||||
|
||||
// Alternative pattern for standard DuckDuckGo HTML
|
||||
const altPattern =
|
||||
/<a[^>]+rel="nofollow"[^>]*href="([^"]+)"[^>]*>([^<]+)<\/a>[\s\S]*?<span[^>]*>([^<]{20,})/gi;
|
||||
|
||||
// Try result-link pattern first
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = resultPattern.exec(html)) !== null && results.length < maxResults) {
|
||||
const [, url, title, snippet] = match;
|
||||
if (url && title && !url.includes("duckduckgo.com")) {
|
||||
results.push({
|
||||
title: decodeHtmlEntities(title.trim()),
|
||||
url: decodeUrl(url),
|
||||
snippet: decodeHtmlEntities(snippet.trim()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If no results, try alternative pattern
|
||||
if (results.length === 0) {
|
||||
while ((match = altPattern.exec(html)) !== null && results.length < maxResults) {
|
||||
const [, url, title, snippet] = match;
|
||||
if (url && title && !url.includes("duckduckgo.com")) {
|
||||
results.push({
|
||||
title: decodeHtmlEntities(title.trim()),
|
||||
url: decodeUrl(url),
|
||||
snippet: decodeHtmlEntities(snippet.trim()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: extract any external links with reasonable text
|
||||
if (results.length === 0) {
|
||||
const linkPattern = /<a[^>]+href="(https?:\/\/(?!duckduckgo)[^"]+)"[^>]*>([^<]{10,100})<\/a>/gi;
|
||||
const seenUrls = new Set<string>();
|
||||
|
||||
while ((match = linkPattern.exec(html)) !== null && results.length < maxResults) {
|
||||
const [, url, title] = match;
|
||||
if (!seenUrls.has(url) && !url.includes("duckduckgo")) {
|
||||
seenUrls.add(url);
|
||||
results.push({
|
||||
title: decodeHtmlEntities(title.trim()),
|
||||
url: decodeUrl(url),
|
||||
snippet: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode HTML entities
|
||||
*/
|
||||
const decodeHtmlEntities = (text: string): string => {
|
||||
const entities: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
""": '"',
|
||||
"'": "'",
|
||||
" ": " ",
|
||||
"'": "'",
|
||||
"/": "/",
|
||||
};
|
||||
|
||||
let decoded = text;
|
||||
for (const [entity, char] of Object.entries(entities)) {
|
||||
decoded = decoded.replace(new RegExp(entity, "g"), char);
|
||||
}
|
||||
|
||||
// Handle numeric entities
|
||||
decoded = decoded.replace(/&#(\d+);/g, (_, code) =>
|
||||
String.fromCharCode(parseInt(code, 10)),
|
||||
);
|
||||
|
||||
return decoded;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode DuckDuckGo redirect URLs
|
||||
*/
|
||||
const decodeUrl = (url: string): string => {
|
||||
// DuckDuckGo often wraps URLs in redirects
|
||||
if (url.includes("uddg=")) {
|
||||
const match = url.match(/uddg=([^&]+)/);
|
||||
if (match) {
|
||||
return decodeURIComponent(match[1]);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform web search using DuckDuckGo
|
||||
*/
|
||||
const performSearch = async (
|
||||
query: string,
|
||||
maxResults: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SearchResult[]> => {
|
||||
const encodedQuery = encodeURIComponent(query);
|
||||
|
||||
// Use DuckDuckGo HTML search (lite version for easier parsing)
|
||||
const searchUrl = `https://lite.duckduckgo.com/lite/?q=${encodedQuery}`;
|
||||
|
||||
const response = await fetch(searchUrl, {
|
||||
headers: {
|
||||
"User-Agent": WEB_SEARCH_DEFAULTS.USER_AGENT,
|
||||
Accept: "text/html",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
return parseSearchResults(html, maxResults);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute web search
|
||||
*/
|
||||
export const executeWebSearch = async (
|
||||
args: WebSearchParams,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> => {
|
||||
const { query, maxResults = 5 } = args;
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
return createErrorResult("Search query is required");
|
||||
}
|
||||
|
||||
ctx.onMetadata?.({
|
||||
title: WEB_SEARCH_TITLES.SEARCHING(query),
|
||||
status: "running",
|
||||
});
|
||||
|
||||
try {
|
||||
// Create timeout with abort signal
|
||||
const timeoutId = setTimeout(
|
||||
() => ctx.abort.abort(),
|
||||
WEB_SEARCH_DEFAULTS.TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const results = await performSearch(query, maxResults, ctx.abort.signal);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (results.length === 0) {
|
||||
return createNoResultsResult(query);
|
||||
}
|
||||
|
||||
return createSuccessResult(results, query);
|
||||
} catch (error) {
|
||||
if (ctx.abort.signal.aborted) {
|
||||
return createErrorResult(WEB_SEARCH_MESSAGES.TIMEOUT);
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return createErrorResult(WEB_SEARCH_MESSAGES.SEARCH_ERROR(message));
|
||||
}
|
||||
};
|
||||
|
||||
export const webSearchTool: ToolDefinition<typeof webSearchParams> = {
|
||||
name: "web_search",
|
||||
description: WEB_SEARCH_DESCRIPTION,
|
||||
parameters: webSearchParams,
|
||||
execute: executeWebSearch,
|
||||
};
|
||||
17
src/tools/web-search/params.ts
Normal file
17
src/tools/web-search/params.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Web Search Tool Parameters
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const webSearchParams = z.object({
|
||||
query: z.string().describe("The search query"),
|
||||
maxResults: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(5)
|
||||
.describe("Maximum number of results to return (default: 5)"),
|
||||
});
|
||||
|
||||
export type WebSearchParamsSchema = typeof webSearchParams;
|
||||
export type WebSearchParams = z.infer<typeof webSearchParams>;
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
SessionHeader,
|
||||
} from "@tui/components/index";
|
||||
import { InputLine, calculateLineStartPos } from "@tui/components/input-line";
|
||||
import { useThemeStore, useThemeColors } from "@stores/theme-store";
|
||||
import { useThemeStore, useThemeColors } from "@tui/hooks/useThemeStore";
|
||||
import type { AgentConfig } from "@/types/agent-config";
|
||||
import { createFilePickerState } from "@/services/file-picker-service";
|
||||
import { INTERRUPT_TIMEOUT } from "@constants/ui";
|
||||
@@ -53,6 +53,8 @@ import { PAGE_SCROLL_LINES, MOUSE_SCROLL_LINES } from "@constants/auto-scroll";
|
||||
import { useMouseScroll } from "@tui/hooks";
|
||||
import type { PasteState } from "@interfaces/PastedContent";
|
||||
import { createInitialPasteState } from "@interfaces/PastedContent";
|
||||
import { readClipboardImage } from "@services/clipboard-service";
|
||||
import { ImageAttachment } from "@tui/components/ImageAttachment";
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type { AppProps } from "@interfaces/AppProps";
|
||||
@@ -171,6 +173,11 @@ export function App({
|
||||
pasteState.pastedBlocks,
|
||||
);
|
||||
|
||||
// Capture images before clearing
|
||||
const images = pasteState.pastedImages.length > 0
|
||||
? [...pasteState.pastedImages]
|
||||
: undefined;
|
||||
|
||||
// Clear paste state after expanding
|
||||
setPasteState(clearPastedBlocks());
|
||||
|
||||
@@ -179,12 +186,17 @@ export function App({
|
||||
setScreenMode("session");
|
||||
}
|
||||
|
||||
addLog({ type: "user", content: expandedMessage });
|
||||
// Build log content with image indicator
|
||||
const logContent = images
|
||||
? `${expandedMessage}\n[${images.length} image${images.length > 1 ? "s" : ""} attached]`
|
||||
: expandedMessage;
|
||||
|
||||
addLog({ type: "user", content: logContent });
|
||||
setMode("thinking");
|
||||
startThinking();
|
||||
|
||||
try {
|
||||
await onSubmit(expandedMessage);
|
||||
await onSubmit(expandedMessage, { images });
|
||||
} finally {
|
||||
stopThinking();
|
||||
setMode("idle");
|
||||
@@ -199,6 +211,7 @@ export function App({
|
||||
screenMode,
|
||||
setScreenMode,
|
||||
pasteState.pastedBlocks,
|
||||
pasteState.pastedImages,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -527,10 +540,41 @@ export function App({
|
||||
pastedBlocks: updatedBlocks,
|
||||
}));
|
||||
}
|
||||
} else if (input === "v") {
|
||||
// Handle Ctrl+V for image paste
|
||||
readClipboardImage().then((image) => {
|
||||
if (image) {
|
||||
setPasteState((prev) => ({
|
||||
...prev,
|
||||
pastedImages: [...prev.pastedImages, image],
|
||||
}));
|
||||
addLog({
|
||||
type: "system",
|
||||
content: `Image attached (${image.mediaType})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Cmd+V (macOS) for image paste
|
||||
if (key.meta && input === "v") {
|
||||
readClipboardImage().then((image) => {
|
||||
if (image) {
|
||||
setPasteState((prev) => ({
|
||||
...prev,
|
||||
pastedImages: [...prev.pastedImages, image],
|
||||
}));
|
||||
addLog({
|
||||
type: "system",
|
||||
content: `Image attached (${image.mediaType})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip meta key combinations
|
||||
if (key.meta) {
|
||||
return;
|
||||
@@ -633,10 +677,12 @@ export function App({
|
||||
{isHomeMode ? (
|
||||
<HomeContent provider={provider} model={model} version={version} />
|
||||
) : (
|
||||
<>
|
||||
<Box flexDirection="row" flexGrow={1}>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<LogPanel />
|
||||
</Box>
|
||||
<TodoPanel />
|
||||
<LogPanel />
|
||||
</>
|
||||
</Box>
|
||||
)}
|
||||
<PermissionModal />
|
||||
<LearningModal />
|
||||
@@ -712,6 +758,11 @@ export function App({
|
||||
}
|
||||
paddingX={1}
|
||||
>
|
||||
{/* Show attached images */}
|
||||
{pasteState.pastedImages.length > 0 && (
|
||||
<ImageAttachment images={pasteState.pastedImages} />
|
||||
)}
|
||||
|
||||
{isLocked ? (
|
||||
<Text dimColor>Input locked during execution...</Text>
|
||||
) : isEmpty ? (
|
||||
@@ -741,7 +792,7 @@ export function App({
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Enter to send • Alt+Enter for newline • @ to add files
|
||||
Enter to send • Alt+Enter for newline • @ to add files • Ctrl+V to paste image
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
74
src/tui/components/ImageAttachment.tsx
Normal file
74
src/tui/components/ImageAttachment.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* ImageAttachment Component - Displays pasted image indicators
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import type { PastedImage } from "@/types/image";
|
||||
import { formatImageSize, getImageSizeFromBase64 } from "@services/clipboard-service";
|
||||
|
||||
interface ImageAttachmentProps {
|
||||
images: PastedImage[];
|
||||
onRemove?: (id: string) => void;
|
||||
}
|
||||
|
||||
const IMAGE_ICON = "📷";
|
||||
|
||||
export function ImageAttachment({
|
||||
images,
|
||||
onRemove,
|
||||
}: ImageAttachmentProps): React.ReactElement | null {
|
||||
if (images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" gap={1} marginBottom={1}>
|
||||
{images.map((image, index) => {
|
||||
const size = getImageSizeFromBase64(image.data);
|
||||
const formattedSize = formatImageSize(size);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={image.id}
|
||||
borderStyle="round"
|
||||
borderColor="cyan"
|
||||
paddingX={1}
|
||||
>
|
||||
<Text color="cyan">{IMAGE_ICON} </Text>
|
||||
<Text>Image {index + 1}</Text>
|
||||
<Text dimColor> ({formattedSize})</Text>
|
||||
{onRemove && (
|
||||
<Text dimColor> [x]</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageAttachmentCompact({
|
||||
images,
|
||||
}: {
|
||||
images: PastedImage[];
|
||||
}): React.ReactElement | null {
|
||||
if (images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalSize = images.reduce(
|
||||
(acc, img) => acc + getImageSizeFromBase64(img.data),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color="cyan">{IMAGE_ICON} </Text>
|
||||
<Text>
|
||||
{images.length} image{images.length > 1 ? "s" : ""} attached
|
||||
</Text>
|
||||
<Text dimColor> ({formatImageSize(totalSize)})</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type ModeDisplayConfig,
|
||||
} from "@constants/tui-components";
|
||||
import type { AppMode, ToolCall } from "@/types/tui";
|
||||
import { useTodoStore } from "@stores/todo-store";
|
||||
import { useTodoStore } from "@tui/hooks/useTodoStore";
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
const totalSeconds = Math.floor(ms / TIME_UNITS.SECOND);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import React, { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { useThemeStore, useThemeColors } from "@stores/theme-store";
|
||||
import { useThemeStore, useThemeColors } from "@tui/hooks/useThemeStore";
|
||||
import { THEMES } from "@constants/themes";
|
||||
|
||||
interface ThemeSelectProps {
|
||||
|
||||
@@ -1,103 +1,204 @@
|
||||
/**
|
||||
* TodoPanel Component - Shows agent-generated task plan
|
||||
* TodoPanel Component - Shows agent-generated task plan as a right-side pane
|
||||
*
|
||||
* Displays current plan with task status and progress
|
||||
* Displays current plan with task status and progress in Claude Code style:
|
||||
* - Spinner with current task title, duration, and tokens
|
||||
* - ✓ with strikethrough for completed tasks
|
||||
* - ■ for in_progress tasks
|
||||
* - □ for pending tasks
|
||||
* - Collapsible completed tasks view
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { useTodoStore } from "@stores/todo-store";
|
||||
import { useTodoStore } from "@tui/hooks/useTodoStore";
|
||||
import { useAppStore } from "@tui/store";
|
||||
import type { TodoStatus } from "@/types/todo";
|
||||
import type { TodoItem, TodoStatus } from "@/types/todo";
|
||||
|
||||
const STATUS_ICONS: Record<TodoStatus, string> = {
|
||||
pending: "○",
|
||||
in_progress: "◐",
|
||||
completed: "●",
|
||||
pending: "□",
|
||||
in_progress: "■",
|
||||
completed: "✓",
|
||||
failed: "✗",
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<TodoStatus, string> = {
|
||||
pending: "gray",
|
||||
pending: "white",
|
||||
in_progress: "yellow",
|
||||
completed: "green",
|
||||
failed: "red",
|
||||
};
|
||||
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
const MAX_VISIBLE_COMPLETED = 3;
|
||||
const PANEL_WIDTH = 50;
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
const formatTokens = (count: number): string => {
|
||||
if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return String(count);
|
||||
};
|
||||
|
||||
interface TaskItemProps {
|
||||
item: TodoItem;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
const TaskItem = ({ item, isLast }: TaskItemProps): React.ReactElement => {
|
||||
const icon = STATUS_ICONS[item.status];
|
||||
const color = STATUS_COLORS[item.status];
|
||||
const isCompleted = item.status === "completed";
|
||||
const isInProgress = item.status === "in_progress";
|
||||
|
||||
// Tree connector: L for last item, ├ for other items
|
||||
const connector = isLast ? "└" : "├";
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>{connector}─ </Text>
|
||||
<Text color={color}>{icon} </Text>
|
||||
<Text
|
||||
color={isInProgress ? "white" : color}
|
||||
bold={isInProgress}
|
||||
strikethrough={isCompleted}
|
||||
dimColor={isCompleted}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export function TodoPanel(): React.ReactElement | null {
|
||||
const currentPlan = useTodoStore((state) => state.currentPlan);
|
||||
const todosVisible = useAppStore((state) => state.todosVisible);
|
||||
const sessionStats = useAppStore((state) => state.sessionStats);
|
||||
|
||||
// Spinner animation
|
||||
const [spinnerFrame, setSpinnerFrame] = useState(0);
|
||||
|
||||
// Elapsed time tracking
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentPlan) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setSpinnerFrame((f) => (f + 1) % SPINNER_FRAMES.length);
|
||||
setElapsed(Date.now() - currentPlan.createdAt);
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [currentPlan]);
|
||||
|
||||
// Don't render if no plan or hidden
|
||||
if (!currentPlan || !todosVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { completed, total, percentage } = useTodoStore
|
||||
.getState()
|
||||
.getProgress();
|
||||
const { completed, total } = useTodoStore.getState().getProgress();
|
||||
const totalTokens = sessionStats.inputTokens + sessionStats.outputTokens;
|
||||
|
||||
// Progress bar
|
||||
const barWidth = 20;
|
||||
const filledWidth = Math.round((percentage / 100) * barWidth);
|
||||
const progressBar =
|
||||
"█".repeat(filledWidth) + "░".repeat(barWidth - filledWidth);
|
||||
// Get current in_progress task
|
||||
const currentTask = currentPlan.items.find(
|
||||
(item) => item.status === "in_progress",
|
||||
);
|
||||
|
||||
// Separate tasks by status
|
||||
const completedTasks = currentPlan.items.filter(
|
||||
(item) => item.status === "completed",
|
||||
);
|
||||
const pendingTasks = currentPlan.items.filter(
|
||||
(item) => item.status === "pending",
|
||||
);
|
||||
const inProgressTasks = currentPlan.items.filter(
|
||||
(item) => item.status === "in_progress",
|
||||
);
|
||||
const failedTasks = currentPlan.items.filter(
|
||||
(item) => item.status === "failed",
|
||||
);
|
||||
|
||||
// Determine which completed tasks to show (most recent)
|
||||
const visibleCompletedTasks = completedTasks.slice(-MAX_VISIBLE_COMPLETED);
|
||||
const hiddenCompletedCount = Math.max(
|
||||
0,
|
||||
completedTasks.length - MAX_VISIBLE_COMPLETED,
|
||||
);
|
||||
|
||||
// Build task list in display order:
|
||||
// 1. Visible completed tasks (oldest of the visible first)
|
||||
// 2. In-progress task (current)
|
||||
// 3. Pending tasks
|
||||
// 4. Failed tasks
|
||||
const displayTasks = [
|
||||
...visibleCompletedTasks,
|
||||
...inProgressTasks,
|
||||
...pendingTasks,
|
||||
...failedTasks,
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="cyan"
|
||||
width={PANEL_WIDTH}
|
||||
borderStyle="single"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
marginBottom={1}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box justifyContent="space-between" marginBottom={1}>
|
||||
<Text color="cyan" bold>
|
||||
📋 {currentPlan.title}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{completed}/{total} ({percentage}%)
|
||||
</Text>
|
||||
{/* Header with spinner, task name, duration, and tokens */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color="magenta">{SPINNER_FRAMES[spinnerFrame]} </Text>
|
||||
<Text color="white" bold>
|
||||
{currentTask?.title ?? currentPlan.title}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
({formatDuration(elapsed)} · ↓ {formatTokens(totalTokens)} tokens)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Progress bar */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color="cyan">{progressBar}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Task list */}
|
||||
{/* Task list with tree connectors */}
|
||||
<Box flexDirection="column">
|
||||
{currentPlan.items.map((item, index) => {
|
||||
const icon = STATUS_ICONS[item.status];
|
||||
const color = STATUS_COLORS[item.status];
|
||||
const isActive = item.status === "in_progress";
|
||||
{displayTasks.map((item, index) => (
|
||||
<TaskItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isLast={index === displayTasks.length - 1 && hiddenCompletedCount === 0}
|
||||
/>
|
||||
))}
|
||||
|
||||
return (
|
||||
<Box key={item.id} flexDirection="column">
|
||||
<Box>
|
||||
<Text color={color}>{icon} </Text>
|
||||
<Text
|
||||
color={isActive ? "white" : color}
|
||||
bold={isActive}
|
||||
dimColor={item.status === "completed"}
|
||||
>
|
||||
{index + 1}. {item.title}
|
||||
</Text>
|
||||
</Box>
|
||||
{item.description && isActive && (
|
||||
<Box marginLeft={3}>
|
||||
<Text dimColor>{item.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{/* Hidden completed tasks summary */}
|
||||
{hiddenCompletedCount > 0 && (
|
||||
<Box>
|
||||
<Text dimColor>└─ ... +{hiddenCompletedCount} completed</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer hint */}
|
||||
<Box marginTop={1}>
|
||||
{/* Footer with progress */}
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Text dimColor>
|
||||
{completed}/{total} tasks
|
||||
</Text>
|
||||
<Text dimColor>Ctrl+T to hide</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
150
src/tui/components/VimStatusLine.tsx
Normal file
150
src/tui/components/VimStatusLine.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* VimStatusLine Component
|
||||
*
|
||||
* Displays the current vim mode and hints
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { useVimStore } from "@tui/hooks/useVimStore";
|
||||
import { useThemeColors } from "@tui/hooks/useThemeStore";
|
||||
import {
|
||||
VIM_MODE_LABELS,
|
||||
VIM_MODE_COLORS,
|
||||
VIM_MODE_HINTS,
|
||||
} from "@constants/vim";
|
||||
import type { VimMode } from "@/types/vim";
|
||||
|
||||
/**
|
||||
* Props for VimStatusLine
|
||||
*/
|
||||
interface VimStatusLineProps {
|
||||
/** Whether to show hints */
|
||||
showHints?: boolean;
|
||||
/** Whether to show command buffer in command mode */
|
||||
showCommandBuffer?: boolean;
|
||||
/** Whether to show search pattern */
|
||||
showSearchPattern?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mode display color
|
||||
*/
|
||||
const getModeColor = (mode: VimMode): string => {
|
||||
return VIM_MODE_COLORS[mode] || "white";
|
||||
};
|
||||
|
||||
/**
|
||||
* VimStatusLine Component
|
||||
*/
|
||||
export const VimStatusLine: React.FC<VimStatusLineProps> = ({
|
||||
showHints = true,
|
||||
showCommandBuffer = true,
|
||||
showSearchPattern = true,
|
||||
}) => {
|
||||
const enabled = useVimStore((state) => state.enabled);
|
||||
const mode = useVimStore((state) => state.mode);
|
||||
const commandBuffer = useVimStore((state) => state.commandBuffer);
|
||||
const searchPattern = useVimStore((state) => state.searchPattern);
|
||||
const searchMatches = useVimStore((state) => state.searchMatches);
|
||||
const currentMatchIndex = useVimStore((state) => state.currentMatchIndex);
|
||||
const colors = useThemeColors();
|
||||
|
||||
// Don't render if vim mode is disabled
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modeLabel = VIM_MODE_LABELS[mode];
|
||||
const modeColor = getModeColor(mode);
|
||||
const modeHint = VIM_MODE_HINTS[mode];
|
||||
|
||||
// Build status content
|
||||
const renderModeIndicator = () => (
|
||||
<Box marginRight={1}>
|
||||
<Text backgroundColor={modeColor} color="black" bold>
|
||||
{` ${modeLabel} `}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderCommandBuffer = () => {
|
||||
if (!showCommandBuffer || mode !== "command" || !commandBuffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box marginRight={1}>
|
||||
<Text color={colors.primary}>:</Text>
|
||||
<Text color={colors.text}>{commandBuffer.slice(1)}</Text>
|
||||
<Text color={colors.primary}>_</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSearchInfo = () => {
|
||||
if (!showSearchPattern || !searchPattern) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchInfo =
|
||||
searchMatches.length > 0
|
||||
? ` [${currentMatchIndex + 1}/${searchMatches.length}]`
|
||||
: " [no matches]";
|
||||
|
||||
return (
|
||||
<Box marginRight={1}>
|
||||
<Text color={colors.textDim}>
|
||||
/{searchPattern}
|
||||
{matchInfo}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderHints = () => {
|
||||
if (!showHints || mode === "command") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>{modeHint}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box flexDirection="row">
|
||||
{renderModeIndicator()}
|
||||
{renderCommandBuffer()}
|
||||
{renderSearchInfo()}
|
||||
</Box>
|
||||
{renderHints()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact vim mode indicator (just the mode label)
|
||||
*/
|
||||
export const VimModeIndicator: React.FC = () => {
|
||||
const enabled = useVimStore((state) => state.enabled);
|
||||
const mode = useVimStore((state) => state.mode);
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modeLabel = VIM_MODE_LABELS[mode];
|
||||
const modeColor = getModeColor(mode);
|
||||
|
||||
return (
|
||||
<Text backgroundColor={modeColor} color="black">
|
||||
{` ${modeLabel} `}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default VimStatusLine;
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { useThemeColors } from "@stores/theme-store";
|
||||
import { useThemeColors } from "@tui/hooks/useThemeStore";
|
||||
import { MCP_INDICATORS } from "@constants/home-screen";
|
||||
import type { HomeFooterProps } from "@types/home-screen";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { useThemeColors } from "@stores/theme-store";
|
||||
import { useThemeColors } from "@tui/hooks/useThemeStore";
|
||||
import { PLACEHOLDERS } from "@constants/home-screen";
|
||||
|
||||
interface PromptBoxProps {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { useThemeColors } from "@stores/theme-store";
|
||||
import { useThemeColors } from "@tui/hooks/useThemeStore";
|
||||
import type { SessionHeaderProps } from "@types/home-screen";
|
||||
|
||||
const formatCost = (cost: number): string => {
|
||||
|
||||
@@ -16,12 +16,17 @@ export { ThemeSelect } from "@tui/components/ThemeSelect";
|
||||
export { MCPSelect } from "@tui/components/MCPSelect";
|
||||
export { TodoPanel } from "@tui/components/TodoPanel";
|
||||
export { LearningModal } from "@tui/components/LearningModal";
|
||||
export { ImageAttachment, ImageAttachmentCompact } from "@tui/components/ImageAttachment";
|
||||
export { BouncingLoader } from "@tui/components/BouncingLoader";
|
||||
export {
|
||||
DiffView,
|
||||
parseDiffOutput,
|
||||
isDiffContent,
|
||||
} from "@tui/components/DiffView";
|
||||
export {
|
||||
VimStatusLine,
|
||||
VimModeIndicator,
|
||||
} from "@tui/components/VimStatusLine";
|
||||
|
||||
// Home screen components
|
||||
export {
|
||||
|
||||
43
src/tui/hooks/useThemeStore.ts
Normal file
43
src/tui/hooks/useThemeStore.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* useThemeStore React Hook
|
||||
*
|
||||
* React hook for accessing theme store state
|
||||
*/
|
||||
|
||||
import { useStore } from "zustand";
|
||||
import {
|
||||
themeStoreVanilla,
|
||||
themeActions,
|
||||
type ThemeState,
|
||||
} from "@stores/theme-store";
|
||||
import type { ThemeColors } from "@/types/theme";
|
||||
|
||||
/**
|
||||
* Theme store with actions for React components
|
||||
*/
|
||||
interface ThemeStoreWithActions extends ThemeState {
|
||||
setTheme: (themeName: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for theme store
|
||||
*/
|
||||
export const useThemeStore = <T>(
|
||||
selector: (state: ThemeStoreWithActions) => T,
|
||||
): T => {
|
||||
const state = useStore(themeStoreVanilla, (s) => s);
|
||||
const stateWithActions: ThemeStoreWithActions = {
|
||||
...state,
|
||||
setTheme: themeActions.setTheme,
|
||||
};
|
||||
return selector(stateWithActions);
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook for theme colors only
|
||||
*/
|
||||
export const useThemeColors = (): ThemeColors => {
|
||||
return useStore(themeStoreVanilla, (state) => state.colors);
|
||||
};
|
||||
|
||||
export default useThemeStore;
|
||||
35
src/tui/hooks/useTodoStore.ts
Normal file
35
src/tui/hooks/useTodoStore.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* useTodoStore React Hook
|
||||
*
|
||||
* React hook for accessing todo store state
|
||||
*/
|
||||
|
||||
import { useStore } from "zustand";
|
||||
import {
|
||||
todoStoreVanilla,
|
||||
todoStore,
|
||||
type TodoState,
|
||||
} from "@stores/todo-store";
|
||||
|
||||
/**
|
||||
* Todo store with actions for React components
|
||||
*/
|
||||
interface TodoStoreWithActions extends TodoState {
|
||||
getProgress: () => { completed: number; total: number; percentage: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for todo store
|
||||
*/
|
||||
export const useTodoStore = <T>(
|
||||
selector: (state: TodoStoreWithActions) => T,
|
||||
): T => {
|
||||
const state = useStore(todoStoreVanilla, (s) => s);
|
||||
const stateWithActions: TodoStoreWithActions = {
|
||||
...state,
|
||||
getProgress: todoStore.getProgress,
|
||||
};
|
||||
return selector(stateWithActions);
|
||||
};
|
||||
|
||||
export default useTodoStore;
|
||||
359
src/tui/hooks/useVimMode.ts
Normal file
359
src/tui/hooks/useVimMode.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* useVimMode Hook
|
||||
*
|
||||
* React hook for vim mode keyboard handling in the TUI
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useInput } from "ink";
|
||||
import type { Key } from "ink";
|
||||
import type { VimMode, VimAction, VimKeyEventResult } from "@/types/vim";
|
||||
import { useVimStore, vimActions } from "@stores/vim-store";
|
||||
import {
|
||||
VIM_DEFAULT_BINDINGS,
|
||||
VIM_SCROLL_AMOUNTS,
|
||||
ESCAPE_KEYS,
|
||||
VIM_COMMANDS,
|
||||
} from "@constants/vim";
|
||||
|
||||
/**
|
||||
* Options for useVimMode hook
|
||||
*/
|
||||
interface UseVimModeOptions {
|
||||
/** Whether the hook is active */
|
||||
isActive?: boolean;
|
||||
/** Callback when scrolling up */
|
||||
onScrollUp?: (lines: number) => void;
|
||||
/** Callback when scrolling down */
|
||||
onScrollDown?: (lines: number) => void;
|
||||
/** Callback when going to top */
|
||||
onGoToTop?: () => void;
|
||||
/** Callback when going to bottom */
|
||||
onGoToBottom?: () => void;
|
||||
/** Callback when entering insert mode */
|
||||
onEnterInsert?: () => void;
|
||||
/** Callback when executing a command */
|
||||
onCommand?: (command: string) => void;
|
||||
/** Callback when search pattern changes */
|
||||
onSearch?: (pattern: string) => void;
|
||||
/** Callback when going to next search match */
|
||||
onSearchNext?: () => void;
|
||||
/** Callback when going to previous search match */
|
||||
onSearchPrev?: () => void;
|
||||
/** Callback for quit command */
|
||||
onQuit?: () => void;
|
||||
/** Callback for write (save) command */
|
||||
onWrite?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a vim command string
|
||||
*/
|
||||
const parseCommand = (
|
||||
command: string
|
||||
): { name: string; args: string[] } | null => {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
const name = parts[0] || "";
|
||||
const args = parts.slice(1);
|
||||
|
||||
return { name, args };
|
||||
};
|
||||
|
||||
/**
|
||||
* Find matching key binding
|
||||
*/
|
||||
const findBinding = (
|
||||
key: string,
|
||||
mode: VimMode,
|
||||
ctrl: boolean,
|
||||
shift: boolean
|
||||
) => {
|
||||
return VIM_DEFAULT_BINDINGS.find((binding) => {
|
||||
if (binding.mode !== mode) return false;
|
||||
if (binding.key.toLowerCase() !== key.toLowerCase()) return false;
|
||||
if (binding.ctrl && !ctrl) return false;
|
||||
if (binding.shift && !shift) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if key is escape
|
||||
*/
|
||||
const isEscape = (input: string, key: Key): boolean => {
|
||||
return key.escape || ESCAPE_KEYS.includes(input);
|
||||
};
|
||||
|
||||
/**
|
||||
* useVimMode hook
|
||||
*/
|
||||
export const useVimMode = (options: UseVimModeOptions = {}) => {
|
||||
const {
|
||||
isActive = true,
|
||||
onScrollUp,
|
||||
onScrollDown,
|
||||
onGoToTop,
|
||||
onGoToBottom,
|
||||
onEnterInsert,
|
||||
onCommand,
|
||||
onSearch,
|
||||
onSearchNext,
|
||||
onSearchPrev,
|
||||
onQuit,
|
||||
onWrite,
|
||||
} = options;
|
||||
|
||||
const mode = useVimStore((state) => state.mode);
|
||||
const enabled = useVimStore((state) => state.enabled);
|
||||
const commandBuffer = useVimStore((state) => state.commandBuffer);
|
||||
const searchPattern = useVimStore((state) => state.searchPattern);
|
||||
const config = useVimStore((state) => state.config);
|
||||
|
||||
/**
|
||||
* Handle action execution
|
||||
*/
|
||||
const executeAction = useCallback(
|
||||
(action: VimAction, argument?: string | number): void => {
|
||||
const actionHandlers: Record<VimAction, () => void> = {
|
||||
scroll_up: () => onScrollUp?.(VIM_SCROLL_AMOUNTS.LINE),
|
||||
scroll_down: () => onScrollDown?.(VIM_SCROLL_AMOUNTS.LINE),
|
||||
scroll_half_up: () => onScrollUp?.(VIM_SCROLL_AMOUNTS.HALF_PAGE),
|
||||
scroll_half_down: () => onScrollDown?.(VIM_SCROLL_AMOUNTS.HALF_PAGE),
|
||||
goto_top: () => onGoToTop?.(),
|
||||
goto_bottom: () => onGoToBottom?.(),
|
||||
enter_insert: () => {
|
||||
vimActions.setMode("insert");
|
||||
onEnterInsert?.();
|
||||
},
|
||||
enter_command: () => {
|
||||
vimActions.setMode("command");
|
||||
vimActions.clearCommandBuffer();
|
||||
},
|
||||
enter_visual: () => {
|
||||
vimActions.setMode("visual");
|
||||
},
|
||||
exit_mode: () => {
|
||||
vimActions.setMode("normal");
|
||||
vimActions.clearCommandBuffer();
|
||||
vimActions.clearSearch();
|
||||
},
|
||||
search_start: () => {
|
||||
vimActions.setMode("command");
|
||||
vimActions.setCommandBuffer("/");
|
||||
},
|
||||
search_next: () => {
|
||||
vimActions.nextMatch();
|
||||
onSearchNext?.();
|
||||
},
|
||||
search_prev: () => {
|
||||
vimActions.prevMatch();
|
||||
onSearchPrev?.();
|
||||
},
|
||||
execute_command: () => {
|
||||
const buffer = vimActions.getState().commandBuffer;
|
||||
|
||||
// Check if it's a search
|
||||
if (buffer.startsWith("/")) {
|
||||
const pattern = buffer.slice(1);
|
||||
vimActions.setSearchPattern(pattern);
|
||||
onSearch?.(pattern);
|
||||
vimActions.setMode("normal");
|
||||
vimActions.clearCommandBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseCommand(buffer);
|
||||
if (parsed) {
|
||||
const { name, args } = parsed;
|
||||
|
||||
// Handle built-in commands
|
||||
if (name === VIM_COMMANDS.QUIT || name === VIM_COMMANDS.QUIT_FORCE) {
|
||||
onQuit?.();
|
||||
} else if (name === VIM_COMMANDS.WRITE) {
|
||||
onWrite?.();
|
||||
} else if (name === VIM_COMMANDS.WRITE_QUIT) {
|
||||
onWrite?.();
|
||||
onQuit?.();
|
||||
} else if (name === VIM_COMMANDS.NOHL) {
|
||||
vimActions.clearSearch();
|
||||
} else {
|
||||
onCommand?.(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
vimActions.setMode("normal");
|
||||
vimActions.clearCommandBuffer();
|
||||
},
|
||||
cancel: () => {
|
||||
vimActions.setMode("normal");
|
||||
vimActions.clearCommandBuffer();
|
||||
},
|
||||
yank: () => {
|
||||
// Yank would copy content to register
|
||||
// Implementation depends on what content is available
|
||||
},
|
||||
paste: () => {
|
||||
// Paste from register
|
||||
// Implementation depends on context
|
||||
},
|
||||
delete: () => {
|
||||
// Delete selected content
|
||||
},
|
||||
undo: () => {
|
||||
// Undo last change
|
||||
},
|
||||
redo: () => {
|
||||
// Redo last undone change
|
||||
},
|
||||
word_forward: () => {
|
||||
// Move to next word
|
||||
},
|
||||
word_backward: () => {
|
||||
// Move to previous word
|
||||
},
|
||||
line_start: () => {
|
||||
// Move to line start
|
||||
},
|
||||
line_end: () => {
|
||||
// Move to line end
|
||||
},
|
||||
none: () => {
|
||||
// No action
|
||||
},
|
||||
};
|
||||
|
||||
const handler = actionHandlers[action];
|
||||
if (handler) {
|
||||
handler();
|
||||
}
|
||||
},
|
||||
[
|
||||
onScrollUp,
|
||||
onScrollDown,
|
||||
onGoToTop,
|
||||
onGoToBottom,
|
||||
onEnterInsert,
|
||||
onCommand,
|
||||
onSearch,
|
||||
onSearchNext,
|
||||
onSearchPrev,
|
||||
onQuit,
|
||||
onWrite,
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle key input
|
||||
*/
|
||||
const handleInput = useCallback(
|
||||
(input: string, key: Key): VimKeyEventResult => {
|
||||
// Not enabled, pass through
|
||||
if (!enabled) {
|
||||
return { handled: false, preventDefault: false };
|
||||
}
|
||||
|
||||
// Handle escape in any mode
|
||||
if (isEscape(input, key) && mode !== "normal") {
|
||||
executeAction("exit_mode");
|
||||
return { handled: true, preventDefault: true };
|
||||
}
|
||||
|
||||
// Command mode - build command buffer
|
||||
if (mode === "command") {
|
||||
if (key.return) {
|
||||
executeAction("execute_command");
|
||||
return { handled: true, preventDefault: true };
|
||||
}
|
||||
|
||||
if (key.backspace || key.delete) {
|
||||
const buffer = vimActions.getState().commandBuffer;
|
||||
if (buffer.length > 0) {
|
||||
vimActions.setCommandBuffer(buffer.slice(0, -1));
|
||||
}
|
||||
if (buffer.length <= 1) {
|
||||
executeAction("cancel");
|
||||
}
|
||||
return { handled: true, preventDefault: true };
|
||||
}
|
||||
|
||||
// Add character to command buffer
|
||||
if (input && input.length === 1) {
|
||||
vimActions.appendCommandBuffer(input);
|
||||
return { handled: true, preventDefault: true };
|
||||
}
|
||||
|
||||
return { handled: true, preventDefault: true };
|
||||
}
|
||||
|
||||
// Normal mode - check bindings
|
||||
if (mode === "normal") {
|
||||
const binding = findBinding(input, mode, key.ctrl, key.shift);
|
||||
|
||||
if (binding) {
|
||||
executeAction(binding.action, binding.argument);
|
||||
return {
|
||||
handled: true,
|
||||
action: binding.action,
|
||||
preventDefault: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle 'gg' for go to top (two-key sequence)
|
||||
// For simplicity, we handle 'g' as goto_top
|
||||
// A full implementation would track pending keys
|
||||
|
||||
return { handled: false, preventDefault: false };
|
||||
}
|
||||
|
||||
// Visual mode
|
||||
if (mode === "visual") {
|
||||
const binding = findBinding(input, mode, key.ctrl, key.shift);
|
||||
|
||||
if (binding) {
|
||||
executeAction(binding.action, binding.argument);
|
||||
return {
|
||||
handled: true,
|
||||
action: binding.action,
|
||||
preventDefault: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { handled: false, preventDefault: false };
|
||||
}
|
||||
|
||||
// Insert mode - pass through to normal input handling
|
||||
return { handled: false, preventDefault: false };
|
||||
},
|
||||
[enabled, mode, executeAction]
|
||||
);
|
||||
|
||||
// Use ink's input hook
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (!isActive || !enabled) return;
|
||||
|
||||
const result = handleInput(input, key);
|
||||
|
||||
// Result handling is done by the callbacks
|
||||
},
|
||||
{ isActive: isActive && enabled }
|
||||
);
|
||||
|
||||
return {
|
||||
mode,
|
||||
enabled,
|
||||
commandBuffer,
|
||||
searchPattern,
|
||||
config,
|
||||
handleInput,
|
||||
enable: vimActions.enable,
|
||||
disable: vimActions.disable,
|
||||
toggle: vimActions.toggle,
|
||||
setMode: vimActions.setMode,
|
||||
};
|
||||
};
|
||||
|
||||
export default useVimMode;
|
||||
18
src/tui/hooks/useVimStore.ts
Normal file
18
src/tui/hooks/useVimStore.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* useVimStore React Hook
|
||||
*
|
||||
* React hook for accessing vim store state
|
||||
*/
|
||||
|
||||
import { useStore } from "zustand";
|
||||
import { vimStore } from "@stores/vim-store";
|
||||
import type { VimStore } from "@stores/vim-store";
|
||||
|
||||
/**
|
||||
* React hook for vim store
|
||||
*/
|
||||
export const useVimStore = <T>(selector: (state: VimStore) => T): T => {
|
||||
return useStore(vimStore, selector);
|
||||
};
|
||||
|
||||
export default useVimStore;
|
||||
159
src/types/hooks.ts
Normal file
159
src/types/hooks.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Hook System Type Definitions
|
||||
*
|
||||
* Types for lifecycle hooks that can intercept tool execution
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available hook event types
|
||||
*/
|
||||
export type HookEventType =
|
||||
| "PreToolUse"
|
||||
| "PostToolUse"
|
||||
| "SessionStart"
|
||||
| "SessionEnd"
|
||||
| "UserPromptSubmit"
|
||||
| "Stop";
|
||||
|
||||
/**
|
||||
* Hook definition from configuration
|
||||
*/
|
||||
export interface HookDefinition {
|
||||
/** Event type to trigger on */
|
||||
event: HookEventType;
|
||||
/** Path to bash script (relative to project root or absolute) */
|
||||
script: string;
|
||||
/** Timeout in milliseconds (default 30000) */
|
||||
timeout?: number;
|
||||
/** Whether the hook is enabled (default true) */
|
||||
enabled?: boolean;
|
||||
/** Optional name for logging/debugging */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for hooks
|
||||
*/
|
||||
export interface HooksConfig {
|
||||
hooks: HookDefinition[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of hook execution
|
||||
*/
|
||||
export type HookResult =
|
||||
| HookResultAllow
|
||||
| HookResultWarn
|
||||
| HookResultBlock
|
||||
| HookResultModify;
|
||||
|
||||
export interface HookResultAllow {
|
||||
action: "allow";
|
||||
}
|
||||
|
||||
export interface HookResultWarn {
|
||||
action: "warn";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface HookResultBlock {
|
||||
action: "block";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface HookResultModify {
|
||||
action: "modify";
|
||||
updatedInput: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input passed to PreToolUse hooks via stdin
|
||||
*/
|
||||
export interface PreToolUseHookInput {
|
||||
sessionId: string;
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
workingDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input passed to PostToolUse hooks via stdin
|
||||
*/
|
||||
export interface PostToolUseHookInput {
|
||||
sessionId: string;
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
result: {
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
};
|
||||
workingDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input passed to SessionStart hooks via stdin
|
||||
*/
|
||||
export interface SessionStartHookInput {
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input passed to SessionEnd hooks via stdin
|
||||
*/
|
||||
export interface SessionEndHookInput {
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
duration: number;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input passed to UserPromptSubmit hooks via stdin
|
||||
*/
|
||||
export interface UserPromptSubmitHookInput {
|
||||
sessionId: string;
|
||||
prompt: string;
|
||||
workingDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input passed to Stop hooks via stdin
|
||||
*/
|
||||
export interface StopHookInput {
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
reason: "interrupt" | "complete" | "error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of all hook input types
|
||||
*/
|
||||
export type HookInput =
|
||||
| PreToolUseHookInput
|
||||
| PostToolUseHookInput
|
||||
| SessionStartHookInput
|
||||
| SessionEndHookInput
|
||||
| UserPromptSubmitHookInput
|
||||
| StopHookInput;
|
||||
|
||||
/**
|
||||
* Hook execution context
|
||||
*/
|
||||
export interface HookExecutionContext {
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
event: HookEventType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook execution error
|
||||
*/
|
||||
export interface HookExecutionError {
|
||||
hook: HookDefinition;
|
||||
error: string;
|
||||
exitCode?: number;
|
||||
}
|
||||
68
src/types/image.ts
Normal file
68
src/types/image.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Image types for multimodal message support
|
||||
*/
|
||||
|
||||
export type ImageMediaType = "image/png" | "image/jpeg" | "image/gif" | "image/webp";
|
||||
|
||||
export interface ImageContent {
|
||||
type: "image";
|
||||
mediaType: ImageMediaType;
|
||||
data: string; // base64 encoded
|
||||
source?: string; // original file path or "clipboard"
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface TextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type MessageContent = TextContent | ImageContent;
|
||||
|
||||
export interface MultimodalMessage {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string | MessageContent[];
|
||||
tool_call_id?: string;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PastedImage {
|
||||
id: string;
|
||||
mediaType: ImageMediaType;
|
||||
data: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export const isImageContent = (content: MessageContent): content is ImageContent => {
|
||||
return content.type === "image";
|
||||
};
|
||||
|
||||
export const isTextContent = (content: MessageContent): content is TextContent => {
|
||||
return content.type === "text";
|
||||
};
|
||||
|
||||
export const createTextContent = (text: string): TextContent => ({
|
||||
type: "text",
|
||||
text,
|
||||
});
|
||||
|
||||
export const createImageContent = (
|
||||
data: string,
|
||||
mediaType: ImageMediaType,
|
||||
source?: string,
|
||||
): ImageContent => ({
|
||||
type: "image",
|
||||
mediaType,
|
||||
data,
|
||||
source,
|
||||
});
|
||||
@@ -4,6 +4,23 @@
|
||||
|
||||
export type AgentType = "coder" | "tester" | "refactorer" | "documenter";
|
||||
|
||||
// Re-export image types
|
||||
export type {
|
||||
ImageMediaType,
|
||||
ImageContent,
|
||||
TextContent,
|
||||
MessageContent,
|
||||
MultimodalMessage,
|
||||
PastedImage,
|
||||
} from "@/types/image";
|
||||
|
||||
export {
|
||||
isImageContent,
|
||||
isTextContent,
|
||||
createTextContent,
|
||||
createImageContent,
|
||||
} from "@/types/image";
|
||||
|
||||
export type IntentType =
|
||||
| "ask"
|
||||
| "code"
|
||||
|
||||
177
src/types/plugin.ts
Normal file
177
src/types/plugin.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Plugin System Type Definitions
|
||||
*
|
||||
* Types for the plugin architecture
|
||||
*/
|
||||
|
||||
import type { z } from "zod";
|
||||
import type { ToolResult, ToolContext } from "@/types/tools";
|
||||
import type { HookDefinition } from "@/types/hooks";
|
||||
|
||||
/**
|
||||
* Plugin manifest structure
|
||||
*/
|
||||
export interface PluginManifest {
|
||||
/** Plugin name (must match directory name) */
|
||||
name: string;
|
||||
/** Plugin version */
|
||||
version: string;
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
/** Optional author */
|
||||
author?: string;
|
||||
/** Custom tools provided by this plugin */
|
||||
tools?: PluginToolReference[];
|
||||
/** Custom commands (slash commands) */
|
||||
commands?: PluginCommandReference[];
|
||||
/** Plugin-specific hooks */
|
||||
hooks?: PluginHookReference[];
|
||||
/** Required capabilities */
|
||||
capabilities?: PluginCapability[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference to a tool in the manifest
|
||||
*/
|
||||
export interface PluginToolReference {
|
||||
/** Tool name */
|
||||
name: string;
|
||||
/** Path to tool file (relative to plugin directory) */
|
||||
file: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference to a command in the manifest
|
||||
*/
|
||||
export interface PluginCommandReference {
|
||||
/** Command name (without leading /) */
|
||||
name: string;
|
||||
/** Path to command file (relative to plugin directory) */
|
||||
file: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference to a hook in the manifest
|
||||
*/
|
||||
export interface PluginHookReference {
|
||||
/** Hook event type */
|
||||
event: string;
|
||||
/** Path to hook script (relative to plugin directory) */
|
||||
script: string;
|
||||
/** Optional timeout */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin capabilities
|
||||
*/
|
||||
export type PluginCapability =
|
||||
| "filesystem"
|
||||
| "network"
|
||||
| "shell"
|
||||
| "mcp";
|
||||
|
||||
/**
|
||||
* Tool definition from a plugin
|
||||
*/
|
||||
export interface PluginToolDefinition<T = unknown> {
|
||||
/** Tool name */
|
||||
name: string;
|
||||
/** Tool description */
|
||||
description: string;
|
||||
/** Zod schema for parameters */
|
||||
parameters: z.ZodType<T>;
|
||||
/** Tool execution function */
|
||||
execute: (args: T, ctx: ToolContext) => Promise<ToolResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command definition from a plugin
|
||||
*/
|
||||
export interface PluginCommandDefinition {
|
||||
/** Command name (without leading /) */
|
||||
name: string;
|
||||
/** Command description */
|
||||
description: string;
|
||||
/** Command prompt template or content */
|
||||
prompt: string;
|
||||
/** Optional arguments schema */
|
||||
args?: Record<string, PluginCommandArg>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command argument definition
|
||||
*/
|
||||
export interface PluginCommandArg {
|
||||
/** Argument description */
|
||||
description: string;
|
||||
/** Whether argument is required */
|
||||
required?: boolean;
|
||||
/** Default value */
|
||||
default?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded plugin
|
||||
*/
|
||||
export interface LoadedPlugin {
|
||||
/** Plugin manifest */
|
||||
manifest: PluginManifest;
|
||||
/** Plugin directory path */
|
||||
path: string;
|
||||
/** Loaded tools */
|
||||
tools: Map<string, PluginToolDefinition>;
|
||||
/** Loaded commands */
|
||||
commands: Map<string, PluginCommandDefinition>;
|
||||
/** Loaded hooks */
|
||||
hooks: HookDefinition[];
|
||||
/** Whether plugin is enabled */
|
||||
enabled: boolean;
|
||||
/** Load error if any */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin load result
|
||||
*/
|
||||
export interface PluginLoadResult {
|
||||
success: boolean;
|
||||
plugin?: LoadedPlugin;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin registry state
|
||||
*/
|
||||
export interface PluginRegistry {
|
||||
/** All loaded plugins by name */
|
||||
plugins: Map<string, LoadedPlugin>;
|
||||
/** All plugin tools by name (prefixed with plugin name) */
|
||||
tools: Map<string, PluginToolDefinition>;
|
||||
/** All plugin commands by name */
|
||||
commands: Map<string, PluginCommandDefinition>;
|
||||
/** Whether registry is initialized */
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin tool execution context
|
||||
*/
|
||||
export interface PluginToolContext extends ToolContext {
|
||||
/** Plugin name */
|
||||
pluginName: string;
|
||||
/** Plugin directory path */
|
||||
pluginPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin discovery result
|
||||
*/
|
||||
export interface PluginDiscoveryResult {
|
||||
/** Plugin name */
|
||||
name: string;
|
||||
/** Plugin path */
|
||||
path: string;
|
||||
/** Manifest path */
|
||||
manifestPath: string;
|
||||
}
|
||||
189
src/types/session-fork.ts
Normal file
189
src/types/session-fork.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Session Fork Type Definitions
|
||||
*
|
||||
* Types for session snapshots, forks, and rewind functionality
|
||||
*/
|
||||
|
||||
import type { TodoItem } from "@/types/todo";
|
||||
|
||||
/**
|
||||
* A snapshot of session state at a point in time
|
||||
*/
|
||||
export interface SessionSnapshot {
|
||||
/** Unique snapshot ID */
|
||||
id: string;
|
||||
/** User-friendly name for the snapshot */
|
||||
name: string;
|
||||
/** Timestamp when snapshot was created */
|
||||
timestamp: number;
|
||||
/** Parent snapshot ID (null for root) */
|
||||
parentId: string | null;
|
||||
/** Session state at this point */
|
||||
state: SessionSnapshotState;
|
||||
/** Suggested commit message based on changes */
|
||||
suggestedCommitMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* State captured in a snapshot
|
||||
*/
|
||||
export interface SessionSnapshotState {
|
||||
/** Message history */
|
||||
messages: SessionMessage[];
|
||||
/** Todo items */
|
||||
todoItems: TodoItem[];
|
||||
/** Files in context */
|
||||
contextFiles: string[];
|
||||
/** Metadata */
|
||||
metadata: SessionSnapshotMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message in session history
|
||||
*/
|
||||
export interface SessionMessage {
|
||||
/** Message role */
|
||||
role: "user" | "assistant" | "system" | "tool";
|
||||
/** Message content */
|
||||
content: string;
|
||||
/** Timestamp */
|
||||
timestamp: number;
|
||||
/** Tool call ID if tool message */
|
||||
toolCallId?: string;
|
||||
/** Tool name if tool message */
|
||||
toolName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for a snapshot
|
||||
*/
|
||||
export interface SessionSnapshotMetadata {
|
||||
/** Provider used */
|
||||
provider: string;
|
||||
/** Model used */
|
||||
model: string;
|
||||
/** Agent type */
|
||||
agent: string;
|
||||
/** Working directory */
|
||||
workingDir: string;
|
||||
/** Total tokens used up to this point */
|
||||
totalTokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A session fork (branch of snapshots)
|
||||
*/
|
||||
export interface SessionFork {
|
||||
/** Unique fork ID */
|
||||
id: string;
|
||||
/** User-friendly name for the fork */
|
||||
name: string;
|
||||
/** All snapshots in this fork */
|
||||
snapshots: SessionSnapshot[];
|
||||
/** Current snapshot ID */
|
||||
currentSnapshotId: string;
|
||||
/** Parent fork ID if branched from another fork */
|
||||
parentForkId?: string;
|
||||
/** Timestamp when fork was created */
|
||||
createdAt: number;
|
||||
/** Timestamp of last update */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session fork storage file structure
|
||||
*/
|
||||
export interface SessionForkFile {
|
||||
/** Version for migration support */
|
||||
version: number;
|
||||
/** Session ID */
|
||||
sessionId: string;
|
||||
/** All forks */
|
||||
forks: SessionFork[];
|
||||
/** Current fork ID */
|
||||
currentForkId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of creating a snapshot
|
||||
*/
|
||||
export interface SnapshotCreateResult {
|
||||
success: boolean;
|
||||
snapshot?: SessionSnapshot;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of rewinding to a snapshot
|
||||
*/
|
||||
export interface RewindResult {
|
||||
success: boolean;
|
||||
snapshot?: SessionSnapshot;
|
||||
messagesRestored: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of creating a fork
|
||||
*/
|
||||
export interface ForkCreateResult {
|
||||
success: boolean;
|
||||
fork?: SessionFork;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of switching forks
|
||||
*/
|
||||
export interface ForkSwitchResult {
|
||||
success: boolean;
|
||||
fork?: SessionFork;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork summary for listing
|
||||
*/
|
||||
export interface ForkSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
snapshotCount: number;
|
||||
currentSnapshotName: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot summary for listing
|
||||
*/
|
||||
export interface SnapshotSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
timestamp: number;
|
||||
messageCount: number;
|
||||
isCurrent: boolean;
|
||||
suggestedCommitMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for snapshot creation
|
||||
*/
|
||||
export interface SnapshotOptions {
|
||||
/** Custom name for the snapshot */
|
||||
name?: string;
|
||||
/** Include todos in snapshot */
|
||||
includeTodos?: boolean;
|
||||
/** Include context files in snapshot */
|
||||
includeContextFiles?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for fork creation
|
||||
*/
|
||||
export interface ForkOptions {
|
||||
/** Custom name for the fork */
|
||||
name?: string;
|
||||
/** Snapshot to branch from (defaults to current) */
|
||||
fromSnapshot?: string;
|
||||
}
|
||||
165
src/types/vim.ts
Normal file
165
src/types/vim.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Vim Mode Type Definitions
|
||||
*
|
||||
* Types for vim-style navigation and editing
|
||||
*/
|
||||
|
||||
/**
|
||||
* Vim mode states
|
||||
*/
|
||||
export type VimMode = "normal" | "insert" | "command" | "visual";
|
||||
|
||||
/**
|
||||
* Vim command action
|
||||
*/
|
||||
export type VimAction =
|
||||
| "scroll_up"
|
||||
| "scroll_down"
|
||||
| "scroll_half_up"
|
||||
| "scroll_half_down"
|
||||
| "goto_top"
|
||||
| "goto_bottom"
|
||||
| "enter_insert"
|
||||
| "enter_command"
|
||||
| "enter_visual"
|
||||
| "exit_mode"
|
||||
| "search_start"
|
||||
| "search_next"
|
||||
| "search_prev"
|
||||
| "execute_command"
|
||||
| "cancel"
|
||||
| "yank"
|
||||
| "paste"
|
||||
| "delete"
|
||||
| "undo"
|
||||
| "redo"
|
||||
| "word_forward"
|
||||
| "word_backward"
|
||||
| "line_start"
|
||||
| "line_end"
|
||||
| "none";
|
||||
|
||||
/**
|
||||
* Key binding definition
|
||||
*/
|
||||
export interface VimKeyBinding {
|
||||
/** Key to match */
|
||||
key: string;
|
||||
/** Mode this binding applies in */
|
||||
mode: VimMode;
|
||||
/** Action to execute */
|
||||
action: VimAction;
|
||||
/** Whether ctrl modifier is required */
|
||||
ctrl?: boolean;
|
||||
/** Whether shift modifier is required */
|
||||
shift?: boolean;
|
||||
/** Optional argument for the action */
|
||||
argument?: string | number;
|
||||
/** Description for help */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vim state
|
||||
*/
|
||||
export interface VimState {
|
||||
/** Current mode */
|
||||
mode: VimMode;
|
||||
/** Whether vim mode is enabled */
|
||||
enabled: boolean;
|
||||
/** Current search pattern */
|
||||
searchPattern: string;
|
||||
/** Command buffer for : commands */
|
||||
commandBuffer: string;
|
||||
/** Cursor position for visual mode */
|
||||
visualStart: number | null;
|
||||
/** Count prefix for repeated actions */
|
||||
count: number;
|
||||
/** Pending operator (d, y, c, etc.) */
|
||||
pendingOperator: string | null;
|
||||
/** Last search direction */
|
||||
searchDirection: "forward" | "backward";
|
||||
/** Register for yank/paste */
|
||||
register: string;
|
||||
/** Search matches */
|
||||
searchMatches: VimSearchMatch[];
|
||||
/** Current search match index */
|
||||
currentMatchIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search match result
|
||||
*/
|
||||
export interface VimSearchMatch {
|
||||
/** Line number (0-indexed) */
|
||||
line: number;
|
||||
/** Column start */
|
||||
start: number;
|
||||
/** Column end */
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vim command parsed result
|
||||
*/
|
||||
export interface VimCommand {
|
||||
/** Command name */
|
||||
name: string;
|
||||
/** Command arguments */
|
||||
args: string[];
|
||||
/** Full command string */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vim command handler
|
||||
*/
|
||||
export interface VimCommandHandler {
|
||||
/** Command name or alias */
|
||||
name: string;
|
||||
/** Aliases for the command */
|
||||
aliases?: string[];
|
||||
/** Description */
|
||||
description: string;
|
||||
/** Handler function */
|
||||
execute: (args: string[]) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vim configuration
|
||||
*/
|
||||
export interface VimConfig {
|
||||
/** Whether vim mode is enabled by default */
|
||||
enabled: boolean;
|
||||
/** Whether to start in normal mode */
|
||||
startInNormalMode: boolean;
|
||||
/** Custom key bindings (override defaults) */
|
||||
customBindings?: VimKeyBinding[];
|
||||
/** Whether to show mode indicator */
|
||||
showModeIndicator: boolean;
|
||||
/** Search highlights enabled */
|
||||
searchHighlights: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vim mode change event
|
||||
*/
|
||||
export interface VimModeChangeEvent {
|
||||
previousMode: VimMode;
|
||||
newMode: VimMode;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vim key event result
|
||||
*/
|
||||
export interface VimKeyEventResult {
|
||||
/** Whether the key was handled */
|
||||
handled: boolean;
|
||||
/** Action that was executed */
|
||||
action?: VimAction;
|
||||
/** Whether to prevent default handling */
|
||||
preventDefault: boolean;
|
||||
/** Mode change if any */
|
||||
modeChange?: VimModeChangeEvent;
|
||||
}
|
||||
@@ -79,6 +79,7 @@ export const addPastedBlock = (
|
||||
newState: {
|
||||
pastedBlocks: newBlocks,
|
||||
pasteCounter: newCounter,
|
||||
pastedImages: state.pastedImages ?? [],
|
||||
},
|
||||
pastedContent,
|
||||
};
|
||||
@@ -201,9 +202,10 @@ export const normalizeLineEndings = (text: string): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears all pasted blocks
|
||||
* Clears all pasted blocks and images
|
||||
*/
|
||||
export const clearPastedBlocks = (): PasteState => ({
|
||||
pastedBlocks: new Map(),
|
||||
pasteCounter: 0,
|
||||
pastedImages: [],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user