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/cli-board-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Show shared agent board messages in the CLI with sender and recipient names, message bodies, and storage status.
114 changes: 114 additions & 0 deletions packages/tui/src/kilocode/board-tool.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { ToolPart } from "@kilocode/sdk/v2"
import type { JSX } from "@opentui/solid"
import { createMemo, For, Show, type Component } from "solid-js"
import { useTheme } from "../context/theme"
import { formatMarkdownTables } from "../util/markdown"
import { isRecord } from "../util/record"

function text(value: unknown) {
return typeof value === "string" ? value : undefined
}

function parse(output?: string) {
if (!output) return undefined
try {
const value: unknown = JSON.parse(output)
return isRecord(value) ? value : undefined
} catch {
return undefined
}
}

function message(value: unknown): value is Record<string, unknown> & { body: string; from: string; to: string } {
return (
isRecord(value) && typeof value.body === "string" && typeof value.from === "string" && typeof value.to === "string"
)
}

function label(id: unknown, value: unknown) {
if (id === "ALL") return "All agents"
const title = text(value)?.trim()
if (title) return title
if (id === "main") return "Primary agent"
const key = text(id)
return key ? `Agent · ${key.slice(-8)}` : "Agent"
}

function route(value: Record<string, unknown>) {
const kind = text(value.type)
return `${kind ? `${kind} · ` : ""}${label(value.from, value.fromLabel)} → ${label(value.to, value.toLabel)}`
}

export function BoardTool(props: {
part: ToolPart
conceal?: boolean
block: Component<{ title: string; part: ToolPart; spinner: boolean; children: JSX.Element }>
}) {
const { theme, syntax } = useTheme()
const post = () => props.part.tool === "board_post"
const output = () => (props.part.state.status === "completed" ? props.part.state.output : undefined)
const metadata = () => (props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {}))
const result = createMemo(() => parse(output()))
const rows = createMemo(() => {
const data = result()
if (!data) return undefined
const items = post() ? [data] : data.messages
if (!Array.isArray(items)) return undefined
return items.every(message) ? items : undefined
})
const title = createMemo(() => {
if (!post()) {
const count = rows()?.length
return `# Shared agent board${count === undefined ? "" : ` (${count} message${count === 1 ? "" : "s"})`}`
}
const data = result()
const meta = metadata()
return `# ${route({
from: meta.from ?? data?.from,
to: meta.to ?? data?.to ?? props.part.state.input.to,
fromLabel: meta.fromLabel ?? data?.fromLabel,
toLabel: meta.toLabel ?? data?.toLabel,
type: meta.type ?? data?.type ?? props.part.state.input.type,
})}`
})
const fallback = () => output() || (post() ? text(props.part.state.input.body) : undefined)

return (
<props.block
title={title()}
part={props.part}
spinner={props.part.state.status === "pending" || props.part.state.status === "running"}
>
<Show when={rows()} fallback={<Show when={fallback()}>{(body) => <text fg={theme.text}>{body()}</text>}</Show>}>
{(items) => (
<box gap={1}>
<Show when={items().length} fallback={<text fg={theme.textMuted}>No messages on the board.</text>}>
<For each={items()}>
{(item) => (
<box gap={1}>
<Show when={!post()}>
<text fg={theme.textMuted}>{route(item)}</text>
</Show>
<markdown
syntaxStyle={syntax()}
content={formatMarkdownTables(item.body)}
conceal={props.conceal}
fg={theme.markdownText}
/>
</box>
)}
</For>
</Show>
<Show when={post() && props.part.state.status === "completed"}>
<text fg={theme.textMuted}>Stored only. Delivery and reading are not confirmed.</text>
</Show>
<Show when={text(result()?.warning)}>{(warning) => <text fg={theme.warning}>{warning()}</text>}</Show>
<Show when={result()?.hasMore === true}>
<text fg={theme.textMuted}>More messages are available.</text>
</Show>
</box>
)}
</Show>
</props.block>
)
}
7 changes: 7 additions & 0 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { useKV } from "../../context/kv.tsx"
import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { ApprovalBadge, describeApproval, stateMetadata } from "../../kilocode/tool-approval" // kilocode_change
import { BoardTool } from "../../kilocode/board-tool" // kilocode_change
import { useEpilogue } from "../../context/epilogue"
import { normalizePath } from "../../util/path"
import { PermissionPrompt } from "./permission"
Expand Down Expand Up @@ -1960,6 +1961,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Grep {...toolprops} />
</Match>
{/* kilocode_change start - preserve Kilo tool-specific status rendering */}
<Match when={display() === "board_post" || display() === "board_read"}>
<BoardTool part={props.part} block={BlockTool} conceal={ctx.conceal()} />
</Match>
<Match when={display() === "background_process"}>
<BackgroundProcess {...toolprops} />
</Match>
Expand Down Expand Up @@ -2325,6 +2329,7 @@ export function InlineToolRow(props: {
)
}

