Advertisement

← vibesdk

src/routes/chat/utils/tool-display.ts

10.4 KB Download file
import type { ToolEvent } from './message-helpers';

function asString(value: unknown): string | undefined {
	return typeof value === 'string' && value.trim().length > 0
		? value.trim()
		: undefined;
}

function pickString(
	args: Record<string, unknown> | undefined,
	...keys: string[]
): string | undefined {
	if (!args) return undefined;
	for (const key of keys) {
		const value = asString(args[key]);
		if (value) return value;
	}
	return undefined;
}

function pickStringArray(
	args: Record<string, unknown> | undefined,
	...keys: string[]
): string[] {
	if (!args) return [];
	for (const key of keys) {
		const value = args[key];
		if (Array.isArray(value)) {
			return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
		}
		const single = asString(value);
		if (single) return [single];
	}
	return [];
}

/** Shorten a path for display: keep last 2 segments when long. */
export function shortenPath(path: string, max = 42): string {
	const normalized = path.replace(/\\/g, '/');
	if (normalized.length <= max) return normalized;
	const parts = normalized.split('/').filter(Boolean);
	if (parts.length <= 2) {
		return `…${normalized.slice(-(max - 1))}`;
	}
	const tail = parts.slice(-2).join('/');
	return tail.length + 2 <= max ? `…/${tail}` : `…${tail.slice(-(max - 1))}`;
}

function quote(value: string, max = 48): string {
	const trimmed = value.length > max ? `${value.slice(0, max - 1)}…` : value;
	return `"${trimmed}"`;
}

function pathLabel(path: string | undefined): string {
	return path ? shortenPath(path) : 'file';
}

export function pickToolPath(args?: Record<string, unknown>): string | undefined {
	return pickString(args, 'path', 'filePath', 'file');
}

export function pickToolPaths(args?: Record<string, unknown>): string[] {
	const multi = pickStringArray(args, 'paths');
	if (multi.length > 0) return multi;
	const single = pickToolPath(args);
	return single ? [single] : [];
}

type VerbSet = { start: string; success: string; error: string };

const VERBS: Record<string, VerbSet> = {
	read: { start: 'Reading', success: 'Read', error: 'Failed to read' },
	write: { start: 'Writing', success: 'Wrote', error: 'Failed to write' },
	edit: { start: 'Editing', success: 'Edited', error: 'Failed to edit' },
	delete: { start: 'Deleting', success: 'Deleted', error: 'Failed to delete' },
	list: { start: 'Listing', success: 'Listed', error: 'Failed to list' },
	find: { start: 'Finding', success: 'Find', error: 'Failed to find' },
	grep: { start: 'Searching', success: 'Searched', error: 'Failed to search' },
	bash: { start: 'Running', success: 'Ran', error: 'Failed to run' },
	commit: { start: 'Committing', success: 'Committed', error: 'Failed to commit' },
	deploy_space: { start: 'Deploying', success: 'Deployed', error: 'Failed to deploy' },
	deploy_preview: { start: 'Deploying preview', success: 'Deployed preview', error: 'Failed to deploy preview' },
	set_title: { start: 'Setting title', success: 'Set title', error: 'Failed to set title' },
	ask_questions: { start: 'Asking', success: 'Asked', error: 'Failed to ask' },
	get_browser_console_logs: {
		start: 'Checking browser console',
		success: 'Checked browser console',
		error: 'Failed to check browser console',
	},
	activate_skill: { start: 'Loading skill', success: 'Loaded skill', error: 'Failed to load skill' },
	web_search: { start: 'Searching the web', success: 'Searched the web', error: 'Web search failed' },
	git: { start: 'Running git', success: 'Ran git', error: 'Git failed' },
	read_files: { start: 'Reading files', success: 'Read files', error: 'Failed to read files' },
	regenerate_file: { start: 'Regenerating', success: 'Regenerated', error: 'Failed to regenerate' },
	generate_files: { start: 'Generating files', success: 'Generated files', error: 'Failed to generate files' },
	exec_commands: { start: 'Running commands', success: 'Ran commands', error: 'Commands failed' },
	run_analysis: { start: 'Analyzing', success: 'Analyzed', error: 'Analysis failed' },
	get_runtime_errors: { start: 'Checking runtime errors', success: 'Checked runtime errors', error: 'Failed to check runtime errors' },
	get_logs: { start: 'Fetching logs', success: 'Fetched logs', error: 'Failed to fetch logs' },
	deep_debug: { start: 'Debugging', success: 'Finished debugging', error: 'Debugging failed' },
	queue_request: { start: 'Queuing request', success: 'Queued request', error: 'Failed to queue request' },
	submit_feedback: { start: 'Submitting feedback', success: 'Submitted feedback', error: 'Failed to submit feedback' },
	rename_project: { start: 'Renaming project', success: 'Renamed project', error: 'Failed to rename project' },
	alter_blueprint: { start: 'Updating blueprint', success: 'Updated blueprint', error: 'Failed to update blueprint' },
	wait_for_generation: { start: 'Waiting for generation', success: 'Generation ready', error: 'Wait failed' },
	wait_for_debug: { start: 'Waiting for debug', success: 'Debug ready', error: 'Wait failed' },
	wait: { start: 'Waiting', success: 'Waited', error: 'Wait failed' },
};

