diff --git a/apps/web/src/agent/orchestrator.ts b/apps/web/src/agent/orchestrator.ts index dc73dab2..df4a800d 100644 --- a/apps/web/src/agent/orchestrator.ts +++ b/apps/web/src/agent/orchestrator.ts @@ -9,6 +9,7 @@ import { toolRegistry } from "@/agent/tools/registry"; import "@/agent/tools"; import { useChatStore } from "@/stores/chat-store"; import { useAgentStore } from "@/stores/agent-store"; +import { usePlanStore } from "@/stores/plan-store"; const MAX_ITERATIONS = 20; @@ -33,6 +34,8 @@ const WRITE_TOOLS = new Set([ "update_keyframe_curve", ]); +const EXECUTE_ONLY_TOOLS = new Set(["update_plan_step"]); + interface APIResponse { content: string; toolCalls?: ToolCall[]; @@ -57,10 +60,16 @@ export async function run( while (iterations < MAX_ITERATIONS) { iterations++; + const liveMode = useAgentStore.getState().mode; + const liveContext: AgentContext = { ...context, mode: liveMode }; + const response = await fetch("/api/agent/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: workingMessages, context }), + body: JSON.stringify({ + messages: workingMessages, + context: liveContext, + }), }); if (!response.ok) { @@ -210,9 +219,45 @@ async function resolveToolCalls( for (const tc of toolCalls) { useAgentStore.getState().setActiveTool(tc.name); + const currentMode = useAgentStore.getState().mode; + const currentPlan = usePlanStore.getState().plan; - const currentMode = useAgentStore.getState().permissionMode; - if (currentMode === "ask" && WRITE_TOOLS.has(tc.name)) { + if ( + WRITE_TOOLS.has(tc.name) && + currentPlan?.status === "awaiting_approval" + ) { + results.push({ + toolCallId: tc.id, + name: tc.name, + result: null, + error: + "Cannot edit while a plan is awaiting user approval. Call request_plan_approval and wait for the user to choose Go edit.", + }); + continue; + } + + if (currentMode === "plan" && WRITE_TOOLS.has(tc.name)) { + results.push({ + toolCallId: tc.id, + name: tc.name, + result: null, + error: `Cannot use '${tc.name}' in plan mode. This tool modifies the timeline. Use submit_plan to finalize your plan, then request_plan_approval to switch to execute mode.`, + }); + continue; + } + + if (currentMode === "plan" && EXECUTE_ONLY_TOOLS.has(tc.name)) { + results.push({ + toolCallId: tc.id, + name: tc.name, + result: null, + error: `Cannot use '${tc.name}' in plan mode. This tool is only available during execution.`, + }); + continue; + } + + const currentPermissionMode = useAgentStore.getState().permissionMode; + if (currentPermissionMode === "ask" && WRITE_TOOLS.has(tc.name)) { const approved = await requestApproval(tc); if (!approved) { results.push({ diff --git a/apps/web/src/agent/system-prompt.ts b/apps/web/src/agent/system-prompt.ts index 348fd867..213e0a13 100644 --- a/apps/web/src/agent/system-prompt.ts +++ b/apps/web/src/agent/system-prompt.ts @@ -1,20 +1,80 @@ -import type { AgentContext } from "@/agent/types"; +import type { AgentContext, AgentMode } from "@/agent/types"; export interface ToolSummary { name: string; description: string; } -/** - * Builds the system prompt for the LLM from the editor context. - * - * Pure function — no client-only imports (no EditorCore, WASM, browser APIs). - * Consumable server-side in the API route. - */ +const PLAN_MODE_INSTRUCTIONS = ` +## CURRENT MODE: PLAN (READ-ONLY) + +You are in PLAN mode. You CANNOT make any edits to the timeline. You can only: +- Read and analyze the project (list_project_assets, list_timeline, get_element, list_effects, get_effect, list_keyframes, list_animatable_properties) +- Load media into your context (load_context) +- Discover skills (list_skills, load_skill) +- Ask the user questions (ask_user) +- Submit your plan (submit_plan) + +## YOUR WORKFLOW IN PLAN MODE: + +1. **Analyze**: Use read-only tools to understand what media and timeline state exist. Use load_context to actually see/hear the footage. +2. **Discover**: If the user's request matches a known editing pattern, call list_skills to find relevant techniques. +3. **Clarify**: If anything is ambiguous, use ask_user to get clarification before planning. +4. **Plan**: Once you understand the footage and the goal, call submit_plan with a structured step-by-step plan. This shows the user a plan card with Go edit / Keep planning and waits for their choice. + +## PLAN QUALITY RULES: + +- Each step must reference SPECIFIC tools and SPECIFIC values (timestamps, element IDs after discovery, effect params) +- Steps should be ordered by dependency (split before delete, add text before animate, etc.) +- If a skill was loaded, reference its techniques but ADAPT them to the actual footage — never copy blindly +- Include timing estimates where possible +- Be honest about limitations — if the footage doesn't support a technique, say so + +## WHEN TO SUBMIT: + +Submit the plan when: +- You have enough context about the media content +- All clarifying questions have been answered +- You have a concrete, actionable plan + +After submit_plan returns approved=true, continue with the plan in execute mode. If it returns approved=false, keep discussing and refining the plan. +`; + +const EXECUTE_MODE_INSTRUCTIONS = ` +## CURRENT MODE: EXECUTE + +You are in EXECUTE mode. You can read AND write to the timeline, but complex editing requests still require a plan first. + +## MANDATORY PLANNING GATE + +For any complex editing request that would require multiple timeline edits (cuts, moving media, adding titles/effects, or touching 3+ tools), you MUST: + +1. Analyze the project with read-only tools. +2. Call submit_plan with a concise structured checklist. +3. Wait for the user to choose Go edit. + +Do NOT perform write tools for complex edits before approval. This is required even if the current mode is EXECUTE. + +## EXECUTION RULES: + +1. Follow the plan steps in order. Each step lists the tools to use. +2. After completing a step, call update_plan_step to mark it done. +3. If a step fails, note the error in update_plan_step and decide whether to continue or skip. +4. If you discover the plan needs adjustment mid-execution, explain what changed and why. +5. When all steps are done, provide a summary of what was accomplished. + +## IF NO ACTIVE PLAN: + +Simple one-shot edits may execute directly. Complex edits must go through submit_plan first. +`; + export function buildSystemPrompt( context: AgentContext, tools?: ToolSummary[], + mode?: AgentMode, ): string { + const activeMode = mode ?? context.mode ?? "execute"; + const mediaSection = context.mediaAssets.length > 0 ? `Active media assets:\n${context.mediaAssets.map((m) => `- [id: ${m.id}] ${m.name} (${m.type}, ${m.duration}s)`).join("\n")}` @@ -45,9 +105,15 @@ export function buildSystemPrompt( "Only claim edits that were actually performed by tool calls in this conversation. Do not say you added, removed, cleaned, or updated text/subtitles unless the relevant add_text, update_text, delete_timeline_elements, or update_timeline_element_timing tool call succeeded.", "When the user asks to add titles, hooks, labels, captions, subtitles, or visible text, call add_text. Do not add text proactively for unrelated edit requests such as cutting silence unless the user asks for text.", "When you need to perform an action matching one of these tools, call the appropriate tool. For all other requests, respond directly in plain text.", - "SKILLS: You have editing skills available — pre-built recipe workflows for common editing patterns. When the user asks for a complex edit (viral video, pitch, specific style, etc.), call list_skills to discover relevant skills, then call load_skill with the matching skillId to get full step-by-step instructions. Follow those instructions precisely using the standard editing tools. If the user's request doesn't match any skill, proceed with your own editing approach.", + "SKILLS: You have editing skills available — pre-built technique libraries for common editing patterns. When the user asks for a complex edit (viral video, pitch, specific style, etc.), call list_skills to discover relevant skills, then call load_skill with the matching skillId to get technique definitions. Adapt those techniques to the actual footage content. If the user's request doesn't match any skill, proceed with your own editing approach.", ); } + if (activeMode === "plan") { + parts.push(PLAN_MODE_INSTRUCTIONS); + } else { + parts.push(EXECUTE_MODE_INSTRUCTIONS); + } + return parts.join("\n"); } diff --git a/apps/web/src/agent/tools/ask-user.tool.ts b/apps/web/src/agent/tools/ask-user.tool.ts new file mode 100644 index 00000000..2746b59b --- /dev/null +++ b/apps/web/src/agent/tools/ask-user.tool.ts @@ -0,0 +1,44 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { askUserSchema } from "@/agent/tools/schemas"; +import { usePlanStore } from "@/stores/plan-store"; +import { nanoid } from "nanoid"; + +const askUserTool: ToolDefinition = { + ...askUserSchema, + execute: async ( + args: Record, + _context: AgentContext, + ): Promise<{ questionId: string; answer: string } | { error: string }> => { + const { question, options } = args as { + question: string; + options?: Array<{ label: string; description?: string }>; + }; + + if (!question || typeof question !== "string") { + return { error: "question is required and must be a string" }; + } + + const questionId = nanoid(); + const planStore = usePlanStore.getState(); + + planStore.setPendingQuestion({ + id: questionId, + question, + options: options && Array.isArray(options) ? options : undefined, + }); + + return new Promise((resolve) => { + const unsubscribe = usePlanStore.subscribe((state) => { + if (state.pendingQuestion?.answer) { + unsubscribe(); + const answer = state.pendingQuestion.answer; + usePlanStore.getState().setPendingQuestion(null); + resolve({ questionId, answer }); + } + }); + }); + }, +}; + +toolRegistry.register(askUserSchema.name, askUserTool); diff --git a/apps/web/src/agent/tools/index.ts b/apps/web/src/agent/tools/index.ts index 3cd7819b..9b6b71dc 100644 --- a/apps/web/src/agent/tools/index.ts +++ b/apps/web/src/agent/tools/index.ts @@ -38,3 +38,7 @@ import "@/agent/tools/update-keyframe-curve.tool"; import "@/agent/tools/list-animatable-properties.tool"; import "@/agent/skills/list-skills.tool"; import "@/agent/skills/load-skill.tool"; +import "@/agent/tools/submit-plan.tool"; +import "@/agent/tools/ask-user.tool"; +import "@/agent/tools/request-plan-approval.tool"; +import "@/agent/tools/update-plan-step.tool"; diff --git a/apps/web/src/agent/tools/request-plan-approval.tool.ts b/apps/web/src/agent/tools/request-plan-approval.tool.ts new file mode 100644 index 00000000..e00b415c --- /dev/null +++ b/apps/web/src/agent/tools/request-plan-approval.tool.ts @@ -0,0 +1,46 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { requestPlanApprovalSchema } from "@/agent/tools/schemas"; +import { useAgentStore } from "@/stores/agent-store"; +import { usePlanStore } from "@/stores/plan-store"; +import type { AgentMode } from "@/agent/types"; + +const requestPlanApprovalTool: ToolDefinition = { + ...requestPlanApprovalSchema, + execute: async ( + _args: Record, + _context: AgentContext, + ): Promise<{ approved: boolean; mode: AgentMode } | { error: string }> => { + const planStore = usePlanStore.getState(); + if (!planStore.plan) { + return { + error: "No plan has been submitted yet. Call submit_plan first.", + }; + } + + useAgentStore.getState().setMode("plan"); + planStore.updatePlanStatus("awaiting_approval"); + + return new Promise((resolve) => { + useAgentStore.getState().setPendingModeTransition({ + targetMode: "execute", + resolve: (approved) => { + useAgentStore.getState().setPendingModeTransition(null); + if (approved) { + useAgentStore.getState().setMode("execute"); + usePlanStore.getState().updatePlanStatus("executing"); + } else { + useAgentStore.getState().setMode("plan"); + usePlanStore.getState().updatePlanStatus("awaiting_approval"); + } + resolve({ + approved, + mode: approved ? "execute" : "plan", + }); + }, + }); + }); + }, +}; + +toolRegistry.register(requestPlanApprovalSchema.name, requestPlanApprovalTool); diff --git a/apps/web/src/agent/tools/schemas.ts b/apps/web/src/agent/tools/schemas.ts index dbba40dd..1e819a9e 100644 --- a/apps/web/src/agent/tools/schemas.ts +++ b/apps/web/src/agent/tools/schemas.ts @@ -305,6 +305,45 @@ export const loadSkillSchema: ToolSchema = { parameters: [{ key: "skillId", type: "string", required: true }], }; +export const submitPlanSchema: ToolSchema = { + name: "submit_plan", + description: + "Submits a structured editing plan with steps and waits for user approval through a plan card with Go edit and Keep planning actions. Use this before complex edits, even from execute mode. Each step should describe a specific action with the tools to use. If approved=true is returned, continue executing the plan. If approved=false is returned, keep refining the plan with the user.", + parameters: [ + { key: "summary", type: "string", required: true }, + { key: "steps", type: "array", required: true }, + { key: "questions", type: "array", required: false }, + ], +}; + +export const askUserSchema: ToolSchema = { + name: "ask_user", + description: + "Asks the user a question before or during planning. Use when you need clarification about their intent, preferences, or content details before finalizing the plan. Optionally provide quick-reply options for common answers. The user can also type a free-form response.", + parameters: [ + { key: "question", type: "string", required: true }, + { key: "options", type: "array", required: false }, + ], +}; + +export const requestPlanApprovalSchema: ToolSchema = { + name: "request_plan_approval", + description: + "Requests user approval to switch from planning to editing for an already submitted plan. Usually you do not need this because submit_plan already waits for approval. The user sees a plan card with Keep planning and Go edit options.", + parameters: [], +}; + +export const updatePlanStepSchema: ToolSchema = { + name: "update_plan_step", + description: + "Updates the status of a plan step during execution. Call this after completing each step to track progress. The plan panel updates in real-time. Use status 'done' for successful completion, 'skipped' if the step was unnecessary, or 'in_progress' if starting.", + parameters: [ + { key: "stepId", type: "string", required: true }, + { key: "status", type: "string", required: true }, + { key: "result", type: "string", required: false }, + ], +}; + /** * The exact list of schemas exposed to the LLM. * Excludes internal-only tools (transcribe_video, mock). @@ -338,4 +377,8 @@ export const providerToolSchemas: ToolSchema[] = [ listAnimatablePropertiesSchema, listSkillsSchema, loadSkillSchema, + submitPlanSchema, + askUserSchema, + requestPlanApprovalSchema, + updatePlanStepSchema, ]; diff --git a/apps/web/src/agent/tools/submit-plan.tool.ts b/apps/web/src/agent/tools/submit-plan.tool.ts new file mode 100644 index 00000000..65b028fc --- /dev/null +++ b/apps/web/src/agent/tools/submit-plan.tool.ts @@ -0,0 +1,109 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { submitPlanSchema } from "@/agent/tools/schemas"; +import { usePlanStore, createPlanFromSteps } from "@/stores/plan-store"; +import { useAgentStore } from "@/stores/agent-store"; +import type { AgentMode } from "@/agent/types"; + +type SubmittedStep = { description: string; tools: string[] }; + +const submitPlanTool: ToolDefinition = { + ...submitPlanSchema, + execute: async ( + args: Record, + _context: AgentContext, + ): Promise< + | { + planId: string; + stepCount: number; + approved: boolean; + mode: AgentMode; + } + | { error: string } + > => { + const { summary, steps, questions } = args as { + summary: string; + steps: unknown; + questions?: string[]; + }; + + if (!summary || typeof summary !== "string") { + return { error: "summary is required and must be a string" }; + } + + const normalizedSteps = normalizeSteps(steps); + if (normalizedSteps.length === 0) { + return { + error: "steps must be a non-empty array or a numbered list string", + }; + } + + for (const [i, step] of normalizedSteps.entries()) { + if (!step.description || typeof step.description !== "string") { + return { error: `Step ${i + 1} must have a description string` }; + } + if (!Array.isArray(step.tools)) { + return { error: `Step ${i + 1} must have a tools array` }; + } + } + + const plan = createPlanFromSteps(summary, normalizedSteps, questions); + usePlanStore.getState().setPlan(plan); + useAgentStore.getState().setMode("plan"); + + return new Promise((resolve) => { + useAgentStore.getState().setPendingModeTransition({ + targetMode: "execute", + resolve: (approved) => { + useAgentStore.getState().setPendingModeTransition(null); + if (approved) { + useAgentStore.getState().setMode("execute"); + usePlanStore.getState().updatePlanStatus("executing"); + } else { + useAgentStore.getState().setMode("plan"); + usePlanStore.getState().updatePlanStatus("awaiting_approval"); + } + resolve({ + planId: plan.id, + stepCount: plan.steps.length, + approved, + mode: approved ? "execute" : "plan", + }); + }, + }); + }); + }, +}; + +toolRegistry.register(submitPlanSchema.name, submitPlanTool); + +function normalizeSteps(steps: unknown): SubmittedStep[] { + if (Array.isArray(steps)) { + return steps + .map((step) => { + if (typeof step === "string") { + return { description: step, tools: [] }; + } + if (typeof step !== "object" || step === null) return null; + const raw = step as { description?: unknown; tools?: unknown }; + return { + description: + typeof raw.description === "string" ? raw.description : "", + tools: Array.isArray(raw.tools) + ? raw.tools.filter( + (tool): tool is string => typeof tool === "string", + ) + : [], + }; + }) + .filter((step): step is SubmittedStep => step !== null); + } + + if (typeof steps !== "string") return []; + + return steps + .split("\n") + .map((line) => line.replace(/^\s*\d+[.)-]?\s*/, "").trim()) + .filter(Boolean) + .map((description) => ({ description, tools: [] })); +} diff --git a/apps/web/src/agent/tools/update-plan-step.tool.ts b/apps/web/src/agent/tools/update-plan-step.tool.ts new file mode 100644 index 00000000..beb837dc --- /dev/null +++ b/apps/web/src/agent/tools/update-plan-step.tool.ts @@ -0,0 +1,57 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { updatePlanStepSchema } from "@/agent/tools/schemas"; +import { usePlanStore } from "@/stores/plan-store"; +import type { PlanStepStatus } from "@/agent/types"; + +const VALID_STATUSES: PlanStepStatus[] = [ + "pending", + "in_progress", + "done", + "skipped", +]; + +const updatePlanStepTool: ToolDefinition = { + ...updatePlanStepSchema, + execute: async ( + args: Record, + _context: AgentContext, + ): + | { success: boolean; stepId: string; status: string } + | { error: string } => { + const { stepId, status, result } = args as { + stepId: string; + status: string; + result?: string; + }; + + if (!stepId || typeof stepId !== "string") { + return { error: "stepId is required" }; + } + + if (!VALID_STATUSES.includes(status as PlanStepStatus)) { + return { + error: `Invalid status: ${status}. Must be one of: ${VALID_STATUSES.join(", ")}`, + }; + } + + const planStore = usePlanStore.getState(); + const plan = planStore.plan; + if (!plan) { + return { error: "No active plan" }; + } + + const step = plan.steps.find((s) => s.id === stepId); + if (!step) { + return { + error: `Step not found: ${stepId}. Available: ${plan.steps.map((s) => s.id).join(", ")}`, + }; + } + + planStore.updateStepStatus(stepId, status as PlanStepStatus, result); + + return { success: true, stepId, status }; + }, +}; + +toolRegistry.register(updatePlanStepSchema.name, updatePlanStepTool); diff --git a/apps/web/src/agent/types.ts b/apps/web/src/agent/types.ts index 3c4fe233..35221793 100644 --- a/apps/web/src/agent/types.ts +++ b/apps/web/src/agent/types.ts @@ -5,12 +5,45 @@ export type ExecutionState = | "responding" | "error"; +export type AgentMode = "plan" | "execute"; + +export type PlanStepStatus = "pending" | "in_progress" | "done" | "skipped"; +export type PlanStatus = + | "drafting" + | "awaiting_approval" + | "approved" + | "executing" + | "completed"; + +export interface PlanStep { + id: string; + description: string; + tools: string[]; + status: PlanStepStatus; + result?: string; +} + +export interface Plan { + id: string; + summary: string; + steps: PlanStep[]; + questions?: string[]; + status: PlanStatus; + createdAt: number; +} + +export interface AgentQuestion { + id: string; + question: string; + options?: Array<{ label: string; description?: string }>; + answer?: string; +} + export interface ChatMessage { id: string; role: "system" | "user" | "assistant" | "tool_result"; content: string; toolCalls?: ToolCall[]; - /** Associates a tool_result message with the ToolCall it answers. */ toolCallId?: string; timestamp: number; } @@ -41,6 +74,7 @@ export interface AgentContext { }>; timelineTracks?: AgentTimelineTrack[]; playbackTimeMs: number; + mode?: AgentMode; } export type AgentTimelineTrack = { diff --git a/apps/web/src/app/api/agent/chat/route.ts b/apps/web/src/app/api/agent/chat/route.ts index 1e906922..5291fd55 100644 --- a/apps/web/src/app/api/agent/chat/route.ts +++ b/apps/web/src/app/api/agent/chat/route.ts @@ -63,6 +63,7 @@ const chatRequestSchema = z.object({ ) .optional(), playbackTimeMs: z.number(), + mode: z.enum(["plan", "execute"]).optional(), }) satisfies z.ZodType, }); @@ -89,7 +90,8 @@ export async function POST(request: NextRequest) { const { messages, context } = result.data; - // Resolve provider config from LLM_* env vars + const mode = context.mode; + const provider = process.env.LLM_PROVIDER; const apiKey = process.env.LLM_API_KEY; const model = process.env.LLM_MODEL; @@ -107,8 +109,7 @@ export async function POST(request: NextRequest) { const config: ProviderConfig = { provider, apiKey, model, baseUrl }; - // Build system prompt with tool guidance - const systemPrompt = buildSystemPrompt(context, providerToolSchemas); + const systemPrompt = buildSystemPrompt(context, providerToolSchemas, mode); // Delegate to provider adapter try { diff --git a/apps/web/src/components/editor/panels/chat/ask-user-prompt.tsx b/apps/web/src/components/editor/panels/chat/ask-user-prompt.tsx new file mode 100644 index 00000000..6fb7b0c8 --- /dev/null +++ b/apps/web/src/components/editor/panels/chat/ask-user-prompt.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { QuestionIcon } from "@hugeicons/core-free-icons"; +import { Button } from "@/components/ui/button"; +import { usePlanStore } from "@/stores/plan-store"; + +export function AskUserPrompt() { + const pendingQuestion = usePlanStore((s) => s.pendingQuestion); + const answerQuestion = usePlanStore((s) => s.answerQuestion); + const [freeForm, setFreeForm] = useState(""); + + const handleOptionClick = useCallback( + (label: string) => { + answerQuestion(label); + setFreeForm(""); + }, + [answerQuestion], + ); + + const handleFreeFormSubmit = useCallback(() => { + const trimmed = freeForm.trim(); + if (!trimmed) return; + answerQuestion(trimmed); + setFreeForm(""); + }, [freeForm, answerQuestion]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleFreeFormSubmit(); + } + }, + [handleFreeFormSubmit], + ); + + if (!pendingQuestion || pendingQuestion.answer) return null; + + return ( +
+
+ + + Agent question + +
+

{pendingQuestion.question}

+ + {pendingQuestion.options && pendingQuestion.options.length > 0 && ( +
+ {pendingQuestion.options.map((opt) => ( + + ))} +
+ )} + +
+ setFreeForm(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Type your answer..." + className="border-border bg-input flex-1 rounded-md border px-2 py-1 text-xs outline-none focus:border-blue-500/50" + /> + +
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/chat/chat-input.tsx b/apps/web/src/components/editor/panels/chat/chat-input.tsx index f7218e3b..91084b34 100644 --- a/apps/web/src/components/editor/panels/chat/chat-input.tsx +++ b/apps/web/src/components/editor/panels/chat/chat-input.tsx @@ -1,11 +1,13 @@ "use client"; -import { useState, useCallback, type KeyboardEvent } from "react"; +import { useState, useCallback, useRef, type KeyboardEvent } from "react"; import { HugeiconsIcon } from "@hugeicons/react"; import { Sent02Icon, FlashIcon, Shield01Icon, + CheckListIcon, + Edit02Icon, } from "@hugeicons/core-free-icons"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; @@ -19,9 +21,17 @@ interface ChatInputProps { export function ChatInput({ onSend, disabled }: ChatInputProps) { const [value, setValue] = useState(""); + const shiftHandledRef = useRef(false); const permissionMode = useAgentStore((s) => s.permissionMode); const setPermissionMode = useAgentStore((s) => s.setPermissionMode); const pendingApproval = useAgentStore((s) => s.pendingApproval); + const mode = useAgentStore((s) => s.mode); + const setMode = useAgentStore((s) => s.setMode); + const pendingTransition = useAgentStore((s) => s.pendingModeTransition); + const isPlanMode = mode === "plan"; + const toggleMode = useCallback(() => { + setMode(isPlanMode ? "execute" : "plan"); + }, [isPlanMode, setMode]); const handleSend = useCallback(() => { const trimmed = value.trim(); @@ -32,17 +42,30 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) { const handleKeyDown = useCallback( (e: KeyboardEvent) => { + if (e.key === "Shift") { + if (!shiftHandledRef.current) { + shiftHandledRef.current = true; + toggleMode(); + } + return; + } + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }, - [handleSend], + [handleSend, toggleMode], ); - const toggleMode = useCallback(() => { - const next: PermissionMode = - permissionMode === "skip" ? "ask" : "skip"; + const handleKeyUp = useCallback((e: KeyboardEvent) => { + if (e.key === "Shift") { + shiftHandledRef.current = false; + } + }, []); + + const togglePermission = useCallback(() => { + const next: PermissionMode = permissionMode === "skip" ? "ask" : "skip"; setPermissionMode(next); }, [permissionMode, setPermissionMode]); @@ -50,21 +73,47 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) { return (
+
+ + + Press Shift to switch + +