Skip to content

feat(tui): add plan mode command and fix plan file editing - #854

Open
euxaristia wants to merge 31 commits into
Gitlawb:mainfrom
euxaristia:feat/tui-plan-mode
Open

feat(tui): add plan mode command and fix plan file editing#854
euxaristia wants to merge 31 commits into
Gitlawb:mainfrom
euxaristia:feat/tui-plan-mode

Conversation

@euxaristia

Copy link
Copy Markdown
Contributor

Summary

  • Adds the /plan command and TUI wiring for PermissionModePlan (see the companion agent-side PR), including a command-palette entry, editor round-trip for the plan file, and status/notes preserved across editor exit
  • Plan storage lives outside the workspace, keyed by a slugified path plus a SHA-256 hash of the exact workspace/session string for collision resistance, and resolves not-yet-existing paths through their deepest existing ancestor so it works before the workspace is fully materialized
  • Hardens the editor round-trip: closes a symlink race during staging, contains staging physically, keeps plan state cancel-safe, and guards exitPlanMode against clobbering an unrelated permission mode
  • Resets plan mode correctly on spec-session switch and preserves beforeTool policy vetoes while active

Test plan

  • go test ./internal/tui/... ./internal/planmode/...

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@euxaristia, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 24 minutes

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 13ac6d11-1cc2-469e-a047-6287c83d48cb

📥 Commits

Reviewing files that changed from the base of the PR and between 8e26679 and a78803a.

📒 Files selected for processing (24)
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/agent/plan_mode_advertised_test.go
  • internal/agent/request_permissions_test.go
  • internal/agent/types.go
  • internal/planmode/export_test.go
  • internal/planmode/planmode.go
  • internal/planmode/planmode_test.go
  • internal/tools/types.go
  • internal/tools/update_plan.go
  • internal/tools/update_plan_test.go
  • internal/tui/btw.go
  • internal/tui/btw_test.go
  • internal/tui/commands.go
  • internal/tui/commands_test.go
  • internal/tui/model.go
  • internal/tui/model_test.go
  • internal/tui/plan_command.go
  • internal/tui/plan_command_test.go
  • internal/tui/session.go
  • internal/tui/session_test.go
  • internal/tui/spec_mode.go
  • internal/tui/spec_mode_test.go
  • internal/tui/view.go

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

euxaristia and others added 29 commits July 31, 2026 15:38
- restrict plan file permissions to owner only (0o700 dir, 0o600 file)
- surface editor failures from /plan open in the transcript
- simplify fileExists to return a bool
- collapse redundant plan path resolution in planText
/plan open was non-functional because run.go assigned the live program
to a copy of the model after tea.NewProgram had already captured it by
value; the field is removed and tea.ExecProcess is used directly.
Shift+Tab no longer silently drops plan mode, planmode.DraftSystemPrompt
is wired into plan-mode runs, plan file paths reject symlink escapes,
read errors are no longer swallowed, opening a new plan file seeds it
from the agent's draft instead of leaving it blank and shadowing that
draft, /plan off restores the prior permission mode instead of forcing
Auto, and the session slug is stable when no session ID exists yet.
…g, and persistence

Make a bare /plan toggle off when already active instead of only
reprinting the plan. Scope plan mode to the session that entered it:
/new and /resume to a different session now exit plan mode instead of
leaking a stale grant or restore-mode across sessions. Create the
active session before naming its plan file so a fresh TUI no longer
collides on a shared plan.md. Persist every update_plan call to the
plan file so it is the durable source of truth instead of an
in-memory snapshot. Replace the preflight Lstat symlink check with
os.Root, closing the check/use race via descriptor-relative
operations. Skip the plan-file permission assertions on Windows,
where POSIX mode bits aren't meaningful.
…odes

exitPlanMode() unconditionally reset permissionMode to Auto before
restoring permissionModeBeforePlan, so /new and /resume to a different
session dropped an explicit Ask/Auto choice made outside plan mode.
Only touch permissionMode when actually leaving PermissionModePlan.
…path

string(permissionMode) already yields "plan" and "spec-draft" for the
two modes this branch handles, so the if/else was reassigning identical
values.
…tus and notes

/plan open let the user edit the plan file in $EDITOR, but the edit was
never synced back into the in-memory update_plan, so it kept driving
execution off the stale pre-edit draft. reloadPlanFromFile() now parses
the saved file and pushes it back into update_plan via a new SetPlan
method.

