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:
2026-01-31 22:22:04 -05:00
parent 37d4a43154
commit a3c407d89a
56 changed files with 7507 additions and 90 deletions

107
README.md
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
View 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;

View 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
View 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;

View 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"`;

View File

@@ -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 */

View File

@@ -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: [],
});

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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();

View 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);
};

View 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
View 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
View 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";

View 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
View 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;
};

View 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;
};

View 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;
};

View 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,
};

View 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,
};

View 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;
};

View 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,
};

View File

@@ -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 };

View File

@@ -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
View 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";
};

View File

@@ -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
View 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
View 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";

View 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> = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#39;": "'",
"&nbsp;": " ",
"&#x27;": "'",
"&#x2F;": "/",
};
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,
};

View 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>;

View File

@@ -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>

View 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>
);
}

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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>

View 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;

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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 => {

View File

@@ -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 {

View 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;

View 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
View 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;

View 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
View 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
View 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,
});

View File

@@ -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
View 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
View 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
View 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;
}

View File

@@ -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: [],
});