function verbFor(name: string, status: ToolEvent['status']): string {
	const set = VERBS[name];
	if (set) return set[status === 'start' ? 'start' : status === 'error' ? 'error' : 'success'];
	const pretty = name.replace(/_/g, ' ');
	if (status === 'start') return `Running ${pretty}`;
	if (status === 'error') return `Failed ${pretty}`;
	return pretty.charAt(0).toUpperCase() + pretty.slice(1);
}

function detailFor(event: ToolEvent): string | undefined {
	const { name, args } = event;
	const path = pickToolPath(args);
	const paths = pickToolPaths(args);

	switch (name) {
		case 'read':
		case 'write':
		case 'edit':
		case 'delete':
		case 'list':
		case 'regenerate_file':
			return path ? pathLabel(path) : undefined;
		case 'read_files':
			if (paths.length === 1) return pathLabel(paths[0]);
			if (paths.length > 1) return `${paths.length} files`;
			return undefined;
		case 'find': {
			const pattern = pickString(args, 'pattern', 'glob');
			return pattern ? pattern : undefined;
		}
		case 'grep': {
			const query = pickString(args, 'query', 'pattern', 'search');
			const include = pickString(args, 'include');
			if (query && include) return `${quote(query)} in ${include}`;
			if (query) return quote(query);
			return undefined;
		}
		case 'bash':
		case 'exec_commands': {
			const script = pickString(args, 'script', 'command');
			if (script) return quote(script, 40);
			const commands = pickStringArray(args, 'commands');
			if (commands.length === 1) return quote(commands[0], 40);
			if (commands.length > 1) return `${commands.length} commands`;
			return undefined;
		}
		case 'commit': {
			const message = pickString(args, 'message');
			return message ? quote(message, 40) : undefined;
		}
		case 'git': {
			const command = pickString(args, 'command');
			const message = pickString(args, 'message');
			if (command === 'commit' && message) return `commit ${quote(message, 32)}`;
			return command;
		}
		case 'set_title':
		case 'rename_project': {
			const title = pickString(args, 'title', 'newName', 'name');
			return title ? quote(title, 40) : undefined;
		}
		case 'web_search': {
			const query = pickString(args, 'query');
			const url = pickString(args, 'url');
			if (query) return quote(query, 40);
			if (url) return shortenPath(url, 40);
			return undefined;
		}
		case 'activate_skill': {
			const skill = pickString(args, 'skill', 'name', 'id', 'skill_id');
			return skill;
		}
		case 'ask_questions': {
			const questions = args?.questions;
			if (Array.isArray(questions) && questions.length > 0) {
				return questions.length === 1 ? '1 question' : `${questions.length} questions`;
			}
			return undefined;
		}
		case 'get_browser_console_logs': {
			const url = pickString(args, 'url');
			return url ? shortenPath(url, 40) : undefined;
		}
		case 'queue_request': {
			const req = pickString(args, 'modificationRequest', 'message');
			return req ? quote(req, 40) : undefined;
		}
		case 'deep_debug': {
			const issue = pickString(args, 'issue');
			return issue ? quote(issue, 40) : undefined;
		}
		case 'generate_files': {
			const phase = pickString(args, 'phase_name', 'phaseName');
			return phase;
		}
		default:
			return path ? pathLabel(path) : undefined;
	}
}

/**
 * Plain-English one-line summary for a tool event.
 * e.g. "Reading src/App.tsx…", "Read src/App.tsx", "Failed to read src/App.tsx"
 */
export function getToolSummary(event: ToolEvent): string {
	const verb = verbFor(event.name, event.status);
	const detail = detailFor(event);
	const ellipsis = event.status === 'start' ? '…' : '';
	if (!detail) return `${verb}${ellipsis}`;
	return `${verb} ${detail}${ellipsis}`;
}

/** Noun used when collapsing multiple consecutive tools of the same name. */
function groupNoun(name: string, count: number): string {
	const nouns: Record<string, [string, string]> = {
		read: ['file', 'files'],
		write: ['file', 'files'],
		edit: ['file', 'files'],
		delete: ['file', 'files'],
		list: ['directory', 'directories'],
		find: ['search', 'searches'],
		grep: ['search', 'searches'],
		bash: ['command', 'commands'],
		exec_commands: ['command', 'commands'],
		read_files: ['file batch', 'file batches'],
		commit: ['commit', 'commits'],
		web_search: ['search', 'searches'],
		ask_questions: ['question set', 'question sets'],
	};
	const pair = nouns[name] ?? ['step', 'steps'];
	return count === 1 ? pair[0] : pair[1];
}

/**
 * Summary for a collapsed group of consecutive same-name tools.
 * e.g. "Read 4 files", "Reading 2 files…", "Edited 3 files"
 */
export function getToolGroupSummary(events: ToolEvent[]): string {
	if (events.length === 0) return '';
	if (events.length === 1) return getToolSummary(events[0]);

	const name = events[0].name;
	const anyRunning = events.some((e) => e.status === 'start');
	const anyError = events.some((e) => e.status === 'error');
	const status: ToolEvent['status'] = anyRunning ? 'start' : anyError ? 'error' : 'success';
	const verb = verbFor(name, status);
	const noun = groupNoun(name, events.length);
	const ellipsis = status === 'start' ? '…' : '';
	return `${verb} ${events.length} ${noun}${ellipsis}`;
}

export function getGroupStatus(events: ToolEvent[]): ToolEvent['status'] {
	if (events.some((e) => e.status === 'start')) return 'start';
	if (events.some((e) => e.status === 'error')) return 'error';
	return 'success';
}