684 lines
21 KiB
TypeScript
684 lines
21 KiB
TypeScript
// Import useInput from vendored Ink for bracketed paste support
|
||
import { Box, Text, useInput } from "ink";
|
||
import type React from "react";
|
||
import { memo, useEffect, useMemo, useState } from "react";
|
||
import type { ApprovalContext } from "../../permissions/analyzer";
|
||
import { type AdvancedDiffSuccess, computeAdvancedDiff } from "../helpers/diff";
|
||
import { resolvePlaceholders } from "../helpers/pasteRegistry";
|
||
import type { ApprovalRequest } from "../helpers/stream";
|
||
import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer";
|
||
import { colors } from "./colors";
|
||
import { PasteAwareTextInput } from "./PasteAwareTextInput";
|
||
|
||
type Props = {
|
||
approvals: ApprovalRequest[];
|
||
approvalContexts: ApprovalContext[];
|
||
progress?: { current: number; total: number };
|
||
totalTools?: number;
|
||
isExecuting?: boolean;
|
||
onApproveAll: () => void;
|
||
onApproveAlways: (scope?: "project" | "session") => void;
|
||
onDenyAll: (reason: string) => void;
|
||
};
|
||
|
||
type DynamicPreviewProps = {
|
||
toolName: string;
|
||
toolArgs: string;
|
||
parsedArgs: Record<string, unknown> | null;
|
||
precomputedDiff: AdvancedDiffSuccess | null;
|
||
};
|
||
|
||
// Options renderer - memoized to prevent unnecessary re-renders
|
||
const OptionsRenderer = memo(
|
||
({
|
||
options,
|
||
selectedOption,
|
||
}: {
|
||
options: Array<{ label: string; action: () => void }>;
|
||
selectedOption: number;
|
||
}) => {
|
||
return (
|
||
<Box flexDirection="column">
|
||
{options.map((option, index) => {
|
||
const isSelected = index === selectedOption;
|
||
const color = isSelected ? colors.approval.header : undefined;
|
||
return (
|
||
<Box key={option.label} flexDirection="row">
|
||
<Box width={2} flexShrink={0}>
|
||
<Text color={color}>{isSelected ? ">" : " "}</Text>
|
||
</Box>
|
||
<Box flexGrow={1}>
|
||
<Text color={color}>
|
||
{index + 1}. {option.label}
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
})}
|
||
</Box>
|
||
);
|
||
},
|
||
);
|
||
|
||
OptionsRenderer.displayName = "OptionsRenderer";
|
||
|
||
// Dynamic preview component - defined outside to avoid recreation on every render
|
||
const DynamicPreview: React.FC<DynamicPreviewProps> = ({
|
||
toolName,
|
||
toolArgs,
|
||
parsedArgs,
|
||
precomputedDiff,
|
||
}) => {
|
||
const t = toolName.toLowerCase();
|
||
|
||
if (
|
||
t === "bash" ||
|
||
t === "shell_command" ||
|
||
t === "shellcommand" ||
|
||
t === "run_shell_command" ||
|
||
t === "runshellcommand"
|
||
) {
|
||
const cmdVal = parsedArgs?.command;
|
||
const cmd =
|
||
typeof cmdVal === "string" ? cmdVal : toolArgs || "(no arguments)";
|
||
const descVal = parsedArgs?.description;
|
||
const desc = typeof descVal === "string" ? descVal : "";
|
||
|
||
return (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
<Text>{cmd}</Text>
|
||
{desc ? <Text dimColor>{desc}</Text> : null}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
if (t === "shell") {
|
||
const cmdVal = parsedArgs?.command;
|
||
const cmd = Array.isArray(cmdVal)
|
||
? cmdVal.join(" ")
|
||
: typeof cmdVal === "string"
|
||
? cmdVal
|
||
: "(no command)";
|
||
const justificationVal = parsedArgs?.justification;
|
||
const justification =
|
||
typeof justificationVal === "string" ? justificationVal : "";
|
||
|
||
return (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
<Text>{cmd}</Text>
|
||
{justification ? <Text dimColor>{justification}</Text> : null}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
if (
|
||
t === "ls" ||
|
||
t === "list_dir" ||
|
||
t === "listdir" ||
|
||
t === "list_directory" ||
|
||
t === "listdirectory"
|
||
) {
|
||
const pathVal =
|
||
parsedArgs?.path || parsedArgs?.target_directory || parsedArgs?.dir_path;
|
||
const path = typeof pathVal === "string" ? pathVal : "(current directory)";
|
||
const ignoreVal = parsedArgs?.ignore || parsedArgs?.ignore_globs;
|
||
const ignore =
|
||
Array.isArray(ignoreVal) && ignoreVal.length > 0
|
||
? ` (ignoring: ${ignoreVal.join(", ")})`
|
||
: "";
|
||
|
||
return (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
<Text>List files in: {path}</Text>
|
||
{ignore ? <Text dimColor>{ignore}</Text> : null}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
if (t === "read" || t === "read_file" || t === "readfile") {
|
||
const pathVal = parsedArgs?.file_path || parsedArgs?.target_file;
|
||
const path = typeof pathVal === "string" ? pathVal : "(no file specified)";
|
||
const offsetVal = parsedArgs?.offset;
|
||
const limitVal = parsedArgs?.limit;
|
||
const rangeInfo =
|
||
typeof offsetVal === "number" || typeof limitVal === "number"
|
||
? ` (lines ${offsetVal ?? 1}–${typeof offsetVal === "number" && typeof limitVal === "number" ? offsetVal + limitVal : "end"})`
|
||
: "";
|
||
|
||
return (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
<Text>
|
||
Read file: {path}
|
||
{rangeInfo}
|
||
</Text>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
if (
|
||
t === "grep" ||
|
||
t === "grep_files" ||
|
||
t === "grepfiles" ||
|
||
t === "search_file_content" ||
|
||
t === "searchfilecontent"
|
||
) {
|
||
const patternVal = parsedArgs?.pattern;
|
||
const pattern =
|
||
typeof patternVal === "string" ? patternVal : "(no pattern)";
|
||
const pathVal = parsedArgs?.path;
|
||
const path = typeof pathVal === "string" ? ` in ${pathVal}` : "";
|
||
const includeVal = parsedArgs?.include || parsedArgs?.glob;
|
||
const includeInfo =
|
||
typeof includeVal === "string" ? ` (${includeVal})` : "";
|
||
|
||
return (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
<Text>
|
||
Search for: {pattern}
|
||
{path}
|
||
{includeInfo}
|
||
</Text>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
if (t === "apply_patch" || t === "applypatch") {
|
||
const inputVal = parsedArgs?.input;
|
||
const patchPreview =
|
||
typeof inputVal === "string" && inputVal.length > 100
|
||
? `${inputVal.slice(0, 100)}...`
|
||
: typeof inputVal === "string"
|
||
? inputVal
|
||
: "(no patch content)";
|
||
|
||
return (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
<Text>Apply patch:</Text>
|
||
<Text dimColor>{patchPreview}</Text>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
if (t === "update_plan" || t === "updateplan") {
|
||
const planVal = parsedArgs?.plan;
|
||
const explanationVal = parsedArgs?.explanation;
|
||
|
||
if (Array.isArray(planVal)) {
|
||
const explanation =
|
||
typeof explanationVal === "string" ? explanationVal : undefined;
|
||
|
||
return (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
{explanation && (
|
||
<Text italic dimColor>
|
||
{explanation}
|
||
</Text>
|
||
)}
|
||
{planVal
|
||
.map((item: unknown, idx: number) => {
|
||
if (typeof item === "object" && item !== null) {
|
||
const stepItem = item as { step?: string; status?: string };
|
||
const step = stepItem.step || "(no description)";
|
||
const status = stepItem.status || "pending";
|
||
const checkbox = status === "completed" ? "☒" : "☐";
|
||
return (
|
||
<Text key={`${idx}-${step.slice(0, 20)}`}>
|
||
{checkbox} {step}
|
||
</Text>
|
||
);
|
||
}
|
||
return null;
|
||
})
|
||
.filter((el): el is React.ReactElement => el !== null)}
|
||
</Box>
|
||
);
|
||
}
|
||
}
|
||
|
||
if (t === "glob") {
|
||
const patternVal = parsedArgs?.pattern;
|
||
const pattern =
|
||
typeof patternVal === "string" ? patternVal : "(no pattern)";
|
||
const dirPathVal = parsedArgs?.dir_path;
|
||
const dirInfo = typeof dirPathVal === "string" ? ` in ${dirPathVal}` : "";
|
||
|
||
return (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
<Text>
|
||
Find files matching: {pattern}
|
||
{dirInfo}
|
||
</Text>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// File edit previews: write/edit/multi_edit/replace/write_file
|
||
if (
|
||
(t === "write" ||
|
||
t === "edit" ||
|
||
t === "multiedit" ||
|
||
t === "replace" ||
|
||
t === "write_file" ||
|
||
t === "writefile") &&
|
||
parsedArgs
|
||
) {
|
||
try {
|
||
const filePath = String(parsedArgs.file_path || "");
|
||
if (!filePath) throw new Error("no file_path");
|
||
|
||
if (precomputedDiff) {
|
||
return (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
{t === "write" || t === "write_file" || t === "writefile" ? (
|
||
<AdvancedDiffRenderer
|
||
precomputed={precomputedDiff}
|
||
kind="write"
|
||
filePath={filePath}
|
||
content={String(parsedArgs.content ?? "")}
|
||
showHeader={false}
|
||
/>
|
||
) : t === "edit" || t === "replace" ? (
|
||
<AdvancedDiffRenderer
|
||
precomputed={precomputedDiff}
|
||
kind="edit"
|
||
filePath={filePath}
|
||
oldString={String(parsedArgs.old_string ?? "")}
|
||
newString={String(parsedArgs.new_string ?? "")}
|
||
replaceAll={Boolean(parsedArgs.replace_all)}
|
||
showHeader={false}
|
||
/>
|
||
) : (
|
||
<AdvancedDiffRenderer
|
||
precomputed={precomputedDiff}
|
||
kind="multi_edit"
|
||
filePath={filePath}
|
||
edits={
|
||
(parsedArgs.edits as Array<{
|
||
old_string: string;
|
||
new_string: string;
|
||
replace_all?: boolean;
|
||
}>) || []
|
||
}
|
||
showHeader={false}
|
||
/>
|
||
)}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// Fallback to non-precomputed rendering
|
||
if (t === "write" || t === "write_file" || t === "writefile") {
|
||
return (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
<AdvancedDiffRenderer
|
||
kind="write"
|
||
filePath={filePath}
|
||
content={String(parsedArgs.content ?? "")}
|
||
showHeader={false}
|
||
/>
|
||
</Box>
|
||
);
|
||
}
|
||
if (t === "edit" || t === "replace") {
|
||
return (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
<AdvancedDiffRenderer
|
||
kind="edit"
|
||
filePath={filePath}
|
||
oldString={String(parsedArgs.old_string ?? "")}
|
||
newString={String(parsedArgs.new_string ?? "")}
|
||
replaceAll={Boolean(parsedArgs.replace_all)}
|
||
showHeader={false}
|
||
/>
|
||
</Box>
|
||
);
|
||
}
|
||
if (t === "multiedit") {
|
||
const edits =
|
||
(parsedArgs.edits as Array<{
|
||
old_string: string;
|
||
new_string: string;
|
||
replace_all?: boolean;
|
||
}>) || [];
|
||
return (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
<AdvancedDiffRenderer
|
||
kind="multi_edit"
|
||
filePath={filePath}
|
||
edits={edits}
|
||
showHeader={false}
|
||
/>
|
||
</Box>
|
||
);
|
||
}
|
||
} catch {
|
||
// Fall through to default
|
||
}
|
||
}
|
||
|
||
// Default for file-edit tools when args not parseable yet
|
||
if (
|
||
t === "write" ||
|
||
t === "edit" ||
|
||
t === "multiedit" ||
|
||
t === "replace" ||
|
||
t === "write_file"
|
||
) {
|
||
return (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
<Text dimColor>Preparing preview…</Text>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// For non-edit tools, pretty-print JSON if available
|
||
let pretty: string;
|
||
if (parsedArgs && typeof parsedArgs === "object") {
|
||
const clone = { ...parsedArgs };
|
||
// Remove noisy fields
|
||
if ("request_heartbeat" in clone) delete clone.request_heartbeat;
|
||
pretty = JSON.stringify(clone, null, 2);
|
||
} else {
|
||
pretty = toolArgs || "(no arguments)";
|
||
}
|
||
|
||
return (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
<Text>{pretty}</Text>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export const ApprovalDialog = memo(function ApprovalDialog({
|
||
approvals,
|
||
approvalContexts,
|
||
progress,
|
||
totalTools,
|
||
isExecuting,
|
||
onApproveAll,
|
||
onApproveAlways,
|
||
onDenyAll,
|
||
}: Props) {
|
||
const [selectedOption, setSelectedOption] = useState(0);
|
||
const [isEnteringReason, setIsEnteringReason] = useState(false);
|
||
const [denyReason, setDenyReason] = useState("");
|
||
|
||
// Use first approval/context for now (backward compat)
|
||
// TODO: Support individual approval decisions for multiple approvals
|
||
// Note: Parent ensures approvals.length > 0 before rendering this component
|
||
const approvalRequest = approvals[0];
|
||
const approvalContext = approvalContexts[0] || null;
|
||
|
||
// Reset state when approval changes (e.g., moving from tool 2 to tool 3)
|
||
// biome-ignore lint/correctness/useExhaustiveDependencies: need to trigger on progress change
|
||
useEffect(() => {
|
||
setSelectedOption(0);
|
||
setIsEnteringReason(false);
|
||
setDenyReason("");
|
||
}, [progress?.current]);
|
||
|
||
// Build options based on approval context
|
||
const options = useMemo(() => {
|
||
const approvalLabel =
|
||
progress && progress.total > 1
|
||
? "Yes, approve this tool"
|
||
: "Yes, just this once";
|
||
const opts = [{ label: approvalLabel, action: onApproveAll }];
|
||
|
||
// Add context-aware approval option if available (only for single approvals)
|
||
if (approvalContext?.allowPersistence) {
|
||
opts.push({
|
||
label: approvalContext.approveAlwaysText,
|
||
action: () =>
|
||
onApproveAlways(
|
||
approvalContext.defaultScope === "user"
|
||
? "session"
|
||
: approvalContext.defaultScope,
|
||
),
|
||
});
|
||
}
|
||
|
||
// Add deny option
|
||
const denyLabel =
|
||
progress && progress.total > 1
|
||
? "No, deny this tool (esc)"
|
||
: "No, and tell Letta what to do differently (esc)";
|
||
opts.push({
|
||
label: denyLabel,
|
||
action: () => {}, // Handled separately via setIsEnteringReason
|
||
});
|
||
|
||
return opts;
|
||
}, [progress, approvalContext, onApproveAll, onApproveAlways]);
|
||
|
||
useInput((_input, key) => {
|
||
if (isExecuting) return;
|
||
|
||
if (isEnteringReason) {
|
||
// When entering reason, only handle enter/escape
|
||
if (key.return) {
|
||
// Resolve placeholders before sending denial reason
|
||
const resolvedReason = resolvePlaceholders(denyReason);
|
||
onDenyAll(resolvedReason);
|
||
} else if (key.escape) {
|
||
setIsEnteringReason(false);
|
||
setDenyReason("");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (key.escape) {
|
||
// Shortcut: ESC immediately opens the deny reason prompt
|
||
setSelectedOption(options.length - 1);
|
||
setIsEnteringReason(true);
|
||
return;
|
||
}
|
||
|
||
// Navigate with arrow keys
|
||
if (key.upArrow) {
|
||
setSelectedOption((prev) => (prev > 0 ? prev - 1 : options.length - 1));
|
||
} else if (key.downArrow) {
|
||
setSelectedOption((prev) => (prev < options.length - 1 ? prev + 1 : 0));
|
||
} else if (key.return) {
|
||
// Handle selection
|
||
const selected = options[selectedOption];
|
||
if (selected) {
|
||
// Check if this is the deny option (last option)
|
||
if (selectedOption === options.length - 1) {
|
||
setIsEnteringReason(true);
|
||
} else {
|
||
selected.action();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Number key shortcuts
|
||
const num = parseInt(_input, 10);
|
||
if (!Number.isNaN(num) && num >= 1 && num <= options.length) {
|
||
const selected = options[num - 1];
|
||
if (selected) {
|
||
// Check if this is the deny option (last option)
|
||
if (num === options.length) {
|
||
setIsEnteringReason(true);
|
||
} else {
|
||
selected.action();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Parse JSON args
|
||
let parsedArgs: Record<string, unknown> | null = null;
|
||
try {
|
||
parsedArgs = approvalRequest?.toolArgs
|
||
? JSON.parse(approvalRequest.toolArgs)
|
||
: null;
|
||
} catch {
|
||
// Keep as-is if not valid JSON
|
||
}
|
||
|
||
// Compute diff for file-editing tools
|
||
const precomputedDiff = useMemo((): AdvancedDiffSuccess | null => {
|
||
if (!parsedArgs || !approvalRequest) return null;
|
||
|
||
const toolName = approvalRequest.toolName.toLowerCase();
|
||
if (toolName === "write") {
|
||
const result = computeAdvancedDiff({
|
||
kind: "write",
|
||
filePath: parsedArgs.file_path as string,
|
||
content: (parsedArgs.content as string) || "",
|
||
});
|
||
return result.mode === "advanced" ? result : null;
|
||
} else if (toolName === "edit") {
|
||
const result = computeAdvancedDiff({
|
||
kind: "edit",
|
||
filePath: parsedArgs.file_path as string,
|
||
oldString: (parsedArgs.old_string as string) || "",
|
||
newString: (parsedArgs.new_string as string) || "",
|
||
replaceAll: parsedArgs.replace_all as boolean | undefined,
|
||
});
|
||
return result.mode === "advanced" ? result : null;
|
||
} else if (toolName === "multiedit") {
|
||
const result = computeAdvancedDiff({
|
||
kind: "multi_edit",
|
||
filePath: parsedArgs.file_path as string,
|
||
edits:
|
||
(parsedArgs.edits as Array<{
|
||
old_string: string;
|
||
new_string: string;
|
||
replace_all?: boolean;
|
||
}>) || [],
|
||
});
|
||
return result.mode === "advanced" ? result : null;
|
||
}
|
||
|
||
return null;
|
||
}, [approvalRequest, parsedArgs]);
|
||
|
||
// Guard: should never happen as parent checks length, but satisfies TypeScript
|
||
if (!approvalRequest) {
|
||
return null;
|
||
}
|
||
|
||
// Get the human-readable header label
|
||
const headerLabel = getHeaderLabel(approvalRequest.toolName);
|
||
|
||
if (isEnteringReason) {
|
||
return (
|
||
<Box flexDirection="column">
|
||
<Box
|
||
borderStyle="round"
|
||
borderColor={colors.approval.border}
|
||
width="100%"
|
||
flexDirection="column"
|
||
paddingX={1}
|
||
>
|
||
<Text bold>What should Letta do differently? (esc to cancel):</Text>
|
||
<Box height={1} />
|
||
<Box>
|
||
<Text dimColor>{"> "}</Text>
|
||
<PasteAwareTextInput value={denyReason} onChange={setDenyReason} />
|
||
</Box>
|
||
</Box>
|
||
<Box height={1} />
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Box flexDirection="column">
|
||
<Box
|
||
borderStyle="round"
|
||
borderColor={colors.approval.border}
|
||
width="100%"
|
||
flexDirection="column"
|
||
paddingX={1}
|
||
>
|
||
{/* Human-readable header (same color as border) */}
|
||
<Text bold color={colors.approval.header}>
|
||
{progress && progress.total > 1
|
||
? `${progress.total} tools require approval${totalTools && totalTools > progress.total ? ` (${totalTools} total)` : ""}`
|
||
: headerLabel}
|
||
</Text>
|
||
{progress && progress.total > 1 && (
|
||
<Text dimColor>
|
||
({progress.current - 1} reviewed,{" "}
|
||
{progress.total - (progress.current - 1)} remaining)
|
||
</Text>
|
||
)}
|
||
{isExecuting && progress && progress.total > 1 && (
|
||
<Text dimColor>Executing tool...</Text>
|
||
)}
|
||
<Box height={1} />
|
||
|
||
{/* Dynamic per-tool renderer (indented) */}
|
||
<DynamicPreview
|
||
toolName={approvalRequest.toolName}
|
||
toolArgs={approvalRequest.toolArgs}
|
||
parsedArgs={parsedArgs}
|
||
precomputedDiff={precomputedDiff}
|
||
/>
|
||
<Box height={1} />
|
||
|
||
{/* Prompt */}
|
||
<Text bold>Do you want to proceed?</Text>
|
||
<Box height={1} />
|
||
|
||
{/* Options selector (single line per option) */}
|
||
<OptionsRenderer options={options} selectedOption={selectedOption} />
|
||
</Box>
|
||
<Box height={1} />
|
||
</Box>
|
||
);
|
||
});
|
||
|
||
ApprovalDialog.displayName = "ApprovalDialog";
|
||
|
||
// Helper functions for tool name mapping
|
||
function getHeaderLabel(toolName: string): string {
|
||
const t = toolName.toLowerCase();
|
||
// Anthropic toolset
|
||
if (t === "bash") return "Bash command";
|
||
if (t === "ls") return "List Files";
|
||
if (t === "read") return "Read File";
|
||
if (t === "write") return "Write File";
|
||
if (t === "edit") return "Edit File";
|
||
if (t === "multi_edit" || t === "multiedit") return "Edit Files";
|
||
if (t === "grep") return "Search in Files";
|
||
if (t === "glob") return "Find Files";
|
||
if (t === "todo_write" || t === "todowrite") return "Update Todos";
|
||
// Codex toolset (snake_case)
|
||
if (t === "shell_command") return "Shell command";
|
||
if (t === "shell") return "Shell script";
|
||
if (t === "read_file") return "Read File";
|
||
if (t === "list_dir") return "List Files";
|
||
if (t === "grep_files") return "Search in Files";
|
||
if (t === "apply_patch") return "Apply Patch";
|
||
if (t === "update_plan") return "Plan update";
|
||
// Codex toolset (PascalCase → lowercased)
|
||
if (t === "shellcommand") return "Shell command";
|
||
if (t === "readfile") return "Read File";
|
||
if (t === "listdir") return "List Files";
|
||
if (t === "grepfiles") return "Search in Files";
|
||
if (t === "applypatch") return "Apply Patch";
|
||
if (t === "updateplan") return "Plan update";
|
||
// Gemini toolset (snake_case)
|
||
if (t === "run_shell_command") return "Shell command";
|
||
if (t === "list_directory") return "List Directory";
|
||
if (t === "search_file_content") return "Search in Files";
|
||
if (t === "write_todos") return "Update Todos";
|
||
if (t === "read_many_files") return "Read Multiple Files";
|
||
// Gemini toolset (PascalCase → lowercased)
|
||
if (t === "runshellcommand") return "Shell command";
|
||
if (t === "listdirectory") return "List Directory";
|
||
if (t === "searchfilecontent") return "Search in Files";
|
||
if (t === "writetodos") return "Update Todos";
|
||
if (t === "readmanyfiles") return "Read Multiple Files";
|
||
// Shared/additional tools
|
||
if (t === "replace") return "Edit File";
|
||
if (t === "write_file" || t === "writefile") return "Write File";
|
||
if (t === "killbash") return "Kill Shell";
|
||
if (t === "bashoutput") return "Shell Output";
|
||
return toolName;
|
||
}
|