Merge pull request #10162 from Kilo-Org/glaze-nickel

fix(vscode): render plan exit as ready link
This commit is contained in:
Kirill Kalishev
2026-05-15 08:48:45 -04:00
committed by GitHub
24 changed files with 292 additions and 16 deletions
@@ -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()")
// <Part may be followed by newline or space
const partIdx = src.search(/<Part[\s\n]/)
expect(planExitIdx).toBeGreaterThan(0)
expect(partIdx).toBeGreaterThan(0)
expect(planExitIdx).toBeLessThan(partIdx)
})
})
@@ -21,7 +21,10 @@ import { useData } from "@kilocode/kilo-ui/context/data"
import { useSession } from "../../context/session"
import { useDisplay } from "../../context/display"
import { useConfig } from "../../context/config"
import { useLanguage } from "../../context/language"
import { useServer } from "../../context/server"
import { snapshotProgress } from "../../context/session-utils"
import { planDisplayPath } from "../../utils/plan-path"
import { QuestionDock } from "./QuestionDock"
import { SuggestBar } from "./SuggestBar"
@@ -30,6 +33,50 @@ import { SuggestBar } from "./SuggestBar"
// so the user can see what the AI set up.
export const UPSTREAM_SUPPRESSED_TOOLS = new Set(["todowrite", "todoread"])
/** Extract plan path from a completed plan_exit tool part. */
function planExitInfo(part: SDKPart): { plan: string } | undefined {
if (part.type !== "tool") return undefined
const tp = part as unknown as ToolPart
if (tp.tool !== "plan_exit") return undefined
if (tp.state?.status !== "completed") return undefined
const meta = (tp.state as { metadata?: Record<string, unknown> }).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 (
<Show when={info()}>
<div data-component="plan-exit-card">
<span data-slot="plan-exit-label">{label()}</span>{" "}
<a data-slot="plan-exit-link" href="#" onClick={open}>
{display()}
</a>
</div>
</Show>
)
}
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<AssistantMessageProps> = (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 (
<Show
when={isUpstreamSuppressed || activeQuestion() || activeSuggestion() || bash() || PART_MAPPING[part.type]}
when={
isUpstreamSuppressed ||
activeQuestion() ||
activeSuggestion() ||
bash() ||
planExit() ||
PART_MAPPING[part.type]
}
>
<div data-component="tool-part-wrapper" data-part-type={part.type}>
<Show
@@ -169,30 +230,37 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
when={activeSuggestion()}
fallback={
<Show
when={bash()}
when={planExit()}
fallback={
<Show
when={isUpstreamSuppressed}
when={bash()}
fallback={
<Part
part={part}
message={props.message as SDKMessage}
showAssistantCopyPartID={props.showAssistantCopyPartID}
reasoningAutoCollapse={display.reasoningAutoCollapse()}
feedback={props.feedback}
animate={
part.type === "tool" &&
((part as unknown as ToolPart).state?.status === "pending" ||
(part as unknown as ToolPart).state?.status === "running")
<Show
when={isUpstreamSuppressed}
fallback={
<Part
part={part}
message={props.message as SDKMessage}
showAssistantCopyPartID={props.showAssistantCopyPartID}
reasoningAutoCollapse={display.reasoningAutoCollapse()}
feedback={props.feedback}
animate={
part.type === "tool" &&
((part as unknown as ToolPart).state?.status === "pending" ||
(part as unknown as ToolPart).state?.status === "running")
}
/>
}
/>
>
<TodoToolCard part={part as unknown as ToolPart} />
</Show>
}
>
<TodoToolCard part={part as unknown as ToolPart} />
{(tool) => <BashToolCard part={tool() as unknown as ToolPart} defaultOpen={open()} />}
</Show>
}
>
{(tool) => <BashToolCard part={tool() as unknown as ToolPart} defaultOpen={open()} />}
{(tp) => <PlanExitCard part={tp()} />}
</Show>
}
>
+1
View File
@@ -1586,4 +1586,5 @@ export const dict = {
"diffViewer.baseBranch.empty": "لا توجد فروع مطابقة",
"diffViewer.baseBranch.loading": "جارٍ تحميل الفروع…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "الخطة جاهزة:",
}
+1
View File
@@ -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:",
}
+1
View File
@@ -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:",
}
+1
View File
@@ -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:",
}
+1
View File
@@ -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<Record<Keys, string>>
@@ -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:",
}
+1
View File
@@ -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:",
}
+1
View File
@@ -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 :",
}
+1
View File
@@ -1608,4 +1608,5 @@ export const dict = {
"diffViewer.baseBranch.empty": "一致するブランチがありません",
"diffViewer.baseBranch.loading": "ブランチを読み込み中…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "プランの準備ができました:",
}
+1
View File
@@ -1593,4 +1593,5 @@ export const dict = {
"diffViewer.baseBranch.empty": "일치하는 브랜치 없음",
"diffViewer.baseBranch.loading": "브랜치 로딩 중…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "계획이 준비되었습니다:",
}
+1
View File
@@ -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:",
}
+1
View File
@@ -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<Record<Keys, string>>
+1
View File
@@ -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:",
}
+1
View File
@@ -1619,4 +1619,5 @@ export const dict = {
"diffViewer.baseBranch.empty": "Нет подходящих веток",
"diffViewer.baseBranch.loading": "Загрузка веток…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "План готов:",
}
+1
View File
@@ -1592,4 +1592,5 @@ export const dict = {
"diffViewer.baseBranch.empty": "No matching branches",
"diffViewer.baseBranch.loading": "Loading branches…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "แผนพร้อมแล้ว:",
}
+1
View File
@@ -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:",
}
+1
View File
@@ -1635,4 +1635,5 @@ export const dict = {
"diffViewer.baseBranch.empty": "No matching branches",
"diffViewer.baseBranch.loading": "Loading branches…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "План готовий:",
}
+1
View File
@@ -1549,4 +1549,5 @@ export const dict = {
"diffViewer.baseBranch.empty": "没有匹配的分支",
"diffViewer.baseBranch.loading": "正在加载分支…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "计划已准备就绪:",
} satisfies Partial<Record<Keys, string>>
+1
View File
@@ -1556,4 +1556,5 @@ export const dict = {
"diffViewer.baseBranch.empty": "沒有相符的分支",
"diffViewer.baseBranch.loading": "正在載入分支…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "計畫已準備就緒:",
} satisfies Partial<Record<Keys, string>>
@@ -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";
@@ -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;
}
}
}
@@ -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
}