diff --git a/.gitignore b/.gitignore index 85a7d40..ad3016a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ dist/ .ralph-done .ralph.log -.reference/ \ No newline at end of file +.reference/ +CONTEXT/ \ No newline at end of file diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..a99d39c --- /dev/null +++ b/.ignore @@ -0,0 +1 @@ +!CONTEXT/ diff --git a/.ralph-prompt.md.example b/.ralph-prompt.md.example new file mode 100644 index 0000000..dd924f7 --- /dev/null +++ b/.ralph-prompt.md.example @@ -0,0 +1,22 @@ +# Ralph Custom Prompt Template + +This is an example custom prompt file for Ralph. Copy this to `.ralph-prompt.md` and customize. + +## Usage + +Ralph will automatically read `.ralph-prompt.md` from your project root, or you can specify a custom path: + +```bash +ralph --prompt-file ./my-custom-prompt.md +``` + +## Placeholders + +- `{{PLAN_FILE}}` - Replaced with the plan file path (e.g., `plan.md`) +- `{plan}` - Also replaced with the plan file path (legacy format) + +--- + +## Example Prompt + +READ all of {{PLAN_FILE}}. Pick ONE task. If needed, verify via web/code search (this applies to packages, knowledge, deterministic data - NEVER VERIFY EDIT TOOLS WORKED OR THAT YOU COMMITED SOMETHING. BE PRAGMATIC ABOUT EVERYTHING). Complete task. Commit change (update the {{PLAN_FILE}} in the same commit). ONLY do one task unless GLARINGLY OBVIOUS steps should run together. Update {{PLAN_FILE}}. If you learn a critical operational detail, update AGENTS.md. When ALL tasks complete, create .ralph-done and exit. NEVER GIT PUSH. ONLY COMMIT. diff --git a/AGENTS.md b/AGENTS.md index 3898562..6ee0766 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,6 +90,83 @@ useKeyboard((e: KeyEvent) => { 4. **Terminal title reset**: Call `renderer.setTerminalTitle("")` before `renderer.destroy()` to reset the window title on exit. +## Local Development & Building + +### Building and Installing Locally + +**ALWAYS follow this exact sequence when asked to build/install:** + +```bash +# 1. Run tests first +bun test + +# 2. Build all platform binaries +bun run build + +# 3. Install to /usr/local/bin (Linux/macOS) +sudo cp dist/hona-ralph-cli-linux-x64/bin/ralph /usr/local/bin/ralph + +# 4. Verify installation +ralph -v +``` + +**Platform-specific binary paths:** +- Linux x64: `dist/hona-ralph-cli-linux-x64/bin/ralph` +- Linux arm64: `dist/hona-ralph-cli-linux-arm64/bin/ralph` +- macOS x64: `dist/hona-ralph-cli-darwin-x64/bin/ralph` +- macOS arm64: `dist/hona-ralph-cli-darwin-arm64/bin/ralph` +- Windows x64: `dist/hona-ralph-cli-windows-x64/bin/ralph.exe` + +### Version Handling + +**CRITICAL**: Version is injected at build time via Bun's `define` option, NOT read from package.json at runtime. + +In `src/index.ts`: +```typescript +// @ts-expect-error - RALPH_VERSION is replaced at build time +const version: string = RALPH_VERSION; +``` + +In `scripts/build.ts`: +```typescript +define: { + RALPH_VERSION: JSON.stringify(version), +}, +``` + +**Never import version from package.json** - it won't work in compiled binaries. + +### Version Bumping Workflow + +When releasing a new version: + +```bash +# 1. Bump version (creates commit automatically if --no-git-tag-version is omitted) +npm version patch --no-git-tag-version + +# 2. Commit the version bump +git add package.json +git commit -m "chore: bump version to X.Y.Z" + +# 3. Build and install +bun run build +sudo cp dist/hona-ralph-cli-linux-x64/bin/ralph /usr/local/bin/ralph + +# 4. Verify +ralph -v + +# 5. Push +git push origin master +``` + +### Common Pitfalls + +1. **Stale binary in PATH**: Check `which -a ralph` - there may be an old binary in `~/.bun/bin/ralph` shadowing the new one. Remove it: `rm ~/.bun/bin/ralph` + +2. **Shell hash cache**: After installing, run `hash -r` to clear the shell's command cache + +3. **Forgetting to rebuild**: The version is baked into the binary at build time. Changing package.json does nothing until you rebuild. + ## NPM Publishing To release a new version to npm, run: diff --git a/README.md b/README.md index dab3d89..8e3ed5f 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,46 @@ ralph --reset # start fresh, ignore previous state | `--model, -m` | `opencode/claude-opus-4-5` | Model (provider/model format) | | `--prompt` | see below | Custom prompt (`{plan}` placeholder) | | `--reset, -r` | `false` | Reset state | +| `--server, -s` | (none) | OpenCode server URL to connect to | +| `--server-timeout` | `5000` | Health check timeout in ms | **Default prompt:** ``` READ all of {plan}. Pick ONE task. If needed, verify via web/code search (this applies to packages, knowledge, deterministic data - NEVER VERIFY EDIT TOOLS WORKED OR THAT YOU COMMITED SOMETHING. BE PRAGMATIC ABOUT EVERYTHING). Complete task. Commit change (update the plan.md in the same commit). ONLY do one task unless GLARINGLY OBVIOUS steps should run together. Update {plan}. If you learn a critical operational detail, update AGENTS.md. When ALL tasks complete, create .ralph-done and exit. NEVER GIT PUSH. ONLY COMMIT. ``` +### Connecting to an Existing Server + +Ralph can connect to an already-running OpenCode server instead of starting its own: + +```bash +# Connect to local server on custom port +ralph --server http://localhost:5000 + +# Connect to remote server (requires shared filesystem) +ralph -s http://192.168.1.100:4190 + +# With custom timeout +ralph --server http://localhost:4190 --server-timeout 10000 +``` + +**Important:** Ralph reads `plan.md` and git state locally. When connecting to a remote server, ensure both machines have access to the same working directory (e.g., via NFS mount or the same repo checkout). + +## Configuration + +Ralph reads configuration from `~/.config/ralph/config.json`: + +```json +{ + "model": "opencode/claude-opus-4-5", + "plan": "plan.md", + "server": "http://localhost:4190", + "serverTimeout": 5000 +} +``` + +CLI arguments override config file values. + ## Keybindings | Key | Action | diff --git a/bin/ralph b/bin/ralph deleted file mode 100644 index 28637bd..0000000 --- a/bin/ralph +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env node -/** - * Ralph CLI Launcher - * - * This is a thin JavaScript wrapper that finds and executes the correct - * platform-specific binary for the current OS/architecture. - * - * The binary is located in one of the optional dependency packages: - * - @hona/ralph-cli-darwin-arm64 - * - @hona/ralph-cli-darwin-x64 - * - @hona/ralph-cli-linux-arm64 - * - @hona/ralph-cli-linux-x64 - * - @hona/ralph-cli-windows-x64 - * - * Environment variables: - * RALPH_BIN_PATH - Override path to the ralph binary - */ - -const childProcess = require("child_process"); -const fs = require("fs"); -const path = require("path"); -const os = require("os"); -const { createRequire } = require("module"); - -/** - * Run the binary with inherited stdio - */ -function run(target) { - const result = childProcess.spawnSync(target, process.argv.slice(2), { - stdio: "inherit", - }); - - if (result.error) { - console.error(result.error.message); - process.exit(1); - } - - const code = typeof result.status === "number" ? result.status : 0; - process.exit(code); -} - -// Check for environment variable override -const envPath = process.env.RALPH_BIN_PATH; -if (envPath) { - run(envPath); -} - -// Map OS and architecture to package naming conventions -const platformMap = { - darwin: "darwin", - linux: "linux", - win32: "windows", -}; - -const archMap = { - x64: "x64", - arm64: "arm64", -}; - -let platform = platformMap[os.platform()]; -if (!platform) { - platform = os.platform(); -} - -let arch = archMap[os.arch()]; -if (!arch) { - arch = os.arch(); -} - -// Construct package and binary names -const packageName = "@hona/ralph-cli-" + platform + "-" + arch; -const binaryName = platform === "windows" ? "ralph.exe" : "ralph"; - -/** - * Find the binary using Node's require.resolve (handles scoped packages correctly) - */ -function findBinaryWithRequire() { - try { - // Create a require function relative to this script - const localRequire = createRequire(__filename); - const packageJsonPath = localRequire.resolve(packageName + "/package.json"); - const packageDir = path.dirname(packageJsonPath); - const binaryPath = path.join(packageDir, "bin", binaryName); - - if (fs.existsSync(binaryPath)) { - return binaryPath; - } - } catch (e) { - // Package not found via require.resolve - } - return null; -} - -/** - * Fallback: Search for the binary in node_modules hierarchy manually - * Handles scoped packages stored at node_modules/@scope/package-name - */ -function findBinaryManual(startDir) { - // For scoped package @hona/ralph-cli-linux-x64, we need to look at: - // node_modules/@hona/ralph-cli-linux-x64/bin/ralph - const scopedParts = packageName.split("/"); - const scope = scopedParts[0]; // "@hona" - const pkgName = scopedParts[1]; // "ralph-cli-linux-x64" - - let current = startDir; - for (;;) { - const modules = path.join(current, "node_modules"); - - if (fs.existsSync(modules)) { - // Check scoped package path: node_modules/@hona/ralph-cli-{platform}-{arch} - const scopedPath = path.join(modules, scope, pkgName, "bin", binaryName); - if (fs.existsSync(scopedPath)) { - return scopedPath; - } - } - - // Move up to parent directory - const parent = path.dirname(current); - if (parent === current) { - // Reached filesystem root - return null; - } - current = parent; - } -} - -// Try to find the binary - first with require.resolve, then manual search -const scriptPath = fs.realpathSync(__filename); -const scriptDir = path.dirname(scriptPath); - -let resolved = findBinaryWithRequire(); -if (!resolved) { - resolved = findBinaryManual(scriptDir); -} - -if (!resolved) { - console.error( - "Error: Could not find the ralph binary for your platform.\n\n" + - "It seems that your package manager failed to install the correct version\n" + - "of the ralph CLI for your platform (" + - platform + - "-" + - arch + - ").\n\n" + - 'You can try manually installing the "' + - packageName + - '" package:\n\n' + - " npm install " + - packageName + - "\n" - ); - process.exit(1); -} - -run(resolved); diff --git a/bin/ralph b/bin/ralph new file mode 120000 index 0000000..282e6cb --- /dev/null +++ b/bin/ralph @@ -0,0 +1 @@ +../dist/hona-ralph-cli-linux-x64/bin/ralph \ No newline at end of file diff --git a/bun.lock b/bun.lock index f4d55ce..955a95b 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@opencode-ai/sdk": "1.1.1", "@opentui/core": "0.1.68", "@opentui/solid": "0.1.68", + "fuzzysort": "^3.1.0", "solid-js": "^1.9.0", "yargs": "^18.0.0", }, @@ -253,6 +254,8 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], diff --git a/docs/command-palette-fix.md b/docs/command-palette-fix.md new file mode 100644 index 0000000..f2e0f9e --- /dev/null +++ b/docs/command-palette-fix.md @@ -0,0 +1,4 @@ +## Command palette keyboard handling + +- **Issue**: Pressing `c` locked the app because the command palette dialog could be triggered while another dialog/input was focused and no repaint was forced, so the UI stopped responding but kept consuming `c`. +- **Fix**: Made the keyboard-notified flag reactive to avoid repeated logging, prevented the palette from opening if a dialog is already stacked, and explicitly requested a render after showing the palette so the overlay appears immediately. diff --git a/docs/opentui-theme-colors-fix.md b/docs/opentui-theme-colors-fix.md new file mode 100644 index 0000000..e3256b5 --- /dev/null +++ b/docs/opentui-theme-colors-fix.md @@ -0,0 +1,71 @@ +# OpenTUI Theme Colors Fix + +## Problem + +Theme colors were not being applied to the main app view (Header, Footer, Log), while dialogs displayed colors correctly. The UI appeared as "dark background with lighter text and almost no color at all." + +## Root Cause + +Two issues were identified: + +### 1. Incorrect OpenTUI Color Pattern in Footer + +The Footer component used nested `` inside `` elements: + +```tsx +// WRONG - OpenTUI doesn't support nested span style for colors + + +{props.linesAdded} + / + -{props.linesRemoved} + +``` + +OpenTUI requires the `fg` attribute directly on `` elements: + +```tsx +// CORRECT - Use separate elements with fg attribute ++{props.linesAdded} +/ +-{props.linesRemoved} +``` + +The dialogs worked because they already used the correct pattern with separate `` elements. + +### 2. Non-Reactive Theme Access + +Components captured the theme once at render time: + +```tsx +// NON-REACTIVE - Captures theme value once, doesn't update on theme change +const t = theme(); +return ...; +``` + +Should use a reactive getter: + +```tsx +// REACTIVE - Theme is accessed each time t() is called in JSX +const t = () => theme(); +return ...; +``` + +## Fix + +1. **Footer**: Replaced all `` with separate `` elements +2. **All main components**: Changed from `const t = theme()` to `const t = () => theme()` and updated usages to `t().property` + +### Components Updated + +- `src/components/footer.tsx` - Fixed span pattern + reactive getter +- `src/components/header.tsx` - Reactive getter +- `src/components/log.tsx` - Reactive getter +- `src/components/steering.tsx` - Fixed span pattern + reactive getter +- `src/components/toast.tsx` - Reactive getter + fixed non-reactive early return + +## Key Takeaway + +When using OpenTUI/SolidJS for TUI rendering: + +1. **Colors**: Always use `` directly, never `` +2. **Reactivity**: Use getter functions `const t = () => theme()` and access via `t()` in JSX to ensure theme changes propagate diff --git a/package.json b/package.json index 71d4423..50ca43f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hona/ralph-cli", - "version": "0.1.0", + "version": "1.0.5", "type": "module", "bin": { "ralph": "./bin/ralph" @@ -19,6 +19,7 @@ "@opencode-ai/sdk": "1.1.1", "@opentui/core": "0.1.68", "@opentui/solid": "0.1.68", + "fuzzysort": "^3.1.0", "solid-js": "^1.9.0", "yargs": "^18.0.0" }, diff --git a/plan.md b/plan.md index dd627a2..0b357e3 100644 --- a/plan.md +++ b/plan.md @@ -1,531 +1,17 @@ -# opencode-ralph TUI Fix Plan +# Test Plan for Manual Testing -Critical fixes for TUI rendering, keyboard handling, and process management. +This file is used to test the `parsePlan()` function in opencode-ralph. -**Problem Summary**: The TUI doesn't update, 'q' doesn't quit, and keyboard events are not being processed. Root causes identified: -1. Subprocess wrapper in `bin/ralph.ts` interferes with OpenTUI's stdin/stdout control -2. Solid's `onMount` lifecycle hook not firing reliably, preventing keyboard event registration -3. Conflicting stdin handlers between ralph and OpenTUI -4. Missing OpenTUI configuration options that opencode uses +## Test Tasks ---- +- [x] **1** Completed task with uppercase X +- [X] **2** Completed task with lowercase x +- [ ] **3** Incomplete task 1 +- [ ] **4** Incomplete task 2 +- [ ] **5** Incomplete task 3 -## Phase 1: Remove Subprocess Wrapper (Root Cause Fix) +## Expected Results -The `bin/ralph.ts` file spawns a child process which creates stdin/stdout inheritance issues with OpenTUI. - -- [x] **1.1** Backup current `bin/ralph.ts` implementation: - - Copy current implementation to a comment block for reference - - Document why subprocess approach was originally used (preload requirement) - -- [x] **1.2** Refactor `bin/ralph.ts` to run directly without subprocess: - - Remove `spawn()` call entirely - - Import and call the main entry point directly - - Example pattern from opencode: direct invocation without subprocess - -- [x] **1.3** Handle the `@opentui/solid/preload` requirement: - - Option A: Add preload to `bunfig.toml` at package root (already exists) ✓ - - Option B: Use dynamic import after preload is loaded (not needed) - - Verified: preload is applied correctly - TUI renders and Solid JSX works - -- [x] **1.4** Preserve `RALPH_USER_CWD` behavior: - - The cwd handling in `src/index.ts` works correctly - - Tested: `bin/ralph.ts` saves `RALPH_USER_CWD`, changes to package root, then `src/index.ts` restores to user's cwd - - Note: Must run `bun bin/ralph.ts` from the package directory (or use `bun run ralph`) so bun finds `bunfig.toml` for the preload - -- [x] **1.5** Test the direct execution approach: - - Run `bun bin/ralph.ts` directly - works - - TUI renders correctly with header, log area, footer - - Keyboard shortcuts displayed: (q) interrupt (p) pause - ---- - -## Phase 2: Fix Component Lifecycle and Mount Timing - -The `onMount` hook in Solid components isn't firing reliably, which breaks keyboard event registration. - -- [x] **2.1** Research how opencode handles component initialization: - - Look at `.reference/opencode/packages/opencode/src/cli/cmd/tui/app.tsx` - - Note they don't await render() and don't use mount promises - - Document the pattern they use - - **Findings (2025-01-05):** - 1. **No await on render()** - OpenCode calls `render()` without awaiting (line 108-162) - 2. **No mount promises** - No `mountPromise`/`mountResolve` pattern exists - 3. **Promise wraps entire `tui()` function** - Returns `new Promise()` that resolves only via `onExit` callback, not mount completion - 4. **State via contexts not signals** - Uses nested Providers (RouteProvider, SDKProvider, LocalProvider, etc.) - 5. **`onMount` for init logic only** - Used at line 225 for arg processing, NOT for signaling external code - 6. **`renderer.disableStdoutInterception()` called at line 170** - Immediately after `useRenderer()` - 7. **`useKittyKeyboard: {}` in render options** - At line 152, enables keyboard protocol - 8. **Trusts Solid reactivity** - No manual `renderer.requestRender()` calls for state updates - -- [x] **2.2** Remove the `mountPromise` pattern in `src/app.tsx`: - - The current code resolves `mountPromise` synchronously during component body - - This is a workaround that doesn't actually wait for `onMount` - - Remove `mountResolve` and `mountPromise` variables - - **Completed (2025-01-05):** - - Removed `mountResolve` module-level variable - - Removed `mountPromise` creation in `startApp()` - - Removed `await mountPromise` call - - Removed mount resolution logic in `App` component body - - Now follows OpenCode pattern: state setters available immediately after `render()` completes - -- [x] **2.3** Refactor `startApp()` to not depend on mount timing: - - Return `stateSetters` immediately after render() completes - - Trust that Solid's reactive system will handle updates - - The state setters should work even before `onMount` fires - - **Completed (2025-01-05):** - - Added validation that globalSetState/globalUpdateIterationTimes are set after render() - - Simplified stateSetters to directly use the global setters (no wrapper indirection) - - Added clear documentation explaining that state setters are set in component body (not onMount) - - Follows OpenCode pattern: trust Solid's reactive system, no mount timing dependencies - -- [x] **2.4** Simplify the `globalSetState` pattern: - - Currently wraps setState with logging and requestRender - - Consider if this wrapper is necessary - - Keep the `renderer.requestRender?.()` call as it may help - - **Completed (2025-01-05):** - - Removed verbose debug logging from globalSetState wrapper - - Kept `renderer.requestRender?.()` call for Windows compatibility - - Added clear documentation comment explaining why the wrapper exists - - Follows OpenCode's approach: requestRender only for specific edge cases, but kept defensively for cross-platform reliability - -- [x] **2.5** Test that state updates trigger re-renders: - - Add logging to verify setState is being called - - Verify the TUI visually updates when state changes - - **Completed (2025-01-05):** - - Added `createEffect` that logs whenever state changes to confirm Solid's reactivity is working - - The effect logs status, iteration, tasksComplete, totalTasks, eventsCount, and isIdle on every state change - - This proves setState triggers re-renders (effect fires on each state mutation) - - TypeScript compiles successfully, CLI loads without errors - ---- - -## Phase 3: Fix Keyboard Event Registration - -The `useKeyboard` hook relies on `onMount` which may not be firing. - -- [x] **3.1** Verify `useKeyboard` hook is being called: - - Add logging inside the `useKeyboard` callback in `App` component - - Check if the callback is ever invoked - - **Completed (2025-01-05):** - - Added log statement before `useKeyboard` call: `"useKeyboard hook being registered (component body)"` - - Added detailed logging inside the callback with all KeyEvent properties: `name`, `ctrl`, `meta`, `shift`, `sequence`, `eventType` - - Added `onMount` hook to verify mounting fires (critical because `useKeyboard` registers its handler inside `onMount`, not during component body) - - Simplified key extraction to use `e.name` directly since that's the correct property per OpenTUI's KeyEvent class - - TypeScript compiles successfully - - **Key finding from research:** `useKeyboard` in `@opentui/solid` registers the callback inside `onMount`, NOT immediately during component body execution. This means if `onMount` doesn't fire, keyboard events won't work. The added `onMount` log will help diagnose this. - -- [x] **3.2** Check if keyboard events are reaching the renderer: - - Add logging to verify `renderer.keyInput` exists - - Add a direct listener to `renderer.keyInput.on("keypress", ...)` for debugging - - **Completed (2025-01-05):** - - Added `keyInput` existence check that logs: `exists`, `type`, `hasOnMethod` - - Added direct debug listener to `renderer.keyInput.on("keypress", ...)` that logs: - - `name`: the key name - - `sequence`: the escape sequence - - `eventType`: press/release - - This bypasses the `useKeyboard` hook entirely to verify if events reach the renderer at all - - The debug listener is added during component body execution (not onMount), so it works regardless of lifecycle timing - - If this listener fires but `useKeyboard` doesn't, it proves `onMount` is the issue - -- [x] **3.3** Add `useKittyKeyboard` option to render config: - - OpenCode uses `useKittyKeyboard: {}` in their render options - - Add this to ralph's render call in `src/app.tsx`: - ```typescript - await render( - () => , - { - targetFps: 15, - exitOnCtrlC: false, - useKittyKeyboard: {}, // ADD THIS - } - ); - ``` - - **Completed (2025-01-05):** - - Added `useKittyKeyboard: {}` to render options in `src/app.tsx` at line 78 - - This enables the Kitty keyboard protocol for improved key event handling - - TypeScript compiles successfully - -- [x] **3.4** Add `renderer.disableStdoutInterception()` call: - - OpenCode calls this right after getting the renderer - - Add in `App` component: `renderer.disableStdoutInterception()` - - This prevents OpenTUI from capturing stdout which may interfere - - **Completed (2025-01-05):** - - Added `renderer.disableStdoutInterception()` call immediately after `useRenderer()` in the App component - - Matches OpenCode's pattern at line 169-170 of their app.tsx - - TypeScript compiles successfully - -- [x] **3.5** Fix keyboard event property access: - - Current code uses `(e as any).key ?? (e as any).name ?? (e as any).sequence` - - OpenTUI's `KeyEvent` type has `.name` property - - Simplify to use `e.name` directly with proper typing - - **Completed (2025-01-05):** - - Added import: `import type { KeyEvent } from "@opentui/core";` - - Added explicit `KeyEvent` type annotation to the `useKeyboard` callback parameter - - Simplified key extraction from `String(e.name ?? "").toLowerCase()` to `e.name.toLowerCase()` - - Removed all `(e as any)` casts - now uses `e.ctrl`, `e.meta` directly with proper typing - - TypeScript compiles successfully with no errors - ---- - -## Phase 4: Remove Conflicting stdin Handler - -The fallback stdin handler in `src/index.ts` may conflict with OpenTUI's keyboard handling. - -- [x] **4.1** Understand the conflict: - - OpenTUI sets stdin to raw mode for keyboard handling - - Ralph's `process.stdin.on("data")` handler may interfere - - Document which handler should take precedence - - **Findings (2025-01-05):** - 1. **OpenCode does NOT use fallback stdin handlers** - OpenCode only uses `process.stdin.on("data")` temporarily for querying terminal background color via OSC escape sequences, NOT for keyboard input. All keyboard handling goes through `useKeyboard` exclusively. - 2. **OpenTUI sets up stdin in raw mode** - In `setupInput()`, OpenTUI calls `stdin.setRawMode(true)`, registers its own `stdin.on("data")` handler, and uses `StdinBuffer` to properly parse escape sequences. - 3. **Multiple listeners cause conflict** - Node.js allows multiple `stdin.on("data")` listeners. Both Ralph's handler AND OpenTUI's handler receive the same data, leading to: - - Double processing: "q" triggers both `requestQuit()` and `useKeyboard` callback - - Potential interference with escape sequence detection in OpenTUI's `StdinBuffer` - - Redundancy since `useKeyboard` in `src/app.tsx` already handles "q" and Ctrl+C - 4. **Recommendation: Remove Ralph's stdin handler** - OpenTUI expects exclusive control over stdin. The `useKeyboard` hook provides the proper quit functionality through OpenTUI's official API. - -- [x] **4.2** Remove the fallback stdin handler: - - Delete the `process.stdin.on("data")` block in `src/index.ts` - - The keyboard handling should be done entirely through OpenTUI's `useKeyboard` - - **Completed (2025-01-05):** - - Removed the `process.stdin.on("data")` handler block from `src/index.ts` - - Added explanatory comment noting why this handler was removed - - OpenTUI now has exclusive control over stdin for keyboard handling - - The `useKeyboard` hook in `src/app.tsx` handles 'q' and Ctrl+C quit actions - -- [x] **4.3** If fallback is needed, make it conditional: - - Only add stdin handler if OpenTUI keyboard handling fails - - Add a flag to detect if keyboard events are being received - - Fall back to raw stdin only as last resort - - **Completed (2025-01-05):** - - Added `onKeyboardEvent` callback prop to `App` component and `startApp` function - - In `src/index.ts`: implemented conditional fallback with 5-second timeout - - Fallback only activates if no OpenTUI keyboard events received within timeout - - Once OpenTUI keyboard is confirmed working, fallback is permanently disabled - - Fallback handler supports 'q' quit, Ctrl+C quit, and 'p' pause toggle - - Cleanup properly clears the fallback timeout - -- [x] **4.4** Test keyboard handling without fallback: - - Remove the stdin handler - - Verify 'q' and 'p' keys work through OpenTUI - - **Completed (2025-01-05):** - - Verified TypeScript compiles successfully with `bun run typecheck` - - Analyzed code structure to confirm keyboard handling is properly configured: - - `useKeyboard` in `src/app.tsx` handles 'q' (quit), 'p' (pause toggle), and Ctrl+C (quit) - - Callback is properly typed with `KeyEvent` from `@opentui/core` - - `onKeyboardEvent` prop signals to `src/index.ts` when OpenTUI keyboard is working - - Fallback handler in `src/index.ts` is purely conditional: - - Only activates after 5-second timeout if NO OpenTUI events received - - Once `keyboardWorking=true` (set by `onKeyboardEvent` callback), fallback is permanently disabled - - Fallback code explicitly checks `if (keyboardWorking) return;` before processing - - The stdin handler is NOT removed but is properly conditional and non-interfering - - Note: Manual testing requires running the TUI interactively, but code analysis confirms the implementation follows OpenCode's pattern and should work correctly - ---- - -## Phase 5: Improve Render Configuration - -Match opencode's render configuration for consistency. - -- [x] **5.1** Review opencode's full render options: - - `targetFps: 60` (ralph uses 15) - - `gatherStats: false` - - `exitOnCtrlC: false` - - `useKittyKeyboard: {}` - - `consoleOptions` with keybindings - - **Findings (2025-01-05):** - OpenCode's render options in `.reference/opencode/packages/opencode/src/cli/cmd/tui/app.tsx` (lines 148-161): - ```typescript - { - targetFps: 60, // High FPS for smooth UI - gatherStats: false, // Disable stats gathering for performance - exitOnCtrlC: false, // Manual Ctrl+C handling via useKeyboard - useKittyKeyboard: {}, // Enable Kitty keyboard protocol - consoleOptions: { // Console copy-selection support - keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], - onCopySelection: (text) => { Clipboard.copy(text).catch(...) }, - }, - } - ``` - - **Ralph's current options** in `src/app.tsx` (lines 95-99): - ```typescript - { - targetFps: 15, // Deliberately low for CPU efficiency - exitOnCtrlC: false, // Already correct - useKittyKeyboard: {}, // Already added in Phase 3 - } - ``` - - **Key differences:** - 1. **targetFps**: OpenCode uses 60, Ralph uses 15 for lower CPU usage (intentional choice) - 2. **gatherStats**: OpenCode explicitly sets `false`, Ralph doesn't set it (defaults to false) - 3. **consoleOptions**: OpenCode has clipboard keybindings for Ctrl+Y copy-selection; Ralph doesn't need this for its simple logging TUI - -- [x] **5.2** Update ralph's render options: - - Increase `targetFps` to 30 or 60 (test performance impact) - - Add `useKittyKeyboard: {}` - - Keep `exitOnCtrlC: false` (we handle quit manually) - - **Completed (2025-01-05):** - - Changed `targetFps` from 15 to 30 (balanced: smoother than 15, less CPU than 60) - - Added `gatherStats: false` for performance (matches OpenCode pattern) - - `useKittyKeyboard: {}` already present from Phase 3 - - `exitOnCtrlC: false` already present - - TypeScript compiles successfully - -- [x] **5.3** Consider adding console options: - - OpenCode has copy-selection keybindings - - May not be necessary for ralph but worth noting - - **Decision (2025-01-05):** - - **Not implementing** - Ralph's TUI is a simple read-only logging display - - No text selection or copy functionality is needed for this use case - - OpenCode's `consoleOptions` with `Ctrl+Y` copy-selection is for their interactive console component - - If copy-paste is needed in the future, this can be revisited - ---- - -## Phase 6: Fix the App Exit Flow - -Ensure clean shutdown when 'q' is pressed. - -- [x] **6.1** Review current quit flow: - - `useKeyboard` handler calls `renderer.destroy()` and `props.onQuit()` - - `onQuit` callback aborts the loop and resolves `exitPromise` - - Verify this chain is being executed - - **Completed (2025-01-05):** - - Reviewed quit flow chain: `useKeyboard` callback → `renderer.destroy()` → `props.onQuit()` → `exitResolve()` → `exitPromise` resolves → finally block → `process.exit(0)` - - **Fixed**: Removed `(renderer as any).destroy?.()` cast - `destroy()` is properly typed on `CliRenderer` class - - **Added**: `renderer.setTerminalTitle("")` call before `destroy()` to reset window title (matches OpenCode pattern in exit.tsx) - - Quit flow is correctly implemented and the chain executes as expected - -- [x] **6.2** Ensure `renderer.destroy()` is called correctly: - - Current code: `(renderer as any).destroy?.()` - - The `?` optional chaining may be hiding issues - - Verify `destroy` method exists on renderer - - **Completed (2025-01-05):** - - Verified `renderer.destroy()` is now called directly without cast or optional chaining - - The fix was applied in task 6.1: removed `(renderer as any).destroy?.()` cast - - `destroy()` is properly typed on `CliRenderer` class from `@opentui/solid` - - Code at lines 315 and 325 in `src/app.tsx` calls `renderer.destroy()` directly - - TypeScript compiles successfully, confirming the method exists on the renderer type - -- [x] **6.3** Add logging to quit flow: - - Log when quit key is detected - - Log when `onQuit` callback is called - - Log when `exitPromise` resolves - - **Completed (2025-01-05):** - - Quit key detection: `log("app", "Quit via 'q' key")` at app.tsx:312 and `log("app", "Quit via Ctrl+C")` at app.tsx:322 - - onQuit callback: `log("app", "onQuit called")` at app.tsx:77 and `log("main", "onQuit callback triggered")` at index.ts:355 - - exitPromise resolve: `log("main", "Exit received, cleaning up")` at index.ts:504 - - Full quit flow logging chain: quit key → onQuit → exitResolve → exitPromise resolves → finally block - -- [x] **6.4** Test quit flow end-to-end: - - Start ralph - - Press 'q' - - Verify process exits cleanly - - Check logs for expected sequence - - **Completed (2025-01-05):** - - Ran `bun bin/ralph.ts` with input "n" (fresh start) to capture TUI output - - TUI renders correctly: header with "starting", "iteration 1", "0/0 tasks", footer with "(q) interrupt (p) pause" - - Checked `.ralph.log` for quit flow logging - - **CRITICAL FINDING**: `onMount` is NOT firing! The log shows: - - `[app] useKeyboard hook being registered (component body)` ✓ - - `[app] render() completed, state setters ready` ✓ - - `[main] Enabling fallback stdin handler (OpenTUI keyboard not detected)` ← 5 sec timeout triggered - - **MISSING**: `onMount fired - keyboard handlers should now be registered` ← never logged! - - This confirms `onMount` lifecycle hook does not fire reliably in @opentui/solid - - `useKeyboard` registers its actual listener inside OpenTUI's `onMount`, so keyboard events don't work - - The fallback stdin handler (added in task 4.3) activates after 5 seconds as expected - - **Result**: Quit via 'q' works ONLY through the fallback handler, not through OpenTUI's `useKeyboard` - - **Root cause**: @opentui/solid `onMount` timing issue - needs investigation or workaround - ---- - -## Phase 7: Testing and Validation - -Verify all fixes work together. - -- [x] **7.1** Create a test checklist: - - [x] TUI renders on startup - - [x] Header shows correct status ("starting", "iteration 1", "0/0 tasks", etc.) - - [x] Log area shows events (empty on startup, populates with tool events during loop) - - [x] Footer shows stats ("+0 / -0 · 0 commits · 0s") - - [x] 'q' key quits the app (via fallback stdin handler after 5s timeout) - - [x] 'p' key toggles pause (via fallback stdin handler) - - [x] Ctrl+C quits the app (via signal handler) - - [x] State updates reflect in UI (verified via createEffect logging in .ralph.log) - - **Verification Results (2026-01-05):** - - TUI renders correctly with all visual components - - **KNOWN ISSUE**: `onMount` lifecycle hook in `@opentui/solid` does NOT fire reliably - - This means `useKeyboard` callback never gets registered (it registers inside `onMount`) - - Workaround in place: Fallback stdin handler activates after 5 seconds if no OpenTUI keyboard events received - - All keyboard functionality works via the fallback handler - - State changes are reactive and trigger UI updates (verified via `createEffect` logging) - -- [x] **7.2** Test on different terminals: - - [x] Windows Terminal - Primary dev environment, works correctly - - [x] PowerShell - Same console subsystem as Windows Terminal, should work - - [x] CMD - Same console subsystem, should work - - [x] Terminal-specific considerations documented below - - **Terminal Compatibility Analysis (2026-01-05):** - - **What works across all Windows terminals:** - - TUI rendering via @opentui/solid (uses Windows console APIs) - - Fallback stdin handler (raw mode via `process.stdin.setRawMode(true)`) - - Signal handling (SIGINT, SIGTERM) - - State updates and reactive rendering - - **Terminal-specific code in ralph:** - 1. `process.stdin.isTTY` check before setting raw mode (src/index.ts:296, src/prompt.ts:15) - 2. Windows keepalive interval to prevent premature exit (src/index.ts:239) - 3. Defensive `renderer.requestRender()` calls for Windows where automatic redraw can stall (src/app.tsx:223) - 4. `renderer.setTerminalTitle("")` reset on exit (src/app.tsx:314, 324) - - **Kitty Keyboard Protocol:** - - Enabled via `useKittyKeyboard: {}` option - - Not supported by all terminals (Windows Terminal has partial support) - - Fallback stdin handler provides coverage regardless of protocol support - - **Known Limitations:** - - `onMount` not firing means `useKeyboard` never registers - relies on fallback stdin handler - - This is consistent across all terminals (not terminal-specific) - - Kitty keyboard protocol features may not work in older terminals or CMD - -- [x] **7.3** Test the loop integration: - - Run ralph with a real plan.md - - Verify iterations are logged - - Verify progress updates - - Verify tool events appear - - **Completed (2026-01-05):** - - Fixed integration test suite in `tests/integration/ralph-flow.test.ts`: - - Fixed mock method name: `promptAsync` → `prompt` to match actual SDK usage - - Added missing `server.connected` event to mock event stream (required to trigger `prompt` call) - - All 9 integration tests pass, verifying: - 1. Callbacks are called in correct order during iteration - 2. Tool events are captured with correct data (separator, spinner, tool events) - 3. Session is created and prompt is sent via SDK - 4. Task counts are parsed from plan file - 5. `.ralph-done` file detection triggers `onComplete` - 6. Clean exit when `.ralph-done` is created mid-iteration - 7. Pause/resume callbacks work with `.ralph-pause` file - 8. Clean exit on abort signal - 9. State persistence is updated via `onIterationComplete` callback - -- [ ] **7.4** Test edge cases: - - Start with no plan.md file - - Start with invalid config - - Network errors during loop - - Rapid key presses - ---- - -## Phase 8: Cleanup and Documentation - -Remove debugging code and document findings. - -- [x] **8.1** Remove excessive logging: - - Keep essential logs for troubleshooting - - Remove verbose debug logs added during fix - - Consider log levels (debug vs info) - - **Completed (2026-01-05):** - - Removed debug logging from `src/app.tsx`: - - Removed renderer info dump and keyInput debug listener - - Removed `createEffect` that logged every state change - - Removed `onMount` verification log - - Removed verbose `useKeyboard` callback logging (kept only quit action logs) - - Removed unused imports: `createEffect`, `onMount`, `ToolEvent` - - Simplified verbose comments throughout `src/app.tsx` - - Kept essential logs: quit actions, onQuit callback - - TypeScript compiles successfully - -- [x] **8.2** Update AGENTS.md with findings: - - Document OpenTUI configuration requirements - - Document keyboard handling approach - - Note any Windows-specific considerations - - **Completed (2026-01-05):** - - Added "OpenTUI Configuration" section with render options and `disableStdoutInterception()` pattern - - Added "Keyboard Handling" section documenting the `onMount` lifecycle issue and fallback stdin workaround - - Added "Windows-Specific Considerations" section with keepalive, requestRender, TTY checks, and terminal title reset - - All findings from Phases 1-7 are now documented for future reference - -- [ ] **8.3** Update README if needed: - - Installation instructions - - Known issues - - Terminal compatibility - -- [x] **8.4** Clean up commented code: - - Remove backup code blocks - - Remove TODO comments that are resolved - - Ensure code is production-ready - - **Completed (2026-01-05):** - - Searched entire codebase for TODO, FIXME, HACK, XXX, BACKUP, ORIGINAL patterns - none found - - Reviewed all source files for commented-out code - none found - - Removed trailing blank lines from `src/components/header.tsx` - - Verified TypeScript compiles successfully - - Note: "Task 4.3:" comments in `src/index.ts` retained as useful documentation explaining why the fallback stdin pattern was implemented - ---- - -## Quick Reference: Key Files to Modify - -| File | Purpose | -|------|---------| -| `bin/ralph.ts` | Entry point - remove subprocess | -| `src/index.ts` | Main logic - remove stdin handler | -| `src/app.tsx` | TUI component - fix keyboard, render config | -| `bunfig.toml` | Bun config - ensure preload is set | - -## Quick Reference: OpenTUI Patterns from OpenCode - -```typescript -// Render call pattern -render( - () => , - { - targetFps: 60, - gatherStats: false, - exitOnCtrlC: false, - useKittyKeyboard: {}, - } -); - -// Inside App component -const renderer = useRenderer(); -renderer.disableStdoutInterception(); - -// Keyboard handling -useKeyboard((evt) => { - if (evt.name === "q" && !evt.ctrl && !evt.meta) { - // quit - } -}); -``` \ No newline at end of file +When parsed with `parsePlan()`: +- `done` should be 2 (tasks 1 and 2 are checked) +- `total` should be 5 (2 done + 3 not done) diff --git a/src/app.tsx b/src/app.tsx index f83e34a..d569a17 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,14 +1,32 @@ import { render, useKeyboard, useRenderer } from "@opentui/solid"; import type { KeyEvent } from "@opentui/core"; -import { createSignal, onCleanup, Setter } from "solid-js"; +import { createSignal, onCleanup, onMount, Setter, type Accessor } from "solid-js"; import { Header } from "./components/header"; import { Log } from "./components/log"; import { Footer } from "./components/footer"; import { PausedOverlay } from "./components/paused"; +import { SteeringOverlay } from "./components/steering"; +import { DialogProvider, DialogStack, useDialog, useInputFocus } from "./context/DialogContext"; +import { CommandProvider, useCommand, type CommandOption } from "./context/CommandContext"; +import { ToastProvider, useToast } from "./context/ToastContext"; +import { ThemeProvider, useTheme } from "./context/ThemeContext"; +import { ToastStack } from "./components/toast"; +import { DialogSelect, type SelectOption } from "./ui/DialogSelect"; +import { DialogAlert } from "./ui/DialogAlert"; +import { DialogPrompt } from "./ui/DialogPrompt"; +import { keymap, matchesKeybind, type KeybindDef } from "./lib/keymap"; import type { LoopState, LoopOptions, PersistedState } from "./state"; -import { colors } from "./components/colors"; -import { calculateEta } from "./util/time"; +import { detectInstalledTerminals, launchTerminal, getAttachCommand as getAttachCmdFromTerminal, type KnownTerminal } from "./lib/terminal-launcher"; +import { copyToClipboard, detectClipboardTool } from "./lib/clipboard"; +import { loadConfig, setPreferredTerminal } from "./lib/config"; +import { parsePlanTasks, type Task } from "./plan"; +import { Tasks } from "./components/tasks"; + + import { log } from "./util/log"; +import { createDebugSession } from "./loop"; +import { createLoopState, type LoopStateStore, type LoopAction } from "./hooks/useLoopState"; +import { createLoopStats, type LoopStatsStore } from "./hooks/useLoopStats"; type AppProps = { options: LoopOptions; @@ -24,6 +42,7 @@ type AppProps = { export type AppStateSetters = { setState: Setter; updateIterationTimes: (times: number[]) => void; + setSendMessage: (fn: ((message: string) => Promise) | null) => void; }; /** @@ -37,6 +56,7 @@ export type StartAppResult = { // Module-level state setters that will be populated when App renders let globalSetState: Setter | null = null; let globalUpdateIterationTimes: ((times: number[]) => void) | null = null; +let globalSendMessage: ((message: string) => Promise) | null = null; @@ -111,6 +131,9 @@ export async function startApp(props: StartAppProps): Promise { iterationTimesRef.push(...times); globalUpdateIterationTimes!(times); }, + setSendMessage: (fn) => { + globalSendMessage = fn; + }, }; return { exitPromise, stateSetters }; @@ -124,7 +147,30 @@ export function App(props: AppProps) { // which may interfere with logging and other output (matches OpenCode pattern). renderer.disableStdoutInterception(); - // State signal for loop state + // Create loop state store using the hook architecture + // This provides a reducer-based state management pattern with dispatch actions + const loopStore = createLoopState({ + status: "starting", + iteration: props.persistedState.iterationTimes.length + 1, + tasksComplete: 0, + totalTasks: 0, + commits: 0, + linesAdded: 0, + linesRemoved: 0, + events: [], + isIdle: true, + }); + + // Create loop stats store for tracking iteration timing and ETA + const loopStats = createLoopStats(); + + // Initialize loop stats with persisted state + loopStats.initialize( + props.persistedState.startTime, + props.persistedState.iterationTimes + ); + + // State signal for loop state (legacy - being migrated to loopStore) // Initialize iteration to length + 1 since we're about to start the next iteration const [state, setState] = createSignal({ status: "starting", @@ -138,10 +184,40 @@ export function App(props: AppProps) { isIdle: true, // Starts idle, waiting for first LLM response }); - // Signal to track iteration times (for ETA calculation) - const [iterationTimes, setIterationTimes] = createSignal( - props.iterationTimesRef || [...props.persistedState.iterationTimes] - ); + // Steering mode state signals + const [commandMode, setCommandMode] = createSignal(false); + const [commandInput, setCommandInput] = createSignal(""); + + // Tasks panel state signals + const [showTasks, setShowTasks] = createSignal(false); + const [tasks, setTasks] = createSignal([]); + + // Function to refresh tasks from plan file + const refreshTasks = async () => { + if (props.options.planFile) { + const parsed = await parsePlanTasks(props.options.planFile); + setTasks(parsed); + } + }; + + // Initialize tasks on mount and set up polling interval + let tasksRefreshInterval: ReturnType | null = null; + + onMount(() => { + refreshTasks(); + // Poll for task updates every 2 seconds + tasksRefreshInterval = setInterval(() => { + refreshTasks(); + }, 2000); + }); + + // Clean up tasks refresh interval on unmount + onCleanup(() => { + if (tasksRefreshInterval) { + clearInterval(tasksRefreshInterval); + tasksRefreshInterval = null; + } + }); // Export wrapped state setter for external access. Calls requestRender() // after updates to ensure TUI refreshes on all platforms. @@ -150,19 +226,25 @@ export function App(props: AppProps) { renderer.requestRender?.(); return result; }; - globalUpdateIterationTimes = (times: number[]) => setIterationTimes(times); - - // Track elapsed time from the persisted start time - const [elapsed, setElapsed] = createSignal( - Date.now() - props.persistedState.startTime - ); + // Update iteration times in loopStats (used for ETA calculation) + globalUpdateIterationTimes = (times: number[]) => { + // Re-initialize loopStats with the updated iteration times + // This keeps the hook-based stats in sync with external updates + loopStats.initialize(props.persistedState.startTime, times); + }; - // Update elapsed time periodically (5000ms to reduce render frequency) - // Skip updates when idle or paused to reduce unnecessary re-renders + // Update elapsed time and ETA periodically (5000ms to reduce render frequency) + // Uses loopStats hook for pause-aware elapsed time tracking const elapsedInterval = setInterval(() => { const currentState = state(); - if (!currentState.isIdle && currentState.status !== "paused") { - setElapsed(Date.now() - props.persistedState.startTime); + const status = currentState.status; + // Only tick when actively running (not paused, ready, or idle) + if (!currentState.isIdle && status !== "paused" && status !== "ready") { + // Tick loopStats for pause-aware elapsed time (hook-based approach) + loopStats.tick(); + // Update remaining tasks for ETA calculation + const remainingTasks = currentState.totalTasks - currentState.tasksComplete; + loopStats.setRemainingTasks(remainingTasks); } }, 5000); @@ -173,13 +255,6 @@ export function App(props: AppProps) { globalUpdateIterationTimes = null; }); - // Calculate ETA based on iteration times and remaining tasks - const eta = () => { - const currentState = state(); - const remainingTasks = currentState.totalTasks - currentState.tasksComplete; - return calculateEta(iterationTimes(), remainingTasks); - }; - // Pause file path const PAUSE_FILE = ".ralph-pause"; @@ -188,55 +263,744 @@ export function App(props: AppProps) { const file = Bun.file(PAUSE_FILE); const exists = await file.exists(); if (exists) { - // Resume: delete pause file and update status + // Resume: delete pause file and update status via dispatch await Bun.write(PAUSE_FILE, ""); // Ensure file exists before unlinking const fs = await import("node:fs/promises"); await fs.unlink(PAUSE_FILE); + // Use dispatch as primary state update mechanism + loopStore.dispatch({ type: "RESUME" }); + loopStats.resume(); + // Also update legacy state for external compatibility setState((prev) => ({ ...prev, status: "running" })); } else { - // Pause: create pause file and update status + // Pause: create pause file and update status via dispatch await Bun.write(PAUSE_FILE, String(process.pid)); + // Use dispatch as primary state update mechanism + loopStore.dispatch({ type: "PAUSE" }); + loopStats.pause(); + // Also update legacy state for external compatibility setState((prev) => ({ ...prev, status: "paused" })); } }; // Track if we've notified about keyboard events working (only notify once) - let keyboardEventNotified = false; + const [keyboardEventNotified, setKeyboardEventNotified] = createSignal(false); + + /** + * Show the command palette dialog. + * Converts registered commands to SelectOptions for the dialog. + */ + const showCommandPalette = () => { + // This function will be passed to CommandProvider's onShowPalette callback + // The actual implementation uses the dialog context inside AppContent + }; + + return ( + + + + + + + + + + ); +} + +/** + * Props for the inner AppContent component. + */ +type AppContentProps = { + state: () => LoopState; + setState: Setter; + options: LoopOptions; + commandMode: () => boolean; + setCommandMode: (v: boolean) => void; + setCommandInput: (v: string) => void; + togglePause: () => Promise; + renderer: ReturnType; + onQuit: () => void; + onKeyboardEvent?: () => void; + keyboardEventNotified: Accessor; + setKeyboardEventNotified: Setter; + showTasks: () => boolean; + setShowTasks: (v: boolean) => void; + tasks: () => Task[]; + refreshTasks: () => Promise; + // Hook-based state stores (for gradual migration) + loopStore: LoopStateStore; + loopStats: LoopStatsStore; +}; + +/** + * Inner component that uses context hooks for dialogs and commands. + * Separated from App to be inside the context providers. + */ +function AppContent(props: AppContentProps) { + const dialog = useDialog(); + const command = useCommand(); + const toast = useToast(); + const theme = useTheme(); + const { isInputFocused: dialogInputFocused } = useInputFocus(); + + // Get theme colors reactively - call theme.theme() to access the resolved theme + const t = () => theme.theme(); + + // Combined check for any input being focused + const isInputFocused = () => props.commandMode() || dialogInputFocused(); + + /** + * Get the attach command string for the current session. + * Returns null if no session is active. + */ + const getAttachCommand = (): string | null => { + const currentState = props.state(); + if (!currentState.sessionId) return null; + + const serverUrl = currentState.serverUrl || "http://localhost:10101"; + return `opencode attach ${serverUrl} --session ${currentState.sessionId}`; + }; + + /** + * Show a dialog with the attach command for manual copying. + * Used as fallback when clipboard is not available. + */ + const showAttachCommandDialog = () => { + const attachCmd = getAttachCommand(); + if (!attachCmd) { + dialog.show(() => ( + + )); + return; + } + + dialog.show(() => ( + + )); + }; + + /** + * Copy the attach command to clipboard. + * Falls back to showing a dialog if clipboard is unavailable. + */ + const copyAttachCommand = async () => { + const attachCmd = getAttachCommand(); + if (!attachCmd) { + toast.show({ + variant: "warning", + message: "No active session to copy attach command", + }); + return; + } + + // Check if clipboard tool is available + const clipboardTool = await detectClipboardTool(); + if (!clipboardTool) { + // No clipboard tool available - show dialog as fallback + log("app", "No clipboard tool available, showing dialog fallback"); + showAttachCommandDialog(); + return; + } + + // Attempt to copy to clipboard + const result = await copyToClipboard(attachCmd); + if (result.success) { + toast.show({ + variant: "success", + message: "Copied to clipboard", + }); + log("app", "Attach command copied to clipboard"); + } else { + toast.show({ + variant: "error", + message: `Failed to copy: ${result.error || "Unknown error"}`, + }); + log("app", "Failed to copy to clipboard", { error: result.error }); + // Fall back to dialog on error + showAttachCommandDialog(); + } + }; + + // Register default commands on mount + onMount(() => { + // Register "Start/Pause/Resume" command + command.register("togglePause", () => { + const status = props.state().status; + const title = status === "ready" ? "Start" : status === "paused" ? "Resume" : "Pause"; + const description = status === "ready" + ? "Start the automation loop" + : status === "paused" + ? "Resume the automation loop" + : "Pause the automation loop"; + return [ + { + title, + value: "togglePause", + description, + keybind: keymap.togglePause.label, + onSelect: () => { + props.togglePause(); + }, + }, + ]; + }); + + // Register "Copy attach command" action + command.register("copyAttach", () => [ + { + title: "Copy attach command", + value: "copyAttach", + description: "Copy attach command to clipboard", + keybind: keymap.copyAttach.label, + disabled: !props.state().sessionId, + onSelect: () => { + copyAttachCommand(); + }, + }, + ]); + + // Register "Choose default terminal" action + command.register("terminalConfig", () => [ + { + title: "Choose default terminal", + value: "terminalConfig", + description: "Select terminal for launching attach sessions", + keybind: keymap.terminalConfig.label, + onSelect: () => { + showTerminalConfigDialog(); + }, + }, + ]); + + // Register "Toggle tasks panel" action + command.register("toggleTasks", () => [ + { + title: props.showTasks() ? "Hide tasks panel" : "Show tasks panel", + value: "toggleTasks", + description: "Show/hide the tasks checklist from plan file", + keybind: keymap.toggleTasks.label, + onSelect: () => { + log("app", "Tasks panel toggled via command palette"); + props.setShowTasks(!props.showTasks()); + }, + }, + ]); + + // Register "Switch theme" command + command.register("switchTheme", () => [ + { + title: "Switch theme", + value: "switchTheme", + description: `Change UI color theme (current: ${theme.themeName()})`, + onSelect: () => { + // Defer to next tick so command palette's pop() completes first + queueMicrotask(() => showThemeDialog()); + }, + }, + ]); + }); + + /** + * Show terminal configuration dialog. + * Lists detected terminals and allows user to select one. + */ + const showTerminalConfigDialog = async () => { + // Detect installed terminals + const terminals = await detectInstalledTerminals(); + + if (terminals.length === 0) { + dialog.show(() => ( + + )); + return; + } + + // Convert terminals to SelectOption format + const options: SelectOption[] = terminals.map((terminal: KnownTerminal) => ({ + title: terminal.name, + value: terminal.command, + description: `Command: ${terminal.command}`, + })); + + dialog.show(() => ( + { + const selected = terminals.find((t: KnownTerminal) => t.command === opt.value); + if (selected) { + // Save to config + setPreferredTerminal(selected.name); + log("app", "Terminal preference saved", { terminal: selected.name }); + dialog.show(() => ( + + )); + } + }} + onCancel={() => {}} + borderColor={t().secondary} + /> + )); + }; + + /** + * Show theme selection dialog. + * Lists all available themes and allows user to switch. + */ + const showThemeDialog = () => { + const options: SelectOption[] = theme.themeNames.map((name) => ({ + title: name, + value: name, + description: name === theme.themeName() ? "(current)" : undefined, + })); - // Keyboard handling + dialog.show(() => ( + { + theme.setThemeName(opt.value); + log("app", "Theme changed", { theme: opt.value }); + toast.show({ + variant: "success", + message: `Theme changed to ${opt.value}`, + }); + // Force re-render after state updates propagate + queueMicrotask(() => props.renderer.requestRender?.()); + }} + onCancel={() => {}} + borderColor={t().accent} + /> + )); + props.renderer.requestRender?.(); + }; + + /** + * Handle N key press in debug mode: create a new session. + * Only available in debug mode. Creates a session and stores it in state. + */ + const handleDebugNewSession = async () => { + // Only available in debug mode + if (!props.options.debug) { + return; + } + + // Check if session already exists + const currentState = props.state(); + const existingSessionId = currentState.sessionId; + if (existingSessionId) { + dialog.show(() => ( + + )); + return; + } + + log("app", "Debug mode: creating new session via N key"); + + try { + const session = await createDebugSession({ + serverUrl: props.options.serverUrl, + serverTimeoutMs: props.options.serverTimeoutMs, + model: props.options.model, + agent: props.options.agent, + }); + + // Update state via dispatch as primary mechanism + props.loopStore.dispatch({ + type: "SET_SESSION", + sessionId: session.sessionId, + serverUrl: session.serverUrl, + attached: session.attached, + }); + props.loopStore.dispatch({ type: "SET_IDLE", isIdle: true }); + + // Also update legacy state for external compatibility + props.setState((prev) => ({ + ...prev, + sessionId: session.sessionId, + serverUrl: session.serverUrl, + attached: session.attached, + status: "ready", // Ready for input + })); + + // Store sendMessage function for steering mode + globalSendMessage = session.sendMessage; + + log("app", "Debug mode: session created successfully", { + sessionId: session.sessionId + }); + + dialog.show(() => ( + + )); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + log("app", "Debug mode: failed to create session", { error: errorMsg }); + + dialog.show(() => ( + + )); + } + }; + + /** + * Handle P key press in debug mode: open prompt dialog for manual input. + * Only available in debug mode with an active session. + */ + const handleDebugPromptInput = () => { + // Only available in debug mode + if (!props.options.debug) { + return; + } + + // Check for active session + const currentState = props.state(); + if (!currentState.sessionId) { + dialog.show(() => ( + + )); + return; + } + + log("app", "Debug mode: opening prompt dialog via P key"); + + dialog.show(() => ( + { + if (!value.trim()) { + return; + } + + if (globalSendMessage) { + log("app", "Debug mode: sending prompt", { message: value.slice(0, 50) }); + try { + await globalSendMessage(value); + // Update status via dispatch as primary mechanism + props.loopStore.dispatch({ type: "START" }); + props.loopStore.dispatch({ type: "SET_IDLE", isIdle: false }); + // Also update legacy state for external compatibility + props.setState((prev) => ({ + ...prev, + status: "running", + isIdle: false, + })); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + log("app", "Debug mode: failed to send prompt", { error: errorMsg }); + dialog.show(() => ( + + )); + } + } else { + log("app", "Debug mode: no sendMessage function available"); + dialog.show(() => ( + + )); + } + }} + onCancel={() => { + log("app", "Debug mode: prompt dialog cancelled"); + }} + borderColor={t().accent} + /> + )); + }; + + /** + * Handle T key press: launch terminal with attach command or show config dialog. + * Requires an active session. Uses preferred terminal if configured. + */ + const handleTerminalLaunch = async () => { + const currentState = props.state(); + + // Check for active session + if (!currentState.sessionId) { + dialog.show(() => ( + + )); + return; + } + + // Load config to check for preferred terminal + const config = loadConfig(); + + if (!config.preferredTerminal) { + // No configured terminal: show config dialog + log("app", "No preferred terminal configured, showing dialog"); + await showTerminalConfigDialog(); + return; + } + + // Find the preferred terminal in detected terminals + const terminals = await detectInstalledTerminals(); + const preferredTerminal = terminals.find( + (t: KnownTerminal) => t.name === config.preferredTerminal + ); + + if (!preferredTerminal) { + // Preferred terminal not found/installed + log("app", "Preferred terminal not found", { preferred: config.preferredTerminal }); + dialog.show(() => ( + + )); + await showTerminalConfigDialog(); + return; + } + + // Build attach command using server URL from state (supports external/attached mode) + const serverUrl = currentState.serverUrl || "http://localhost:10101"; + const attachCmd = getAttachCmdFromTerminal(serverUrl, currentState.sessionId); + + log("app", "Launching terminal", { + terminal: preferredTerminal.name, + serverUrl, + sessionId: currentState.sessionId, + }); + + // Launch the terminal + const result = await launchTerminal(preferredTerminal, attachCmd); + + if (!result.success) { + dialog.show(() => ( + + )); + } + }; + + /** + * Detect if the `:` (colon) key was pressed. + * Handles multiple keyboard configurations: + * - Direct `:` character (Kitty protocol or non-US keyboards) + * - Shift+`;` (US keyboard layout via raw mode) + * - Semicolon with shift modifier + */ + const isColonKey = (e: KeyEvent): boolean => { + // Direct colon character (most common case with Kitty protocol) + if (e.name === ":") return true; + // Raw character is colon + if (e.raw === ":") return true; + // Shift+semicolon on US keyboard layout + if (e.name === ";" && e.shift) return true; + return false; + }; + + /** + * Show the command palette dialog with all registered commands. + */ + const showCommandPalette = () => { + if (dialog.hasDialogs()) { + return; + } + + const commands = command.getCommands(); + const options = commands.map((cmd): CommandOption & { onSelect: () => void } => ({ + title: cmd.title, + value: cmd.value, + description: cmd.description, + keybind: cmd.keybind, + disabled: cmd.disabled, + onSelect: cmd.onSelect, + })); + + dialog.show(() => ( + { + // Find and execute the command + const cmd = commands.find(c => c.value === opt.value); + cmd?.onSelect(); + }} + onCancel={() => {}} + borderColor={t().accent} + /> + )); + props.renderer.requestRender?.(); + }; + + // Keyboard handling - now inside context providers useKeyboard((e: KeyEvent) => { // Notify caller that OpenTUI keyboard handling is working - // This allows the caller to skip setting up a fallback stdin handler - if (!keyboardEventNotified && props.onKeyboardEvent) { - keyboardEventNotified = true; + // Also log the first key event for diagnostic purposes (Phase 1.1) + if (!props.keyboardEventNotified() && props.onKeyboardEvent) { + props.setKeyboardEventNotified(true); props.onKeyboardEvent(); + // Log first key event to diagnose keyboard issues + log("keyboard", "First OpenTUI key event received", { + key: e.name, + ctrl: e.ctrl, + shift: e.shift, + meta: e.meta, + raw: e.raw, + isInputFocused: isInputFocused(), + commandMode: props.commandMode(), + dialogInputFocused: dialogInputFocused(), + }); } - + const key = e.name.toLowerCase(); - // p key: toggle pause - if (key === "p" && !e.ctrl && !e.meta) { - togglePause(); + // SAFETY VALVE: Ctrl+C always quits, even if a dialog is open/broken + // This ensures users can always exit the app without killing the terminal + if (key === "c" && e.ctrl) { + log("app", "Quit requested via Ctrl+C (safety valve)"); + props.renderer.setTerminalTitle(""); + props.renderer.destroy(); + props.onQuit(); return; } - // q key: quit - if (key === "q" && !e.ctrl && !e.meta) { - log("app", "Quit requested via 'q' key"); - renderer.setTerminalTitle(""); - renderer.destroy(); - props.onQuit(); + // Skip if any input is focused (dialogs, steering mode, etc.) + if (isInputFocused()) return; + + // ESC key: close tasks panel if open + if (key === "escape" && props.showTasks()) { + log("app", "Tasks panel closed via ESC"); + props.setShowTasks(false); return; } - // Ctrl+C: quit - if (key === "c" && e.ctrl) { - log("app", "Quit requested via Ctrl+C"); - renderer.setTerminalTitle(""); - renderer.destroy(); + // c: open command palette + if (matchesKeybind(e, keymap.commandPalette)) { + log("app", "Command palette opened via 'c' key"); + showCommandPalette(); + return; + } + + // : key: open steering mode (requires active session) + if (isColonKey(e) && !e.ctrl && !e.meta) { + const currentState = props.state(); + // Only allow steering when there's an active session + if (currentState.sessionId) { + log("app", "Steering mode opened via ':' key"); + props.setCommandMode(true); + props.setCommandInput(""); + } + return; + } + + // p key: toggle pause OR prompt input (debug mode) + // Phase 2.2: Use matchesKeybind for consistent key routing + if (matchesKeybind(e, keymap.togglePause)) { + if (props.options.debug) { + // In debug mode, p opens prompt input dialog + handleDebugPromptInput(); + return; + } + // In normal mode, p toggles pause + props.togglePause(); + return; + } + + // t key: launch terminal with attach command (only when no modifiers) + if (matchesKeybind(e, keymap.terminalConfig)) { + handleTerminalLaunch(); + return; + } + + // Shift+T: toggle tasks panel + if (matchesKeybind(e, keymap.toggleTasks)) { + log("app", "Tasks panel toggled via Shift+T"); + props.setShowTasks(!props.showTasks()); + return; + } + + // n key: create new session (debug mode only) + if (key === "n" && !e.ctrl && !e.meta && !e.shift) { + if (props.options.debug) { + handleDebugNewSession(); + return; + } + } + + // q key: quit + // Phase 2.2: Use matchesKeybind for consistent key routing + if (matchesKeybind(e, keymap.quit)) { + log("app", "Quit requested via 'q' key"); + props.renderer.setTerminalTitle(""); + props.renderer.destroy(); props.onQuit(); return; } + + // Note: Ctrl+C is handled above as a safety valve (before isInputFocused check) }); return ( @@ -244,24 +1008,73 @@ export function App(props: AppProps) { flexDirection="column" width="100%" height="100%" - backgroundColor={colors.bgDark} + backgroundColor={t().background} >
- +