Files
openclaw/src/agents/tool-display-common.ts
Taras Lukavyi 0e068194ad fix(tool-display): cd ~/dir && npm install shows as run cd — compound commands truncated to first stage (#21925)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 4728bfe8e75dfcdf21f9ac22e7a26d081dc95d93
Co-authored-by: Lukavyi <1013690+Lukavyi@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-21 08:03:32 +05:30

1121 lines
29 KiB
TypeScript

export type ToolDisplayActionSpec = {
label?: string;
detailKeys?: string[];
};
export type ToolDisplaySpec = {
title?: string;
label?: string;
detailKeys?: string[];
actions?: Record<string, ToolDisplayActionSpec>;
};
export type CoerceDisplayValueOptions = {
includeFalse?: boolean;
includeZero?: boolean;
includeNonFinite?: boolean;
maxStringChars?: number;
maxArrayEntries?: number;
};
type ArgsRecord = Record<string, unknown>;
function asRecord(args: unknown): ArgsRecord | undefined {
return args && typeof args === "object" ? (args as ArgsRecord) : undefined;
}
export function normalizeToolName(name?: string): string {
return (name ?? "tool").trim();
}
export function defaultTitle(name: string): string {
const cleaned = name.replace(/_/g, " ").trim();
if (!cleaned) {
return "Tool";
}
return cleaned
.split(/\s+/)
.map((part) =>
part.length <= 2 && part.toUpperCase() === part
? part
: `${part.at(0)?.toUpperCase() ?? ""}${part.slice(1)}`,
)
.join(" ");
}
export function normalizeVerb(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
return trimmed.replace(/_/g, " ");
}
export function coerceDisplayValue(
value: unknown,
opts: CoerceDisplayValueOptions = {},
): string | undefined {
const maxStringChars = opts.maxStringChars ?? 160;
const maxArrayEntries = opts.maxArrayEntries ?? 3;
if (value === null || value === undefined) {
return undefined;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? "";
if (!firstLine) {
return undefined;
}
if (firstLine.length > maxStringChars) {
return `${firstLine.slice(0, Math.max(0, maxStringChars - 3))}`;
}
return firstLine;
}
if (typeof value === "boolean") {
if (!value && !opts.includeFalse) {
return undefined;
}
return value ? "true" : "false";
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
return opts.includeNonFinite ? String(value) : undefined;
}
if (value === 0 && !opts.includeZero) {
return undefined;
}
return String(value);
}
if (Array.isArray(value)) {
const values = value
.map((item) => coerceDisplayValue(item, opts))
.filter((item): item is string => Boolean(item));
if (values.length === 0) {
return undefined;
}
const preview = values.slice(0, maxArrayEntries).join(", ");
return values.length > maxArrayEntries ? `${preview}` : preview;
}
return undefined;
}
export function lookupValueByPath(args: unknown, path: string): unknown {
if (!args || typeof args !== "object") {
return undefined;
}
let current: unknown = args;
for (const segment of path.split(".")) {
if (!segment) {
return undefined;
}
if (!current || typeof current !== "object") {
return undefined;
}
const record = current as Record<string, unknown>;
current = record[segment];
}
return current;
}
export function formatDetailKey(raw: string, overrides: Record<string, string> = {}): string {
const segments = raw.split(".").filter(Boolean);
const last = segments.at(-1) ?? raw;
const override = overrides[last];
if (override) {
return override;
}
const cleaned = last.replace(/_/g, " ").replace(/-/g, " ");
const spaced = cleaned.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
return spaced.trim().toLowerCase() || last.toLowerCase();
}
export function resolvePathArg(args: unknown): string | undefined {
const record = asRecord(args);
if (!record) {
return undefined;
}
for (const candidate of [record.path, record.file_path, record.filePath]) {
if (typeof candidate !== "string") {
continue;
}
const trimmed = candidate.trim();
if (trimmed) {
return trimmed;
}
}
return undefined;
}
export function resolveReadDetail(args: unknown): string | undefined {
const record = asRecord(args);
if (!record) {
return undefined;
}
const path = resolvePathArg(record);
if (!path) {
return undefined;
}
const offsetRaw =
typeof record.offset === "number" && Number.isFinite(record.offset)
? Math.floor(record.offset)
: undefined;
const limitRaw =
typeof record.limit === "number" && Number.isFinite(record.limit)
? Math.floor(record.limit)
: undefined;
const offset = offsetRaw !== undefined ? Math.max(1, offsetRaw) : undefined;
const limit = limitRaw !== undefined ? Math.max(1, limitRaw) : undefined;
if (offset !== undefined && limit !== undefined) {
const unit = limit === 1 ? "line" : "lines";
return `${unit} ${offset}-${offset + limit - 1} from ${path}`;
}
if (offset !== undefined) {
return `from line ${offset} in ${path}`;
}
if (limit !== undefined) {
const unit = limit === 1 ? "line" : "lines";
return `first ${limit} ${unit} of ${path}`;
}
return `from ${path}`;
}
export function resolveWriteDetail(toolKey: string, args: unknown): string | undefined {
const record = asRecord(args);
if (!record) {
return undefined;
}
const path =
resolvePathArg(record) ?? (typeof record.url === "string" ? record.url.trim() : undefined);
if (!path) {
return undefined;
}
if (toolKey === "attach") {
return `from ${path}`;
}
const destinationPrefix = toolKey === "edit" ? "in" : "to";
const content =
typeof record.content === "string"
? record.content
: typeof record.newText === "string"
? record.newText
: typeof record.new_string === "string"
? record.new_string
: undefined;
if (content && content.length > 0) {
return `${destinationPrefix} ${path} (${content.length} chars)`;
}
return `${destinationPrefix} ${path}`;
}
export function resolveWebSearchDetail(args: unknown): string | undefined {
const record = asRecord(args);
if (!record) {
return undefined;
}
const query = typeof record.query === "string" ? record.query.trim() : undefined;
const count =
typeof record.count === "number" && Number.isFinite(record.count) && record.count > 0
? Math.floor(record.count)
: undefined;
if (!query) {
return undefined;
}
return count !== undefined ? `for "${query}" (top ${count})` : `for "${query}"`;
}
export function resolveWebFetchDetail(args: unknown): string | undefined {
const record = asRecord(args);
if (!record) {
return undefined;
}
const url = typeof record.url === "string" ? record.url.trim() : undefined;
if (!url) {
return undefined;
}
const mode = typeof record.extractMode === "string" ? record.extractMode.trim() : undefined;
const maxChars =
typeof record.maxChars === "number" && Number.isFinite(record.maxChars) && record.maxChars > 0
? Math.floor(record.maxChars)
: undefined;
const suffix = [
mode ? `mode ${mode}` : undefined,
maxChars !== undefined ? `max ${maxChars} chars` : undefined,
]
.filter((value): value is string => Boolean(value))
.join(", ");
return suffix ? `from ${url} (${suffix})` : `from ${url}`;
}
function stripOuterQuotes(value: string | undefined): string | undefined {
if (!value) {
return value;
}
const trimmed = value.trim();
if (
trimmed.length >= 2 &&
((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'")))
) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
}
function splitShellWords(input: string | undefined, maxWords = 48): string[] {
if (!input) {
return [];
}
const words: string[] = [];
let current = "";
let quote: '"' | "'" | undefined;
let escaped = false;
for (let i = 0; i < input.length; i += 1) {
const char = input[i];
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (quote) {
if (char === quote) {
quote = undefined;
} else {
current += char;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (/\s/.test(char)) {
if (!current) {
continue;
}
words.push(current);
if (words.length >= maxWords) {
return words;
}
current = "";
continue;
}
current += char;
}
if (current) {
words.push(current);
}
return words;
}
function binaryName(token: string | undefined): string | undefined {
if (!token) {
return undefined;
}
const cleaned = stripOuterQuotes(token) ?? token;
const segment = cleaned.split(/[/]/).at(-1) ?? cleaned;
return segment.trim().toLowerCase();
}
function optionValue(words: string[], names: string[]): string | undefined {
const lookup = new Set(names);
for (let i = 0; i < words.length; i += 1) {
const token = words[i];
if (!token) {
continue;
}
if (lookup.has(token)) {
const value = words[i + 1];
if (value && !value.startsWith("-")) {
return value;
}
continue;
}
for (const name of names) {
if (name.startsWith("--") && token.startsWith(`${name}=`)) {
return token.slice(name.length + 1);
}
}
}
return undefined;
}
function positionalArgs(words: string[], from = 1, optionsWithValue: string[] = []): string[] {
const args: string[] = [];
const takesValue = new Set(optionsWithValue);
for (let i = from; i < words.length; i += 1) {
const token = words[i];
if (!token) {
continue;
}
if (token === "--") {
for (let j = i + 1; j < words.length; j += 1) {
const candidate = words[j];
if (candidate) {
args.push(candidate);
}
}
break;
}
if (token.startsWith("--")) {
if (token.includes("=")) {
continue;
}
if (takesValue.has(token)) {
i += 1;
}
continue;
}
if (token.startsWith("-")) {
if (takesValue.has(token)) {
i += 1;
}
continue;
}
args.push(token);
}
return args;
}
function firstPositional(
words: string[],
from = 1,
optionsWithValue: string[] = [],
): string | undefined {
return positionalArgs(words, from, optionsWithValue)[0];
}
function trimLeadingEnv(words: string[]): string[] {
if (words.length === 0) {
return words;
}
let index = 0;
if (binaryName(words[0]) === "env") {
index = 1;
while (index < words.length) {
const token = words[index];
if (!token) {
break;
}
if (token.startsWith("-")) {
index += 1;
continue;
}
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
index += 1;
continue;
}
break;
}
return words.slice(index);
}
while (index < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index])) {
index += 1;
}
return words.slice(index);
}
function unwrapShellWrapper(command: string): string {
const words = splitShellWords(command, 10);
if (words.length < 3) {
return command;
}
const bin = binaryName(words[0]);
if (!(bin === "bash" || bin === "sh" || bin === "zsh" || bin === "fish")) {
return command;
}
const flagIndex = words.findIndex(
(token, index) => index > 0 && (token === "-c" || token === "-lc" || token === "-ic"),
);
if (flagIndex === -1) {
return command;
}
const inner = words
.slice(flagIndex + 1)
.join(" ")
.trim();
return inner ? (stripOuterQuotes(inner) ?? command) : command;
}
function scanTopLevelChars(
command: string,
visit: (char: string, index: number) => boolean | void,
): void {
let quote: '"' | "'" | undefined;
let escaped = false;
for (let i = 0; i < command.length; i += 1) {
const char = command[i];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (quote) {
if (char === quote) {
quote = undefined;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (visit(char, i) === false) {
return;
}
}
}
function splitTopLevelStages(command: string): string[] {
const parts: string[] = [];
let start = 0;
scanTopLevelChars(command, (char, index) => {
if (char === ";") {
parts.push(command.slice(start, index));
start = index + 1;
return true;
}
if ((char === "&" || char === "|") && command[index + 1] === char) {
parts.push(command.slice(start, index));
start = index + 2;
return true;
}
return true;
});
parts.push(command.slice(start));
return parts.map((part) => part.trim()).filter((part) => part.length > 0);
}
function splitTopLevelPipes(command: string): string[] {
const parts: string[] = [];
let start = 0;
scanTopLevelChars(command, (char, index) => {
if (char === "|" && command[index - 1] !== "|" && command[index + 1] !== "|") {
parts.push(command.slice(start, index));
start = index + 1;
}
return true;
});
parts.push(command.slice(start));
return parts.map((part) => part.trim()).filter((part) => part.length > 0);
}
function parseChdirTarget(head: string): string | undefined {
const words = splitShellWords(head, 3);
const bin = binaryName(words[0]);
if (bin === "cd" || bin === "pushd") {
return words[1] || undefined;
}
return undefined;
}
function isChdirCommand(head: string): boolean {
const bin = binaryName(splitShellWords(head, 2)[0]);
return bin === "cd" || bin === "pushd" || bin === "popd";
}
function isPopdCommand(head: string): boolean {
return binaryName(splitShellWords(head, 2)[0]) === "popd";
}
type PreambleResult = {
command: string;
chdirPath?: string;
};
function stripShellPreamble(command: string): PreambleResult {
let rest = command.trim();
let chdirPath: string | undefined;
for (let i = 0; i < 4; i += 1) {
// Find the first top-level separator (&&, ||, ;, \n) respecting quotes/escaping.
let first: { index: number; length: number; isOr?: boolean } | undefined;
scanTopLevelChars(rest, (char, idx) => {
if (char === "&" && rest[idx + 1] === "&") {
first = { index: idx, length: 2 };
return false;
}
if (char === "|" && rest[idx + 1] === "|") {
first = { index: idx, length: 2, isOr: true };
return false;
}
if (char === ";" || char === "\n") {
first = { index: idx, length: 1 };
return false;
}
});
const head = (first ? rest.slice(0, first.index) : rest).trim();
// cd/pushd/popd is preamble when followed by && / ; / \n, or when we already
// stripped at least one preamble segment (handles chained cd's like `cd /tmp && cd /app`).
// NOT for || — `cd /app || npm install` means npm runs when cd *fails*, so (in /app) is wrong.
const isChdir = (first ? !first.isOr : i > 0) && isChdirCommand(head);
const isPreamble =
head.startsWith("set ") || head.startsWith("export ") || head.startsWith("unset ") || isChdir;
if (!isPreamble) {
break;
}
if (isChdir) {
// popd returns to the previous directory, so inferred cwd from earlier
// preamble steps is no longer reliable.
if (isPopdCommand(head)) {
chdirPath = undefined;
} else {
chdirPath = parseChdirTarget(head) ?? chdirPath;
}
}
rest = first ? rest.slice(first.index + first.length).trimStart() : "";
if (!rest) {
break;
}
}
return { command: rest.trim(), chdirPath };
}
function summarizeKnownExec(words: string[]): string {
if (words.length === 0) {
return "run command";
}
const bin = binaryName(words[0]) ?? "command";
if (bin === "git") {
const globalWithValue = new Set([
"-C",
"-c",
"--git-dir",
"--work-tree",
"--namespace",
"--config-env",
]);
const gitCwd = optionValue(words, ["-C"]);
let sub: string | undefined;
for (let i = 1; i < words.length; i += 1) {
const token = words[i];
if (!token) {
continue;
}
if (token === "--") {
sub = firstPositional(words, i + 1);
break;
}
if (token.startsWith("--")) {
if (token.includes("=")) {
continue;
}
if (globalWithValue.has(token)) {
i += 1;
}
continue;
}
if (token.startsWith("-")) {
if (globalWithValue.has(token)) {
i += 1;
}
continue;
}
sub = token;
break;
}
const map: Record<string, string> = {
status: "check git status",
diff: "check git diff",
log: "view git history",
show: "show git object",
branch: "list git branches",
checkout: "switch git branch",
switch: "switch git branch",
commit: "create git commit",
pull: "pull git changes",
push: "push git changes",
fetch: "fetch git changes",
merge: "merge git changes",
rebase: "rebase git branch",
add: "stage git changes",
restore: "restore git files",
reset: "reset git state",
stash: "stash git changes",
};
if (sub && map[sub]) {
return map[sub];
}
if (!sub || sub.startsWith("/") || sub.startsWith("~") || sub.includes("/")) {
return gitCwd ? `run git command in ${gitCwd}` : "run git command";
}
return `run git ${sub}`;
}
if (bin === "grep" || bin === "rg" || bin === "ripgrep") {
const positional = positionalArgs(words, 1, [
"-e",
"--regexp",
"-f",
"--file",
"-m",
"--max-count",
"-A",
"--after-context",
"-B",
"--before-context",
"-C",
"--context",
]);
const pattern = optionValue(words, ["-e", "--regexp"]) ?? positional[0];
const target = positional.length > 1 ? positional.at(-1) : undefined;
if (pattern) {
return target ? `search "${pattern}" in ${target}` : `search "${pattern}"`;
}
return "search text";
}
if (bin === "find") {
const path = words[1] && !words[1].startsWith("-") ? words[1] : ".";
const name = optionValue(words, ["-name", "-iname"]);
return name ? `find files named "${name}" in ${path}` : `find files in ${path}`;
}
if (bin === "ls") {
const target = firstPositional(words, 1);
return target ? `list files in ${target}` : "list files";
}
if (bin === "head" || bin === "tail") {
const lines =
optionValue(words, ["-n", "--lines"]) ??
words
.slice(1)
.find((token) => /^-\d+$/.test(token))
?.slice(1);
const positional = positionalArgs(words, 1, ["-n", "--lines"]);
let target = positional.at(-1);
if (target && /^\d+$/.test(target) && positional.length === 1) {
target = undefined;
}
const side = bin === "head" ? "first" : "last";
const unit = lines === "1" ? "line" : "lines";
if (lines && target) {
return `show ${side} ${lines} ${unit} of ${target}`;
}
if (lines) {
return `show ${side} ${lines} ${unit}`;
}
if (target) {
return `show ${target}`;
}
return `show ${bin} output`;
}
if (bin === "cat") {
const target = firstPositional(words, 1);
return target ? `show ${target}` : "show output";
}
if (bin === "sed") {
const expression = optionValue(words, ["-e", "--expression"]);
const positional = positionalArgs(words, 1, ["-e", "--expression", "-f", "--file"]);
const script = expression ?? positional[0];
const target = expression ? positional[0] : positional[1];
if (script) {
const compact = (stripOuterQuotes(script) ?? script).replace(/\s+/g, "");
const range = compact.match(/^([0-9]+),([0-9]+)p$/);
if (range) {
return target
? `print lines ${range[1]}-${range[2]} from ${target}`
: `print lines ${range[1]}-${range[2]}`;
}
const single = compact.match(/^([0-9]+)p$/);
if (single) {
return target ? `print line ${single[1]} from ${target}` : `print line ${single[1]}`;
}
}
return target ? `run sed on ${target}` : "run sed transform";
}
if (bin === "printf" || bin === "echo") {
return "print text";
}
if (bin === "cp" || bin === "mv") {
const positional = positionalArgs(words, 1, ["-t", "--target-directory", "-S", "--suffix"]);
const src = positional[0];
const dst = positional[1];
const action = bin === "cp" ? "copy" : "move";
if (src && dst) {
return `${action} ${src} to ${dst}`;
}
if (src) {
return `${action} ${src}`;
}
return `${action} files`;
}
if (bin === "rm") {
const target = firstPositional(words, 1);
return target ? `remove ${target}` : "remove files";
}
if (bin === "mkdir") {
const target = firstPositional(words, 1);
return target ? `create folder ${target}` : "create folder";
}
if (bin === "touch") {
const target = firstPositional(words, 1);
return target ? `create file ${target}` : "create file";
}
if (bin === "curl" || bin === "wget") {
const url = words.find((token) => /^https?:\/\//i.test(token));
return url ? `fetch ${url}` : "fetch url";
}
if (bin === "npm" || bin === "pnpm" || bin === "yarn" || bin === "bun") {
const positional = positionalArgs(words, 1, ["--prefix", "-C", "--cwd", "--config"]);
const sub = positional[0] ?? "command";
const map: Record<string, string> = {
install: "install dependencies",
test: "run tests",
build: "run build",
start: "start app",
lint: "run lint",
run: positional[1] ? `run ${positional[1]}` : "run script",
};
return map[sub] ?? `run ${bin} ${sub}`;
}
if (bin === "node" || bin === "python" || bin === "python3" || bin === "ruby" || bin === "php") {
const heredoc = words.slice(1).find((token) => token.startsWith("<<"));
if (heredoc) {
return `run ${bin} inline script (heredoc)`;
}
const inline =
bin === "node"
? optionValue(words, ["-e", "--eval"])
: bin === "python" || bin === "python3"
? optionValue(words, ["-c"])
: undefined;
if (inline !== undefined) {
return `run ${bin} inline script`;
}
const nodeOptsWithValue = ["-e", "--eval", "-m"];
const otherOptsWithValue = ["-c", "-e", "--eval", "-m"];
const script = firstPositional(
words,
1,
bin === "node" ? nodeOptsWithValue : otherOptsWithValue,
);
if (!script) {
return `run ${bin}`;
}
if (bin === "node") {
const mode =
words.includes("--check") || words.includes("-c")
? "check js syntax for"
: "run node script";
return `${mode} ${script}`;
}
return `run ${bin} ${script}`;
}
if (bin === "openclaw") {
const sub = firstPositional(words, 1);
return sub ? `run openclaw ${sub}` : "run openclaw";
}
const arg = firstPositional(words, 1);
if (!arg || arg.length > 48) {
return `run ${bin}`;
}
return /^[A-Za-z0-9._/-]+$/.test(arg) ? `run ${bin} ${arg}` : `run ${bin}`;
}
function summarizePipeline(stage: string): string {
const pipeline = splitTopLevelPipes(stage);
if (pipeline.length > 1) {
const first = summarizeKnownExec(trimLeadingEnv(splitShellWords(pipeline[0])));
const last = summarizeKnownExec(trimLeadingEnv(splitShellWords(pipeline[pipeline.length - 1])));
const extra = pipeline.length > 2 ? ` (+${pipeline.length - 2} steps)` : "";
return `${first} -> ${last}${extra}`;
}
return summarizeKnownExec(trimLeadingEnv(splitShellWords(stage)));
}
type ExecSummary = {
text: string;
chdirPath?: string;
allGeneric?: boolean;
};
function summarizeExecCommand(command: string): ExecSummary | undefined {
const { command: cleaned, chdirPath } = stripShellPreamble(command);
if (!cleaned) {
// All segments were preamble (e.g. `cd /tmp && cd /app`) — preserve chdirPath for context.
return chdirPath ? { text: "", chdirPath } : undefined;
}
const stages = splitTopLevelStages(cleaned);
if (stages.length === 0) {
return undefined;
}
const summaries = stages.map((stage) => summarizePipeline(stage));
const text = summaries.length === 1 ? summaries[0] : summaries.join(" → ");
const allGeneric = summaries.every((s) => isGenericSummary(s));
return { text, chdirPath, allGeneric };
}
/** Known summarizer prefixes that indicate a recognized command with useful context. */
const KNOWN_SUMMARY_PREFIXES = [
"check git",
"view git",
"show git",
"list git",
"switch git",
"create git",
"pull git",
"push git",
"fetch git",
"merge git",
"rebase git",
"stage git",
"restore git",
"reset git",
"stash git",
"search ",
"find files",
"list files",
"show first",
"show last",
"print line",
"print text",
"copy ",
"move ",
"remove ",
"create folder",
"create file",
"fetch http",
"install dependencies",
"run tests",
"run build",
"start app",
"run lint",
"run openclaw",
"run node script",
"run node ",
"run python",
"run ruby",
"run php",
"run sed",
"run git ",
"run npm ",
"run pnpm ",
"run yarn ",
"run bun ",
"check js syntax",
];
/** True when the summary is generic and the raw command would be more informative. */
function isGenericSummary(summary: string): boolean {
if (summary === "run command") {
return true;
}
// "run <binary>" or "run <binary> <arg>" without useful context
if (summary.startsWith("run ")) {
return !KNOWN_SUMMARY_PREFIXES.some((prefix) => summary.startsWith(prefix));
}
return false;
}
/** Compact the raw command for display: collapse whitespace, trim long strings. */
function compactRawCommand(raw: string, maxLength = 120): string {
const oneLine = raw
.replace(/\s*\n\s*/g, " ")
.replace(/\s{2,}/g, " ")
.trim();
if (oneLine.length <= maxLength) {
return oneLine;
}
return `${oneLine.slice(0, Math.max(0, maxLength - 1))}`;
}
export function resolveExecDetail(args: unknown): string | undefined {
const record = asRecord(args);
if (!record) {
return undefined;
}
const raw = typeof record.command === "string" ? record.command.trim() : undefined;
if (!raw) {
return undefined;
}
const unwrapped = unwrapShellWrapper(raw);
const result = summarizeExecCommand(unwrapped) ?? summarizeExecCommand(raw);
const summary = result?.text || "run command";
const cwdRaw =
typeof record.workdir === "string"
? record.workdir
: typeof record.cwd === "string"
? record.cwd
: undefined;
// Explicit workdir takes priority; fall back to cd path extracted from the command.
const cwd = cwdRaw?.trim() || result?.chdirPath || undefined;
const compact = compactRawCommand(unwrapped);
// When ALL stages are generic (e.g. "run jj"), use the compact raw command instead.
// For mixed stages like "run cargo build → run tests", keep the summary since some parts are useful.
if (result?.allGeneric !== false && isGenericSummary(summary)) {
return cwd ? `${compact} (in ${cwd})` : compact;
}
const displaySummary = cwd ? `${summary} (in ${cwd})` : summary;
// Append the raw command when the summary differs meaningfully from the command itself.
if (compact && compact !== displaySummary && compact !== summary) {
return `${displaySummary}\n\n\`${compact}\``;
}
return displaySummary;
}
export function resolveActionSpec(
spec: ToolDisplaySpec | undefined,
action: string | undefined,
): ToolDisplayActionSpec | undefined {
if (!spec || !action) {
return undefined;
}
return spec.actions?.[action] ?? undefined;
}
export function resolveDetailFromKeys(
args: unknown,
keys: string[],
opts: {
mode: "first" | "summary";
coerce?: CoerceDisplayValueOptions;
maxEntries?: number;
formatKey?: (raw: string) => string;
},
): string | undefined {
if (opts.mode === "first") {
for (const key of keys) {
const value = lookupValueByPath(args, key);
const display = coerceDisplayValue(value, opts.coerce);
if (display) {
return display;
}
}
return undefined;
}
const entries: Array<{ label: string; value: string }> = [];
for (const key of keys) {
const value = lookupValueByPath(args, key);
const display = coerceDisplayValue(value, opts.coerce);
if (!display) {
continue;
}
entries.push({ label: opts.formatKey ? opts.formatKey(key) : key, value: display });
}
if (entries.length === 0) {
return undefined;
}
if (entries.length === 1) {
return entries[0].value;
}
const seen = new Set<string>();
const unique: Array<{ label: string; value: string }> = [];
for (const entry of entries) {
const token = `${entry.label}:${entry.value}`;
if (seen.has(token)) {
continue;
}
seen.add(token);
unique.push(entry);
}
if (unique.length === 0) {
return undefined;
}
return unique
.slice(0, opts.maxEntries ?? 8)
.map((entry) => `${entry.label} ${entry.value}`)
.join(" · ");
}