Compare commits
2
Commits
f5ee3a0f2d
...
5962b50c8d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5962b50c8d | ||
|
|
3b799c69fb |
+189
-24
@@ -5,8 +5,10 @@ import type { ChatMessage, ToolState } from "./derive";
|
||||
import ChatStream, {
|
||||
Bubble,
|
||||
TypingIndicator,
|
||||
lineDiff,
|
||||
renderMarkdown,
|
||||
toolArgsEntries,
|
||||
toolSummaryText,
|
||||
} from "./ChatStream";
|
||||
|
||||
function msg(partial: Partial<ChatMessage>): ChatMessage {
|
||||
@@ -61,10 +63,7 @@ function stream(p: {
|
||||
describe("Bubble", () => {
|
||||
it("renders plain text per role class", () => {
|
||||
const { container } = render(
|
||||
<Bubble
|
||||
msg={msg({ role: "user", text: "hi there" })}
|
||||
tools={new Map()}
|
||||
/>,
|
||||
<Bubble msg={msg({ role: "user", text: "hi there" })} tools={new Map()} />,
|
||||
);
|
||||
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
|
||||
expect(container.textContent).toContain("hi there");
|
||||
@@ -92,10 +91,7 @@ describe("Bubble", () => {
|
||||
it("toolResult collapses to a one-line preview, expands to full text", async () => {
|
||||
const long = `${"x".repeat(200)}`;
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({ role: "toolResult", text: long })}
|
||||
tools={new Map()}
|
||||
/>,
|
||||
<Bubble msg={msg({ role: "toolResult", text: long })} tools={new Map()} />,
|
||||
);
|
||||
const details = screen
|
||||
.getByText("result")
|
||||
@@ -158,9 +154,7 @@ describe("Bubble", () => {
|
||||
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
|
||||
expect(screen.getByText("finished")).toBeInTheDocument();
|
||||
|
||||
const summary = screen
|
||||
.getByText("🛠 bash")
|
||||
.closest("summary") as HTMLElement;
|
||||
const summary = screen.getByText("🛠 bash").closest("summary") as HTMLElement;
|
||||
const card = summary.closest("details") as HTMLDetailsElement;
|
||||
expect(card.open).toBe(false);
|
||||
await userEvent.click(summary);
|
||||
@@ -254,9 +248,7 @@ describe("ChatStream", () => {
|
||||
}),
|
||||
);
|
||||
expect(container.querySelector(".typing")).toBeNull();
|
||||
rerender(
|
||||
stream({ messages: [msg({ key: "a", text: "one" })], busy: false }),
|
||||
);
|
||||
rerender(stream({ messages: [msg({ key: "a", text: "one" })], busy: false }));
|
||||
expect(container.querySelector(".typing")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -765,15 +757,34 @@ describe("copy button", () => {
|
||||
|
||||
describe("pretty tool args", () => {
|
||||
const tool = (args: string): ToolState => ({
|
||||
id: "t1", name: "bash", args, running: false, isError: false, preview: "out",
|
||||
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"');
|
||||
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(document.querySelector(".tool-summary")?.textContent).toBe("ls -la");
|
||||
expect(document.querySelector(".tool-args")?.textContent).not.toContain(
|
||||
'"command"',
|
||||
);
|
||||
});
|
||||
|
||||
it("priority ordering puts command first even when not first in JSON", () => {
|
||||
@@ -783,10 +794,24 @@ describe("pretty tool args", () => {
|
||||
});
|
||||
|
||||
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();
|
||||
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(document.querySelector(".tool-summary")?.textContent).toBe(
|
||||
"src/main.ts",
|
||||
);
|
||||
expect(document.querySelector(".tool-args .arg-k")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -805,3 +830,143 @@ describe("pretty tool args", () => {
|
||||
expect(entries[0]?.v).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool summary + diff", () => {
|
||||
const tool = (args: string, name = "bash"): ToolState => ({
|
||||
id: "t1",
|
||||
name,
|
||||
args,
|
||||
running: false,
|
||||
isError: false,
|
||||
preview: "",
|
||||
});
|
||||
it("bash summary shows the command", () => {
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({
|
||||
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }],
|
||||
})}
|
||||
tools={new Map([["t1", tool('{"command":"ls -la","timeout":30}')]])}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
|
||||
expect(document.querySelector(".tool-summary")?.textContent).toBe("ls -la");
|
||||
});
|
||||
|
||||
it("read summary shows the path", () => {
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({
|
||||
toolCalls: [{ id: "t1", name: "read", argsJson: "{}" }],
|
||||
})}
|
||||
tools={new Map([["t1", tool('{"path":"src/main.ts"}', "read")]])}
|
||||
/>,
|
||||
);
|
||||
expect(document.querySelector(".tool-summary")?.textContent).toBe(
|
||||
"src/main.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("long summary is truncated with ellipsis", () => {
|
||||
const long: string = "x".repeat(300);
|
||||
expect(toolSummaryText(`{"command":"${long}"}`)).toBe(long);
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({
|
||||
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }],
|
||||
})}
|
||||
tools={new Map([["t1", tool(`{"command":"${long}"}`)]])}
|
||||
/>,
|
||||
);
|
||||
const summary = document.querySelector(".tool-summary");
|
||||
expect(summary?.textContent?.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("edit tool renders a line diff of old/new text", () => {
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({
|
||||
toolCalls: [{ id: "t1", name: "edit", argsJson: "{}" }],
|
||||
})}
|
||||
tools={
|
||||
new Map([
|
||||
[
|
||||
"t1",
|
||||
tool(
|
||||
'{"path":"a.ts","edits":[{"oldText":"const a = 1;\\nconst b = 2;","newText":"const a = 3;\\nconst b = 2;"}]}',
|
||||
"edit",
|
||||
),
|
||||
],
|
||||
])
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(document.querySelector(".diff-del")?.textContent).toContain(
|
||||
"-const a = 1;",
|
||||
);
|
||||
expect(document.querySelector(".diff-add")?.textContent).toContain(
|
||||
"+const a = 3;",
|
||||
);
|
||||
expect(document.querySelector(".diff-ctx")?.textContent).toContain(
|
||||
" const b = 2;",
|
||||
);
|
||||
expect(document.querySelector(".tool-args")).toBeNull();
|
||||
});
|
||||
|
||||
it("write tool renders content as added lines", () => {
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({
|
||||
toolCalls: [{ id: "t1", name: "write", argsJson: "{}" }],
|
||||
})}
|
||||
tools={
|
||||
new Map([
|
||||
["t1", tool('{"path":"new.ts","content":"hello\\nworld"}', "write")],
|
||||
])
|
||||
}
|
||||
/>,
|
||||
);
|
||||
const adds = document.querySelectorAll(".diff-add");
|
||||
expect(adds).toHaveLength(2);
|
||||
expect(adds[0]?.textContent).toContain("+hello");
|
||||
expect(adds[1]?.textContent).toContain("+world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lineDiff", () => {
|
||||
it("identical text is all context", () => {
|
||||
const d = lineDiff("a\nb", "a\nb");
|
||||
expect(d).toEqual([
|
||||
{ kind: "ctx", text: "a" },
|
||||
{ kind: "ctx", text: "b" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("single line change is del + add", () => {
|
||||
const d = lineDiff("a\nb\nc", "a\nx\nc");
|
||||
expect(d).toEqual([
|
||||
{ kind: "ctx", text: "a" },
|
||||
{ kind: "del", text: "b" },
|
||||
{ kind: "add", text: "x" },
|
||||
{ kind: "ctx", text: "c" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("pure addition and deletion", () => {
|
||||
expect(lineDiff("a", "a\nb")).toEqual([
|
||||
{ kind: "ctx", text: "a" },
|
||||
{ kind: "add", text: "b" },
|
||||
]);
|
||||
expect(lineDiff("a\nb", "a")).toEqual([
|
||||
{ kind: "ctx", text: "a" },
|
||||
{ kind: "del", text: "b" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("oversized blocks fall back to del-all + add-all", () => {
|
||||
const a = Array.from({ length: 401 }, (_, i) => `l${i}`).join("\n");
|
||||
const d = lineDiff(a, "x");
|
||||
expect(d.filter((l) => l.kind === "del")).toHaveLength(401);
|
||||
expect(d.filter((l) => l.kind === "add")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
+184
-21
@@ -108,20 +108,20 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
|
||||
n += 1;
|
||||
code = null;
|
||||
}
|
||||
} else if (code !== null) {
|
||||
code.push(line);
|
||||
} else {
|
||||
} else if (code === null) {
|
||||
plain.push(line);
|
||||
} else {
|
||||
code.push(line);
|
||||
}
|
||||
}
|
||||
if (code !== null) {
|
||||
if (code === null) {
|
||||
flushPlain();
|
||||
} else {
|
||||
out.push(
|
||||
<pre className="md-code" key={`c-${n}`}>
|
||||
{code.join("\n")}
|
||||
</pre>,
|
||||
);
|
||||
} else {
|
||||
flushPlain();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -162,16 +162,27 @@ 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",
|
||||
"command",
|
||||
"path",
|
||||
"file_path",
|
||||
"pattern",
|
||||
"url",
|
||||
"query",
|
||||
"content",
|
||||
"prompt",
|
||||
"task",
|
||||
"description",
|
||||
];
|
||||
|
||||
// Above this many lines per side, LCS is skipped and the whole old block is
|
||||
// rendered removed + the whole new block added.
|
||||
const DIFF_MAX_LINES: number = 400;
|
||||
|
||||
export interface ArgEntry {
|
||||
k: string | null;
|
||||
v: string;
|
||||
@@ -186,8 +197,7 @@ export function toolArgsEntries(argsText: string): ArgEntry[] {
|
||||
} catch {
|
||||
return [{ k: null, v: argsText }];
|
||||
}
|
||||
if (typeof parsed === "string")
|
||||
return [{ k: null, v: parsed }];
|
||||
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>;
|
||||
@@ -202,7 +212,7 @@ export function toolArgsEntries(argsText: string): ArgEntry[] {
|
||||
const v =
|
||||
typeof raw === "string"
|
||||
? raw
|
||||
: JSON.stringify(raw, null, 2) ?? String(raw);
|
||||
: (JSON.stringify(raw, null, 2) ?? String(raw));
|
||||
entries.push({ k, v });
|
||||
}
|
||||
if (entries.length === 1 && entries[0] !== undefined)
|
||||
@@ -210,6 +220,148 @@ export function toolArgsEntries(argsText: string): ArgEntry[] {
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** One-line "what is this tool doing" for the collapsed summary line:
|
||||
* the highest-priority arg's value (bash → command, read/write/edit → path). */
|
||||
export function toolSummaryText(argsText: string): string {
|
||||
const first = toolArgsEntries(argsText)[0];
|
||||
return first === undefined ? "" : first.v;
|
||||
}
|
||||
|
||||
// ---------- edit/write diff view ----------
|
||||
|
||||
export interface DiffLine {
|
||||
kind: "ctx" | "add" | "del";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Line-level LCS diff between two text blocks. */
|
||||
export function lineDiff(a: string, b: string): DiffLine[] {
|
||||
const left: string[] = a.split("\n");
|
||||
const right: string[] = b.split("\n");
|
||||
if (left.length > DIFF_MAX_LINES || right.length > DIFF_MAX_LINES)
|
||||
return [
|
||||
...left.map((text): DiffLine => ({ kind: "del", text })),
|
||||
...right.map((text): DiffLine => ({ kind: "add", text })),
|
||||
];
|
||||
const n: number = left.length;
|
||||
const m: number = right.length;
|
||||
const dp: number[][] = Array.from({ length: n + 1 }, () =>
|
||||
Array.from({ length: m + 1 }, (): number => 0),
|
||||
);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
const row: number[] | undefined = dp[i];
|
||||
if (row === undefined) continue;
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
const a: string | undefined = left[i];
|
||||
const b: string | undefined = right[j];
|
||||
row[j] =
|
||||
a !== undefined && a === b
|
||||
? (dp[i + 1]?.[j + 1] ?? 0) + 1
|
||||
: Math.max(dp[i + 1]?.[j] ?? 0, dp[i]?.[j + 1] ?? 0);
|
||||
}
|
||||
}
|
||||
const out: DiffLine[] = [];
|
||||
let i: number = 0;
|
||||
let j: number = 0;
|
||||
while (i < n && j < m) {
|
||||
const a: string | undefined = left[i];
|
||||
const b: string | undefined = right[j];
|
||||
if (a !== undefined && a === b) {
|
||||
out.push({ kind: "ctx", text: a });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) {
|
||||
if (a !== undefined) out.push({ kind: "del", text: a });
|
||||
i += 1;
|
||||
} else {
|
||||
if (b !== undefined) out.push({ kind: "add", text: b });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
for (; i < n; i++) {
|
||||
const a: string | undefined = left[i];
|
||||
if (a !== undefined) out.push({ kind: "del", text: a });
|
||||
}
|
||||
for (; j < m; j++) {
|
||||
const b: string | undefined = right[j];
|
||||
if (b !== undefined) out.push({ kind: "add", text: b });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface TextEdit {
|
||||
oldText: string;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
function parseArgsObj(argsText: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const p: unknown = JSON.parse(argsText);
|
||||
return typeof p === "object" && p !== null && !Array.isArray(p)
|
||||
? (p as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Diff blocks for edit/write tool args; null when the tool is neither or
|
||||
* the args do not carry the expected fields. */
|
||||
export function toolDiffBlocks(
|
||||
name: string,
|
||||
argsText: string,
|
||||
): DiffLine[][] | null {
|
||||
const obj: Record<string, unknown> | null = parseArgsObj(argsText);
|
||||
if (obj === null) return null;
|
||||
if (name === "write" && typeof obj.content === "string")
|
||||
return [
|
||||
obj.content
|
||||
.split("\n")
|
||||
.slice(0, DIFF_MAX_LINES)
|
||||
.map((text): DiffLine => ({ kind: "add", text })),
|
||||
];
|
||||
if (name !== "edit") return null;
|
||||
const edits: TextEdit[] = [];
|
||||
if (Array.isArray(obj.edits)) {
|
||||
for (const e of obj.edits) {
|
||||
if (
|
||||
typeof e === "object" &&
|
||||
e !== null &&
|
||||
typeof (e as Record<string, unknown>).oldText === "string" &&
|
||||
typeof (e as Record<string, unknown>).newText === "string"
|
||||
)
|
||||
edits.push(e as TextEdit);
|
||||
}
|
||||
} else if (
|
||||
typeof obj.oldText === "string" &&
|
||||
typeof obj.newText === "string"
|
||||
) {
|
||||
edits.push({ oldText: obj.oldText, newText: obj.newText });
|
||||
}
|
||||
return edits.length === 0
|
||||
? null
|
||||
: edits.map((e): DiffLine[] => lineDiff(e.oldText, e.newText));
|
||||
}
|
||||
|
||||
const DIFF_MARK: Record<DiffLine["kind"], string> = {
|
||||
add: "+",
|
||||
del: "-",
|
||||
ctx: " ",
|
||||
};
|
||||
|
||||
function DiffBlock({ lines }: { lines: DiffLine[] }): React.ReactNode {
|
||||
return (
|
||||
<pre className="diff">
|
||||
{lines.map((l, i) => (
|
||||
<span key={i} className={`diff-line diff-${l.kind}`}>
|
||||
{DIFF_MARK[l.kind] + l.text}
|
||||
{"\n"}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCard({ tool }: { tool: ToolState }) {
|
||||
const status: string = tool.running
|
||||
? "running…"
|
||||
@@ -219,23 +371,34 @@ function ToolCard({ tool }: { tool: ToolState }) {
|
||||
const statusClass: string = tool.isError
|
||||
? "tool-status-err"
|
||||
: "tool-status-ok";
|
||||
const summary: string = oneLine(toolSummaryText(tool.args));
|
||||
const diffBlocks: DiffLine[][] | null = toolDiffBlocks(tool.name, tool.args);
|
||||
return (
|
||||
<details className="tool-card">
|
||||
<summary>
|
||||
<span className="tool-name">🛠 {tool.name}</span>
|
||||
<span className={tool.running ? "" : statusClass}>
|
||||
{summary.length > 0 && <span className="tool-summary">{summary}</span>}
|
||||
<span className={`tool-status ${tool.running ? "" : statusClass}`}>
|
||||
{tool.running ? "working…" : status}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="tool-body">
|
||||
<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>
|
||||
{diffBlocks === null ? (
|
||||
<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 className="tool-diffs">
|
||||
{diffBlocks.map((b, i) => (
|
||||
<DiffBlock key={i} lines={b} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="arg-k">output</span>
|
||||
<pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}>
|
||||
|
||||
+71
-14
@@ -25,8 +25,7 @@
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
--shadow-2: 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||
font-family:
|
||||
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter,
|
||||
sans-serif;
|
||||
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -687,6 +686,18 @@ a:hover {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bubble .tool-card summary .tool-summary {
|
||||
flex: 1;
|
||||
min-width: 40px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.bubble .tool-card summary .tool-status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bubble .tool-card .tool-body {
|
||||
border-top: 1px solid var(--border);
|
||||
@@ -1433,24 +1444,37 @@ mark.hit {
|
||||
/* ---------- busy activity pip ---------- */
|
||||
|
||||
.busy-pip {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-left: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: busy-pulse 1.1s ease-in-out infinite;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-left: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: busy-pulse 1.1s ease-in-out infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes busy-pulse {
|
||||
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); }
|
||||
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; }
|
||||
.tool-args {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tool-arg {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.arg-k {
|
||||
display: block;
|
||||
font-size: 10.5px;
|
||||
@@ -1468,3 +1492,36 @@ mark.hit {
|
||||
font-size: 12.5px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ---------- edit/write diff ---------- */
|
||||
|
||||
.tool-diffs {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.diff {
|
||||
margin: 0 0 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
background: var(--bg-veil);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
.diff-line {
|
||||
display: block;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.diff-del {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
.diff-add {
|
||||
color: var(--ok);
|
||||
background: rgba(52, 211, 153, 0.08);
|
||||
}
|
||||
.diff-ctx {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user