From 261edcde55a3fcee8fb94a51f4698a8013b0bd8a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 7 Sep 2026 10:03:58 +0200 Subject: [PATCH] fix(cli): show shared agent board messages --- .changeset/cli-board-messages.md | 5 + packages/tui/src/kilocode/board-tool.tsx | 114 +++++++++++ packages/tui/src/routes/session/index.tsx | 7 + .../tui/test/kilocode/board-tool.test.tsx | 179 ++++++++++++++++++ 4 files changed, 305 insertions(+) create mode 100644 .changeset/cli-board-messages.md create mode 100644 packages/tui/src/kilocode/board-tool.tsx create mode 100644 packages/tui/test/kilocode/board-tool.test.tsx diff --git a/.changeset/cli-board-messages.md b/.changeset/cli-board-messages.md new file mode 100644 index 000000000000..d28bc87cfa29 --- /dev/null +++ b/.changeset/cli-board-messages.md @@ -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. diff --git a/packages/tui/src/kilocode/board-tool.tsx b/packages/tui/src/kilocode/board-tool.tsx new file mode 100644 index 000000000000..ba7b9c975c86 --- /dev/null +++ b/packages/tui/src/kilocode/board-tool.tsx @@ -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 & { 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) { + 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 ( + + {(body) => {body()}}}> + {(items) => ( + + No messages on the board.}> + + {(item) => ( + + + {route(item)} + + + + )} + + + + Stored only. Delivery and reading are not confirmed. + + {(warning) => {warning()}} + + More messages are available. + + + )} + + + ) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index f75f1f1508cb..088e52afcc35 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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" @@ -1960,6 +1961,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess {/* kilocode_change start - preserve Kilo tool-specific status rendering */} + + + @@ -2325,6 +2329,7 @@ export function InlineToolRow(props: { ) } +export { BlockTool } // kilocode_change function BlockTool(props: { title?: string children: JSX.Element @@ -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", diff --git a/packages/tui/test/kilocode/board-tool.test.tsx b/packages/tui/test/kilocode/board-tool.test.tsx new file mode 100644 index 000000000000..39914957bef2 --- /dev/null +++ b/packages/tui/test/kilocode/board-tool.test.tsx @@ -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 = {}): 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 +} + +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() + const app = await testRender( + () => ( + + + + ({}) }}> + ready.resolve()} /> + + + + + ), + { 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.") +})