web: pretty tool cards — args rendered as the real command/path (priority keys, label-less single field), output label; 276 tests green

This commit is contained in:
Raphael Westphal
2026-08-20 14:55:55 +02:00
parent 2d0839357d
commit bdfa61a8b2
4 changed files with 128 additions and 8 deletions
+6 -2
View File
@@ -74,7 +74,9 @@ try {
const models = await runtime.getAvailable(provider); const models = await runtime.getAvailable(provider);
const found = models.find((m) => m.id === modelId); const found = models.find((m) => m.id === modelId);
if (found === undefined) { 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 { } else {
model = found; model = found;
console.error(`[lvmh-bridge] initial model: ${requested}`); console.error(`[lvmh-bridge] initial model: ${requested}`);
@@ -83,7 +85,9 @@ try {
console.error(`[lvmh-bridge] model resolution failed:`, err); 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 // SDK does not bind extensions implicitly (unlike TUI/RPC modes); without
// bindExtensions the session_start event never fires, so the lvmh plugin // bindExtensions the session_start event never fires, so the lvmh plugin
// would never dial the daemon. // would never dial the daemon.
+44
View File
@@ -6,6 +6,7 @@ import ChatStream, {
Bubble, Bubble,
TypingIndicator, TypingIndicator,
renderMarkdown, renderMarkdown,
toolArgsEntries,
} from "./ChatStream"; } from "./ChatStream";
function msg(partial: Partial<ChatMessage>): ChatMessage { function msg(partial: Partial<ChatMessage>): ChatMessage {
@@ -761,3 +762,46 @@ describe("copy button", () => {
expect(screen.queryByRole("button", { name: "Copy message" })).toBeNull(); 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(<Bubble msg={{ key: "k", role: "assistant", text: "", thinking: null,
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }], toolCallId: null, streaming: false, ts: 0 }}
tools={new Map([["t1", tool('{"command":"ls -la","foo":1}')]])} />);
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(<Bubble msg={{ key: "k", role: "assistant", text: "", thinking: null,
toolCalls: [{ id: "t1", name: "read", argsJson: "{}" }], toolCallId: null, streaming: false, ts: 0 }}
tools={new Map([["t1", tool('{"path":"src/main.ts"}')]])} />);
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");
});
});
+56 -6
View File
@@ -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<string, unknown>;
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 }) { function ToolCard({ tool }: { tool: ToolState }) {
const status: string = tool.running const status: string = tool.running
? "running…" ? "running…"
@@ -180,14 +228,16 @@ function ToolCard({ tool }: { tool: ToolState }) {
</span> </span>
</summary> </summary>
<div className="tool-body"> <div className="tool-body">
<div> <div className="tool-args">
<strong>args</strong> {toolArgsEntries(tool.args).map((e, i) => (
<pre style={{ margin: "4px 0 10px", whiteSpace: "pre-wrap" }}> <div key={e.k ?? i} className="tool-arg">
{tool.args} {e.k !== null && <span className="arg-k">{e.k}</span>}
</pre> <pre className="arg-v">{e.v}</pre>
</div>
))}
</div> </div>
<div> <div>
<strong>result</strong> <span className="arg-k">output</span>
<pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}> <pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}>
{tool.preview} {tool.preview}
</pre> </pre>
+22
View File
@@ -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); } 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); } 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);
}