Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@ The `--plan "description"` flag enables interactive plan creation:
- User answers via fzf picker (or numbered fallback); an "Other" option allows typing a custom answer
- Q&A history stored in progress file for context
- When ready, Claude emits PLAN_DRAFT signal with full plan content for user review
- User can Accept, Revise (with feedback), or Reject the draft
- If revised, feedback is passed to Claude for plan modifications
- User can Accept, Revise (with feedback), Interactive review, or Reject the draft
- Interactive review opens `$EDITOR` with the plan content; on save, a unified diff is computed and fed back as revision feedback
- If revised (manually or via interactive review), feedback is passed to Claude for plan modifications
- Loop continues until user accepts and Claude emits PLAN_READY signal
- Plan file written to docs/plans/
- After completion, prompts user: "Continue with plan implementation?"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ The `--plan` flag provides a simpler integrated experience:
ralphex --plan "add health check endpoint"
```

Claude explores your codebase, asks clarifying questions via a terminal picker (fzf or numbered fallback), and generates a complete plan file in `docs/plans/`.
Claude explores your codebase, asks clarifying questions via a terminal picker (fzf or numbered fallback), and generates a complete plan file in `docs/plans/`. When reviewing the draft, you can accept, revise with text feedback, open it in `$EDITOR` for interactive annotation, or reject it.

**Example session:**
```
Expand Down
24 changes: 14 additions & 10 deletions cmd/ralphex/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -523,15 +523,14 @@ func printStartupInfo(info startupInfo, colors *progress.Colors) {
return
}

