feat: enhance tool display names for better user experience in chat UI

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-29 07:40:11 -07:00
parent c110f5b955
commit 9a114a2d45
7 changed files with 267 additions and 32 deletions

View file

@ -622,6 +622,95 @@ async def _stream_agent_events(
status="in_progress", status="in_progress",
items=last_active_step_items, items=last_active_step_items,
) )
elif tool_name == "rm":
rm_path = (
tool_input.get("path", "")
if isinstance(tool_input, dict)
else str(tool_input)
)
display_path = rm_path if len(rm_path) <= 80 else "" + rm_path[-77:]
last_active_step_title = "Deleting file"
last_active_step_items = [display_path] if display_path else []
yield streaming_service.format_thinking_step(
step_id=tool_step_id,
title="Deleting file",
status="in_progress",
items=last_active_step_items,
)
elif tool_name == "rmdir":
rmdir_path = (
tool_input.get("path", "")
if isinstance(tool_input, dict)
else str(tool_input)
)
display_path = (
rmdir_path if len(rmdir_path) <= 80 else "" + rmdir_path[-77:]
)
last_active_step_title = "Deleting folder"
last_active_step_items = [display_path] if display_path else []
yield streaming_service.format_thinking_step(
step_id=tool_step_id,
title="Deleting folder",
status="in_progress",
items=last_active_step_items,
)
elif tool_name == "mkdir":
mkdir_path = (
tool_input.get("path", "")
if isinstance(tool_input, dict)
else str(tool_input)
)
display_path = (
mkdir_path if len(mkdir_path) <= 80 else "" + mkdir_path[-77:]
)
last_active_step_title = "Creating folder"
last_active_step_items = [display_path] if display_path else []
yield streaming_service.format_thinking_step(
step_id=tool_step_id,
title="Creating folder",
status="in_progress",
items=last_active_step_items,
)
elif tool_name == "move_file":
src = (
tool_input.get("source_path", "")
if isinstance(tool_input, dict)
else ""
)
dst = (
tool_input.get("destination_path", "")
if isinstance(tool_input, dict)
else ""
)
display_src = src if len(src) <= 60 else "" + src[-57:]
display_dst = dst if len(dst) <= 60 else "" + dst[-57:]
last_active_step_title = "Moving file"
last_active_step_items = (
[f"{display_src}{display_dst}"] if src or dst else []
)
yield streaming_service.format_thinking_step(
step_id=tool_step_id,
title="Moving file",
status="in_progress",
items=last_active_step_items,
)
elif tool_name == "write_todos":
todos = (
tool_input.get("todos", []) if isinstance(tool_input, dict) else []
)
todo_count = len(todos) if isinstance(todos, list) else 0
last_active_step_title = "Planning tasks"
last_active_step_items = (
[f"{todo_count} task{'s' if todo_count != 1 else ''}"]
if todo_count
else []
)
yield streaming_service.format_thinking_step(
step_id=tool_step_id,
title="Planning tasks",
status="in_progress",
items=last_active_step_items,
)
elif tool_name == "save_document": elif tool_name == "save_document":
doc_title = ( doc_title = (
tool_input.get("title", "") tool_input.get("title", "")
@ -729,7 +818,15 @@ async def _stream_agent_events(
items=last_active_step_items, items=last_active_step_items,
) )
else: else:
last_active_step_title = f"Using {tool_name.replace('_', ' ')}" # Fallback for tools without a curated thinking-step title
# (typically connector tools, MCP-registered tools, or
# newly added tools that haven't been wired up here yet).
# Render the snake_cased name as a sentence-cased phrase
# so non-technical users see e.g. "Send gmail email"
# rather than the raw identifier "send_gmail_email".
last_active_step_title = (
tool_name.replace("_", " ").strip().capitalize() or tool_name
)
last_active_step_items = [] last_active_step_items = []
yield streaming_service.format_thinking_step( yield streaming_service.format_thinking_step(
step_id=tool_step_id, step_id=tool_step_id,
@ -885,6 +982,41 @@ async def _stream_agent_events(
status="completed", status="completed",
items=last_active_step_items, items=last_active_step_items,
) )
elif tool_name == "rm":
yield streaming_service.format_thinking_step(
step_id=original_step_id,
title="Deleting file",
status="completed",
items=last_active_step_items,
)
elif tool_name == "rmdir":
yield streaming_service.format_thinking_step(
step_id=original_step_id,
title="Deleting folder",
status="completed",
items=last_active_step_items,
)
elif tool_name == "mkdir":
yield streaming_service.format_thinking_step(
step_id=original_step_id,
title="Creating folder",
status="completed",
items=last_active_step_items,
)
elif tool_name == "move_file":
yield streaming_service.format_thinking_step(
step_id=original_step_id,
title="Moving file",
status="completed",
items=last_active_step_items,
)
elif tool_name == "write_todos":
yield streaming_service.format_thinking_step(
step_id=original_step_id,
title="Planning tasks",
status="completed",
items=last_active_step_items,
)
elif tool_name == "save_document": elif tool_name == "save_document":
result_str = ( result_str = (
tool_output.get("result", "") tool_output.get("result", "")
@ -1136,9 +1268,14 @@ async def _stream_agent_events(
items=completed_items, items=completed_items,
) )
else: else:
# Fallback completion title — see the matching in-progress
# branch above for the wording rationale.
fallback_title = (
tool_name.replace("_", " ").strip().capitalize() or tool_name
)
yield streaming_service.format_thinking_step( yield streaming_service.format_thinking_step(
step_id=original_step_id, step_id=original_step_id,
title=f"Using {tool_name.replace('_', ' ')}", title=fallback_title,
status="completed", status="completed",
items=last_active_step_items, items=last_active_step_items,
) )

View file

@ -17,16 +17,12 @@ import {
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { getToolIcon } from "@/contracts/enums/toolIcons"; import { getToolDisplayName, getToolIcon } from "@/contracts/enums/toolIcons";
import { type AgentAction, agentActionsApiService } from "@/lib/apis/agent-actions-api.service"; import { type AgentAction, agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
import { AppError } from "@/lib/error"; import { AppError } from "@/lib/error";
import { formatRelativeDate } from "@/lib/format-date"; import { formatRelativeDate } from "@/lib/format-date";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function formatToolName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
interface ActionLogItemProps { interface ActionLogItemProps {
action: AgentAction; action: AgentAction;
threadId: number; threadId: number;
@ -43,7 +39,7 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
const hasError = action.error !== null && action.error !== undefined; const hasError = action.error !== null && action.error !== undefined;
const Icon = getToolIcon(action.tool_name); const Icon = getToolIcon(action.tool_name);
const displayName = formatToolName(action.tool_name); const displayName = getToolDisplayName(action.tool_name);
const argsPreview = action.args ? JSON.stringify(action.args, null, 2) : null; const argsPreview = action.args ? JSON.stringify(action.args, null, 2) : null;
const truncatedArgs = const truncatedArgs =

View file

@ -37,6 +37,7 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import { import {
agentActionsApiService, agentActionsApiService,
type RevertTurnActionResult, type RevertTurnActionResult,
@ -48,10 +49,6 @@ interface RevertTurnButtonProps {
chatTurnId: string | null | undefined; chatTurnId: string | null | undefined;
} }
function formatToolName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
// Empty-array sentinel so the per-turn ``selectAtom`` slice returns a // Empty-array sentinel so the per-turn ``selectAtom`` slice returns a
// stable reference when the turn has no recorded actions yet. Without // stable reference when the turn has no recorded actions yet. Without
// this every render allocates a fresh ``[]`` and Jotai's // this every render allocates a fresh ``[]`` and Jotai's
@ -218,7 +215,7 @@ function RevertResultRow({ result }: { result: RevertTurnActionResult }) {
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-medium truncate"> <p className="font-medium truncate">
{formatToolName(result.tool_name)}{" "} {getToolDisplayName(result.tool_name)}{" "}
<span className="ml-1 text-xs text-muted-foreground"> <span className="ml-1 text-xs text-muted-foreground">
{result.status.replace(/_/g, " ")} {result.status.replace(/_/g, " ")}
</span> </span>

View file

@ -82,6 +82,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { import {
CONNECTOR_ICON_TO_TYPES, CONNECTOR_ICON_TO_TYPES,
CONNECTOR_TOOL_ICON_PATHS, CONNECTOR_TOOL_ICON_PATHS,
getToolDisplayName,
getToolIcon, getToolIcon,
} from "@/contracts/enums/toolIcons"; } from "@/contracts/enums/toolIcons";
import type { Document } from "@/contracts/types/document.types"; import type { Document } from "@/contracts/types/document.types";
@ -1317,12 +1318,14 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
); );
}; };
/** Convert snake_case tool names to human-readable labels */ /**
* Friendly tool name for display in the chat UI. Delegates to the
* shared map in ``contracts/enums/toolIcons`` so unix-style identifiers
* (``rm``, ``ls``, ``grep`` ) and snake_cased function names render as
* plain English (e.g. "Delete file", "List files", "Search in files").
*/
function formatToolName(name: string): string { function formatToolName(name: string): string {
return name return getToolDisplayName(name);
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
} }
interface ToolGroup { interface ToolGroup {

View file

@ -25,16 +25,12 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getToolIcon } from "@/contracts/enums/toolIcons"; import { getToolDisplayName, getToolIcon } from "@/contracts/enums/toolIcons";
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service"; import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
import { AppError } from "@/lib/error"; import { AppError } from "@/lib/error";
import { isInterruptResult } from "@/lib/hitl"; import { isInterruptResult } from "@/lib/hitl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function formatToolName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
/** /**
* Inline Revert button rendered on a tool card when the matching * Inline Revert button rendered on a tool card when the matching
* ``AgentActionLog`` row is reversible and hasn't been reverted yet. * ``AgentActionLog`` row is reversible and hasn't been reverted yet.
@ -104,9 +100,10 @@ function ToolCardRevertButton({ toolCallId }: { toolCallId: string }) {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Revert this action?</AlertDialogTitle> <AlertDialogTitle>Revert this action?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will undo <span className="font-medium">{formatToolName(action.toolName)}</span>{" "} This will undo{" "}
and append a new audit entry. Chat history is preserved only the tool's effects on <span className="font-medium">{getToolDisplayName(action.toolName)}</span> and add a
your knowledge base or connectors will be reversed where possible. new entry to the history. Your chat is preserved only the changes the agent made to
your knowledge base or connected apps will be rolled back where possible.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@ -164,7 +161,7 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
: null; : null;
const Icon = getToolIcon(toolName); const Icon = getToolIcon(toolName);
const displayName = formatToolName(toolName); const displayName = getToolDisplayName(toolName);
return ( return (
<div <div
@ -215,7 +212,7 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
? `Failed: ${displayName}` ? `Failed: ${displayName}`
: displayName} : displayName}
</p> </p>
{isRunning && <p className="text-xs text-muted-foreground mt-0.5">Running...</p>} {isRunning && <p className="text-xs text-muted-foreground mt-0.5">Working</p>}
{cancelledReason && ( {cancelledReason && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{cancelledReason}</p> <p className="text-xs text-muted-foreground mt-0.5 truncate">{cancelledReason}</p>
)} )}
@ -241,7 +238,7 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
<div className="px-5 py-3 space-y-3"> <div className="px-5 py-3 space-y-3">
{argsText && ( {argsText && (
<div> <div>
<p className="text-xs font-medium text-muted-foreground mb-1">Arguments</p> <p className="text-xs font-medium text-muted-foreground mb-1">Inputs</p>
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all"> <pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all">
{argsText} {argsText}
</pre> </pre>

View file

@ -8,6 +8,7 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { connectorsApiService } from "@/lib/apis/connectors-api.service"; import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import type { HitlDecision, InterruptResult } from "@/lib/hitl"; import type { HitlDecision, InterruptResult } from "@/lib/hitl";
@ -77,7 +78,7 @@ function GenericApprovalCard({
const [editedParams, setEditedParams] = useState<Record<string, unknown>>(args); const [editedParams, setEditedParams] = useState<Record<string, unknown>>(args);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const displayName = toolName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); const displayName = getToolDisplayName(toolName);
const mcpServer = interruptData.context?.mcp_server as string | undefined; const mcpServer = interruptData.context?.mcp_server as string | undefined;
const toolDescription = interruptData.context?.tool_description as string | undefined; const toolDescription = interruptData.context?.tool_description as string | undefined;
@ -186,12 +187,11 @@ function GenericApprovalCard({
</> </>
)} )}
{/* Parameters */}
{Object.keys(args).length > 0 && ( {Object.keys(args).length > 0 && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-2"> <div className="px-5 py-4 space-y-2">
<p className="text-xs font-medium text-muted-foreground">Parameters</p> <p className="text-xs font-medium text-muted-foreground">Inputs</p>
{phase === "pending" && isEditing ? ( {phase === "pending" && isEditing ? (
<ParamEditor <ParamEditor
params={editedParams} params={editedParams}

View file

@ -113,6 +113,111 @@ export function getToolIcon(name: string): LucideIcon {
return TOOL_ICONS[name] ?? Wrench; return TOOL_ICONS[name] ?? Wrench;
} }
/**
* Friendly display names for tools shown in the chat UI.
*
* Most users aren't engineers; they shouldn't see raw unix-style
* identifiers like ``rm`` / ``rmdir`` / ``ls`` / ``grep`` / ``glob`` or
* snake_cased function names. The map below renders each tool with
* plain English wording (verb + object) so non-technical users
* understand what the agent is doing at a glance.
*
* Unmapped tool names fall back to a snake_case-to-Title-Case
* conversion via :func:`getToolDisplayName`.
*/
const TOOL_DISPLAY_NAMES: Record<string, string> = {
// Filesystem / knowledge base
read_file: "Read file",
write_file: "Write file",
edit_file: "Edit file",
move_file: "Move file",
rm: "Delete file",
rmdir: "Delete folder",
mkdir: "Create folder",
ls: "List files",
glob: "Find files",
grep: "Search in files",
write_todos: "Plan tasks",
save_document: "Save document",
// Generators
generate_podcast: "Generate podcast",
generate_video_presentation: "Generate video presentation",
generate_report: "Generate report",
generate_resume: "Generate resume",
generate_image: "Generate image",
display_image: "Show image",
// Web / search
scrape_webpage: "Read webpage",
web_search: "Search the web",
search_surfsense_docs: "Search knowledge base",
// Memory
update_memory: "Update memory",
// Calendar
search_calendar_events: "Search calendar",
create_calendar_event: "Create event",
update_calendar_event: "Update event",
delete_calendar_event: "Delete event",
// Gmail
search_gmail: "Search Gmail",
read_gmail_email: "Read email",
create_gmail_draft: "Draft email",
update_gmail_draft: "Update draft",
send_gmail_email: "Send email",
trash_gmail_email: "Move email to trash",
// Notion
create_notion_page: "Create Notion page",
update_notion_page: "Update Notion page",
delete_notion_page: "Delete Notion page",
// Confluence
create_confluence_page: "Create Confluence page",
update_confluence_page: "Update Confluence page",
delete_confluence_page: "Delete Confluence page",
// Linear
create_linear_issue: "Create Linear issue",
update_linear_issue: "Update Linear issue",
delete_linear_issue: "Delete Linear issue",
// Jira
create_jira_issue: "Create Jira issue",
update_jira_issue: "Update Jira issue",
delete_jira_issue: "Delete Jira issue",
// Drive-like file connectors
create_google_drive_file: "Create Google Drive file",
delete_google_drive_file: "Delete Google Drive file",
create_dropbox_file: "Create Dropbox file",
delete_dropbox_file: "Delete Dropbox file",
create_onedrive_file: "Create OneDrive file",
delete_onedrive_file: "Delete OneDrive file",
// Discord
list_discord_channels: "List Discord channels",
read_discord_messages: "Read Discord messages",
send_discord_message: "Send Discord message",
// Teams
list_teams_channels: "List Teams channels",
read_teams_messages: "Read Teams messages",
send_teams_message: "Send Teams message",
// Luma
list_luma_events: "List Luma events",
read_luma_event: "Read Luma event",
create_luma_event: "Create Luma event",
// Misc
get_connected_accounts: "Check connected accounts",
execute: "Run command",
execute_code: "Run code",
};
/**
* Format a tool's canonical (snake_case) name for display in the chat UI.
*
* Looks up :data:`TOOL_DISPLAY_NAMES` first; falls back to a
* snake_case-to-Title-Case rewrite for tools that don't have a curated
* label (e.g. dynamically registered MCP tools).
*/
export function getToolDisplayName(name: string): string {
const friendly = TOOL_DISPLAY_NAMES[name];
if (friendly) return friendly;
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
export const CONNECTOR_TOOL_ICON_PATHS: Record<string, { src: string; alt: string }> = { export const CONNECTOR_TOOL_ICON_PATHS: Record<string, { src: string; alt: string }> = {
gmail: { src: "/connectors/google-gmail.svg", alt: "Gmail" }, gmail: { src: "/connectors/google-gmail.svg", alt: "Gmail" },
google_calendar: { src: "/connectors/google-calendar.svg", alt: "Google Calendar" }, google_calendar: { src: "/connectors/google-calendar.svg", alt: "Google Calendar" },