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:
1 parent
a07224a7f0
commit
ee1ef73e93
7 files changed
+775
-52
No files matched your search
@@ -0,0 +1,5 @@
|
||||
def hello_world():
|
||||
print("Hello, World!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
hello_world()
|
||||
+29
-14
@@ -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
@@ -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 },
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user