diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8e533a7c --- /dev/null +++ b/AGENTS.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 diff --git a/apps/tui/next-env.d.ts b/apps/tui/next-env.d.ts new file mode 100644 index 00000000..830fb594 --- /dev/null +++ b/apps/tui/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/tui/next.config.js b/apps/tui/next.config.js new file mode 100644 index 00000000..cf97dc63 --- /dev/null +++ b/apps/tui/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +} + +module.exports = nextConfig \ No newline at end of file diff --git a/apps/tui/package.json b/apps/tui/package.json new file mode 100644 index 00000000..296f65e5 --- /dev/null +++ b/apps/tui/package.json @@ -0,0 +1,24 @@ +{ + "name": "@freecode/tui", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "freecode": "./dist/index.js" + }, + "scripts": { + "dev": "tsx src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@earendil-works/pi-tui": "^0.74.0", + "chalk": "^5.5.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "tsx": "^4.0.0", + "typescript": "^5.7.0" + } +} \ No newline at end of file diff --git a/apps/tui/src/assets/logo.ts b/apps/tui/src/assets/logo.ts new file mode 100644 index 00000000..8dcf423e --- /dev/null +++ b/apps/tui/src/assets/logo.ts @@ -0,0 +1,10 @@ +export const logoLines = [ + ' ██████╗ ██████╗ ███████╗███████╗ ██████╗ ██████╗ ██████╗ ███████╗', + '██╔════╝ ██╔══██╗██╔════╝██╔════╝██╔════╝██╔═══██╗██╔══██╗██╔════╝', + '█████╗ ██████╔╝█████╗ █████╗ ██║ ██║ ██║██║ ██║█████╗ ', + '██╔══╝ ██╔══██╗██╔══╝ ██╔══╝ ██║ ██║ ██║██║ ██║██╔══╝ ', + '██║ ██║ ██║███████╗███████╗╚██████╗╚██████╔╝██████╔╝███████╗', + '╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝', +] + +export const logoTagline = 'AI-assisted coding — no API costs' \ No newline at end of file diff --git a/apps/tui/src/commands/built-in.ts b/apps/tui/src/commands/built-in.ts new file mode 100644 index 00000000..5ace6aaf --- /dev/null +++ b/apps/tui/src/commands/built-in.ts @@ -0,0 +1,47 @@ +import { registerCommand, type Command, type CommandContext } from "./index.js"; +import { AVAILABLE_MODELS } from "../models.js"; + +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`); + }, +}; + +const clearCommand: Command = { + name: "clear", + description: "Clear all messages", + execute: (_args, ctx) => { + ctx.showMessage("*Messages cleared*"); + }, +}; + +const exitCommand: Command = { + name: "exit", + description: "Exit FreeCode", + execute: () => { + process.exit(0); + }, +}; + +const modelCommand: Command = { + name: "model", + description: "Select AI model", + execute: (_args, ctx) => { + ctx.showMessage(`**Select AI Model:**\n\nUse the selector below to choose a model.`); + ctx.showModelSelector?.(); + }, +}; + +export function registerBuiltInCommands(): void { + registerCommand(helpCommand); + registerCommand(clearCommand); + registerCommand(exitCommand); + registerCommand(modelCommand); +} \ No newline at end of file diff --git a/apps/tui/src/commands/index.ts b/apps/tui/src/commands/index.ts new file mode 100644 index 00000000..cdb5258c --- /dev/null +++ b/apps/tui/src/commands/index.ts @@ -0,0 +1,55 @@ +import type { AutocompleteItem, SlashCommand } from "@earendil-works/pi-tui"; + +export interface CommandContext { + showMessage(content: string): void; + showModelSelector?(): void; +} + +export interface Command { + name: string; + description: string; + execute(args: string[], context: CommandContext): void | Promise; +} + +class CommandRegistry { + private commands = new Map(); + private autocompleteItems: AutocompleteItem[] = []; + + register(command: Command): void { + this.commands.set(command.name, command); + this.autocompleteItems.push({ + label: command.name, + value: command.name, + description: command.description, + }); + } + + get(name: string): Command | undefined { + return this.commands.get(name); + } + + getAll(): Command[] { + return Array.from(this.commands.values()); + } + + getAutocompleteItems(): AutocompleteItem[] { + return this.autocompleteItems; + } + + getSlashCommands(): SlashCommand[] { + return this.getAll().map((cmd) => ({ + name: cmd.name, + description: cmd.description, + })); + } +} + +export const commandRegistry = new CommandRegistry(); + +export function registerCommand(command: Command): void { + commandRegistry.register(command); +} + +export function getCommand(name: string): Command | undefined { + return commandRegistry.get(name); +} \ No newline at end of file diff --git a/apps/tui/src/index.ts b/apps/tui/src/index.ts new file mode 100644 index 00000000..e5ab835a --- /dev/null +++ b/apps/tui/src/index.ts @@ -0,0 +1,139 @@ +#!/usr/bin/env node +import { ProcessTerminal, TUI, Key, matchesKey, CombinedAutocompleteProvider, SelectList, type SelectItem, type SelectListTheme } from "@earendil-works/pi-tui"; +import { commandRegistry } from "./commands/index.js"; +import { registerBuiltInCommands } from "./commands/built-in.js"; +import { AVAILABLE_MODELS } from "./models.js"; +import { Editor } from "@earendil-works/pi-tui"; +import { Markdown } from "@earendil-works/pi-tui"; +import { Text } from "@earendil-works/pi-tui"; +import chalk from "chalk"; +import { defaultEditorTheme, defaultMarkdownTheme } from "./themes.js"; +import { logoLines, logoTagline } from "./assets/logo.js"; + +registerBuiltInCommands(); + +let tui: TUI; +let messageCount = 0; +let currentModel = "claude-sonnet-4-6"; +let modelSelector: SelectList | null = null; + +const terminal = new ProcessTerminal(); +tui = new TUI(terminal); + +const welcomeText = `${chalk.cyanBright(logoLines.join('\n'))} + +${chalk.dim(logoTagline)} + +Type your messages below. Press Ctrl+C to exit.`; + +tui.addChild(new Text(welcomeText)); + +const editor = new Editor(tui, defaultEditorTheme); + +const autocompleteProvider = new CombinedAutocompleteProvider( + commandRegistry.getSlashCommands(), + process.cwd(), + null, +); +editor.setAutocompleteProvider(autocompleteProvider); + +tui.addChild(editor); +tui.setFocus(editor); + +const defaultSelectListTheme: SelectListTheme = { + selectedPrefix: (text) => `❯ ${text}`, + selectedText: (text) => chalk.cyanBright(text), + description: (text) => chalk.dim(text), + scrollInfo: (text) => chalk.dim(text), + noMatch: (text) => chalk.red(text), +}; + +function showMessage(content: string): void { + const msg = new Markdown(content, 1, 1, defaultMarkdownTheme); + const children = tui.children; + children.splice(children.length - 1, 0, msg); + tui.requestRender(); +} + +function hideModelSelector(): void { + if (modelSelector) { + const idx = tui.children.indexOf(modelSelector); + if (idx !== -1) { + tui.children.splice(idx, 1); + } + modelSelector = null; + tui.setFocus(editor); + tui.requestRender(); + } +} + +function showModelSelector(): void { + hideModelSelector(); + + const modelItems: SelectItem[] = AVAILABLE_MODELS.map((m) => ({ + label: m.name, + value: m.id, + description: m.description, + })); + + const maxVisible = Math.min(modelItems.length, 5); + modelSelector = new SelectList(modelItems, maxVisible, defaultSelectListTheme); + + modelSelector.onSelect = (item: SelectItem) => { + currentModel = item.value; + const model = AVAILABLE_MODELS.find((m) => m.id === item.value); + showMessage(`**Model changed to:** ${model?.name ?? item.value}`); + hideModelSelector(); + }; + + modelSelector.onCancel = () => { + hideModelSelector(); + }; + + const editorIdx = tui.children.indexOf(editor); + tui.children.splice(editorIdx + 1, 0, modelSelector); + tui.setFocus(modelSelector); + tui.requestRender(); +} + +editor.onSubmit = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return; + + if (trimmed.startsWith("/")) { + const parts = trimmed.slice(1).split(/\s+/); + const commandName = parts[0]?.toLowerCase(); + const args = parts.slice(1); + + if (commandName) { + const command = commandRegistry.get(commandName); + if (command) { + command.execute(args, { showMessage, showModelSelector }); + return; + } else { + showMessage(`**Error:** Unknown command: /${commandName}. Type /help for available commands.`); + return; + } + } + } + + messageCount++; + showMessage(`**You:** ${trimmed}`); + + setTimeout(() => { + showMessage(`**FreeCode:** Message ${messageCount} received!`); + }, 500); +}; + +// Handle Ctrl+C for clean exit from keyboard +tui.addInputListener((data) => { + if (matchesKey(data, Key.ctrl("c"))) { + if (tui) { + tui.stop(); + } + process.exit(0); + } + return undefined; +}); + +tui.start(); \ No newline at end of file diff --git a/apps/tui/src/models.ts b/apps/tui/src/models.ts new file mode 100644 index 00000000..0b44fe1c --- /dev/null +++ b/apps/tui/src/models.ts @@ -0,0 +1,5 @@ +export const AVAILABLE_MODELS = [ + { id: "claude-opus-4-7", name: "Claude Opus 4.7", description: "Most capable model for complex tasks" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", description: "Balanced performance and speed" }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", description: "Fast, efficient for simple tasks" }, +]; \ No newline at end of file diff --git a/apps/tui/src/themes.ts b/apps/tui/src/themes.ts new file mode 100644 index 00000000..3291f48e --- /dev/null +++ b/apps/tui/src/themes.ts @@ -0,0 +1,34 @@ +import { Chalk } from "chalk"; +import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@earendil-works/pi-tui"; + +const chalk = new Chalk({ level: 3 }); + +export const defaultSelectListTheme: SelectListTheme = { + selectedPrefix: (text: string) => chalk.blue(text), + selectedText: (text: string) => chalk.bold(text), + description: (text: string) => chalk.dim(text), + scrollInfo: (text: string) => chalk.dim(text), + noMatch: (text: string) => chalk.dim(text), +}; + +export const defaultMarkdownTheme: MarkdownTheme = { + heading: (text: string) => chalk.bold.cyan(text), + link: (text: string) => chalk.blue(text), + linkUrl: (text: string) => chalk.dim(text), + code: (text: string) => chalk.yellow(text), + codeBlock: (text: string) => chalk.green(text), + codeBlockBorder: (text: string) => chalk.dim(text), + quote: (text: string) => chalk.italic(text), + quoteBorder: (text: string) => chalk.dim(text), + hr: (text: string) => chalk.dim(text), + listBullet: (text: string) => chalk.cyan(text), + bold: (text: string) => chalk.bold(text), + italic: (text: string) => chalk.italic(text), + strikethrough: (text: string) => chalk.strikethrough(text), + underline: (text: string) => chalk.underline(text), +}; + +export const defaultEditorTheme: EditorTheme = { + borderColor: (text: string) => chalk.dim(text), + selectList: defaultSelectListTheme, +}; \ No newline at end of file diff --git a/apps/tui/tsconfig.json b/apps/tui/tsconfig.json new file mode 100644 index 00000000..fff4570f --- /dev/null +++ b/apps/tui/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react", + "jsxFactory": "React.createElement", + "jsxFragmentFactory": "React.Fragment", + "strict": true, + "outDir": "dist", + "rootDir": "src", + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} \ No newline at end of file diff --git a/docs/superpowers/plans/2026-05-08-freecode-tui-implementation.md b/docs/superpowers/plans/2026-05-08-freecode-tui-implementation.md new file mode 100644 index 00000000..709e0e84 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-freecode-tui-implementation.md @@ -0,0 +1,1159 @@ +# FreeCode TUI 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:** Build the React + xterm.js TUI app in `apps/tui` with the logo-on-idle pattern, chat message list, textarea prompt with auto-resize, and IPC bridge to backend. + +**Architecture:** The TUI is a Next.js app with layered architecture — xterm.js for terminal rendering, React DOM overlay for chat UI, Zustand stores for state, and a JSON-RPC IPC bridge connecting to the CLI backend. + +**Tech Stack:** Next.js, React 19, xterm.js, Zustand, TypeScript + +--- + +## File Structure + +``` +apps/tui/ # New TUI app (Next.js) +├── app/ +│ ├── layout.tsx # Root layout with providers +│ ├── page.tsx # Main TUI entry page +│ ├── globals.css # CSS variables, base styles +│ └── providers.tsx # React context providers +├── components/ +│ ├── Logo.tsx # ASCII logo, visible on idle +│ ├── PromptInput.tsx # Auto-resizing textarea prompt +│ ├── ChatLayout.tsx # Layout wrapper: logo + messages + input +│ ├── MessageList.tsx # Virtualized scrollable message list +│ ├── messages/ +│ │ ├── UserMessage.tsx # User message renderer +│ │ ├── AssistantMessage.tsx # Assistant message renderer +│ │ └── parts/ +│ │ ├── TextPart.tsx # Text content part +│ │ ├── CodePart.tsx # Code block part +│ │ └── ToolPart.tsx # Tool call result part +│ └── ui/ +│ ├── LayerStack.tsx # Toast/dialog/context menu stack +│ └── Toast.tsx # Toast notification component +├── stores/ +│ ├── chat-store.ts # Messages + status state +│ ├── ui-panel-store.ts # Panel visibility state +│ └── session-store.ts # Backend connection state +├── ipc/ +│ ├── bridge.ts # IPC bridge implementation +│ ├── protocol.ts # JSON-RPC message types +│ └── client.ts # IPC client for TUI +├── hooks/ +│ └── useAutoResize.ts # Auto-resize textarea hook +├── lib/ +│ └── keymap.ts # Keybinding registry +├── package.json +├── tsconfig.json +└── next.config.js + +packages/store/ # New shared store package (extracted later) +├── src/ +│ ├── chat-store.ts +│ ├── ui-panel-store.ts +│ ├── session-store.ts +│ └── index.ts +└── package.json +``` + +--- + +## Task 1: Scaffold TUI App + +**Files:** +- Create: `apps/tui/package.json` +- Create: `apps/tui/tsconfig.json` +- Create: `apps/tui/next.config.js` +- Create: `apps/tui/app/layout.tsx` +- Create: `apps/tui/app/globals.css` +- Create: `apps/tui/app/page.tsx` +- Modify: `turbo.json` (add TUI to pipeline) +- Modify: `package.json` (add workspace reference) + +- [ ] **Step 1: Create `apps/tui/package.json`** + +```json +{ + "name": "@freecode/tui", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.7.0" + } +} +``` + +- [ ] **Step 2: Create `apps/tui/tsconfig.json`** + +```json +{ + "extends": "@freecode/typescript-config/nextjs.json", + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "strict": true, + "noEmit": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} +``` + +- [ ] **Step 3: Create `apps/tui/next.config.js`** + +```js +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +} + +module.exports = nextConfig +``` + +- [ ] **Step 4: Create `apps/tui/app/layout.tsx`** + +```tsx +import type { Metadata } from 'next' +import './globals.css' + +export const metadata: Metadata = { + title: 'FreeCode', + description: 'AI-assisted coding via your ChatGPT/Claude session', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} +``` + +- [ ] **Step 5: Create `apps/tui/app/globals.css`** + +```css +:root { + --bg-primary: #0a0a0a; + --bg-secondary: #141414; + --bg-tertiary: #1e1e1e; + --text-primary: #e4e4e4; + --text-secondary: #a3a3a3; + --text-muted: #6b6b6b; + --accent: #3b82f6; + --accent-hover: #2563eb; + --border: #2a2a2a; + --success: #22c55e; + --error: #ef4444; + --warning: #f59e0b; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + background: var(--bg-primary); + color: var(--text-primary); + font-family: 'Geist Mono', 'Fira Code', 'Cascadia Code', monospace; +} + +#__next, main { + height: 100%; +} +``` + +- [ ] **Step 6: Create `apps/tui/app/page.tsx`** + +```tsx +'use client' + +import { ChatLayout } from '../components/ChatLayout' + +export default function TUIPage() { + return +} +``` + +- [ ] **Step 7: Modify `package.json` root to add workspace reference** + +Add to root `package.json` `workspaces`: +```json +"workspaces": [ + "apps/*", + "packages/*" +] +``` + +Or ensure `apps/tui` is listed in turbo pipeline in `turbo.json`. + +--- + +## Task 2: Create Zustand Stores + +**Files:** +- Create: `apps/tui/stores/chat-store.ts` +- Create: `apps/tui/stores/ui-panel-store.ts` +- Create: `apps/tui/stores/session-store.ts` +- Create: `apps/tui/stores/index.ts` + +- [ ] **Step 1: Create `apps/tui/stores/chat-store.ts`** + +```typescript +import { create } from 'zustand' + +export type MessagePart = + | { type: 'text'; content: string } + | { type: 'code'; language: string; content: string } + | { type: 'tool'; tool: { name: string; args: Record }; result?: string } + +export interface Message { + id: string + role: 'user' | 'assistant' | 'system' + parts: MessagePart[] + timestamp: number +} + +interface ChatStore { + messages: Message[] + status: 'idle' | 'streaming' | 'error' + hasStartedTyping: boolean + appendMessage: (msg: Message) => void + updateMessage: (id: string, patch: Partial) => void + setStatus: (status: 'idle' | 'streaming' | 'error') => void + setHasStartedTyping: (value: boolean) => void + clearMessages: () => void +} + +export const useChatStore = create((set) => ({ + messages: [], + status: 'idle', + hasStartedTyping: false, + + appendMessage: (msg) => + set((state) => ({ messages: [...state.messages, msg] })), + + updateMessage: (id, patch) => + set((state) => ({ + messages: state.messages.map((m) => + m.id === id ? { ...m, ...patch } : m + ), + })), + + setStatus: (status) => set({ status }), + + setHasStartedTyping: (value) => set({ hasStartedTyping: value }), + + clearMessages: () => set({ messages: [], hasStartedTyping: false }), +})) +``` + +- [ ] **Step 2: Create `apps/tui/stores/ui-panel-store.ts`** + +```typescript +import { create } from 'zustand' + +interface DiffPanelState { + open: boolean + diff: string + originalFile?: string +} + +interface FileTreePanelState { + open: boolean + files: { path: string; modified: boolean }[] +} + +interface UIPanelStore { + diffPanel: DiffPanelState + fileTreePanel: FileTreePanelState + settingsOpen: boolean + toggleDiffPanel: () => void + openDiffPanel: (diff: string, originalFile?: string) => void + closeDiffPanel: () => void + toggleFileTreePanel: () => void + openFileTreePanel: (files: { path: string; modified: boolean }[]) => void + closeFileTreePanel: () => void + toggleSettings: () => void + closeSettings: () => void +} + +export const useUIPanelStore = create((set) => ({ + diffPanel: { open: false, diff: '' }, + fileTreePanel: { open: false, files: [] }, + settingsOpen: false, + + toggleDiffPanel: () => + set((state) => ({ + diffPanel: { ...state.diffPanel, open: !state.diffPanel.open }, + })), + + openDiffPanel: (diff, originalFile) => + set({ diffPanel: { open: true, diff, originalFile } }), + + closeDiffPanel: () => + set((state) => ({ diffPanel: { ...state.diffPanel, open: false } })), + + toggleFileTreePanel: () => + set((state) => ({ + fileTreePanel: { ...state.fileTreePanel, open: !state.fileTreePanel.open }, + })), + + openFileTreePanel: (files) => + set({ fileTreePanel: { open: true, files } }), + + closeFileTreePanel: () => + set((state) => ({ fileTreePanel: { ...state.fileTreePanel, open: false } })), + + toggleSettings: () => set((state) => ({ settingsOpen: !state.settingsOpen })), + + closeSettings: () => set({ settingsOpen: false }), +})) +``` + +- [ ] **Step 3: Create `apps/tui/stores/session-store.ts`** + +```typescript +import { create } from 'zustand' + +interface SessionStore { + connected: boolean + sessionId: string | null + connect: () => Promise + disconnect: () => void + setConnected: (connected: boolean) => void + setSessionId: (id: string | null) => void +} + +export const useSessionStore = create((set) => ({ + connected: false, + sessionId: null, + + connect: async () => { + // IPC connection will be initialized here + set({ connected: true, sessionId: crypto.randomUUID() }) + }, + + disconnect: () => { + set({ connected: false, sessionId: null }) + }, + + setConnected: (connected) => set({ connected }), + setSessionId: (id) => set({ sessionId: id }), +})) +``` + +- [ ] **Step 4: Create `apps/tui/stores/index.ts`** + +```typescript +export { useChatStore, type Message, type MessagePart } from './chat-store' +export { useUIPanelStore } from './ui-panel-store' +export { useSessionStore } from './session-store' +``` + +--- + +## Task 3: Build ChatLayout and PromptInput + +**Files:** +- Create: `apps/tui/components/ChatLayout.tsx` +- Create: `apps/tui/components/PromptInput.tsx` +- Create: `apps/tui/hooks/useAutoResize.ts` +- Create: `apps/tui/components/Logo.tsx` + +- [ ] **Step 1: Create `apps/tui/hooks/useAutoResize.ts`** + +```typescript +import { useCallback, useEffect, useRef } from 'react' + +interface UseAutoResizeOptions { + minRows?: number + maxRows?: number + onHeightChange?: (height: number) => void +} + +export function useAutoResize(options: UseAutoResizeOptions = {}) { + const { minRows = 1, maxRows = 5 } = options + const textareaRef = useRef(null) + + const resize = useCallback(() => { + const textarea = textareaRef.current + if (!textarea) return + + // Reset height to auto to get correct scrollHeight + textarea.style.height = 'auto' + + // Calculate desired height based on line count + const lineHeight = parseInt(getComputedStyle(textarea).lineHeight) || 24 + const computedHeight = textarea.scrollHeight + const maxHeight = lineHeight * maxRows + + // Cap at maxRows + if (computedHeight > maxHeight) { + textarea.style.height = `${maxHeight}px` + textarea.style.overflow = 'auto' + } else { + textarea.style.height = `${computedHeight}px` + textarea.style.overflow = 'hidden' + } + }, [maxRows]) + + const reset = useCallback(() => { + const textarea = textareaRef.current + if (!textarea) return + textarea.style.height = 'auto' + textarea.style.overflow = 'hidden' + }, []) + + return { textareaRef, resize, reset } +} +``` + +- [ ] **Step 2: Create `apps/tui/components/Logo.tsx`** + +```tsx +'use client' + +import { useChatStore } from '../stores' + +export function Logo() { + const hasStartedTyping = useChatStore((s) => s.hasStartedTyping) + + if (hasStartedTyping) return null + + return ( +
+
+{`  ██████╗ ██████╗ ███████╗██╗███████╗██╗     ██╗
+ ██╔════╝██╔═══██╗██╔════╝██║██╔════╝██║     ██║
+ ██║     ██║   ██║███████╗██║███████╗██║     ██║
+ ██║     ██║   ██║╚════██║██║╚════██║██║     ██║
+ ╚██████╗╚██████╔╝███████║██║███████║███████╗██║
+  ╚═════╝ ╚═════╝ ╚══════╝██║╚══════╝╚══════╝╚═╝`}
+      
+
+ AI-assisted coding — no API costs +
+
+ ) +} +``` + +- [ ] **Step 3: Create `apps/tui/components/PromptInput.tsx`** + +```tsx +'use client' + +import { useState, useCallback, KeyboardEvent } from 'react' +import { useChatStore } from '../stores' +import { useAutoResize } from '../hooks/useAutoResize' + +interface PromptInputProps { + onSubmit: (text: string) => void +} + +export function PromptInput({ onSubmit }: PromptInputProps) { + const [value, setValue] = useState('') + const setHasStartedTyping = useChatStore((s) => s.setHasStartedTyping) + const { textareaRef, resize } = useAutoResize({ minRows: 1, maxRows: 5 }) + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const text = e.target.value + setValue(text) + resize() + if (text.length > 0) { + setHasStartedTyping(true) + } + }, + [resize, setHasStartedTyping] + ) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + const trimmed = value.trim() + if (trimmed) { + onSubmit(trimmed) + setValue('') + resize() + } + } + }, + [value, onSubmit, resize] + ) + + return ( +
+