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 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.
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user