From cba508c9ee39b4c9e13cc48b5c2f96e13f8f79f3 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:38:51 -0400 Subject: [PATCH] feat(tui): add interactive plan mode and fix compilation --- internal/agent/loop.go | 25 ++++++- internal/agent/types.go | 6 ++ internal/tui/model.go | 6 +- internal/tui/plan_command.go | 130 +++++++++++++++++++++++++++++++++-- internal/tui/run.go | 1 + 5 files changed, 159 insertions(+), 9 deletions(-) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 2ea4ac0bd..ed9314520 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -898,12 +898,18 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal }, nil } tool, toolFound := registry.Get(call.Name) - if permissionMode == PermissionModeSpecDraft && toolFound && !ToolAdvertised(tool, permissionMode) { + if (permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan) && toolFound && !ToolAdvertised(tool, permissionMode) { + modeName := string(permissionMode) + if permissionMode == PermissionModePlan { + modeName = "plan" + } else { + modeName = "spec-draft" + } return ToolResult{ ToolCallID: call.ID, Name: call.Name, Status: tools.StatusError, - Output: `Error: Tool "` + call.Name + `" is not available in spec-draft mode.`, + Output: `Error: Tool "` + call.Name + `" is not available in ` + modeName + ` mode.`, DenialReason: DenialFiltered, }, nil } @@ -2857,6 +2863,9 @@ func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool { if permissionMode == PermissionModeSpecDraft { return toolAdvertisedInSpecDraft(tool) } + if permissionMode == PermissionModePlan { + return toolAdvertisedInPlan(tool) + } if permissionMode == PermissionModeAuto { return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto } @@ -2889,6 +2898,18 @@ func toolAdvertisedInSpecDraft(tool tools.Tool) bool { return safety.SideEffect == tools.SideEffectRead && safety.Permission == tools.PermissionAllow } +// toolAdvertisedInPlan mirrors toolAdvertisedInSpecDraft: the agent may only +// read the workspace, ask the user, and shape the plan with update_plan. No +// mutating tool is advertised, so plan mode stays strictly read-only. +func toolAdvertisedInPlan(tool tools.Tool) bool { + switch tool.Name() { + case "ask_user", "update_plan": + return true + } + safety := tool.Safety() + return safety.SideEffect == tools.SideEffectRead && safety.Permission == tools.PermissionAllow +} + func stopReasonFromToolResult(result ToolResult) StopReason { if result.Meta == nil { return "" diff --git a/internal/agent/types.go b/internal/agent/types.go index 1b12e0c78..91f9d1eea 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -24,6 +24,12 @@ const ( PermissionModeAsk PermissionMode = "ask" PermissionModeUnsafe PermissionMode = "unsafe" PermissionModeSpecDraft PermissionMode = "spec-draft" + // PermissionModePlan is an interactive, read-only planning mode toggled from + // the TUI with /plan. It applies to the CURRENT session (unlike spec-draft, + // which drafts in a separate session): the agent may inspect the workspace + // and shape the plan with update_plan/ask_user, but no mutating tool is + // advertised, so it cannot write files, run shell, or implement while planning. + PermissionModePlan PermissionMode = "plan" // PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it // advertises the in-workspace mutators a member needs to build (write/edit + // shell) on top of the Auto set, while the sandbox engine still gates them at diff --git a/internal/tui/model.go b/internal/tui/model.go index 908777668..40bf1cc48 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -126,6 +126,9 @@ type model struct { agentOptions agent.Options notifier *notify.Notifier permissionMode agent.PermissionMode + // program is the live Bubble Tea program, set right before Run so /plan open + // can suspend the TUI, launch $EDITOR, and resume on exit. + program *tea.Program selfCorrectTests bool reasoningEffort modelregistry.ReasoningEffort responseStyle string @@ -4204,8 +4207,7 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) { m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.debugText()}) return m, nil case commandPlan: - m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.planText()}) - return m, nil + return m.handlePlanCommand(command.text) case commandDoctor: return m.startDoctorCommand(command.text) case commandSearch: diff --git a/internal/tui/plan_command.go b/internal/tui/plan_command.go index 8baf629da..5d4dd5b83 100644 --- a/internal/tui/plan_command.go +++ b/internal/tui/plan_command.go @@ -2,8 +2,14 @@ package tui import ( "fmt" + "os" + "os/exec" "strings" + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/planmode" "github.com/Gitlawb/zero/internal/tools" ) @@ -11,20 +17,126 @@ type currentPlanReader interface { CurrentPlan() []tools.PlanItem } +// handlePlanCommand toggles plan mode on the current session, in the style of +// openclaude's /plan: +// +// /plan toggle plan mode on/off; when on, show the current plan +// /plan open open the session's plan file in $VISUAL/$EDITOR +// /plan off exit plan mode (alias: /plan exit) +// +// Plan mode is read-only: tool advertisement (see agent.toolAdvertisedInPlan) +// only exposes read tools, update_plan, and ask_user, so the agent cannot +// mutate the workspace while planning. +func (m model) handlePlanCommand(text string) (tea.Model, tea.Cmd) { + if _, ok := m.registry.Get("update_plan"); !ok { + m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "No plan is active."}) + return m, nil + } + + arg := strings.ToLower(strings.TrimSpace(text)) + switch arg { + case "off", "exit": + if m.permissionMode != agent.PermissionModePlan { + m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Plan mode is not active."}) + return m, nil + } + m.permissionMode = agent.PermissionModeAuto + m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Exited plan mode. The agent can now implement."}) + return m, nil + case "open": + return m.openPlanInEditor() + } + + // No subcommand: toggle plan mode, then surface the current plan. + if m.permissionMode == agent.PermissionModePlan { + m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.planText()}) + return m, nil + } + if m.pending || m.exiting { + m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "Cannot enter plan mode while a run is active."}) + return m, nil + } + m.permissionMode = agent.PermissionModePlan + textToShow := planEnterText(m) + "\n\n" + m.planText() + m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: textToShow}) + return m, nil +} + +// openPlanInEditor writes the session plan file (if missing) and suspends the +// TUI to launch $VISUAL/$EDITOR on it, resuming on exit. +func (m model) openPlanInEditor() (tea.Model, tea.Cmd) { + if m.permissionMode != agent.PermissionModePlan { + m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Enter plan mode (/plan) before opening the plan file."}) + return m, nil + } + path, err := planmode.PlanFilePath(m.cwd, m.activeSession.SessionID) + if err != nil { + m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "plan path error: " + err.Error()}) + return m, nil + } + if _, ok := fileExists(path); !ok { + if _, err := planmode.WritePlan(m.cwd, m.activeSession.SessionID, ""); err != nil { + m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "plan write error: " + err.Error()}) + return m, nil + } + } + editor := strings.TrimSpace(os.Getenv("VISUAL")) + if editor == "" { + editor = strings.TrimSpace(os.Getenv("EDITOR")) + } + if editor == "" { + m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Set $VISUAL or $EDITOR to open the plan file:\n" + path}) + return m, nil + } + if m.program == nil { + // No live program (e.g. under test): just report the path. + m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Plan file: " + path}) + return m, nil + } + parts := strings.Fields(editor) + cmd := exec.Command(parts[0], append(parts[1:], path)...) //nolint:gosec // editor path from $VISUAL/$EDITOR + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return m, tea.ExecProcess(cmd, func(err error) tea.Msg { + return nil + }) +} + +func planEnterText(m model) string { + path, err := planmode.PlanFilePath(m.cwd, m.activeSession.SessionID) + planNote := "" + if err == nil && path != "" { + planNote = "\nPlan file: " + path + } + return "Entered plan mode. The agent can inspect the workspace and shape the plan with update_plan, but cannot edit files or run commands until you exit.\n" + + "Use /plan to view the plan, /plan open to edit it, or /plan off to implement." + planNote +} + func (m model) planText() string { + // Prefer the session plan file when present. + if path, err := planmode.PlanFilePath(m.cwd, m.activeSession.SessionID); err == nil && path != "" { + if content, exists, err := planmode.ReadPlan(m.cwd, m.activeSession.SessionID); err == nil && exists { + header := "Current Plan (plan mode)" + if path != "" { + header += "\n" + path + } + return header + "\n" + strings.TrimRight(content, "\n") + } + } + + // Fall back to the update_plan list the agent has been building. tool, ok := m.registry.Get("update_plan") if !ok { - return "No plan is active." + return "Plan mode is active. No plan written yet. Use update_plan to outline steps, or /plan open to draft the plan file." } - reader, ok := tool.(currentPlanReader) if !ok { - return "No plan is active." + return "Plan mode is active. No plan written yet." } - plan := reader.CurrentPlan() if len(plan) == 0 { - return "No plan is active." + return "Plan mode is active. No plan written yet. Use update_plan to outline steps, or /plan open to draft the plan file." } lines := make([]string, 0, len(plan)+1) @@ -38,3 +150,11 @@ func (m model) planText() string { } return strings.Join(lines, "\n") } + +func fileExists(path string) (struct{}, bool) { + _, err := os.Stat(path) + if err != nil { + return struct{}{}, false + } + return struct{}{}, true +} diff --git a/internal/tui/run.go b/internal/tui/run.go index f2925dea2..ccde3964d 100644 --- a/internal/tui/run.go +++ b/internal/tui/run.go @@ -57,6 +57,7 @@ func Run(ctx context.Context, options Options) int { initialModel.mouseCapture = true } program = tea.NewProgram(initialModel, programOpts...) + initialModel.program = program if _, err := program.Run(); err != nil { // Surface the failure: exiting 1 with zero diagnostics left users