feat: add plan mode (#156)

* feat: add plan mode with interactive questions UI

Add generate_plan tool, structured plan types, and an OpenCode-style
inline questions panel with tabbed navigation, number-key shortcuts,
custom text answers, and a review/confirm tab.

* fix: plan-to-agent handover, mode-scoped tools, and UI polish

- Carry plan context forward when switching plan→agent mode
- Restrict generate_plan tool to plan mode only
- Stamp modeColor on all message types for correct per-message colors
- Sanitize model-echoed role labels and raw JSON from content
- Truncate tool result labels to single lines
- Single-word tab labels for plan questions
- Always show edit/write file results with diff
- Clear agent history on mode switch
This commit is contained in:
Ismail Pelaseyed authored and GitHub committed 2026-03-19 10:20:50 +01:00
1 parent a07224a7f0
commit ee1ef73e93
7 files changed
+775 -52

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
def hello_world():
print("Hello, World!")
if __name__ == "__main__":
hello_world()
+29 -14
View File
@@ -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) };
+64 -2
View File
@@ -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 },
};
},
}),
};
}
+30
View File
@@ -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 }[] = [
+230 -36
View File
@@ -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<Plan | null>(null);
const [pqs, setPqs] = useState<PlanQuestionsState>(initialPlanQuestionsState());
const imageCounterRef = useRef(0);
const pasteCounterRef = useRef(0);
const inputRef = useRef<TextareaRenderable>(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 && (
<ShimmerText t={t} text="Planning next moves" />
)}
{/* Plan questions panel — inline, OpenCode-style */}
{showPlanPanel && (
<PlanQuestionsPanel
t={t}
questions={planQuestions}
state={pqs}
/>
)}
</scrollbox>
{/* Prompt */}
<box flexShrink={0}>
<PromptBox t={t} inputRef={inputRef} isProcessing={isProcessing} showModelPicker={showModelPicker} showSlashMenu={showSlashMenu}
<PromptBox t={t} inputRef={inputRef} isProcessing={isProcessing} showModelPicker={showModelPicker} showSlashMenu={showSlashMenu} showPlanQuestions={showPlanPanel}
onSubmit={handleSubmit} onPaste={handlePaste} pasteBlocks={pasteBlocks}
modeInfo={modeInfo} model={model} modelInfo={modelInfo} />
</box>
@@ -399,7 +583,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) {
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} flexShrink={0}>
<PromptBox t={t} inputRef={inputRef} isProcessing={isProcessing} showModelPicker={showModelPicker} showSlashMenu={showSlashMenu}
<PromptBox t={t} inputRef={inputRef} isProcessing={isProcessing} showModelPicker={showModelPicker} showSlashMenu={showSlashMenu} showPlanQuestions={showPlanPanel}
onSubmit={handleSubmit} onPaste={handlePaste} pasteBlocks={pasteBlocks}
modeInfo={modeInfo} model={model} modelInfo={modelInfo}
placeholder={"What are we building?"} />
@@ -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<TextareaRenderable | null>;
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<typeof getModelInfo>; placeholder?: string;
@@ -460,7 +644,7 @@ function PromptBox({ t, inputRef, isProcessing, showModelPicker, showSlashMenu,
<box>
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} backgroundColor={t.backgroundElement} flexShrink={0}>
<textarea
ref={inputRef} focused={!isProcessing && !showModelPicker && !showSlashMenu}
ref={inputRef} focused={!isProcessing && !showModelPicker && !showSlashMenu && !showPlanQuestions}
placeholder={isProcessing ? "Working... (esc to stop)" : (placeholder || "Message Grok...")}
textColor={t.text} backgroundColor={t.backgroundElement} placeholderColor={t.textMuted}
minHeight={1} maxHeight={10} wrapMode="word"
@@ -497,7 +681,7 @@ function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index:
case "user":
return (
<box
border={["left"]} customBorderChars={SPLIT} borderColor={modeColor}
border={["left"]} customBorderChars={SPLIT} borderColor={entry.modeColor || modeColor}
marginTop={index === 0 ? 0 : 1} marginBottom={1}
>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={t.backgroundPanel} flexShrink={0}>
@@ -517,7 +701,7 @@ function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index:
return (
<box paddingLeft={3} marginTop={1}>
<text>
<span style={{ fg: modeColor }}>{"▣ "}</span>
<span style={{ fg: entry.modeColor || modeColor }}>{"▣ "}</span>
<span style={{ fg: t.textMuted }}>{entry.content.replace("▣ ", "")}</span>
</text>
</box>
@@ -527,23 +711,27 @@ function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index:
const name = entry.toolCall?.function.name || "tool";
const args = toolArgs(entry.toolCall);
const diff = entry.toolResult?.diff;
const plan = entry.toolResult?.plan;
if ((name === "write_file" || name === "edit_file") && diff) {
const label = name === "write_file"
? `Write ${diff.filePath}`
: `Edit ${diff.filePath}`;
if (name === "generate_plan" && plan) {
return <PlanView plan={plan} t={t} />;
}
if (name === "write_file" || name === "edit_file") {
const filePath = diff?.filePath || tryParseArg(entry.toolCall, "path") || args;
const label = name === "write_file" ? `Write ${filePath}` : `Edit ${filePath}`;
return (
<box gap={0}>
<InlineTool t={t} pending={false}>{label}</InlineTool>
<DiffView t={t} diff={diff} />
{diff && <DiffView t={t} diff={diff} />}
</box>
);
}
if (name === "read_file") return <InlineTool t={t} pending={false}>{`Read ${tryParseArg(entry.toolCall, "path") || args}`}</InlineTool>;
if (name === "search_web" || name === "search_x") return <InlineTool t={t} pending={false}>{name === "search_web" ? "Web" : "X"}{` Search "${args}"`}</InlineTool>;
if (name === "read_file") return <InlineTool t={t} pending={false}>{`Read ${trunc(tryParseArg(entry.toolCall, "path") || args, 60)}`}</InlineTool>;
if (name === "search_web" || name === "search_x") return <InlineTool t={t} pending={false}>{name === "search_web" ? "Web" : "X"}{` Search "${trunc(args, 60)}"`}</InlineTool>;
return <InlineTool t={t} pending={false}>{name === "bash" ? args : `${name} ${args}`}</InlineTool>;
return <InlineTool t={t} pending={false}>{trunc(name === "bash" ? args : `${name} ${args}`, 80)}</InlineTool>;
}
default:
@@ -821,13 +1009,19 @@ function tryParseArg(tc: ToolCall | undefined, key: string): string {
}
function toolLabel(tc: ToolCall): string {
const args = toolArgs(tc);
if (tc.function.name === "bash") return args || "Running command...";
if (tc.function.name === "read_file") return `Read ${args}`;
if (tc.function.name === "write_file") return `Write ${args}`;
if (tc.function.name === "edit_file") return `Edit ${args}`;
if (tc.function.name === "search_web") return `Web Search "${args}"`;
if (tc.function.name === "search_x") return `X Search "${args}"`;
return `${tc.function.name} ${args}`;
if (tc.function.name === "bash") return trunc(args || "Running command...", 80);
if (tc.function.name === "read_file") return `Read ${trunc(args, 60)}`;
if (tc.function.name === "write_file") return `Write ${trunc(args, 60)}`;
if (tc.function.name === "edit_file") return `Edit ${trunc(args, 60)}`;
if (tc.function.name === "search_web") return `Web Search "${trunc(args, 60)}"`;
if (tc.function.name === "search_x") return `X Search "${trunc(args, 60)}"`;
if (tc.function.name === "generate_plan") return "Generating plan...";
return trunc(`${tc.function.name} ${args}`, 80);
}
function sanitizeContent(raw: string): string {
let s = raw.replace(/^[\s\n]*assistant:\s*/gi, "");
s = s.replace(/\{"success"\s*:\s*(true|false)\s*,\s*"output"\s*:\s*"[\s\S]*$/m, "");
return s.trim();
}
function trunc(s: string, n: number): string { return s.length <= n ? s : s.slice(0, n) + "…"; }
function formatDuration(ms: number): string {
+404
View File
@@ -0,0 +1,404 @@
import React from "react";
import type { Plan, PlanQuestion } from "../types/index.js";
import type { Theme } from "./theme.js";
export type PlanAnswers = Record<string, string | string[]>;
/* ── Plan Steps (inline in chat) ───────────────────────────── */
interface PlanViewProps {
plan: Plan;
t: Theme;
}
export function PlanView({ plan, t }: PlanViewProps) {
return (
<box paddingLeft={3} marginTop={1} flexShrink={0} flexDirection="column">
<box marginBottom={1}>
<text>
<span style={{ fg: t.planTitle }}>
<b>{"◆ "}{plan.title}</b>
</span>
</text>
</box>
<box paddingLeft={2} marginBottom={1}>
<text fg={t.textMuted}>{plan.summary}</text>
</box>
{plan.steps.map((step, i) => (
<box key={i} paddingLeft={2} marginBottom={0} flexDirection="column">
<text>
<span style={{ fg: t.planStepNum }}>{`${i + 1}. `}</span>
<span style={{ fg: t.planStepTitle }}>
<b>{step.title}</b>
</span>
</text>
<box paddingLeft={3}>
<text fg={t.planStepDesc}>{step.description}</text>
</box>
{step.filePaths && step.filePaths.length > 0 && (
<box paddingLeft={3}>
<text>
{step.filePaths.map((fp, j) => (
<span key={j} style={{ fg: t.planStepFile }}>
{j > 0 ? ", " : ""}
{fp}
</span>
))}
</text>
</box>
)}
</box>
))}
</box>
);
}
/* ── Plan Questions Panel (OpenCode-style tabbed inline) ──── */
const SPLIT = {
topLeft: "",
bottomLeft: "",
vertical: "┃",
topRight: "",
bottomRight: "",
horizontal: " ",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
};
export interface PlanQuestionsState {
tab: number;
selected: number;
answers: PlanAnswers;
customInputs: Record<string, string>;
editing: boolean;
}
export function initialPlanQuestionsState(): PlanQuestionsState {
return {
tab: 0,
selected: 0,
answers: {},
customInputs: {},
editing: false,
};
}
interface PlanQuestionsPanelProps {
t: Theme;
questions: PlanQuestion[];
state: PlanQuestionsState;
}
export function PlanQuestionsPanel({
t,
questions,
state,
}: PlanQuestionsPanelProps) {
const isSingle = questions.length === 1 && questions[0]?.type !== "multiselect";
const isConfirmTab = !isSingle && state.tab === questions.length;
const q = questions[state.tab];
return (
<box
flexDirection="column"
border={["left"]}
customBorderChars={SPLIT}
borderColor={t.planBorder}
marginTop={1}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={t.backgroundPanel}
>
{/* Tabs */}
{!isSingle && (
<box flexDirection="row" gap={2} marginBottom={1} flexShrink={0}>
{questions.map((q, i) => {
const isActive = i === state.tab;
const isAnswered = hasAnswer(state.answers, q);
const label = tabLabel(q);
return (
<text key={q.id}>
<span
style={{
fg: isActive
? t.planTitle
: isAnswered
? t.planOptionCheck
: t.textMuted,
}}
>
{isActive ? <b>{label}</b> : label}
{isAnswered && !isActive ? " ✓" : ""}
</span>
</text>
);
})}
<text>
<span
style={{
fg: isConfirmTab ? t.planTitle : t.textMuted,
}}
>
{isConfirmTab ? <b>{"Confirm"}</b> : "Confirm"}
</span>
</text>
</box>
)}
{/* Question body */}
{isConfirmTab ? (
<ConfirmView t={t} questions={questions} answers={state.answers} />
) : q ? (
<QuestionBody
t={t}
question={q}
state={state}
/>
) : null}
{/* Footer hints */}
<box flexDirection="row" gap={3} marginTop={1} flexShrink={0}>
{!isSingle && (
<text>
<span style={{ fg: t.text }}>{"⇆"}</span>
<span style={{ fg: t.planHint }}>{" tab"}</span>
</text>
)}
<text>
<span style={{ fg: t.text }}>{"↑↓"}</span>
<span style={{ fg: t.planHint }}>{" select"}</span>
</text>
<text>
<span style={{ fg: t.text }}>{"enter"}</span>
<span style={{ fg: t.planHint }}>
{isConfirmTab
? " submit"
: q?.type === "multiselect"
? " toggle"
: isSingle
? " submit"
: " confirm"}
</span>
</text>
<text>
<span style={{ fg: t.text }}>{"esc"}</span>
<span style={{ fg: t.planHint }}>{" dismiss"}</span>
</text>
</box>
</box>
);
}
/* ── Question Body ────────────────────────────────────────── */
function QuestionBody({
t,
question: q,
state,
}: {
t: Theme;
question: PlanQuestion;
state: PlanQuestionsState;
}) {
const isMulti = q.type === "multiselect";
const options = q.options ?? [];
const showCustom = q.type !== "text";
const totalItems = options.length + (showCustom ? 1 : 0);
const isOnCustom = showCustom && state.selected === options.length;
const customText = state.customInputs[q.id] ?? "";
return (
<box flexDirection="column">
{/* Question text */}
<box marginBottom={1}>
<text fg={t.planQuestionText}>
<b>{q.question}</b>
{isMulti ? <span style={{ fg: t.textMuted }}>{" (select all that apply)"}</span> : null}
</text>
</box>
{q.type === "text" ? (
/* Free-form text input */
<box backgroundColor={t.planInputBg} paddingLeft={1} paddingRight={1}>
<text fg={t.planInputText}>
{state.editing || customText
? customText + (state.editing ? "▌" : "")
: <span style={{ fg: t.textMuted }}>{"Type your answer..."}</span>}
</text>
</box>
) : (
/* Options list */
<box flexDirection="column">
{options.map((opt, i) => {
const isFocused = i === state.selected;
const isPicked = isOptionPicked(state.answers, q, opt.id);
return (
<box
key={opt.id}
backgroundColor={isFocused ? t.selectedBg : undefined}
paddingLeft={1}
flexDirection="row"
>
<text>
<span style={{ fg: t.textMuted }}>
{`${i + 1}. `}
</span>
<span
style={{
fg: isFocused
? t.selected
: isPicked
? t.planOptionCheck
: t.text,
}}
>
{isMulti
? `[${isPicked ? "✓" : " "}] ${opt.label}`
: opt.label}
</span>
{isPicked && !isMulti ? (
<span style={{ fg: t.planOptionCheck }}>{" ✓"}</span>
) : null}
</text>
</box>
);
})}
{/* "Type your own answer" option */}
{showCustom && (
<box
backgroundColor={isOnCustom ? t.selectedBg : undefined}
paddingLeft={1}
>
{state.editing && isOnCustom ? (
<box backgroundColor={t.planInputBg} paddingLeft={1} paddingRight={1} flexGrow={1}>
<text fg={t.planInputText}>
{customText + "▌"}
</text>
</box>
) : (
<text>
<span style={{ fg: t.textMuted }}>
{`${totalItems}. `}
</span>
<span
style={{
fg: isOnCustom ? t.planOptionSelected : t.textMuted,
}}
>
{isMulti
? `[${customText && isOptionPicked(state.answers, q, customText) ? "✓" : " "}] Type your own answer`
: "Type your own answer"}
</span>
{customText ? (
<span style={{ fg: t.textDim }}>{` (${customText})`}</span>
) : null}
</text>
)}
</box>
)}
</box>
)}
</box>
);
}
/* ── Confirm/Review Tab ───────────────────────────────────── */
function ConfirmView({
t,
questions,
answers,
}: {
t: Theme;
questions: PlanQuestion[];
answers: PlanAnswers;
}) {
return (
<box flexDirection="column">
<box marginBottom={1}>
<text fg={t.planQuestionText}>
<b>{"Review"}</b>
</text>
</box>
{questions.map((q) => {
const val = formatAnswer(q, answers);
const answered = val !== "(not answered)";
return (
<box key={q.id} paddingLeft={1}>
<text>
<span style={{ fg: t.text }}>
<b>{tabLabel(q)}:</b>
</span>
<span style={{ fg: answered ? t.planOptionCheck : t.textMuted }}>
{" "}{val}
</span>
</text>
</box>
);
})}
</box>
);
}
/* ── Helpers ───────────────────────────────────────────────── */
function tabLabel(q: PlanQuestion): string {
if (q.header) return q.header;
const words = q.question.replace(/[?.,!:]+$/, "").split(/\s+/);
const key = words.find((w) => w.length > 2 && !/^(what|how|should|which|does|the|and|for|are|can|will|you|this|that|with|from)$/i.test(w));
return key ?? words[0] ?? "Question";
}
function hasAnswer(answers: PlanAnswers, q: PlanQuestion): boolean {
const a = answers[q.id];
if (!a) return false;
if (Array.isArray(a)) return a.length > 0;
return a.trim().length > 0;
}
function isOptionPicked(
answers: PlanAnswers,
q: PlanQuestion,
optionId: string,
): boolean {
const a = answers[q.id];
if (!a) return false;
if (Array.isArray(a)) return a.includes(optionId);
return a === optionId;
}
function formatAnswer(q: PlanQuestion, answers: PlanAnswers): string {
const a = answers[q.id];
if (!a) return "(not answered)";
if (q.type === "text") return (a as string) || "(not answered)";
if (q.type === "select") {
const opt = q.options?.find((o) => o.id === a);
return (opt?.label ?? (a as string)) || "(not answered)";
}
const arr = a as string[];
if (arr.length === 0) return "(not answered)";
return arr
.map((id) => q.options?.find((o) => o.id === id)?.label ?? id)
.join(", ");
}
export function formatPlanAnswers(
questions: PlanQuestion[],
answers: PlanAnswers,
): string {
const parts: string[] = ["Here are my answers to the plan questions:\n"];
for (const q of questions) {
const val = formatAnswer(q, answers);
parts.push(`- ${q.question}: ${val}`);
}
return parts.join("\n");
}
+13
View File
@@ -34,6 +34,19 @@ export const dark = {
mdLinkText: "#d4a0d4",
mdHr: "#333333",
mdListBullet: "#666666",
planBorder: "#e5c07b",
planTitle: "#e5c07b",
planStepNum: "#e5c07b",
planStepTitle: "#e0e0e0",
planStepDesc: "#999999",
planStepFile: "#5c9cf5",
planQuestionText: "#e0e0e0",
planOptionDefault: "#888888",
planOptionSelected: "#e5c07b",
planOptionCheck: "#22c55e",
planInputBg: "#1a1a1a",
planInputText: "#e0e0e0",
planHint: "#555555",
} as const;
export type Theme = typeof dark;