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:
@@ -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.
|
||||
|
||||
@@ -6,6 +6,7 @@ import ChatStream, {
|
||||
Bubble,
|
||||
TypingIndicator,
|
||||
renderMarkdown,
|
||||
toolArgsEntries,
|
||||
} from "./ChatStream";
|
||||
|
||||
function msg(partial: Partial<ChatMessage>): 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(<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
@@ -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 }) {
|
||||
const status: string = tool.running
|
||||
? "running…"
|
||||
@@ -180,14 +228,16 @@ function ToolCard({ tool }: { tool: ToolState }) {
|
||||
</span>
|
||||
</summary>
|
||||
<div className="tool-body">
|
||||
<div>
|
||||
<strong>args</strong>
|
||||
<pre style={{ margin: "4px 0 10px", whiteSpace: "pre-wrap" }}>
|
||||
{tool.args}
|
||||
</pre>
|
||||
<div className="tool-args">
|
||||
{toolArgsEntries(tool.args).map((e, i) => (
|
||||
<div key={e.k ?? i} className="tool-arg">
|
||||
{e.k !== null && <span className="arg-k">{e.k}</span>}
|
||||
<pre className="arg-v">{e.v}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<strong>result</strong>
|
||||
<span className="arg-k">output</span>
|
||||
<pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}>
|
||||
{tool.preview}
|
||||
</pre>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user