mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge pull request #10162 from Kilo-Org/glaze-nickel
fix(vscode): render plan exit as ready link
This commit is contained in:
@@ -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
@@ -1586,4 +1586,5 @@ export const dict = {
|
||||
"diffViewer.baseBranch.empty": "لا توجد فروع مطابقة",
|
||||
"diffViewer.baseBranch.loading": "جارٍ تحميل الفروع…",
|
||||
"diffViewer.baseBranch.none": "—",
|
||||
"plan.exit.ready": "الخطة جاهزة:",
|
||||
}
|
||||
|
||||
+1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1608,4 +1608,5 @@ export const dict = {
|
||||
"diffViewer.baseBranch.empty": "一致するブランチがありません",
|
||||
"diffViewer.baseBranch.loading": "ブランチを読み込み中…",
|
||||
"diffViewer.baseBranch.none": "—",
|
||||
"plan.exit.ready": "プランの準備ができました:",
|
||||
}
|
||||
|
||||
+1
@@ -1593,4 +1593,5 @@ export const dict = {
|
||||
"diffViewer.baseBranch.empty": "일치하는 브랜치 없음",
|
||||
"diffViewer.baseBranch.loading": "브랜치 로딩 중…",
|
||||
"diffViewer.baseBranch.none": "—",
|
||||
"plan.exit.ready": "계획이 준비되었습니다:",
|
||||
}
|
||||
|
||||
+1
@@ -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
@@ -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
@@ -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
@@ -1619,4 +1619,5 @@ export const dict = {
|
||||
"diffViewer.baseBranch.empty": "Нет подходящих веток",
|
||||
"diffViewer.baseBranch.loading": "Загрузка веток…",
|
||||
"diffViewer.baseBranch.none": "—",
|
||||
"plan.exit.ready": "План готов:",
|
||||
}
|
||||
|
||||
+1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user