mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
refactor: agent builder
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(",")
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user