@@ -471,8 +763,14 @@ export function renderExpandedToolCardContent(
embedSandboxMode: EmbedSandboxMode = "scripts",
allowExternalEmbedUrls = false,
) {
+ const view = resolveToolCallView({ name: card.name, args: card.args, details: card.details });
const display = resolveToolDisplay({ name: card.name, args: card.args });
- const detail = formatToolDetail(display);
+ // File/search rows already carry their target; the "with …" connector only
+ // reads well for generic tools ("with query …"), not "with from sessions.ts".
+ const detail =
+ view.kind === "read" || view.kind === "search" || view.kind === "fetch"
+ ? display.detail
+ : formatToolDetail(display);
const hasOutput = Boolean(card.outputText?.trim());
const hasInput = Boolean(card.inputText?.trim());
const isError = isToolCardError(card);
@@ -497,6 +795,76 @@ export function renderExpandedToolCardContent(
allowExternalEmbedUrls,
})
: nothing;
+ const sidebarAction = canOpenSidebar
+ ? html`
+
+ `
+ : nothing;
+
+ // Command calls render terminal-style: `$ command` + raw output. Remaining
+ // args (workdir, timeout, env…) stay visible as key-value rows so identical
+ // commands in different contexts remain distinguishable in the audit trail.
+ if (view.kind === "command" && view.command && !card.preview) {
+ const argsRecord =
+ card.args && typeof card.args === "object" && !Array.isArray(card.args)
+ ? (card.args as Record
)
+ : null;
+ const extraArgs = Object.fromEntries(
+ Object.entries(argsRecord ?? {}).filter(([key]) => key !== "command"),
+ );
+ return html`
+
+ ${sidebarAction}
+ ${renderTerminalBlock(
+ view.command,
+ card.outputText ?? (isError ? "No output — tool failed." : undefined),
+ isError,
+ )}
+ ${Object.keys(extraArgs).length > 0 ? renderArgsKeyValueList(extraArgs) : nothing}
+
+ `;
+ }
+
+ // Edits and writes with a resolvable diff render it inline; the raw tool
+ // output stays reachable behind the raw-details toggle.
+ if ((view.kind === "edit" || view.kind === "write") && view.diff && view.diff.length > 0) {
+ return html`
+
+
+ ${renderDiffBlock(view.diff)}
+ ${isError && hasOutput
+ ? renderToolDataBlock({ label: "Tool error", text: card.outputText! })
+ : hasOutput
+ ? renderRawOutputToggle(card.outputText!)
+ : nothing}
+
+ `;
+ }
+
+ // File reads and searches summarize their primary target in the row, so the
+ // full args JSON is noise — but any remaining args (filters, limits, request
+ // options…) stay visible as key-value rows for auditability.
+ const summarizedKind = view.kind === "read" || view.kind === "search" || view.kind === "fetch";
+ const inputBlockArgs = summarizedKind
+ ? extraArgsBeyondRowTarget(card.args, view.kind)
+ : card.args;
+ const showInputBlock = hasInput && (!summarizedKind || inputBlockArgs !== null);
return html`
@@ -504,30 +872,17 @@ export function renderExpandedToolCardContent(
? html`
`
: nothing}
- ${hasInput
- ? renderToolDataBlock({
- label: "Tool input",
- text: card.inputText!,
- })
+ ${showInputBlock
+ ? canRenderArgsAsKeyValue(inputBlockArgs)
+ ? renderArgsKeyValueList(inputBlockArgs)
+ : renderToolDataBlock({
+ label: "Tool input",
+ text: card.inputText!,
+ })
: nothing}
${hasOutput
? card.preview
diff --git a/ui/src/pages/chat/tool-stream.node.test.ts b/ui/src/pages/chat/tool-stream.node.test.ts
index aff0ae8af019..a08f274d39ae 100644
--- a/ui/src/pages/chat/tool-stream.node.test.ts
+++ b/ui/src/pages/chat/tool-stream.node.test.ts
@@ -815,3 +815,33 @@ describe("app-tool-stream fallback lifecycle handling", () => {
vi.useRealTimers();
});
});
+
+describe("app-tool-stream result blocks", () => {
+ it("emits a result block for completed tools with empty output", () => {
+ const host = createHost();
+
+ handleAgentEvent(host, {
+ runId: "run-1",
+ seq: 1,
+ stream: "tool",
+ ts: TOOL_STREAM_TEST_NOW,
+ sessionKey: "main",
+ data: { phase: "start", name: "bash", toolCallId: "call-1", args: { command: "true" } },
+ });
+ handleAgentEvent(host, {
+ runId: "run-1",
+ seq: 2,
+ stream: "tool",
+ ts: TOOL_STREAM_TEST_NOW + 1,
+ sessionKey: "main",
+ data: { phase: "result", name: "bash", toolCallId: "call-1", result: "" },
+ });
+
+ const entry = host.toolStreamById.get("call-1") as ToolStreamEntry;
+ expect(entry.resultReceived).toBe(true);
+ const content = entry.message.content as Array>;
+ // The empty-output result block marks the call as finished so the UI does
+ // not keep it in a running state for the rest of the run.
+ expect(content.some((block) => block.type === "toolresult" && block.text === "")).toBe(true);
+ });
+});
diff --git a/ui/src/pages/chat/tool-stream.ts b/ui/src/pages/chat/tool-stream.ts
index 58c6ce11fc83..388afa3f9d67 100644
--- a/ui/src/pages/chat/tool-stream.ts
+++ b/ui/src/pages/chat/tool-stream.ts
@@ -38,6 +38,11 @@ export type ToolStreamEntry = {
name: string;
args?: unknown;
output?: string;
+ /** Structured result details (e.g. edit diff) captured from the result event. */
+ details?: unknown;
+ isError?: boolean;
+ /** True once a result event landed, even when the output text is empty. */
+ resultReceived?: boolean;
startedAt: number;
updatedAt: number;
message: Record;
@@ -248,11 +253,15 @@ function buildToolStreamMessage(entry: ToolStreamEntry): Record
name: entry.name,
arguments: entry.args ?? {},
});
- if (entry.output) {
+ // Emit the result block whenever a result landed, even with empty output;
+ // otherwise a completed no-stdout command keeps its running state in the UI.
+ if (entry.output || entry.resultReceived) {
content.push({
type: "toolresult",
name: entry.name,
- text: entry.output,
+ text: entry.output ?? "",
+ ...(entry.details !== undefined ? { details: entry.details } : {}),
+ ...(entry.isError !== undefined ? { isError: entry.isError } : {}),
});
}
return {
@@ -261,6 +270,12 @@ function buildToolStreamMessage(entry: ToolStreamEntry): Record
runId: entry.runId,
content,
timestamp: entry.startedAt,
+ // Running-state markers: only live tool-stream cards may show a spinner,
+ // and completion comes from the result event — partial `update` output
+ // must not end the running state. Transcript messages never carry these,
+ // so historical output-less calls (aborted runs) stay inert.
+ __openclawToolStreamLive: true,
+ __openclawToolStreamResultReceived: entry.resultReceived === true,
};
}
@@ -710,6 +725,9 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
: phase === "result"
? formatToolOutput(data.result)
: undefined;
+ const resultDetails = phase === "result" ? readRecord(data.result)?.details : undefined;
+ const resultIsError =
+ phase === "result" && typeof data.isError === "boolean" ? data.isError : undefined;
if (name === "session_status" && phase === "result") {
syncSessionStatusModelOverride(host, data);
}
@@ -739,6 +757,9 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
name,
args,
output: output || undefined,
+ ...(resultDetails !== undefined ? { details: resultDetails } : {}),
+ ...(resultIsError !== undefined ? { isError: resultIsError } : {}),
+ ...(phase === "result" ? { resultReceived: true } : {}),
startedAt: typeof payload.ts === "number" ? payload.ts : now,
updatedAt: now,
message: {},
@@ -753,6 +774,15 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
if (output !== undefined) {
entry.output = output || undefined;
}
+ if (resultDetails !== undefined) {
+ entry.details = resultDetails;
+ }
+ if (resultIsError !== undefined) {
+ entry.isError = resultIsError;
+ }
+ if (phase === "result") {
+ entry.resultReceived = true;
+ }
entry.updatedAt = now;
}
diff --git a/ui/src/pages/chat/tool-titles.test.ts b/ui/src/pages/chat/tool-titles.test.ts
new file mode 100644
index 000000000000..4677e9e81951
--- /dev/null
+++ b/ui/src/pages/chat/tool-titles.test.ts
@@ -0,0 +1,124 @@
+// Control UI tests cover tool-title request eligibility and the title store.
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { GatewayBrowserClient } from "../../api/gateway.ts";
+import {
+ configureToolTitleFetcher,
+ getToolCallTitle,
+ resetToolTitlesForTest,
+ resolveToolTitleRequest,
+ setToolTitleForTest,
+} from "./tool-titles.ts";
+
+const LONG_GENERIC_ARGS = {
+ title: "Investigate flaky gateway reconnect loop on the staging cluster",
+ description: "The websocket reconnect loop spins when the auth token expires mid-session.",
+};
+
+describe("resolveToolTitleRequest", () => {
+ afterEach(() => {
+ resetToolTitlesForTest();
+ });
+
+ it("requests titles for commands of at least 12 characters", () => {
+ const request = resolveToolTitleRequest("bash", { command: "git status --short" });
+
+ expect(request).not.toBeNull();
+ expect(request?.input).toBe("git status --short");
+ expect(request?.key).toMatch(/^t/);
+ });
+
+ it.each([
+ ["a short command", "bash", { command: "ls -la" }],
+ ["a read call", "read", { path: "/repo/a-very-long-path/to/some/file.ts" }],
+ ["an edit call", "edit", { path: "/repo/a.ts", oldText: "x".repeat(200), newText: "y" }],
+ ["a write call", "write", { path: "/repo/a.ts", content: "x".repeat(200) }],
+ ["a search call", "grep", { pattern: "x".repeat(200) }],
+ ["a fetch call", "web_fetch", { url: `https://x.dev/${"a".repeat(200)}` }],
+ ["a generic call with short args", "mcp__linear__create_issue", { title: "Fix bug" }],
+ ])("does not request a title for %s", (_label, name, args) => {
+ expect(resolveToolTitleRequest(name, args)).toBeNull();
+ });
+
+ it("requests titles for generic tools with at least 120 chars of serialized args", () => {
+ const request = resolveToolTitleRequest("mcp__linear__create_issue", LONG_GENERIC_ARGS);
+
+ expect(request).not.toBeNull();
+ expect(request?.input).toBe(JSON.stringify(LONG_GENERIC_ARGS));
+ });
+
+ it("keys equal name and args to the same digest", () => {
+ const first = resolveToolTitleRequest("bash", { command: "pnpm install --frozen" });
+ const second = resolveToolTitleRequest("bash", { command: "pnpm install --frozen" });
+
+ expect(first?.key).toBe(second?.key);
+ });
+});
+
+describe("getToolCallTitle", () => {
+ afterEach(() => {
+ resetToolTitlesForTest();
+ });
+
+ it("returns a stored title for the resolved request key", () => {
+ const args = { command: "git log --oneline -5" };
+ const request = resolveToolTitleRequest("bash", args);
+ if (!request) {
+ throw new Error("expected an eligible title request");
+ }
+ setToolTitleForTest(request.key, "Checked recent commits");
+
+ expect(getToolCallTitle("bash", args)).toBe("Checked recent commits");
+ });
+
+ it("returns undefined for eligible calls without a stored title", () => {
+ expect(getToolCallTitle("bash", { command: "git log --oneline -5" })).toBeUndefined();
+ });
+
+ it("returns undefined for ineligible calls even when a title exists", () => {
+ setToolTitleForTest("some-key", "Never shown");
+
+ expect(getToolCallTitle("read", { path: "/repo/a.ts" })).toBeUndefined();
+ });
+});
+
+describe("title fetch batching", () => {
+ afterEach(() => {
+ configureToolTitleFetcher({ client: null, sessionKey: null, onTitlesChanged: null });
+ resetToolTitlesForTest();
+ vi.useRealTimers();
+ });
+
+ it("sends queued items with the session and agent captured at schedule time", async () => {
+ vi.useFakeTimers();
+ const requests: Array<{ sessionKey: string; agentId?: string }> = [];
+ const client = {
+ request: vi.fn(async (_method: string, params: unknown) => {
+ requests.push(params as { sessionKey: string; agentId?: string });
+ return { titles: {} };
+ }),
+ } as unknown as GatewayBrowserClient;
+
+ // Pane A schedules, then pane B re-renders (and reconfigures) before the
+ // debounce fires; the request must keep pane A's session and agent.
+ configureToolTitleFetcher({
+ client,
+ sessionKey: "global",
+ agentId: "alice",
+ onTitlesChanged: null,
+ });
+ getToolCallTitle("bash", { command: "pnpm run build --filter ui" });
+ configureToolTitleFetcher({
+ client,
+ sessionKey: "agent:b:main",
+ agentId: "b",
+ onTitlesChanged: null,
+ });
+ getToolCallTitle("bash", { command: "pnpm test ui/src/pages/chat" });
+ await vi.advanceTimersByTimeAsync(1_000);
+
+ expect(requests).toEqual([
+ expect.objectContaining({ sessionKey: "global", agentId: "alice" }),
+ expect.objectContaining({ sessionKey: "agent:b:main", agentId: "b" }),
+ ]);
+ });
+});
diff --git a/ui/src/pages/chat/tool-titles.ts b/ui/src/pages/chat/tool-titles.ts
new file mode 100644
index 000000000000..40853a668875
--- /dev/null
+++ b/ui/src/pages/chat/tool-titles.ts
@@ -0,0 +1,240 @@
+/**
+ * AI-generated purpose titles for complex tool calls.
+ *
+ * The store is process-global and keyed by a digest of tool name + args, so a
+ * title generated once (or served from the gateway cache) applies to every
+ * render of the same call. Fetching is debounced and best-effort: when no
+ * utility model or Luna default is usable, rows keep their deterministic
+ * labels.
+ */
+
+import type { GatewayBrowserClient } from "../../api/gateway.ts";
+import { resolveToolCallKind, unwrapShellWrapperCommand } from "../../lib/chat/tool-call-view.ts";
+
+const MAX_TITLE_INPUT_CHARS = 2_000;
+const MAX_ITEMS_PER_REQUEST = 24;
+const REQUEST_DEBOUNCE_MS = 250;
+const MIN_COMMAND_CHARS_FOR_TITLE = 12;
+const MIN_GENERIC_INPUT_CHARS_FOR_TITLE = 120;
+
+const titlesByKey = new Map();
+const pendingKeys = new Set();
+const failedKeys = new Set();
+// Bumped whenever titles land; chat threads include it in their lit guard()
+// dependencies so cached row subtrees repaint with the new titles.
+let titlesVersion = 0;
+
+export function getToolTitlesVersion(): number {
+ return titlesVersion;
+}
+
+// Everything a flush needs is captured at schedule time: split panes
+// reconfigure the module globals on every render, so flush-time globals can
+// belong to a different pane than the one that queued the item.
+type PendingItem = {
+ key: string;
+ name: string;
+ input: string;
+ sessionKey: string;
+ agentId: string | null;
+ client: GatewayBrowserClient;
+ notify: (() => void) | null;
+};
+type ToolTitlesResult = { titles?: Record };
+
+let queue = new Map();
+let flushTimer: ReturnType | null = null;
+let activeClient: GatewayBrowserClient | null = null;
+let activeSessionKey: string | null = null;
+let activeAgentId: string | null = null;
+let notifyUpdate: (() => void) | null = null;
+
+/** FNV-1a over name + serialized args; stable across renders of one call. */
+function digest(name: string, input: string): string {
+ const source = `${name}\u0000${input}`;
+ let hash = 0x811c9dc5;
+ for (let index = 0; index < source.length; index += 1) {
+ hash ^= source.charCodeAt(index);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ return `t${(hash >>> 0).toString(36)}${source.length.toString(36)}`;
+}
+
+function serializeArgs(args: unknown): string | null {
+ if (args === undefined || args === null) {
+ return null;
+ }
+ if (typeof args === "string") {
+ return args.slice(0, MAX_TITLE_INPUT_CHARS);
+ }
+ try {
+ const encoded = JSON.stringify(args);
+ return typeof encoded === "string" ? encoded.slice(0, MAX_TITLE_INPUT_CHARS) : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Only calls where a purpose summary beats the deterministic label qualify:
+ * shell commands and arg-heavy generic/MCP tools. File reads/edits/writes
+ * already render precise labels.
+ */
+export function resolveToolTitleRequest(
+ name: string,
+ args: unknown,
+): { key: string; input: string } | null {
+ const kind = resolveToolCallKind(name, args);
+ if (kind === "command") {
+ const record =
+ args && typeof args === "object" && !Array.isArray(args)
+ ? (args as Record)
+ : null;
+ const rawCommand = typeof record?.command === "string" ? record.command.trim() : "";
+ const command = unwrapShellWrapperCommand(rawCommand).trim();
+ if (command.length < MIN_COMMAND_CHARS_FOR_TITLE) {
+ return null;
+ }
+ const input = command.slice(0, MAX_TITLE_INPUT_CHARS);
+ return { key: digest("command", input), input };
+ }
+ if (kind !== "generic") {
+ return null;
+ }
+ const input = serializeArgs(args);
+ if (!input || input.length < MIN_GENERIC_INPUT_CHARS_FOR_TITLE) {
+ return null;
+ }
+ return { key: digest(name.trim().toLowerCase(), input), input };
+}
+
+export function getToolCallTitle(name: string, args: unknown): string | undefined {
+ const request = resolveToolTitleRequest(name, args);
+ if (!request) {
+ return undefined;
+ }
+ const cached = titlesByKey.get(request.key);
+ if (cached) {
+ return cached;
+ }
+ scheduleTitleRequest(name, request);
+ return undefined;
+}
+
+export function configureToolTitleFetcher(params: {
+ client: GatewayBrowserClient | null;
+ sessionKey: string | null;
+ /** Selected agent; required for global-session keys where the gateway would otherwise resolve the default agent. */
+ agentId?: string | null;
+ onTitlesChanged: (() => void) | null;
+}): void {
+ activeClient = params.client;
+ activeSessionKey = params.sessionKey;
+ activeAgentId = params.agentId ?? null;
+ notifyUpdate = params.onTitlesChanged;
+}
+
+function scheduleTitleRequest(name: string, request: { key: string; input: string }): void {
+ if (
+ !activeClient ||
+ !activeSessionKey ||
+ titlesByKey.has(request.key) ||
+ pendingKeys.has(request.key) ||
+ failedKeys.has(request.key) ||
+ queue.has(request.key)
+ ) {
+ return;
+ }
+ queue.set(request.key, {
+ key: request.key,
+ name,
+ input: request.input,
+ sessionKey: activeSessionKey,
+ agentId: activeAgentId,
+ client: activeClient,
+ notify: notifyUpdate,
+ });
+ flushTimer ??= setTimeout(() => {
+ flushTimer = null;
+ void flushTitleQueue();
+ }, REQUEST_DEBOUNCE_MS);
+}
+
+async function flushTitleQueue(): Promise {
+ // One request per scheduling pane (client + session + agent); other panes'
+ // items stay queued for the follow-up flush.
+ const head = queue.values().next().value;
+ if (!head) {
+ queue = new Map();
+ return;
+ }
+ const batch: PendingItem[] = [];
+ for (const item of queue.values()) {
+ if (
+ item.client === head.client &&
+ item.sessionKey === head.sessionKey &&
+ item.agentId === head.agentId &&
+ batch.length < MAX_ITEMS_PER_REQUEST
+ ) {
+ batch.push(item);
+ }
+ }
+ for (const item of batch) {
+ queue.delete(item.key);
+ pendingKeys.add(item.key);
+ }
+ const hasBacklog = queue.size > 0;
+ try {
+ const result = await head.client.request("chat.toolTitles", {
+ sessionKey: head.sessionKey,
+ ...(head.agentId ? { agentId: head.agentId } : {}),
+ items: batch.map((item) => ({ id: item.key, name: item.name, input: item.input })),
+ });
+ const titles = result?.titles ?? {};
+ let changed = false;
+ for (const item of batch) {
+ const title = typeof titles[item.key] === "string" ? titles[item.key].trim() : "";
+ if (title) {
+ titlesByKey.set(item.key, title);
+ changed = true;
+ } else {
+ failedKeys.add(item.key);
+ }
+ }
+ if (changed) {
+ titlesVersion += 1;
+ head.notify?.();
+ }
+ } catch {
+ // Gateway without the method, no usable cheap model, transient errors:
+ // titles are decorative, so fail closed and keep deterministic labels.
+ for (const item of batch) {
+ failedKeys.add(item.key);
+ }
+ } finally {
+ for (const item of batch) {
+ pendingKeys.delete(item.key);
+ }
+ if (hasBacklog && !flushTimer) {
+ flushTimer = setTimeout(() => {
+ flushTimer = null;
+ void flushTitleQueue();
+ }, REQUEST_DEBOUNCE_MS);
+ }
+ }
+}
+
+export function resetToolTitlesForTest(): void {
+ titlesByKey.clear();
+ pendingKeys.clear();
+ failedKeys.clear();
+ queue = new Map();
+ if (flushTimer) {
+ clearTimeout(flushTimer);
+ flushTimer = null;
+ }
+}
+
+export function setToolTitleForTest(key: string, title: string): void {
+ titlesByKey.set(key, title);
+}
diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css
index c8fc7110ca29..923490b65f86 100644
--- a/ui/src/styles/chat/tool-cards.css
+++ b/ui/src/styles/chat/tool-cards.css
@@ -116,6 +116,322 @@
color: var(--destructive, var(--danger, #c0392b));
}
+/* ── Kind-aware tool rows ── */
+.chat-tool-row__prompt {
+ flex-shrink: 0;
+ font-family: var(--mono);
+ font-size: calc(var(--control-ui-text-sm) - 1px);
+ font-weight: 600;
+ color: color-mix(in srgb, var(--ok) 70%, var(--muted) 30%);
+}
+
+.chat-tool-row__cmd {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--mono);
+ font-size: calc(var(--control-ui-text-sm) - 1px);
+ color: var(--text);
+}
+
+.chat-tool-row__cmd--secondary {
+ flex: 0 100000 auto;
+ color: color-mix(in srgb, var(--muted) 85%, var(--text) 15%);
+}
+
+.chat-tool-row__title {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 500;
+ color: var(--text);
+}
+
+.chat-tool-row__verb {
+ flex-shrink: 0;
+ font-weight: 500;
+ color: var(--text);
+}
+
+.chat-tool-row__target {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--mono);
+ font-size: calc(var(--control-ui-text-sm) - 1px);
+ font-weight: 500;
+ color: var(--text);
+}
+
+.chat-tool-row__detail {
+ flex: 0 100000 auto;
+ min-width: 24px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--mono);
+ font-size: calc(var(--control-ui-text-sm) - 1px);
+ color: color-mix(in srgb, var(--muted) 88%, var(--text) 12%);
+}
+
+.chat-tool-row--running .chat-tool-msg-summary__icon {
+ color: var(--accent-2, #14b8a6);
+}
+
+.chat-tool-row__spinner {
+ flex-shrink: 0;
+ margin-left: auto;
+ width: 7px;
+ height: 7px;
+ border-radius: var(--radius-full, 999px);
+ background: var(--accent-2, #14b8a6);
+ animation: chatToolRowPulse 1.1s ease-in-out infinite;
+}
+
+@keyframes chatToolRowPulse {
+ 0%,
+ 100% {
+ opacity: 0.35;
+ transform: scale(0.85);
+ }
+
+ 50% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .chat-tool-row__spinner {
+ animation: none;
+ opacity: 0.8;
+ }
+}
+
+.chat-tool-row__badge {
+ flex-shrink: 0;
+ margin-left: auto;
+ padding: 1px 6px;
+ border-radius: var(--radius-full, 999px);
+ background: color-mix(in srgb, var(--destructive, #ef4444) 14%, transparent);
+ color: var(--destructive, #ef4444);
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1.5;
+}
+
+/* ── Diffstat chips (+N -M) ── */
+.chat-diffstat {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+ font-family: var(--mono);
+ font-size: calc(var(--control-ui-text-sm) - 1px);
+ font-weight: 600;
+}
+
+.chat-diffstat__add {
+ color: var(--ok, #22c55e);
+}
+
+.chat-diffstat__del {
+ color: var(--destructive, #ef4444);
+}
+
+/* ── Inline diff ── */
+.chat-diff {
+ margin-top: 6px;
+ border-radius: var(--radius-sm);
+ background: color-mix(in srgb, var(--secondary) 55%, transparent);
+ font-family: var(--mono);
+ font-size: 12px;
+ line-height: 1.55;
+ overflow-x: auto;
+ overflow-y: auto;
+ max-height: min(480px, 55vh);
+}
+
+.chat-diff__row {
+ display: flex;
+ align-items: baseline;
+ min-width: max-content;
+ padding-right: 12px;
+}
+
+.chat-diff__row--add {
+ background: color-mix(in srgb, var(--ok, #22c55e) 13%, transparent);
+}
+
+.chat-diff__row--add .chat-diff__sign {
+ color: var(--ok, #22c55e);
+}
+
+.chat-diff__row--del {
+ background: color-mix(in srgb, var(--destructive, #ef4444) 12%, transparent);
+}
+
+.chat-diff__row--del .chat-diff__sign {
+ color: var(--destructive, #ef4444);
+}
+
+.chat-diff__row--skip {
+ color: var(--muted);
+ user-select: none;
+}
+
+.chat-diff__gutter {
+ flex-shrink: 0;
+ width: 42px;
+ padding-right: 8px;
+ text-align: right;
+ color: color-mix(in srgb, var(--muted) 75%, transparent);
+ user-select: none;
+}
+
+.chat-diff__sign {
+ flex-shrink: 0;
+ width: 16px;
+ text-align: center;
+ font-weight: 600;
+ color: transparent;
+ user-select: none;
+}
+
+.chat-diff__text {
+ white-space: pre;
+ tab-size: 4;
+ color: var(--text);
+}
+
+.chat-diff__row--del .chat-diff__text {
+ color: color-mix(in srgb, var(--text) 72%, var(--muted) 28%);
+}
+
+/* ── Terminal-style command block ── */
+.chat-tool-term {
+ margin-top: 6px;
+ border-radius: var(--radius-sm);
+ background: color-mix(in srgb, var(--secondary) 82%, transparent);
+ overflow: hidden;
+}
+
+.chat-tool-term__cmd {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ padding: 8px 12px 0;
+ font-family: var(--mono);
+ font-size: 12px;
+ line-height: 1.55;
+ color: var(--text);
+}
+
+.chat-tool-term__cmd code {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ font-family: inherit;
+}
+
+.chat-tool-term__prompt {
+ flex-shrink: 0;
+ font-weight: 600;
+ color: color-mix(in srgb, var(--ok) 70%, var(--muted) 30%);
+ user-select: none;
+}
+
+.chat-tool-term__out {
+ margin: 0;
+ padding: 6px 12px 10px 12px;
+ font-family: var(--mono);
+ font-size: 12px;
+ line-height: 1.5;
+ color: color-mix(in srgb, var(--text) 82%, var(--muted) 18%);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ overflow: auto;
+ max-height: min(420px, 55vh);
+}
+
+.chat-tool-term__cmd:last-child {
+ padding-bottom: 10px;
+}
+
+.chat-tool-term--error .chat-tool-term__out {
+ color: color-mix(in srgb, var(--destructive, #ef4444) 70%, var(--text) 30%);
+}
+
+/* Command/diff text reuses for semantics only; kill the markdown pill. */
+.chat-tool-row__cmd,
+.chat-tool-row__cmd code,
+.chat-tool-term__cmd code,
+.chat-tool-term__out code {
+ background: none;
+ border: 0;
+ padding: 0;
+ font-size: inherit;
+ color: inherit;
+}
+
+/* ── Command token colors (display-only highlighting) ── */
+.chat-cmd--name {
+ color: var(--accent-2, #14b8a6);
+ font-weight: 600;
+}
+
+.chat-cmd--str {
+ color: color-mix(in srgb, var(--ok, #22c55e) 78%, var(--text) 22%);
+}
+
+.chat-cmd--num {
+ color: color-mix(in srgb, var(--accent, #ff5c5c) 82%, var(--text) 18%);
+}
+
+.chat-cmd--flag {
+ color: color-mix(in srgb, var(--muted) 70%, var(--text) 30%);
+}
+
+.chat-cmd--op {
+ color: color-mix(in srgb, var(--accent-2, #14b8a6) 60%, var(--muted) 40%);
+}
+
+/* ── Key-value args (generic tools) ── */
+.chat-tool-kv {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ margin-top: 6px;
+ font-size: var(--control-ui-text-sm);
+ line-height: 1.6;
+}
+
+.chat-tool-kv__row {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ min-width: 0;
+}
+
+.chat-tool-kv__key {
+ flex-shrink: 0;
+ font-family: var(--mono);
+ font-size: calc(var(--control-ui-text-sm) - 1px);
+ color: color-mix(in srgb, var(--muted) 90%, var(--text) 10%);
+}
+
+.chat-tool-kv__value {
+ min-width: 0;
+ color: var(--text);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
/* ── Expanded tool row body ── */
.chat-tool-msg-body {
box-sizing: border-box;
@@ -155,6 +471,26 @@
min-width: 0;
}
+/* Cards whose body is a single block (terminal, diff) skip the header row and
+ float the side-panel action over the block instead. */
+.chat-tool-card--flush {
+ position: relative;
+}
+
+.chat-tool-card--flush > .chat-tool-card__actions {
+ position: absolute;
+ top: 8px;
+ right: 6px;
+ z-index: 1;
+ opacity: 0;
+ transition: opacity 120ms ease;
+}
+
+.chat-tool-card--flush:hover > .chat-tool-card__actions,
+.chat-tool-card--flush:focus-within > .chat-tool-card__actions {
+ opacity: 1;
+}
+
.chat-tool-card__actions {
display: inline-flex;
align-items: center;
@@ -533,14 +869,33 @@
line-height: 1.4;
}
-/* Left rule groups the rows without wrapping them in another card. */
+/* Rows live in one bordered card with hairline separators. */
.chat-activity-group__body {
display: flex;
flex-direction: column;
gap: 0;
- margin: 2px 0 0 14px;
- padding-left: 8px;
- border-left: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
+ margin: 4px 0 0 2px;
+ border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
+ border-radius: var(--radius-md, 10px);
+ background: color-mix(in srgb, var(--card, var(--bg)) 55%, transparent);
+ overflow: hidden;
+}
+
+.chat-activity-group__body > .chat-bubble {
+ border-top: 1px solid color-mix(in srgb, var(--border) 62%, transparent);
+}
+
+.chat-activity-group__body > .chat-bubble:first-child {
+ border-top: 0;
+}
+
+.chat-activity-group__body .chat-tool-msg-summary {
+ padding: 7px 12px;
+ border-radius: 0;
+}
+
+.chat-activity-group__body .chat-tool-msg-body {
+ padding: 0 12px 10px 12px;
}
/* Reading Indicator: bubble chrome and sizing come from .chat-bubble and