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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/subagent-avatars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Identify subagents with consistent theme-colored avatars in Task cards, background agents, subagent tabs, and swarm messages. Animate running avatars instead of showing a separate loading indicator.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/kilo-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"./tabs": "./src/components/tabs.tsx",
"./card": "./src/components/card.tsx",
"./avatar": "./src/components/avatar.tsx",
"./agent-avatar": "./src/components/agent-avatar.tsx",
"./logo": "./src/components/logo.tsx",
"./favicon": "./src/components/favicon.tsx",
"./file-icon": "./src/components/file-icon.tsx",
Expand Down
61 changes: 61 additions & 0 deletions packages/kilo-ui/src/components/agent-avatar-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test"
import { COLORS, identity, palette } from "./agent-avatar-identity"

describe("agent avatar identity", () => {
test("uses the same identity for the same participant", () => {
expect(identity("ses_agent-one")).toEqual(identity("ses_agent-one"))
expect(identity("ses_agent-one").cells).not.toEqual(identity("ses_agent-two").cells)
})

test("keeps unknown participants neutral", () => {
expect(identity("").color).toBeUndefined()
expect(identity("unknown")).toEqual(identity(""))
expect(identity(" ")).toEqual(identity(""))
expect(identity("main").color).toBeNumber()
})

test("produces visible symmetric patterns within the avatar", () => {
for (const id of ["main", "ses_agent-one", "ses_agent-two", "participant", ""]) {
const avatar = identity(id)
expect(avatar.cells.length).toBeGreaterThan(0)
for (const cell of avatar.cells) {
expect(cell).toBeGreaterThanOrEqual(0)
expect(cell).toBeLessThan(25)
expect([0, 4, 20, 24]).not.toContain(cell)
expect(avatar.cells).toContain(Math.floor(cell / 5) * 5 + 4 - (cell % 5))
}
}
})

test("gives the first siblings distinct colors and keeps earlier assignments stable", () => {
const ids = Array.from({ length: COLORS + 3 }, (_, index) => `ses_sibling_${index}`)
const colors = palette(ids)
expect(new Set(ids.slice(0, COLORS).map((id) => colors.get(id))).size).toBe(COLORS)
for (const id of ids) expect(colors.get(id)).toBeNumber()
// Adding a later sibling never changes the color of an earlier one.
const fewer = palette(ids.slice(0, 5))
for (const id of ids.slice(0, 5)) expect(fewer.get(id)).toBe(colors.get(id))
expect(palette(["", "unknown", "ses_x"]).get("")).toBeUndefined()
})

test("draws one connected glyph with a bounded size", () => {
for (let index = 0; index < 200; index++) {
const cells = identity(`ses_${index}`).cells
const lit = new Set(cells)
expect(cells.length).toBeGreaterThanOrEqual(7)
expect(cells.length).toBeLessThanOrEqual(18)
const seen = new Set([cells[0]])
const queue = [cells[0]]
while (queue.length > 0) {
const cell = queue.pop()!
const near = [cell - 5, cell + 5, cell % 5 > 0 ? cell - 1 : -1, cell % 5 < 4 ? cell + 1 : -1]
for (const next of near) {
if (!lit.has(next) || seen.has(next)) continue
seen.add(next)
queue.push(next)
}
}
expect(seen.size).toBe(cells.length)
}
})
})
77 changes: 77 additions & 0 deletions packages/kilo-ui/src/components/agent-avatar-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
export const COLORS = 8
const HALF = 15

/**
* Assign colors to sibling agents in spawn order. Each agent keeps its hashed
* color when it is still free; otherwise it takes the next free hue, so the
* first eight siblings never share a color. After that, colors repeat and the
* glyph shape is what tells agents apart.
*/
export function palette(ids: string[]) {
const used = new Set<number>()
const result = new Map<string, number>()
for (const id of ids) {
if (result.has(id)) continue
const color = identity(id).color
if (color == null) continue
if (used.size >= COLORS) used.clear()
const pick = Array.from({ length: COLORS }, (_, index) => (color + index) % COLORS).find((hue) => !used.has(hue))
if (pick == null) continue
used.add(pick)
result.set(id, pick)
}
return result
}
const MIN = 6
const MAX = 9