export { BlockTool } // kilocode_change
function BlockTool(props: {
title?: string
children: JSX.Element
Expand Down Expand Up @@ -3053,6 +3058,8 @@ const toolDisplays = new Set([
"question",
"skill",
// kilocode_change start - retain dedicated Kilo tool renderers
"board_post",
"board_read",
"execute",
"background_process",
"interactive_terminal",
Expand Down
179 changes: 179 additions & 0 deletions packages/tui/test/kilocode/board-tool.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { expect, test } from "bun:test"
import type { ToolPart } from "@kilocode/sdk/v2"
import { testRender } from "@opentui/solid"
import { CodeRenderable, type Renderable } from "@opentui/core"
import { onMount } from "solid-js"
import { TuiConfigProvider } from "../../src/config"
import { KVProvider } from "../../src/context/kv"
import { ThemeProvider } from "../../src/context/theme"
import { BoardTool } from "../../src/kilocode/board-tool"
import { BlockTool, toolDisplay } from "../../src/routes/session"
import { tmpdir } from "../fixture/fixture"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"

const message = {
from: "main",
to: "ses_helper12345678",
fromLabel: "Release investigation",
toLabel: "Inspect publish timeout",
type: "INFO",
body: "The **larger package** succeeded.\n\nCheck the timeout setting next.",
}

function part(tool: string, output: unknown, metadata: Record<string, unknown> = {}): ToolPart {
return {
id: "prt_board",
sessionID: "ses_board",
messageID: "msg_board",
callID: "call_board",
type: "tool",
tool,
state: {
status: "completed",
input: { to: message.to, type: message.type, body: message.body },
output: typeof output === "string" ? output : JSON.stringify(output),
title: "Shared agent board",
metadata,
time: { start: 1, end: 2 },
},
}
}

function Fixture(props: { part: ToolPart; ready: () => void }) {
onMount(props.ready)
return <BoardTool part={props.part} block={BlockTool} conceal />
}

async function highlight(node: Renderable) {
if (node instanceof CodeRenderable) await node.highlightingDone
await Promise.all(node.getChildren().map(highlight))
}

async function render(part: ToolPart, width = 100) {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
const config = createTuiResolvedConfig()
const ready = Promise.withResolvers<void>()
const app = await testRender(
() => (
<TestTuiContexts paths={{ state: tmp.path }}>
<KVProvider>
<TuiConfigProvider config={config}>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
<Fixture part={part} ready={() => ready.resolve()} />
</ThemeProvider>
</TuiConfigProvider>
</KVProvider>
</TestTuiContexts>
),
{ width, height: 40 },
)
try {
await ready.promise
await app.flush()
await highlight(app.renderer.root)
await app.flush()
return app
.captureCharFrame()
.split("\n")
.map((line) => line.replace(/^┃/, "").trim())
.join(" ")
.replace(/\s+/g, " ")
.trim()
} finally {
app.renderer.destroy()
}
}

test("board tools use dedicated renderers", () => {
expect(toolDisplay("board_post")).toBe("board_post")
expect(toolDisplay("board_read")).toBe("board_read")
expect(toolDisplay("plugin_tool")).toBe("generic")
})

test("posts show labelled routes, markdown bodies, receipts, and approval notes", async () => {
const frame = await render(
part("board_post", message, {
approval: { source: "agent", agent: "code", rule: { permission: "board_post", pattern: "*", action: "allow" } },
}),
)
expect(frame).toContain("INFO · Release investigation → Inspect publish timeout")
expect(frame).toContain("The larger package succeeded.")
expect(frame).toContain("Check the timeout setting next.")
expect(frame).toContain("Stored only. Delivery and reading are not confirmed.")
expect(frame).toContain("auto-approved by the code agent")
expect(frame).not.toContain('"body":')
})

test("post headers prefer metadata labels and preserve availability warnings", async () => {
const warning = "No other recipients were active at this post attempt."
const frame = await render(
part("board_post", { ...message, warning }, { fromLabel: "Coordinator", toLabel: "Worker" }),
)
expect(frame).toContain("Coordinator → Worker")
expect(frame).toContain(warning)
})

test("reads show all message routes, broadcasts, and pagination without a post receipt", async () => {
const frame = await render(
part("board_read", {
messages: [message, { from: message.to, to: "ALL", type: "RESULT", body: "Timeout confirmed." }],
hasMore: true,
}),
)
expect(frame).toContain("Shared agent board (2 messages)")
expect(frame).toContain("Release investigation → Inspect publish timeout")
expect(frame).toContain("RESULT · Agent · 12345678 → All agents")
expect(frame).toContain("Timeout confirmed.")
expect(frame).toContain("More messages are available.")
expect(frame).not.toContain("Stored only")
})

test("empty reads show an explicit empty state", async () => {
const frame = await render(part("board_read", { messages: [] }))
expect(frame).toContain("Shared agent board (0 messages)")
expect(frame).toContain("No messages on the board.")
})

test.each(["unparsed board output", { messages: [{ body: "Incomplete message" }] }])(
"malformed responses preserve the output: %j",
async (output) => {
const frame = await render(part("board_read", output))
expect(frame).toContain(typeof output === "string" ? output : JSON.stringify(output))
expect(frame).not.toContain("No messages on the board")
expect(frame).not.toContain("Stored only")
},
)

test("failed posts retain the body and error without claiming storage", async () => {
const value = part("board_post", message)
value.state = { status: "error", input: value.state.input, error: "Permission denied", time: { start: 1, end: 2 } }
const frame = await render(value)
expect(frame).toContain("Check the timeout setting next.")
expect(frame).toContain("Permission denied")
expect(frame).not.toContain("Stored only")
})

test("pending posts show their input without claiming storage", async () => {
const value = part("board_post", message)
value.state = { status: "pending", input: value.state.input, raw: "" }
const frame = await render(value)
expect(frame).toContain("Check the timeout setting next.")
expect(frame).not.toContain("Stored only")
})

test("message bodies wrap in narrow terminals", async () => {
const frame = await render(
part("board_post", {
from: "main",
to: "ALL",
type: "INFO",
body: "A message that must wrap without losing any words at the end.",
}),
40,
)
expect(frame).toContain("Primary agent → All agents")
expect(frame).toContain("A message that must wrap without losing any words at the end.")
expect(frame).toContain("Stored only. Delivery and reading are not confirmed.")
})
Loading