From bdfa61a8b2fee99dbac371262cc72167f836f607 Mon Sep 17 00:00:00 2001 From: Raphael Westphal Date: Thu, 20 Aug 2026 14:55:55 +0200 Subject: [PATCH] =?UTF-8?q?web:=20pretty=20tool=20cards=20=E2=80=94=20args?= =?UTF-8?q?=20rendered=20as=20the=20real=20command/path=20(priority=20keys?= =?UTF-8?q?,=20label-less=20single=20field),=20output=20label;=20276=20tes?= =?UTF-8?q?ts=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/bridge/index.mjs | 8 +++-- web/src/ChatStream.test.tsx | 44 ++++++++++++++++++++++++++ web/src/ChatStream.tsx | 62 +++++++++++++++++++++++++++++++++---- web/src/index.css | 22 +++++++++++++ 4 files changed, 128 insertions(+), 8 deletions(-) diff --git a/docker/bridge/index.mjs b/docker/bridge/index.mjs index c238b81..4230f97 100644 --- a/docker/bridge/index.mjs +++ b/docker/bridge/index.mjs @@ -74,7 +74,9 @@ try { const models = await runtime.getAvailable(provider); const found = models.find((m) => m.id === modelId); if (found === undefined) { - console.error(`[lvmh-bridge] LVMH_MODEL ${requested} not found; using default`); + console.error( + `[lvmh-bridge] LVMH_MODEL ${requested} not found; using default`, + ); } else { model = found; console.error(`[lvmh-bridge] initial model: ${requested}`); @@ -83,7 +85,9 @@ try { console.error(`[lvmh-bridge] model resolution failed:`, err); } } - const { session } = await createAgentSession(model === undefined ? {} : { model }); + const { session } = await createAgentSession( + model === undefined ? {} : { model }, + ); // SDK does not bind extensions implicitly (unlike TUI/RPC modes); without // bindExtensions the session_start event never fires, so the lvmh plugin // would never dial the daemon. diff --git a/web/src/ChatStream.test.tsx b/web/src/ChatStream.test.tsx index 380772f..49aa621 100644 --- a/web/src/ChatStream.test.tsx +++ b/web/src/ChatStream.test.tsx @@ -6,6 +6,7 @@ import ChatStream, { Bubble, TypingIndicator, renderMarkdown, + toolArgsEntries, } from "./ChatStream"; function msg(partial: Partial): ChatMessage { @@ -761,3 +762,46 @@ describe("copy button", () => { expect(screen.queryByRole("button", { name: "Copy message" })).toBeNull(); }); }); + +describe("pretty tool args", () => { + const tool = (args: string): ToolState => ({ + id: "t1", name: "bash", args, running: false, isError: false, preview: "out", + }); + + it("bash command renders as the bare command, no JSON braces", () => { + render(); + expect(screen.getByText("ls -la")).toBeInTheDocument(); + expect(document.querySelector(".tool-args")?.textContent).not.toContain('"command"'); + }); + + it("priority ordering puts command first even when not first in JSON", () => { + const entries = toolArgsEntries('{"path":"a.go","command":"go build ./..."}'); + expect(entries[0]?.v).toBe("go build ./..."); + expect(entries[1]?.k).toBe("path"); + }); + + it("single string arg renders label-less; multi-field keeps labels", () => { + render(); + expect(screen.getByText("src/main.ts")).toBeInTheDocument(); + expect(document.querySelector(".tool-args .arg-k")).toBeNull(); + }); + + it("non-JSON args pass through raw", () => { + const entries = toolArgsEntries("just text"); + expect(entries).toEqual([{ k: null, v: "just text" }]); + }); + + it("JSON string arg unwraps", () => { + const entries = toolArgsEntries('"plain string"'); + expect(entries).toEqual([{ k: null, v: "plain string" }]); + }); + + it("non-string values are pretty JSON", () => { + const entries = toolArgsEntries('{"offset":1}'); + expect(entries[0]?.v).toBe("1"); + }); +}); diff --git a/web/src/ChatStream.tsx b/web/src/ChatStream.tsx index 6bee09c..735a1a3 100644 --- a/web/src/ChatStream.tsx +++ b/web/src/ChatStream.tsx @@ -162,6 +162,54 @@ function CopyButton({ text }: { text: string }): React.ReactNode { ); } + +// ---------- pretty tool args ---------- + +// Keys shown first when present (most readable "what is this tool doing" +// signal); the rest follow in their original order. +const ARG_PRIORITY: string[] = [ + "command", "path", "file_path", "pattern", "url", "query", "content", + "prompt", "task", "description", +]; + +export interface ArgEntry { + k: string | null; + v: string; +} + +/** Parse a tool's args (JSON object, JSON string, or raw text) into labeled + * display entries. A lone string entry renders without a label. */ +export function toolArgsEntries(argsText: string): ArgEntry[] { + let parsed: unknown; + try { + parsed = JSON.parse(argsText); + } catch { + return [{ k: null, v: argsText }]; + } + if (typeof parsed === "string") + return [{ k: null, v: parsed }]; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) + return [{ k: null, v: argsText }]; + const obj = parsed as Record; + const keys = Object.keys(obj); + const ordered = [ + ...ARG_PRIORITY.filter((k) => k in obj), + ...keys.filter((k) => !ARG_PRIORITY.includes(k)), + ]; + const entries: ArgEntry[] = []; + for (const k of ordered) { + const raw = obj[k]; + const v = + typeof raw === "string" + ? raw + : JSON.stringify(raw, null, 2) ?? String(raw); + entries.push({ k, v }); + } + if (entries.length === 1 && entries[0] !== undefined) + return [{ k: null, v: entries[0].v }]; + return entries; +} + function ToolCard({ tool }: { tool: ToolState }) { const status: string = tool.running ? "running…" @@ -180,14 +228,16 @@ function ToolCard({ tool }: { tool: ToolState }) {
-
- args -
-						{tool.args}
-					
+
+ {toolArgsEntries(tool.args).map((e, i) => ( +
+ {e.k !== null && {e.k}} +
{e.v}
+
+ ))}
- result + output
 						{tool.preview}
 					
diff --git a/web/src/index.css b/web/src/index.css index 49cb2d5..e597235 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1446,3 +1446,25 @@ mark.hit { 0%, 100% { transform: scale(0.55); opacity: 0.45; box-shadow: 0 0 0 0 rgba(108, 140, 255, 0.5); } 50% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 5px rgba(108, 140, 255, 0); } } + +/* ---------- pretty tool args ---------- */ + +.tool-args { margin-bottom: 10px; } +.tool-arg { margin-bottom: 6px; } +.arg-k { + display: block; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-faint); + margin-bottom: 2px; +} +.arg-v { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--mono); + font-size: 12.5px; + color: var(--text); +}