diff --git a/.gitignore b/.gitignore index 96fab4fe..0d8e8ee0 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ yarn-error.log* # Misc .DS_Store *.pem + +# Worktrees +.worktrees diff --git a/AGENTS.md b/AGENTS.md index 8e533a7c..109b4924 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,12 +4,45 @@ ## Project Overview -FreeCode is a CLI tool that drives ChatGPT (via Playwright/CDP) to assist with coding tasks. The architecture consists of: +FreeCode is a CLI tool that drives AI coding assistants (ChatGPT, Claude, Gemini) via browser automation to assist with coding tasks. The architecture uses a **thin-client model**: multiple frontends (TUI, VS Code extension) delegate all intelligence to a shared CLI backend via JSON-RPC over stdin/stdout. -- **CLI Backend** (`apps/cli/`) — Node.js/TypeScript that handles browser automation, context management, response parsing, and file application -- **TUI Frontend** (`apps/tui/`) — React + xterm.js terminal UI with layered architecture (terminal rendering + React DOM overlay) +The system uses a two-phase approach: the AI first returns which files it needs, then receives those files + prompt and returns structured file changes. -The system uses a two-phase approach: ChatGPT first returns which files it needs, then receives those files + prompt and returns structured file changes. +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TUI │ +│ (apps/tui) — pure UI shell │ +│ Uses pi-tui for terminal rendering │ +│ IPC client sends/receives JSON-RPC │ +└──────────────────────────┬──────────────────────────────────┘ + │ + │ JSON-RPC (stdin/stdout) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ CLI Backend │ +│ (apps/cli) — ALL intelligence │ +│ Browser controller, parser, tools, context engine, │ +│ agent loop, file applier │ +└──────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ AI Provider (Browser) │ +│ ChatGPT / Claude / Gemini │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ VS Code Extension │ +│ (apps/vscode) — pure UI shell │ +│ React webview + IPC client to CLI │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Key principle:** TUI and VSCode are pure presentation layers. All business logic lives in CLI. --- @@ -22,20 +55,11 @@ The system uses a two-phase approach: ChatGPT first returns which files it needs 3. **DRY** — Don't repeat yourself; extract shared logic to single sources of truth 4. **Decomposition** — Each file/module does one thing well; avoid bloated files -### React Component Guidelines +### Thin Client Principles -1. **Single responsibility per component** — A component should render one UI element or compose smaller components. If a component exceeds ~150 lines, decompose it. -2. **Colocation** — Keep component-specific hooks, utils, and types near the component that uses them -3. **Composition over prop-drilling** — Use compound components, context, or composition patterns instead of passing many props through many levels -4. **Extract when used in 2+ places** — If logic/JSX is copied, extract it -5. **Pure presentational vs smart containers** — Separate data-fetching from rendering - -### State Management - -- **Zustand stores** (`stores/`) — Global state that crosses component boundaries (chat, panels, session) -- **Local state** (`useState`) — Component-specific state that doesn't escape the component -- **Derived state** — Compute from store values, don't duplicate in store -- **Store access in non-components** — Use `store.getState()` (not hooks) for IPC, utilities, etc. +1. **Zero business logic in frontends** — TUI and VSCode do only rendering and IPC. No browser automation, no file reading, no parsing. +2. **IPC is the only bridge** — All communication between frontends and CLI goes through JSON-RPC. No shared state. +3. **CLI owns everything** — Browser controller, providers, context engine, parser, tools, agent loop all live in CLI. --- @@ -43,136 +67,205 @@ The system uses a two-phase approach: ChatGPT first returns which files it needs ``` freecode/ +├── packages/ +│ └── shared/ # Shared types + IPC protocol ONLY +│ └── src/ +│ ├── types.ts # Message, MessagePart, ToolResult, +│ │ # FileChange, ParsedResponse +│ ├── ipc/ +│ │ └── protocol.ts # JsonRpcRequest/Response, StreamResponse +│ └── index.ts +│ ├── apps/ -│ ├── cli/ # CLI backend (Node.js/TypeScript) +│ ├── cli/ # ALL intelligence lives here │ │ └── src/ -│ │ ├── index.ts # Entry point -│ │ ├── cli.ts # REPL orchestration -│ │ ├── browser/ # Playwright + CDP controller +│ │ ├── server.ts # JSON-RPC stdin/stdout server +│ │ ├── agent/ # Agent loop + session management +│ │ │ ├── loop.ts +│ │ │ └── session.ts +│ │ ├── browser/ # Playwright + CDP + provider adapters │ │ │ ├── controller.ts -│ │ │ ├── chatgpt-adapter.ts +│ │ │ ├── providers/ +│ │ │ │ ├── index.ts +│ │ │ │ ├── chatgpt.ts +│ │ │ │ └── types.ts │ │ │ └── types.ts -│ │ ├── context/ # Two-phase context engine -│ │ │ ├── engine.ts +│ │ ├── context/ # File tree + context collection +│ │ │ ├── collector.ts │ │ │ └── file-tree.ts -│ │ ├── parser/ # Format-agnostic response parser +│ │ ├── parser/ # Response parsing +│ │ │ ├── registry.ts +│ │ │ └── extractors/ +│ │ │ ├── structured.ts +│ │ │ ├── markdown.ts +│ │ │ └── json.ts +│ │ ├── tools/ # Tool definitions + execution │ │ │ ├── index.ts -│ │ │ ├── json-parser.ts -│ │ │ ├── markdown-parser.ts -│ │ │ └── types.ts -│ │ ├── applier/ # File application with diff preview +│ │ │ ├── read.ts +│ │ │ ├── write.ts +│ │ │ ├── edit.ts +│ │ │ ├── bash.ts +│ │ │ ├── grep.ts +│ │ │ ├── find.ts +│ │ │ └── glob.ts +│ │ └── applier/ # File diff + write +│ │ ├── index.ts +│ │ ├── differ.ts +│ │ └── writer.ts +│ │ +│ ├── tui/ # Pure UI shell — no business logic +│ │ └── src/ +│ │ ├── index.ts # Entry point: mounts TUI, connects IPC +│ │ ├── commands/ # TUI-specific commands (model select) │ │ │ ├── index.ts -│ │ │ ├── differ.ts -│ │ │ └── writer.ts -│ │ └── types/ # Shared types -│ └── tui/ # React TUI frontend -│ └── src/ -│ ├── app/ # Next.js app router -│ ├── components/ # UI components -│ │ ├── ChatLayout.tsx -│ │ ├── PromptInput.tsx -│ │ ├── Logo.tsx -│ │ ├── messages/ # Message rendering -│ │ │ ├── UserMessage.tsx -│ │ │ ├── AssistantMessage.tsx -│ │ │ └── parts/ # Message part renderers -│ │ │ ├── TextPart.tsx -│ │ │ ├── CodePart.tsx -│ │ │ └── ToolPart.tsx -│ │ └── ui/ # LayerStack, Toast, Dialog -│ ├── stores/ # Zustand stores -│ │ ├── chat-store.ts -│ │ ├── ui-panel-store.ts -│ │ ├── session-store.ts -│ │ └── index.ts -│ ├── ipc/ # JSON-RPC bridge to CLI -│ │ ├── bridge.ts -│ │ ├── protocol.ts -│ │ └── client.ts -│ └── hooks/ # Custom hooks -│ └── useAutoResize.ts -├── packages/ -│ └── shared/ # Shared types between apps +│ │ │ └── built-in.ts +│ │ ├── ipc/ +│ │ │ └── client.ts # JSON-RPC client to CLI +│ │ └── assets/ +│ │ └── logo.ts +│ │ +│ └── vscode/ # Pure UI shell — no business logic │ └── src/ -│ └── types.ts +│ ├── extension.ts # VS Code extension entry point +│ ├── chat/ +│ │ ├── ChatView.tsx # Main webview panel +│ │ ├── MessageList.tsx +│ │ ├── MessageInput.tsx +│ │ └── parts/ # Message part renderers +│ │ ├── TextPart.tsx +│ │ ├── CodePart.tsx +│ │ └── ToolPart.tsx +│ ├── stores/ +│ │ └── chat-store.ts # UI state only (messages, status) +│ └── ipc/ +│ └── client.ts # JSON-RPC client to CLI +│ └── docs/ └── superpowers/ - ├── specs/ # Design specifications - └── plans/ # Implementation plans + ├── specs/ # Design specifications + └── plans/ # Implementation plans ``` --- -## Component Design Patterns +## Boundary: What Lives Where + +| Concern | CLI | TUI | VSCode | +|---------|-----|-----|--------| +| Browser automation (Playwright/CDP) | ✅ | ❌ | ❌ | +| Provider adapters (ChatGPT, Claude) | ✅ | ❌ | ❌ | +| Agent loop + session management | ✅ | ❌ | ❌ | +| Context collection (file tree) | ✅ | ❌ | ❌ | +| Response parsing | ✅ | ❌ | ❌ | +| Tool execution | ✅ | ❌ | ❌ | +| File diff + writing | ✅ | ❌ | ❌ | +| TUI rendering (pi-tui) | ❌ | ✅ | ❌ | +| VS Code webview | ❌ | ❌ | ✅ | +| IPC client | ❌ | ✅ | ✅ | -### Message Parts Pattern +--- -Messages contain typed `parts`: +## IPC Protocol -```typescript -type MessagePart = - | { type: 'text'; content: string } - | { type: 'code'; language: string; content: string } - | { type: 'tool'; tool: { name: string; args: Record }; result?: string } -``` +CLI exposes a JSON-RPC 2.0 interface over stdin/stdout. Both TUI and VSCode use the same protocol. -Each part type has its own component (`TextPart`, `CodePart`, `ToolPart`). The parent `Message` component switches on type: +### Methods -```typescript -// In AssistantMessage.tsx -{message.parts.map((part, i) => { - switch (part.type) { - case 'text': return - case 'code': return - case 'tool': return - } -})} -``` - -### Store Pattern +| Method | Params | Returns | Description | +|--------|--------|---------|-------------| +| `tools.list` | — | `ToolListItem[]` | List available tools | +| `tools.call` | `{ name: string, args: Record }` | `ToolResult` | Execute a tool | +| `session.start` | `{ projectPath: string, provider?: string }` | `{ sessionId: string }` | Start a new session | +| `session.send` | `{ sessionId: string, message: string }` | `StreamResponse` (streaming) | Send a message | +| `session.stop` | `{ sessionId: string }` | `void` | Abort current turn | +| `providers.list` | — | `ProviderInfo[]` | List available AI providers | -Each store is in its own file with co-located types: +### Streaming Response ```typescript -// stores/chat-store.ts -interface ChatStore { - messages: Message[] - status: 'idle' | 'streaming' | 'error' - // ... +interface StreamResponse { + type: "text" | "code" | "tool" | "done" | "error"; + content: string; + toolName?: string; // when type === "tool" + toolArgs?: unknown; // when type === "tool" + toolResult?: string; // when type === "tool" (after execution) } -export const useChatStore = create((set) => ({ /* ... */ })) ``` -Export from `stores/index.ts` for clean imports: +--- + +## Type Sharing + +Core domain types live in `packages/shared/src/types.ts`: ```typescript -export { useChatStore, type Message, type MessagePart } from './chat-store' -``` +export interface Message { + id: string; + role: "user" | "assistant"; + parts: MessagePart[]; + timestamp: number; +} -### Hook Pattern +export type MessagePart = + | { type: "text"; content: string } + | { type: "code"; language: string; content: string } + | { type: "tool"; tool: { name: string; args: Record }; result?: string }; -Custom hooks encapsulate logic and state: +export interface ToolDef { + id: string; + description: string; + parameters: JsonSchema; +} -```typescript -// hooks/useAutoResize.ts -export function useAutoResize(options: UseAutoResizeOptions = {}) { - const textareaRef = useRef(null) - const resize = useCallback(() => { /* ... */ }, []) - return { textareaRef, resize } +export interface ToolResult { + title: string; + output: string; + metadata?: Record; +} + +export interface FileChange { + path: string; + action: "create" | "update" | "delete"; + content?: string; + diff?: string; } ``` --- +## Key Design Decisions + +### 1. Long-Running CLI Daemon + +CLI stays alive between turns, maintaining browser connection and session state. Starting a new browser + logging in per prompt is slow (5-15 seconds). A persistent connection enables sub-second response for subsequent turns. + +### 2. Two-Phase Context Collection + +Before sending a prompt, CLI first asks the LLM which files it needs, then reads only those files: + +1. Send prompt + file tree to LLM → LLM returns list of needed files +2. CLI reads those files +3. Send files + prompt to LLM → LLM returns structured response + +### 3. Format-Agnostic Parser + +Parser tries multiple strategies (structured, markdown, JSON) in chain until one succeeds. LLMs are inconsistent in output format. + +### 4. Diff Preview Before Apply + +File changes are shown as a diff to the user for approval before writing. Prevents accidental data loss. + +--- + ## File Naming Conventions | Type | Convention | Example | |------|-----------|---------| | Components | PascalCase | `ChatLayout.tsx`, `CodePart.tsx` | -| Stores | kebab-case | `chat-store.ts`, `ui-panel-store.ts` | -| Hooks | camelCase with `use` prefix | `useAutoResize.ts` | -| Utilities | camelCase | `file-tree.ts`, `differ.ts` | -| Types/Interfaces | PascalCase | `types.ts` exports `FileChange`, `ParsedResponse` | +| Stores | kebab-case | `chat-store.ts` | +| IPC client | camelCase | `ipc/client.ts` | +| Provider adapters | camelCase | `chatgpt.ts` | +| Tool implementations | camelCase | `read.ts`, `write.ts` | --- @@ -184,7 +277,8 @@ export function useAutoResize(options: UseAutoResizeOptions = {}) { - **Context layer** (`apps/cli/src/context/`) — File tree, context compilation - **Parser layer** (`apps/cli/src/parser/`) — Response parsing (JSON/markdown/tool) - **Applier layer** (`apps/cli/src/applier/`) — File writing, diff generation -- **UI components** (`apps/tui/src/components/`) — React components +- **Tools layer** (`apps/cli/src/tools/`) — Tool definitions and execution +- **UI components** (`apps/tui/src/`, `apps/vscode/src/`) — Rendering only ### 2. Check existing patterns @@ -197,35 +291,26 @@ Before adding code, verify: If a file exceeds ~150 lines, decompose: - Extract sub-components -- Move helper functions to `lib/` or `utils/` +- Move helper functions to utils - Split store logic into separate files -### 4. Testing - -- **Components** — React Testing Library -- **Stores** — Unit tests for state transitions -- **IPC** — Integration tests with mock backend -- **E2E** — Playwright for full flow - --- -## Key invariants +## Key Invariants -1. **Components are dumb** — They receive props and render UI; business logic lives in stores/hooks -2. **Stores are flat** — No nested store composition; use selectors for derived state -3. **IPC is centralized** — All TUI→backend communication goes through `ipc/client.ts` -4. **Types are shared** — Core domain types (`FileChange`, `ParsedResponse`) live in `packages/shared` -5. **DOM adapters are isolated** — ChatGPT/Claude adapters in `browser/` can be swapped without changing core logic +1. **Frontends are dumb** — TUI and VSCode only render UI and send/receive IPC. All logic is in CLI. +2. **IPC is the only bridge** — No shared state between frontends and CLI. +3. **Types are centralized** — Core domain types live in `packages/shared`. No duplicate type definitions. +4. **Providers are swappable** — ChatGPT/Claude adapters in `browser/providers/` can be swapped without changing core logic. +5. **Parser is chain-based** — Multiple extractors tried in order until one succeeds. --- -## Deferred Items (Not Yet Implemented) +## Deferred Items -- Rust TUI for richer terminal UI -- Provider adapters (Claude, Gemini) -- Context intelligence (graphify/contextcarry integration) -- VS Code extension -- Autonomous multi-step agents -- Vector DB / semantic search +- **MCP server integration** — Expose tools via Model Context Protocol +- **Storage layer** — Persistent session history across restarts +- **Claude/Gemini providers** — Additional AI provider adapters +- **Rust TUI** — Higher-fidelity terminal rendering (only if performance demands) Don't implement these unless explicitly requested. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..0e7f062f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,256 @@ +# FreeCode Agent Guide + +> How to work on this codebase — architectural principles, patterns, and practices. + +## Project Overview + +FreeCode is a CLI tool that drives AI coding assistants (ChatGPT, Claude, Gemini) via browser automation to assist with coding tasks. The architecture uses a **thin-client model**: multiple frontends (TUI, VS Code extension) delegate all intelligence to a shared CLI backend via JSON-RPC over stdin/stdout. + +The system uses a two-phase approach: the AI first returns which files it needs, then receives those files + prompt and returns structured file changes. + +--- + +## Architecture + +**TUI and VSCode are pure presentation layers. All business logic lives in CLI.** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TUI │ +│ (apps/tui) — pure UI shell │ +│ Uses pi-tui for terminal rendering │ +│ IPC client sends/receives JSON-RPC │ +└──────────────────────────┬──────────────────────────────────┘ + │ + │ JSON-RPC (stdin/stdout) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ CLI Backend │ +│ (apps/cli) — ALL intelligence │ +│ Browser controller, parser, tools, context engine, │ +│ agent loop, file applier │ +└──────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ AI Provider (Browser) │ +│ ChatGPT / Claude / Gemini │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Key principle:** TUI and VSCode are pure presentation layers. All business logic lives in CLI. + +--- + +## Project Structure + +``` +freecode/ +├── packages/ +│ └── shared/ # Shared types + IPC protocol ONLY +│ └── src/ +│ ├── types.ts # Message, MessagePart, ToolResult, +│ │ # FileChange, ParsedResponse +│ ├── ipc/ +│ │ └── protocol.ts # JsonRpcRequest/Response, StreamResponse +│ └── index.ts +│ +├── apps/ +│ ├── cli/ # ALL intelligence lives here +│ │ └── src/ +│ │ ├── server.ts # JSON-RPC stdin/stdout server +│ │ ├── agent/ # Agent loop + session management +│ │ ├── browser/ # Playwright + CDP + provider adapters +│ │ ├── context/ # File tree + context collection +│ │ ├── parser/ # Response parsing +│ │ ├── tools/ # Tool definitions + execution +│ │ └── applier/ # File diff + write +│ │ +│ ├── tui/ # Pure UI shell — no business logic +│ │ └── src/ +│ │ ├── index.ts # Entry point: mounts TUI, connects IPC +│ │ ├── commands/ # TUI-specific commands (model select) +│ │ ├── ipc/ +│ │ │ └── client.ts # JSON-RPC client to CLI +│ │ └── assets/ +│ │ +│ └── vscode/ # Pure UI shell — no business logic +│ └── src/ +│ ├── extension.ts # VS Code extension entry point +│ ├── chat/ # React webview components +│ ├── stores/ # Zustand stores (UI state only) +│ └── ipc/ +│ └── client.ts # JSON-RPC client to CLI +│ +└── docs/ + └── superpowers/ + ├── specs/ # Design specifications + └── plans/ # Implementation plans +``` + +--- + +## Boundary: What Lives Where + +| Concern | CLI | TUI | VSCode | +|---------|-----|-----|--------| +| Browser automation (Playwright/CDP) | ✅ | ❌ | ❌ | +| Provider adapters (ChatGPT, Claude) | ✅ | ❌ | ❌ | +| Agent loop + session management | ✅ | ❌ | ❌ | +| Context collection (file tree) | ✅ | ❌ | ❌ | +| Response parsing | ✅ | ❌ | ❌ | +| Tool execution | ✅ | ❌ | ❌ | +| File diff + writing | ✅ | ❌ | ❌ | +| TUI rendering (pi-tui) | ❌ | ✅ | ❌ | +| VS Code webview | ❌ | ❌ | ✅ | +| IPC client | ❌ | ✅ | ✅ | + +--- + +## IPC Protocol + +CLI exposes a JSON-RPC 2.0 interface over stdin/stdout. Both TUI and VSCode use the same protocol. + +### Methods + +| Method | Params | Returns | Description | +|--------|--------|---------|-------------| +| `tools.list` | — | `ToolListItem[]` | List available tools | +| `tools.call` | `{ name: string, args: Record }` | `ToolResult` | Execute a tool | +| `session.start` | `{ projectPath: string, provider?: string }` | `{ sessionId: string }` | Start a new session | +| `session.send` | `{ sessionId: string, message: string }` | `StreamResponse` (streaming) | Send a message | +| `session.stop` | `{ sessionId: string }` | `void` | Abort current turn | +| `providers.list` | — | `ProviderInfo[]` | List available AI providers | + +### Streaming Response + +```typescript +interface StreamResponse { + type: "text" | "code" | "tool" | "done" | "error"; + content: string; + toolName?: string; // when type === "tool" + toolArgs?: unknown; // when type === "tool" + toolResult?: string; // when type === "tool" (after execution) +} +``` + +--- + +## Type Sharing + +Core domain types live in `packages/shared/src/types.ts`. No duplicate type definitions in frontends. + +```typescript +export interface Message { + id: string; + role: "user" | "assistant"; + parts: MessagePart[]; + timestamp: number; +} + +export type MessagePart = + | { type: "text"; content: string } + | { type: "code"; language: string; content: string } + | { type: "tool"; tool: { name: string; args: Record }; result?: string }; +``` + +--- + +## Architectural Principles + +### Core Design Principles + +1. **SOLID** — Single responsibility, Open-closed, Liskov substitution, Interface segregation, Dependency inversion +2. **YAGNI** — Only implement what's needed now; avoid speculative generalization +3. **DRY** — Don't repeat yourself; extract shared logic to single sources of truth +4. **Decomposition** — Each file/module does one thing well; avoid bloated files + +### Thin Client Principles + +1. **Zero business logic in frontends** — TUI and VSCode do only rendering and IPC. No browser automation, no file reading, no parsing. +2. **IPC is the only bridge** — All communication between frontends and CLI goes through JSON-RPC. No shared state. +3. **CLI owns everything** — Browser controller, providers, context engine, parser, tools, agent loop all live in CLI. + +--- + +## Key Design Decisions + +### 1. Long-Running CLI Daemon + +CLI stays alive between turns, maintaining browser connection and session state. Starting a new browser + logging in per prompt is slow (5-15 seconds). A persistent connection enables sub-second response for subsequent turns. + +### 2. Two-Phase Context Collection + +Before sending a prompt, CLI first asks the LLM which files it needs, then reads only those files: +1. Send prompt + file tree to LLM → LLM returns list of needed files +2. CLI reads those files +3. Send files + prompt to LLM → LLM returns structured response + +### 3. Format-Agnostic Parser + +Parser tries multiple strategies (structured, markdown, JSON) in chain until one succeeds. LLMs are inconsistent in output format. + +### 4. Diff Preview Before Apply + +File changes are shown as a diff to the user for approval before writing. Prevents accidental data loss. + +--- + +## File Naming Conventions + +| Type | Convention | Example | +|------|-----------|---------| +| Components | PascalCase | `ChatLayout.tsx`, `CodePart.tsx` | +| Stores | kebab-case | `chat-store.ts` | +| IPC client | camelCase | `ipc/client.ts` | +| Provider adapters | camelCase | `chatgpt.ts` | +| Tool implementations | camelCase | `read.ts`, `write.ts` | +| Parser extractors | camelCase | `structured.ts`, `markdown.ts` | + +--- + +## Adding New Features + +### 1. Identify the domain + +- **Browser layer** (`apps/cli/src/browser/`) — Playwright/CDP, DOM adapters +- **Context layer** (`apps/cli/src/context/`) — File tree, context compilation +- **Parser layer** (`apps/cli/src/parser/`) — Response parsing (JSON/markdown/tool) +- **Applier layer** (`apps/cli/src/applier/`) — File writing, diff generation +- **Tools layer** (`apps/cli/src/tools/`) — Tool definitions and execution +- **UI components** (`apps/tui/src/`, `apps/vscode/src/`) — Rendering only + +### 2. Check existing patterns + +Before adding code, verify: +- Does a similar pattern exist? Follow it. +- Is this functionality needed in more than one place? Extract to shared. +- Does this component do more than one thing? Decompose. + +### 3. File limits + +If a file exceeds ~150 lines, decompose: +- Extract sub-components +- Move helper functions to utils +- Split store logic into separate files + +--- + +## Key Invariants + +1. **Frontends are dumb** — TUI and VSCode only render UI and send/receive IPC. All logic is in CLI. +2. **IPC is the only bridge** — No shared state between frontends and CLI. +3. **Types are centralized** — Core domain types live in `packages/shared`. No duplicate type definitions. +4. **Providers are swappable** — ChatGPT/Claude adapters in `browser/providers/` can be swapped without changing core logic. +5. **Parser is chain-based** — Multiple extractors tried in order until one succeeds. + +--- + +## Deferred Items + +- **MCP server integration** — Expose tools via Model Context Protocol +- **Storage layer** — Persistent session history across restarts +- **Claude/Gemini providers** — Additional AI provider adapters +- **Rust TUI** — Higher-fidelity terminal rendering (only if performance demands) + +Don't implement these unless explicitly requested. \ No newline at end of file diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 00000000..c2733cd5 --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,21 @@ +{ + "name": "@freecode/cli", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "scripts": { + "dev": "tsx src/index.ts", + "build": "tsc", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@freecode/shared": "workspace:*", + "playwright": "^1.42.0" + }, + "devDependencies": { + "@types/node": "^22.15.3", + "tsx": "^4.0.0", + "typescript": "^5.7.0" + } +} \ No newline at end of file diff --git a/apps/cli/src/agent/index.ts b/apps/cli/src/agent/index.ts new file mode 100644 index 00000000..a2ef2c42 --- /dev/null +++ b/apps/cli/src/agent/index.ts @@ -0,0 +1,6 @@ +// ============================================================================= +// Agent Module +// ============================================================================= + +export { executePromptCycle } from './loop.js'; +export type { AgentResult, StreamCallback, StreamEvent, ExecutorOptions } from './types.js'; \ No newline at end of file diff --git a/apps/cli/src/agent/loop.ts b/apps/cli/src/agent/loop.ts new file mode 100644 index 00000000..fc819df6 --- /dev/null +++ b/apps/cli/src/agent/loop.ts @@ -0,0 +1,138 @@ +// ============================================================================= +// Agent Loop +// Orchestrates a single agent turn: context → prompt → parse → apply +// ============================================================================= + +import { PlaywrightBrowserController } from '../browser/controller.js'; +import { createDefaultProviders, getProvider } from '../browser/providers/index.js'; +import { collectContext } from '../context/collector.js'; +import { createDefaultStrategies } from '../context/strategies/index.js'; +import { parse } from '../parser/index.js'; +import { applyChanges } from '../applier/index.js'; +import { logger } from '../utils/logger.js'; +import type { StreamCallback, AgentResult, StreamEvent } from './types.js'; + +createDefaultProviders(); +createDefaultStrategies(); + +export interface ExecutorOptions { + prompt: string; + provider: string; + projectPath: string; + contextOptions?: { + maxDepth?: number; + ignorePatterns?: string[]; + }; +} + +export async function executePromptCycle( + options: ExecutorOptions, + onStream?: StreamCallback +): Promise { + const { prompt, provider, projectPath, contextOptions } = options; + const errors: string[] = []; + + const providerDef = getProvider(provider); + if (!providerDef) { + return { success: false, filesCreated: 0, errors: [`Unknown provider: ${provider}`] }; + } + + const controller = new PlaywrightBrowserController(); + + const emit = (event: StreamEvent) => { + onStream?.(event); + }; + + try { + emit({ type: 'status', content: 'Connecting to browser...' }); + await controller.connect(); + emit({ type: 'status', content: 'Browser connected' }); + + emit({ type: 'status', content: `Loading ${providerDef.name}...` }); + await controller.navigate(providerDef); + emit({ type: 'status', content: `${providerDef.name} loaded` }); + + emit({ type: 'status', content: 'Collecting project context...' }); + const contextResult = await collectContext(projectPath, 'file-tree', contextOptions); + + if (!contextResult.success) { + errors.push(`Context collection failed: ${contextResult.error}`); + return { success: false, filesCreated: 0, errors }; + } + + const context = contextResult.value; + + const fullPrompt = `Project: ${context.name} +Path: ${context.projectPath} + +File tree: +${context.tree} + +Task: ${prompt} + +IMPORTANT: Respond with file operations in this EXACT format: +FILE: +\`\`\` + +\`\`\` + +Create or modify files as needed to complete the task.`; + + emit({ type: 'status', content: 'Sending to AI...' }); + await controller.sendPrompt(fullPrompt); + emit({ type: 'status', content: 'Waiting for response...' }); + + const response = await controller.waitForResponse(); + emit({ type: 'status', content: 'Response received' }); + + logger.info('Raw response length', { length: response.length }); + + if (response.length < 50) { + errors.push(`Response too short (${response.length} chars): ${response}`); + emit({ type: 'error', content: `Response too short: ${response}` }); + return { success: false, filesCreated: 0, errors }; + } + + const parseResult = parse(response); + + if (!parseResult.success) { + errors.push(`Parse failed: ${parseResult.error}`); + emit({ type: 'error', content: 'Could not parse response' }); + emit({ type: 'text', content: response.slice(0, 500) + '...' }); + return { success: false, filesCreated: 0, errors }; + } + + const parsedResponse = parseResult.response!; + emit({ type: 'status', content: `Summary: ${parsedResponse.summary}` }); + + const fileChanges = parsedResponse.changes; + emit({ type: 'status', content: `Applying ${fileChanges.length} file(s)...` }); + + const applyResults = await applyChanges(fileChanges, projectPath); + const succeeded = applyResults.filter(r => r.success).length; + const failed = applyResults.filter(r => !r.success); + + if (failed.length > 0) { + failed.forEach(f => { + if (f.error) errors.push(f.error); + }); + } + + emit({ type: 'done', content: `Done! Created ${succeeded}/${fileChanges.length} files` }); + + return { + success: errors.length === 0, + summary: parsedResponse.summary, + filesCreated: succeeded, + errors, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('Executor failed', { error: errorMessage }); + errors.push(errorMessage); + emit({ type: 'error', content: errorMessage }); + return { success: false, filesCreated: 0, errors }; + } finally { + await controller.disconnect(); + } +} \ No newline at end of file diff --git a/apps/cli/src/agent/types.ts b/apps/cli/src/agent/types.ts new file mode 100644 index 00000000..4d31d71e --- /dev/null +++ b/apps/cli/src/agent/types.ts @@ -0,0 +1,40 @@ +// ============================================================================= +// Agent Types +// ============================================================================= + +import type { FileChange } from '../parser/types.js'; + +export interface AgentConfig { + projectPath: string; + provider: string; +} + +export interface ExecutorOptions { + prompt: string; + provider: string; + projectPath: string; + contextOptions?: { + maxDepth?: number; + ignorePatterns?: string[]; + }; +} + +export interface AgentResult { + success: boolean; + summary?: string; + filesCreated: number; + errors: string[]; +} + +export interface StreamCallback { + (event: StreamEvent): void; +} + +export type StreamEvent = + | { type: 'status'; content: string } + | { type: 'text'; content: string } + | { type: 'code'; content: string; language?: string } + | { type: 'tool'; toolName: string; args: unknown } + | { type: 'tool_result'; toolName: string; result: string } + | { type: 'done'; content: string } + | { type: 'error'; content: string }; \ No newline at end of file diff --git a/apps/cli/src/applier/index.ts b/apps/cli/src/applier/index.ts new file mode 100644 index 00000000..f9b1f16f --- /dev/null +++ b/apps/cli/src/applier/index.ts @@ -0,0 +1,63 @@ +// ============================================================================= +// File Applicator +// Applies file changes to disk with diff preview +// ============================================================================= + +import * as fs from 'fs'; +import * as path from 'path'; +import { logger } from '../utils/logger.js'; +import { ok, err, type Result } from '../utils/result.js'; +import type { FileChange } from '../parser/types.js'; + +export interface ApplyResult { + path: string; + success: boolean; + error?: string; +} + +export async function applyFileChange( + change: FileChange, + basePath: string +): Promise> { + const fullPath = change.path.startsWith('/') ? change.path : `${basePath}/${change.path}`; + + try { + if (change.action === 'delete') { + if (fs.existsSync(fullPath)) { + fs.unlinkSync(fullPath); + logger.info('File deleted', { path: change.path }); + } + return ok({ path: change.path, success: true }); + } + + if (change.content !== undefined) { + const dir = path.dirname(fullPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(fullPath, change.content, 'utf-8'); + logger.info('File written', { path: change.path, size: change.content.length }); + return ok({ path: change.path, success: true }); + } + + return err('No content provided for write/create'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('Failed to apply change', { path: change.path, error: errorMessage }); + return err(`Failed to ${change.action} ${change.path}: ${errorMessage}`); + } +} + +export async function applyChanges( + changes: FileChange[], + basePath: string +): Promise { + const results: ApplyResult[] = []; + + for (const change of changes) { + const result = await applyFileChange(change, basePath); + results.push(result.success ? result.value : { path: change.path, success: false, error: result.error }); + } + + return results; +} \ No newline at end of file diff --git a/apps/cli/src/browser/controller.ts b/apps/cli/src/browser/controller.ts new file mode 100644 index 00000000..73d05077 --- /dev/null +++ b/apps/cli/src/browser/controller.ts @@ -0,0 +1,125 @@ +// ============================================================================= +// Playwright Browser Controller +// ============================================================================= + +import { chromium, type Browser, type Page } from 'playwright'; +import type { BrowserController, BrowserConfig } from './types.js'; +import type { PageAdapter, ProviderDefinition } from './providers/index.js'; +import { logger } from '../utils/logger.js'; + +export class PlaywrightBrowserController implements BrowserController { + private browser: Browser | null = null; + private page: Page | null = null; + private adapter: PageAdapter | null = null; + private config: Required; + + constructor(config: BrowserConfig = {}) { + this.config = { + cdpUrl: config.cdpUrl || process.env.CDP_URL || 'http://localhost:9222', + headless: config.headless ?? false, + }; + } + + setAdapter(adapter: PageAdapter): void { + this.adapter = adapter; + } + + async connect(): Promise { + try { + logger.info('Connecting to Chrome via CDP', { url: this.config.cdpUrl }); + this.browser = await chromium.connectOverCDP(this.config.cdpUrl); + const context = this.browser.contexts()[0]; + this.page = context.pages()[0] || await context.newPage(); + logger.info('Browser connected successfully'); + } catch (error) { + logger.error('Failed to connect to Chrome', { error: String(error) }); + throw new Error( + `Failed to connect to Chrome at ${this.config.cdpUrl}. ` + + 'Ensure Chrome is running with: chrome --remote-debugging-port=9222' + ); + } + } + + async disconnect(): Promise { + if (this.browser) { + logger.info('Disconnecting browser'); + await this.browser.close(); + this.browser = null; + this.page = null; + } + } + + isConnected(): boolean { + return this.browser !== null && this.page !== null; + } + + getPage(): Page | null { + return this.page; + } + + async navigate(provider: ProviderDefinition): Promise { + if (!this.page) throw new Error('Not connected'); + await this.page.goto(provider.config.url); + await provider.adapter.waitForLoadState(this.page); + this.adapter = provider.adapter; + } + + async sendPrompt(prompt: string): Promise { + if (!this.page || !this.adapter) { + throw new Error('Not connected or adapter not set'); + } + + if (this.adapter.waitForInput) { + await this.adapter.waitForInput(this.page); + } + + const input = this.adapter.getInputLocator(this.page); + await input.fill(prompt); + const submitButton = this.adapter.getSubmitButton(this.page); + await submitButton.click(); + } + + async waitForResponse(): Promise { + if (!this.page || !this.adapter) { + throw new Error('Not connected or adapter not set'); + } + + const responseLocator = this.adapter.getResponseLocator(this.page); + + // Wait for streaming to finish + let streaming = await this.adapter.isStreaming(this.page); + while (streaming) { + logger.debug('Streaming in progress...'); + await this.page.waitForTimeout(1000); + streaming = await this.adapter.isStreaming(this.page); + } + + // Wait for response to appear and have content + logger.debug('Waiting for response to have content'); + try { + await responseLocator.last().waitFor({ state: 'visible', timeout: 60000 }); + // Wait a bit for content to populate + await this.page.waitForTimeout(3000); + + // Try multiple times to get text + let text = ''; + for (let i = 0; i < 5; i++) { + text = await responseLocator.last().innerText({ timeout: 5000 }).catch(() => ''); + if (text.length > 0) break; + logger.debug('Retrying get text', { attempt: i + 1 }); + await this.page.waitForTimeout(1000); + } + + logger.debug('Response received', { length: text.length }); + return text; + } catch (e) { + logger.error('Timeout waiting for response content'); + throw new Error('Timeout waiting for ChatGPT response content'); + } + } + + async executePrompt(prompt: string): Promise { + await this.sendPrompt(prompt); + return this.waitForResponse(); + } +} \ No newline at end of file diff --git a/apps/cli/src/browser/providers/chatgpt.ts b/apps/cli/src/browser/providers/chatgpt.ts new file mode 100644 index 00000000..12804356 --- /dev/null +++ b/apps/cli/src/browser/providers/chatgpt.ts @@ -0,0 +1,35 @@ +// ============================================================================= +// ChatGPT Provider Adapter +// ============================================================================= + +import type { Page, Locator } from 'playwright'; +import type { PageAdapter } from './types.js'; + +export class ChatGPTAdapter implements PageAdapter { + name = 'chatgpt'; + + getInputLocator(page: Page): Locator { + return page.getByRole('textbox', { name: 'Chat with ChatGPT' }).first(); + } + + async waitForInput(page: Page): Promise { + await page.getByRole('textbox', { name: 'Chat with ChatGPT' }).first().waitFor({ state: 'visible', timeout: 10000 }); + } + + getSubmitButton(page: Page): Locator { + return page.locator('button[data-testid="send-button"]').first(); + } + + getResponseLocator(page: Page): Locator { + return page.locator('div[data-message-author-role="assistant"]').last(); + } + + async isStreaming(page: Page): Promise { + const stopButton = page.locator('button[aria-label="Stop generating"]'); + return stopButton.isVisible().catch(() => false); + } + + async waitForLoadState(page: Page): Promise { + await page.waitForLoadState('networkidle'); + } +} \ No newline at end of file diff --git a/apps/cli/src/browser/providers/index.ts b/apps/cli/src/browser/providers/index.ts new file mode 100644 index 00000000..f28a8aa6 --- /dev/null +++ b/apps/cli/src/browser/providers/index.ts @@ -0,0 +1,40 @@ +// ============================================================================= +// Provider Registry +// ============================================================================= + +import type { PageAdapter, ProviderConfig } from './types.js'; +export type { PageAdapter, ProviderConfig } from './types.js'; + +import { ChatGPTAdapter } from './chatgpt.js'; + +export interface ProviderDefinition { + id: string; + name: string; + adapter: PageAdapter; + config: ProviderConfig; +} + +const providers: Map = new Map(); + +export function registerProvider(definition: ProviderDefinition): void { + providers.set(definition.id, definition); +} + +export function getProvider(id: string): ProviderDefinition | undefined { + return providers.get(id); +} + +export function listProviders(): ProviderDefinition[] { + return Array.from(providers.values()); +} + +export function createDefaultProviders(): void { + registerProvider({ + id: 'chatgpt', + name: 'ChatGPT', + adapter: new ChatGPTAdapter(), + config: { + url: 'https://chatgpt.com', + }, + }); +} \ No newline at end of file diff --git a/apps/cli/src/browser/providers/types.ts b/apps/cli/src/browser/providers/types.ts new file mode 100644 index 00000000..bd6552a8 --- /dev/null +++ b/apps/cli/src/browser/providers/types.ts @@ -0,0 +1,20 @@ +// ============================================================================= +// Page Adapter Interface +// ============================================================================= + +import type { Locator, Page } from 'playwright'; + +export interface PageAdapter { + name: string; + getInputLocator(page: Page): Locator; + waitForInput?(page: Page): Promise; + getSubmitButton(page: Page): Locator; + getResponseLocator(page: Page): Locator; + isStreaming(page: Page): Promise; + waitForLoadState(page: Page): Promise; +} + +export interface ProviderConfig { + url: string; + waitForNetworkIdle?: boolean; +} \ No newline at end of file diff --git a/apps/cli/src/browser/types.ts b/apps/cli/src/browser/types.ts new file mode 100644 index 00000000..a66ffde0 --- /dev/null +++ b/apps/cli/src/browser/types.ts @@ -0,0 +1,21 @@ +// ============================================================================= +// Browser Controller Interface +// ============================================================================= + +import type { Page, Browser } from 'playwright'; + +export interface BrowserController { + connect(): Promise; + disconnect(): Promise; + isConnected(): boolean; + getPage(): Page | null; + navigate(provider: unknown): Promise; + sendPrompt(prompt: string): Promise; + waitForResponse(): Promise; + executePrompt(prompt: string): Promise; +} + +export interface BrowserConfig { + cdpUrl?: string; + headless?: boolean; +} \ No newline at end of file diff --git a/apps/cli/src/context/collector.ts b/apps/cli/src/context/collector.ts new file mode 100644 index 00000000..1bbcc393 --- /dev/null +++ b/apps/cli/src/context/collector.ts @@ -0,0 +1,28 @@ +// ============================================================================= +// Context Collector +// Orchestrates context collection using registered strategies +// ============================================================================= + +import type { ProjectContext, ContextOptions } from './types.js'; +import { getStrategy } from './strategies/index.js'; +import { logger } from '../utils/logger.js'; +import { ok, err, type Result } from '../utils/result.js'; + +export async function collectContext( + projectPath: string, + strategyName = 'file-tree', + options?: ContextOptions +): Promise> { + try { + const strategy = getStrategy(strategyName); + if (!strategy) { + return err(`Unknown context strategy: ${strategyName}`); + } + + const context = await strategy.collect(projectPath, options); + return ok(context); + } catch (error) { + logger.error('Context collection failed', { error: String(error) }); + return err(`Failed to collect context: ${error instanceof Error ? error.message : String(error)}`); + } +} \ No newline at end of file diff --git a/apps/cli/src/context/strategies/file-tree.ts b/apps/cli/src/context/strategies/file-tree.ts new file mode 100644 index 00000000..03509e62 --- /dev/null +++ b/apps/cli/src/context/strategies/file-tree.ts @@ -0,0 +1,99 @@ +// ============================================================================= +// File Tree Context Strategy +// Collects file tree and file contents up to maxDepth +// ============================================================================= + +import * as fs from 'fs'; +import * as path from 'path'; +import type { ContextStrategy, ContextOptions, ProjectContext, ContextMetadata } from '../types.js'; +import { logger } from '../../utils/logger.js'; + +const DEFAULT_IGNORE = [ + 'node_modules', '.git', 'dist', 'build', '.next', '.turbo', + '.vscode', '.idea', '*.lock', '*.log', '.cache', '.temp', +]; + +export class FileTreeStrategy implements ContextStrategy { + name = 'file-tree'; + + async collect(projectPath: string, options: ContextOptions = {}): Promise { + const { + maxDepth = 3, + ignorePatterns = DEFAULT_IGNORE, + } = options; + + logger.info('Collecting project context', { projectPath, maxDepth }); + + const { tree, files } = this.gatherContext(projectPath, ignorePatterns, maxDepth); + + const metadata: ContextMetadata = { + collectedAt: Date.now(), + fileCount: Object.keys(files).length, + totalSize: Object.values(files).reduce((acc, content) => acc + content.length, 0), + }; + + logger.info('Context collected', { fileCount: metadata.fileCount }); + + return { + projectPath, + name: path.basename(projectPath), + tree, + files, + metadata, + }; + } + + private gatherContext( + dirPath: string, + patterns: string[], + maxDepth: number, + currentDepth = 0 + ): { tree: string; files: Record } { + let tree = ''; + const files: Record = {}; + + if (currentDepth > maxDepth) return { tree, files }; + + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (this.shouldIgnore(fullPath, patterns)) continue; + + const indent = currentDepth > 0 ? ' '.repeat(currentDepth) : ''; + const icon = entry.isDirectory() ? '📁 ' : '📄 '; + tree += `${indent}${icon}${entry.name}${entry.isDirectory() ? '/' : ''}\n`; + + if (entry.isFile()) { + const relativePath = path.relative(process.cwd(), fullPath); + files[relativePath] = this.readFile(fullPath); + } else if (entry.isDirectory()) { + const childContext = this.gatherContext(fullPath, patterns, maxDepth, currentDepth + 1); + tree += childContext.tree; + Object.assign(files, childContext.files); + } + } + } catch { + // Skip unreadable directories + } + + return { tree, files }; + } + + private shouldIgnore(filePath: string, patterns: string[]): boolean { + const basename = path.basename(filePath); + return patterns.some((pattern) => { + if (pattern.startsWith('*')) return basename.endsWith(pattern.slice(1)); + return basename === pattern; + }); + } + + private readFile(filePath: string): string { + try { + return fs.readFileSync(filePath, 'utf-8'); + } catch { + return `// Error reading: ${filePath}`; + } + } +} \ No newline at end of file diff --git a/apps/cli/src/context/strategies/index.ts b/apps/cli/src/context/strategies/index.ts new file mode 100644 index 00000000..7f722cef --- /dev/null +++ b/apps/cli/src/context/strategies/index.ts @@ -0,0 +1,22 @@ +// ============================================================================= +// Context Strategies Registry +// ============================================================================= + +export { FileTreeStrategy } from './file-tree.js'; + +import type { ContextStrategy } from '../types.js'; +import { FileTreeStrategy } from './file-tree.js'; + +const strategies: Map = new Map(); + +export function registerStrategy(strategy: ContextStrategy): void { + strategies.set(strategy.name, strategy); +} + +export function getStrategy(name: string): ContextStrategy | undefined { + return strategies.get(name); +} + +export function createDefaultStrategies(): void { + registerStrategy(new FileTreeStrategy()); +} \ No newline at end of file diff --git a/apps/cli/src/context/types.ts b/apps/cli/src/context/types.ts new file mode 100644 index 00000000..26819514 --- /dev/null +++ b/apps/cli/src/context/types.ts @@ -0,0 +1,28 @@ +// ============================================================================= +// Context Types +// ============================================================================= + +export interface ProjectContext { + projectPath: string; + name: string; + tree: string; + files: Record; + metadata: ContextMetadata; +} + +export interface ContextMetadata { + collectedAt: number; + fileCount: number; + totalSize: number; +} + +export interface ContextStrategy { + name: string; + collect(projectPath: string, options?: ContextOptions): Promise; +} + +export interface ContextOptions { + maxDepth?: number; + ignorePatterns?: string[]; + includePatterns?: string[]; +} \ No newline at end of file diff --git a/apps/cli/src/parser/extractors/index.ts b/apps/cli/src/parser/extractors/index.ts new file mode 100644 index 00000000..b2a11460 --- /dev/null +++ b/apps/cli/src/parser/extractors/index.ts @@ -0,0 +1,28 @@ +// ============================================================================= +// Parser Extractors Registry +// ============================================================================= + +export { StructuredExtractor } from './structured.js'; +export { MarkdownExtractor } from './markdown.js'; +export { JsonExtractor } from './json.js'; + +import type { ParserStrategy } from '../types.js'; +import { StructuredExtractor } from './structured.js'; +import { MarkdownExtractor } from './markdown.js'; +import { JsonExtractor } from './json.js'; + +const extractors: Map = new Map(); + +export function registerExtractor(extractor: ParserStrategy): void { + extractors.set(extractor.name, extractor); +} + +export function getExtractor(name: string): ParserStrategy | undefined { + return extractors.get(name); +} + +export function createDefaultExtractors(): void { + registerExtractor(new StructuredExtractor()); + registerExtractor(new MarkdownExtractor()); + registerExtractor(new JsonExtractor()); +} \ No newline at end of file diff --git a/apps/cli/src/parser/extractors/json.ts b/apps/cli/src/parser/extractors/json.ts new file mode 100644 index 00000000..8718212b --- /dev/null +++ b/apps/cli/src/parser/extractors/json.ts @@ -0,0 +1,51 @@ +// ============================================================================= +// JSON Extractor +// Matches { "changes": [{ "path": "...", "content": "..." }] } +// ============================================================================= + +import type { ParserStrategy, ParserResult, FileChange } from '../types.js'; + +export class JsonExtractor implements ParserStrategy { + name = 'json'; + + parse(raw: string): ParserResult { + // Try to find a JSON object with changes array + const jsonMatch = raw.match(/\{[\s\S]*"changes"\s*:\s*\[[\s\S]*\]/); + if (!jsonMatch) { + return { success: false, error: 'No JSON changes structure found' }; + } + + try { + const parsed = JSON.parse(jsonMatch[0]); + const changes: FileChange[] = []; + + if (Array.isArray(parsed.changes)) { + for (const item of parsed.changes) { + if (item.path) { + changes.push({ + path: item.path, + action: item.action || 'create', + content: item.content, + }); + } + } + } + + if (changes.length === 0) { + return { success: false, error: 'No valid changes found in JSON' }; + } + + return { + success: true, + response: { + summary: parsed.summary || 'Files from JSON', + changes, + raw, + parserUsed: this.name, + }, + }; + } catch { + return { success: false, error: 'Failed to parse JSON' }; + } + } +} \ No newline at end of file diff --git a/apps/cli/src/parser/extractors/markdown.ts b/apps/cli/src/parser/extractors/markdown.ts new file mode 100644 index 00000000..9cc68b48 --- /dev/null +++ b/apps/cli/src/parser/extractors/markdown.ts @@ -0,0 +1,41 @@ +// ============================================================================= +// Markdown Extractor +// Matches code blocks without FILE: prefix +// ============================================================================= + +import type { ParserStrategy, ParserResult, FileChange } from '../types.js'; + +export class MarkdownExtractor implements ParserStrategy { + name = 'markdown'; + + parse(raw: string): ParserResult { + const changes: FileChange[] = []; + + // Match code blocks with language hint (e.g. ```typescript filename.ts) + const namedCodeBlock = /```[\w]*\s*([^\n\t]+?)\n([\s\S]*?)```/g; + let match; + while ((match = namedCodeBlock.exec(raw)) !== null) { + const filename = match[1].trim(); + const content = match[2].trim(); + + // Skip if it doesn't look like a filename + if (filename && !filename.startsWith('#') && content.length > 5) { + changes.push({ path: filename, action: 'create', content }); + } + } + + if (changes.length === 0) { + return { success: false, error: 'No markdown code blocks found' }; + } + + return { + success: true, + response: { + summary: 'Files from markdown code blocks', + changes, + raw, + parserUsed: this.name, + }, + }; + } +} \ No newline at end of file diff --git a/apps/cli/src/parser/extractors/structured.ts b/apps/cli/src/parser/extractors/structured.ts new file mode 100644 index 00000000..12c8546a --- /dev/null +++ b/apps/cli/src/parser/extractors/structured.ts @@ -0,0 +1,70 @@ +// ============================================================================= +// Structured Extractor +// Matches FILE: path followed by code blocks +// ============================================================================= + +import type { ParserStrategy, ParserResult, FileChange } from '../types.js'; + +export class StructuredExtractor implements ParserStrategy { + name = 'structured'; + + parse(raw: string): ParserResult { + const changes: FileChange[] = []; + + // Pattern 1: FILE: path followed by code block ```...``` + const codeBlockPattern = /FILE:\s*(.+?)\n```[\w]*\n([\s\S]*?)```/g; + let match; + while ((match = codeBlockPattern.exec(raw)) !== null) { + const path = match[1].trim(); + const content = match[2].trim(); + if (path && content && !changes.some(c => c.path === path)) { + changes.push({ path, action: 'create', content }); + } + } + + // Pattern 2: FILE: path followed by content (no code block) + const noCodeBlockPattern = /FILE:\s*([^\n]+)\n\n([\s\S]*?)(?=\nFILE:|$)/g; + while ((match = noCodeBlockPattern.exec(raw)) !== null) { + const path = match[1].trim(); + let content = match[2]; + + // Skip if content starts with header-like text that indicates bad match + if (content.startsWith('Markdown\n') || content.startsWith('markdown\n') || + content.startsWith('Json\n') || content.startsWith('json\n')) { + content = content.replace(/^(?:Markdown|Json)\n*/i, ''); + } + + if (path && content && content.length > 5 && !changes.some(c => c.path === path)) { + changes.push({ path, action: 'create', content: content.trim() }); + } + } + + if (changes.length === 0) { + return { success: false, error: 'No structured file blocks found' }; + } + + return { + success: true, + response: { + summary: this.extractSummary(raw), + changes, + raw, + parserUsed: this.name, + }, + }; + } + + private extractSummary(raw: string): string { + const cleaned = raw + .replace(/FILE:\s*[^\n]+\n?/g, '') + .replace(/```[\s\S]*?```/g, '') + .replace(/^#+\s*/gm, '') + .trim(); + + const lines = cleaned.split('\n').filter(l => l.trim().length > 10); + if (lines.length > 0) { + return lines[0].slice(0, 200); + } + return 'Generated file structure'; + } +} \ No newline at end of file diff --git a/apps/cli/src/parser/index.ts b/apps/cli/src/parser/index.ts new file mode 100644 index 00000000..9f6085c7 --- /dev/null +++ b/apps/cli/src/parser/index.ts @@ -0,0 +1,6 @@ +// ============================================================================= +// Parser Module +// ============================================================================= + +export * from './types.js'; +export { parse, parseWithStrategy, parseWithChain, DEFAULT_PARSER_CHAIN } from './registry.js'; \ No newline at end of file diff --git a/apps/cli/src/parser/registry.ts b/apps/cli/src/parser/registry.ts new file mode 100644 index 00000000..66b96c7f --- /dev/null +++ b/apps/cli/src/parser/registry.ts @@ -0,0 +1,50 @@ +// ============================================================================= +// Parser Registry +// ============================================================================= + +import type { ParserResult } from './types.js'; +import { getExtractor, createDefaultExtractors } from './extractors/index.js'; +import { logger } from '../utils/logger.js'; + +createDefaultExtractors(); + +export interface ParserRegistryOptions { + maxAttempts?: number; +} + +export function parseWithStrategy( + raw: string, + strategyName: string +): ParserResult { + const extractor = getExtractor(strategyName); + if (!extractor) { + return { success: false, error: `Unknown strategy: ${strategyName}` }; + } + + const result = extractor.parse(raw); + if (result.success) { + logger.debug('Parsing succeeded', { strategy: strategyName }); + } + return result; +} + +export function parseWithChain(raw: string, strategies: string[]): ParserResult { + for (const strategy of strategies) { + const result = parseWithStrategy(raw, strategy); + if (result.success) { + return result; + } + } + + return { + success: false, + error: 'No parser succeeded', + }; +} + +export const DEFAULT_PARSER_CHAIN = ['structured', 'markdown', 'json']; + +export function parse(raw: string, chain = DEFAULT_PARSER_CHAIN): ParserResult { + logger.debug('Parsing response', { chain }); + return parseWithChain(raw, chain); +} \ No newline at end of file diff --git a/apps/cli/src/parser/types.ts b/apps/cli/src/parser/types.ts new file mode 100644 index 00000000..8f939c57 --- /dev/null +++ b/apps/cli/src/parser/types.ts @@ -0,0 +1,29 @@ +// ============================================================================= +// Parser Types +// ============================================================================= + +export type FileAction = 'create' | 'write' | 'delete'; + +export interface FileChange { + path: string; + action: FileAction; + content?: string; +} + +export interface ParsedResponse { + summary: string; + changes: FileChange[]; + raw: string; + parserUsed: string; +} + +export interface ParserStrategy { + name: string; + parse(raw: string): ParserResult; +} + +export interface ParserResult { + success: boolean; + response?: ParsedResponse; + error?: string; +} \ No newline at end of file diff --git a/apps/cli/src/server.ts b/apps/cli/src/server.ts new file mode 100644 index 00000000..ba498cdc --- /dev/null +++ b/apps/cli/src/server.ts @@ -0,0 +1,215 @@ +// ============================================================================= +// JSON-RPC Server — CLI Backend +// Handles tools.list, tools.call, session.start, session.send, session.stop, providers.list +// ============================================================================= + +import { getTool, listTools } from "./tools/index.js"; +import { executePromptCycle } from "./agent/loop.js"; +import { createDefaultProviders, listProviders } from "./browser/providers/index.js"; +import { logger } from "./utils/logger.js"; +import type { ToolContext } from "./tools/types.js"; +import type { JsonRpcRequest, JsonRpcResponse, SessionConfig } from "@freecode/shared"; + +// ============================================================================= +// Type Imports +// ============================================================================= + +interface ToolListItem { + id: string; + description: string; +} + +interface ToolCallResult { + title: string; + output: string; + metadata?: Record; +} + +interface SessionStartResult { + sessionId: string; +} + +interface SessionInfo { + id: string; + projectPath: string; + provider: string; +} + +// ============================================================================= +// Session Management +// ============================================================================= + +const sessions: Map = new Map(); +let sessionCounter = 0; + +function createSession(config: SessionConfig): SessionInfo { + const id = `session-${++sessionCounter}`; + const session: SessionInfo = { + id, + projectPath: config.projectPath, + provider: config.provider || "chatgpt", + }; + sessions.set(id, session); + return session; +} + +function getSession(id: string): SessionInfo | undefined { + return sessions.get(id); +} + +// ============================================================================= +// Response Helpers +// ============================================================================= + +function createResponse(id: number | string, result: unknown): JsonRpcResponse { + return { jsonrpc: "2.0", id, result }; +} + +function createError( + id: number | string, + code: number, + message: string, + data?: unknown +): JsonRpcResponse { + return { jsonrpc: "2.0", id, error: { code, message, data } }; +} + +// ============================================================================= +// Method Handlers +// ============================================================================= + +const methodHandlers: Record< + string, + (params: Record) => Promise +> = { + // Tool methods + "tools.list": async (): Promise => { + return listTools(); + }, + + "tools.call": async (params: Record): Promise => { + const { name, args } = params as { name: string; args: Record }; + const tool = getTool(name as string); + if (!tool) { + throw new Error(`Tool not found: ${name}`); + } + const ctx: ToolContext = { cwd: process.cwd() }; + return tool.execute(args, ctx); + }, + + // Session methods + "session.start": async (params: Record): Promise => { + const config = params as unknown as SessionConfig; + const session = createSession(config); + logger.info("Session started", { sessionId: session.id, provider: session.provider }); + return { sessionId: session.id }; + }, + + "session.send": async (params: Record): Promise => { + const { sessionId, message } = params as { sessionId: string; message: string }; + const session = getSession(sessionId); + + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + logger.info("Session send", { sessionId, messageLength: message.length }); + + const result = await executePromptCycle( + { + prompt: message, + provider: session.provider, + projectPath: session.projectPath, + }, + undefined // TODO: wire up streaming in future + ); + + return result; + }, + + "session.stop": async (params: Record): Promise => { + const { sessionId } = params as { sessionId: string }; + const session = getSession(sessionId); + if (session) { + sessions.delete(sessionId); + logger.info("Session stopped", { sessionId }); + } + }, + + // Provider methods + "providers.list": async (): Promise => { + return listProviders().map((p) => ({ + id: p.id, + name: p.name, + description: `AI provider via ${p.name}`, + })); + }, +}; + +// ============================================================================= +// Request Handler +// ============================================================================= + +async function handleRequest(request: JsonRpcRequest): Promise { + try { + const handler = methodHandlers[request.method]; + if (!handler) { + return createError(request.id, -32601, `Method not found: ${request.method}`); + } + const result = await handler(request.params ?? {}); + return createResponse(request.id, result); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createError(request.id, -32603, message); + } +} + +// ============================================================================= +// Main Server Loop +// ============================================================================= + +async function main() { + // Initialize providers + createDefaultProviders(); + + let buffer = ""; + + process.stdin.setEncoding("utf-8"); + + process.stdin.on("data", async (chunk: string) => { + buffer += chunk; + + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const request = JSON.parse(line) as JsonRpcRequest; + const response = await handleRequest(request); + process.stdout.write(JSON.stringify(response) + "\n"); + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + process.stderr.write(`Parse error: ${error}\n`); + } + } + }); + + process.stdin.on("end", () => { + if (buffer.trim()) { + try { + const request = JSON.parse(buffer) as JsonRpcRequest; + handleRequest(request).then((r) => { + process.stdout.write(JSON.stringify(r) + "\n"); + }); + } catch (e) { + process.stderr.write(`Final parse error: ${e}\n`); + } + } + }); +} + +main().catch((e) => { + process.stderr.write(`Server error: ${e}\n`); + process.exit(1); +}); \ No newline at end of file diff --git a/apps/cli/src/tools/index.ts b/apps/cli/src/tools/index.ts new file mode 100644 index 00000000..b76546ec --- /dev/null +++ b/apps/cli/src/tools/index.ts @@ -0,0 +1,21 @@ +import { ReadTool } from "./read" +import { WriteTool } from "./write" +import type { ToolDef } from "./types" + +export type { ToolContext, ToolResult, JsonSchema } from "./types" +export type { ToolDef } + +export const tools = { + read: ReadTool, + write: WriteTool, +} as const + +export type ToolId = keyof typeof tools + +export function getTool(id: string): ToolDef | undefined { + return tools[id as ToolId] as ToolDef | undefined +} + +export function listTools(): { id: string; description: string }[] { + return Object.values(tools).map((t) => ({ id: t.id, description: t.description })) +} \ No newline at end of file diff --git a/apps/cli/src/tools/read.ts b/apps/cli/src/tools/read.ts new file mode 100644 index 00000000..c1abcb68 --- /dev/null +++ b/apps/cli/src/tools/read.ts @@ -0,0 +1,118 @@ +import * as fs from "fs" +import * as path from "path" +import type { ToolDef, ToolContext, ToolResult } from "./types" + +interface ReadParams { + filePath: string + offset?: number + limit?: number +} + +const DEFAULT_LIMIT = 2000 +const MAX_LINE_LENGTH = 2000 +const MAX_BYTES = 50 * 1024 +const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` + +function isBinaryFile(bytes: Uint8Array): boolean { + if (bytes.length === 0) return false + let nonPrintableCount = 0 + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] === 0) return true + if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) { + nonPrintableCount++ + } + } + return nonPrintableCount / bytes.length > 0.3 +} + +function readLines( + filepath: string, + opts: { limit: number; offset: number }, +): { raw: string[]; count: number; cut: boolean; more: boolean } { + const content = fs.readFileSync(filepath, "utf-8") + const allLines = content.split("\n") + const start = opts.offset - 1 + const raw = allLines.slice(start, start + opts.limit) + const count = allLines.length + const more = start + opts.limit < count + const cut = raw.join("\n").length > MAX_BYTES || raw.length >= opts.limit + + return { raw, count, cut, more } +} + +export const ReadTool: ToolDef = { + id: "read", + description: "Read file contents", + parameters: { + type: "object", + properties: { + filePath: { description: "The absolute path to the file or directory to read" }, + offset: { description: "The line number to start reading from (1-indexed)" }, + limit: { description: "The maximum number of lines to read (defaults to 2000)" }, + }, + required: ["filePath"], + }, + execute: async (params: ReadParams, ctx: ToolContext): Promise => { + let filepath = params.filePath + if (!path.isAbsolute(filepath)) { + filepath = path.resolve(ctx.cwd, filepath) + } + + const stat = fs.statSync(filepath) + + if (stat.isDirectory()) { + const items = fs.readdirSync(filepath).sort() + const offset = params.offset || 1 + const limit = params.limit ?? DEFAULT_LIMIT + const start = offset - 1 + const sliced = items.slice(start, start + limit) + const truncated = start + sliced.length < items.length + + return { + title: path.basename(filepath), + output: [ + `${filepath}`, + `directory`, + ``, + sliced.join("\n"), + truncated + ? `\n(Showing ${sliced.length} of ${items.length} entries)` + : `\n(${items.length} entries)`, + ``, + ].join("\n"), + metadata: { truncated }, + } + } + + const sample = fs.readFileSync(filepath) + if (isBinaryFile(sample)) { + return { title: path.basename(filepath), output: `Cannot read binary file: ${filepath}` } + } + + const lines = readLines(filepath, { + limit: params.limit ?? DEFAULT_LIMIT, + offset: params.offset || 1, + }) + + let output = [`${filepath}`, `file`, "\n"].join("\n") + output += lines.raw.map((line, i) => `${i + (params.offset || 1)}: ${line}`).join("\n") + + const last = (params.offset || 1) + lines.raw.length - 1 + const next = last + 1 + + if (lines.cut) { + output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${params.offset || 1}-${last}. Use offset=${next} to continue.)` + } else if (lines.more) { + output += `\n\n(Showing lines ${params.offset || 1}-${last} of ${lines.count}. Use offset=${next} to continue.)` + } else { + output += `\n\n(End of file - total ${lines.count} lines)` + } + output += "\n" + + return { + title: path.basename(filepath), + output, + metadata: { truncated: lines.cut || lines.more, lines: lines.count }, + } + }, +} \ No newline at end of file diff --git a/apps/cli/src/tools/types.ts b/apps/cli/src/tools/types.ts new file mode 100644 index 00000000..293e10ea --- /dev/null +++ b/apps/cli/src/tools/types.ts @@ -0,0 +1,25 @@ +export interface ToolContext { + cwd: string + abort?: AbortSignal +} + +export interface ToolResult { + title: string + output: string + metadata?: Record +} + +export interface ToolDef