The first version of that parser discarded each item's [status] bracket
(resetting everything to pending on reload) and mis-parsed a "Notes: ..."
continuation line as its own bogus plan step. Both are fixed: status is
parsed back through the tool's existing normalization, and a Notes line
folds into the preceding item instead of becoming a new one.
The palette showed "/plan - Show planning mode status" but /plan
actually toggles plan mode and supports open/off subcommands.
…and session reset

- executeRequestPermissions now denies plan/spec-draft mode
  unconditionally, instead of relying on the registry-based
  ToolAdvertised gate, which only fires when the tool happens to be
  present in whatever registry the caller passed in.
- /new and /resume now clear the shared update_plan state and sticky
  plan panel on a session switch, not just the permission mode.
- A successful $EDITOR exit from /plan open now always emits
  planEditorFinishedMsg, so edited plan content actually reloads
  instead of being silently dropped.
- /plan open now blocks while a run is active, matching the bare
  /plan toggle's guard.
- parsePlanFileLines now folds multi-line Notes blocks instead of
  treating continuation lines as bogus new steps.
…ext, other findings

- /plan open now stages the plan file for $EDITOR in config.UserConfigDir()
  instead of handing it a workspace-relative path: ReadPlan/WritePlan resolve
  through os.Root and can't be redirected, but the external editor process
  opens its argument path with ordinary I/O, so a sandboxed tool invocation
  could previously replace the plan file with a symlink between our
  protected write and the editor's open. The OS temp directory doesn't avoid
  this since the sandbox's default write scope explicitly includes it.
- A user-edited plan now gets recorded as a session event on reload, so it
  actually reaches the model's context instead of only updating the
  update_plan tool's in-memory state, which the model has no way to observe
  on its own.
- /resume now hydrates the destination session's own persisted plan file
  after a session switch, instead of leaving update_plan and the sticky
  panel empty until the next update_plan call risks overwriting it.
- formatPlanItems/parsePlanFileLines now indent multi-line Content
  continuations the same way Notes continuations already were, so
  agent-authored multi-line plan steps survive a round-trip through $EDITOR
  instead of shattering into bogus new pending steps.
- WritePlan now Chmods the plan directory and file unconditionally after
  MkdirAll/OpenFile, since those only apply their mode at creation and would
  otherwise leave a pre-existing, more permissive dir/file broadly readable.
- /plan open now checks plan mode is active before ensureActiveSession
  instead of after, so an invalid invocation doesn't leave a persistent
  empty session behind in /resume.
- StageForEditor rejects a staging directory that XDG_CONFIG_HOME has
  redirected into the sandbox's default-writable roots (the workspace or
  the OS temp directory) instead of silently staging somewhere a sandboxed
  process could symlink-swap.
- The staged file is created per invocation via os.CreateTemp: a random,
  unpredictable name opened with O_EXCL, so a planted path is refused
  rather than followed, and two Zero instances editing the same resumed
  session no longer overwrite each other's staged draft. Cleanup removes
  only the file this invocation created.
- Clearing every line in the editor now records an explicit plan-cleared
  user event in the session context, so the next run does not replay the
  discarded plan from the earlier update_plan call.
- $VISUAL/$EDITOR values are parsed with POSIX shell word-splitting
  (mvdan.cc/sh/v3/shell, already a dependency) instead of strings.Fields,
  so quoted executable paths with spaces and flags launch correctly.
- The /plan palette description says the literal "off" subcommand, and
  the help expectation matches.
…an state, lossless plan encoding

- The editor staging containment check now judges physical paths: the
  staging directory is created first, resolved with EvalSymlinks, checked
  against the symlink-resolved workspace and temp roots, and the staging
  itself is anchored on the resolved path. An XDG_CONFIG_HOME symlinked
  into a sandbox-writable root no longer passes on its lexical spelling.
- update_plan refuses to apply once its run context is cancelled, with the
  check sharing the mutex that guards SetPlan, so a cancelled run's late
  call can no longer repopulate the plan the UI just reset for a new
  session; the UI-side file sync also runs only on successful results, so
  a refused call cannot rewrite the old session's plan file either.
- The plan file encoding round-trips losslessly: indentation is decided
  before content (a continuation reading "2. validate" stays a
  continuation), continuations whose text would read as structure
  ("Notes:" or a leading backslash) are escaped, and whitespace-only
  indented lines survive as blank continuation lines. Round-trip tests
  cover the adversarial cases and assert a fixed point on the second pass.
…xisting ancestor

