summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/logic/tool-summary.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-15 08:44:04 +0900
committerAdam Malczewski <[email protected]>2026-10-15 08:44:04 +0900
commitc38aba84b29f13c4d6ac940b47ce231e55b6558a (patch)
tree5470958aab18ba956305f12d2fdfd5dcf25c2686 /src/features/chat/logic/tool-summary.ts
parent8676bd29452f7539622901cb5ae703a3fdaebd07 (diff)
downloaddispatch-web-c38aba84b29f13c4d6ac940b47ce231e55b6558a.tar.gz
dispatch-web-c38aba84b29f13c4d6ac940b47ce231e55b6558a.zip
feat(tool-call-cards): polish responsive headers and expanded details
Diffstat (limited to 'src/features/chat/logic/tool-summary.ts')
-rw-r--r--src/features/chat/logic/tool-summary.ts68
1 files changed, 50 insertions, 18 deletions
diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts
index 3b75a6b..2e7680c 100644
--- a/src/features/chat/logic/tool-summary.ts
+++ b/src/features/chat/logic/tool-summary.ts
@@ -4,9 +4,14 @@ export interface ToolCardView {
readonly name: string;
readonly status: "pending" | "ok" | "error";
readonly callSummary: string;
- readonly callBadges: readonly string[];
+ readonly callBadges: readonly {
+ readonly label: string;
+ readonly tone: "success" | "error";
+ }[];
readonly resultSummary: string | null;
+ readonly compactResultSummary: string | null;
readonly callDetail:
+ | { readonly kind: "unavailable"; readonly text: string }
| { readonly kind: "command"; readonly text: string }
| {
readonly kind: "edit";
@@ -68,6 +73,22 @@ function serialize(input: unknown, pretty = false): string {
}
}
+/** Small scalar arrays are easier to scan inline; nested/long values stay pretty JSON. */
+function fieldValue(value: unknown): string {
+ if (
+ Array.isArray(value) &&
+ value.every((item) => item === null || ["string", "number", "boolean"].includes(typeof item))
+ ) {
+ try {
+ const inline = `[${value.map((item) => JSON.stringify(item)).join(", ")}]`;
+ if (inline.length <= 80) return inline;
+ } catch {
+ // Non-wire values still use the safe serializer below.
+ }
+ }
+ return serialize(value, true);
+}
+
function arg(args: Args, ...keys: string[]): string {
for (const key of keys) {
const value = Object.hasOwn(args, key) ? args[key] : undefined;
@@ -146,7 +167,7 @@ function detail(name: string, input: unknown, args: Args): ToolCardView["callDet
return {
kind: "fields",
fields: (entries.length > 0 ? entries : [["input", input]]).map(([key, value]) => {
- const text = serialize(value, true);
+ const text = fieldValue(value);
return { key: String(key), value: text, multiline: text.includes("\n") };
}),
};
@@ -166,8 +187,7 @@ function responseSummary(name: string, content: string, isError: boolean): strin
const truncated = /\[Output truncated[^\]]*\]/i.test(content);
let summary: string;
if (timeout) summary = "timed out";
- else if (isError)
- summary = `failed${lines.length > 1 ? ` · ${count}` : first ? ` · ${first}` : ""}`;
+ else if (isError) summary = "failed";
else if (!content.trim()) summary = "no output";
else if (/^\(empty file\b/i.test(content)) summary = "empty file";
else {
@@ -189,7 +209,19 @@ function responseSummary(name: string, content: string, isError: boolean): strin
summary = count;
} else summary = first;
}
- return oneLine(`${summary}${truncated ? " · truncated" : ""}`, 96);
+ const label = `${summary}${truncated ? " · truncated" : ""}`;
+ // Even the wide header must leave room for the call; prefer a whole short
+ // fact over chopping an arbitrary one-line response halfway through a word.
+ return label.length <= 40 ? label : compactResponseSummary(label, lines.length);
+}
+
+/** Never CSS-clip response facts: swap to a whole short fact when space is scarce. */
+function compactResponseSummary(summary: string, count: number): string {
+ const fact = summary.split(" · ")[0] ?? summary;
+ if (/^(failed|timed out|no output|empty file|created|overwrote|\d+ lines?)$/.test(fact))
+ return fact;
+ if (/^\d+ replacements?$/.test(fact)) return "replaced";
+ return `${count} ${count === 1 ? "line" : "lines"}`;
}
export function viewToolEntry(entry: ToolEntry): ToolCardView {
@@ -216,16 +248,21 @@ export function viewToolEntry(entry: ToolEntry): ToolCardView {
const builtInSummary = summarize?.(args).trim();
const callSummary =
entry.call === null
- ? "call unavailable"
+ ? "Call in earlier history"
: oneLine(builtInSummary || generic || serialize(input));
- const callBadges: string[] = [];
+ const callBadges: ToolCardView["callBadges"][number][] = [];
if (entry.toolName === "edit_file" && entry.call !== null) {
if (typeof args.oldString === "string" && typeof args.newString === "string") {
- callBadges.push(`+${lineCount(args.newString)} −${lineCount(args.oldString)}`);
+ callBadges.push(
+ { label: `+${lineCount(args.newString)}`, tone: "success" },
+ { label: `−${lineCount(args.oldString)}`, tone: "error" },
+ );
}
}
const result = entry.result;
const lines = result === null ? [] : outputLines(result.content);
+ const resultSummary =
+ result === null ? null : responseSummary(entry.toolName, result.content, result.isError);
return {
name: entry.toolName,
status: result === null ? "pending" : result.isError ? "error" : "ok",
@@ -234,18 +271,13 @@ export function viewToolEntry(entry: ToolEntry): ToolCardView {
callDetail:
entry.call === null
? {
- kind: "fields",
- fields: [
- {
- key: "call",
- value: "Call unavailable (earlier history unloaded)",
- multiline: false,
- },
- ],
+ kind: "unavailable",
+ text: "The call is in earlier, unloaded history. Its response is shown below.",
}
: detail(entry.toolName, input, args),
- resultSummary:
- result === null ? null : responseSummary(entry.toolName, result.content, result.isError),
+ resultSummary,
+ compactResultSummary:
+ resultSummary === null ? null : compactResponseSummary(resultSummary, lines.length),
output:
result === null
? null