diff --git a/src/commands/components/execute/execute-solid.tsx b/src/commands/components/execute/execute-solid.tsx index 2f70b80..32b4b8b 100644 --- a/src/commands/components/execute/execute-solid.tsx +++ b/src/commands/components/execute/execute-solid.tsx @@ -2,7 +2,11 @@ import { tui } from "@tui-solid/app"; import { getProviderInfo } from "@services/chat-tui-service"; import type { ChatServiceState } from "@services/chat-tui-service"; import type { AgentConfig } from "@/types/agent-config"; -import type { PermissionScope, LearningScope } from "@/types/tui"; +import type { + PermissionScope, + LearningScope, + PlanApprovalPromptResponse, +} from "@/types/tui"; export interface RenderAppSolidProps { sessionId: string; @@ -15,6 +19,7 @@ export interface RenderAppSolidProps { allowed: boolean, scope?: PermissionScope, ) => void; + handlePlanApprovalResponse?: (response: PlanApprovalPromptResponse) => void; handleLearningResponse?: ( save: boolean, scope?: LearningScope, @@ -47,6 +52,7 @@ export const renderAppSolid = async ( onModelSelect: props.handleModelSelect, onThemeSelect: props.handleThemeSelect, onPermissionResponse: props.handlePermissionResponse ?? (() => {}), + onPlanApprovalResponse: props.handlePlanApprovalResponse ?? (() => {}), onLearningResponse: props.handleLearningResponse ?? (() => {}), plan: props.plan, }); diff --git a/src/commands/components/execute/execute.tsx b/src/commands/components/execute/execute.tsx index bba614b..131796d 100644 --- a/src/commands/components/execute/execute.tsx +++ b/src/commands/components/execute/execute.tsx @@ -5,7 +5,11 @@ import { addServer, connectServer } from "@services/mcp/manager"; import * as brainService from "@services/brain"; import type { ChatServiceState } from "@services/chat-tui-service"; import type { AgentConfig } from "@/types/agent-config"; -import type { PermissionScope, LearningScope } from "@/types/tui"; +import type { + PermissionScope, + LearningScope, + PlanApprovalPromptResponse, +} from "@/types/tui"; import type { ProviderModel } from "@/types/providers"; import type { MCPAddFormData } from "@/types/mcp"; @@ -29,6 +33,7 @@ export interface RenderAppProps { allowed: boolean, scope?: PermissionScope, ) => void; + handlePlanApprovalResponse?: (response: PlanApprovalPromptResponse) => void; handleLearningResponse?: ( save: boolean, scope?: LearningScope, @@ -189,6 +194,7 @@ export const renderApp = async (props: RenderAppProps): Promise => { }, onMCPAdd: props.handleMCPAdd ?? defaultHandleMCPAdd, onPermissionResponse: props.handlePermissionResponse ?? (() => {}), + onPlanApprovalResponse: props.handlePlanApprovalResponse ?? (() => {}), onLearningResponse: props.handleLearningResponse ?? (() => {}), onBrainSetJwtToken: props.handleBrainSetJwtToken ?? defaultHandleBrainSetJwtToken, diff --git a/src/services/chat-tui/plan-approval.ts b/src/services/chat-tui/plan-approval.ts new file mode 100644 index 0000000..95ce88d --- /dev/null +++ b/src/services/chat-tui/plan-approval.ts @@ -0,0 +1,63 @@ +/** + * Chat TUI plan approval handling + * + * Follows the same pattern as permissions.ts: + * Creates a blocking promise that resolves when the user responds + * to the plan approval modal. + */ + +import { v4 as uuidv4 } from "uuid"; + +import type { PlanApprovalPromptResponse } from "@/types/tui"; +import type { ImplementationPlan } from "@/types/plan-mode"; +import { appStore } from "@tui-solid/context/app"; + +export interface PlanApprovalHandlerRequest { + plan: ImplementationPlan; + planFilePath?: string; +} + +export type PlanApprovalHandler = ( + request: PlanApprovalHandlerRequest, +) => Promise; + +let planApprovalHandler: PlanApprovalHandler | null = null; + +export const setPlanApprovalHandler = ( + handler: PlanApprovalHandler | null, +): void => { + planApprovalHandler = handler; +}; + +export const getPlanApprovalHandler = (): PlanApprovalHandler | null => + planApprovalHandler; + +export const createPlanApprovalHandler = (): PlanApprovalHandler => { + return ( + request: PlanApprovalHandlerRequest, + ): Promise => { + return new Promise((resolve) => { + appStore.setMode("plan_approval"); + + appStore.setPlanApprovalPrompt({ + id: uuidv4(), + planTitle: request.plan.title, + planSummary: request.plan.summary, + planFilePath: request.planFilePath, + resolve: (response) => { + appStore.setPlanApprovalPrompt(null); + appStore.setMode("thinking"); + resolve(response); + }, + }); + }); + }; +}; + +export const setupPlanApprovalHandler = (): void => { + setPlanApprovalHandler(createPlanApprovalHandler()); +}; + +export const cleanupPlanApprovalHandler = (): void => { + setPlanApprovalHandler(null); +}; diff --git a/src/tui-solid/components/modals/plan-approval-modal.tsx b/src/tui-solid/components/modals/plan-approval-modal.tsx index 2c49dcc..8af3883 100644 --- a/src/tui-solid/components/modals/plan-approval-modal.tsx +++ b/src/tui-solid/components/modals/plan-approval-modal.tsx @@ -2,77 +2,62 @@ import { createSignal, createMemo, For, Show } from "solid-js"; import { useKeyboard } from "@opentui/solid"; import { TextAttributes } from "@opentui/core"; import { useTheme } from "@tui-solid/context/theme"; -import type { ImplementationPlan, PlanStep } from "@/types/plan-mode"; - -/** - * Plan approval request for the TUI - */ -export interface PlanApprovalRequest { - plan: ImplementationPlan; - resolve?: (response: { approved: boolean; message?: string }) => void; -} +import type { PlanApprovalPrompt, PlanApprovalPromptResponse } from "@/types/tui"; +import { + PLAN_APPROVAL_OPTIONS, + PLAN_APPROVAL_FOOTER_TEXT, +} from "@constants/plan-approval"; +import type { PlanApprovalOption, PlanEditMode } from "@constants/plan-approval"; interface PlanApprovalModalProps { - request: PlanApprovalRequest; - onRespond?: (approved: boolean, message?: string) => void; + prompt: PlanApprovalPrompt; + onRespond: (response: PlanApprovalPromptResponse) => void; isActive?: boolean; } -interface ApprovalOption { - key: string; - label: string; - approved: boolean; - message?: string; -} - -const APPROVAL_OPTIONS: ApprovalOption[] = [ - { key: "y", label: "Approve - proceed with implementation", approved: true }, - { key: "e", label: "Edit - modify plan before execution", approved: false, message: "edit" }, - { key: "n", label: "Reject - cancel this plan", approved: false }, -]; - export function PlanApprovalModal(props: PlanApprovalModalProps) { const theme = useTheme(); const [selectedIndex, setSelectedIndex] = createSignal(0); - const [scrollOffset, setScrollOffset] = createSignal(0); + const [feedbackMode, setFeedbackMode] = createSignal(false); + const [feedbackText, setFeedbackText] = createSignal(""); const isActive = () => props.isActive ?? true; - const MAX_VISIBLE_STEPS = 5; + const optionCount = PLAN_APPROVAL_OPTIONS.length; - const plan = () => props.request.plan; - - const visibleSteps = createMemo(() => { - const steps = plan().steps; - const offset = scrollOffset(); - return steps.slice(offset, offset + MAX_VISIBLE_STEPS); - }); - - const canScrollUp = () => scrollOffset() > 0; - const canScrollDown = () => scrollOffset() + MAX_VISIBLE_STEPS < plan().steps.length; - - const handleResponse = (approved: boolean, message?: string): void => { - if (props.request.resolve) { - props.request.resolve({ approved, message }); + const handleApproval = (option: PlanApprovalOption): void => { + if (option.editMode === "feedback") { + setFeedbackMode(true); + return; } - props.onRespond?.(approved, message); + + props.onRespond({ + approved: true, + editMode: option.editMode, + }); }; - const getRiskIcon = (risk: PlanStep["riskLevel"]): string => { - const icons: Record = { - high: "\u26A0\uFE0F", - medium: "\u26A1", - low: "\u2713", - }; - return icons[risk]; + const handleFeedbackSubmit = (): void => { + const text = feedbackText().trim(); + if (!text) return; + + props.onRespond({ + approved: true, + editMode: "feedback", + feedback: text, + }); }; - const getRiskColor = (risk: PlanStep["riskLevel"]): string => { - const colors: Record = { - high: theme.colors.error, - medium: theme.colors.warning, - low: theme.colors.success, - }; - return colors[risk]; + const handleCancel = (): void => { + if (feedbackMode()) { + setFeedbackMode(false); + setFeedbackText(""); + return; + } + + props.onRespond({ + approved: false, + editMode: "manual_approve", + }); }; useKeyboard((evt) => { @@ -80,57 +65,71 @@ export function PlanApprovalModal(props: PlanApprovalModalProps) { evt.stopPropagation(); - if (evt.name === "up") { - if (evt.shift) { - // Scroll plan view - if (canScrollUp()) { - setScrollOffset((prev) => prev - 1); - } - } else { - // Navigate options - setSelectedIndex((prev) => - prev > 0 ? prev - 1 : APPROVAL_OPTIONS.length - 1, - ); + // Feedback mode: handle text input + if (feedbackMode()) { + if (evt.name === "return") { + handleFeedbackSubmit(); + evt.preventDefault(); + return; } + if (evt.name === "escape") { + handleCancel(); + evt.preventDefault(); + return; + } + if (evt.name === "backspace") { + setFeedbackText((prev) => prev.slice(0, -1)); + evt.preventDefault(); + return; + } + if (evt.name.length === 1 && !evt.ctrl && !evt.meta) { + setFeedbackText((prev) => prev + evt.name); + evt.preventDefault(); + } + return; + } + + // Normal mode: navigate options + if (evt.name === "up") { + setSelectedIndex((prev) => + prev > 0 ? prev - 1 : optionCount - 1, + ); evt.preventDefault(); return; } if (evt.name === "down") { - if (evt.shift) { - // Scroll plan view - if (canScrollDown()) { - setScrollOffset((prev) => prev + 1); - } - } else { - // Navigate options - setSelectedIndex((prev) => - prev < APPROVAL_OPTIONS.length - 1 ? prev + 1 : 0, - ); - } + setSelectedIndex((prev) => + prev < optionCount - 1 ? prev + 1 : 0, + ); evt.preventDefault(); return; } if (evt.name === "return") { - const option = APPROVAL_OPTIONS[selectedIndex()]; - handleResponse(option.approved, option.message); + handleApproval(PLAN_APPROVAL_OPTIONS[selectedIndex()]); evt.preventDefault(); return; } if (evt.name === "escape") { - handleResponse(false, "cancelled"); + handleCancel(); evt.preventDefault(); return; } - // Handle shortcut keys + // Shift+Tab shortcut for option 1 (auto-accept clear) + if (evt.name === "tab" && evt.shift) { + handleApproval(PLAN_APPROVAL_OPTIONS[0]); + evt.preventDefault(); + return; + } + + // Number key shortcuts (1-4) if (evt.name.length === 1 && !evt.ctrl && !evt.meta) { - const charLower = evt.name.toLowerCase(); - const option = APPROVAL_OPTIONS.find((o) => o.key === charLower); + const option = PLAN_APPROVAL_OPTIONS.find((o) => o.key === evt.name); if (option) { - handleResponse(option.approved, option.message); + handleApproval(option); evt.preventDefault(); } } @@ -139,130 +138,110 @@ export function PlanApprovalModal(props: PlanApprovalModalProps) { return ( {/* Header */} - - Plan Approval Required - - - - {/* Title and Summary */} - - {plan().title} - - {plan().summary} - - - {/* Estimated Changes */} - - - +{plan().estimatedChanges.filesCreated} create{" "} - - - ~{plan().estimatedChanges.filesModified} modify{" "} - - - -{plan().estimatedChanges.filesDeleted} delete + CodeTyper has written up a plan and is ready to execute. Would you + like to proceed? - {/* Steps */} - - - Implementation Steps ({plan().steps.length} total): - - - {"\u25B2"} more above... - - - {(step, index) => ( - - - {getRiskIcon(step.riskLevel)}{" "} - - - {scrollOffset() + index() + 1}.{" "} - - - {step.title.substring(0, 50)} - {step.title.length > 50 ? "..." : ""} - - - )} - - - {"\u25BC"} more below... - - - - {/* Risks */} - 0}> - - - Risks ({plan().risks.length}): + {/* Plan info */} + + + + {props.prompt.planTitle} - - {(risk) => ( - - - - - {risk.description.substring(0, 60)} - - - )} - - 2}> - - {" "} - ... and {plan().risks.length - 2} more - - + + + + + + {props.prompt.planSummary} {/* Options */} - - - {(option, index) => { - const isSelected = () => index() === selectedIndex(); - const keyColor = () => - option.approved ? theme.colors.success : theme.colors.error; - return ( - - - {isSelected() ? "> " : " "} - - [{option.key}] - - {option.label} - - - ); - }} - - + + + + {(option, index) => { + const isSelected = () => index() === selectedIndex(); + return ( + + + {isSelected() ? "> " : " "} + + {option.key}. + + {option.label} + + + + {" "}({option.shortcut}) + + + + ); + }} + + + + + {/* Feedback input mode */} + + + + Tell CodeTyper what to change: + + + + {feedbackText() || " "} + _ + + + + Enter to submit | Esc to cancel + + + {/* Footer */} - - - {"\u2191\u2193"} options | Shift+{"\u2191\u2193"} scroll steps | Enter select | y/e/n shortcut - - + + + + + {PLAN_APPROVAL_FOOTER_TEXT} - {props.prompt.planFilePath} + + + + + {"\u2191\u2193"} options | Enter select | 1-4 shortcut | Esc + cancel + + + + ); } diff --git a/src/tui-solid/routes/session.tsx b/src/tui-solid/routes/session.tsx index 3f8c712..e8a62a6 100644 --- a/src/tui-solid/routes/session.tsx +++ b/src/tui-solid/routes/session.tsx @@ -22,6 +22,7 @@ import { ModeSelect } from "@tui-solid/components/submenu/mode-select"; import { ProviderSelect } from "@tui-solid/components/submenu/provider-select"; import { FilePicker } from "@tui-solid/components/inputs/file-picker"; import { PermissionModal } from "@tui-solid/components/modals/permission-modal"; +import { PlanApprovalModal } from "@tui-solid/components/modals/plan-approval-modal"; import { LearningModal } from "@tui-solid/components/modals/learning-modal"; import { HelpMenu } from "@tui-solid/components/menu/help-menu"; import { HelpDetail } from "@tui-solid/components/panels/help-detail"; @@ -36,6 +37,7 @@ import type { LearningScope, InteractionMode, MCPServerDisplay, + PlanApprovalPromptResponse, } from "@/types/tui"; import type { MCPAddFormData } from "@/types/mcp"; @@ -63,6 +65,7 @@ interface SessionProps { onProviderSelect?: (providerId: string) => void; onCascadeToggle?: () => void; onPermissionResponse: (allowed: boolean, scope?: PermissionScope) => void; + onPlanApprovalResponse: (response: PlanApprovalPromptResponse) => void; onLearningResponse: ( save: boolean, scope?: LearningScope, @@ -297,8 +300,18 @@ export function Session(props: SessionProps) { /> + + + + - +