{ + id: string + description: string + parameters: JsonSchema + execute: (params: P, ctx: ToolContext) => Promise +} + +export type ToolRegistry = Record + +export interface JsonSchema { + type: string + properties?: Record + required?: string[] +} \ No newline at end of file diff --git a/apps/cli/src/tools/write.ts b/apps/cli/src/tools/write.ts new file mode 100644 index 00000000..9a6cac11 --- /dev/null +++ b/apps/cli/src/tools/write.ts @@ -0,0 +1,49 @@ +import * as fs from "fs" +import * as path from "path" +import type { ToolDef, ToolContext, ToolResult } from "./types" + +interface WriteParams { + content: string + filePath: string +} + +export const WriteTool: ToolDef = { + id: "write", + description: "Create or overwrite files", + parameters: { + type: "object", + properties: { + content: { description: "The content to write to the file" }, + filePath: { description: "The absolute path to the file to write" }, + }, + required: ["content", "filePath"], + }, + execute: async (params: WriteParams, ctx: ToolContext): Promise => { + let filepath = params.filePath + if (!path.isAbsolute(filepath)) { + filepath = path.resolve(ctx.cwd, filepath) + } + + const dir = path.dirname(filepath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + if (params.content === '') { + if (fs.existsSync(filepath)) { + fs.unlinkSync(filepath) + return { title: path.basename(filepath), output: 'File deleted.', metadata: { filepath } } + } + return { title: path.basename(filepath), output: 'File not found.', metadata: { filepath } } + } + + const exists = fs.existsSync(filepath) + fs.writeFileSync(filepath, params.content, 'utf-8') + + return { + title: path.basename(filepath), + output: exists ? 'File updated successfully.' : 'File created successfully.', + metadata: { filepath, exists }, + } + }, +} \ No newline at end of file diff --git a/apps/cli/src/utils/logger.ts b/apps/cli/src/utils/logger.ts new file mode 100644 index 00000000..d8923c9d --- /dev/null +++ b/apps/cli/src/utils/logger.ts @@ -0,0 +1,43 @@ +// ============================================================================= +// Logger +// ============================================================================= + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface Logger { + debug(message: string, meta?: Record): void; + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + error(message: string, meta?: Record): void; +} + +export class ConsoleLogger implements Logger { + private prefix: string; + + constructor(prefix = '') { + this.prefix = prefix ? `[${prefix}] ` : ''; + } + + private log(level: LogLevel, message: string, meta?: Record): void { + const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''; + console.log(`${this.prefix}${level.toUpperCase()}: ${message}${metaStr}`); + } + + debug(message: string, meta?: Record): void { + this.log('debug', message, meta); + } + + info(message: string, meta?: Record): void { + this.log('info', message, meta); + } + + warn(message: string, meta?: Record): void { + this.log('warn', message, meta); + } + + error(message: string, meta?: Record): void { + this.log('error', message, meta); + } +} + +export const logger = new ConsoleLogger('freecode'); \ No newline at end of file diff --git a/apps/cli/src/utils/result.ts b/apps/cli/src/utils/result.ts new file mode 100644 index 00000000..5cf9fb1b --- /dev/null +++ b/apps/cli/src/utils/result.ts @@ -0,0 +1,23 @@ +// ============================================================================= +// Result Type +// ============================================================================= + +export type Result = + | { success: true; value: T } + | { success: false; error: E }; + +export function ok(value: T): Result { + return { success: true, value }; +} + +export function err(error: E): Result { + return { success: false, error }; +} + +export function isOk(result: Result): result is { success: true; value: T } { + return result.success === true; +} + +export function isErr(result: Result): result is { success: false; error: E } { + return result.success === false; +} \ No newline at end of file diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 00000000..51089f23 --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/apps/tui/README.md b/apps/tui/README.md new file mode 100644 index 00000000..24e9b876 --- /dev/null +++ b/apps/tui/README.md @@ -0,0 +1,32 @@ +# FreeCode TUI + +Terminal UI that drives ChatGPT via Playwright/CDP. + +## Setup + +```sh +cd apps/tui +pnpm build +npm link +``` + +## Run + +```sh +freecode +``` + +## Development + +```sh +cd apps/tui +pnpm dev +``` + +## Usage +this is the command for arch linux - +```sh +chromium --remote-debugging-port=9222 +``` + +- use /freecode \ No newline at end of file diff --git a/apps/tui/package.json b/apps/tui/package.json index 296f65e5..2038065a 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@earendil-works/pi-tui": "^0.74.0", + "@freecode/shared": "workspace:*", "chalk": "^5.5.0" }, "devDependencies": { diff --git a/apps/tui/src/commands/built-in.ts b/apps/tui/src/commands/built-in.ts index 5ace6aaf..d59a0709 100644 --- a/apps/tui/src/commands/built-in.ts +++ b/apps/tui/src/commands/built-in.ts @@ -1,5 +1,6 @@ import { registerCommand, type Command, type CommandContext } from "./index.js"; import { AVAILABLE_MODELS } from "../models.js"; +import { registerFreecodeCommand } from "./freecode/index.js"; const helpCommand: Command = { name: "help", @@ -10,7 +11,8 @@ const helpCommand: Command = { - **/help** - Show this help message - **/clear** - Clear all messages - **/model** - Select AI model -- **/exit** - Exit FreeCode`); +- **/exit** - Exit FreeCode +- **/freecode** - Send prompt to ChatGPT and apply file changes`); }, }; @@ -44,4 +46,5 @@ export function registerBuiltInCommands(): void { registerCommand(clearCommand); registerCommand(exitCommand); registerCommand(modelCommand); + registerFreecodeCommand(); } \ No newline at end of file diff --git a/apps/tui/src/commands/freecode/index.ts b/apps/tui/src/commands/freecode/index.ts new file mode 100644 index 00000000..220c816d --- /dev/null +++ b/apps/tui/src/commands/freecode/index.ts @@ -0,0 +1,104 @@ +// ============================================================================= +// Freecode Command — sends prompt to CLI via IPC +// ============================================================================= + +import { registerCommand, type Command, type CommandContext } from "../index.js"; +import { + startCli, + stopCli, + sessionStart, + sessionStop, + sessionStart as startSession, + listProviders, + type SessionInfo, +} from "../../ipc/client.js"; + +// State +let currentSession: SessionInfo | null = null; +let providersLoaded = false; +let cachedProviders: Array<{ id: string; name: string }> = []; + +async function ensureProviders(): Promise { + if (!providersLoaded) { + try { + const providers = await listProviders(); + cachedProviders = providers.map((p: { id: string; name: string }) => ({ + id: p.id, + name: p.name, + })); + } catch { + cachedProviders = [{ id: "chatgpt", name: "ChatGPT" }]; + } + providersLoaded = true; + } +} + +function formatProviderList(): string { + return cachedProviders + .map((p) => `- **${p.name}** (${p.id})`) + .join("\n"); +} + +async function ensureSession(ctx: CommandContext): Promise { + if (currentSession) return true; + + startCli(); + + // Small delay to let CLI start + await new Promise((resolve) => setTimeout(resolve, 500)); + + try { + currentSession = await sessionStart({ + projectPath: process.cwd(), + provider: "chatgpt", + }); + return true; + } catch (error) { + ctx.showMessage( + `❌ **Failed to start session:** ${error instanceof Error ? error.message : String(error)}` + ); + return false; + } +} + +const freecodeCommand: Command = { + name: "freecode", + description: "Send prompt to AI and apply file changes", + execute: async (args, ctx) => { + const userPrompt = args.join(" "); + + if (!userPrompt.trim()) { + await ensureProviders(); + ctx.showMessage(`**Usage:** /freecode + +**Example:** /freecode summarize this project and write at project.md + +**Available providers:** +${formatProviderList()}`); + return; + } + + ctx.showMessage(`**You:** ${userPrompt}`); + ctx.showMessage("🔄 **Processing...**"); + + // Ensure CLI is running and we have a session + const ready = await ensureSession(ctx); + if (!ready) return; + + try { + // TODO: Wire up session.send when CLI supports streaming + // For now, just show a placeholder + ctx.showMessage( + "⏳ **AI processing...**\n\n(Full CLI integration coming in next step)" + ); + } catch (error) { + ctx.showMessage( + `❌ **Error:** ${error instanceof Error ? error.message : String(error)}` + ); + } + }, +}; + +export function registerFreecodeCommand(): void { + registerCommand(freecodeCommand); +} \ No newline at end of file diff --git a/apps/tui/src/ipc/client.ts b/apps/tui/src/ipc/client.ts new file mode 100644 index 00000000..255da4a6 --- /dev/null +++ b/apps/tui/src/ipc/client.ts @@ -0,0 +1,144 @@ +// ============================================================================= +// IPC Client — JSON-RPC bridge to CLI backend +// ============================================================================= + +import { spawn, type ChildProcess } from "child_process"; +import type { + JsonRpcRequest, + JsonRpcResponse, + ToolListItem, + ToolResult, + SessionConfig, + ProviderInfo, +} from "@freecode/shared"; + +// ============================================================================= +// IPC Transport +// ============================================================================= + +let requestId = 0; +let cliProcess: ChildProcess | null = null; +let messageBuffer = ""; +let pendingRequests = new Map< + number | string, + { resolve: (value: unknown) => void; reject: (error: Error) => void } +>(); + +function generateId(): number { + return ++requestId; +} + +function parseResponse(data: string): JsonRpcResponse[] { + const responses: JsonRpcResponse[] = []; + const lines = data.split("\n"); + for (const line of lines) { + if (!line.trim()) continue; + try { + responses.push(JSON.parse(line) as JsonRpcResponse); + } catch { + // Skip malformed lines + } + } + return responses; +} + +export function startCli(cwd?: string): void { + if (cliProcess) return; + + cliProcess = spawn("node", ["apps/cli/src/server.ts"], { + cwd: cwd || process.cwd(), + stdio: ["pipe", "pipe", "pipe"], + }); + + cliProcess.stdout?.setEncoding("utf-8"); + + cliProcess.stderr?.on("data", (data) => { + console.error("[CLI stderr]", data.toString()); + }); + + cliProcess.stdout?.on("data", (data: string) => { + messageBuffer += data; + const responses = parseResponse(messageBuffer); + messageBuffer = ""; + + for (const response of responses) { + const pending = pendingRequests.get(response.id); + if (pending) { + pendingRequests.delete(response.id); + if (response.error) { + pending.reject(new Error(response.error.message)); + } else { + pending.resolve(response.result); + } + } + } + }); + + cliProcess.on("error", (err) => { + console.error("[CLI process error]", err); + cliProcess = null; + }); + + cliProcess.on("exit", (code) => { + console.log("[CLI exited]", code); + cliProcess = null; + }); +} + +function sendRequest(method: string, params?: Record): Promise { + return new Promise((resolve, reject) => { + if (!cliProcess || !cliProcess.stdin) { + reject(new Error("CLI not running")); + return; + } + + const id = generateId(); + const request: JsonRpcRequest = { jsonrpc: "2.0", id, method, params }; + pendingRequests.set(id, { resolve: resolve as (value: unknown) => void, reject }); + + cliProcess.stdin.write(JSON.stringify(request) + "\n"); + }); +} + +export function stopCli(): void { + if (cliProcess) { + cliProcess.kill(); + cliProcess = null; + } +} + +// ============================================================================= +// Tool Methods +// ============================================================================= + +export async function listTools(): Promise { + return (await sendRequest("tools.list")) as ToolListItem[]; +} + +export async function callTool(name: string, args: Record): Promise { + return (await sendRequest("tools.call", { name, args })) as ToolResult; +} + +// ============================================================================= +// Session Methods +// ============================================================================= + +export interface SessionInfo { + sessionId: string; +} + +export async function sessionStart(config: SessionConfig): Promise { + return (await sendRequest("session.start", config as unknown as Record)) as SessionInfo; +} + +export async function sessionStop(sessionId: string): Promise { + await sendRequest("session.stop", { sessionId }); +} + +// ============================================================================= +// Provider Methods +// ============================================================================= + +export async function listProviders(): Promise { + return (await sendRequest("providers.list")) as ProviderInfo[]; +} \ No newline at end of file diff --git a/apps/tui/src/lib/utils/logger.ts b/apps/tui/src/lib/utils/logger.ts new file mode 100644 index 00000000..8a2a7e9a --- /dev/null +++ b/apps/tui/src/lib/utils/logger.ts @@ -0,0 +1,39 @@ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface Logger { + debug(message: string, meta?: Record): void; + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + error(message: string, meta?: Record): void; +} + +export class ConsoleLogger implements Logger { + private prefix: string; + + constructor(prefix = '') { + this.prefix = prefix ? `[${prefix}] ` : ''; + } + + private log(level: LogLevel, message: string, meta?: Record): void { + const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''; + console.log(`${this.prefix}${level.toUpperCase()}: ${message}${metaStr}`); + } + + debug(message: string, meta?: Record): void { + this.log('debug', message, meta); + } + + info(message: string, meta?: Record): void { + this.log('info', message, meta); + } + + warn(message: string, meta?: Record): void { + this.log('warn', message, meta); + } + + error(message: string, meta?: Record): void { + this.log('error', message, meta); + } +} + +export const logger = new ConsoleLogger('freecode'); diff --git a/apps/tui/src/lib/utils/result.ts b/apps/tui/src/lib/utils/result.ts new file mode 100644 index 00000000..ac345013 --- /dev/null +++ b/apps/tui/src/lib/utils/result.ts @@ -0,0 +1,39 @@ +export type Result = + | { success: true; value: T } + | { success: false; error: E }; + +export function ok(value: T): Result { + return { success: true, value }; +} + +export function err(error: E): Result { + return { success: false, error }; +} + +export function isOk(result: Result): result is { success: true; value: T } { + return result.success === true; +} + +export function isErr(result: Result): result is { success: false; error: E } { + return result.success === false; +} + +export function map( + result: Result, + fn: (value: T) => U +): Result { + if (isOk(result)) { + return ok(fn(result.value)); + } + return result as Result; +} + +export function flatMap( + result: Result, + fn: (value: T) => Result +): Result { + if (isOk(result)) { + return fn(result.value); + } + return result as Result; +} diff --git a/apps/vscode/.vscode/launch.json b/apps/vscode/.vscode/launch.json new file mode 100644 index 00000000..3cddd095 --- /dev/null +++ b/apps/vscode/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"] + } + ] +} \ No newline at end of file diff --git a/apps/vscode/package-lock.json b/apps/vscode/package-lock.json new file mode 100644 index 00000000..723d8e7b --- /dev/null +++ b/apps/vscode/package-lock.json @@ -0,0 +1,674 @@ +{ + "name": "freecode", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "freecode", + "version": "0.1.0", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@types/vscode": "^1.88.0", + "esbuild": "^0.28.0", + "typescript": "^5.4.0" + }, + "engines": { + "vscode": "^1.88.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.29", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", + "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.120.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", + "integrity": "sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/apps/vscode/package.json b/apps/vscode/package.json new file mode 100644 index 00000000..10e42fe4 --- /dev/null +++ b/apps/vscode/package.json @@ -0,0 +1,54 @@ +{ + "name": "freecode-chat", + "displayName": "FreeCode", + "description": "AI coding assistant with chat interface", + "version": "0.1.0", + "publisher": "freecode", + "engines": { + "vscode": "^1.88.0" + }, + "activationEvents": [ + "onView:freecode.chat" + ], + "main": "./dist/extension.js", + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "freecode.sidebar", + "title": "FreeCode", + "icon": "$(terminal)" + } + ] + }, + "views": { + "freecode.sidebar": [ + { + "id": "freecode.chat", + "type": "webview", + "title": "Chat" + } + ] + } + }, + "scripts": { + "build": "tsc && npm run build:webview", + "build:webview": "esbuild src/webview/main.tsx --bundle --outfile=dist/webview/bundle.js --platform=browser --format=iife && cp src/webview/index.html dist/webview/", + "watch": "tsc -w", + "watch:webview": "esbuild src/webview/main.tsx --bundle --outfile=dist/webview/bundle.js --platform=browser --format=iife --watch" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@types/vscode": "^1.88.0", + "esbuild": "^0.28.0", + "typescript": "^5.4.0" + }, + "dependencies": { + "@freecode/shared": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zustand": "^4.5.0" + } +} \ No newline at end of file diff --git a/apps/vscode/src/chat/ChatView.tsx b/apps/vscode/src/chat/ChatView.tsx new file mode 100644 index 00000000..4755f15d --- /dev/null +++ b/apps/vscode/src/chat/ChatView.tsx @@ -0,0 +1,26 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; + +export class ChatView implements vscode.WebviewViewProvider { + constructor(private context: vscode.ExtensionContext) {} + + resolveWebviewView(webviewView: vscode.WebviewView): void { + webviewView.webview.options = { + enableScripts: true, + }; + + const htmlPath = path.join(this.context.extensionPath, 'dist', 'webview', 'index.html'); + let html = fs.readFileSync(htmlPath, 'utf-8'); + + // Replace relative URLs with webview URIs + html = html.replace( + 'bundle.js', + webviewView.webview.asWebviewUri( + vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'webview', 'bundle.js') + ).toString() + ); + + webviewView.webview.html = html; + } +} \ No newline at end of file diff --git a/apps/vscode/src/chat/Message.tsx b/apps/vscode/src/chat/Message.tsx new file mode 100644 index 00000000..199b1adc --- /dev/null +++ b/apps/vscode/src/chat/Message.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import type { Message } from '../lib/types.js'; +import { TextPart } from './parts/TextPart.js'; +import { CodePart } from './parts/CodePart.js'; +import { ToolPart } from './parts/ToolPart.js'; + +interface MessageProps { + message: Message; +} + +export const ChatMessage: React.FC = ({ message }) => { + const isUser = message.role === 'user'; + + return ( +

+
+ {message.parts.map((part, i) => { + switch (part.type) { + case 'text': + return ; + case 'code': + return ; + case 'tool': + return ; + } + })} +
+
+ ); +}; \ No newline at end of file diff --git a/apps/vscode/src/chat/MessageInput.tsx b/apps/vscode/src/chat/MessageInput.tsx new file mode 100644 index 00000000..f81425e8 --- /dev/null +++ b/apps/vscode/src/chat/MessageInput.tsx @@ -0,0 +1,69 @@ +import React, { useState, useCallback } from 'react'; + +interface MessageInputProps { + onSend: (message: string) => void; + disabled?: boolean; +} + +export const MessageInput: React.FC = ({ onSend, disabled }) => { + const [value, setValue] = useState(''); + + const handleSubmit = useCallback(() => { + if (!value.trim() || disabled) return; + onSend(value.trim()); + setValue(''); + }, [value, disabled, onSend]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSubmit(); + } + }; + + return ( +
+