Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/kilo-ui/src/components/basic-tool.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
19 changes: 19 additions & 0 deletions packages/kilo-ui/src/components/basic-tool.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
24 changes: 20 additions & 4 deletions packages/kilo-ui/src/components/basic-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface BasicToolProps extends BaseProps {
tool?: string
callID?: string
partID?: string
approvalPlacement?: "body" | "hidden"
}

type OpenProps = Pick<BasicToolProps, "tool" | "callID" | "partID" | "forceOpen" | "defaultOpen">
Expand All @@ -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 ? <ToolApprovalLine display={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 = () => (
<div data-slot="basic-tool-details">
<Show when={approval()}>{(value) => <ToolApprovalLine display={value()} />}</Show>
<Show when={inBody() && approval()}>{(value) => <ToolApprovalLine display={value()} />}</Show>
{props.children}
</div>
)
if (!("children" in props) && !approval()) {
if (!("children" in props) && !inBody()) {
return <Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} />
}
return (
<Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} hasDetails>
<Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} hasDetails={inBody()}>
{details()}
</Base>
)
Expand Down
31 changes: 25 additions & 6 deletions packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1311,7 +1311,14 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
}}
</Match>
<Match when={true}>
<ToolApprovalProvider value={() => resolveToolApproval(meta(), i18n.t as (k: string, p?: Record<string, string | number | boolean>) => string)}>
<ToolApprovalProvider
value={() =>
resolveToolApproval(
meta(),
i18n.t as (k: string, p?: Record<string, string | number | boolean>) => string,
)
}
>
<Dynamic
component={render()}
input={input()}
Expand Down Expand Up @@ -1497,9 +1504,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
/>
</Tooltip>
</Show>
<Show when={props.throughput}>
{(el) => <span data-slot="assistant-throughput-inline">{el()}</span>}
</Show>
<Show when={props.throughput}>{(el) => <span data-slot="assistant-throughput-inline">{el()}</span>}</Show>
</div>
</Show>
<Show when={summary()}>
Expand Down Expand Up @@ -2172,6 +2177,8 @@ ToolRegistry.register({
}, 50)
}

const approvalLine = useToolApprovalLine()

const trigger = () => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
Expand All @@ -2190,11 +2197,22 @@ ToolRegistry.register({
</Match>
</Switch>
</Show>
{/* Keep the auto-approve line attached to the subagent card instead of forcing a collapsible body. */}
{approvalLine()}
</div>
</div>
)

return <BasicTool hideDetails icon="task" status={props.status} trigger={trigger()} animated />
return (
<BasicTool
hideDetails
approvalPlacement="hidden"
icon="task"
status={props.status}
trigger={trigger()}
animated
/>
)
},
})

Expand Down Expand Up @@ -2932,6 +2950,7 @@ ToolRegistry.register({
<BasicTool
{...props}
defaultOpen
approvalPlacement="hidden"
icon="checklist"
trigger={
<ToolTriggerRow
Expand Down
57 changes: 57 additions & 0 deletions packages/kilo-ui/src/components/tool-approval.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test"
import { resolveToolApproval } from "./tool-approval"

// Echo the key + params so assertions can see which string was chosen without a real dictionary.
const t = (key: string, params?: Record<string, string | number | boolean>) =>
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()
})
})
15 changes: 9 additions & 6 deletions packages/kilo-ui/src/components/tool-approval.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand All @@ -76,9 +81,7 @@ export function ToolApprovalLine(props: { display: ToolApprovalDisplay }) {
<div data-slot="tool-approval-line" data-source={props.display.approval.source}>
<span data-slot="tool-approval-decision">{props.display.decision}</span>
<Show when={!manual()}>
<Show when={props.display.source}>
{(text) => <span data-slot="tool-approval-source">{text()}</span>}
</Show>
<Show when={props.display.source}>{(text) => <span data-slot="tool-approval-source">{text()}</span>}</Show>
<Show when={props.display.rule}>{(text) => <span data-slot="tool-approval-rule">{text()}</span>}</Show>
</Show>
</div>
Expand Down
Loading