Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

1070 lines
38 KiB
TypeScript

import { stripVersionSuffix } from '@sim/utils/string'
/**
* Single source of truth for copilot tool-call display titles.
*
* The mothership (Go) no longer emits any presentation metadata on the stream —
* tool-call titles are derived entirely here, keyed by tool name (plus arguments
* for the dynamic cases). The live client render layer (see
* `home/hooks/stream/stream-helpers.ts`) wraps this with workspace/block-name
* enrichment for the run_* tools; every other surface (server persistence,
* transcript replay, fallback rendering) calls `getToolDisplayTitle` directly.
*
* Icons are likewise client-owned — see `getAgentIcon` in the message-content
* utils. Nothing about tool presentation lives on the Go side anymore.
*/
type ToolArgs = Record<string, unknown> | undefined
export const CONTEXT_COMPACTION_DISPLAY_TITLE = 'Summarizing context'
function stringArg(args: ToolArgs, key: string): string {
const value = args?.[key]
return typeof value === 'string' ? value.trim() : ''
}
function firstStringArg(args: ToolArgs, ...keys: string[]): string {
for (const key of keys) {
const value = stringArg(args, key)
if (value) return value
}
return ''
}
function stringArrayArg(args: ToolArgs, key: string): string[] {
const value = args?.[key]
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
}
function nestedStringArg(args: ToolArgs, parentKey: string, ...keys: string[]): string {
const parent = args?.[parentKey]
if (!parent || typeof parent !== 'object') return ''
return firstStringArg(parent as Record<string, unknown>, ...keys)
}
function recordArg(args: ToolArgs, key: string): Record<string, unknown> | undefined {
const value = args?.[key]
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined
}
function stringOrNumberArg(args: ToolArgs, key: string): string {
const value = args?.[key]
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
}
function deploymentTitle(args: ToolArgs, deploymentType: string): string {
return `${stringArg(args, 'action') === 'undeploy' ? 'Undeploying' : 'Deploying'} ${deploymentType}`
}
function resourceTypeLabel(type: string): string {
const labels: Record<string, string> = {
knowledgebase: 'knowledge base',
file_folder: 'file folder',
log: 'logs',
}
return labels[type] ?? type
}
interface OperationDisplay {
verb: string
resource: string
}
function namedOperationTitle(
args: ToolArgs,
target: string,
placeholder: string,
labels: Record<string, OperationDisplay>
): string {
const operation = stringArg(args, 'operation')
const display = labels[operation]
return display ? `${display.verb} ${target || display.resource}` : placeholder
}
/** Compact form of a URL for titles: host + path, no scheme/query noise. */
function displayUrl(raw: string): string {
if (!raw) return ''
try {
const url = new URL(raw)
const path = url.pathname === '/' ? '' : url.pathname
return `${url.host}${path}`.slice(0, 80)
} catch {
return raw.slice(0, 80)
}
}
function isWorkflowArtifactPath(path: string, filename: string): boolean {
const trimmed = path.trim()
return trimmed.startsWith('workflows/') && trimmed.endsWith(`/${filename}`)
}
function decodePathSegment(segment: string): string {
try {
return decodeURIComponent(segment)
} catch {
return segment
}
}
function pathLeaf(path: string): string {
const normalized = path.replace(/\/+$/, '')
const leaf = normalized.split('/').filter(Boolean).at(-1) || normalized
return decodePathSegment(leaf)
}
function summarizeTargets(targets: string[], fallback: string): string {
const normalized = targets.map((target) => target.trim()).filter(Boolean)
if (normalized.length === 0) return fallback
if (normalized.length === 1) return normalized[0]
if (normalized.length === 2) return `${normalized[0]} and ${normalized[1]}`
return `${normalized[0]}, ${normalized[1]}, and ${normalized.length - 2} more`
}
function countedResourceTarget(
args: ToolArgs,
key: string,
singular: string,
plural: string
): string {
const values = args?.[key]
return Array.isArray(values) && values.length > 1 ? `${values.length} ${plural}` : singular
}
function firstOutputFilePath(args: ToolArgs): string {
const outputs = args?.outputs
if (!outputs || typeof outputs !== 'object') return ''
const files = (outputs as Record<string, unknown>).files
if (!Array.isArray(files)) return ''
for (const file of files) {
if (!file || typeof file !== 'object') continue
const path = stringArg(file as Record<string, unknown>, 'path')
if (path) return path
}
return ''
}
function firstOutputFileMode(args: ToolArgs): string {
const outputs = args?.outputs
if (!outputs || typeof outputs !== 'object') return ''
const files = (outputs as Record<string, unknown>).files
if (!Array.isArray(files)) return ''
for (const file of files) {
if (!file || typeof file !== 'object') continue
const mode = stringArg(file as Record<string, unknown>, 'mode')
if (mode) return mode
}
return ''
}
function createFileTitle(args: ToolArgs): string {
const nestedArgs =
args?.args && typeof args.args === 'object' ? (args.args as Record<string, unknown>) : undefined
const target =
firstOutputFilePath(args) ||
firstStringArg(args, 'fileName') ||
firstOutputFilePath(nestedArgs) ||
firstStringArg(nestedArgs, 'fileName')
const mode = firstOutputFileMode(args) || firstOutputFileMode(nestedArgs)
const verb = mode === 'overwrite' ? 'Overwriting' : 'Creating'
if (!target) return `${verb} file`
return `${verb} ${pathLeaf(target)}`
}
function ffmpegTitle(args: ToolArgs): string {
const titles: Record<string, string> = {
overlay_audio: 'Adding audio to media',
mix_audio: 'Mixing audio',
concat: 'Combining media',
trim: 'Trimming media',
scale_pad: 'Resizing media',
overlay_image: 'Adding image to media',
add_text: 'Adding text to media',
fade: 'Adding fade to media',
extract_audio: 'Extracting audio',
convert: 'Converting media',
thumbnail: 'Creating thumbnail',
probe: 'Inspecting media',
}
return titles[stringArg(args, 'operation')] ?? 'Processing media'
}
function knowledgeBaseTitle(args: ToolArgs): string {
const operation = stringArg(args, 'operation')
const operationArgs = recordArg(args, 'args')
const name = stringArg(operationArgs, 'name')
const tagName = stringArg(operationArgs, 'tagDisplayName')
const fileTarget = summarizeTargets(
stringArrayArg(operationArgs, 'filePaths').map(pathLeaf),
'file'
)
const titles: Record<string, string> = {
create: `Creating ${name || 'knowledge base'}`,
get: 'Reading knowledge base',
query: 'Searching knowledge base',
add_file: `Adding ${fileTarget} to knowledge base`,
update: 'Updating knowledge base',
delete: `Deleting ${countedResourceTarget(operationArgs, 'knowledgeBaseIds', 'knowledge base', 'knowledge bases')}`,
delete_document: `Deleting ${countedResourceTarget(operationArgs, 'documentIds', 'document', 'documents')}`,
update_document: 'Updating document',
list_tags: 'Listing knowledge base tags',
create_tag: `Creating ${tagName || 'knowledge base tag'}`,
update_tag: `Updating ${tagName || 'knowledge base tag'}`,
delete_tag: 'Deleting knowledge base tag',
get_tag_usage: 'Checking tag usage',
add_connector: 'Adding knowledge base connector',
update_connector: 'Updating knowledge base connector',
delete_connector: 'Deleting knowledge base connector',
sync_connector: 'Syncing knowledge base connector',
}
return titles[operation] ?? 'Managing knowledge base'
}
function queryUserTableTitle(args: ToolArgs): string {
const titles: Record<string, string> = {
get: 'Reading table',
get_schema: 'Reading table schema',
get_row: 'Reading table row',
query_rows: 'Querying table',
}
return titles[stringArg(args, 'operation')] ?? 'Querying table'
}
function searchKnowledgeBaseTitle(args: ToolArgs): string {
const titles: Record<string, string> = {
get: 'Reading knowledge base',
query: 'Searching knowledge base',
list_tags: 'Listing knowledge base tags',
}
return titles[stringArg(args, 'operation')] ?? 'Searching knowledge base'
}
function manageSandboxTitle(args: ToolArgs): string {
const titles: Record<string, string> = {
add: 'Creating sandbox',
edit: 'Updating sandbox',
delete: 'Deleting sandbox',
list: 'Listing sandboxes',
}
return titles[stringArg(args, 'operation')] ?? 'Managing sandbox'
}
function manageScheduledTaskTitle(args: ToolArgs): string {
const operationArgs = recordArg(args, 'args')
const title = stringArg(operationArgs, 'title')
const titles: Record<string, string> = {
create: `Creating ${title || 'scheduled task'}`,
list: 'Listing scheduled tasks',
get: 'Reading scheduled task',
update: `Updating ${title || 'scheduled task'}`,
delete: 'Deleting scheduled task',
}
return titles[stringArg(args, 'operation')] ?? 'Managing scheduled task'
}
function userTableTitle(args: ToolArgs): string {
const operation = stringArg(args, 'operation')
const operationArgs = recordArg(args, 'args')
const name = stringArg(operationArgs, 'name')
const newName = stringArg(operationArgs, 'newName')
const columnName = stringArg(operationArgs, 'columnName')
const columnDefinitionName = nestedStringArg(operationArgs, 'column', 'name')
const columnTargets = [
...stringArrayArg(operationArgs, 'columnNames'),
...(columnName ? [columnName] : []),
]
switch (operation) {
case 'create':
return `Creating ${name || 'table'}`
case 'create_from_file':
return 'Creating table from file'
case 'import_file':
return 'Importing file into table'
case 'get':
return 'Reading table'
case 'get_schema':
return 'Reading table schema'
case 'delete':
return `Deleting ${countedResourceTarget(operationArgs, 'tableIds', 'table', 'tables')}`
case 'rename':
return newName ? `Renaming table to ${newName}` : 'Renaming table'
case 'insert_row':
return 'Adding table row'
case 'batch_insert_rows':
return `Adding ${countedResourceTarget(operationArgs, 'rows', 'table row', 'table rows')}`
case 'get_row':
return 'Reading table row'
case 'query_rows':
return 'Querying table'
case 'update_row':
return 'Updating table row'
case 'delete_row':
return 'Deleting table row'
case 'update_rows_by_filter':
case 'batch_update_rows':
return 'Updating table rows'
case 'delete_rows_by_filter':
case 'batch_delete_rows':
return 'Deleting table rows'
case 'add_column':
return columnDefinitionName ? `Adding column ${columnDefinitionName}` : 'Adding table column'
case 'rename_column':
if (columnName && newName) return `Renaming column ${columnName} to ${newName}`
return newName ? `Renaming table column to ${newName}` : 'Renaming table column'
case 'delete_column':
return `Deleting ${summarizeTargets(columnTargets, 'table column')}`
case 'update_column':
return columnName ? `Updating column ${columnName}` : 'Updating table column'
case 'add_workflow_group':
return 'Adding table workflow'
case 'update_workflow_group':
return 'Updating table workflow'
case 'delete_workflow_group':
return 'Deleting table workflow'
case 'add_workflow_group_output':
return 'Adding workflow output column'
case 'delete_workflow_group_output':
return 'Deleting workflow output column'
case 'run_column':
return 'Running table workflow'
case 'cancel_table_runs':
return 'Cancelling table runs'
case 'list_workflow_outputs':
return 'Listing workflow outputs'
case 'list_enrichments':
return 'Listing enrichments'
case 'add_enrichment':
return `Adding ${name || 'enrichment'}`
default:
return 'Managing table'
}
}
function materializeFileTitle(args: ToolArgs): string {
const operation = stringArg(args, 'operation') || 'save'
const targets = stringArrayArg(args, 'fileNames').map(pathLeaf)
if (operation === 'import') {
return `Importing ${summarizeTargets(targets, 'workflow')}`
}
return `Saving ${summarizeTargets(targets, 'file')}`
}
function openResourceTitle(args: ToolArgs): string {
const resources = args?.resources
if (!Array.isArray(resources) || resources.length === 0) return 'Opening resource'
if (resources.length > 1) return `Opening ${resources.length} resources`
const resource = resources[0]
if (!resource || typeof resource !== 'object') return 'Opening resource'
const type = stringArg(resource as Record<string, unknown>, 'type')
return `Opening ${type ? resourceTypeLabel(type) : 'resource'}`
}
function setGlobalWorkflowVariablesTitle(args: ToolArgs): string {
const operations = args?.operations
if (!Array.isArray(operations) || operations.length === 0) return 'Setting workflow variables'
const parsed = operations.filter(
(operation): operation is Record<string, unknown> =>
Boolean(operation) && typeof operation === 'object' && !Array.isArray(operation)
)
const operationNames = parsed.map((operation) => stringArg(operation, 'operation'))
const firstOperation = operationNames[0]
const allSameOperation =
firstOperation && operationNames.every((operation) => operation === firstOperation)
const verbByOperation: Record<string, string> = {
add: 'Adding',
edit: 'Updating',
delete: 'Deleting',
}
const verb = allSameOperation ? (verbByOperation[firstOperation] ?? 'Updating') : 'Updating'
if (parsed.length === 1) {
const variableName = stringArg(parsed[0], 'name')
return `${verb} workflow variable${variableName ? ` ${variableName}` : ''}`
}
return `${verb} ${parsed.length} workflow variables`
}
/**
* Verb for an mv call, derived from its arguments so the row reads as what
* the call actually does: a single source whose parent path matches the
* destination's (only the leaf changes) is a rename; multiple sources, a
* trailing-slash folder destination, or a parent change is a move. Segments
* are decoded so an encoded source compares correctly against a plain-text
* destination leaf.
*/
export function mvDisplayVerb(
source: string | undefined,
destination: string | undefined
): 'Renaming' | 'Moving' {
if (!source || !destination || /\/\s*$/.test(destination)) return 'Moving'
const segments = (path: string) =>
path
.trim()
.replace(/^\/+|\/+$/g, '')
.split('/')
.map(decodePathSegment)
const src = segments(source)
const dst = segments(destination)
if (src.length < 2 || dst.length < 2) return 'Moving'
const sameParent = src.slice(0, -1).join('/') === dst.slice(0, -1).join('/')
const leafChanged = src.at(-1) !== dst.at(-1)
return sameParent && leafChanged ? 'Renaming' : 'Moving'
}
function workspaceFileTitle(args: ToolArgs): string {
const title = stringArg(args, 'title')
if (!title) return ''
const verbByOperation: Record<string, string> = {
create: 'Creating',
append: 'Adding',
patch: 'Editing',
update: 'Writing',
rename: 'Renaming',
delete: 'Deleting',
}
const verb = verbByOperation[stringArg(args, 'operation')] ?? 'Writing'
return `${verb} ${title}`
}
/** Static fallback titles for tools without an argument-aware title. */
const TOOL_TITLES: Record<string, string> = {
// Gateway rows brand from the streamed toolId as soon as it resolves; this
// covers only the instant before the integration is known. The raw
// humanized name ("Call Integration Tool") must never render.
call_integration_tool: 'Calling integration',
read: 'Reading file',
search_library_docs: 'Searching library docs',
user_table: 'Managing table',
run_code: 'Running code',
query_user_table: 'Querying table',
workspace_file: 'Editing file',
edit_content: 'Applying file content',
create_workflow: 'Creating workflow',
edit_workflow: 'Editing workflow',
knowledge_base: 'Managing knowledge base',
search_knowledge_base: 'Searching knowledge base',
open_resource: 'Opening resource',
generate_image: 'Generating image',
generate_video: 'Generating video',
generate_audio: 'Generating audio',
ffmpeg: 'Processing media',
check_deployment_status: 'Checking deployment status',
create_file: 'Creating file',
create_file_folder: 'Creating folder',
create_workspace_mcp_server: 'Creating MCP server',
delete_workspace_mcp_server: 'Deleting MCP server',
deploy_api: 'Deploying API',
deploy_chat: 'Deploying chat',
deploy_custom_block: 'Deploying custom block',
deploy_mcp: 'Deploying MCP tool',
diff_workflows: 'Comparing workflows',
download_to_workspace_file: 'Downloading file',
function_execute: 'Running code',
complete_scheduled_task: 'Completing scheduled task',
generate_api_key: 'Generating API key',
get_block_outputs: 'Getting block outputs',
get_block_upstream_references: 'Getting block references',
get_deployed_workflow_state: 'Getting deployed workflow',
get_deployment_log: 'Getting deployment logs',
get_platform_actions: 'Getting platform actions',
get_scheduled_task_logs: 'Reading scheduled task logs',
get_workflow_data: 'Getting workflow data',
get_workflow_run_options: 'Getting run options',
list_file_folders: 'Listing folders',
list_integration_tools: 'Listing integration tools',
list_user_workspaces: 'Listing workspaces',
list_workspace_mcp_servers: 'Listing MCP servers',
load_deployment: 'Loading deployment',
materialize_file: 'Preparing file',
manage_sandbox: 'Managing sandbox',
manage_scheduled_task: 'Managing scheduled task',
move_file: 'Moving file',
move_file_folder: 'Moving folder',
move_workflow: 'Moving workflow',
oauth_get_auth_link: 'Getting authorization link',
oauth_request_access: 'Requesting access',
promote_to_live: 'Promoting to live',
redeploy: 'Redeploying API',
rename_file: 'Renaming file',
rename_file_folder: 'Renaming folder',
rename_workflow: 'Renaming workflow',
restore_resource: 'Restoring resource',
run_block: 'Running block',
scheduled_task: 'Managing scheduled task',
search_documentation: 'Searching documentation',
search_patterns: 'Searching patterns',
set_block_enabled: 'Toggling block',
set_environment_variables: 'Setting environment variables',
set_global_workflow_variables: 'Setting workflow variables',
update_deployment_version: 'Updating deployment',
update_scheduled_task_history: 'Updating scheduled task history',
update_workspace_mcp_server: 'Updating MCP server',
// Browser agent tools without an argument-aware title.
browser_go_back: 'Going back',
browser_go_forward: 'Going forward',
browser_switch_tab: 'Switching tab',
browser_close_tab: 'Closing tab',
browser_list_tabs: 'Listing tabs',
browser_list_sessions: 'Checking signed-in sites',
browser_snapshot: 'Scanning page',
browser_read_text: 'Reading page',
browser_screenshot: 'Taking screenshot',
browser_click: 'Clicking element',
browser_type: 'Typing text',
browser_select_option: 'Selecting option',
browser_hover: 'Hovering element',
// Subagent trigger tools, when surfaced as a tool call.
workflow: 'Workflow Agent',
run: 'Run Agent',
deploy: 'Deploy Agent',
auth: 'Auth Agent',
knowledge: 'Knowledge Agent',
table: 'Table Agent',
agent: 'Tools Agent',
research: 'Research Agent',
scout: 'Scout Agent',
search: 'Search Agent',
file: 'File Agent',
media: 'Media Agent',
browser: 'Browser Agent',
superagent: 'Executing action',
respond: 'Gathering thoughts',
context_compaction: CONTEXT_COMPACTION_DISPLAY_TITLE,
}
/** Acronyms that must keep their canonical casing when humanized. */
const ACRONYM_CASING: Record<string, string> = {
mcp: 'MCP',
api: 'API',
oauth: 'OAuth',
url: 'URL',
id: 'ID',
ai: 'AI',
}
/**
* Humanize an internal identifier without leaking snake_case or kebab-case into
* the UI. Sentence case is useful for resource names appended to a verb, while
* title case is used for standalone tool-name fallbacks.
*/
export function humanizeDisplayIdentifier(
name: string,
casing: 'sentence' | 'title' = 'title'
): string {
const words = stripVersionSuffix(name).split(/[-_]+/).filter(Boolean)
if (words.length === 0) return name
return words
.map((word, index) => {
const normalized = word.toLowerCase()
const acronym = ACRONYM_CASING[normalized]
if (acronym) return acronym
if (casing === 'sentence' && index > 0) return normalized
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
})
.join(' ')
}
/**
* Final fallback: humanize a raw tool name (e.g. `manage_folder` -> "Manage
* Folder"), matching the legacy client humanizer so labels never render blank.
*/
export function humanizeToolName(name: string): string {
return humanizeDisplayIdentifier(name)
}
/** One shape, so the live countdown and the settled title never diverge. */
function formatWaitTitle(seconds: number, reason: string): string {
const duration = seconds > 0 ? ` ${seconds}s` : ''
return reason ? `Waiting${duration} for ${reason}` : `Waiting${duration}`
}
function requestedWaitSeconds(args: ToolArgs): number {
const raw = args?.seconds
return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? Math.round(raw) : 0
}
/**
* The duration is the whole content of a pause: without it the row is an
* unexplained stall, which is what the user is staring at while it runs.
*/
function waitTitle(args: ToolArgs): string {
return formatWaitTitle(requestedWaitSeconds(args), stringArg(args, 'reason'))
}
/**
* The title of a pause that is still running, counting down what is left.
*
* A number that never changes next to a spinner looks the same as a hung turn,
* and a pause is the one row where the user is doing nothing but watching it.
* At zero the number is dropped rather than frozen at "0s", because the pause
* itself is over and what remains is the turn picking back up.
*/
export function getWaitCountdownTitle(args: ToolArgs, elapsedMs: number): string {
const elapsedSeconds = Math.max(0, Math.floor(elapsedMs / 1000))
const remaining = Math.max(0, requestedWaitSeconds(args) - elapsedSeconds)
return formatWaitTitle(remaining, stringArg(args, 'reason'))
}
/** Past this a command wraps the row; the terminal panel still shows it in full. */
const MAX_COMMAND_TITLE_LENGTH = 48
function runningCommandTitle(rawCommand: string): string {
const command = rawCommand.replace(/\s+/g, ' ')
if (!command) return 'Running command'
const shortened =
command.length > MAX_COMMAND_TITLE_LENGTH
? `${command.slice(0, MAX_COMMAND_TITLE_LENGTH - 1)}…`
: command
return `Running ${shortened}`
}
const TERMINAL_OPERATION_TITLES: Record<string, string> = {
read: 'Reading terminal',
input: 'Typing into terminal',
kill: 'Stopping command',
cwd: 'Checking terminal',
list: 'Listing terminals',
new: 'Opening terminal',
switch: 'Switching terminal',
close: 'Closing terminal',
panes: 'Listing tmux panes',
}
/**
* The terminal tool carries what it does in `operation`, so the row title has
* to come from the arguments rather than the tool name — otherwise every shell
* action in the transcript reads simply "Terminal".
*/
function terminalTitle(args: ToolArgs): string {
const operation = stringArg(args, 'operation')
const nested = args?.args
const inner: ToolArgs =
nested && typeof nested === 'object' && !Array.isArray(nested)
? (nested as Record<string, unknown>)
: undefined
if (operation === 'run') return runningCommandTitle(stringArg(inner, 'command'))
if (operation === 'handoff') {
// Matches the browser takeover row: the reason is the whole point of the
// row, since it is what the user has to act on.
const reason = stringArg(inner, 'reason')
return reason ? `Waiting for you: ${reason}` : 'Waiting for you in the terminal'
}
return TERMINAL_OPERATION_TITLES[operation] ?? 'Using terminal'
}
/**
* Resolve a tool-call display title from its name and arguments. Argument-aware
* cases come first, then the static map, then a humanized fallback. This never
* returns an empty string.
*/
export function getToolDisplayTitle(name: string, args?: Record<string, unknown>): string {
const mcpToolMatch = name.match(/^mcp-[^-]+-(.+)$/)
if (mcpToolMatch?.[1]) {
return humanizeToolName(mcpToolMatch[1])
}
switch (name) {
case 'deploy_api':
return deploymentTitle(args, 'API')
case 'deploy_chat':
return deploymentTitle(args, 'chat')
case 'deploy_custom_block':
return deploymentTitle(args, 'custom block')
case 'ffmpeg':
return ffmpegTitle(args)
case 'knowledge_base':
return knowledgeBaseTitle(args)
case 'query_user_table':
return queryUserTableTitle(args)
case 'search_knowledge_base':
return searchKnowledgeBaseTitle(args)
case 'manage_sandbox':
return manageSandboxTitle(args)
case 'manage_scheduled_task':
return manageScheduledTaskTitle(args)
case 'user_table':
return userTableTitle(args)
case 'materialize_file':
return materializeFileTitle(args)
case 'open_resource':
return openResourceTitle(args)
case 'wait':
return waitTitle(args)
case 'terminal':
return terminalTitle(args)
// The surface used to be one tool per operation. Conversations recorded
// then still reference those names, so they keep their titles rather than
// regressing to a humanized "Terminal Run".
case 'terminal_run':
return runningCommandTitle(stringArg(args, 'command'))
case 'terminal_read':
return 'Reading terminal'
case 'terminal_input':
return 'Typing into terminal'
case 'terminal_kill':
return 'Stopping command'
case 'terminal_cwd':
return 'Checking terminal'
case 'terminal_list':
return 'Listing terminals'
case 'terminal_new':
return 'Opening terminal'
case 'terminal_switch':
return 'Switching terminal'
case 'terminal_close':
return 'Closing terminal'
case 'restore_resource': {
const type = stringArg(args, 'type')
return `Restoring ${type ? resourceTypeLabel(type) : 'resource'}`
}
case 'set_block_enabled': {
const enabled = args?.enabled
return typeof enabled === 'boolean'
? `${enabled ? 'Enabling' : 'Disabling'} block`
: 'Toggling block'
}
case 'load_deployment': {
const version = stringOrNumberArg(args, 'version')
if (!version) return 'Loading deployment'
return version === 'live'
? 'Loading live deployment'
: `Loading deployment version ${version}`
}
case 'promote_to_live': {
const version = stringOrNumberArg(args, 'version')
return version ? `Promoting version ${version} to live` : 'Promoting to live'
}
case 'update_deployment_version': {
const version = stringOrNumberArg(args, 'version')
return version ? `Updating deployment version ${version}` : 'Updating deployment'
}
case 'generate_api_key': {
const keyName = stringArg(args, 'name')
return keyName ? `Generating API key ${keyName}` : 'Generating API key'
}
case 'list_integration_tools': {
const integration = stringArg(args, 'integration')
return integration
? `Listing ${humanizeToolName(integration)} tools`
: 'Listing integration tools'
}
case 'set_environment_variables': {
const scope = stringArg(args, 'scope') || 'workspace'
return `Setting ${scope} environment variables`
}
case 'set_global_workflow_variables':
return setGlobalWorkflowVariablesTitle(args)
case 'create_file':
return createFileTitle(args)
case 'share_file': {
const action = stringArg(args, 'action') || 'share'
const path = stringArg(args, 'path')
const target = firstStringArg(args, 'toolTitle', 'title') || (path ? pathLeaf(path) : 'file')
return action === 'unshare' ? `Unsharing ${target}` : `Sharing ${target}`
}
case 'create_workflow': {
const target = firstStringArg(args, 'name', 'workflowName', 'title')
return `Creating ${target || 'workflow'}`
}
case 'edit_workflow': {
const target = firstStringArg(args, 'workflowName', 'name', 'title')
return `Editing ${target || 'workflow'}`
}
case 'create_workspace_mcp_server': {
const target = firstStringArg(args, 'name', 'serverName', 'title')
return `Creating ${target || 'MCP server'}`
}
case 'update_workspace_mcp_server': {
const target = firstStringArg(args, 'name', 'serverName', 'title')
return `Updating ${target || 'MCP server'}`
}
case 'delete_workspace_mcp_server': {
const target = firstStringArg(args, 'serverName', 'name', 'title')
return `Deleting ${target || 'MCP server'}`
}
case 'search_online': {
const target = firstStringArg(args, 'toolTitle', 'title')
return target ? `Searching online for ${target}` : 'Searching online'
}
case 'grep': {
const target = firstStringArg(args, 'toolTitle', 'title')
return target ? `Searching for ${target}` : 'Searching'
}
case 'glob': {
const target = firstStringArg(args, 'toolTitle', 'title')
return target ? `Finding ${target}` : 'Finding files'
}
case 'mv': {
const sources = stringArrayArg(args, 'sources')
const verb =
sources.length === 1 ? mvDisplayVerb(sources[0], stringArg(args, 'destination')) : 'Moving'
if (verb === 'Renaming' && sources[0]) {
const destination = stringArg(args, 'destination')
if (destination) return `Renaming ${pathLeaf(sources[0])} to ${pathLeaf(destination)}`
}
const target = firstStringArg(args, 'toolTitle', 'title')
return target ? `${verb} ${target}` : verb
}
case 'cp': {
const target = firstStringArg(args, 'toolTitle', 'title')
return target ? `Duplicating ${target}` : 'Duplicating workflow'
}
case 'mkdir': {
const target = firstStringArg(args, 'toolTitle', 'title')
return target ? `Creating ${target}` : 'Creating folder'
}
case 'rm': {
// toolTitle is the model's phrasing; the paths are the fallback because
// rm spans categories and there is no one noun to count.
const target =
firstStringArg(args, 'toolTitle', 'title') ||
summarizeTargets(stringArrayArg(args, 'paths').map(pathLeaf), 'resource')
return target ? `Deleting ${target}` : 'Deleting'
}
case 'enrichment_run': {
const subject = nestedStringArg(
args,
'inputs',
'fullName',
'companyName',
'domain',
'email',
'companyDomain'
)
return subject ? `Searching for ${subject}` : 'Searching'
}
case 'scrape_page': {
const url = stringArg(args, 'url')
return url ? `Scraping ${url}` : 'Scraping page'
}
case 'browser_navigate': {
const url = displayUrl(stringArg(args, 'url'))
return url ? `Opening ${url}` : 'Opening page'
}
case 'browser_open_url': {
const url = displayUrl(stringArg(args, 'url'))
return url ? `Opening ${url}` : 'Opening page'
}
case 'browser_open_tab': {
const url = displayUrl(stringArg(args, 'url'))
return url ? `Opening ${url} in a new tab` : 'Opening new tab'
}
case 'browser_wait_for': {
const text = stringArg(args, 'text')
return text ? `Waiting for "${text}"` : 'Waiting for page'
}
case 'browser_press_key': {
const key = stringArg(args, 'key')
return key ? `Pressing ${key}` : 'Pressing key'
}
case 'browser_scroll': {
const direction = stringArg(args, 'direction')
return direction ? `Scrolling ${direction}` : 'Scrolling page'
}
case 'browser_extract': {
const instruction = stringArg(args, 'instruction')
return instruction ? `Extracting ${instruction}` : 'Extracting page data'
}
case 'browser_request_takeover': {
const reason = stringArg(args, 'reason')
return reason ? `Waiting for you: ${reason}` : 'Waiting for you in the browser'
}
case 'crawl_website': {
const url = stringArg(args, 'url')
return url ? `Crawling ${url}` : 'Crawling website'
}
case 'get_page_contents': {
const urls = stringArrayArg(args, 'urls')
if (urls.length === 1) return `Getting ${urls[0]}`
if (urls.length > 1) return `Getting ${urls.length} pages`
return 'Getting page contents'
}
case 'manage_custom_tool': {
const schema = args?.schema
const target =
firstStringArg(args, 'toolTitle', 'title', 'name') ||
(schema && typeof schema === 'object'
? nestedStringArg(schema as Record<string, unknown>, 'function', 'name')
: '')
return namedOperationTitle(args, target, 'Custom tool action', {
add: { verb: 'Creating', resource: 'custom tool' },
edit: { verb: 'Updating', resource: 'custom tool' },
delete: { verb: 'Deleting', resource: 'custom tool' },
list: { verb: 'Viewing', resource: 'custom tools' },
})
}
case 'manage_mcp_tool': {
const target =
firstStringArg(args, 'serverName', 'name', 'title') ||
nestedStringArg(args, 'config', 'name')
return namedOperationTitle(args, target, 'MCP server action', {
add: { verb: 'Creating', resource: 'MCP server' },
edit: { verb: 'Updating', resource: 'MCP server' },
delete: { verb: 'Deleting', resource: 'MCP server' },
list: { verb: 'Viewing', resource: 'MCP servers' },
})
}
case 'manage_skill': {
const target = firstStringArg(args, 'name', 'skillName', 'title')
return namedOperationTitle(args, target, 'Skill action', {
add: { verb: 'Creating', resource: 'skill' },
edit: { verb: 'Updating', resource: 'skill' },
delete: { verb: 'Deleting', resource: 'skill' },
list: { verb: 'Viewing', resource: 'skills' },
})
}
case 'manage_credential': {
const operation = stringArg(args, 'operation')
if (operation === 'rename') {
const from = firstStringArg(args, 'previousDisplayName', 'oldName', 'credentialName')
const to = firstStringArg(args, 'displayName', 'newName', 'name', 'title')
if (from && to) return `Renaming ${from} to ${to}`
return to ? `Renaming credential to ${to}` : 'Renaming credential'
}
const target = firstStringArg(args, 'credentialName', 'displayName', 'name', 'title')
return namedOperationTitle(args, target, 'Credential action', {
delete: { verb: 'Deleting', resource: 'credential' },
})
}
case 'run_workflow':
case 'run_from_block':
case 'run_workflow_until_block':
return 'Running workflow'
case 'query_logs': {
const workflowName = stringArg(args, 'workflowName')
return workflowName ? `Querying logs for ${workflowName}` : 'Querying logs'
}
case 'read': {
if (isWorkflowArtifactPath(stringArg(args, 'path'), 'lint.json')) {
return 'Validating workflow state'
}
break
}
case 'workspace_file':
case 'function_execute': {
const title = name === 'workspace_file' ? workspaceFileTitle(args) : stringArg(args, 'title')
if (title) return title
break
}
}
return TOOL_TITLES[name] ?? humanizeToolName(name)
}
/**
* Present-participle to past-tense verb map for completed tool titles. Applied
* to the leading word only, so "Searching online for X" -> "Searched online
* for X" while non-gerund labels ("Run Agent", "Folder action") pass through.
*/
const COMPLETED_VERB_REWRITES: Record<string, string> = {
Accessing: 'Accessed',
Adding: 'Added',
Applying: 'Applied',
Cancelling: 'Cancelled',
Calling: 'Called',
Checking: 'Checked',
Clicking: 'Clicked',
Closing: 'Closed',
Combining: 'Combined',
Comparing: 'Compared',
Completing: 'Completed',
Converting: 'Converted',
Crawling: 'Crawled',
Creating: 'Created',
Deleting: 'Deleted',
Deploying: 'Deployed',
Disabling: 'Disabled',
Downloading: 'Downloaded',
Duplicating: 'Duplicated',
Editing: 'Edited',
Enabling: 'Enabled',
Executing: 'Executed',
Extracting: 'Extracted',
Fading: 'Faded',
Finding: 'Found',
Gathering: 'Gathered',
Generating: 'Generated',
Getting: 'Got',
Going: 'Went',
Hovering: 'Hovered',
Importing: 'Imported',
Inspecting: 'Inspected',
Listing: 'Listed',
Loading: 'Loaded',
Managing: 'Managed',
Mixing: 'Mixed',
Moving: 'Moved',
Opening: 'Opened',
Overwriting: 'Overwrote',
Preparing: 'Prepared',
Pressing: 'Pressed',
Processing: 'Processed',
Promoting: 'Promoted',
Querying: 'Queried',
Reading: 'Read',
Redeploying: 'Redeployed',
Renaming: 'Renamed',
Requesting: 'Requested',
Resizing: 'Resized',
Restoring: 'Restored',
Running: 'Ran',
Saving: 'Saved',
Scanning: 'Scanned',
Scraping: 'Scraped',
Scrolling: 'Scrolled',
Searching: 'Searched',
Selecting: 'Selected',
Setting: 'Set',
Sharing: 'Shared',
Stopping: 'Stopped',
Summarizing: 'Summarized',
Switching: 'Switched',
Syncing: 'Synced',
Taking: 'Took',
Toggling: 'Toggled',
Trimming: 'Trimmed',
Typing: 'Typed',
Undeploying: 'Undeployed',
Unsharing: 'Unshared',
Updating: 'Updated',
Using: 'Used',
Validating: 'Validated',
Viewing: 'Viewed',
Waiting: 'Waited',
Writing: 'Wrote',
}
/**
* Rewrite a resolved display title to its past-tense form for a successfully
* completed tool call (e.g. "Querying logs for X" -> "Queried logs for X").
* Operates on the already-resolved title so enriched and persisted titles both
* work. Returns undefined when the title has no leading gerund rewrite — the
* caller keeps the original. Integration gateway descriptions are base-form
* verb phrases ("Read recent emails") whose first word never matches a gerund
* key, so they intentionally pass through unchanged.
*/
export function getToolCompletedTitle(title: string): string | undefined {
const spaceIndex = title.indexOf(' ')
const firstWord = spaceIndex === -1 ? title : title.slice(0, spaceIndex)
const past = COMPLETED_VERB_REWRITES[firstWord]
if (!past) return undefined
return past + title.slice(firstWord.length)
}
/**
* Resolve the final title for a tool status at a rendering boundary. Persisted
* and live snapshots intentionally keep the present-tense activity title so a
* running/error row remains truthful; every successful renderer calls this to
* project the corresponding completed title from the canonical verb map.
*/
export function getToolStatusDisplayTitle(title: string, status: string): string {
return status === 'success' ? (getToolCompletedTitle(title) ?? title) : title
}