planStr := info.PlanFile
if planStr == "" {
planStr = "(no plan - review only)"
}
modeStr := ""
if info.Mode != processor.ModeFull {
modeStr = fmt.Sprintf(" (%s mode)", info.Mode)
}
colors.Info().Printf("starting ralphex loop: %s (max %d iterations)%s\n", planStr, info.MaxIterations, modeStr)
colors.Info().Printf("starting ralphex loop (max %d iterations)%s\n", info.MaxIterations, modeStr)
if info.PlanFile != "" {
colors.Info().Printf("plan: %s\n", toRelPath(info.PlanFile))
}
colors.Info().Printf("branch: %s\n", info.Branch)
colors.Info().Printf("progress log: %s\n\n", info.ProgressPath)
}
Expand Down Expand Up @@ -606,11 +605,7 @@ func runPlanMode(ctx context.Context, o opts, req executePlanRequest) error {

// print completion message with plan file path if found
if planFile != "" {
relPath, relErr := filepath.Rel(".", planFile)
if relErr != nil {
relPath = planFile
}
req.Colors.Info().Printf("\nplan creation completed in %s, created %s\n", elapsed, relPath)
req.Colors.Info().Printf("\nplan creation completed in %s, created %s\n", elapsed, toRelPath(planFile))
} else {
req.Colors.Info().Printf("\nplan creation completed in %s\n", elapsed)
}
Expand Down Expand Up @@ -681,6 +676,15 @@ func dumpDefaults(dir string) error {
return nil
}

// toRelPath converts an absolute path to relative (from cwd). returns original on error.
func toRelPath(p string) string {
rel, err := filepath.Rel(".", p)
if err != nil {
return p
}
return rel
}

// isResetOnly returns true if --reset was the only meaningful flag/arg specified.
// this allows reset to work standalone (exit after reset) while also supporting
// combined usage like "ralphex --reset docs/plans/feature.md".
Expand Down
114 changes: 114 additions & 0 deletions docs/plans/completed/2026-02-15-interactive-plan-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Interactive Plan Review in --plan Mode

## Overview

Add an "Interactive review" option to the plan draft review menu in `ralphex --plan`. When selected, opens `$EDITOR` with the plan draft in a temp file. On save and close, computes a unified diff and feeds it back as revision feedback through the existing revise mechanism. If the user closes the editor without changes, re-shows the menu. This provides direct text editing for plan annotation without requiring any specific terminal emulator.

## Context (from discovery)

- Files/components involved: `pkg/input/input.go`, `pkg/input/input_test.go`
- Current `AskDraftReview` presents 3 options: Accept, Revise, Reject
- Options are shown via `selectWithNumbers` (not fzf)
- Runner in `pkg/processor/runner.go` passes feedback string through unchanged — content format (text vs diff) is irrelevant to it
- `TerminalCollector` struct has `stdin`/`stdout` fields for testing; editor launch needs testability via a similar pattern
- `github.com/pmezard/go-difflib` is already vendored (via testify) — use `difflib.GetUnifiedDiffString` for diff computation
- Current `AskDraftReview` creates a second `bufio.NewReader` for revision feedback — when wrapping in a loop, must create reader once at method top and thread it through to avoid losing buffered data

## Development Approach

- **Testing approach**: Regular (code first, then tests)
- The change is contained in a single package (`pkg/input`)
- No runner or progress changes needed — "Interactive review" maps to `("revise", diff)` at the interface boundary
- Editor lookup order: `$VISUAL` → `$EDITOR` → `vi` (standard unix convention, matches git)
- Temp file uses `.md` extension so editors apply markdown syntax highlighting
- No exported constant for interactive review — the action never crosses the package boundary, use local dispatch only

## Testing Strategy

- **Unit tests**: test `openEditor`, `computeDiff`, and the modified `AskDraftReview` flow
- Editor launch is abstracted behind `editorFunc` field on `TerminalCollector` so tests can override it
- Tests verify: diff computation, no-changes re-prompting, editor error handling, temp file cleanup

## Progress Tracking

- Mark completed items with `[x]` immediately when done
- Add newly discovered tasks with ➕ prefix
- Document issues/blockers with ⚠️ prefix

## Implementation Steps

### Task 1: Add openEditor and computeDiff methods

**Files:**
- Modify: `pkg/input/input.go`
- Modify: `pkg/input/input_test.go`

- [x] add `editorFunc` field to `TerminalCollector` (type `func(ctx context.Context, content string) (string, error)`) for testability — nil means use real editor
- [x] add `openEditor` method that: creates temp `.md` file with plan content, looks up editor (`$VISUAL` → `$EDITOR` → `vi`), runs via `exec.CommandContext` with stdin/stdout/stderr connected to os, reads file back, cleans up temp file. If editor command not found, return error with "set $EDITOR environment variable" message
- [x] add `computeDiff` method that uses `difflib.GetUnifiedDiffString` from `github.com/pmezard/go-difflib` with 2 lines of context, returns diff string (empty if no changes)
- [x] write tests for `computeDiff` — changed content produces unified diff, unchanged content returns empty string
- [x] write test for `openEditor` with real temp file — verify file creation, content writing, and cleanup (use a simple editor command like `cat` or `true`)
- [x] run `go test ./pkg/input/...` — must pass before next task

### Task 2: Add interactive review option to AskDraftReview

**Files:**
- Modify: `pkg/input/input.go`
- Modify: `pkg/input/input_test.go`

- [x] refactor `AskDraftReview` to create `bufio.Reader` once at method top and thread it through `selectWithNumbers` and revision feedback reading — prevents data loss when looping
- [x] wrap option selection in a loop: show 4 options (Accept, Revise, Interactive review, Reject). If "Interactive review" is selected: call `editorFunc` (or `openEditor` if nil), compute diff. If diff is non-empty, return `("revise", diff)`. If diff is empty or editor errors (log warning), continue loop. Accept/Revise/Reject break out as before
- [x] write test for interactive review with changes — mock `editorFunc` to return modified content, verify returns `("revise", diff)`
- [x] write test for interactive review without changes — mock `editorFunc` to return same content, then simulate "Accept" on re-prompt, verify returns `("accept", "")`
- [x] write test for interactive review with editor error — mock `editorFunc` to return error, verify re-prompts menu
- [x] verify existing `AskDraftReview` tests still pass (accept, revise, reject paths unchanged)
- [x] run `go test ./pkg/input/...` — must pass before next task

### Task 3: Verify acceptance criteria

- [x] run full unit test suite: `go test ./...`
- [x] run linter: `golangci-lint run`
- [x] verify test coverage for `pkg/input` meets 80%+

### Task 4: [Final] Update documentation

- [x] update CLAUDE.md "Plan Creation Mode" section to mention interactive review option
- [x] update README.md plan creation section if needed

## Technical Details

**Editor launch:**
- `os.CreateTemp("", "ralphex-plan-*.md")` for temp file
- Write plan content, close file
- Look up editor: `$VISUAL` → `$EDITOR` → `vi`
- `exec.CommandContext(ctx, editor, tmpFile)` with `Stdin=os.Stdin`, `Stdout=os.Stdout`, `Stderr=os.Stderr`
- Read file back after editor exits
- Clean up temp file with defer
- If editor not found: return error with helpful message

**Diff computation:**
- Use `difflib.GetUnifiedDiffString` from `github.com/pmezard/go-difflib` (already vendored via testify)
- Headers: `--- original` / `+++ annotated`
- 2 lines of context around changes
- Returns empty string when content is identical

**bufio.Reader threading:**
- Current code creates separate `bufio.Reader` instances in `selectWithNumbers` and for revision feedback
- With a loop, this loses buffered data from piped/test input
- Fix: create reader once at top of `AskDraftReview`, pass to `selectWithNumbers` (add optional reader parameter, same pattern as `readCustomAnswer`)

**Menu flow:**
```
━━━ Plan Draft ━━━
<rendered plan>
━━━━━━━━━━━━━━━━━━

Review the plan draft
1) Accept
2) Revise
3) Interactive review
4) Reject
Enter number (1-4):
```

If user picks 3 → editor opens → diff computed → if non-empty, returns `("revise", diff)` → if empty, loop back to menu.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/go-pkgz/notify v1.3.0
github.com/jessevdk/go-flags v1.6.1
github.com/playwright-community/playwright-go v0.5200.1
github.com/pmezard/go-difflib v1.0.0
github.com/stretchr/testify v1.11.1
github.com/tmaxmax/go-sse v0.11.0
golang.org/x/sys v0.41.0
Expand Down Expand Up @@ -47,7 +48,6 @@ require (
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/slack-go/slack v0.17.3 // indirect
Expand Down
3 changes: 2 additions & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ ralphex --codex-only
# tasks-only mode (run only task phase, skip all reviews)
ralphex --tasks-only docs/plans/feature.md

# interactive plan creation
# interactive plan creation — Claude asks questions, generates draft,
# user reviews with accept/revise/interactive review ($EDITOR)/reject
ralphex --plan "add user authentication"

# reset global config to defaults (interactive)
Expand Down
10 changes: 7 additions & 3 deletions pkg/config/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,15 @@ func (al *agentLoader) loadFileWithFallback(path, filename string) (string, erro
}
content := strings.TrimSpace(normalizeCRLF(string(data)))
// check if file has actual prompt body (strip comments only for emptiness check)
if _, body := parseOptions(strings.TrimSpace(stripComments(content))); body != "" {
stripped := strings.TrimSpace(stripComments(content))
opts, body := parseOptions(stripped)
if body != "" {
return content, nil
}
// fall back to embedded default, frontmatter options (if any) are dropped
log.Printf("[WARN] agent %s: no prompt body, falling back to embedded default (frontmatter options dropped)", filename)
// warn only when frontmatter options are being dropped; silent fallback for all-commented files
if opts.Model != "" || opts.AgentType != "" {
log.Printf("[WARN] agent %s: no prompt body, falling back to embedded default (frontmatter options dropped)", filename)
}
return al.loadFromEmbedFS(filename)
}

Expand Down
3 changes: 2 additions & 1 deletion pkg/config/defaults/prompts/make_plan.txt
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ CRITICAL: After emitting PLAN_DRAFT, STOP immediately. Do not continue. Do not w

The loop will:
1. Display the draft to the user with terminal rendering
2. Ask the user to Accept, Revise, or Reject
2. Ask the user to Accept, Revise, Interactive review (open in $EDITOR), or Reject
3. Run another iteration with the user's decision

**Handling user responses:**
Expand All @@ -88,6 +88,7 @@ If user ACCEPTS (progress file contains "DRAFT REVIEW: accept"):

If user requests REVISION (progress file contains "DRAFT REVIEW: revise" and "FEEDBACK:"):
- Read the feedback from the progress file
- Feedback may be free-form text (from "Revise") or a unified diff with interpretation instructions (from "Interactive review" where the user edited the plan in $EDITOR). Both formats indicate what the user wants changed — apply the requested modifications
- Modify the plan based on the feedback
- Emit a new PLAN_DRAFT with the updated plan
- STOP and wait for next review
Expand Down
Loading
Loading