refactor: agent builder

This commit is contained in:
Catriel Müller
2026-05-26 16:11:01 -03:00
parent ce80806bfa
commit 10de9ebfe6
8 changed files with 926 additions and 277 deletions
@@ -1,103 +1,322 @@
import { For, Show } from "solid-js"
import { createMemo, For, Show, type JSX } from "solid-js"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Tag } from "@kilocode/kilo-ui/tag"
import { toMode, toAction } from "../../shared/utils"
import { useConfig } from "../../context/config"
import { toAction, toMode, toolCapabilities, toolName } from "../../shared/utils"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { snippets, useAgentBuilder } from "./state/agents"
import {
agentEditable,
agentTitle,
snippets,
useAgentBuilder,
type AgentEntry,
type AgentItem,
} from "./state/agents"
type Row = { item: AgentItem; entry?: AgentEntry; rank: number }
function base(input: string) {
const index = input.indexOf("/settings")
if (index > 0) return `${input.slice(0, index)}/settings`
if (input.startsWith("/config")) return "/config"
return "/settings"
}
function desc(item: AgentItem) {
return item.description ?? "No description available."
}
function label(rank: number) {
if (rank === 0) return "Project Agents"
return "Global Agents"
}
function useAgentLinks() {
const loc = useLocation()
const nav = useNavigate()
const href = (id?: string) => {
const suffix = id ? `/${encodeURIComponent(id)}` : ""
return `${base(loc.pathname)}/agents${suffix}${loc.search}`
}
return { href, nav }
}
function countTools(input: string[]) {
if (input.length === 1) return "1 tool"
return `${input.length} tools`
}
function FieldCard(props: { label: string; actions?: JSX.Element; children: JSX.Element }) {
return (
<article class="resolved-card default-model-card agent-field-card">
<header class="default-model-header">
<span>{props.label}</span>
<Show when={props.actions}>
<div class="tags default-model-actions">{props.actions}</div>
</Show>
</header>
<div class="default-model-value">{props.children}</div>
</article>
)
}
export function AgentsRoute() {
const state = useAgentBuilder()
const links = useAgentLinks()
const ctx = useConfig()
const snap = () => ctx.data()
const rows = createMemo(() => {
const data = snap()
if (!data) return []
const scope = ctx.query()?.scope ?? "global"
const entries = new Map((data.overlay.collections.agent ?? []).map((entry) => [entry.key, entry]))
const local = new Set(
(data.overlay.collections.agent ?? []).filter((entry) => entry.source === "project").map((entry) => entry.key),
)
return data.agents
.filter((item) => {
const entry = entries.get(item.name)
if (scope === "global") return !entry?.local
return local.has(item.name) || !entry?.local
})
.map((item) => ({ item, entry: entries.get(item.name), rank: local.has(item.name) ? 0 : 1 }))
.sort((a, b) => a.rank - b.rank || agentTitle(a.item).localeCompare(agentTitle(b.item)))
})
const groups = createMemo(() => [0, 1].map((rank) => rows().filter((row) => row.rank === rank)).filter((row) => row.length))
return (
<Show when={snap()}>
{(data) => (
<ConfigPage
title="Agents"
actions={
<>
<Button variant="primary" onClick={() => links.nav(links.href("new"))}>
New Agent
</Button>
<Tag>{rows().length}</Tag>
</>
}
>
<div class="agents">
<Show when={groups().length} fallback={<p class="empty">No agents loaded.</p>}>
<For each={groups()}>
{(group) => (
<section class="agent-section">
<Show when={ctx.query()?.scope === "project"}>
<h2>{label(group[0]?.rank ?? 1)}</h2>
</Show>
<For each={group}>
{(row) => (
<article class="model agent-card" classList={{ inherited: row.entry?.inherited }}>
<div class="model-main">
<div class="model-title">
<div>
<strong>{agentTitle(row.item)}</strong>
<span>{row.item.name}</span>
</div>
</div>
<Button variant="secondary" onClick={() => links.nav(links.href(row.item.name))}>
{agentEditable(row.item, row.entry) ? "Edit" : "Inspect"}
</Button>
</div>
<p class="model-description">{desc(row.item)}</p>
<div class="tags agent-tags">
<Tag>{row.item.mode}</Tag>
<Tag>{row.item.native ? "Native" : "Custom"}</Tag>
<Show when={row.item.hidden}>
<Tag>Hidden</Tag>
</Show>
<Show when={row.item.deprecated}>
<Tag>Deprecated</Tag>
</Show>
<Show when={row.entry}>
{(entry) => (
<SourceBadge
source={entry().source}
inherited={entry().inherited}
overridden={entry().overridden}
/>
)}
</Show>
</div>
</article>
)}
</For>
</section>
)}
</For>
</Show>
</div>
</ConfigPage>
)}
</Show>
)
}
export function AgentBuilderRoute() {
const links = useAgentLinks()
const params = useParams()
const agent = () => (params.agentID === "new" ? undefined : params.agentID)
const state = useAgentBuilder(agent)
const title = createMemo(() => {
if (!agent()) return "Agent Builder"
if (state.locked()) return "Inspect Agent"
return "Edit Agent"
})
return (
<Show when={state.snap()}>
{(data) => (
<ConfigPage title="Agent Builder" actions={<Tag>{data().agents.length}</Tag>}>
{(_data) => (
<ConfigPage
title={title()}
actions={
<>
<Button variant="secondary" disabled={Boolean(state.ctx.saving()) || !state.ready()} onClick={state.openMarkdown}>
Markdown
</Button>
<Show when={!state.locked()}>
<Button variant="primary" disabled={Boolean(state.ctx.saving()) || !state.ready()} onClick={state.save}>
Save
</Button>
</Show>
<IconButton
icon="close"
variant="secondary"
aria-label="Close agent builder"
onClick={() => links.nav(links.href())}
/>
</>
}
>
<div class="builder">
<section class="builder-form">
<div class="builder-grid">
<label>
Scope
<select
value={state.scope()}
onChange={(event) => state.setScope(event.currentTarget.value === "global" ? "global" : "project")}
>
<option value="project">Project</option>
<option value="global">Global</option>
</select>
</label>
<label>
Agent id
<div class="resolved-grid model-defaults agent-fields">
<FieldCard label="Agent id">
<input
value={state.id()}
placeholder="reviewer"
readOnly={state.locked()}
spellcheck={false}
onInput={(event) => state.setId(event.currentTarget.value)}
/>
</label>
<label>
Mode
<select value={state.mode()} onChange={(event) => state.setMode(toMode(event.currentTarget.value))}>
</FieldCard>
<FieldCard label="Description">
<input
value={state.desc()}
placeholder="Review code and report risks"
readOnly={state.locked()}
onInput={(event) => state.setDesc(event.currentTarget.value)}
/>
</FieldCard>
<FieldCard label="Mode">
<select
value={state.mode()}
disabled={state.locked()}
onChange={(event) => state.setMode(toMode(event.currentTarget.value))}
>
<option value="primary">Primary</option>
<option value="subagent">Subagent</option>
<option value="all">Both</option>
</select>
</label>
<label>
Model
<input
list="agent-model-list"
value={state.model()}
placeholder="provider/model or inherit default"
spellcheck={false}
onInput={(event) => state.setModel(event.currentTarget.value)}
/>
</label>
<label>
Description
<input
value={state.desc()}
placeholder="Review code and report risks"
onInput={(event) => state.setDesc(event.currentTarget.value)}
/>
</label>
<label>
Color
</FieldCard>
<FieldCard
label="Model"
actions={
<>
<Show when={!state.locked() && state.model()}>
<Button variant="secondary" disabled={Boolean(state.ctx.saving())} onClick={state.clearModel}>
Use Default
</Button>
</Show>
<IconButton
icon="edit"
variant="secondary"
aria-label="Edit agent model"
disabled={Boolean(state.ctx.saving()) || state.locked()}
onClick={state.openModel}
/>
</>
}
>
<Show
when={state.selected()}
fallback={
<>
<strong>{state.model() || "Inherit default model"}</strong>
<Show when={state.model()}>{(value) => <span class="default-model-id">{value()}</span>}</Show>
</>
}
>
{(model) => (
<>
<strong>{`${model().provider.name} / ${model().model.name}`}</strong>
<span class="default-model-id">{model().id}</span>
</>
)}
</Show>
</FieldCard>
<FieldCard label="Color">
<input
value={state.color()}
placeholder="blue"
readOnly={state.locked()}
onInput={(event) => state.setColor(event.currentTarget.value)}
/>
</label>
<label>
Max steps
</FieldCard>
<FieldCard label="Max steps">
<input
value={state.steps()}
placeholder="optional"
inputMode="numeric"
readOnly={state.locked()}
onInput={(event) => state.setSteps(event.currentTarget.value)}
/>
</label>
<label class="wide">
Tool ids
<input
value={state.tools().join(", ")}
placeholder="read, grep, bash"
spellcheck={false}
onInput={(event) => state.setTools(event.currentTarget.value)}
/>
</label>
<label class="wide prompt-field">
Prompt
</FieldCard>
<FieldCard
label="Tool Access"
actions={
<>
<Show when={!state.locked() && state.tools().length}>
<Button variant="secondary" disabled={Boolean(state.ctx.saving())} onClick={state.clearTools}>
Clear
</Button>
</Show>
<IconButton
icon="edit"
variant="secondary"
aria-label="Edit tool access"
disabled={Boolean(state.ctx.saving()) || state.locked()}
onClick={state.openTools}
/>
</>
}
>
<Show
when={state.tools().length}
fallback={
<>
<strong>No tools selected</strong>
<span class="default-model-id">No allow permissions will be written.</span>
</>
}
>
<strong>{countTools(state.tools())}</strong>
<div class="tag-cloud agent-tool-summary">
<For each={state.tools()}>{(tool) => <Tag>{tool}</Tag>}</For>
</div>
</Show>
</FieldCard>
<FieldCard label="Prompt">
<textarea
value={state.prompt()}
placeholder="Describe how this agent should behave."
readOnly={state.locked()}
onInput={(event) => state.setPrompt(event.currentTarget.value)}
/>
</label>
</FieldCard>
</div>
<datalist id="agent-model-list">
<For each={state.all()}>{(item) => <option value={item.id}>{item.model.name}</option>}</For>
</datalist>
<div class="builder-block">
<div class="block-title">
@@ -109,7 +328,7 @@ export function AgentsRoute() {
{(snippet) => (
<Button
variant="secondary"
disabled={Boolean(state.ctx.saving())}
disabled={Boolean(state.ctx.saving()) || state.locked()}
onClick={() => state.insert(snippet)}
>
{snippet}
@@ -119,26 +338,6 @@ export function AgentsRoute() {
</div>
</div>
<div class="builder-block">
<div class="block-title">
<strong>Tool Access</strong>
<span>Selected tools are written as allow permissions in the agent frontmatter.</span>
</div>
<div class="tool-picks">
<For each={data().tools.slice(0, 32)}>
{(tool) => (
<Button
variant={state.picked().has(tool) ? "primary" : "secondary"}
disabled={Boolean(state.ctx.saving())}
onClick={() => state.toggleTool(tool)}
>
{tool}
</Button>
)}
</For>
</div>
</div>
<div class="builder-block">
<div class="block-title">
<strong>Agent Permissions</strong>
@@ -150,6 +349,7 @@ export function AgentsRoute() {
<input
value={state.permTool()}
placeholder="bash"
readOnly={state.locked()}
onInput={(event) => state.setPermTool(event.currentTarget.value)}
/>
</label>
@@ -158,6 +358,7 @@ export function AgentsRoute() {
<input
value={state.permPattern()}
placeholder="optional pattern"
readOnly={state.locked()}
onInput={(event) => state.setPermPattern(event.currentTarget.value)}
/>
</label>
@@ -165,6 +366,7 @@ export function AgentsRoute() {
Action
<select
value={state.permAction()}
disabled={state.locked()}
onChange={(event) => state.setPermAction(toAction(event.currentTarget.value))}
>
<option value="ask">Ask</option>
@@ -172,7 +374,7 @@ export function AgentsRoute() {
<option value="deny">Deny</option>
</select>
</label>
<Button variant="secondary" disabled={Boolean(state.ctx.saving())} onClick={state.addPermission}>
<Button variant="secondary" disabled={Boolean(state.ctx.saving()) || state.locked()} onClick={state.addPermission}>
Add Override
</Button>
</div>
@@ -188,63 +390,157 @@ export function AgentsRoute() {
</For>
</div>
</div>
<div class="builder-actions">
<Button variant="secondary" disabled={Boolean(state.ctx.saving())} onClick={state.preview}>
Preview Markdown
</Button>
<Button variant="primary" disabled={Boolean(state.ctx.saving())} onClick={state.save}>
Save Agent
</Button>
</div>
</section>
</div>
<section class="builder-side">
<div class="builder-block">
<div class="block-title">
<strong>Existing Agents</strong>
<span>Reloads after save so the CLI selector sees the new file.</span>
</div>
<div class="mini-list">
<Show when={data().agents.length} fallback={<p class="empty">No agents loaded.</p>}>
<For each={data().agents.slice(0, 12)}>
{(item) => {
const meta = () => data().overlay.collections.agent.find((entry) => entry.key === item.name)
return (
<article class="mini-item simple" classList={{ inherited: meta()?.inherited }}>
<strong>{item.displayName ?? item.name}</strong>
<span>{item.description ?? item.name}</span>
<div class="tags">
<Tag>{item.mode}</Tag>
<Show when={meta()}>
{(entry) => (
<SourceBadge
source={entry().source}
inherited={entry().inherited}
overridden={entry().overridden}
/>
)}
</Show>
</div>
</article>
)
}}
</For>
</Show>
<Show when={state.panel() === "model"}>
<div class="drawer-scrim" onClick={state.close} />
<aside class="provider-drawer" aria-label="Agent model selector">
<header class="drawer-header">
<div>
<h2>Choose Model</h2>
<span>Favorites are listed first, then models are sorted alphabetically.</span>
</div>
<Button variant="ghost" aria-label="Close model selector" onClick={state.close}>
X
</Button>
</header>
<label class="drawer-search">
Filter models
<input
type="search"
value={state.picker()}
placeholder="Search by name, provider, or ID"
onInput={(event) => state.setPicker(event.currentTarget.value)}
/>
</label>
<div class="provider-picker model-picker">
<Show when={state.models().length} fallback={<p class="empty">No models match this filter.</p>}>
<For each={state.models()}>
{(item) => (
<button
class="provider-option model-option"
classList={{ selected: state.choice() === item.id }}
type="button"
onClick={() => state.selectModel(item)}
>
<span class="model-star" classList={{ active: state.fav(item) }} aria-hidden="true" />
<div>
<strong>{item.model.name}</strong>
<span>{item.id}</span>
</div>
<div class="tags">
<Tag>{item.provider.name}</Tag>
<Tag>{item.model.isFree ? "free" : "paid"}</Tag>
</div>
</button>
)}
</For>
</Show>
</div>
<div class="builder-block preview-card">
<div class="block-title">
<strong>Markdown Preview</strong>
<span>{state.draft()?.path ?? "Preview or save to generate a path."}</span>
<footer class="drawer-footer">
<Button variant="secondary" onClick={state.close}>
Cancel
</Button>
<Button
variant="primary"
disabled={Boolean(state.ctx.saving()) || state.locked() || !state.choice()}
onClick={state.saveModel}
>
Save
</Button>
</footer>
</aside>
</Show>
<Show when={state.panel() === "tools"}>
<div class="drawer-scrim" onClick={state.close} />
<aside class="provider-drawer" aria-label="Agent tool access selector">
<header class="drawer-header">
<div>
<h2>Choose Tool Access</h2>
<span>Selected tools are written as allow permissions in the agent frontmatter.</span>
</div>
<Show when={state.draft()} fallback={<p class="empty">No preview generated yet.</p>}>
<Button variant="ghost" aria-label="Close tool selector" onClick={state.close}>
X
</Button>
</header>
<label class="drawer-search">
Filter tools
<input
type="search"
value={state.search()}
placeholder="Search by tool name, ID, or capability"
onInput={(event) => state.setSearch(event.currentTarget.value)}
/>
</label>
<div class="provider-picker tool-picker">
<Show when={state.options().length} fallback={<p class="empty">No tools match this filter.</p>}>
<For each={state.options()}>
{(tool) => (
<button
class="provider-option tool-option"
classList={{ selected: state.pickedDraft().has(tool.id) }}
aria-pressed={state.pickedDraft().has(tool.id)}
type="button"
onClick={() => state.toggleTool(tool.id)}
>
<div class="model-main tool-main">
<div class="model-title">
<div>
<strong>{toolName(tool.id)}</strong>
<span>{tool.id}</span>
</div>
</div>
<div class="tags">
<Tag>{state.pickedDraft().has(tool.id) ? "Allowed" : "Off"}</Tag>
</div>
</div>
<ul class="tool-capabilities">
<For each={toolCapabilities(tool)}>{(cap) => <li>{cap}</li>}</For>
</ul>
</button>
)}
</For>
</Show>
</div>
<footer class="drawer-footer">
<Button variant="secondary" onClick={state.close}>
Cancel
</Button>
<Button variant="primary" disabled={Boolean(state.ctx.saving()) || state.locked()} onClick={state.saveTools}>
Save
</Button>
</footer>
</aside>
</Show>
<Show when={state.panel() === "markdown"}>
<div class="drawer-scrim" onClick={state.close} />
<aside class="provider-drawer" aria-label="Generated agent markdown">
<header class="drawer-header">
<div>
<h2>Markdown</h2>
<span>{state.draft()?.path ?? "Previewing the current agent configuration."}</span>
</div>
<Button variant="ghost" aria-label="Close markdown preview" onClick={state.close}>
X
</Button>
</header>
<div class="provider-picker markdown-picker">
<Show when={state.draft()} fallback={<p class="empty">Generating markdown preview...</p>}>
{(draft) => <pre class="markdown-preview">{draft().markdown}</pre>}
</Show>
</div>
</section>
</div>
</aside>
</Show>
</ConfigPage>
)}
</Show>
@@ -17,7 +17,7 @@ export function ConfigSidebar() {
return rest || "/"
})
const href = (path: string) => `${path === "/" ? base() : `${base()}${path}`}${loc.search}`
const current = (path: string) => path === active()
const current = (path: string) => path === active() || (path !== "/" && active().startsWith(`${path}/`))
const group = (item: ConfigNode): item is ConfigGroup => "items" in item
const marked = (group: ConfigGroup) => group.items.some((item) => current(item.path))
const expanded = (group: ConfigGroup) => marked(group) || Boolean(open()[group.id])
@@ -1,95 +1,9 @@
import { createMemo, For, Show } from "solid-js"
import { Tag } from "@kilocode/kilo-ui/tag"
import type { ToolListItem } from "@kilocode/sdk/v2/client"
import { useConfig } from "../../context/config"
import { toolCapabilities, toolName } from "../../shared/utils"
import { ConfigPage, ConfigToolbar } from "./ConfigPage"
const labels: Record<string, string> = {
apply_patch: "Apply Patch",
bash: "Bash",
edit: "Edit File",
glob: "Find Files",
grep: "Search Content",
lsp: "Language Server",
question: "Ask User",
read: "Read File",
recall: "Local Recall",
skill: "Load Skill",
task: "Subagent Task",
todowrite: "Todo List",
webfetch: "Fetch URL",
websearch: "Web Search",
write: "Write File",
}
const copy: Record<string, string[]> = {
apply_patch: ["Apply structured patches to add, update, move, or delete files.", "Keeps edits explicit and easy to review."],
bash: ["Run shell commands in the current workspace.", "Supports timeouts, working directories, and command descriptions."],
edit: ["Replace exact text inside an existing file.", "Can update one match or all matches when needed."],
glob: ["Find files by path patterns.", "Useful for locating files before reading or editing them."],
grep: ["Search file contents with regular expressions.", "Can limit results to a file pattern such as TypeScript or CSS files."],
lsp: ["Use language-server features for code navigation.", "Supports operations such as definitions, references, and diagnostics."],
question: ["Ask the user for a decision during execution.", "Supports single-choice and multi-choice prompts."],
read: ["Read files, directories, images, and PDFs from the workspace.", "Supports line offsets and limits for large files."],
recall: ["Search and read previous project conversations.", "Useful for recovering prior decisions or implementation context."],
skill: ["Load task-specific instructions and workflows.", "Applies specialized repo guidance when a matching skill exists."],
task: ["Delegate focused exploration or implementation work to a subagent.", "Useful for parallel codebase research and complex subtasks."],
todowrite: ["Track multi-step implementation work.", "Shows progress with pending, in-progress, and completed states."],
webfetch: ["Fetch and convert web pages for analysis.", "Supports markdown, text, and HTML output formats."],
websearch: ["Search the web for external information.", "Supports fast, automatic, and deep search modes."],
write: ["Create or overwrite files with complete contents.", "Best for new files or full-file replacements."],
}
const acronyms = new Set(["api", "html", "id", "json", "lsp", "mcp", "pdf", "url"])
type Item = { id: string; detail: ToolListItem | undefined }
function word(input: string) {
const lower = input.toLowerCase()
if (acronyms.has(lower)) return lower.toUpperCase()
return input.charAt(0).toUpperCase() + input.slice(1)
}
function name(id: string) {
const known = labels[id]
if (known) return known
const clean = id
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[._:/-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
if (!clean) return id
return clean.split(" ").map(word).join(" ")
}
function clip(input: string) {
if (input.length <= 180) return input
return `${input.slice(0, 177)}...`
}
function summary(input: string) {
const text = input
.replace(/```[\s\S]*?```/g, " ")
.replace(/[*`#>_-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
const parts = text
.split(/(?:\.|\?|!)\s+/)
.map((part) => part.trim())
.filter(Boolean)
.slice(0, 2)
if (parts.length) return parts.map(clip)
return text ? [clip(text)] : []
}
function capabilities(item: Item) {
const known = copy[item.id]
if (known) return known
const desc = item.detail?.description.trim()
if (desc) return summary(desc)
return ["No capability description is available for this tool."]
}
export function ToolsRoute() {
const ctx = useConfig()
const snap = () => ctx.data()
@@ -99,7 +13,7 @@ export function ToolsRoute() {
const details = new Map(data.toolDetails.map((item) => [item.id, item]))
return data.tools
.map((id) => ({ id, detail: details.get(id) }))
.sort((a, b) => name(a.id).localeCompare(name(b.id)))
.sort((a, b) => toolName(a.id).localeCompare(toolName(b.id)))
})
return (
@@ -119,13 +33,13 @@ export function ToolsRoute() {
<div class="model-main tool-main">
<div class="model-title">
<div>
<strong>{name(tool.id)}</strong>
<strong>{toolName(tool.id)}</strong>
<span>{tool.id}</span>
</div>
</div>
</div>
<ul class="tool-capabilities">
<For each={capabilities(tool)}>{(cap) => <li>{cap}</li>}</For>
<For each={toolCapabilities(tool)}>{(cap) => <li>{cap}</li>}</For>
</ul>
</article>
)}
@@ -1,6 +1,6 @@
import type { Component } from "solid-js"
import type { IconProps } from "@kilocode/kilo-ui/icon"
import { AgentsRoute } from "./AgentsRoute"
import { AgentBuilderRoute, AgentsRoute } from "./AgentsRoute"
import { CliUiRoute } from "./CliUiRoute"
import { FormattersRoute } from "./FormattersRoute"
import { KeybindsRoute } from "./KeybindsRoute"
@@ -112,5 +112,7 @@ function sections(item: ConfigNode) {
export const configSections = [
...configNav.flatMap(sections),
{ path: "/agents/new", href: "/settings/agents/new", icon: "task", label: "New Agent", component: AgentBuilderRoute },
{ path: "/agents/:agentID", href: "/settings/agents", icon: "task", label: "Edit Agent", component: AgentBuilderRoute },
{ path: "/models", href: "/settings/models/default", icon: "models", label: "Models", component: ModelsRoute },
]
@@ -1,12 +1,18 @@
import { createEffect, createMemo, createSignal } from "solid-js"
import type { AgentBuilderPreviewResponse } from "@kilocode/sdk/v2/client"
import { previewAgent, saveAgent, type AgentPayload, type Scope } from "../../../client"
import type { Accessor } from "solid-js"
import type { AgentBuilderPreviewResponse, Model, Provider } from "@kilocode/sdk/v2/client"
import { previewAgent, saveAgent, type AgentPayload, type Scope, type Snapshot } from "../../../client"
import { useConfig } from "../../../context/config"
import { catalog, clean, sorted, text } from "../../../shared/utils"
import { clean, friendly, sorted, text, toMode, toolCapabilities, toolName } from "../../../shared/utils"
type Mode = AgentPayload["mode"]
type Permission = NonNullable<AgentPayload["permission"]>
type Draft = AgentBuilderPreviewResponse
type Panel = "closed" | "model" | "tools" | "markdown"
type Favorite = Snapshot["modelState"]["favorite"][number]
type Item = { id: string; provider: Provider; model: Model }
export type AgentItem = Snapshot["agents"][number]
export type AgentEntry = Snapshot["overlay"]["collections"][string][number]
export const snippets = [
"Review the current diff and report risks, regressions, and missing tests.",
@@ -15,6 +21,17 @@ export const snippets = [
"Run the smallest relevant validation checks and report any remaining failures.",
]
function order(a: Provider, b: Provider) {
if (a.id === "kilo") return -1
if (b.id === "kilo") return 1
return a.name.localeCompare(b.name)
}
function same(item: Item, ref: Favorite) {
if (item.provider.id === ref.providerID && item.model.id === ref.modelID) return true
return item.model.providerID === ref.providerID && item.model.id === ref.modelID
}
function maybe(input: string) {
const value = clean(input)
if (value) return value
@@ -27,11 +44,78 @@ function count(input: string) {
return undefined
}
export function useAgentBuilder() {
function record(input: unknown): Record<string, unknown> {
if (input && typeof input === "object" && !Array.isArray(input)) return input as Record<string, unknown>
return {}
}
function string(input: unknown) {
if (typeof input === "string") return input
return ""
}
function number(input: unknown) {
if (typeof input === "number" && Number.isFinite(input)) return input
return undefined
}
function source(item: AgentItem) {
const value = item.options.source
if (typeof value === "string") return value
return undefined
}
function split(input: unknown) {
const entries = Object.entries(perms(input))
return {
tools: sorted(entries.filter(([, value]) => value === "allow").map(([key]) => key)),
permission: Object.fromEntries(entries.filter(([, value]) => value !== "allow")) as Permission,
}
}
function perms(input: unknown): Permission {
if (!Array.isArray(input)) return record(input) as Permission
return input.reduce((result, item) => {
const rule = record(item)
const tool = string(rule.permission)
const action = string(rule.action)
if (!tool || !action) return result
const pattern = string(rule.pattern) || "*"
if (pattern === "*") return { ...result, [tool]: action }
const cur = result[tool]
const map = cur && typeof cur === "object" && !Array.isArray(cur) ? cur : {}
return { ...result, [tool]: { ...map, [pattern]: action } }
}, {} as Permission)
}
export function agentMeta(data: Snapshot, id: string) {
return (data.overlay.collections.agent ?? []).find((entry) => entry.key === id)
}
export function agentTitle(item: AgentItem) {
return item.displayName ?? friendly(item.name)
}
export function agentModel(item: AgentItem) {
if (!item.model) return ""
return `${item.model.providerID}/${item.model.modelID}`
}
export function agentEditable(item: AgentItem, entry?: AgentEntry) {
if (item.native) return false
if (entry?.inherited) return false
if (entry?.editable === false) return false
if (source(item) === "organization") return false
return true
}
export function useAgentBuilder(agent?: Accessor<string | undefined>) {
const ctx = useConfig()
const snap = () => ctx.data()
const [scope, setScope] = createSignal<Scope>(ctx.query()?.scope ?? "project")
const [dirty, setDirty] = createSignal(false)
const scope = createMemo<Scope>(() => ctx.query()?.scope ?? "project")
const [loaded, setLoaded] = createSignal<string>()
const [ready, setReady] = createSignal(true)
const [locked, setLocked] = createSignal(false)
const [id, setId] = createSignal("reviewer")
const [desc, setDesc] = createSignal("")
const [mode, setMode] = createSignal<Mode>("subagent")
@@ -45,14 +129,72 @@ export function useAgentBuilder() {
const [permAction, setPermAction] = createSignal<"ask" | "allow" | "deny">("ask")
const [permission, setPermission] = createSignal<Permission>({})
const [draft, setDraft] = createSignal<Draft>()
const [panel, setPanel] = createSignal<Panel>("closed")
const [picker, setPicker] = createSignal("")
const [choice, setChoice] = createSignal("")
const [search, setSearch] = createSignal("")
const [chosen, setChosen] = createSignal<string[]>([])
const all = createMemo(() => {
const providers = createMemo(() => {
const data = snap()
if (!data) return []
return catalog(data)
const ids = new Set([...Object.keys(data.effective.provider ?? {}), ...data.providers.connected])
return data.providers.all
.filter((provider) => ids.has(provider.id))
.filter((provider) => Object.keys(provider.models).length > 0)
.sort(order)
})
const picked = createMemo(() => new Set(tools()))
const all = createMemo(() => {
return providers().flatMap((provider) =>
Object.values(provider.models).map((model) => ({
id: `${provider.id}/${model.id}`,
provider,
model,
})),
)
})
const favorites = createMemo(() => snap()?.modelState.favorite ?? [])
const fav = (item: Item) => favorites().some((ref) => same(item, ref))
const models = createMemo(() => {
const term = picker().trim().toLowerCase()
return all()
.filter((item) => {
if (!term) return true
return `${item.id} ${item.model.name} ${item.provider.name}`.toLowerCase().includes(term)
})
.sort((a, b) => {
const ranked = Number(fav(b)) - Number(fav(a))
if (ranked !== 0) return ranked
const named = a.model.name.localeCompare(b.model.name)
if (named !== 0) return named
const provider = a.provider.name.localeCompare(b.provider.name)
if (provider !== 0) return provider
return a.id.localeCompare(b.id)
})
})
const item = (value: unknown) => {
if (typeof value !== "string") return undefined
return all().find((model) => model.id === value || `${model.model.providerID}/${model.model.id}` === value)
}
const selected = createMemo(() => item(model()))
const options = createMemo(() => {
const data = snap()
if (!data) return []
const term = search().trim().toLowerCase()
const details = new Map(data.toolDetails.map((item) => [item.id, item]))
return data.tools
.map((id) => ({ id, detail: details.get(id) }))
.filter((tool) => {
if (!term) return true
return `${tool.id} ${toolName(tool.id)} ${toolCapabilities(tool).join(" ")}`.toLowerCase().includes(term)
})
.sort((a, b) => toolName(a.id).localeCompare(toolName(b.id)))
})
const pickedDraft = createMemo(() => new Set(chosen()))
const rules = createMemo(() =>
Object.entries(permission()).flatMap(([tool, rule]) => {
if (rule && typeof rule === "object" && !Array.isArray(rule)) {
@@ -66,35 +208,144 @@ export function useAgentBuilder() {
}),
)
createEffect(() => {
if (dirty()) return
const query = ctx.query()
if (!query) return
setScope(query.scope)
})
function updateScope(value: Scope) {
setDirty(true)
setScope(value)
function reset(value = "reviewer") {
setId(value)
setDesc("")
setMode("subagent")
setModel("")
setColor("")
setSteps("")
setTools([])
setPrompt("")
setPermission({})
setDraft(undefined)
setLocked(false)
setPanel("closed")
setPicker("")
setChoice("")
setSearch("")
setChosen([])
}
function setToolList(value: string) {
setTools(
value
.split(",")
.map((word) => word.trim())
.filter(Boolean),
)
createEffect(() => {
const value = agent?.()
if (value) return
if (loaded() === "") return
reset()
setReady(true)
setLoaded("")
})
createEffect(() => {
const data = snap()
const key = agent?.()
if (!data || !key) return
if (loaded() === key) return
reset(key)
setReady(false)
setLoaded(key)
const item = data.agents.find((item) => item.name === key)
if (!item) {
ctx.fail(`Agent not found: ${key}`)
return
}
const entry = agentMeta(data, key)
const edit = agentEditable(item, entry)
ctx.fail("")
setReady(true)
setLocked(!edit)
const cfg = record(entry?.value)
const parts = split(cfg.permission ?? (edit ? undefined : item.permission))
const step = number(cfg.steps) ?? item.steps
setId(item.name)
setDesc(string(cfg.description) || item.description || "")
setMode(toMode(string(cfg.mode) || item.mode))
setModel(string(cfg.model) || agentModel(item))
setColor(string(cfg.color) || item.color || "")
setSteps(step ? String(step) : "")
setTools(parts.tools)
setPermission(parts.permission)
setPrompt(string(cfg.prompt) || item.prompt || "")
})
function close() {
setPanel("closed")
}
function guard() {
if (!locked()) return true
ctx.fail("Inspected agents are read-only.")
return false
}
function openMarkdown() {
const payload = build()
if (!payload) return
setDraft(undefined)
setPanel("markdown")
ctx.run("Previewing agent", () => previewAgent(ctx.target(), payload).then(setDraft), { refetch: false })
}
function openModel() {
if (!guard()) return
setPicker("")
setChoice(model())
setPanel("model")
}
function selectModel(item: Item) {
setChoice(item.id)
}
function saveModel() {
if (!guard()) return
const id = choice()
if (!id) {
ctx.fail("Select a model before saving the agent model field.")
return
}
setModel(id)
close()
}
function clearModel() {
if (!guard()) return
setModel("")
}
function openTools() {
if (!guard()) return
setSearch("")
setChosen(tools())
setPanel("tools")
}
function toggleTool(id: string) {
const values = new Set(tools())
if (!guard()) return
const values = new Set(chosen())
if (values.has(id)) values.delete(id)
else values.add(id)
setTools(sorted(values))
setChosen(sorted(values))
}
function saveTools() {
if (!guard()) return
setTools(chosen())
close()
}
function clearTools() {
if (!guard()) return
setTools([])
}
function addPermission() {
if (!guard()) return
const tool = clean(permTool())
if (!tool) {
ctx.fail("Enter an agent permission tool key before adding an override.")
@@ -115,11 +366,17 @@ export function useAgentBuilder() {
}
function insert(value: string) {
if (!guard()) return
const cur = clean(prompt())
setPrompt(cur ? `${cur}\n\n${value}` : value)
}
function build(): AgentPayload | undefined {
if (!ready()) {
ctx.fail("Select an agent before previewing or saving.")
return undefined
}
const name = clean(id())
const body = clean(prompt())
if (!name || !body) {
@@ -141,13 +398,8 @@ export function useAgentBuilder() {
}
}
function preview() {
const payload = build()
if (!payload) return
ctx.run("Previewing agent", () => previewAgent(ctx.target(), payload).then(setDraft), { refetch: false })
}
function save() {
if (!guard()) return
const payload = build()
if (!payload) return
ctx.run("Saving agent", () => saveAgent(ctx.target(), payload).then(setDraft))
@@ -156,9 +408,7 @@ export function useAgentBuilder() {
return {
ctx,
snap,
all,
scope,
setScope: updateScope,
id,
setId,
desc,
@@ -172,7 +422,6 @@ export function useAgentBuilder() {
steps,
setSteps,
tools,
setTools: setToolList,
prompt,
setPrompt,
permTool,
@@ -182,12 +431,32 @@ export function useAgentBuilder() {
permAction,
setPermAction,
draft,
picked,
ready,
locked,
panel,
close,
openMarkdown,
picker,
setPicker,
choice,
models,
selected,
fav,
openModel,
selectModel,
saveModel,
clearModel,
search,
setSearch,
options,
pickedDraft,
openTools,
rules,
toggleTool,
saveTools,
clearTools,
addPermission,
insert,
preview,
save,
}
}
+86 -1
View File
@@ -1,10 +1,49 @@
import type { Provider } from "@kilocode/sdk/v2/client"
import type { Provider, ToolListItem } from "@kilocode/sdk/v2/client"
import type { Snapshot } from "../client"
export type McpMap = NonNullable<Snapshot["effective"]["mcp"]>
export type FormatterMap = Exclude<NonNullable<Snapshot["effective"]["formatter"]>, boolean>
export type LspMap = Exclude<NonNullable<Snapshot["effective"]["lsp"]>, boolean>
export type PermissionMap = Exclude<NonNullable<Snapshot["effective"]["permission"]>, string | null>
export type ToolItem = { id: string; detail: ToolListItem | undefined }
const labels: Record<string, string> = {
apply_patch: "Apply Patch",
bash: "Bash",
edit: "Edit File",
glob: "Find Files",
grep: "Search Content",
lsp: "Language Server",
question: "Ask User",
read: "Read File",
recall: "Local Recall",
skill: "Load Skill",
task: "Subagent Task",
todowrite: "Todo List",
webfetch: "Fetch URL",
websearch: "Web Search",
write: "Write File",
}
const copy: Record<string, string[]> = {
apply_patch: ["Apply structured patches to add, update, move, or delete files.", "Keeps edits explicit and easy to review."],
bash: ["Run shell commands in the current workspace.", "Supports timeouts, working directories, and command descriptions."],
edit: ["Replace exact text inside an existing file.", "Can update one match or all matches when needed."],
glob: ["Find files by path patterns.", "Useful for locating files before reading or editing them."],
grep: ["Search file contents with regular expressions.", "Can limit results to a file pattern such as TypeScript or CSS files."],
lsp: ["Use language-server features for code navigation.", "Supports operations such as definitions, references, and diagnostics."],
question: ["Ask the user for a decision during execution.", "Supports single-choice and multi-choice prompts."],
read: ["Read files, directories, images, and PDFs from the workspace.", "Supports line offsets and limits for large files."],
recall: ["Search and read previous project conversations.", "Useful for recovering prior decisions or implementation context."],
skill: ["Load task-specific instructions and workflows.", "Applies specialized repo guidance when a matching skill exists."],
task: ["Delegate focused exploration or implementation work to a subagent.", "Useful for parallel codebase research and complex subtasks."],
todowrite: ["Track multi-step implementation work.", "Shows progress with pending, in-progress, and completed states."],
webfetch: ["Fetch and convert web pages for analysis.", "Supports markdown, text, and HTML output formats."],
websearch: ["Search the web for external information.", "Supports fast, automatic, and deep search modes."],
write: ["Create or overwrite files with complete contents.", "Best for new files or full-file replacements."],
}
const acronyms = new Set(["api", "html", "id", "json", "lsp", "mcp", "pdf", "url"])
export function clean(input: string) {
return input.trim()
@@ -40,6 +79,52 @@ export function friendly(input: string) {
.join(" ")
}
function word(input: string) {
const lower = input.toLowerCase()
if (acronyms.has(lower)) return lower.toUpperCase()
return input.charAt(0).toUpperCase() + input.slice(1)
}
function clip(input: string) {
if (input.length <= 180) return input
return `${input.slice(0, 177)}...`
}
function summary(input: string) {
const value = input
.replace(/```[\s\S]*?```/g, " ")
.replace(/[*`#>_-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
const parts = value
.split(/(?:\.|\?|!)\s+/)
.map((part) => part.trim())
.filter(Boolean)
.slice(0, 2)
if (parts.length) return parts.map(clip)
return value ? [clip(value)] : []
}
export function toolName(id: string) {
const known = labels[id]
if (known) return known
const value = id
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[._:/-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
if (!value) return id
return value.split(" ").map(word).join(" ")
}
export function toolCapabilities(item: ToolItem) {
const known = copy[item.id]
if (known) return known
const desc = item.detail?.description.trim()
if (desc) return summary(desc)
return ["No capability description is available for this tool."]
}
export function csv(input: string) {
return input
.split(",")
+66 -8
View File
@@ -1046,6 +1046,7 @@ textarea:focus {
.providers,
.models,
.agents,
.tools {
display: grid;
gap: 0.5rem;
@@ -1263,6 +1264,20 @@ textarea:focus {
background: color-mix(in srgb, var(--surface-brand-base) 16%, var(--surface-base));
}
.tool-option {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
}
.tool-option.selected {
border-color: var(--border-focus);
background: color-mix(in srgb, var(--surface-brand-base) 16%, var(--surface-base));
}
.tool-option .tool-capabilities {
text-align: left;
}
.model-option .model-star {
pointer-events: none;
}
@@ -1483,6 +1498,23 @@ select.invalid {
padding-left: 0;
}
.agent-card .agent-tags {
justify-content: flex-start;
}
.agent-section {
display: grid;
gap: 0.5rem;
}
.agent-section h2 {
margin: 0.5rem 0 0;
color: var(--text-weaker);
font-size: var(--font-size-small);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.tool-capabilities {
display: grid;
gap: 0.35rem;
@@ -1559,13 +1591,12 @@ select.invalid {
.builder {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(18rem, 0.85fr);
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.builder-form,
.builder-side,
.builder-block {
display: grid;
gap: 0.75rem;
@@ -1577,6 +1608,35 @@ select.invalid {
gap: 0.75rem;
}
.agent-fields {
gap: 0.75rem;
}
.agent-field-card {
gap: 0.75rem;
}
.agent-field-card textarea {
min-height: 14rem;
}
.agent-field-card input[readonly],
.agent-field-card textarea[readonly],
.builder-block input[readonly] {
border-color: var(--border-weak-base);
background: color-mix(in srgb, var(--surface-base) 72%, transparent);
color: var(--text-weak);
cursor: default;
}
.agent-field-card .tag-cloud {
justify-content: flex-start;
}
.agent-tool-summary {
margin-top: 0.15rem;
}
.builder-grid label,
.prompt-field,
.builder-block label {
@@ -1614,7 +1674,6 @@ select.invalid {
}
.tool-picks,
.builder-actions,
.snippet-list {
display: flex;
gap: 0.5rem;
@@ -1626,12 +1685,7 @@ select.invalid {
white-space: normal;
}
.preview-card {
min-height: 18rem;
}
.markdown-preview {
max-height: 28rem;
margin: 0;
overflow: auto;
border: 1px solid var(--border-weak-base);
@@ -1644,6 +1698,10 @@ select.invalid {
white-space: pre-wrap;
}
.markdown-picker .markdown-preview {
min-height: 100%;
}
.advanced h3 {
margin: 0;
color: var(--text-strong);
@@ -2,6 +2,7 @@ import path from "path"
import { existsSync } from "fs"
import z from "zod"
import { Global } from "@opencode-ai/core/global"
import { ConfigAgent } from "@/config/agent"
import { Config } from "@/config/config"
import { ConfigParse } from "@/config/parse"
import { ConfigVariable } from "@/config/variable"
@@ -107,7 +108,8 @@ export namespace KilocodeConfigOverlay {
}
export async function resolve(input: Input): Promise<Result> {
const local = await project(input)
const local = await withAgents(await project(input), await projectDirs(input))
const global = await withAgents(input.global, globalDirs())
const targets = {
global: globalTarget(),
project: await projectTarget(input),
@@ -116,18 +118,18 @@ export namespace KilocodeConfigOverlay {
return {
scope: input.scope,
effective: input.effective,
global: input.global,
global,
project: local,
sources: input.sources,
targets,
fields: Object.fromEntries(
fieldPaths.map((parts) => [
parts.join("."),
field(input.scope, input.effective, input.global, local, [...parts]),
field(input.scope, input.effective, global, local, [...parts]),
]),
),
collections: Object.fromEntries(
collectionPaths.map((key) => [key, collection(input.scope, input.effective, input.global, local, key)]),
collectionPaths.map((key) => [key, collection(input.scope, input.effective, global, local, key)]),
),
}
}
@@ -148,6 +150,29 @@ export namespace KilocodeConfigOverlay {
return [...new Set(checks.filter((item) => item.exists).map((item) => item.file))]
}
async function projectDirs(input: { directory: string; worktree?: string }) {
return Filesystem.findUp([...dirs], input.directory, input.worktree)
}
function globalDirs() {
return [
Global.Path.config,
path.join(Global.Path.home, ".kilocode"),
path.join(Global.Path.home, ".kilo"),
path.join(Global.Path.home, ".opencode"),
]
}
async function withAgents(input: Config.Info, dirs: string[]): Promise<Config.Info> {
const [dir, ...rest] = dirs
if (!dir) return input
if (!existsSync(dir)) return withAgents(input, rest)
const agent = await ConfigAgent.load(dir)
const mode = await ConfigAgent.loadMode(dir)
const next = KilocodeConfig.mergeConfig(KilocodeConfig.mergeConfig(input, { agent }), { agent: mode })
return withAgents(next, rest)
}
async function load(file: string): Promise<Config.Info> {
const text = await Bun.file(file).text()
const expanded = await ConfigVariable.substitute({ text, type: "path", path: file })