function fnv(input: string, seed: number) {
let hash = seed
for (let index = 0; index < input.length; index++) {
hash = Math.imul(hash ^ input.charCodeAt(index), 16777619) >>> 0
}
return hash
}

// Mirror three columns into a five-column grid, like the loading spinner grid.
// The half-cell index is row * 3 + min(column, 4 - column).
function expand(half: (index: number) => boolean) {
return Array.from({ length: 25 }, (_, index) => index).filter((index) => {
const x = index % 5
return half(Math.floor(index / 5) * 3 + Math.min(x, 4 - x))
})
}

export function identity(id: string) {
const known = id.trim() !== "" && id !== "unknown"
if (!known) {
const neutral = new Set([4, 7, 10, 13])
return { color: undefined, cells: expand((index) => neutral.has(index)) }
}
const shape = fnv(id, 2166136261)
const tone = fnv(id, 0x9747b28c)
// Grow one connected shape from a center-column seed so the glyph reads as a
// single figure instead of scattered dots. The bounded count avoids
// near-empty and near-full blobs that look alike.
const count = MIN + (tone % (MAX - MIN + 1))
const rank = (index: number) => Math.imul(shape ^ (index + 1), 0x27d4eb2d) >>> 0
const lit = new Set([2 + (shape % 5) * 3])
while (lit.size < count) {
const edge = Array.from({ length: HALF }, (_, index) => index).filter((index) => {
if (lit.has(index)) return false
// Half-cells 0 and 12 are the grid corners, which the round avatar does not draw.
if (index === 0 || index === 12) return false
const row = Math.floor(index / 3)
const col = index % 3
return (
(col > 0 && lit.has(index - 1)) ||
(col < 2 && lit.has(index + 1)) ||
(row > 0 && lit.has(index - 3)) ||
(row < 4 && lit.has(index + 3))
)
})
const next = edge.reduce((best, index) => (rank(index) > rank(best) ? index : best))
lit.add(next)
}
return { color: (tone >>> 4) % COLORS, cells: expand((index) => lit.has(index)) }
}
87 changes: 87 additions & 0 deletions packages/kilo-ui/src/components/agent-avatar.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
[data-component="agent-avatar"] {
--agent-avatar-a: var(--icon-weak-base);
--agent-avatar-b: var(--icon-weak-base);
display: inline-block;
flex: 0 0 18px;
width: 18px;
height: 18px;
vertical-align: middle;
color: color-mix(in oklch, var(--agent-avatar-a), var(--agent-avatar-b));
fill: currentColor;

/* Unlit dots stay faintly visible, like the spinner's outer ring, but low
enough that the lit glyph stays readable. */
circle {
opacity: 0.1;
}

circle[data-lit] {
opacity: 1;
}

/* While running, the glyph stays solid and the faint frame dots shimmer
around it, so the symbol remains recognizable. */
&[data-status="running"] circle:not([data-lit]) {
animation: agent-avatar-pulse 1.4s ease-in-out infinite both;
}

/* Eight well-separated hues: the six VS Code chart colors plus teal and pink,
ordered so neighbors in the palette are far apart in hue. Siblings take
colors in this order, so the first eight agents never share one. */
&[data-color="0"] {
--agent-avatar-a: var(--vscode-charts-blue, var(--icon-info-base));
--agent-avatar-b: var(--agent-avatar-a);
}

&[data-color="1"] {
--agent-avatar-a: var(--vscode-charts-orange, var(--icon-warning-base));
--agent-avatar-b: var(--agent-avatar-a);
}

&[data-color="2"] {
--agent-avatar-a: var(--vscode-charts-green, var(--icon-success-base));
--agent-avatar-b: var(--agent-avatar-a);
}

&[data-color="3"] {
--agent-avatar-a: var(--vscode-charts-purple, var(--icon-info-base));
--agent-avatar-b: var(--agent-avatar-a);
}

&[data-color="4"] {
--agent-avatar-a: var(--vscode-charts-yellow, var(--icon-warning-base));
--agent-avatar-b: var(--agent-avatar-a);
}

&[data-color="5"] {
--agent-avatar-a: var(--vscode-charts-green, var(--icon-success-base));
--agent-avatar-b: var(--vscode-charts-blue, var(--icon-info-base));
}

&[data-color="6"] {
--agent-avatar-a: var(--vscode-charts-red, var(--icon-critical-base));
--agent-avatar-b: var(--agent-avatar-a);
}

&[data-color="7"] {
--agent-avatar-a: var(--vscode-charts-purple, var(--icon-info-base));
--agent-avatar-b: var(--vscode-charts-red, var(--icon-critical-base));
}
}

