From 44a1ec8a790d51849dc6190b86b0d8b9901a12d8 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 10 May 2026 01:29:28 +0530 Subject: [PATCH 01/44] feat: add playwright dependency --- apps/tui/package.json | 3 ++- pnpm-lock.yaml | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/tui/package.json b/apps/tui/package.json index 296f65e5..09580525 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -13,7 +13,8 @@ }, "dependencies": { "@earendil-works/pi-tui": "^0.74.0", - "chalk": "^5.5.0" + "chalk": "^5.5.0", + "playwright": "^1.42.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a8fd0dc..a61fb8af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: chalk: specifier: ^5.5.0 version: 5.6.2 + playwright: + specifier: ^1.42.0 + version: 1.59.1 devDependencies: '@types/node': specifier: ^22.0.0 @@ -1043,6 +1046,11 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1430,6 +1438,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2603,6 +2621,9 @@ snapshots: dependencies: is-callable: 1.2.7 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2998,6 +3019,14 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss@8.4.31: From 3fbb2a09683e8d923fcc88940bc0f610d089550a Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 10 May 2026 01:30:37 +0530 Subject: [PATCH 02/44] feat: add Result type and Logger utility --- apps/tui/src/lib/utils/logger.ts | 39 + apps/tui/src/lib/utils/result.ts | 39 + .../plans/2026-05-10-freecode-mvp.md | 1355 +++++++++++++++++ 3 files changed, 1433 insertions(+) create mode 100644 apps/tui/src/lib/utils/logger.ts create mode 100644 apps/tui/src/lib/utils/result.ts create mode 100644 docs/superpowers/plans/2026-05-10-freecode-mvp.md 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/docs/superpowers/plans/2026-05-10-freecode-mvp.md b/docs/superpowers/plans/2026-05-10-freecode-mvp.md new file mode 100644 index 00000000..5ab60206 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-freecode-mvp.md @@ -0,0 +1,1355 @@ +# FreeCode MVP: Scalable Architecture + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build MVP with architecture that can scale to multiple providers (ChatGPT, Claude, Gemini), multiple parsers, and easy feature additions. + +**Architecture:** Interface-based design with clear boundaries — Browser Layer (provider-agnostic), Context Layer (configurable collectors), Parser Layer (strategy pattern), Command Layer (composable). + +**Tech Stack:** TypeScript, Playwright, pi-tui, Node.js + +--- + +## Scalable File Structure + +``` +apps/tui/src/ +├── index.ts # Entry point (existing) +├── commands/ +│ ├── index.ts # Command registry (existing) +│ ├── built-in.ts # Built-in commands (existing) +│ └── freecode/ +│ ├── index.ts # /freecode orchestrator +│ ├── provider-mgr.ts # Provider selection & management +│ ├── executor.ts # Execute prompt cycle +│ └── file-applier.ts # Apply parsed file changes +├── lib/ +│ ├── browser/ +│ │ ├── types.ts # Browser interfaces +│ │ ├── controller.ts # CDP connection manager +│ │ └── providers/ +│ │ ├── index.ts # Provider registry +│ │ ├── chatgpt.ts # ChatGPT adapter +│ │ └── types.ts # Provider interface +│ ├── context/ +│ │ ├── types.ts # Context interfaces +│ │ ├── collector.ts # File tree collector +│ │ └── strategies/ +│ │ ├── index.ts # Strategy registry +│ │ └── file-tree.ts # File tree strategy +│ ├── parser/ +│ │ ├── types.ts # Parser interfaces +│ │ ├── registry.ts # Parser registry (strategy pattern) +│ │ ├── extractors/ +│ │ │ ├── index.ts # Extractor registry +│ │ │ ├── markdown.ts # Markdown file block extractor +│ │ │ ├── json.ts # JSON extractor +│ │ │ └── structured.ts # Structured output extractor +│ │ └── index.ts # Main parser orchestrator +│ └── utils/ +│ ├── result.ts # Result/Either type for error handling +│ └── logger.ts # Structured logging +└── models.ts # Model definitions (existing) +``` + +--- + +## Scalability Principles Applied + +| Principle | Implementation | +|-----------|----------------| +| **Interface Segregation** | `BrowserProvider`, `ParserStrategy`, `ContextStrategy` — small focused interfaces | +| **Dependency Inversion** | High-level modules (executor) depend on abstractions, not concretions | +| **Single Responsibility** | Each file does one thing — controller handles CDP, provider handles DOM, parser handles parsing | +| **Strategy Pattern** | Parser registry lets you chain multiple parsing strategies | +| **Factory Pattern** | Provider registry creates providers without coupling to concrete classes | +| **Result Type** | No exceptions thrown across module boundaries — explicit `Result` types | + +--- + +## Task 1: Add Playwright Dependency + +**Files:** +- Modify: `apps/tui/package.json` + +- [ ] **Step 1: Add Playwright to dependencies** + +Modify `apps/tui/package.json`: + +```json +"dependencies": { + "@earendil-works/pi-tui": "^0.74.0", + "chalk": "^5.5.0", + "playwright": "^1.42.0" +} +``` + +- [ ] **Step 2: Install dependencies** + +Run: `cd apps/tui && pnpm install` + +- [ ] **Step 3: Install Chromium browser** + +Run: `cd apps/tui && npx playwright install chromium` + +- [ ] **Step 4: Commit** + +```bash +cd apps/tui && git add -A && git commit -m "feat: add playwright dependency" +``` + +--- + +## Task 2: Utility Types (Result, Logger) + +**Files:** +- Create: `apps/tui/src/lib/utils/result.ts` +- Create: `apps/tui/src/lib/utils/logger.ts` + +- [ ] **Step 1: Create Result type** + +```typescript +// apps/tui/src/lib/utils/result.ts +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; +} +``` + +- [ ] **Step 2: Create Logger utility** + +```typescript +// apps/tui/src/lib/utils/logger.ts +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'); +``` + +- [ ] **Step 3: Commit** + +```bash +cd apps/tui && git add -A && git commit -m "feat: add Result type and Logger utility" +``` + +--- + +## Task 3: Browser Layer (Scalable Provider System) + +**Files:** +- Create: `apps/tui/src/lib/browser/types.ts` +- Create: `apps/tui/src/lib/browser/providers/types.ts` +- Create: `apps/tui/src/lib/browser/providers/chatgpt.ts` +- Create: `apps/tui/src/lib/browser/providers/index.ts` +- Create: `apps/tui/src/lib/browser/controller.ts` + +- [ ] **Step 1: Create browser types (interfaces)** + +```typescript +// apps/tui/src/lib/browser/types.ts +import type { Page, Browser } from 'playwright'; + +export interface BrowserController { + connect(): Promise; + disconnect(): Promise; + isConnected(): boolean; + getPage(): Page | null; +} + +export interface BrowserConfig { + cdpUrl?: string; + headless?: boolean; +} +``` + +- [ ] **Step 2: Create provider interface** + +```typescript +// apps/tui/src/lib/browser/providers/types.ts +export interface PageAdapter { + name: string; + getInputLocator(page: Page): any; + getSubmitButton(page: Page): any; + getResponseLocator(page: Page): any; + isStreaming(page: Page): Promise; + waitForLoadState(page: Page): Promise; +} + +export interface ProviderConfig { + url: string; + waitForNetworkIdle?: boolean; +} +``` + +- [ ] **Step 3: Create ChatGPT provider adapter** + +```typescript +// apps/tui/src/lib/browser/providers/chatgpt.ts +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.locator('textarea'); + } + + getSubmitButton(page: Page): Locator { + return page.locator('button[data-testid="send-button"]'); + } + + getResponseLocator(page: Page): Locator { + return page.locator('[data-testid="turn"]').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'); + } +} +``` + +- [ ] **Step 4: Create provider registry** + +```typescript +// apps/tui/src/lib/browser/providers/index.ts +import type { PageAdapter } from './types.js'; +import { ChatGPTAdapter } from './chatgpt.js'; + +export interface ProviderDefinition { + id: string; + name: string; + adapter: PageAdapter; + config: { + url: string; + }; +} + +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', + }, + }); +} +``` + +- [ ] **Step 5: Create browser controller** + +```typescript +// apps/tui/src/lib/browser/controller.ts +import { chromium, type Browser, type Page } from 'playwright'; +import type { BrowserController, BrowserConfig } from './types.js'; +import type { PageAdapter } from './providers/types.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: PageAdapter): Promise { + if (!this.page) throw new Error('Not connected'); + await this.page.goto(provider.config.url); + await provider.waitForLoadState(this.page); + this.adapter = provider; + } + + async sendPrompt(prompt: string): Promise { + if (!this.page || !this.adapter) { + throw new Error('Not connected or adapter not set'); + } + 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'); + } + + logger.debug('Waiting for streaming to complete'); + while (await this.adapter.isStreaming(this.page)) { + await this.page.waitForTimeout(500); + } + + await this.page.waitForTimeout(1000); + + const responseLocator = this.adapter.getResponseLocator(this.page); + return responseLocator.innerText(); + } + + async executePrompt(prompt: string): Promise { + await this.sendPrompt(prompt); + return this.waitForResponse(); + } +} +``` + +- [ ] **Step 6: Commit** + +```bash +cd apps/tui && git add -A && git commit -m "feat: add scalable browser layer with provider system" +``` + +--- + +## Task 4: Context Layer (Strategy Pattern) + +**Files:** +- Create: `apps/tui/src/lib/context/types.ts` +- Create: `apps/tui/src/lib/context/collector.ts` +- Create: `apps/tui/src/lib/context/strategies/file-tree.ts` +- Create: `apps/tui/src/lib/context/strategies/index.ts` + +- [ ] **Step 1: Create context types** + +```typescript +// apps/tui/src/lib/context/types.ts +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[]; +} +``` + +- [ ] **Step 2: Create base collector with file tree strategy** + +```typescript +// apps/tui/src/lib/context/strategies/file-tree.ts +import * as fs from 'fs'; +import * as path from 'path'; +import type { ContextStrategy, ContextOptions, ProjectContext } 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 = this.generateTree(projectPath, ignorePatterns, maxDepth); + const files = this.collectFiles(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 generateTree( + dirPath: string, + patterns: string[], + maxDepth: number, + currentDepth = 0 + ): string { + if (currentDepth > maxDepth) return ''; + + let tree = ''; + 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.isDirectory()) { + tree += this.generateTree(fullPath, patterns, maxDepth, currentDepth + 1); + } + } + } catch { + // Skip unreadable directories + } + + return tree; + } + + 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 collectFiles( + dirPath: string, + patterns: string[], + maxDepth: number, + currentDepth = 0 + ): Record { + const files: Record = {}; + + if (currentDepth > maxDepth) return 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; + + if (entry.isFile()) { + const relativePath = path.relative(process.cwd(), fullPath); + files[relativePath] = this.readFile(fullPath); + } else if (entry.isDirectory()) { + const childFiles = this.collectFiles(fullPath, patterns, maxDepth, currentDepth + 1); + Object.assign(files, childFiles); + } + } + } catch { + // Skip unreadable directories + } + + return files; + } + + private readFile(filePath: string): string { + try { + return fs.readFileSync(filePath, 'utf-8'); + } catch { + return `// Error reading: ${filePath}`; + } + } +} +``` + +- [ ] **Step 3: Create strategy registry and main collector** + +```typescript +// apps/tui/src/lib/context/strategies/index.ts +export * 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()); +} +``` + +```typescript +// apps/tui/src/lib/context/collector.ts +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)}`); + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +cd apps/tui && git add -A && git commit -m "feat: add context layer with strategy pattern" +``` + +--- + +## Task 5: Parser Layer (Strategy Chain) + +**Files:** +- Create: `apps/tui/src/lib/parser/types.ts` +- Create: `apps/tui/src/lib/parser/extractors/markdown.ts` +- Create: `apps/tui/src/lib/parser/extractors/json.ts` +- Create: `apps/tui/src/lib/parser/extractors/structured.ts` +- Create: `apps/tui/src/lib/parser/extractors/index.ts` +- Create: `apps/tui/src/lib/parser/registry.ts` +- Create: `apps/tui/src/lib/parser/index.ts` + +- [ ] **Step 1: Create parser types** + +```typescript +// apps/tui/src/lib/parser/types.ts +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; +} +``` + +- [ ] **Step 2: Create markdown extractor** + +```typescript +// apps/tui/src/lib/parser/extractors/markdown.ts +import type { ParserStrategy, ParserResult, ParsedResponse, FileChange } from '../types.js'; + +export class MarkdownExtractor implements ParserStrategy { + name = 'markdown'; + + parse(raw: string): ParserResult { + const changes: FileChange[] = []; + const fileBlockRegex = /FILE:\s*(.+?)\n```[\w]*\n([\s\S]*?)```/g; + + let match; + while ((match = fileBlockRegex.exec(raw)) !== null) { + changes.push({ + path: match[1].trim(), + action: 'create', + content: match[2].trim(), + }); + } + + if (changes.length === 0) { + return { success: false, error: 'No file blocks found' }; + } + + const summary = this.extractSummary(raw); + + return { + success: true, + response: { + summary, + changes, + raw, + parserUsed: this.name, + }, + }; + } + + private extractSummary(raw: string): string { + const lines = raw.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('```') && !trimmed.startsWith('FILE:')) { + const cleaned = trimmed.replace(/^#+\s*/, '').replace(/\*\*/g, ''); + if (cleaned.length > 10) { + return cleaned.slice(0, 200); + } + } + } + return 'Generated file structure'; + } +} +``` + +- [ ] **Step 3: Create JSON extractor** + +```typescript +// apps/tui/src/lib/parser/extractors/json.ts +import type { ParserStrategy, ParserResult, ParsedResponse, FileChange } from '../types.js'; + +export class JsonExtractor implements ParserStrategy { + name = 'json'; + + parse(raw: string): ParserResult { + try { + const match = raw.match(/\{[\s\S]*\}/); + if (!match) { + return { success: false, error: 'No JSON found' }; + } + + const parsed = JSON.parse(match[0]); + const changes: FileChange[] = (parsed.changes || []).map((c: any) => ({ + path: c.file || c.path, + action: (c.action || 'create') as FileChange['action'], + content: c.content || c.code, + })); + + if (changes.length === 0) { + return { success: false, error: 'No changes in JSON' }; + } + + return { + success: true, + response: { + summary: parsed.summary || parsed.description || 'Generated structure', + changes, + raw, + parserUsed: this.name, + }, + }; + } catch (error) { + return { success: false, error: `JSON parse error: ${error}` }; + } + } +} +``` + +- [ ] **Step 4: Create structured output extractor** + +```typescript +// apps/tui/src/lib/parser/extractors/structured.ts +import type { ParserStrategy, ParserResult, FileChange } from '../types.js'; + +export class StructuredExtractor implements ParserStrategy { + name = 'structured'; + + parse(raw: string): ParserResult { + const changes: FileChange[] = []; + + const patterns = [ + /FILE:\s*(.+?)\n```[\w]*\n([\s\S]*?)```/g, + /]*>([\s\S]*?)<\/file>/gi, + /^(.+?\.ts)\n```[\w]*\n([\s\S]*?)```/gm, + ]; + + for (const pattern of patterns) { + let match; + while ((match = pattern.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 }); + } + } + } + + 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 match = raw.match(/^(?!```|FILE:|>|\s)(.+)/m); + return match ? match[1].trim().slice(0, 200) : 'Generated file structure'; + } +} +``` + +- [ ] **Step 5: Create extractor registry** + +```typescript +// apps/tui/src/lib/parser/extractors/index.ts +export { MarkdownExtractor } from './markdown.js'; +export { JsonExtractor } from './json.js'; +export { StructuredExtractor } from './structured.js'; + +import type { ParserStrategy } from '../types.js'; +import { MarkdownExtractor, JsonExtractor, StructuredExtractor } from './index.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 MarkdownExtractor()); + registerExtractor(new JsonExtractor()); + registerExtractor(new StructuredExtractor()); +} +``` + +- [ ] **Step 6: Create parser registry (chains extractors)** + +```typescript +// apps/tui/src/lib/parser/registry.ts +import type { ParserResult, ParsedResponse } 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); +} +``` + +- [ ] **Step 7: Create parser index** + +```typescript +// apps/tui/src/lib/parser/index.ts +export * from './types.js'; +export { parse, parseWithStrategy, parseWithChain } from './registry.js'; +``` + +- [ ] **Step 8: Commit** + +```bash +cd apps/tui && git add -A && git commit -m "feat: add parser layer with strategy chain" +``` + +--- + +## Task 6: File Applicator + +**Files:** +- Create: `apps/tui/src/commands/freecode/file-applier.ts` + +- [ ] **Step 1: Create file applicator** + +```typescript +// apps/tui/src/commands/freecode/file-applier.ts +import * as fs from 'fs'; +import * as path from 'path'; +import type { FileChange } from '../../lib/parser/types.js'; +import { logger } from '../../lib/utils/logger.js'; +import { ok, err, type Result } from '../../lib/utils/result.js'; + +export interface ApplyResult { + path: string; + success: boolean; + error?: string; +} + +export async function applyFileChange( + change: FileChange, + basePath: string +): Promise> { + const fullPath = path.join(basePath, change.path); + + try { + if (change.action === 'delete') { + if (fs.existsSync(fullPath)) { + fs.unlinkSync(fullPath); + logger.info('Deleted file', { path: change.path }); + } + return ok({ path: change.path, success: true }); + } + + const dir = path.dirname(fullPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (change.content !== undefined) { + fs.writeFileSync(fullPath, change.content, 'utf-8'); + logger.info('Wrote file', { 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; +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd apps/tui && git add -A && git commit -m "feat: add file applicator" +``` + +--- + +## Task 7: Freecode Command (Composable Orchestration) + +**Files:** +- Create: `apps/tui/src/commands/freecode/provider-mgr.ts` +- Create: `apps/tui/src/commands/freecode/executor.ts` +- Create: `apps/tui/src/commands/freecode/index.ts` + +- [ ] **Step 1: Create provider manager** + +```typescript +// apps/tui/src/commands/freecode/provider-mgr.ts +import { listProviders, type ProviderDefinition } from '../../lib/browser/providers/index.js'; +import { logger } from '../../lib/utils/logger.js'; + +export interface SelectedProvider { + id: string; + name: string; + definition: ProviderDefinition; +} + +export function selectProvider(providerId?: string): SelectedProvider | null { + const providers = listProviders(); + + if (providers.length === 0) { + logger.error('No providers registered'); + return null; + } + + if (providerId) { + const found = providers.find(p => p.id === providerId); + if (found) { + return { id: found.id, name: found.name, definition: found }; + } + logger.warn(`Provider ${providerId} not found, using default`); + } + + const defaultProvider = providers[0]; + return { id: defaultProvider.id, name: defaultProvider.name, definition: defaultProvider }; +} + +export function formatProviderList(): string { + const providers = listProviders(); + return providers.map(p => `- **${p.id}** - ${p.name}`).join('\n'); +} +``` + +- [ ] **Step 2: Create executor (the main orchestration logic)** + +```typescript +// apps/tui/src/commands/freecode/executor.ts +import { PlaywrightBrowserController } from '../../lib/browser/controller.js'; +import { collectContext } from '../../lib/context/collector.js'; +import { parse } from '../../lib/parser/index.js'; +import { applyChanges } from './file-applier.js'; +import { type SelectedProvider } from './provider-mgr.js'; +import { logger } from '../../lib/utils/logger.js'; + +export interface ExecutorOptions { + prompt: string; + provider: SelectedProvider; + projectPath: string; + contextOptions?: { + maxDepth?: number; + ignorePatterns?: string[]; + }; +} + +export interface ExecutorResult { + success: boolean; + summary?: string; + filesCreated: number; + errors: string[]; +} + +export async function executePromptCycle( + options: ExecutorOptions, + onStatus: (message: string) => void +): Promise { + const { prompt, provider, projectPath, contextOptions } = options; + const errors: string[] = []; + + const controller = new PlaywrightBrowserController(); + + try { + onStatus('🔄 **Connecting to browser...**'); + await controller.connect(); + onStatus('✅ **Browser connected**'); + + onStatus('✅ **Loading ChatGPT...**'); + await controller.navigate(provider.definition.adapter); + onStatus(`✅ **${provider.name} loaded**`); + + onStatus('📁 **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.`; + + onStatus('📤 **Sending to ChatGPT...**'); + await controller.sendPrompt(fullPrompt); + onStatus('⏳ **Waiting for response...**'); + + const response = await controller.waitForResponse(); + onStatus('✅ **Response received**'); + + const parseResult = parse(response); + + if (!parseResult.success) { + errors.push(`Parse failed: ${parseResult.error}`); + onStatus('⚠️ **Could not parse response**'); + onStatus('```\n' + response.slice(0, 500) + '...\n```'); + return { success: false, filesCreated: 0, errors }; + } + + const parsedResponse = parseResult.response!; + onStatus(`📝 **Summary:** ${parsedResponse.summary}`); + + const fileChanges = parsedResponse.changes; + onStatus(`📋 **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); + }); + } + + onStatus(`✨ **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); + return { success: false, filesCreated: 0, errors }; + } finally { + await controller.disconnect(); + } +} +``` + +- [ ] **Step 3: Create /freecode command** + +```typescript +// apps/tui/src/commands/freecode/index.ts +import { registerCommand, type Command, type CommandContext } from '../index.js'; +import { selectProvider, formatProviderList } from './provider-mgr.js'; +import { executePromptCycle } from './executor.js'; +import { createDefaultProviders } from '../../lib/browser/providers/index.js'; + +createDefaultProviders(); + +const freecodeCommand: Command = { + name: 'freecode', + description: 'Send prompt to ChatGPT and apply file changes', + execute: async (args, ctx) => { + const userPrompt = args.join(' '); + + if (!userPrompt.trim()) { + ctx.showMessage(`**Usage:** /freecode + +**Example:** /freecode summarize this project and write at project.md + +**Available providers:** +${formatProviderList()}`); + return; + } + + const provider = selectProvider(); + if (!provider) { + ctx.showMessage('❌ **No provider available**'); + return; + } + + const projectPath = process.cwd(); + + const result = await executePromptCycle( + { prompt: userPrompt, provider, projectPath }, + (status) => ctx.showMessage(status) + ); + + if (!result.success && result.errors.length > 0) { + ctx.showMessage(`❌ **Errors:**\n${result.errors.map(e => `- ${e}`).join('\n')}`); + } + }, +}; + +export function registerFreecodeCommand(): void { + registerCommand(freecodeCommand); +} +``` + +- [ ] **Step 4: Wire into built-in commands** + +Modify `apps/tui/src/commands/built-in.ts`: + +```typescript +import { registerCommand, type Command, type CommandContext } from "./index.js"; +import { AVAILABLE_MODELS } from "../models.js"; +import { registerFreecodeCommand } from "./freecode/index.js"; + +export function registerBuiltInCommands(): void { + registerCommand(helpCommand); + registerCommand(clearCommand); + registerCommand(exitCommand); + registerCommand(modelCommand); + registerFreecodeCommand(); +} +``` + +Also update the help text to include `/freecode`: + +```typescript +const helpCommand: Command = { + name: "help", + description: "Show available commands", + execute: (_args, ctx) => { + ctx.showMessage(`**Available Commands:** + +- **/help** - Show this help message +- **/clear** - Clear all messages +- **/model** - Select AI model +- **/exit** - Exit FreeCode +- **/freecode** - Send prompt to ChatGPT and apply file changes`); + }, +}; +``` + +- [ ] **Step 5: Build and verify** + +Run: `cd apps/tui && pnpm build` +Expected: Compiles without errors + +- [ ] **Step 6: Commit** + +```bash +cd apps/tui && git add -A && git commit -m "feat: add scalable /freecode command with provider system" +``` + +--- + +## Task 8: Test MVP Flow + +**Prerequisites:** +- Chrome running with: `chrome --remote-debugging-port=9222` +- User logged into chatgpt.com + +- [ ] **Step 1: Start TUI** + +Run: `cd apps/tui && pnpm dev` + +- [ ] **Step 2: Test /freecode** + +Type: `/freecode summarize this project and write at project.md` + +Expected flow: +1. Connecting to browser... ✅ +2. Browser connected ✅ +3. Loading ChatGPT... ✅ +4. Collecting project context... (shows file count) +5. Sending to ChatGPT... ✅ +6. Waiting for response... ✅ +7. Response received ✅ +8. Applying files... ✅ +9. Done! Created N files ✅ + +- [ ] **Step 3: Verify project.md exists** + +Run: `cat project.md` + +--- + +## Scalability Verification + +| Scalability Concern | How Addressed | +|---------------------|---------------| +| Add new provider (Claude, Gemini) | Implement `PageAdapter`, call `registerProvider()` | +| Add new parser | Implement `ParserStrategy`, call `registerExtractor()` | +| Add new context strategy | Implement `ContextStrategy`, call `registerStrategy()` | +| Change file operations | Modify only `file-applier.ts` | +| Test components | Each module has single responsibility, easy to mock | + +--- + +## Self-Review Checklist + +1. **Spec coverage:** MVP works — browser connection, context, prompt, parsing, file apply +2. **Placeholder scan:** No TBD/TODO, all steps have complete code +3. **Type consistency:** `FileChange`, `ParserResult`, `Result` used consistently +4. **Interface boundaries:** Each layer has clear interface, no cross-layer dependencies +5. **Error handling:** `Result` used throughout, no exceptions across module boundaries + +--- + +**Plan complete and saved to `docs/superpowers/plans/2026-05-10-freecode-mvp.md`.** + +**Two execution options:** + +1. **Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +2. **Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? \ No newline at end of file From 0fd2276424cfa743ecbd0ac13ec0548b1ff26beb Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 10 May 2026 01:33:45 +0530 Subject: [PATCH 03/44] feat: add scalable browser layer with provider system --- apps/tui/src/lib/browser/controller.ts | 93 +++++++++++++++++++ apps/tui/src/lib/browser/providers/chatgpt.ts | 27 ++++++ apps/tui/src/lib/browser/providers/index.ts | 37 ++++++++ apps/tui/src/lib/browser/providers/types.ts | 15 +++ apps/tui/src/lib/browser/types.ts | 13 +++ 5 files changed, 185 insertions(+) create mode 100644 apps/tui/src/lib/browser/controller.ts create mode 100644 apps/tui/src/lib/browser/providers/chatgpt.ts create mode 100644 apps/tui/src/lib/browser/providers/index.ts create mode 100644 apps/tui/src/lib/browser/providers/types.ts create mode 100644 apps/tui/src/lib/browser/types.ts diff --git a/apps/tui/src/lib/browser/controller.ts b/apps/tui/src/lib/browser/controller.ts new file mode 100644 index 00000000..4cca15eb --- /dev/null +++ b/apps/tui/src/lib/browser/controller.ts @@ -0,0 +1,93 @@ +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'); + } + 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'); + } + + logger.debug('Waiting for streaming to complete'); + while (await this.adapter.isStreaming(this.page)) { + await this.page.waitForTimeout(500); + } + + await this.page.waitForTimeout(1000); + + const responseLocator = this.adapter.getResponseLocator(this.page); + return responseLocator.innerText(); + } + + async executePrompt(prompt: string): Promise { + await this.sendPrompt(prompt); + return this.waitForResponse(); + } +} \ No newline at end of file diff --git a/apps/tui/src/lib/browser/providers/chatgpt.ts b/apps/tui/src/lib/browser/providers/chatgpt.ts new file mode 100644 index 00000000..3234d006 --- /dev/null +++ b/apps/tui/src/lib/browser/providers/chatgpt.ts @@ -0,0 +1,27 @@ +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.locator('textarea'); + } + + getSubmitButton(page: Page): Locator { + return page.locator('button[data-testid="send-button"]'); + } + + getResponseLocator(page: Page): Locator { + return page.locator('[data-testid="turn"]').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/tui/src/lib/browser/providers/index.ts b/apps/tui/src/lib/browser/providers/index.ts new file mode 100644 index 00000000..e4c9197b --- /dev/null +++ b/apps/tui/src/lib/browser/providers/index.ts @@ -0,0 +1,37 @@ +import type { PageAdapter } from './types.js'; +export type { PageAdapter } from './types.js'; +import { ChatGPTAdapter } from './chatgpt.js'; + +export interface ProviderDefinition { + id: string; + name: string; + adapter: PageAdapter; + config: { + url: string; + }; +} + +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/tui/src/lib/browser/providers/types.ts b/apps/tui/src/lib/browser/providers/types.ts new file mode 100644 index 00000000..91822441 --- /dev/null +++ b/apps/tui/src/lib/browser/providers/types.ts @@ -0,0 +1,15 @@ +import type { Page } from 'playwright'; + +export interface PageAdapter { + name: string; + getInputLocator(page: Page): any; + getSubmitButton(page: Page): any; + getResponseLocator(page: Page): any; + 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/tui/src/lib/browser/types.ts b/apps/tui/src/lib/browser/types.ts new file mode 100644 index 00000000..8cc91b47 --- /dev/null +++ b/apps/tui/src/lib/browser/types.ts @@ -0,0 +1,13 @@ +import type { Page, Browser } from 'playwright'; + +export interface BrowserController { + connect(): Promise; + disconnect(): Promise; + isConnected(): boolean; + getPage(): Page | null; +} + +export interface BrowserConfig { + cdpUrl?: string; + headless?: boolean; +} \ No newline at end of file From 9a5de72a554a6ebd0145600b8e6bbdd298ea26e2 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 10 May 2026 01:35:25 +0530 Subject: [PATCH 04/44] fix: use proper Locator types in PageAdapter interface --- apps/tui/src/lib/browser/providers/types.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/tui/src/lib/browser/providers/types.ts b/apps/tui/src/lib/browser/providers/types.ts index 91822441..2ff57eb2 100644 --- a/apps/tui/src/lib/browser/providers/types.ts +++ b/apps/tui/src/lib/browser/providers/types.ts @@ -1,10 +1,10 @@ -import type { Page } from 'playwright'; +import type { Locator, Page } from 'playwright'; export interface PageAdapter { name: string; - getInputLocator(page: Page): any; - getSubmitButton(page: Page): any; - getResponseLocator(page: Page): any; + getInputLocator(page: Page): Locator; + getSubmitButton(page: Page): Locator; + getResponseLocator(page: Page): Locator; isStreaming(page: Page): Promise; waitForLoadState(page: Page): Promise; } From be767873545e764a957f54368b7dfcebc98057cf Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 10 May 2026 01:37:16 +0530 Subject: [PATCH 05/44] feat: add context layer with strategy pattern --- apps/tui/src/lib/context/collector.ts | 23 ++++ .../src/lib/context/strategies/file-tree.ts | 120 ++++++++++++++++++ apps/tui/src/lib/context/strategies/index.ts | 18 +++ apps/tui/src/lib/context/types.ts | 24 ++++ 4 files changed, 185 insertions(+) create mode 100644 apps/tui/src/lib/context/collector.ts create mode 100644 apps/tui/src/lib/context/strategies/file-tree.ts create mode 100644 apps/tui/src/lib/context/strategies/index.ts create mode 100644 apps/tui/src/lib/context/types.ts diff --git a/apps/tui/src/lib/context/collector.ts b/apps/tui/src/lib/context/collector.ts new file mode 100644 index 00000000..c9eadc88 --- /dev/null +++ b/apps/tui/src/lib/context/collector.ts @@ -0,0 +1,23 @@ +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/tui/src/lib/context/strategies/file-tree.ts b/apps/tui/src/lib/context/strategies/file-tree.ts new file mode 100644 index 00000000..fd77757b --- /dev/null +++ b/apps/tui/src/lib/context/strategies/file-tree.ts @@ -0,0 +1,120 @@ +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 = this.generateTree(projectPath, ignorePatterns, maxDepth); + const files = this.collectFiles(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 generateTree( + dirPath: string, + patterns: string[], + maxDepth: number, + currentDepth = 0 + ): string { + if (currentDepth > maxDepth) return ''; + + let tree = ''; + 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.isDirectory()) { + tree += this.generateTree(fullPath, patterns, maxDepth, currentDepth + 1); + } + } + } catch { + // Skip unreadable directories + } + + return tree; + } + + 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 collectFiles( + dirPath: string, + patterns: string[], + maxDepth: number, + currentDepth = 0 + ): Record { + const files: Record = {}; + + if (currentDepth > maxDepth) return 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; + + if (entry.isFile()) { + const relativePath = path.relative(process.cwd(), fullPath); + files[relativePath] = this.readFile(fullPath); + } else if (entry.isDirectory()) { + const childFiles = this.collectFiles(fullPath, patterns, maxDepth, currentDepth + 1); + Object.assign(files, childFiles); + } + } + } catch { + // Skip unreadable directories + } + + return files; + } + + 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/tui/src/lib/context/strategies/index.ts b/apps/tui/src/lib/context/strategies/index.ts new file mode 100644 index 00000000..9f6fd3a0 --- /dev/null +++ b/apps/tui/src/lib/context/strategies/index.ts @@ -0,0 +1,18 @@ +export * 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/tui/src/lib/context/types.ts b/apps/tui/src/lib/context/types.ts new file mode 100644 index 00000000..0d92e2b2 --- /dev/null +++ b/apps/tui/src/lib/context/types.ts @@ -0,0 +1,24 @@ +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 From 4d91e08929bd005984ecd32b03feb99856bdb0a0 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 10 May 2026 01:39:12 +0530 Subject: [PATCH 06/44] fix: single-pass file traversal in FileTreeStrategy --- .../src/lib/context/strategies/file-tree.ts | 58 +++++-------------- 1 file changed, 16 insertions(+), 42 deletions(-) diff --git a/apps/tui/src/lib/context/strategies/file-tree.ts b/apps/tui/src/lib/context/strategies/file-tree.ts index fd77757b..7256852b 100644 --- a/apps/tui/src/lib/context/strategies/file-tree.ts +++ b/apps/tui/src/lib/context/strategies/file-tree.ts @@ -19,8 +19,7 @@ export class FileTreeStrategy implements ContextStrategy { logger.info('Collecting project context', { projectPath, maxDepth }); - const tree = this.generateTree(projectPath, ignorePatterns, maxDepth); - const files = this.collectFiles(projectPath, ignorePatterns, maxDepth); + const { tree, files } = this.gatherContext(projectPath, ignorePatterns, maxDepth); const metadata: ContextMetadata = { collectedAt: Date.now(), @@ -39,15 +38,17 @@ export class FileTreeStrategy implements ContextStrategy { }; } - private generateTree( + private gatherContext( dirPath: string, patterns: string[], maxDepth: number, currentDepth = 0 - ): string { - if (currentDepth > maxDepth) return ''; - + ): { tree: string; files: Record } { let tree = ''; + const files: Record = {}; + + if (currentDepth > maxDepth) return { tree, files }; + try { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); @@ -59,15 +60,20 @@ export class FileTreeStrategy implements ContextStrategy { const icon = entry.isDirectory() ? '📁 ' : '📄 '; tree += `${indent}${icon}${entry.name}${entry.isDirectory() ? '/' : ''}\n`; - if (entry.isDirectory()) { - tree += this.generateTree(fullPath, patterns, maxDepth, currentDepth + 1); + 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; + return { tree, files }; } private shouldIgnore(filePath: string, patterns: string[]): boolean { @@ -78,38 +84,6 @@ export class FileTreeStrategy implements ContextStrategy { }); } - private collectFiles( - dirPath: string, - patterns: string[], - maxDepth: number, - currentDepth = 0 - ): Record { - const files: Record = {}; - - if (currentDepth > maxDepth) return 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; - - if (entry.isFile()) { - const relativePath = path.relative(process.cwd(), fullPath); - files[relativePath] = this.readFile(fullPath); - } else if (entry.isDirectory()) { - const childFiles = this.collectFiles(fullPath, patterns, maxDepth, currentDepth + 1); - Object.assign(files, childFiles); - } - } - } catch { - // Skip unreadable directories - } - - return files; - } - private readFile(filePath: string): string { try { return fs.readFileSync(filePath, 'utf-8'); @@ -117,4 +91,4 @@ export class FileTreeStrategy implements ContextStrategy { return `// Error reading: ${filePath}`; } } -} \ No newline at end of file +} From 712784df048ccf3e504bc715d1877034057c82dd Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 10 May 2026 01:41:16 +0530 Subject: [PATCH 07/44] feat: add parser layer with strategy chain --- apps/tui/src/lib/parser/extractors/index.ts | 22 +++++++++ apps/tui/src/lib/parser/extractors/json.ts | 37 ++++++++++++++ .../tui/src/lib/parser/extractors/markdown.ts | 49 +++++++++++++++++++ .../src/lib/parser/extractors/structured.ts | 46 +++++++++++++++++ apps/tui/src/lib/parser/index.ts | 2 + apps/tui/src/lib/parser/registry.ts | 46 +++++++++++++++++ apps/tui/src/lib/parser/types.ts | 25 ++++++++++ 7 files changed, 227 insertions(+) create mode 100644 apps/tui/src/lib/parser/extractors/index.ts create mode 100644 apps/tui/src/lib/parser/extractors/json.ts create mode 100644 apps/tui/src/lib/parser/extractors/markdown.ts create mode 100644 apps/tui/src/lib/parser/extractors/structured.ts create mode 100644 apps/tui/src/lib/parser/index.ts create mode 100644 apps/tui/src/lib/parser/registry.ts create mode 100644 apps/tui/src/lib/parser/types.ts diff --git a/apps/tui/src/lib/parser/extractors/index.ts b/apps/tui/src/lib/parser/extractors/index.ts new file mode 100644 index 00000000..0a42c44d --- /dev/null +++ b/apps/tui/src/lib/parser/extractors/index.ts @@ -0,0 +1,22 @@ +import { MarkdownExtractor } from './markdown.js'; +import { JsonExtractor } from './json.js'; +import { StructuredExtractor } from './structured.js'; +import type { ParserStrategy } from '../types.js'; + +export { MarkdownExtractor, JsonExtractor, StructuredExtractor }; + +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 MarkdownExtractor()); + registerExtractor(new JsonExtractor()); + registerExtractor(new StructuredExtractor()); +} \ No newline at end of file diff --git a/apps/tui/src/lib/parser/extractors/json.ts b/apps/tui/src/lib/parser/extractors/json.ts new file mode 100644 index 00000000..81dadf70 --- /dev/null +++ b/apps/tui/src/lib/parser/extractors/json.ts @@ -0,0 +1,37 @@ +import type { ParserStrategy, ParserResult, ParsedResponse, FileChange } from '../types.js'; + +export class JsonExtractor implements ParserStrategy { + name = 'json'; + + parse(raw: string): ParserResult { + try { + const match = raw.match(/\{[\s\S]*\}/); + if (!match) { + return { success: false, error: 'No JSON found' }; + } + + const parsed = JSON.parse(match[0]); + const changes: FileChange[] = (parsed.changes || []).map((c: any) => ({ + path: c.file || c.path, + action: (c.action || 'create') as FileChange['action'], + content: c.content || c.code, + })); + + if (changes.length === 0) { + return { success: false, error: 'No changes in JSON' }; + } + + return { + success: true, + response: { + summary: parsed.summary || parsed.description || 'Generated structure', + changes, + raw, + parserUsed: this.name, + }, + }; + } catch (error) { + return { success: false, error: `JSON parse error: ${error}` }; + } + } +} \ No newline at end of file diff --git a/apps/tui/src/lib/parser/extractors/markdown.ts b/apps/tui/src/lib/parser/extractors/markdown.ts new file mode 100644 index 00000000..60116bdb --- /dev/null +++ b/apps/tui/src/lib/parser/extractors/markdown.ts @@ -0,0 +1,49 @@ +import type { ParserStrategy, ParserResult, ParsedResponse, FileChange } from '../types.js'; + +export class MarkdownExtractor implements ParserStrategy { + name = 'markdown'; + + parse(raw: string): ParserResult { + const changes: FileChange[] = []; + const fileBlockRegex = /FILE:\s*(.+?)\n```[\w]*\n([\s\S]*?)```/g; + + let match; + while ((match = fileBlockRegex.exec(raw)) !== null) { + changes.push({ + path: match[1].trim(), + action: 'create', + content: match[2].trim(), + }); + } + + if (changes.length === 0) { + return { success: false, error: 'No file blocks found' }; + } + + const summary = this.extractSummary(raw); + + return { + success: true, + response: { + summary, + changes, + raw, + parserUsed: this.name, + }, + }; + } + + private extractSummary(raw: string): string { + const lines = raw.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('```') && !trimmed.startsWith('FILE:')) { + const cleaned = trimmed.replace(/^#+\s*/, '').replace(/\*\*/g, ''); + if (cleaned.length > 10) { + return cleaned.slice(0, 200); + } + } + } + return 'Generated file structure'; + } +} \ No newline at end of file diff --git a/apps/tui/src/lib/parser/extractors/structured.ts b/apps/tui/src/lib/parser/extractors/structured.ts new file mode 100644 index 00000000..3c4f650f --- /dev/null +++ b/apps/tui/src/lib/parser/extractors/structured.ts @@ -0,0 +1,46 @@ +import type { ParserStrategy, ParserResult, FileChange } from '../types.js'; + +export class StructuredExtractor implements ParserStrategy { + name = 'structured'; + + parse(raw: string): ParserResult { + const changes: FileChange[] = []; + + const patterns = [ + /FILE:\s*(.+?)\n```[\w]*\n([\s\S]*?)```/g, + /]*>([\s\S]*?)<\/file>/gi, + /^(.+?\.ts)\n```[\w]*\n([\s\S]*?)```/gm, + ]; + + for (const pattern of patterns) { + let match; + while ((match = pattern.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 }); + } + } + } + + 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 match = raw.match(/^(?!```|FILE:|>|\s)(.+)/m); + return match ? match[1].trim().slice(0, 200) : 'Generated file structure'; + } +} \ No newline at end of file diff --git a/apps/tui/src/lib/parser/index.ts b/apps/tui/src/lib/parser/index.ts new file mode 100644 index 00000000..5939a65d --- /dev/null +++ b/apps/tui/src/lib/parser/index.ts @@ -0,0 +1,2 @@ +export * from './types.js'; +export { parse, parseWithStrategy, parseWithChain } from './registry.js'; \ No newline at end of file diff --git a/apps/tui/src/lib/parser/registry.ts b/apps/tui/src/lib/parser/registry.ts new file mode 100644 index 00000000..baf69488 --- /dev/null +++ b/apps/tui/src/lib/parser/registry.ts @@ -0,0 +1,46 @@ +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/tui/src/lib/parser/types.ts b/apps/tui/src/lib/parser/types.ts new file mode 100644 index 00000000..7080e859 --- /dev/null +++ b/apps/tui/src/lib/parser/types.ts @@ -0,0 +1,25 @@ +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 From 995d51dd3445e905904891c74e73ac64697a84a3 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 10 May 2026 01:43:59 +0530 Subject: [PATCH 08/44] feat: add file applicator --- .../tui/src/commands/freecode/file-applier.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 apps/tui/src/commands/freecode/file-applier.ts diff --git a/apps/tui/src/commands/freecode/file-applier.ts b/apps/tui/src/commands/freecode/file-applier.ts new file mode 100644 index 00000000..be409d82 --- /dev/null +++ b/apps/tui/src/commands/freecode/file-applier.ts @@ -0,0 +1,59 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { FileChange } from '../../lib/parser/types.js'; +import { logger } from '../../lib/utils/logger.js'; +import { ok, err, type Result } from '../../lib/utils/result.js'; + +export interface ApplyResult { + path: string; + success: boolean; + error?: string; +} + +export async function applyFileChange( + change: FileChange, + basePath: string +): Promise> { + const fullPath = path.join(basePath, change.path); + + try { + if (change.action === 'delete') { + if (fs.existsSync(fullPath)) { + fs.unlinkSync(fullPath); + logger.info('Deleted file', { path: change.path }); + } + return ok({ path: change.path, success: true }); + } + + const dir = path.dirname(fullPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (change.content !== undefined) { + fs.writeFileSync(fullPath, change.content, 'utf-8'); + logger.info('Wrote file', { 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 From 7cbc58c76af9142e1f644393c373056cec8a5470 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 10 May 2026 01:48:58 +0530 Subject: [PATCH 09/44] feat: add scalable /freecode command with provider system --- apps/tui/src/commands/built-in.ts | 5 +- apps/tui/src/commands/freecode/executor.ts | 117 ++++++++++++++++++ apps/tui/src/commands/freecode/index.ts | 45 +++++++ .../tui/src/commands/freecode/provider-mgr.ts | 33 +++++ 4 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 apps/tui/src/commands/freecode/executor.ts create mode 100644 apps/tui/src/commands/freecode/index.ts create mode 100644 apps/tui/src/commands/freecode/provider-mgr.ts 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/executor.ts b/apps/tui/src/commands/freecode/executor.ts new file mode 100644 index 00000000..51afc0bc --- /dev/null +++ b/apps/tui/src/commands/freecode/executor.ts @@ -0,0 +1,117 @@ +import { PlaywrightBrowserController } from '../../lib/browser/controller.js'; +import { collectContext } from '../../lib/context/collector.js'; +import { parse } from '../../lib/parser/index.js'; +import { applyChanges } from './file-applier.js'; +import { type SelectedProvider } from './provider-mgr.js'; +import { logger } from '../../lib/utils/logger.js'; + +export interface ExecutorOptions { + prompt: string; + provider: SelectedProvider; + projectPath: string; + contextOptions?: { + maxDepth?: number; + ignorePatterns?: string[]; + }; +} + +export interface ExecutorResult { + success: boolean; + summary?: string; + filesCreated: number; + errors: string[]; +} + +export async function executePromptCycle( + options: ExecutorOptions, + onStatus: (message: string) => void +): Promise { + const { prompt, provider, projectPath, contextOptions } = options; + const errors: string[] = []; + + const controller = new PlaywrightBrowserController(); + + try { + onStatus('🔄 **Connecting to browser...**'); + await controller.connect(); + onStatus('✅ **Browser connected**'); + + onStatus('✅ **Loading ChatGPT...**'); + await controller.navigate(provider.definition); + onStatus(`✅ **${provider.name} loaded**`); + + onStatus('📁 **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.`; + + onStatus('📤 **Sending to ChatGPT...**'); + await controller.sendPrompt(fullPrompt); + onStatus('⏳ **Waiting for response...**'); + + const response = await controller.waitForResponse(); + onStatus('✅ **Response received**'); + + const parseResult = parse(response); + + if (!parseResult.success) { + errors.push(`Parse failed: ${parseResult.error}`); + onStatus('⚠️ **Could not parse response**'); + onStatus('```\n' + response.slice(0, 500) + '...\n```'); + return { success: false, filesCreated: 0, errors }; + } + + const parsedResponse = parseResult.response!; + onStatus(`📝 **Summary:** ${parsedResponse.summary}`); + + const fileChanges = parsedResponse.changes; + onStatus(`📋 **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); + }); + } + + onStatus(`✨ **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); + return { success: false, filesCreated: 0, errors }; + } finally { + await controller.disconnect(); + } +} \ 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..c5d7da56 --- /dev/null +++ b/apps/tui/src/commands/freecode/index.ts @@ -0,0 +1,45 @@ +import { registerCommand, type Command, type CommandContext } from '../index.js'; +import { selectProvider, formatProviderList } from './provider-mgr.js'; +import { executePromptCycle } from './executor.js'; +import { createDefaultProviders } from '../../lib/browser/providers/index.js'; + +createDefaultProviders(); + +const freecodeCommand: Command = { + name: 'freecode', + description: 'Send prompt to ChatGPT and apply file changes', + execute: async (args, ctx) => { + const userPrompt = args.join(' '); + + if (!userPrompt.trim()) { + ctx.showMessage(`**Usage:** /freecode + +**Example:** /freecode summarize this project and write at project.md + +**Available providers:** +${formatProviderList()}`); + return; + } + + const provider = selectProvider(); + if (!provider) { + ctx.showMessage('❌ **No provider available**'); + return; + } + + const projectPath = process.cwd(); + + const result = await executePromptCycle( + { prompt: userPrompt, provider, projectPath }, + (status) => ctx.showMessage(status) + ); + + if (!result.success && result.errors.length > 0) { + ctx.showMessage(`❌ **Errors:**\n${result.errors.map(e => `- ${e}`).join('\n')}`); + } + }, +}; + +export function registerFreecodeCommand(): void { + registerCommand(freecodeCommand); +} \ No newline at end of file diff --git a/apps/tui/src/commands/freecode/provider-mgr.ts b/apps/tui/src/commands/freecode/provider-mgr.ts new file mode 100644 index 00000000..6181bf9c --- /dev/null +++ b/apps/tui/src/commands/freecode/provider-mgr.ts @@ -0,0 +1,33 @@ +import { listProviders, type ProviderDefinition } from '../../lib/browser/providers/index.js'; +import { logger } from '../../lib/utils/logger.js'; + +export interface SelectedProvider { + id: string; + name: string; + definition: ProviderDefinition; +} + +export function selectProvider(providerId?: string): SelectedProvider | null { + const providers = listProviders(); + + if (providers.length === 0) { + logger.error('No providers registered'); + return null; + } + + if (providerId) { + const found = providers.find(p => p.id === providerId); + if (found) { + return { id: found.id, name: found.name, definition: found }; + } + logger.warn(`Provider ${providerId} not found, using default`); + } + + const defaultProvider = providers[0]; + return { id: defaultProvider.id, name: defaultProvider.name, definition: defaultProvider }; +} + +export function formatProviderList(): string { + const providers = listProviders(); + return providers.map(p => `- **${p.id}** - ${p.name}`).join('\n'); +} \ No newline at end of file From e272bb4cff8a615594d80994ef3f70bc06569431 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 10 May 2026 10:45:31 +0530 Subject: [PATCH 10/44] feat: improve response parsing, robust polling, and browser interaction logic for ChatGPT provider --- apps/tui/src/commands/freecode/executor.ts | 14 +++++ apps/tui/src/lib/browser/controller.ts | 40 ++++++++++++-- apps/tui/src/lib/browser/providers/chatgpt.ts | 10 +++- apps/tui/src/lib/browser/providers/types.ts | 1 + .../src/lib/parser/extractors/structured.ts | 55 +++++++++++++------ 5 files changed, 94 insertions(+), 26 deletions(-) diff --git a/apps/tui/src/commands/freecode/executor.ts b/apps/tui/src/commands/freecode/executor.ts index 51afc0bc..36f1fbb9 100644 --- a/apps/tui/src/commands/freecode/executor.ts +++ b/apps/tui/src/commands/freecode/executor.ts @@ -4,6 +4,9 @@ import { parse } from '../../lib/parser/index.js'; import { applyChanges } from './file-applier.js'; import { type SelectedProvider } from './provider-mgr.js'; import { logger } from '../../lib/utils/logger.js'; +import { createDefaultStrategies } from '../../lib/context/strategies/index.js'; + +createDefaultStrategies(); export interface ExecutorOptions { prompt: string; @@ -73,8 +76,19 @@ Create or modify files as needed to complete the task.`; const response = await controller.waitForResponse(); onStatus('✅ **Response received**'); + logger.info('Raw response length', { length: response.length }); + logger.debug('Response preview', { preview: response.slice(0, 300) }); + + if (response.length < 50) { + errors.push(`Response too short (${response.length} chars): ${response}`); + onStatus(`⚠️ **Response too short:** ${response}`); + return { success: false, filesCreated: 0, errors }; + } + const parseResult = parse(response); + logger.debug('Parse result', { success: parseResult.success, error: parseResult.error }); + if (!parseResult.success) { errors.push(`Parse failed: ${parseResult.error}`); onStatus('⚠️ **Could not parse response**'); diff --git a/apps/tui/src/lib/browser/controller.ts b/apps/tui/src/lib/browser/controller.ts index 4cca15eb..30132d65 100644 --- a/apps/tui/src/lib/browser/controller.ts +++ b/apps/tui/src/lib/browser/controller.ts @@ -64,6 +64,11 @@ export class PlaywrightBrowserController implements BrowserController { 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); @@ -75,15 +80,38 @@ export class PlaywrightBrowserController implements BrowserController { throw new Error('Not connected or adapter not set'); } - logger.debug('Waiting for streaming to complete'); - while (await this.adapter.isStreaming(this.page)) { - await this.page.waitForTimeout(500); + 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); } - await this.page.waitForTimeout(1000); + // 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); - const responseLocator = this.adapter.getResponseLocator(this.page); - return responseLocator.innerText(); + // 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 { diff --git a/apps/tui/src/lib/browser/providers/chatgpt.ts b/apps/tui/src/lib/browser/providers/chatgpt.ts index 3234d006..d05bd262 100644 --- a/apps/tui/src/lib/browser/providers/chatgpt.ts +++ b/apps/tui/src/lib/browser/providers/chatgpt.ts @@ -5,15 +5,19 @@ export class ChatGPTAdapter implements PageAdapter { name = 'chatgpt'; getInputLocator(page: Page): Locator { - return page.locator('textarea'); + 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"]'); + return page.locator('button[data-testid="send-button"]').first(); } getResponseLocator(page: Page): Locator { - return page.locator('[data-testid="turn"]').last(); + return page.locator('div[data-message-author-role="assistant"]').last(); } async isStreaming(page: Page): Promise { diff --git a/apps/tui/src/lib/browser/providers/types.ts b/apps/tui/src/lib/browser/providers/types.ts index 2ff57eb2..010f987b 100644 --- a/apps/tui/src/lib/browser/providers/types.ts +++ b/apps/tui/src/lib/browser/providers/types.ts @@ -3,6 +3,7 @@ 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; diff --git a/apps/tui/src/lib/parser/extractors/structured.ts b/apps/tui/src/lib/parser/extractors/structured.ts index 3c4f650f..44c23258 100644 --- a/apps/tui/src/lib/parser/extractors/structured.ts +++ b/apps/tui/src/lib/parser/extractors/structured.ts @@ -6,21 +6,32 @@ export class StructuredExtractor implements ParserStrategy { parse(raw: string): ParserResult { const changes: FileChange[] = []; - const patterns = [ - /FILE:\s*(.+?)\n```[\w]*\n([\s\S]*?)```/g, - /]*>([\s\S]*?)<\/file>/gi, - /^(.+?\.ts)\n```[\w]*\n([\s\S]*?)```/gm, - ]; - - for (const pattern of patterns) { - let match; - while ((match = pattern.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 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) + // Matches "FILE: path\n\nContent until next FILE: or end" + 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() }); } } @@ -40,7 +51,17 @@ export class StructuredExtractor implements ParserStrategy { } private extractSummary(raw: string): string { - const match = raw.match(/^(?!```|FILE:|>|\s)(.+)/m); - return match ? match[1].trim().slice(0, 200) : 'Generated file structure'; + // Remove FILE: lines and code blocks for summary extraction + 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 From 556c37ae45ee56431d41859510d6aaeefd887cd2 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 10 May 2026 12:22:59 +0530 Subject: [PATCH 11/44] docs: add agent development guide to CLAUDE.md --- CLAUDE.md | 231 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8e533a7c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,231 @@ +# FreeCode Agent Guide + +> How to work on this codebase — architectural principles, patterns, and practices. + +## Project Overview + +FreeCode is a CLI tool that drives ChatGPT (via Playwright/CDP) to assist with coding tasks. The architecture consists of: + +- **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: ChatGPT first returns which files it needs, then receives those files + prompt and returns structured file changes. + +--- + +## 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 + +### React Component Guidelines + +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. + +--- + +## Project Structure + +``` +freecode/ +├── apps/ +│ ├── cli/ # CLI backend (Node.js/TypeScript) +│ │ └── src/ +│ │ ├── index.ts # Entry point +│ │ ├── cli.ts # REPL orchestration +│ │ ├── browser/ # Playwright + CDP controller +│ │ │ ├── controller.ts +│ │ │ ├── chatgpt-adapter.ts +│ │ │ └── types.ts +│ │ ├── context/ # Two-phase context engine +│ │ │ ├── engine.ts +│ │ │ └── file-tree.ts +│ │ ├── parser/ # Format-agnostic response parser +│ │ │ ├── index.ts +│ │ │ ├── json-parser.ts +│ │ │ ├── markdown-parser.ts +│ │ │ └── types.ts +│ │ ├── applier/ # File application with diff preview +│ │ │ ├── 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 +│ └── src/ +│ └── types.ts +└── docs/ + └── superpowers/ + ├── specs/ # Design specifications + └── plans/ # Implementation plans +``` + +--- + +## Component Design Patterns + +### Message Parts Pattern + +Messages contain typed `parts`: + +```typescript +type MessagePart = + | { type: 'text'; content: string } + | { type: 'code'; language: string; content: string } + | { type: 'tool'; tool: { name: string; args: Record }; result?: string } +``` + +Each part type has its own component (`TextPart`, `CodePart`, `ToolPart`). The parent `Message` component switches on type: + +```typescript +// In AssistantMessage.tsx +{message.parts.map((part, i) => { + switch (part.type) { + case 'text': return + case 'code': return + case 'tool': return + } +})} +``` + +### Store Pattern + +Each store is in its own file with co-located types: + +```typescript +// stores/chat-store.ts +interface ChatStore { + messages: Message[] + status: 'idle' | 'streaming' | 'error' + // ... +} +export const useChatStore = create((set) => ({ /* ... */ })) +``` + +Export from `stores/index.ts` for clean imports: + +```typescript +export { useChatStore, type Message, type MessagePart } from './chat-store' +``` + +### Hook Pattern + +Custom hooks encapsulate logic and state: + +```typescript +// hooks/useAutoResize.ts +export function useAutoResize(options: UseAutoResizeOptions = {}) { + const textareaRef = useRef(null) + const resize = useCallback(() => { /* ... */ }, []) + return { textareaRef, resize } +} +``` + +--- + +## 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` | + +--- + +## 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 +- **UI components** (`apps/tui/src/components/`) — React components + +### 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 `lib/` or `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 + +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 + +--- + +## Deferred Items (Not Yet Implemented) + +- 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 + +Don't implement these unless explicitly requested. \ No newline at end of file From 8a096ec7be10465f7da713dc523aa8154b06de53 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 11 May 2026 15:46:45 +0530 Subject: [PATCH 12/44] feat: add initial README for FreeCode TUI with setup, run, and usage instructions --- apps/tui/README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 apps/tui/README.md 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 From 262f54907825ccb965a9ce160df37227b301062f Mon Sep 17 00:00:00 2001 From: Ayan De Date: Thu, 21 May 2026 13:46:34 +0530 Subject: [PATCH 13/44] feat: initialize CLI app with package.json, TypeScript configuration, and tool definitions --- apps/cli/package.json | 17 +++++++++++++++++ apps/cli/src/tools/index.ts | 21 +++++++++++++++++++++ apps/cli/src/tools/types.ts | 25 +++++++++++++++++++++++++ apps/cli/tsconfig.json | 18 ++++++++++++++++++ pnpm-lock.yaml | 12 ++++++++++++ 5 files changed, 93 insertions(+) create mode 100644 apps/cli/package.json create mode 100644 apps/cli/src/tools/index.ts create mode 100644 apps/cli/src/tools/types.ts create mode 100644 apps/cli/tsconfig.json diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 00000000..650d8105 --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,17 @@ +{ + "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" + }, + "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/tools/index.ts b/apps/cli/src/tools/index.ts new file mode 100644 index 00000000..4a3cfe2f --- /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: ToolId): ToolDef | undefined { + return tools[id] 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/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/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/pnpm-lock.yaml b/pnpm-lock.yaml index a61fb8af..d15f42bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,18 @@ importers: specifier: 5.9.2 version: 5.9.2 + apps/cli: + devDependencies: + '@types/node': + specifier: ^22.15.3 + version: 22.15.3 + tsx: + specifier: ^4.0.0 + version: 4.21.0 + typescript: + specifier: ^5.7.0 + version: 5.9.2 + apps/docs: dependencies: '@repo/ui': From 9802f54bcef3c589c413dbb9169ee88356f3b63e Mon Sep 17 00:00:00 2001 From: Ayan De Date: Thu, 21 May 2026 13:47:08 +0530 Subject: [PATCH 14/44] feat: implement ReadTool for reading file contents with support for directories and binary file detection --- apps/cli/src/tools/read.ts | 118 +++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 apps/cli/src/tools/read.ts 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 From 13a108462ca02203670368a5c15b5d91214f1998 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Thu, 21 May 2026 13:47:15 +0530 Subject: [PATCH 15/44] feat: implement WriteTool for creating and overwriting files with directory support --- apps/cli/src/tools/write.ts | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 apps/cli/src/tools/write.ts 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 From 6f3376a8413aea28d4f10c75585ca999dc4a8686 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Thu, 21 May 2026 13:47:25 +0530 Subject: [PATCH 16/44] feat: implement JSON-RPC server for tool management and execution --- apps/cli/src/server.ts | 95 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 apps/cli/src/server.ts diff --git a/apps/cli/src/server.ts b/apps/cli/src/server.ts new file mode 100644 index 00000000..8100608a --- /dev/null +++ b/apps/cli/src/server.ts @@ -0,0 +1,95 @@ +import { getTool, listTools, tools } from "./tools/index.js" +import type { ToolContext } from "./tools/types.js" + +type JsonRpcRequest = { + jsonrpc: "2.0" + id: number | string + method: string + params?: Record +} + +type JsonRpcResponse = { + jsonrpc: "2.0" + id: number | string + result?: unknown + error?: { code: number; message: string; data?: unknown } +} + +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 } } +} + +const methodHandlers: Record) => Promise> = { + "tools.list": async () => listTools(), + + "tools.call": async (params: Record) => { + const { name, args } = params as { name: string; args: Record } + const tool = getTool(name as any) + if (!tool) { + throw new Error(`Tool not found: ${name}`) + } + const ctx: ToolContext = { cwd: process.cwd() } + return tool.execute(args, ctx) + }, +} + +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) + } +} + +async function main() { + 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 + const response = 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 From 00054dde1c4281cc3e7720f89c81c8d432f02e51 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Thu, 21 May 2026 13:47:36 +0530 Subject: [PATCH 17/44] feat: implement CLI client for JSON-RPC communication with tools --- apps/tui/src/ipc/client.ts | 119 +++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 apps/tui/src/ipc/client.ts diff --git a/apps/tui/src/ipc/client.ts b/apps/tui/src/ipc/client.ts new file mode 100644 index 00000000..2c7e86b6 --- /dev/null +++ b/apps/tui/src/ipc/client.ts @@ -0,0 +1,119 @@ +import { spawn, type ChildProcess } from "child_process" + +type JsonRpcRequest = { + jsonrpc: "2.0" + id: number | string + method: string + params?: Record +} + +type JsonRpcResponse = { + jsonrpc: "2.0" + id: number | string + result?: unknown + error?: { code: number; message: string; data?: unknown } +} + +let requestId = 0 +let cliProcess: ChildProcess | null = null +let messageBuffer = "" +let pendingRequests = new Map void; reject: (error: Error) => void }>() + +function generateId(): number { + return ++requestId +} + +export interface ToolCallResult { + title: string + output: string + metadata?: Record +} + +export interface ToolListItem { + id: string + description: string +} + +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 {} + } + return responses +} + +export function startCli(): void { + if (cliProcess) return + + cliProcess = spawn("node", ["apps/cli/src/server.ts"], { + cwd: "/home/ayande/Project/freecode", + 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 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 ToolCallResult +} + +export function stopCli(): void { + if (cliProcess) { + cliProcess.kill() + cliProcess = null + } +} \ No newline at end of file From 342a7bfe58b951c0552cfb7023c99d9f4ef103c2 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Thu, 21 May 2026 13:47:42 +0530 Subject: [PATCH 18/44] feat: refactor file change application to use callTool for file operations --- .../tui/src/commands/freecode/file-applier.ts | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/apps/tui/src/commands/freecode/file-applier.ts b/apps/tui/src/commands/freecode/file-applier.ts index be409d82..e56adbb1 100644 --- a/apps/tui/src/commands/freecode/file-applier.ts +++ b/apps/tui/src/commands/freecode/file-applier.ts @@ -1,9 +1,10 @@ -import * as fs from 'fs'; -import * as path from 'path'; +import { callTool, startCli } from '../../ipc/client.js'; import type { FileChange } from '../../lib/parser/types.js'; import { logger } from '../../lib/utils/logger.js'; import { ok, err, type Result } from '../../lib/utils/result.js'; +startCli(); + export interface ApplyResult { path: string; success: boolean; @@ -14,24 +15,17 @@ export async function applyFileChange( change: FileChange, basePath: string ): Promise> { - const fullPath = path.join(basePath, change.path); + const fullPath = change.path.startsWith('/') ? change.path : `${basePath}/${change.path}`; try { if (change.action === 'delete') { - if (fs.existsSync(fullPath)) { - fs.unlinkSync(fullPath); - logger.info('Deleted file', { path: change.path }); - } + const result = await callTool('write', { filePath: fullPath, content: '' }); + logger.info('Delete requested', { path: change.path }); return ok({ path: change.path, success: true }); } - const dir = path.dirname(fullPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - if (change.content !== undefined) { - fs.writeFileSync(fullPath, change.content, 'utf-8'); + await callTool('write', { filePath: fullPath, content: change.content }); logger.info('Wrote file', { path: change.path, size: change.content.length }); return ok({ path: change.path, success: true }); } From 7e22ac8a5caeaadc0877088cc820ccab743d63d7 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Thu, 21 May 2026 15:03:28 +0530 Subject: [PATCH 19/44] design: vscode chat extension spec --- .../specs/2026-05-21-vscode-chat-design.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-21-vscode-chat-design.md diff --git a/docs/superpowers/specs/2026-05-21-vscode-chat-design.md b/docs/superpowers/specs/2026-05-21-vscode-chat-design.md new file mode 100644 index 00000000..a1a0ab02 --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-vscode-chat-design.md @@ -0,0 +1,183 @@ +# VS Code Extension — Chat Interface + +## Context + +FreeCode has two apps: +- **CLI** (`apps/cli/`) — JSON-RPC server over stdin/stdout exposing `tools.list` and `tools.call` +- **TUI** (`apps/tui/`) — React terminal UI that spawns CLI as child process and communicates via JSON-RPC + +The CLI backend handles browser automation (Playwright), context collection, response parsing, and file application. TUI delegates all AI interaction to CLI via IPC. + +**Goal:** Create a VS Code extension (`apps/vscode/`) with a chat interface similar to the Claude VS Code extension. The extension will connect to the CLI backend via IPC (same pattern as TUI) and render messages with code blocks, tool results, etc. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ VS Code Extension │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ Chat UI │ │ Stores │ │ IPC Client │ │ +│ │ (React) │ │ (Zustand) │ │ (JSON-RPC to CLI) │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ +└──────────────────────────┬──────────────────────────────────┘ + │ spawn / connect + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ CLI Backend │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ JSON-RPC │ │ Browser │ │ Parser / Applier │ │ +│ │ Server │ │ Controller │ │ │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ ChatGPT (Browser) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Project Structure + +``` +apps/ +└── vscode/ + ├── package.json # VS Code extension manifest + ├── src/ + │ ├── extension.ts # Entry point, activates extension + │ ├── chat/ + │ │ ├── ChatView.tsx # Main chat panel component + │ │ ├── MessageList.tsx + │ │ ├── MessageInput.tsx + │ │ └── parts/ # Message part renderers + │ │ ├── TextPart.tsx + │ │ ├── CodePart.tsx + │ │ └── ToolPart.tsx + │ ├── stores/ + │ │ ├── chat-store.ts + │ │ └── index.ts + │ ├── ipc/ + │ │ ├── client.ts # JSON-RPC client to CLI + │ │ └── protocol.ts + │ └── lib/ + │ └── types.ts + └── webview/ + └── App.tsx # Webview entry point +``` + +--- + +## IPC Protocol + +The VS Code extension communicates with CLI using JSON-RPC 2.0 over stdin/stdout (same as TUI): + +### Methods + +| Method | Params | Returns | Description | +|--------|--------|---------|-------------| +| `tools.list` | — | `ToolListItem[]` | List available tools | +| `tools.call` | `{ name: string, args: Record }` | `ToolCallResult` | Execute a tool | +| `session.start` | `{ projectPath: string }` | `{ sessionId: string }` | Start a new session | +| `session.send` | `{ sessionId: string, prompt: string }` | `StreamResponse` | Send prompt, stream response | + +### Types + +```typescript +interface ToolListItem { + id: string; + description: string; +} + +interface ToolCallResult { + title: string; + output: string; + metadata?: Record; +} + +interface StreamResponse { + type: 'text' | 'code' | 'tool' | 'done' | 'error'; + content: string; +} +``` + +--- + +## Chat UI Components + +### ChatView (Main Panel) +- Container for the entire chat interface +- Registers with VS Code's ViewContainer +- Manages webview lifecycle + +### MessageList +- Renders array of `Message` objects +- Auto-scrolls to bottom on new messages +- Supports streaming updates + +### MessageInput +- Multi-line text input (textarea) +- Submit on Cmd/Ctrl+Enter +- Disabled during streaming + +### Message Part Renderers +- `TextPart` — Plain text with markdown rendering +- `CodePart` — Syntax highlighted code blocks with copy button +- `ToolPart` — Tool execution result with expand/collapse + +--- + +## Data Flow + +1. **User types prompt** → MessageInput +2. **User submits** (Cmd+Enter) → IPC client sends to CLI +3. **CLI parses response** → streams parts via JSON-RPC +4. **IPC client receives** → updates chat store +5. **React re-renders** → MessageList shows new content +6. **CLI applies file changes** → writes to disk +7. **Tool result** → shown as ToolPart in message + +--- + +## Implementation Steps + +1. **Scaffold VS Code extension** with `vscode.packagejson` and TypeScript config +2. **Create IPC client** (adapt from TUI's `apps/tui/src/ipc/client.ts`) +3. **Build chat UI** with React webview +4. **Implement stores** with Zustand +5. **Wire up message rendering** with part components +6. **Add streaming support** for real-time updates +7. **Test end-to-end** with CLI backend + +--- + +## Tech Stack + +- **VS Code API** — Extension activation, ViewContainer, Webview +- **React 18** — UI components +- **Zustand** — State management +- **@vscode/webview-ui-toolkit** — UI components (optional) +- **TypeScript** — Throughout + +--- + +## Key Differences from TUI + +| Aspect | TUI | VS Code | +|--------|-----|---------| +| Terminal | xterm.js + React DOM overlay | VS Code Webview API | +| Input | Single-line REPL | Multi-line textarea | +| Output | Stream to terminal | Render in chat panel | +| File changes | CLI applies directly | CLI applies, VS Code shows diff | +| Session | Ephemeral | Persisted in workspace | + +--- + +## Not in Scope (v1) + +- Inline completions / ghost text +- CMD+K quick prompt +- Multiple chat sessions +- Voice input \ No newline at end of file From 2670c76965f2864bc695ea579de844183a580575 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Thu, 21 May 2026 15:06:06 +0530 Subject: [PATCH 20/44] plan: vscode chat extension implementation --- .../plans/2026-05-21-vscode-chat-plan.md | 893 ++++++++++++++++++ 1 file changed, 893 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-vscode-chat-plan.md diff --git a/docs/superpowers/plans/2026-05-21-vscode-chat-plan.md b/docs/superpowers/plans/2026-05-21-vscode-chat-plan.md new file mode 100644 index 00000000..3dc16046 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-vscode-chat-plan.md @@ -0,0 +1,893 @@ +# VS Code Chat Extension Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a VS Code extension with chat interface that connects to the CLI backend via JSON-RPC, similar to how the TUI connects. + +**Architecture:** VS Code extension uses VS Code Webview API to render a React-based chat UI. Communication with CLI backend happens via JSON-RPC over stdin/stdout (same pattern as TUI). The CLI handles browser automation and AI interaction. + +**Tech Stack:** VS Code API, React 18, TypeScript, Zustand + +--- + +## File Structure + +``` +apps/vscode/ +├── package.json +├── tsconfig.json +├── src/ +│ ├── extension.ts # Entry point, activates extension +│ ├── lib/ +│ │ └── types.ts # Shared types +│ ├── ipc/ +│ │ ├── client.ts # JSON-RPC client to CLI +│ │ └── protocol.ts # IPC protocol types +│ ├── stores/ +│ │ ├── chat-store.ts # Zustand store +│ │ └── index.ts +│ ├── chat/ +│ │ ├── ChatView.tsx # Main chat panel (webview) +│ │ ├── MessageList.tsx +│ │ ├── MessageInput.tsx +│ │ ├── Message.tsx +│ │ └── parts/ +│ │ ├── TextPart.tsx +│ │ ├── CodePart.tsx +│ │ └── ToolPart.tsx +│ └── webview/ +│ └── App.tsx # Webview root component +``` + +--- + +## Task 1: Scaffold VS Code Extension + +**Files:** +- Create: `apps/vscode/package.json` +- Create: `apps/vscode/tsconfig.json` +- Create: `apps/vscode/src/extension.ts` +- Create: `apps/vscode/src/lib/types.ts` + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "freecode", + "displayName": "FreeCode", + "description": "AI coding assistant with chat interface", + "version": "0.1.0", + "engines": { + "vscode": "^1.88.0" + }, + "activationEvents": ["onView:freecode.chat"], + "main": "./dist/extension.js", + "contributes": { + "viewsContainers": { + "panel": [ + { + "id": "freecode.chat", + "title": "FreeCode Chat", + "icon": "$(chat)" + } + ] + }, + "views": { + "panel": [ + { + "id": "freecode.chat", + "type": "webview", + "屏领": "Chat" + } + ] + } + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.88.0", + "typescript": "^5.4.0" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zustand": "^4.5.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} +``` + +- [ ] **Step 3: Create src/lib/types.ts** + +```typescript +export interface ToolListItem { + id: string; + description: string; +} + +export interface ToolCallResult { + title: string; + output: string; + metadata?: Record; +} + +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 }; +``` + +- [ ] **Step 4: Create src/extension.ts** + +```typescript +import * as vscode from 'vscode'; +import { ChatView } from './chat/ChatView.js'; + +export function activate(context: vscode.ExtensionContext) { + const chatView = new ChatView(context); + + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + 'freecode.chat', + chatView + ) + ); +} + +export function deactivate() {} +``` + +- [ ] **Step 5: Commit** + +```bash +cd /home/ayande/Project/freecode +git add apps/vscode/package.json apps/vscode/tsconfig.json apps/vscode/src/ +git commit -m "feat(vscode): scaffold extension with basic manifest and types" +``` + +--- + +## Task 2: IPC Client + +**Files:** +- Create: `apps/vscode/src/ipc/protocol.ts` +- Create: `apps/vscode/src/ipc/client.ts` + +- [ ] **Step 1: Create src/ipc/protocol.ts** + +```typescript +export interface JsonRpcRequest { + jsonrpc: '2.0'; + id: number | string; + method: string; + params?: Record; +} + +export interface JsonRpcResponse { + jsonrpc: '2.0'; + id: number | string; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +export interface StreamResponse { + type: 'text' | 'code' | 'tool' | 'done' | 'error'; + content: string; +} +``` + +- [ ] **Step 2: Create src/ipc/client.ts** (adapted from TUI) + +```typescript +import { spawn, type ChildProcess } from 'child_process'; +import type { JsonRpcRequest, JsonRpcResponse, ToolListItem, ToolCallResult } from './protocol.js'; + +let requestId = 0; +let cliProcess: ChildProcess | null = null; +let messageBuffer = ''; +let pendingRequests = new Map 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 {} + } + return responses; +} + +export function startCli(): void { + if (cliProcess) return; + + cliProcess = spawn('node', ['apps/cli/src/server.ts'], { + cwd: '/home/ayande/Project/freecode', + 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', () => { + 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 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 ToolCallResult; +} + +export function stopCli(): void { + if (cliProcess) { + cliProcess.kill(); + cliProcess = null; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +cd /home/ayande/Project/freecode +git add apps/vscode/src/ipc/ +git commit -m "feat(vscode): add IPC client for CLI communication" +``` + +--- + +## Task 3: Chat Store + +**Files:** +- Create: `apps/vscode/src/stores/chat-store.ts` +- Create: `apps/vscode/src/stores/index.ts` + +- [ ] **Step 1: Create src/stores/chat-store.ts** + +```typescript +import { create } from 'zustand'; +import type { Message, MessagePart } from '../lib/types.js'; + +interface ChatStore { + messages: Message[]; + status: 'idle' | 'streaming' | 'error'; + error: string | null; + addMessage: (role: 'user' | 'assistant', parts: MessagePart[]) => void; + addPartToLastMessage: (part: MessagePart) => void; + updateLastMessagePart: (index: number, part: MessagePart) => void; + setStatus: (status: 'idle' | 'streaming' | 'error') => void; + setError: (error: string | null) => void; + clearMessages: () => void; +} + +let messageCounter = 0; + +export const useChatStore = create((set) => ({ + messages: [], + status: 'idle', + error: null, + + addMessage: (role, parts) => + set((state) => ({ + messages: [ + ...state.messages, + { + id: `msg-${++messageCounter}`, + role, + parts, + timestamp: Date.now(), + }, + ], + })), + + addPartToLastMessage: (part) => + set((state) => { + if (state.messages.length === 0) return state; + const lastMessage = state.messages[state.messages.length - 1]; + return { + messages: [ + ...state.messages.slice(0, -1), + { ...lastMessage, parts: [...lastMessage.parts, part] }, + ], + }; + }), + + updateLastMessagePart: (index, part) => + set((state) => { + if (state.messages.length === 0) return state; + const lastMessage = state.messages[state.messages.length - 1]; + const newParts = [...lastMessage.parts]; + newParts[index] = part; + return { + messages: [ + ...state.messages.slice(0, -1), + { ...lastMessage, parts: newParts }, + ], + }; + }), + + setStatus: (status) => set({ status }), + setError: (error) => set({ error }), + clearMessages: () => set({ messages: [], status: 'idle', error: null }), +})); +``` + +- [ ] **Step 2: Create src/stores/index.ts** + +```typescript +export { useChatStore } from './chat-store.js'; +export type { Message, MessagePart } from '../lib/types.js'; +``` + +- [ ] **Step 3: Commit** + +```bash +cd /home/ayande/Project/freecode +git add apps/vscode/src/stores/ +git commit -m "feat(vscode): add chat store with Zustand" +``` + +--- + +## Task 4: Message Part Components + +**Files:** +- Create: `apps/vscode/src/chat/parts/TextPart.tsx` +- Create: `apps/vscode/src/chat/parts/CodePart.tsx` +- Create: `apps/vscode/src/chat/parts/ToolPart.tsx` + +- [ ] **Step 1: Create src/chat/parts/TextPart.tsx** + +```typescript +import React from 'react'; + +interface TextPartProps { + content: string; +} + +export const TextPart: React.FC = ({ content }) => { + return ( +

+ {content} +
+ ); +}; +``` + +- [ ] **Step 2: Create src/chat/parts/CodePart.tsx** + +```typescript +import React, { useState } from 'react'; + +interface CodePartProps { + language: string; + content: string; +} + +export const CodePart: React.FC = ({ language, content }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+ {language} + +
+
+        {content}
+      
+
+ ); +}; +``` + +- [ ] **Step 3: Create src/chat/parts/ToolPart.tsx** + +```typescript +import React, { useState } from 'react'; + +interface ToolPartProps { + tool: { name: string; args: Record }; + result?: string; +} + +export const ToolPart: React.FC = ({ tool, result }) => { + const [expanded, setExpanded] = useState(false); + + return ( +
+
setExpanded(!expanded)} + style={{ + padding: '8px 12px', + cursor: 'pointer', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center' + }} + > + + 🔧 {tool.name} + + + {expanded ? '▼' : '▶'} + +
+ {expanded && ( +
+
+ Arguments: {JSON.stringify(tool.args)} +
+ {result && ( +
+              {result}
+            
+ )} +
+ )} +
+ ); +}; +``` + +- [ ] **Step 4: Commit** + +```bash +cd /home/ayande/Project/freecode +git add apps/vscode/src/chat/parts/ +git commit -m "feat(vscode): add message part components" +``` + +--- + +## Task 5: Message Components + +**Files:** +- Create: `apps/vscode/src/chat/Message.tsx` +- Create: `apps/vscode/src/chat/MessageList.tsx` +- Create: `apps/vscode/src/chat/MessageInput.tsx` + +- [ ] **Step 1: Create src/chat/Message.tsx** + +```typescript +import React from 'react'; +import type { Message, MessagePart } 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 Message: 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 ; + } + })} +
+
+ ); +}; +``` + +- [ ] **Step 2: Create src/chat/MessageList.tsx** + +```typescript +import React, { useEffect, useRef } from 'react'; +import { useChatStore } from '../stores/index.js'; +import { Message } from './Message.js'; + +export const MessageList: React.FC = () => { + const messages = useChatStore((state) => state.messages); + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + return ( +
+ {messages.map((msg) => ( + + ))} +
+
+ ); +}; +``` + +- [ ] **Step 3: Create src/chat/MessageInput.tsx** + +```typescript +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 ( +
+