diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index c4495f862ff..3c67c36e2ad 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -516,3 +516,10 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty font-family: var(--font-family-mono); } } + +/* Subagent card: the line renders inside the trigger's info row (an align-baseline flex), + so force it onto its own full-width line directly under the title/description. */ +[data-slot="basic-tool-tool-info-main"] > [data-slot="tool-approval-line"] { + flex-basis: 100%; + padding: 2px 0 0; +} diff --git a/packages/kilo-ui/src/components/basic-tool.test.ts b/packages/kilo-ui/src/components/basic-tool.test.ts new file mode 100644 index 00000000000..c4009ba55f7 --- /dev/null +++ b/packages/kilo-ui/src/components/basic-tool.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { shouldRenderApprovalInBody } from "./basic-tool" + +describe("shouldRenderApprovalInBody", () => { + test("renders in the body by default when an approval exists", () => { + expect(shouldRenderApprovalInBody(undefined, true)).toBe(true) + expect(shouldRenderApprovalInBody("body", true)).toBe(true) + }) + + test("does not render when there is no approval", () => { + expect(shouldRenderApprovalInBody("body", false)).toBe(false) + expect(shouldRenderApprovalInBody(undefined, false)).toBe(false) + }) + + test("never renders in the body for hidden placement, even with an approval", () => { + expect(shouldRenderApprovalInBody("hidden", true)).toBe(false) + expect(shouldRenderApprovalInBody("hidden", false)).toBe(false) + }) +}) diff --git a/packages/kilo-ui/src/components/basic-tool.tsx b/packages/kilo-ui/src/components/basic-tool.tsx index ce545b832ee..7dbaeee9395 100644 --- a/packages/kilo-ui/src/components/basic-tool.tsx +++ b/packages/kilo-ui/src/components/basic-tool.tsx @@ -11,6 +11,7 @@ export interface BasicToolProps extends BaseProps { tool?: string callID?: string partID?: string + approvalPlacement?: "body" | "hidden" } type OpenProps = Pick @@ -19,26 +20,41 @@ export function initialOpen(props: OpenProps) { return props.forceOpen ? true : readToolOpen(toolOpenKey(props), props.defaultOpen) } +export function useToolApprovalLine() { + const approval = useToolApproval() + return () => { + const value = approval() + return value ? : null + } +} + +/** + * Whether BasicTool should inject the approval line into its body. + */ +export function shouldRenderApprovalInBody(placement: BasicToolProps["approvalPlacement"], hasApproval: boolean) { + return placement !== "hidden" && hasApproval +} + export function BasicTool(props: BasicToolProps) { const key = () => toolOpenKey(props) const initial = () => initialOpen(props) const approval = useToolApproval() + const inBody = () => shouldRenderApprovalInBody(props.approvalPlacement, approval() !== undefined) const change = (open: boolean) => { writeToolOpen(key(), open) props.onOpenChange?.(open) } - // The "why was this allowed" line lives in the expanded body, above any tool-specific details. const details = () => (
- {(value) => } + {(value) => } {props.children}
) - if (!("children" in props) && !approval()) { + if (!("children" in props) && !inBody()) { return } return ( - + {details()} ) diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 73e917d3c55..26f678681e3 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -31,7 +31,7 @@ import { useData } from "../context" import { useFileComponent } from "../context/file" import { useDialog } from "../context/dialog" import { type UiI18n, useI18n } from "../context/i18n" -import { GenericTool, BasicTool } from "./basic-tool" +import { BasicTool, useToolApprovalLine } from "./basic-tool" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Card } from "./card" @@ -1311,7 +1311,14 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { }} - resolveToolApproval(meta(), i18n.t as (k: string, p?: Record) => string)}> + + resolveToolApproval( + meta(), + i18n.t as (k: string, p?: Record) => string, + ) + } + > - - {(el) => {el()}} - + {(el) => {el()}} @@ -2172,6 +2177,8 @@ ToolRegistry.register({ }, 50) } + const approvalLine = useToolApprovalLine() + const trigger = () => (
@@ -2190,11 +2197,22 @@ ToolRegistry.register({ + {/* Keep the auto-approve line attached to the subagent card instead of forcing a collapsible body. */} + {approvalLine()}
) - return + return ( + + ) }, }) @@ -2932,6 +2950,7 @@ ToolRegistry.register({ ) => + params + ? `${key}(${Object.entries(params) + .map(([k, v]) => `${k}=${v}`) + .join(",")})` + : key + +describe("resolveToolApproval", () => { + test("returns undefined when there is no approval on the metadata", () => { + expect(resolveToolApproval(undefined, t)).toBeUndefined() + expect(resolveToolApproval({ other: 1 }, t)).toBeUndefined() + }) + + test("manual approvals show only the decision, no source or rule", () => { + const out = resolveToolApproval({ approval: { source: "manual" } }, t) + expect(out).toEqual({ + approval: { source: "manual" }, + decision: "ui.approval.manual", + source: undefined, + rule: undefined, + }) + }) + + test("a specific rule is shown with permission + pattern", () => { + const approval = { source: "project" as const, rule: { permission: "bash", pattern: "git *", action: "allow" } } + const out = resolveToolApproval({ approval }, t) + expect(out?.decision).toBe("ui.approval.auto") + expect(out?.source).toBe("ui.approval.source.project") + expect(out?.rule).toBe("ui.approval.rule(permission=bash,pattern=git *)") + }) + + test("a per-tool rule with a wildcard pattern still shows the tool name", () => { + const approval = { + source: "agent" as const, + agent: "explore", + rule: { permission: "task", pattern: "*", action: "allow" }, + } + const out = resolveToolApproval({ approval }, t) + expect(out?.rule).toBe("ui.approval.rule(permission=task,pattern=*)") + }) + + test("the catch-all */* rule is dropped so the line is not noisy for blanket agent defaults", () => { + // e.g. the code agent auto-approving `task`/`todowrite` via its "*": "allow" default. + const approval = { + source: "agent" as const, + agent: "code", + rule: { permission: "*", pattern: "*", action: "allow" }, + } + const out = resolveToolApproval({ approval }, t) + expect(out?.source).toBe("ui.approval.source.agent(agent=code)") + expect(out?.rule).toBeUndefined() + }) +}) diff --git a/packages/kilo-ui/src/components/tool-approval.tsx b/packages/kilo-ui/src/components/tool-approval.tsx index af07af86ddf..d555e105df3 100644 --- a/packages/kilo-ui/src/components/tool-approval.tsx +++ b/packages/kilo-ui/src/components/tool-approval.tsx @@ -59,13 +59,18 @@ export function resolveToolApproval( if (approval.source === "manual") return undefined return t(`ui.approval.source.${approval.source}`) } + const rule = approval.rule + // The catch-all "*"/"*" rule carries no useful detail (it's the blanket allow-everything default), + // so drop the "matched `*` rule `*`" fragment and let the source alone explain the approval. + const ruleText = + rule && !(rule.permission === "*" && rule.pattern === "*") + ? t("ui.approval.rule", { permission: rule.permission, pattern: rule.pattern }) + : undefined return { approval, decision: approval.source === "manual" ? t("ui.approval.manual") : t("ui.approval.auto"), source: sourceText(), - rule: approval.rule - ? t("ui.approval.rule", { permission: approval.rule.permission, pattern: approval.rule.pattern }) - : undefined, + rule: ruleText, } } @@ -76,9 +81,7 @@ export function ToolApprovalLine(props: { display: ToolApprovalDisplay }) {
{props.display.decision} - - {(text) => {text()}} - + {(text) => {text()}} {(text) => {text()}}