diff --git a/hello.py b/hello.py new file mode 100644 index 0000000..2f39e4f --- /dev/null +++ b/hello.py @@ -0,0 +1,5 @@ +def hello_world(): + print("Hello, World!") + +if __name__ == "__main__": + hello_world() diff --git a/src/agent/agent.ts b/src/agent/agent.ts index ac52fa4..07ba6b1 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -3,7 +3,7 @@ import { createProvider, generateTitle as genTitle, type XaiProvider } from "../ import { createTools } from "../grok/tools.js"; import { BashTool } from "../tools/bash.js"; import { loadCustomInstructions } from "../utils/instructions.js"; -import type { AgentMode, StreamChunk, ToolCall, ToolResult } from "../types/index.js"; +import type { AgentMode, Plan, StreamChunk, ToolCall, ToolResult } from "../types/index.js"; const MAX_TOOL_ROUNDS = 400; @@ -51,15 +51,16 @@ ${ENVIRONMENT} TOOLS: - read_file: Read file contents for analysis. - bash: ONLY for searching (find, grep, ls) — NEVER modify files. +- generate_plan: ALWAYS use this to present your plan. Creates an interactive UI with steps and questions. BEHAVIOR: -- Create a detailed, step-by-step implementation plan -- Identify files that need changes and describe the specific edits -- Highlight potential risks, edge cases, and dependencies -- Suggest a testing strategy -- NEVER create, modify, or delete files — only read and analyze - -Format your plan with clear numbered steps and file paths.`, +- Explore the codebase first using read_file and bash to understand the current state +- ALWAYS call generate_plan to present your plan — never just describe it in text +- Include clear, ordered steps with affected file paths +- Include questions when you need user input on approach, trade-offs, or preferences +- Use "select" questions for single-choice decisions, "multiselect" for picking multiple options, and "text" for free-form input +- Highlight potential risks, edge cases, and dependencies in the plan summary +- NEVER create, modify, or delete files — only read and analyze`, ask: `You are Grok CLI in Ask mode — you answer questions clearly and thoroughly. @@ -77,13 +78,17 @@ BEHAVIOR: - Focus on explanation, not execution`, }; -function buildSystemPrompt(cwd: string, mode: AgentMode): string { +function buildSystemPrompt(cwd: string, mode: AgentMode, planContext?: string | null): string { const custom = loadCustomInstructions(); const customSection = custom ? `\n\nCUSTOM INSTRUCTIONS:\n${custom}\n\nFollow the above alongside standard instructions.\n` : ""; - return `${MODE_PROMPTS[mode]}${customSection} + const planSection = planContext + ? `\n\nAPPROVED PLAN:\nThe following plan has been approved by the user. Execute it now.\n${planContext}\n` + : ""; + + return `${MODE_PROMPTS[mode]}${customSection}${planSection} Current working directory: ${cwd}`; } @@ -97,6 +102,7 @@ export class Agent { private mode: AgentMode = "agent"; private modelId: string; private maxTokens: number; + private planContext: string | null = null; constructor(apiKey: string, baseURL?: string, model?: string, maxToolRounds?: number) { this.provider = createProvider(apiKey, baseURL); @@ -120,7 +126,14 @@ export class Agent { } setMode(mode: AgentMode): void { - this.mode = mode; + if (mode !== this.mode) { + this.mode = mode; + this.messages = []; + } + } + + setPlanContext(ctx: string | null): void { + this.planContext = ctx; } getCwd(): string { @@ -148,8 +161,9 @@ export class Agent { let streamOk = false; try { - const tools = createTools(this.bash, this.provider); - const system = buildSystemPrompt(this.bash.getCwd(), this.mode); + const tools = createTools(this.bash, this.provider, this.mode); + const system = buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext); + this.planContext = null; const result = streamText({ model: this.provider(this.modelId), @@ -245,12 +259,13 @@ function toToolCall(part: { toolCallId: string; toolName: string; args?: unknown function toToolResult(output: unknown): ToolResult { if (output && typeof output === "object" && "success" in output) { - const r = output as { success: boolean; output?: string; diff?: ToolResult["diff"] }; + const r = output as { success: boolean; output?: string; diff?: ToolResult["diff"]; plan?: Plan }; return { success: r.success, output: r.output, error: r.success ? undefined : r.output, diff: r.diff, + plan: r.plan, }; } return { success: true, output: String(output) }; diff --git a/src/grok/tools.ts b/src/grok/tools.ts index f4a0afd..9faa194 100644 --- a/src/grok/tools.ts +++ b/src/grok/tools.ts @@ -3,13 +3,14 @@ import { z } from "zod"; import type { BashTool } from "../tools/bash.js"; import { readFile, writeFile, editFile } from "../tools/file.js"; import type { XaiProvider } from "./client.js"; +import type { AgentMode } from "../types/index.js"; const SEARCH_MODEL = "grok-3-mini-fast"; -export function createTools(bash: BashTool, provider: XaiProvider) { +export function createTools(bash: BashTool, provider: XaiProvider, mode: AgentMode = "agent") { const cwd = () => bash.getCwd(); - return { + const base = { bash: tool({ description: "Execute a bash command. Use for searching (grep, rg, find), git, build tools, package managers, running tests, and any other shell command. For file read/write/edit, prefer the dedicated file tools instead.", @@ -136,5 +137,66 @@ export function createTools(bash: BashTool, provider: XaiProvider) { } }, }), + + }; + + if (mode !== "plan") return base; + + return { + ...base, + generate_plan: tool({ + description: + "Generate an interactive implementation plan with steps and optional questions for the user. The plan is displayed in a structured UI where the user can review steps and answer questions. Always use this tool when creating plans.", + inputSchema: z.object({ + title: z.string().describe("Plan title"), + summary: z.string().describe("Brief summary of what the plan accomplishes"), + steps: z + .array( + z.object({ + title: z.string().describe("Step title"), + description: z + .string() + .describe("Detailed description of what this step involves"), + filePaths: z + .array(z.string()) + .optional() + .describe("Files affected by this step"), + }), + ) + .describe("Ordered list of implementation steps"), + questions: z + .array( + z.object({ + id: z.string().describe("Unique question identifier"), + question: z.string().describe("The question to ask the user"), + header: z + .string() + .optional() + .describe("Single-word tab label (e.g. 'Format', 'Storage', 'Testing')"), + type: z + .enum(["select", "multiselect", "text"]) + .describe("Question type: select (pick one), multiselect (pick many), or text (free-form)"), + options: z + .array( + z.object({ + id: z.string().describe("Option identifier"), + label: z.string().describe("Option display text"), + }), + ) + .optional() + .describe("Options for select/multiselect questions"), + }), + ) + .optional() + .describe("Questions for the user to answer before proceeding"), + }), + execute: async ({ title, summary, steps, questions }) => { + return { + success: true, + output: `Plan "${title}" generated with ${steps.length} steps`, + plan: { title, summary, steps, questions }, + }; + }, + }), }; } diff --git a/src/types/index.ts b/src/types/index.ts index e9f95b5..a5d7bfa 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,11 +6,33 @@ export interface FileDiff { isNew: boolean; } +export interface PlanStep { + title: string; + description: string; + filePaths?: string[]; +} + +export interface PlanQuestion { + id: string; + question: string; + header?: string; + type: "select" | "multiselect" | "text"; + options?: { id: string; label: string }[]; +} + +export interface Plan { + title: string; + summary: string; + steps: PlanStep[]; + questions?: PlanQuestion[]; +} + export interface ToolResult { success: boolean; output?: string; error?: string; diff?: FileDiff; + plan?: Plan; } export interface ToolCall { @@ -26,6 +48,7 @@ export interface ChatEntry { type: "user" | "assistant" | "tool_call" | "tool_result"; content: string; timestamp: Date; + modeColor?: string; toolCalls?: ToolCall[]; toolCall?: ToolCall; toolResult?: ToolResult; @@ -49,6 +72,13 @@ export interface ModelInfo { description: string; } +export interface Skill { + id: string; + title: string; + description: string; + executable?: boolean; +} + export type AgentMode = "agent" | "plan" | "ask"; export const MODES: { id: AgentMode; label: string; color: string }[] = [ diff --git a/src/ui/app.tsx b/src/ui/app.tsx index 8f5e8cc..34d3d04 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -4,12 +4,13 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react"; import type { ScrollBoxRenderable, TextareaRenderable, KeyBinding } from "@opentui/core"; import { decodePasteBytes, PasteEvent } from "@opentui/core"; import type { Agent } from "../agent/agent.js"; -import type { ChatEntry, ToolCall, AgentMode, ModelInfo, FileDiff } from "../types/index.js"; +import type { ChatEntry, ToolCall, AgentMode, ModelInfo, FileDiff, Plan, PlanQuestion } from "../types/index.js"; import { MODES } from "../types/index.js"; import { getModelInfo, MODELS } from "../grok/models.js"; import { saveProjectSettings } from "../utils/settings.js"; import { dark, type Theme } from "./theme.js"; import { Markdown } from "./markdown.js"; +import { PlanView, PlanQuestionsPanel, formatPlanAnswers, initialPlanQuestionsState, type PlanAnswers, type PlanQuestionsState } from "./plan.js"; const STAR_PALETTE = ["#777777", "#666666", "#4a4a4a", "#333333", "#222222"]; @@ -124,6 +125,8 @@ export function App({ agent, initialMessage, onExit }: AppProps) { const [slashMenuIndex, setSlashMenuIndex] = useState(0); const [slashSearchQuery, setSlashSearchQuery] = useState(""); const [pasteBlocks, setPasteBlocks] = useState<{ id: number; content: string; lines: number; isImage?: boolean }[]>([]); + const [activePlan, setActivePlan] = useState(null); + const [pqs, setPqs] = useState(initialPlanQuestionsState()); const imageCounterRef = useRef(0); const pasteCounterRef = useRef(0); const inputRef = useRef(null); @@ -134,7 +137,16 @@ export function App({ agent, initialMessage, onExit }: AppProps) { const startTimeRef = useRef(0); const isProcessingRef = useRef(false); - const setMode = useCallback((m: AgentMode) => { agent.setMode(m); setModeState(m); }, [agent]); + const setMode = useCallback((m: AgentMode) => { + if (m === "agent" && mode === "plan" && activePlan) { + const planText = [`# ${activePlan.title}`, activePlan.summary, "", + ...activePlan.steps.map((s, i) => `${i + 1}. ${s.title}: ${s.description}${s.filePaths?.length ? ` (${s.filePaths.join(", ")})` : ""}`), + ].join("\n"); + agent.setPlanContext(planText); + } + agent.setMode(m); + setModeState(m); + }, [agent, mode, activePlan]); const cycleMode = useCallback(() => { const idx = MODES.findIndex((m) => m.id === mode); setMode(MODES[(idx + 1) % MODES.length].id); @@ -167,14 +179,14 @@ export function App({ agent, initialMessage, onExit }: AppProps) { setIsProcessing(true); setStreamContent(""); setStreamReasoning(""); setActiveToolCalls([]); contentAccRef.current = ""; startTimeRef.current = Date.now(); if (!sessionTitle) agent.generateTitle(text.trim()).then(setSessionTitle).catch(() => {}); - setMessages((prev) => [...prev, { type: "user", content: text.trim(), timestamp: new Date() }]); + setMessages((prev) => [...prev, { type: "user", content: text.trim(), timestamp: new Date(), modeColor: modeInfo.color }]); setTimeout(scrollToBottom, 50); try { for await (const chunk of agent.processMessage(text.trim())) { switch (chunk.type) { case "content": contentAccRef.current += chunk.content || ""; - setStreamContent(contentAccRef.current); + setStreamContent(sanitizeContent(contentAccRef.current)); setTimeout(scrollToBottom, 10); break; case "reasoning": @@ -182,11 +194,11 @@ export function App({ agent, initialMessage, onExit }: AppProps) { break; case "tool_calls": if (chunk.toolCalls) { - if (contentAccRef.current.trim()) { - const s = contentAccRef.current; - setMessages((p) => [...p, { type: "assistant", content: s, timestamp: new Date() }]); - contentAccRef.current = ""; setStreamContent(""); + const cleaned = sanitizeContent(contentAccRef.current); + if (cleaned) { + setMessages((p) => [...p, { type: "assistant", content: cleaned, timestamp: new Date(), modeColor: modeInfo.color }]); } + contentAccRef.current = ""; setStreamContent(""); setActiveToolCalls(chunk.toolCalls); } break; @@ -195,8 +207,12 @@ export function App({ agent, initialMessage, onExit }: AppProps) { setMessages((p) => [...p, { type: "tool_result", content: chunk.toolResult!.success ? (chunk.toolResult!.output || "Success") : (chunk.toolResult!.error || "Error"), - timestamp: new Date(), toolCall: chunk.toolCall, toolResult: chunk.toolResult, + timestamp: new Date(), modeColor: modeInfo.color, toolCall: chunk.toolCall, toolResult: chunk.toolResult, }]); + if (chunk.toolResult.plan?.questions?.length) { + setActivePlan(chunk.toolResult.plan); + setPqs(initialPlanQuestionsState()); + } setActiveToolCalls([]); setTimeout(scrollToBottom, 10); } break; @@ -208,9 +224,9 @@ export function App({ agent, initialMessage, onExit }: AppProps) { } } } catch { contentAccRef.current += "\nAn unexpected error occurred."; setStreamContent(contentAccRef.current); } - if (contentAccRef.current.trim()) { - const f = contentAccRef.current; - setMessages((p) => [...p, { type: "assistant", content: f, timestamp: new Date() }]); + const finalContent = sanitizeContent(contentAccRef.current); + if (finalContent) { + setMessages((p) => [...p, { type: "assistant", content: finalContent, timestamp: new Date(), modeColor: modeInfo.color }]); } contentAccRef.current = ""; setStreamContent(""); setStreamReasoning(""); setActiveToolCalls([]); @@ -222,7 +238,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) { const handleCommand = useCallback((cmd: string): boolean => { const c = cmd.trim().toLowerCase(); - if (c === "/clear") { agent.clearHistory(); setMessages([]); setStreamContent(""); setSessionTitle(null); setPasteBlocks([]); imageCounterRef.current = 0; return true; } + if (c === "/clear") { agent.clearHistory(); setMessages([]); setStreamContent(""); setSessionTitle(null); setPasteBlocks([]); setActivePlan(null); setPqs(initialPlanQuestionsState()); imageCounterRef.current = 0; return true; } if (c === "/model" || c === "/models") { setShowModelPicker(true); setModelPickerIndex(0); setModelSearchQuery(""); return true; } if (c === "/quit" || c === "/exit" || c === "/q") { onExit ? onExit() : process.exit(0); } return false; @@ -233,7 +249,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) { inputRef.current?.clear(); switch (item.id) { case "new": - agent.clearHistory(); setMessages([]); setStreamContent(""); setSessionTitle(null); setPasteBlocks([]); + agent.clearHistory(); setMessages([]); setStreamContent(""); setSessionTitle(null); setPasteBlocks([]); setActivePlan(null); setPqs(initialPlanQuestionsState()); break; case "models": setShowModelPicker(true); setModelPickerIndex(0); setModelSearchQuery(""); @@ -256,7 +272,167 @@ export function App({ agent, initialMessage, onExit }: AppProps) { } }, [agent, flatModels, model]); + const showPlanPanel = !!(activePlan?.questions?.length); + const planQuestions = activePlan?.questions ?? []; + const isSinglePlan = planQuestions.length === 1 && planQuestions[0]?.type !== "multiselect"; + const planTabCount = isSinglePlan ? 1 : planQuestions.length + 1; + const isPlanConfirmTab = !isSinglePlan && pqs.tab === planQuestions.length; + + const dismissPlan = useCallback(() => { + setActivePlan(null); + setPqs(initialPlanQuestionsState()); + }, []); + + const submitPlanAnswers = useCallback(() => { + if (!activePlan?.questions?.length) return; + const text = formatPlanAnswers(activePlan.questions, pqs.answers); + setActivePlan(null); + setPqs(initialPlanQuestionsState()); + processMessage(text); + }, [activePlan, pqs.answers, processMessage]); + + const handlePlanSelect = useCallback((q: PlanQuestion, idx: number, options: { id: string; label: string }[], showCustom: boolean) => { + const isCustom = showCustom && idx === options.length; + if (isCustom) { + if (q.type === "multiselect") { + const customVal = pqs.customInputs[q.id] ?? ""; + if (customVal) { + const existing = (pqs.answers[q.id] as string[] | undefined) ?? []; + if (existing.includes(customVal)) { + setPqs((s) => ({ ...s, answers: { ...s.answers, [q.id]: existing.filter((x) => x !== customVal) } })); + } else { + setPqs((s) => ({ ...s, editing: true })); + } + } else { + setPqs((s) => ({ ...s, editing: true })); + } + } else { + setPqs((s) => ({ ...s, editing: true })); + } + return; + } + const opt = options[idx]; + if (!opt) return; + + if (q.type === "multiselect") { + setPqs((s) => { + const existing = (s.answers[q.id] as string[] | undefined) ?? []; + const next = existing.includes(opt.id) ? existing.filter((x) => x !== opt.id) : [...existing, opt.id]; + return { ...s, answers: { ...s.answers, [q.id]: next } }; + }); + } else { + setPqs((s) => ({ ...s, answers: { ...s.answers, [q.id]: opt.id } })); + if (isSinglePlan) { submitPlanAnswers(); return; } + setPqs((s) => ({ ...s, tab: s.tab + 1, selected: 0 })); + } + }, [pqs, isSinglePlan, submitPlanAnswers]); + useKeyboard((key) => { + if (showPlanPanel) { + const q = planQuestions[pqs.tab]; + + // Escape always dismisses + if (key.name === "escape") { dismissPlan(); return; } + + // When editing custom text input + if (pqs.editing && !isPlanConfirmTab) { + if (key.name === "return") { + const qId = q?.id; + if (qId) { + const text = (pqs.customInputs[qId] ?? "").trim(); + if (text) { + if (q.type === "multiselect") { + const existing = (pqs.answers[qId] as string[] | undefined) ?? []; + const next = existing.includes(text) ? existing : [...existing, text]; + setPqs((s) => ({ ...s, editing: false, answers: { ...s.answers, [qId]: next } })); + } else if (q.type === "text") { + setPqs((s) => ({ ...s, editing: false, answers: { ...s.answers, [qId]: text } })); + if (isSinglePlan) { submitPlanAnswers(); return; } + setPqs((s) => ({ ...s, tab: s.tab + 1, selected: 0 })); + } else { + setPqs((s) => ({ ...s, editing: false, answers: { ...s.answers, [qId]: text } })); + if (isSinglePlan) { submitPlanAnswers(); return; } + setPqs((s) => ({ ...s, tab: s.tab + 1, selected: 0 })); + } + } else { + setPqs((s) => ({ ...s, editing: false })); + } + } + return; + } + if (key.name === "backspace") { + const qId = q?.id; + if (qId) setPqs((s) => ({ ...s, customInputs: { ...s.customInputs, [qId]: (s.customInputs[qId] ?? "").slice(0, -1) } })); + return; + } + if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) { + const qId = q?.id; + if (qId) setPqs((s) => ({ ...s, customInputs: { ...s.customInputs, [qId]: (s.customInputs[qId] ?? "") + key.sequence } })); + return; + } + return; + } + + // Tab / left / right — switch between question tabs + if (key.name === "tab") { + const dir = key.shift ? -1 : 1; + setPqs((s) => ({ ...s, tab: (s.tab + dir + planTabCount) % planTabCount, selected: 0 })); + return; + } + if (key.name === "left" || key.name === "h") { + setPqs((s) => ({ ...s, tab: (s.tab - 1 + planTabCount) % planTabCount, selected: 0 })); + return; + } + if (key.name === "right" || key.name === "l") { + setPqs((s) => ({ ...s, tab: (s.tab + 1) % planTabCount, selected: 0 })); + return; + } + + // Confirm tab + if (isPlanConfirmTab) { + if (key.name === "return") { submitPlanAnswers(); return; } + return; + } + + if (!q) return; + + // Text-only question (no options) + if (q.type === "text") { + setPqs((s) => ({ ...s, editing: true })); + return; + } + + // Up/down — navigate options + const options = q.options ?? []; + const showCustom = true; + const totalItems = options.length + 1; + + if (key.name === "up" || key.name === "k") { + setPqs((s) => ({ ...s, selected: (s.selected - 1 + totalItems) % totalItems })); + return; + } + if (key.name === "down" || key.name === "j") { + setPqs((s) => ({ ...s, selected: (s.selected + 1) % totalItems })); + return; + } + + // Number keys 1-9 for quick selection + const digit = Number(key.name); + if (!Number.isNaN(digit) && digit >= 1 && digit <= Math.min(totalItems, 9)) { + const idx = digit - 1; + setPqs((s) => ({ ...s, selected: idx })); + handlePlanSelect(q, idx, options, showCustom); + return; + } + + // Enter — select current option + if (key.name === "return") { + handlePlanSelect(q, pqs.selected, options, showCustom); + return; + } + + return; + } if (showSlashMenu) { if (key.name === "escape") { setShowSlashMenu(false); setSlashSearchQuery(""); inputRef.current?.clear(); return; } if (key.name === "up") { setSlashMenuIndex((i) => Math.max(0, i - 1)); return; } @@ -381,10 +557,18 @@ export function App({ agent, initialMessage, onExit }: AppProps) { {isProcessing && !streamContent && activeToolCalls.length === 0 && ( )} + {/* Plan questions panel — inline, OpenCode-style */} + {showPlanPanel && ( + + )} {/* Prompt */} - @@ -399,7 +583,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) { - @@ -448,9 +632,9 @@ const TEXTAREA_KEYBINDINGS: KeyBinding[] = [ { name: "return", shift: true, action: "newline" }, ]; -function PromptBox({ t, inputRef, isProcessing, showModelPicker, showSlashMenu, onSubmit, onPaste, pasteBlocks, modeInfo, model, modelInfo, placeholder }: { +function PromptBox({ t, inputRef, isProcessing, showModelPicker, showSlashMenu, showPlanQuestions, onSubmit, onPaste, pasteBlocks, modeInfo, model, modelInfo, placeholder }: { t: Theme; inputRef: React.RefObject; - isProcessing: boolean; showModelPicker: boolean; showSlashMenu: boolean; onSubmit: () => void; + isProcessing: boolean; showModelPicker: boolean; showSlashMenu: boolean; showPlanQuestions: boolean; onSubmit: () => void; onPaste: (event: PasteEvent) => void; pasteBlocks: { id: number; content: string; lines: number }[]; modeInfo: typeof MODES[number]; model: string; modelInfo: ReturnType; placeholder?: string; @@ -460,7 +644,7 @@ function PromptBox({ t, inputRef, isProcessing, showModelPicker, showSlashMenu,