kimi-code/apps/kimi-web/src/lib/toolMeta.ts
Luyu Cheng ad30a1c632
style(web): polish web UI typography and controls (#1502)
* style(web): polish sidebar and tool typography

- Use UI font and medium weight for sidebar and composer controls

- Add reusable shortcut and tool output blocks

- Cap long tool output at 50 lines with a scrollbar

- Update sidebar show-more copy and muted styling

* style(web): refine workspace picker sizing

* style(web): align composer mode menus

* style(web): tune list and question typography

* style(web): reuse shortcut keys in approvals

* style(web): size workspace picker from content

* feat(web): localize chat status labels

* style(web): refine composer toolbar controls

* style(web): use complete Inter variable font

* style(web): tune sidebar workspace typography

* style(web): polish composer and workspace picker

* style(web): refine markdown and thinking typography

* chore: add web UI polish changeset

* fix(web): pin Inter package for Nix build

* style(web): polish goal tool calls

* style: polish goal mode display

* fix: layer latest message pill below menus

* style: align goal strip content
2026-07-09 14:55:58 +08:00

386 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// apps/kimi-web/src/lib/toolMeta.ts
// Helpers for tool display. Labels/chips are localized via the shared i18n instance.
import { i18n } from '../i18n';
import { iconSvg, type IconName } from './icons';
const t = i18n.global.t;
// ---------------------------------------------------------------------------
// toolLabel: human-readable, localized label for a tool name
// ---------------------------------------------------------------------------
const TOOL_LABEL_KEYS: Record<string, string> = {
read: 'tools.label.read',
bash: 'tools.label.bash',
edit: 'tools.label.edit',
multi_edit: 'tools.label.edit',
write: 'tools.label.write',
grep: 'tools.label.grep',
glob: 'tools.label.glob',
ls: 'tools.label.ls',
web_fetch: 'tools.label.web_fetch',
search: 'tools.label.search',
todo: 'tools.label.todo',
task: 'tools.label.task',
agentswarm: 'tools.label.swarm',
askuserquestion: 'tools.label.ask_user',
creategoal: 'tools.label.goal_create',
getgoal: 'tools.label.goal_get',
setgoalbudget: 'tools.label.goal_budget',
updategoal: 'tools.label.goal_update',
};
// ---------------------------------------------------------------------------
// normalizeToolName: fold the many real-world spellings of a tool name into the
// canonical lowercase kind used by the maps below. Daemon tool names arrive
// verbatim and may be CamelCase (`Read`, `MultiEdit`, `WebFetch`, `TodoWrite`)
// or aliased (`shell`, `fetch`). Without this, those names silently fall through
// to the default glyph / raw-arg summary.
// ---------------------------------------------------------------------------
const NAME_ALIASES: Record<string, string> = {
multiedit: 'multi_edit',
multiedits: 'multi_edit',
shell: 'bash',
run: 'bash',
exec: 'bash',
ripgrep: 'grep',
rg: 'grep',
find: 'glob',
fetch: 'web_fetch',
webfetch: 'web_fetch',
url_fetch: 'web_fetch',
urlfetch: 'web_fetch',
list: 'ls',
listdir: 'ls',
list_dir: 'ls',
todowrite: 'todo',
todo_write: 'todo',
todoread: 'todo',
todolist: 'todo',
todo_list: 'todo',
agent: 'task',
subagent: 'task',
websearch: 'search',
web_search: 'search',
create_goal: 'creategoal',
get_goal: 'getgoal',
set_goal_budget: 'setgoalbudget',
update_goal: 'updategoal',
};
export function normalizeToolName(name: string): string {
const lower = (name ?? '').trim().toLowerCase().replace(/[\s-]+/g, '_');
return NAME_ALIASES[lower] ?? lower;
}
export function toolLabel(name: string): string {
const key = TOOL_LABEL_KEYS[normalizeToolName(name)];
return key ? t(key) : name;
}
// ---------------------------------------------------------------------------
// toolGlyph: a small inline SVG string for a tool name, rendered from the
// shared icon registry (lib/icons.ts) at sm (14px). Returns '' for unknown
// tools (no glyph). Suitable for v-html in a 14×14 container.
// ---------------------------------------------------------------------------
const TOOL_GLYPH: Record<string, IconName> = {
read: 'file-text',
bash: 'terminal',
edit: 'pencil',
multi_edit: 'pencil',
write: 'file-plus',
grep: 'search',
search: 'search',
glob: 'glob',
ls: 'folder',
web_fetch: 'globe',
todo: 'check-list',
task: 'sparkles',
agentswarm: 'git-pull-request',
askuserquestion: 'help-circle',
creategoal: 'target',
getgoal: 'target',
setgoalbudget: 'target',
updategoal: 'target',
// Cron scheduling tools share a calendar motif: schedule / list / cancel.
croncreate: 'calendar-schedule',
cronlist: 'calendar-todo',
crondelete: 'calendar-close',
};
export function toolGlyph(name: string): string {
const key = normalizeToolName(name);
let icon = TOOL_GLYPH[key];
if (!icon && (name ?? '').trim().toLowerCase().includes('skill')) icon = 'bolt';
if (!icon) icon = 'tool';
return iconSvg(icon, 'sm');
}
// ---------------------------------------------------------------------------
// toolChip: short stat string derived from tool output / arguments
// Defensive: never throws.
// ---------------------------------------------------------------------------
export interface ToolChipInput {
name: string;
arg: string;
output?: string[];
timing?: string;
status?: string;
}
// ---------------------------------------------------------------------------
// toolSummary: a concise, per-tool-kind header string derived from the tool's
// arguments (`arg` holds the JSON-stringified tool input, or a plain string).
// Read → path + line range, Write/Edit → path, Bash → command (truncated),
// Grep/Search → pattern, Glob/LS → path/pattern, Fetch → host/url.
// Falls back to the raw arg for unknown tools. Defensive: never throws.
// ---------------------------------------------------------------------------
const SUMMARY_MAX = 80;
function clip(s: string, max = SUMMARY_MAX): string {
const trimmed = s.trim();
return trimmed.length > max ? trimmed.slice(0, max - 1) + '…' : trimmed;
}
/** True when the tool argument carries nothing worth showing — an empty object
`{}`, empty array `[]`, empty/`null` string, or a parsed record with no keys.
Used to drop the noisy `{}` from the collapsed header (the expanded body
still renders it). */
function isEmptyArg(arg: string, d: Record<string, unknown> | null): boolean {
const s = arg.trim();
if (s === '' || s === '{}' || s === '[]' || s === 'null') return true;
if (d && Object.keys(d).length === 0) return true;
return false;
}
/** Parse the JSON-stringified `arg` into a record, or null for plain strings. */
function parseArg(arg: string): Record<string, unknown> | null {
const s = arg.trim();
if (!s.startsWith('{')) return null;
try {
const v = JSON.parse(s);
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
} catch {
return null;
}
}
function str(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined;
}
function num(v: unknown): number | undefined {
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
}
/** Reduce a URL to "host[/first-segment]" for a compact fetch summary. */
function urlHost(url: string): string {
try {
const u = new URL(url);
const seg = u.pathname.split('/').filter(Boolean)[0];
return seg ? `${u.host}/${seg}` : u.host;
} catch {
return url.replace(/^https?:\/\//, '');
}
}
/** Take a tool input's file path, regardless of which key the tool used. */
function filePath(d: Record<string, unknown>): string | undefined {
return str(d.path) ?? str(d.file_path) ?? str(d.filePath) ?? str(d.filename);
}
const GOAL_STATUS_KEYS: Record<string, string> = {
active: 'status.goalStatusActive',
blocked: 'status.goalStatusBlocked',
complete: 'status.goalStatusComplete',
};
function goalStatusLabel(value: unknown): string | undefined {
const status = str(value);
if (!status) return undefined;
const key = GOAL_STATUS_KEYS[status];
return key ? t(key) : status;
}
function goalBudgetSummary(d: Record<string, unknown>): string | undefined {
const value = num(d.value);
const unit = str(d.unit);
if (value === undefined || !unit) return undefined;
switch (unit) {
case 'turns':
return t('tools.goal.turns', { value });
case 'tokens':
return t('tools.goal.tokens', { value });
case 'milliseconds':
return t('tools.goal.milliseconds', { value });
case 'seconds':
return t('tools.goal.seconds', { value });
case 'minutes':
return t('tools.goal.minutes', { value });
case 'hours':
return t('tools.goal.hours', { value });
default:
return t('tools.goal.budget', { value, unit });
}
}
const BASH_MAX = 64;
/**
* @param full when true, skip the `…` length clip and return the complete
* summary — used by the expanded tool-card body (it has room to wrap). The
* collapsed header passes the default (clipped) form.
*/
export function toolSummary(name: string, arg: string, full = false): string {
// Local clip that becomes a no-op (trim only) in `full` mode.
const c = (s: string, max = SUMMARY_MAX): string => (full ? s.trim() : clip(s, max));
try {
const d = parseArg(arg);
// Empty argument (e.g. `{}`): keep it OUT of the collapsed header title, but
// still show it in the expanded body (full mode) so the detail isn't lost.
if (!full && isEmptyArg(arg, d)) return '';
// Plain-string arg (already a human string).
const fallback = () => c(arg.replace(/^·\s*/, ''));
if (!d) return fallback();
switch (normalizeToolName(name)) {
case 'read': {
const path = filePath(d);
if (!path) return fallback();
const start = num(d.offset) ?? num(d.line_start) ?? num(d.start_line);
const len = num(d.limit) ?? num(d.length);
const end = num(d.line_end) ?? num(d.end_line) ?? (start !== undefined && len !== undefined ? start + len : undefined);
if (start !== undefined && end !== undefined) return c(`${path}:${start}-${end}`);
if (start !== undefined) return c(`${path}:${start}`);
return c(path);
}
case 'write': {
const path = filePath(d);
return path ? c(`${path} ${t('tools.chip.created')}`) : fallback();
}
case 'edit':
case 'multi_edit': {
const path = filePath(d);
return path ? c(path) : fallback();
}
case 'bash': {
const cmd = str(d.command) ?? str(d.cmd) ?? str(d.script);
return cmd ? c(cmd, BASH_MAX) : fallback();
}
case 'grep':
case 'search': {
const pattern = str(d.pattern) ?? str(d.query) ?? str(d.regex);
const path = str(d.path) ?? str(d.glob) ?? str(d.include);
if (pattern && path) return c(`${pattern} in ${path}`);
return pattern ? c(pattern) : fallback();
}
case 'glob': {
const pattern = str(d.pattern) ?? str(d.glob) ?? str(d.query);
const path = str(d.path) ?? str(d.cwd);
if (pattern && path) return c(`${pattern} in ${path}`);
return pattern ? c(pattern) : (str(d.path) ? c(str(d.path)!) : fallback());
}
case 'ls': {
const dir = str(d.path) ?? str(d.dir) ?? str(d.directory) ?? str(d.cwd);
return dir ? c(dir) : fallback();
}
case 'web_fetch': {
const url = str(d.url) ?? str(d.uri);
return url ? c(urlHost(url)) : fallback();
}
case 'todo':
case 'task': {
const label =
str(d.description) ?? str(d.title) ?? str(d.prompt) ?? str(d.name) ?? str(d.subagent_type);
if (label) return c(label);
const items = Array.isArray(d.todos) ? d.todos : Array.isArray(d.items) ? d.items : undefined;
if (items) return c(t('tools.chip.todos', { count: items.length }));
return fallback();
}
case 'creategoal': {
if (full) return fallback();
const objective = str(d.objective);
const criterion = str(d.completionCriterion);
if (objective && criterion) return c(t('tools.goal.objectiveWithCriterion', { objective, criterion }));
return objective ? c(objective) : fallback();
}
case 'getgoal': {
if (full) return fallback();
return '';
}
case 'setgoalbudget': {
if (full) return fallback();
const summary = goalBudgetSummary(d);
return summary ? c(summary) : fallback();
}
case 'updategoal': {
if (full) return fallback();
const status = goalStatusLabel(d.status);
return status ? c(t('tools.goal.status', { status })) : fallback();
}
default:
return fallback();
}
} catch {
return arg;
}
}
export function toolChip(tool: ToolChipInput): string {
try {
switch (normalizeToolName(tool.name)) {
case 'bash': {
// Prefer timing if present
if (tool.timing) return tool.timing;
return '';
}
case 'read': {
// Count output lines
if (tool.output && tool.output.length > 0) {
const count = tool.output.length;
return t('tools.chip.lines', { count });
}
return '';
}
case 'edit':
case 'multi_edit':
case 'write': {
// Try to parse +A B from output (unified diff summary)
if (tool.output) {
for (const line of tool.output) {
const m = line.match(/\+(\d+).*[-](\d+)/);
if (m) return `+${m[1]} ${m[2]}`;
}
// Also check for simple "N lines" style
const summary = tool.output.find(l => /\d+/.test(l));
if (summary) {
const addMatch = summary.match(/\+(\d+)/);
const remMatch = summary.match(/[-](\d+)/);
if (addMatch || remMatch) {
return `${addMatch ? `+${addMatch[1]}` : ''} ${remMatch ? `${remMatch[1]}` : ''}`.trim();
}
}
// Succeeded but no diff counts available → just signal "edited".
if (tool.status !== 'error') return t('tools.chip.edited');
}
return '';
}
case 'grep':
case 'search': {
if (tool.output && tool.output.length > 0) {
return t('tools.chip.results', { count: tool.output.length });
}
return '';
}
default:
return '';
}
} catch {
return '';
}
}