From 79fe75745fc6abd7bd3aad0c079e8f5150751a2c Mon Sep 17 00:00:00 2001 From: Marius Date: Mon, 20 Jul 2026 09:51:05 +0200 Subject: [PATCH] fix(vscode): render heredoc approvals as plain text (#12304) * fix(vscode): render heredoc approvals as plain text * test: handle heredoc approvals across platforms * chore: update kilo-vscode visual regression baselines --------- Co-authored-by: kilo-maintainer[bot] --- .changeset/calm-heredocs-explain.md | 6 +++ ...permission-dock-heredoc-chromium-linux.png | 4 +- .../src/components/chat/PermissionCommand.tsx | 5 ++- .../src/components/chat/PermissionDock.tsx | 4 +- .../src/stories/composite.stories.tsx | 1 + .../src/types/messages/permissions.ts | 1 + .../src/kilocode/tool/shell-heredoc.ts | 7 ++++ packages/opencode/src/tool/shell.ts | 8 +++- .../kilocode/bash-permission-metadata.test.ts | 38 +++++++++++++++++++ 9 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 .changeset/calm-heredocs-explain.md create mode 100644 packages/opencode/src/kilocode/tool/shell-heredoc.ts diff --git a/.changeset/calm-heredocs-explain.md b/.changeset/calm-heredocs-explain.md new file mode 100644 index 0000000000..179aa51222 --- /dev/null +++ b/.changeset/calm-heredocs-explain.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Display here-document content as plain text in terminal approval prompts. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png index 6a4cdb2424..ce8160161d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:31a447c3602b2959fde3da003baa05901d4d68e935f245c63d7080a574ef7b53 -size 22366 +oid sha256:e7905a190dff49bc7e23af1773ea93ac79cc71659336469745561355cba5f413 +size 22602 diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionCommand.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionCommand.tsx index 81f094bba4..d0984b22c8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionCommand.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionCommand.tsx @@ -10,7 +10,7 @@ import { Icon } from "@kilocode/kilo-ui/icon" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useLanguage } from "../../context/language" -export const PermissionCommand: Component<{ command: string }> = (props) => { +export const PermissionCommand: Component<{ command: string; plain?: boolean }> = (props) => { const language = useLanguage() const [copied, setCopied] = createSignal(false) const state = { signal: { aborted: false } } @@ -23,10 +23,11 @@ export const PermissionCommand: Component<{ command: string }> = (props) => { const pre = document.createElement("pre") const code = document.createElement("code") - code.dataset.lang = "shellscript" + if (!props.plain) code.dataset.lang = "shellscript" code.textContent = command pre.append(code) ref.replaceChildren(pre) + if (props.plain) return const signal = { aborted: false } state.signal = signal diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index 0a5d95c5ae..609db2ccc6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -269,7 +269,9 @@ export const PermissionDock: Component<{ } > {(desc) =>
{desc()}
}
- {(cmd) => } + + {(cmd) => } + {(() => { const desc = description() diff --git a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx index a5f032776d..80f13afeaa 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx @@ -1199,6 +1199,7 @@ print(f"Entries with audio_file set: {found_audio}") print(f"Missing audio_file: {len(expected) - found_audio}") EOF`, rules: ["python3 *"], + heredoc: true, }, tool: { messageID: ASST_MSG_ID, callID: "call-heredoc-001" }, } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/permissions.ts b/packages/kilo-vscode/webview-ui/src/types/messages/permissions.ts index 2c23350bf3..d102fefc39 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/permissions.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/permissions.ts @@ -43,6 +43,7 @@ export interface PermissionRequest { filediff?: PermissionFileDiff files?: PermissionPatchFile[] description?: string + heredoc?: boolean } message?: string tool?: { messageID: string; callID: string } diff --git a/packages/opencode/src/kilocode/tool/shell-heredoc.ts b/packages/opencode/src/kilocode/tool/shell-heredoc.ts new file mode 100644 index 0000000000..2d623a71ff --- /dev/null +++ b/packages/opencode/src/kilocode/tool/shell-heredoc.ts @@ -0,0 +1,7 @@ +import type { ShellID } from "@/tool/shell/id" +import type { Node } from "web-tree-sitter" + +export function heredocs(root: Node, kind: ShellID.Kind) { + if (kind !== "bash") return {} + return root.descendantsOfType("heredoc_redirect").length > 0 ? { heredoc: true } : {} +} diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index daa14cf645..429a04c7f9 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -19,6 +19,7 @@ import * as Truncate from "./truncate" import { Plugin } from "@/plugin" import { normalizeUrls } from "@/kilocode/util/url" // kilocode_change import { CommandTimeout } from "@/kilocode/command-timeout" // kilocode_change +import { heredocs } from "@/kilocode/tool/shell-heredoc" // kilocode_change import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ShellPrompt, type Parameters } from "./shell/prompt" @@ -282,6 +283,7 @@ const ask = Effect.fn("ShellTool.ask")(function* ( ctx: Tool.Context, scan: Scan, command: string, + metadata: ReturnType, // kilocode_change description?: string, // kilocode_change ) { // kilocode_change @@ -302,6 +304,7 @@ const ask = Effect.fn("ShellTool.ask")(function* ( directories, patterns: globs, ...(scan.access === "read" ? { access: "read" as const } : {}), + ...metadata, }, // kilocode_change end }) @@ -312,7 +315,7 @@ const ask = Effect.fn("ShellTool.ask")(function* ( permission: ShellID.ToolID, patterns: Array.from(scan.patterns), always: Array.from(scan.always), - metadata: { command: normalizeUrls(command), ...(description ? { description } : {}) }, // kilocode_change + metadata: { command: normalizeUrls(command), ...(description ? { description } : {}), ...metadata }, // kilocode_change }) }) @@ -411,11 +414,12 @@ export const ShellPermission = Effect.gen(function* () { Effect.gen(function* () { const tree = yield* Effect.acquireRelease(parse(input.command, ps), (tree) => Effect.sync(() => tree.delete())) const scan = yield* collect(tree.rootNode, input.cwd, ps, input.shell, instance) + const metadata = heredocs(tree.rootNode, ShellID.toKind(Shell.name(input.shell))) // kilocode_change if (!containsPath(input.cwd, instance)) { scan.dirs.add(input.cwd) scan.access = "unknown" } - yield* ask(ctx, scan, input.command, input.description) + yield* ask(ctx, scan, input.command, metadata, input.description) // kilocode_change }), ) }) diff --git a/packages/opencode/test/kilocode/bash-permission-metadata.test.ts b/packages/opencode/test/kilocode/bash-permission-metadata.test.ts index 4822d8d134..b138122f07 100644 --- a/packages/opencode/test/kilocode/bash-permission-metadata.test.ts +++ b/packages/opencode/test/kilocode/bash-permission-metadata.test.ts @@ -65,4 +65,42 @@ describe("bash permission metadata.command", () => { }, }) }) + + test.skipIf(process.platform === "win32").each([ + ["single quoted", "cat << 'EOF'\n$HOME\nEOF"], + ["double quoted", 'cat << "EOF"\n$HOME\nEOF'], + ["escaped", "cat << \\EOF\n$HOME\nEOF"], + ["unquoted", "cat << EOF\n$HOME\nEOF"], + ] as const)("marks %s heredocs", async (_, command) => { + await using tmp = await tmpdir() + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const bash = await runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init()))) + const requests: Array> = [] + await Effect.runPromise(bash.execute({ command }, capture(requests))) + + const req = requests.find((item) => item.permission === "bash") + expect(req?.metadata.heredoc).toBe(true) + expect(req?.metadata.command).toBe(command) + expect(req?.patterns).toEqual([command]) + expect(req?.always).toEqual(["cat *"]) + }, + }) + }) + + test("omits heredoc metadata for ordinary commands", async () => { + await using tmp = await tmpdir() + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const bash = await runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init()))) + const requests: Array> = [] + await Effect.runPromise(bash.execute({ command: "echo hello" }, capture(requests))) + + const req = requests.find((item) => item.permission === "bash") + expect(req?.metadata.heredoc).toBeUndefined() + }, + }) + }) })