Skip to content

Commit 0e7adf9

Browse files
committed
refactor(ui): split App.tsx lifecycle into hooks and helpers
Audit #5 — App.tsx had grown to 586 lines mixing bootstrap, state wiring, plan-mode orchestration, streaming event coalescing, ghost-text suggestions, and shell-escape. Lift the four lifecycle units into their own modules so ChatApp becomes wiring + render tree and each unit is independently testable. App.tsx drops to 328 lines.
1 parent f59ba2b commit 0e7adf9

7 files changed

Lines changed: 312 additions & 273 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebase-cli",
3-
"version": "2.0.0-pre.34",
3+
"version": "2.0.0-pre.35",
44
"description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.",
55
"keywords": [
66
"ai",

src/plan/run-flow.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { AgentBundle } from "../agent/agent.js";
2+
import { UserQueryCancelled } from "../user-queries/store.js";
3+
import { buildAgentPrompt, generatePlan, generateQuestion, MAX_QUESTIONS, parseAnswer, revisePlan } from "./flow.js";
4+
import { ANSWER_START_BUILDING, type QAPair } from "./types.js";
5+
6+
export interface PlanFlowHandlers {
7+
/** Render a synthetic assistant message (plan body, cancel notice). */
8+
onReply: (text: string) => void;
9+
/** Surface a plan-flow failure to the user. */
10+
onError: (message: string) => void;
11+
}
12+
13+
/**
14+
* Plan-mode flow:
15+
* 1. Q&A loop (up to MAX_QUESTIONS, with the start-building escape).
16+
* 2. Generate plan, render as a synthetic assistant message so the
17+
* user can read it in chat.
18+
* 3. Approve / Revise / Cancel via the UserQuery primitive.
19+
* 4. On approve, hand the original prompt + plan + Q&A to the agent
20+
* with the canonical buildAgentPrompt wrapper so weaker models
21+
* stick to the plan instead of re-planning mid-execution.
22+
*/
23+
export async function runPlanFlow(
24+
bundle: AgentBundle,
25+
originalPrompt: string,
26+
handlers: PlanFlowHandlers,
27+
): Promise<void> {
28+
const { onReply, onError } = handlers;
29+
const qaHistory: QAPair[] = [];
30+
try {
31+
for (let i = 0; i < MAX_QUESTIONS; i++) {
32+
const result = await generateQuestion(bundle.glue, originalPrompt, qaHistory, i);
33+
if (result.done || !result.question) break;
34+
const q = result.question;
35+
const optionLabels = q.options?.map((o) => o.label);
36+
const answer = await bundle.userQueries.ask({
37+
question: q.question,
38+
options: optionLabels,
39+
placeholder: optionLabels ? `1-${optionLabels.length}, or type a free-form answer` : undefined,
40+
});
41+
const resolved = parseAnswer(answer, q);
42+
if (resolved === ANSWER_START_BUILDING) break;
43+
qaHistory.push({ question: q.question, answer: resolved });
44+
}
45+
46+
let plan = await generatePlan(bundle.glue, originalPrompt, qaHistory);
47+
48+
while (true) {
49+
onReply(plan);
50+
const decision = await bundle.userQueries.ask({
51+
question: "Approve this plan and run it?",
52+
options: ["Yes — run it", "Revise", "Cancel"],
53+
});
54+
const choice = matchOption(decision, ["Yes — run it", "Revise", "Cancel"]);
55+
if (choice === "Yes — run it") {
56+
const finalPrompt = buildAgentPrompt(originalPrompt, plan, qaHistory);
57+
bundle.agent.prompt(finalPrompt).catch((err: unknown) => {
58+
onError(err instanceof Error ? err.message : String(err));
59+
});
60+
return;
61+
}
62+
if (choice === "Cancel") {
63+
onReply("(plan cancelled)");
64+
return;
65+
}
66+
const feedback = await bundle.userQueries.ask({
67+
question: "What should change about the plan?",
68+
placeholder: "describe the revision",
69+
});
70+
plan = await revisePlan(bundle.glue, plan, feedback);
71+
}
72+
} catch (err) {
73+
if (err instanceof UserQueryCancelled) {
74+
onReply("(plan cancelled)");
75+
return;
76+
}
77+
onError(`plan flow failed: ${err instanceof Error ? err.message : String(err)}`);
78+
}
79+
}
80+
81+
/**
82+
* Resolve a user's typed answer to one of the supplied options.
83+
* Accepts the option label (case-insensitive), a 1-based index,
84+
* or the leading word of the label. Falls back to the raw input
85+
* if nothing matches — caller decides what to do with that.
86+
*/
87+
function matchOption(answer: string, options: string[]): string {
88+
const trimmed = answer.trim();
89+
const idx = Number.parseInt(trimmed, 10);
90+
if (Number.isFinite(idx) && idx >= 1 && idx <= options.length) {
91+
return options[idx - 1];
92+
}
93+
const lower = trimmed.toLowerCase();
94+
for (const option of options) {
95+
if (option.toLowerCase() === lower) return option;
96+
if (option.toLowerCase().startsWith(lower) && lower.length >= 3) return option;
97+
}
98+
return trimmed;
99+
}

0 commit comments

Comments
 (0)