The macOS and Windows CI runners spell temp paths through symlinks
(/var -> /private/var) and 8.3 short names (RUNNER~1): a staging directory
that does not exist yet kept its lexical spelling while the existing roots
resolved to physical form, so the containment comparison silently missed.
physicalPath now resolves the deepest existing ancestor and rejoins the
remainder, giving both sides the same spelling.
The planEditorFinishedMsg handler reloaded the edited plan file into both
the update_plan tool and the sticky panel, but emitted no visible
confirmation, so a bare /plan open with no other change looked like a
no-op. Append a system message noting the reload (or a clear when the
edited file is empty), and cover the full Update message path with a test
asserting the tool state, panel, and transcript are all updated.
Three review findings:

- Unknown /plan subcommands (a typo like "openx", or "status") fell
  through the switch to the bare toggle and silently exited the
  read-only mode. They now return a usage error; only bare /plan
  toggles.

- WritePlan opened the plan path with O_TRUNC, destroying the previous
  durable plan before the new content landed, and followed a symlink
  that resolves inside the workspace — a planted
  .zero/plans/<slug>.md symlink would redirect plan mode's one allowed
  write over an arbitrary workspace file. It now refuses symlinked
  targets and writes an owner-only O_EXCL temporary sibling renamed
  into place.

- The update_plan result callback re-read the shared tool's
  CurrentPlan() after the call released its mutex, so a cancel plus
  /new or /resume in that window persisted the wrong session's plan (or
  an empty reset) under the old run's session ID. A successful call now
  carries its own plan snapshot in the result meta and the callback
  persists exactly that snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tests

string(permissionMode) already yields "plan" / "spec-draft", so the
if/else recomputing modeName in the denial message was dead branching
on the same values. Also add plan-mode coverage mirroring three of the
four existing spec-draft regression tests: advertised tool set, and
denied write_file/bash calls. The fourth (submit-and-stop review
control) has no plan-mode analog, since plan mode has no submit tool.
… registry omits it

request_permissions is dispatched by name in executeToolCall before the
registry-based ToolAdvertised gate runs, so that gate only helps when
the tool happens to be present in the caller's registry. A plan- or
spec-draft-mode registry that simply omits the tool (rather than
registering it as denied) let the call fall through to a real
turn/session-scoped permission grant, defeating the read-only
boundary. Deny it unconditionally at the top of
executeRequestPermissions for both read-only modes instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ctive

Plan mode promises a read-only turn, but sessionStart/sessionEnd fire on
every run and beforeTool/afterTool fire around allowed read calls, and
all four execute configured host commands outside the advertised-tool
and sandbox gates — so a project hook could mutate the workspace or
spawn a process from a session that advertises it cannot. Gate all four
dispatch points on the run's permission mode, with a regression test
asserting no hook command launches during a plan-mode run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entering plan mode replaced options.SystemPrompt wholesale with
planmode.DraftSystemPrompt, discarding any embedder-configured system
prompt for the whole duration of plan mode. Layer the plan-mode
instructions onto the configured prompt instead, falling back to the
plain draft prompt when nothing was configured. Also chmod the
plan-edit staging directory unconditionally after MkdirAll, so a
pre-existing, loosely permissioned directory no longer undermines the
staging design's symlink-race protection.
Plan mode still suppresses executable hooks so a read-only planning turn
cannot spawn host processes via session or tool hooks. Spec-draft keeps
the existing trust model so trusted worktrees inherit trust under
--use-spec --worktree (TestExecSpecWorktreeInheritsTrustEndToEnd).

Also close two plan-mode advertisement gaps that Gitlawb#642 already fixed:
exclude process-spawning lsp_navigate, and require Safety metadata for
tools instead of a name-only ask_user/update_plan allowlist, with a
spoofed-name regression test.
update_plan is read-only and auto-allowed, but the TUI persisted every
successful call into .zero/plans under the workspace. Store durable
plans under the user config directory (scoped by workspace) so Ask mode
and plan mode no longer create workspace files without a write grant.

Also verify the editor staging directory is a plain owner-only dir
after chmod (reject group/world-writable or symlink paths), cover the
pre-existing permissive staging-dir case, and assert plan mode layers
DraftSystemPrompt onto a configured agent system prompt rather than
replacing it.
…ing checks

os.UserConfigDir (what config.UserConfigDir defers to outside darwin)
reads %AppData% on Windows and ignores XDG_CONFIG_HOME there, so tests
that only set XDG_CONFIG_HOME silently fail to isolate plan storage on
Windows and fall through to the runner's real profile directory. Set
AppData too wherever a test overrides the config root.