@keyframes agent-avatar-pulse {
0%,
100% {
opacity: 0.1;
}
50% {
opacity: 0.45;
}
}

@media (prefers-reduced-motion: reduce) {
[data-component="agent-avatar"][data-status="running"] circle:not([data-lit]) {
animation: none;
opacity: 0.25;
}
}
56 changes: 56 additions & 0 deletions packages/kilo-ui/src/components/agent-avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createContext, createMemo, For, useContext, type Accessor, type JSX } from "solid-js"
import { identity, palette } from "./agent-avatar-identity"

export type AgentAvatarStatus = "running"

// Map a tool part status to the only avatar state that changes its rendering.
// Finished, errored, cancelled, and waiting children all keep the static glyph.
export function taskStatus(status: string | undefined): AgentAvatarStatus | undefined {
return status === "pending" || status === "running" ? "running" : undefined
}

// Sibling-aware colors. Surfaces that know the child list of one parent session
// provide it here, so the same agent gets the same color in every surface and
// siblings avoid sharing a color until the palette runs out.
const Palette = createContext<Accessor<Map<string, number>>>()

export function AgentAvatarPalette(props: { ids: string[]; children: JSX.Element }) {
const parent = useContext(Palette)
const value = createMemo(() => palette(props.ids))
// The outermost provider wins so nested transcripts keep the parent's colors.
return <Palette.Provider value={parent ?? value}>{props.children}</Palette.Provider>
}

// Corner cells are dropped so the dot grid reads as a circle.
const GRID = Array.from({ length: 25 }, (_, index) => index).filter((index) => ![0, 4, 20, 24].includes(index))

// Same cell geometry as the loading spinner, drawn as round dots on a 1px gap grid.
export function AgentAvatar(props: { id: string; status?: AgentAvatarStatus }) {
const shared = useContext(Palette)
const avatar = createMemo(() => identity(props.id))
const color = createMemo(() => shared?.().get(props.id) ?? avatar().color)
const lit = createMemo(() => new Set(avatar().cells))
return (
<svg
data-component="agent-avatar"
data-color={color()}
data-status={props.status}
width="18"
height="18"
viewBox="0 0 19 19"
aria-hidden="true"
>
<For each={GRID}>
{(cell) => (
<circle
data-lit={lit().has(cell) || undefined}
cx={(cell % 5) * 4 + 1.5}
cy={Math.floor(cell / 5) * 4 + 1.5}
r="1.5"
style={{ "animation-delay": `${-(((cell * 7) % 11) / 11) * 1.4}s` }}
/>
)}
</For>
</svg>
)
}
13 changes: 13 additions & 0 deletions packages/kilo-ui/src/components/basic-tool.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
flex: 1 1 auto;
min-width: 0;
overflow: hidden;

/* Variant B status badges intentionally extend into the existing gap. */
&:has([data-component="agent-avatar"]) {
overflow: visible;
}
}

[data-slot="basic-tool-tool-info"] {
Expand Down Expand Up @@ -36,6 +41,14 @@

[data-slot="basic-tool-icon"] {
display: none;

&:has([data-component="agent-avatar"]) {
display: inline-flex;
flex: 0 0 18px;
width: 18px;
height: 18px;
overflow: visible;
}
}

[data-slot="basic-tool-tool-subtitle"] {
Expand Down
12 changes: 11 additions & 1 deletion packages/kilo-ui/src/components/board-message.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@
[data-slot="board-route-recipient-icon"] {
display: inline-flex;
}

/* Parent session marker: the spinner grid, static, in the avatar slot size. */
.board-route-parent {
width: 18px;
color: var(--text-weak);

> rect {
animation: none !important;
}
}
}

[data-component="board-messages"] {
Expand Down Expand Up @@ -66,7 +76,7 @@
@container (max-width: 260px) {
[data-component="board-route"] {
display: grid;
grid-template-columns: 16px auto minmax(0, 1fr);
grid-template-columns: 18px auto minmax(0, 1fr);
gap: 4px 6px;

.board-route-member {
Expand Down
Loading
Loading