From 8e515dd6f12c112a1c61de3d46b80dbce14ad585 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Thu, 30 Jul 2026 18:04:55 +0200 Subject: [PATCH] feat(tui): show why a tool call was auto-approved or denied Ports the auto-approval provenance explanation already shown in kilo-ui/vscode to the TUI. Adds a Kilo-owned tool-approval.tsx with a plain-text description helper (describeApproval) and a shared ApprovalNote row component, then wires a single call into InlineTool/ InlineToolRow (Shell, Read, Grep, Glob, WebFetch, etc.) and BlockTool (Write, Edit, ApplyPatch, Task), showing a muted line under completed/ failed tool calls. Todo writes are excluded via a hideApproval prop, mirroring the kilo-ui behavior that treats them as orchestration rather than an auditable action. --- packages/tui/src/kilocode/tool-approval.tsx | 64 +++++++++++++++++++++ packages/tui/src/routes/session/index.tsx | 21 ++++++- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 packages/tui/src/kilocode/tool-approval.tsx diff --git a/packages/tui/src/kilocode/tool-approval.tsx b/packages/tui/src/kilocode/tool-approval.tsx new file mode 100644 index 0000000000..fefc09419b --- /dev/null +++ b/packages/tui/src/kilocode/tool-approval.tsx @@ -0,0 +1,64 @@ +import type { RGBA } from "@opentui/core" +import { Show } from "solid-js" +import type { PermissionProvenance } from "@/kilocode/permission/provenance" +import type { ToolState } from "@kilocode/sdk/v2" + +/** `state.metadata` off any tool state, including the pending variant that lacks the field. */ +export function stateMetadata(state: ToolState | undefined) { + return state && "metadata" in state ? state.metadata : undefined +} + +const SOURCES = ["agent", "global", "project", "yolo", "session", "manual", "default"] as const + +/** Read the approval/denial provenance off a tool part's metadata, if present. */ +export function toolApprovalFrom(metadata: Record | undefined) { + const value = metadata?.approval + if (!value || typeof value !== "object") return undefined + const approval = value as PermissionProvenance.Approval + return (SOURCES as readonly string[]).includes(approval.source) ? approval : undefined +} + +function sourceLabel(approval: PermissionProvenance.Approval): string | undefined { + switch (approval.source) { + case "agent": + return approval.agent ? `by the ${approval.agent} agent` : "by the agent" + case "global": + return "by your global config" + case "project": + return "by the project config" + case "yolo": + return "by auto-approve (YOLO) mode" + case "session": + return "by a session auto-approve rule" + case "default": + return "by default" + default: + return undefined + } +} + +/** A short "why" line describing an auto-approval or denial, for the TUI's plain-text rows. */ +export function describeApproval(metadata: Record | undefined): string | undefined { + const approval = toolApprovalFrom(metadata) + if (!approval) return undefined + const manual = approval.source === "manual" + const decision = manual ? "approved by you" : approval.rule?.action === "deny" ? "denied" : "auto-approved" + if (manual) return decision + const source = sourceLabel(approval) + const rule = approval.rule + // The catch-all "*"/"*" rule carries no useful detail; let the source alone explain it. + const ruleText = + rule && !(rule.permission === "*" && rule.pattern === "*") ? ` (matched ${rule.permission} \`${rule.pattern}\`)` : "" + return source ? `${decision} ${source}${ruleText}` : decision +} + +/** The muted "why" row rendered under a completed/failed inline or block tool. */ +export function ApprovalNote(props: { note: string | undefined; color?: RGBA; paddingLeft: number }) { + return ( + + + {props.note} + + + ) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index fc1d28b8d7..1b41ba61d3 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -63,6 +63,7 @@ import { Toast, useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" +import { ApprovalNote, describeApproval, stateMetadata } from "../../kilocode/tool-approval" // kilocode_change import { useEpilogue } from "../../context/epilogue" import { normalizePath } from "../../util/path" import { PermissionPrompt } from "./permission" @@ -2204,6 +2205,8 @@ function InlineTool(props: { const failed = createMemo(() => Boolean(error() && !denied())) const clickable = createMemo(() => Boolean(props.onClick || failed())) + // kilocode_change - explain why the call was auto-approved or denied + const approvalNote = createMemo(() => describeApproval(stateMetadata(props.part.state))) const fg = createMemo(() => { if (props.color) return props.color if (permission()) return theme.warning @@ -2228,6 +2231,8 @@ function InlineTool(props: { failure={props.failure} spinner={props.spinner} separate={props.separate} + note={approvalNote()} // kilocode_change + noteColor={theme.textMuted} // kilocode_change onMouseOver={() => clickable() && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { @@ -2258,6 +2263,8 @@ export function InlineToolRow(props: { failure?: string spinner?: boolean separate?: boolean + note?: string // kilocode_change - why the call was auto-approved or denied + noteColor?: RGBA // kilocode_change children: JSX.Element onMouseOver?: () => void onMouseOut?: () => void @@ -2320,6 +2327,12 @@ export function InlineToolRow(props: { {props.error} + {/* kilocode_change - explain why the call was auto-approved or denied */} + ) } @@ -2330,11 +2343,14 @@ function BlockTool(props: { onClick?: () => void part?: ToolPart spinner?: boolean + hideApproval?: boolean // kilocode_change - suppress the auto-approval note (e.g. todowrite) }) { const { theme } = useTheme() const renderer = useRenderer() const [hover, setHover] = createSignal(false) const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) + // kilocode_change - explain why the call was auto-approved or denied + const approvalNote = createMemo(() => (props.hideApproval ? undefined : describeApproval(stateMetadata(props.part?.state)))) return ( alwaysSeparate.add(el)} @@ -2368,6 +2384,8 @@ function BlockTool(props: { {props.title.replace(/^# /, "")} {props.children} + {/* kilocode_change - explain why the call was auto-approved or denied */} + {error()} @@ -2825,7 +2843,8 @@ function TodoWrite(props: ToolProps) { return ( - + {/* kilocode_change - todo writes are orchestration, not a mutating action to explain */} + {(todo) => }