Also skip the new group/world-writable check in verifyPrivateDirectory
on Windows: NTFS reports a directory's POSIX mode via ACLs rather than
the bits os.Chmod sets, so the check rejected every staging directory
unconditionally and made /plan open never launch $EDITOR on Windows,
the same rationale already used to skip the file-mode assertion in
TestWritePlanUsesRestrictivePermissions.
slugify alone maps distinct session/workspace IDs that differ only by
separator (plan_a vs plan-a) onto the same path. pathKey appends a
SHA-256 suffix of the exact original string so durable plans stay
isolated across those collisions.

Refs Gitlawb#643
…n mode completion, and continuation whitespace
…ool policy vetoes

Reset plan mode when drafting or approving specs, preserve beforeTool policy vetoes during plan mode, reject plan storage in temp tree, and hash unmodified identifiers in pathKey.

Refs Gitlawb#643
… by main

Both were thin unscoped wrappers around the Scoped variants, deleted
upstream in Gitlawb#706 since nothing else called them directly. Only this
branch's tests still did; switch to the Scoped calls main's own tests
already use.
@euxaristia
euxaristia force-pushed the feat/tui-plan-mode branch from 0708379 to a372f61 Compare July 31, 2026 19:46

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plan-mode core here is good. I drove the real advertised-tool gate against the full core registry and plan mode exposes exactly ask_user, glob, grep, list_directory, read_file, read_minified_file, skill, update_plan — every mutator, web_fetch, lsp_navigate, request_permissions, the Task/swarm spawners (SideEffectShell) and all MCP tools (SideEffectNetwork) are denied, at advertisement and at dispatch. The name-only spoofing guard on update_plan/ask_user is the right call, and so is denying request_permissions before the registry lookup rather than relying on the registry to omit it. tea.ExecProcess is used correctly: the m.pending || m.exiting gate keeps it off a live run, the staged copy plus defer cleanup() in the callback is right, and I couldn't find a terminal-state path that escapes bubbletea's release/restore. go build ./..., go vet, gofmt -l, and go test ./internal/tui ./internal/agent ./internal/planmode ./internal/tools are all clean here.

Four things before this goes in.

1. /btw is the session switch you missed. internal/tui/btw.go:94 does side.activeSession = fork without exitPlanMode() or resetPlanForSessionSwitch(). You guard the other four switch sites (session.go:70, session.go:241, spec_mode.go:38, spec_mode.go:203); this is the fifth. Driving the real path — enter plan mode from Ask with an update_plan draft in the tool, then /btw — gives:

  • side.permissionMode == "plan" and side.permissionModeBeforePlan == "ask", so the isolated side conversation is silently read-only and a /plan off inside it restores the main session's prior mode into the fork.
  • side.planText() renders the main session's plan. side.plan.clear() at btw.go:144 only clears the sticky panel; the shared update_plan tool still holds the parent's items.
  • Worse, that leak is now durable: /plan open inside the side conversation seeds the fork's plan file with the parent's plan. I got planmode.ReadPlan(cwd, side.activeSession.SessionID) returning exists=true, "1. [in_progress] MAIN SESSION SECRET STEP\n" for a session that never drafted it.

Add the same two calls at btw.go:94. Two more things while you're in there: leaveBTW (btw.go:164) restores the parent model wholesale but not the shared update_plan tool, which the side conversation may have replaced — it should re-hydrate from the parent session's plan file the way handleResumeCommand now does. And btwCommandUnavailable (btw.go:206) already blocks /new, /resume, /spec, /loop, /goal; /plan now mutates permission mode and writes durable per-session files, so it probably belongs on that list too.

2. internal/planmode drags testing into the shipped binary. planmode.go:12 imports "testing" for SetTempDirForTest (planmode.go:381). go list -deps ./cmd/zero | grep -cx testing is 0 on origin/main and 1 on this branch (it brings flag and regexp along too), and planmode.go is the only non-test file under internal/ that does this. Move the helper to an export_test.go in the package, or to a planmodetest subpackage, and keep tempDirFn unexported.

3. program *tea.Program at model.go:135 is dead. Nothing assigns or reads it — deleting the line and running go build ./internal/tui/ exits 0. The comment says it's "set right before Run", but run.go is untouched by this PR, and plan_command_test.go:210 already refers to it as "(now-removed)". Drop the field and fix that test's rationale comment.

