diff --git a/packages/kilo-vscode/tests/unit/plan-exit.test.ts b/packages/kilo-vscode/tests/unit/plan-exit.test.ts new file mode 100644 index 00000000000..11042c38fa6 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/plan-exit.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for plan_exit webview helpers: + * - planDisplayPath: relative/absolute path display logic + * - plan_exit renderer uses openFile, not openDiff + */ + +import { describe, expect, it } from "bun:test" +import { planDisplayPath } from "../../webview-ui/src/utils/plan-path" +import fs from "node:fs" +import path from "node:path" + +describe("planDisplayPath", () => { + it("returns a relative path unchanged", () => { + expect(planDisplayPath(".kilo/plans/my-plan.md", "/repo")).toBe(".kilo/plans/my-plan.md") + }) + + it("returns absolute path inside repo as repo-relative", () => { + expect(planDisplayPath("/repo/.kilo/plans/my-plan.md", "/repo")).toBe(".kilo/plans/my-plan.md") + }) + + it("returns absolute path inside repo with trailing slash on root as repo-relative", () => { + expect(planDisplayPath("/repo/.kilo/plans/my-plan.md", "/repo/")).toBe(".kilo/plans/my-plan.md") + }) + + it("returns absolute path outside repo unchanged", () => { + expect(planDisplayPath("/other/path/plan.md", "/repo")).toBe("/other/path/plan.md") + }) + + it("handles Windows absolute paths inside root", () => { + expect(planDisplayPath("C:\\repo\\.kilo\\plans\\plan.md", "C:\\repo")).toBe(".kilo\\plans\\plan.md") + }) + + it("handles Windows absolute paths outside root", () => { + expect(planDisplayPath("D:\\other\\plan.md", "C:\\repo")).toBe("D:\\other\\plan.md") + }) + + it("returns empty string unchanged", () => { + expect(planDisplayPath("", "/repo")).toBe("") + }) + + it("path equal to root returns original", () => { + // Edge: plan path IS the root directory itself — fall back to original + expect(planDisplayPath("/repo", "/repo")).toBe("/repo") + }) +}) + +describe("plan_exit renderer uses openFile not openDiff (source)", () => { + const ROOT = path.resolve(import.meta.dir, "../..") + const FILE = path.join(ROOT, "webview-ui/src/components/chat/AssistantMessage.tsx") + const TURN_FILE = path.join(ROOT, "webview-ui/src/components/chat/VscodeSessionTurn.tsx") + const src = fs.readFileSync(FILE, "utf-8") + const turnSrc = fs.readFileSync(TURN_FILE, "utf-8") + + it("PlanExitCard calls data.openFile", () => { + expect(src).toContain("data.openFile") + }) + + it("uses a safe inert anchor href", () => { + expect(src).toContain('href="#"') + expect(src).not.toContain("href={display()}") + }) + + it("always uses the generic ready label", () => { + expect(src).toContain('language.t("plan.exit.ready")') + expect(src).not.toContain('language.t("plan.exit.readyUpdated")') + expect(src).not.toContain('language.t("plan.exit.readyNew")') + }) + + it("does not infer status from tool history", () => { + expect(src).not.toContain("function inferPlanStatus") + expect(src).not.toContain("function patchUpdatedPlan") + expect(src).not.toContain("function toolTouchesPlan") + expect(src).not.toContain("toolDeletions") + expect(src).not.toContain("readyUpdated") + expect(src).not.toContain("readyNew") + expect(src).not.toContain("Object.values(data.store.part ?? {}).flat()") + expect(src).not.toContain("[...props.parts, ...all()]") + expect(src).not.toContain("turnParts") + expect(turnSrc).not.toContain("assistantMessages().flatMap") + expect(turnSrc).not.toContain("turnParts={assistantParts()}") + }) + + it("does not depend on opencode-provided plan status metadata", () => { + expect(src).not.toContain('meta.status === "updated" || meta.status === "new"') + expect(src).not.toContain("meta.status") + }) + + it("PlanExitCard does not call openDiffVirtual", () => { + // Extract just the PlanExitCard function body to scope the assertion + const start = src.indexOf("function PlanExitCard") + const end = src.indexOf("\nfunction ", start + 1) + const block = end === -1 ? src.slice(start) : src.slice(start, end) + expect(block).not.toContain("openDiffVirtual") + expect(block).not.toContain("openDiff") + }) + + it("plan_exit tool is handled before generic Part renderer", () => { + const planExitIdx = src.indexOf("planExit()") + // }).metadata ?? {} + const plan = typeof meta.plan === "string" ? meta.plan : undefined + if (!plan) return undefined + return { plan } +} + +function PlanExitCard(props: { part: ToolPart }) { + const language = useLanguage() + const server = useServer() + const data = useData() + const info = createMemo(() => planExitInfo(props.part as unknown as SDKPart)) + const display = createMemo(() => { + const i = info() + if (!i) return "" + return planDisplayPath(i.plan, server.workspaceDirectory()) + }) + const label = createMemo(() => { + if (!info()) return "" + return language.t("plan.exit.ready") + }) + const open = (e: MouseEvent) => { + e.preventDefault() + const i = info() + if (!i || !data.openFile) return + data.openFile(i.plan) + } + return ( + +
+ {label()}{" "} + + {display()} + +
+
+ ) +} + function isRenderable(part: SDKPart): boolean { if (part.type === "tool") { const tool = (part as SDKPart & { tool: string }).tool @@ -156,10 +203,24 @@ export const AssistantMessage: Component = (props) => { if (tool.state?.status === "error") return return part }) + const planExit = createMemo(() => { + if (part.type !== "tool") return + const tp = part as unknown as ToolPart + if (tp.tool !== "plan_exit") return + if (tp.state?.status !== "completed") return + return tp + }) return (
= (props) => { when={activeSuggestion()} fallback={ } - /> + > + + } > - + {(tool) => } } > - {(tool) => } + {(tp) => } } > diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 571708e79f1..8702427303b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1586,4 +1586,5 @@ export const dict = { "diffViewer.baseBranch.empty": "لا توجد فروع مطابقة", "diffViewer.baseBranch.loading": "جارٍ تحميل الفروع…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "الخطة جاهزة:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 1e33887c878..2bca3fe4609 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1629,4 +1629,5 @@ export const dict = { "diffViewer.baseBranch.empty": "Nenhum branch correspondente", "diffViewer.baseBranch.loading": "Carregando branches…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "Plano pronto:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index febe05e50f3..71749e4a187 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1625,4 +1625,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "Plan je spreman:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 87e2b3141a8..8270b442cc0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1614,4 +1614,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "Planen er klar:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 27def89040d..d30614336e2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1645,4 +1645,5 @@ export const dict = { "diffViewer.baseBranch.empty": "Keine passenden Branches", "diffViewer.baseBranch.loading": "Branches werden geladen…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "Plan ist bereit:", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 6bd70f904f0..784b17fe3ca 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1614,4 +1614,6 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + + "plan.exit.ready": "Plan is ready:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 49cc989e3eb..91313af992a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1635,4 +1635,5 @@ export const dict = { "diffViewer.baseBranch.empty": "Sin ramas que coincidan", "diffViewer.baseBranch.loading": "Cargando ramas…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "El plan está listo:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index dd29a79eca7..6de0bac6c66 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1654,4 +1654,5 @@ export const dict = { "diffViewer.baseBranch.empty": "Aucune branche correspondante", "diffViewer.baseBranch.loading": "Chargement des branches…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "Le plan est prêt :", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 7d8445b2b9f..94cfe25a70e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1608,4 +1608,5 @@ export const dict = { "diffViewer.baseBranch.empty": "一致するブランチがありません", "diffViewer.baseBranch.loading": "ブランチを読み込み中…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "プランの準備ができました:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index cde1a8223f6..565416de95e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1593,4 +1593,5 @@ export const dict = { "diffViewer.baseBranch.empty": "일치하는 브랜치 없음", "diffViewer.baseBranch.loading": "브랜치 로딩 중…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "계획이 준비되었습니다:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index e8d26eed581..ae02bc59c97 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1646,4 +1646,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "Plan is klaar:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index a8a82f409a6..d2382cb5169 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1608,4 +1608,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "Planen er klar:", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index e0118d14302..bfa4457e4c0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1619,4 +1619,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "Plan jest gotowy:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 23de8a5e735..54c6c54c231 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1619,4 +1619,5 @@ export const dict = { "diffViewer.baseBranch.empty": "Нет подходящих веток", "diffViewer.baseBranch.loading": "Загрузка веток…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "План готов:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 665c2fd4659..5e02ca3f5af 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1592,4 +1592,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "แผนพร้อมแล้ว:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 37916494765..33dac0a65a1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1635,4 +1635,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "Plan hazır:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index dbcb80c0424..bbc17472597 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1635,4 +1635,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "План готовий:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 1b22b3b5967..a68d0f2143f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1549,4 +1549,5 @@ export const dict = { "diffViewer.baseBranch.empty": "没有匹配的分支", "diffViewer.baseBranch.loading": "正在加载分支…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "计划已准备就绪:", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index d567947ade9..d37e75f54a1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1556,4 +1556,5 @@ export const dict = { "diffViewer.baseBranch.empty": "沒有相符的分支", "diffViewer.baseBranch.loading": "正在載入分支…", "diffViewer.baseBranch.none": "—", + "plan.exit.ready": "計畫已準備就緒:", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat.css b/packages/kilo-vscode/webview-ui/src/styles/chat.css index a40375411cc..9eda4835ed9 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat.css @@ -19,6 +19,7 @@ @import "./notifications.css"; @import "./tool-overrides.css"; @import "./question-dock.css"; +@import "./plan-exit.css"; @import "./suggest-bar.css"; @import "./settings.css"; @import "./high-contrast.css"; diff --git a/packages/kilo-vscode/webview-ui/src/styles/plan-exit.css b/packages/kilo-vscode/webview-ui/src/styles/plan-exit.css new file mode 100644 index 00000000000..c9378481d5f --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/styles/plan-exit.css @@ -0,0 +1,39 @@ +/* ============================================ + Plan Exit Card + ============================================ */ + +[data-component="tool-part-wrapper"]:has([data-component="plan-exit-card"]) { + border: none !important; + border-radius: 0; + overflow: visible; +} + +[data-component="plan-exit-card"] { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 4px; + padding: 6px 0; + font-family: var(--font-family-sans); + font-size: var(--kilo-font-size-13); + line-height: var(--line-height-large); + + [data-slot="plan-exit-label"] { + color: var(--text-base); + font-weight: var(--font-weight-medium); + } + + [data-slot="plan-exit-link"] { + cursor: pointer; + color: var(--text-interactive-base); + text-decoration: none; + text-underline-offset: 2px; + font-family: var(--font-family-mono); + font-size: var(--kilo-font-size-12); + word-break: break-all; + + &:hover { + text-decoration: underline; + } + } +} diff --git a/packages/kilo-vscode/webview-ui/src/utils/plan-path.ts b/packages/kilo-vscode/webview-ui/src/utils/plan-path.ts new file mode 100644 index 00000000000..99569f52512 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/utils/plan-path.ts @@ -0,0 +1,43 @@ +/** + * Compute the display path for a plan file link. + * + * - Relative paths are returned as-is. + * - Absolute paths inside `root` are returned as a repo-relative path. + * - Absolute paths outside `root` are returned as-is (absolute). + * + * `root` is the workspace/worktree directory string from the server context. + * Both Unix and Windows separators are normalised before comparison. + */ +export function planDisplayPath(plan: string, root: string): string { + if (!plan) return plan + + // Already relative — nothing to do. + if (!isAbsolutePlan(plan)) return plan + + const normalRoot = normaliseDir(root) + const normalPlan = normalisePath(plan) + + if (!normalRoot || !normalPlan.startsWith(normalRoot)) return plan + + // Strip the root prefix (and a trailing separator if present). + const rel = plan.slice(normalRoot.length).replace(/^[\\/]/, "") + return rel || plan +} + +function normalisePath(p: string): string { + return p.replace(/\\/g, "/") +} + +function normaliseDir(dir: string): string { + if (!dir) return "" + const n = dir.replace(/\\/g, "/") + return n.endsWith("/") ? n : n + "/" +} + +function isAbsolutePlan(p: string): boolean { + // Unix absolute + if (p.startsWith("/")) return true + // Windows absolute: C:\ or C:/ + if (/^[A-Za-z]:[\\/]/.test(p)) return true + return false +}