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.
This commit is contained in:
Bruno Agatao
2026-07-30 18:04:55 +02:00
parent 63d35d06cf
commit 8e515dd6f1
2 changed files with 84 additions and 1 deletions
@@ -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<string, unknown> | 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<string, unknown> | 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 (
<Show when={props.note}>
<box paddingLeft={props.paddingLeft}>
<text fg={props.color}>{props.note}</text>
</box>
</Show>
)
}
+20 -1
View File
@@ -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: {
<text fg={props.errorColor}>{props.error}</text>
</box>
</Show>
{/* kilocode_change - explain why the call was auto-approved or denied */}
<ApprovalNote
note={props.note && (props.complete || props.failed) ? props.note : undefined}
color={props.noteColor}
paddingLeft={INLINE_TOOL_ICON_WIDTH}
/>
</box>
)
}
@@ -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 (
<box
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
@@ -2368,6 +2384,8 @@ function BlockTool(props: {
<Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
</Show>
{props.children}
{/* kilocode_change - explain why the call was auto-approved or denied */}
<ApprovalNote note={approvalNote()} color={theme.textMuted} paddingLeft={3} />
<Show when={error()}>
<text fg={theme.error}>{error()}</text>
</Show>
@@ -2825,7 +2843,8 @@ function TodoWrite(props: ToolProps) {
return (
<Switch>
<Match when={parseTodos(props.metadata.todos).length}>
<BlockTool title="# Todos" part={props.part}>
{/* kilocode_change - todo writes are orchestration, not a mutating action to explain */}
<BlockTool title="# Todos" part={props.part} hideApproval>
<box>
<For each={todos()}>{(todo) => <TodoItem status={todo.status} content={todo.content} />}</For>
</box>