4. hooksSuppressed's comment says something the code doesn't do. loop.go:1791 explains suppression as preventing "merely starting a plan session or calling read_file" from mutating the workspace or spawning processes — but dispatchBeforeTool is deliberately exempt, and beforeTool is precisely the hook that fires on every read_file. I ran a plan-mode Run with a beforeTool hook that shells out to go mod init -modfile <tmp>/go.mod: the audit store logged hook_execution_started/completed for beforeTool, read_file returned normally, and the file existed on disk afterwards. Keeping beforeTool for fail-closed policy vetoes is the right trade-off — just say so in the comment instead of claiming the opposite. Relatedly, TestRunSuppressesExecutableHooksInPlanMode asserts "no hook command at all" but only wires sessionStart/sessionEnd; either soften the wording or add a case that pins the beforeTool exemption, so a future change can't flip it silently.

Smaller things, none blocking:

  • exitPlanMode (plan_command.go:122) falls back to Auto when permissionModeBeforePlan is empty. nextPermissionMode folds unknown modes to Ask on purpose ("the stricter landing") and app.go:786 makes Ask the interactive default — Auto is the looser landing. Only reachable via an embedder starting in plan mode, but make it Ask.
  • handleSpecCommand (spec_mode.go:38) clears plan state before createSpecDraftSession; on a create failure the user loses plan mode and the in-memory plan with no session switch. handleResumeCommand's ordering (switch, then reset) is the shape to copy.
  • result.Meta[plan_snapshot] lands verbatim in the session event log (model.go:5526), so every update_plan stores the plan twice on disk. It isn't replayed into model context, so it's disk-only, but stripping it from toolPayload is cheap.
  • Plan files accumulate under UserConfigDir/zero/plans forever, one per (workspace, session), with no pruning. Worth a retention story.
  • Entering plan mode doesn't pause an armed /goal or /loop, so continuations keep firing turns that can't make progress. Safe, just wasteful.

One thing I checked and am happy with: I fuzzed formatPlanItems/parsePlanFileLines beyond your tests (empty content, leading whitespace on the first line, a first line reading "3. ...", a [weird] leading token, tab continuations, blank note lines, an empty first Notes line, CRLF). Every case is a fixed point under repeated open-and-save; the only losses are leading whitespace on an item's first content line and an empty first Notes line, both harmless. The escape/indent encoding holds up.

Same as the others today: this is everything in one pass, nothing queued behind it. And thanks for the turnaround on #849 — that one went from requested-changes to approved inside two hours, which is the loop I'd like these to run in.

Reset plan mode and in-memory plan state when entering a BTW side session so
/btw matches the /new and /resume session-switch guards. Move SetTempDirForTest
into export_test.go so cmd/zero no longer depends on testing. Drop the unused
model.program field. Clarify that plan mode suppresses lifecycle and afterTool
hooks only, while beforeTool still runs for fail-closed vetoes, and pin that
behavior with a regression test.
@euxaristia

Copy link
Copy Markdown
Contributor Author

Addressed review:

  1. /btw now calls exitPlanMode + resetPlanForSessionSwitch like the other session-switch sites (regression: TestBTWExitsPlanModeOnSideAndPreservesParent).
  2. Moved SetTempDirForTest to export_test.go so testing is not a dep of ./cmd/zero.
  3. Dropped unused program *tea.Program on the model.
  4. Corrected hooksSuppressed docs: advisory hooks only; beforeTool still runs for fail-closed vetoes (TestBeforeToolStillRunsInPlanMode).

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — all four are done, and I checked each one rather than going off the summary.

The /btw fix is the one I cared about. It now calls exitPlanMode and resetPlanForSessionSwitch like the other four switch sites, so it's no longer the odd one out. I commented out the exitPlanMode call and TestBTWExitsPlanModeOnSideAndPreservesParent fails with "BTW side kept plan mode: plan", so the guard is genuinely held in place rather than just present in the diff. Good that the test also pins the parent side surviving — that's the half that would have been easy to miss.

The testing import is properly gone: go list -deps ./cmd/zero | grep -cx testing is 0 on this head, where it was 1 before. Moving SetTempDirForTest into export_test.go was the cleaner of the two options I suggested.

Dead program field is gone, and the hooksSuppressed comment now says advisory-only with beforeTool still running for fail-closed vetoes — which matches what #853 actually does now, so the two PRs tell the same story. Worth something that they agree; a comment that drifts from its sibling PR is how the original confusion started.

Nothing else from me on this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants