@@ -201,11 +82,12 @@ export default function ExecuteCode({
finishedText={
cancelled ? localize('com_ui_cancelled') : localize('com_ui_analyzing_finished')
}
+ errorSuffix={hasError && !cancelled ? localize('com_ui_tool_failed') : undefined}
icon={
diff --git a/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx b/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx
new file mode 100644
index 0000000000..bd1ccb1bd4
--- /dev/null
+++ b/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx
@@ -0,0 +1,129 @@
+import { useMemo } from 'react';
+import { FileText } from 'lucide-react';
+import type { TAttachment } from 'librechat-data-provider';
+import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
+import useToolCallState from './useToolCallState';
+import useLazyHighlight from './useLazyHighlight';
+import CodeWindowHeader from './CodeWindowHeader';
+import { AttachmentGroup } from './Attachment';
+import parseJsonField from './parseJsonField';
+import { useLocalize } from '~/hooks';
+import { cn } from '~/utils';
+
+const LANG_MAP: Record
= {
+ py: 'python',
+ js: 'javascript',
+ ts: 'typescript',
+ tsx: 'typescript',
+ jsx: 'javascript',
+ rs: 'rust',
+ go: 'go',
+ rb: 'ruby',
+ java: 'java',
+ kt: 'kotlin',
+ swift: 'swift',
+ cs: 'csharp',
+ php: 'php',
+ lua: 'lua',
+ r: 'r',
+ sh: 'bash',
+ bash: 'bash',
+ zsh: 'bash',
+ json: 'json',
+ yaml: 'yaml',
+ yml: 'yaml',
+ toml: 'toml',
+ md: 'markdown',
+ sql: 'sql',
+ css: 'css',
+ scss: 'scss',
+ less: 'less',
+ html: 'html',
+ xml: 'xml',
+ c: 'c',
+ cpp: 'cpp',
+ h: 'c',
+ hpp: 'cpp',
+};
+
+const FILENAME_MAP: Record = {
+ makefile: 'makefile',
+ dockerfile: 'dockerfile',
+};
+
+export function langFromPath(filePath: string): string {
+ const name = filePath.split('/').pop()?.toLowerCase() ?? '';
+ const byName = FILENAME_MAP[name];
+ if (byName) {
+ return byName;
+ }
+ const ext = name.includes('.') ? (name.split('.').pop() ?? '') : '';
+ return LANG_MAP[ext] ?? 'plaintext';
+}
+
+export default function ReadFileCall({
+ isSubmitting,
+ initialProgress = 0.1,
+ args,
+ output = '',
+ attachments,
+}: {
+ initialProgress: number;
+ isSubmitting: boolean;
+ args?: string | Record;
+ output?: string;
+ attachments?: TAttachment[];
+}) {
+ const localize = useLocalize();
+ const filePath = useMemo(() => parseJsonField(args, 'file_path'), [args]);
+ const fileName = filePath.split('/').pop() || filePath;
+ const lang = useMemo(() => langFromPath(filePath), [filePath]);
+
+ const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } =
+ useToolCallState(initialProgress, isSubmitting, output, !!filePath);
+
+ const highlighted = useLazyHighlight(hasOutput ? output : undefined, lang);
+
+ return (
+ <>
+
+
+ }
+ hasInput={!!filePath || hasOutput}
+ isExpanded={showCode}
+ error={cancelled}
+ />
+
+
+
+ {hasOutput && (
+
+
+
+
+ {highlighted ?? output}
+
+
+
+ )}
+
+
+ {attachments && attachments.length > 0 && }
+ >
+ );
+}
diff --git a/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx b/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx
new file mode 100644
index 0000000000..0a6ce191bb
--- /dev/null
+++ b/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx
@@ -0,0 +1,77 @@
+import { useMemo } from 'react';
+import { ScrollText } from 'lucide-react';
+import type { TAttachment } from 'librechat-data-provider';
+import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
+import useToolCallState from './useToolCallState';
+import { AttachmentGroup } from './Attachment';
+import parseJsonField from './parseJsonField';
+import { useLocalize } from '~/hooks';
+import Stdout from './Stdout';
+import { cn } from '~/utils';
+
+export default function SkillCall({
+ isSubmitting,
+ initialProgress = 0.1,
+ args,
+ output = '',
+ attachments,
+}: {
+ initialProgress: number;
+ isSubmitting: boolean;
+ args?: string | Record;
+ output?: string;
+ attachments?: TAttachment[];
+}) {
+ const localize = useLocalize();
+ const skillName = useMemo(() => parseJsonField(args, 'skillName'), [args]);
+
+ const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } =
+ useToolCallState(initialProgress, isSubmitting, output, !!skillName);
+
+ return (
+ <>
+
+
+ }
+ hasInput={!!skillName || hasOutput}
+ isExpanded={showCode}
+ error={cancelled}
+ />
+
+
+
+ {hasOutput && (
+
+
+
+ {localize('com_ui_output')}
+
+
+
+
+
+
+ )}
+
+
+ {attachments && attachments.length > 0 && }
+ >
+ );
+}
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/langFromPath.test.ts b/client/src/components/Chat/Messages/Content/Parts/__tests__/langFromPath.test.ts
new file mode 100644
index 0000000000..f362ffa643
--- /dev/null
+++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/langFromPath.test.ts
@@ -0,0 +1,117 @@
+import { langFromPath } from '../ReadFileCall';
+
+describe('langFromPath', () => {
+ describe('common extensions', () => {
+ it.each([
+ ['main.py', 'python'],
+ ['index.js', 'javascript'],
+ ['app.ts', 'typescript'],
+ ['component.tsx', 'typescript'],
+ ['component.jsx', 'javascript'],
+ ['lib.rs', 'rust'],
+ ['main.go', 'go'],
+ ['Main.java', 'java'],
+ ])('%s -> %s', (filename, expected) => {
+ expect(langFromPath(filename)).toBe(expected);
+ });
+ });
+
+ describe('full paths', () => {
+ it('resolves language from the filename at the end of a Unix path', () => {
+ expect(langFromPath('/home/user/project/main.py')).toBe('python');
+ });
+
+ it('resolves language when path has multiple segments', () => {
+ expect(langFromPath('/usr/local/src/app/index.ts')).toBe('typescript');
+ });
+
+ it('ignores dots in directory names and uses the file extension', () => {
+ expect(langFromPath('my.project/config.json')).toBe('json');
+ });
+ });
+
+ describe('extensionless filename map', () => {
+ it('returns makefile for "makefile"', () => {
+ expect(langFromPath('makefile')).toBe('makefile');
+ });
+
+ it('returns makefile for mixed-case Makefile', () => {
+ expect(langFromPath('Makefile')).toBe('makefile');
+ });
+
+ it('returns dockerfile for Dockerfile', () => {
+ expect(langFromPath('Dockerfile')).toBe('dockerfile');
+ });
+
+ it('returns dockerfile for lowercase dockerfile', () => {
+ expect(langFromPath('dockerfile')).toBe('dockerfile');
+ });
+
+ it('resolves filename map entry from a full path', () => {
+ expect(langFromPath('/home/user/project/Makefile')).toBe('makefile');
+ });
+ });
+
+ describe('case insensitivity', () => {
+ it('handles uppercase extension .MD -> markdown', () => {
+ expect(langFromPath('README.MD')).toBe('markdown');
+ });
+
+ it('handles mixed-case extension .Ts -> typescript', () => {
+ expect(langFromPath('module.Ts')).toBe('typescript');
+ });
+
+ it('handles uppercase extension .JS -> javascript', () => {
+ expect(langFromPath('bundle.JS')).toBe('javascript');
+ });
+ });
+
+ describe('unknown or unrecognised inputs', () => {
+ it('returns plaintext for unknown extension', () => {
+ expect(langFromPath('archive.xyz')).toBe('plaintext');
+ });
+
+ it('returns plaintext for empty string', () => {
+ expect(langFromPath('')).toBe('plaintext');
+ });
+
+ it('returns plaintext for dotfile with no mapped extension (.gitignore)', () => {
+ expect(langFromPath('.gitignore')).toBe('plaintext');
+ });
+
+ it('returns plaintext for dotfile in a full path', () => {
+ expect(langFromPath('/home/user/.gitignore')).toBe('plaintext');
+ });
+ });
+
+ describe('additional mapped extensions', () => {
+ it.each([
+ ['query.sql', 'sql'],
+ ['style.css', 'css'],
+ ['style.scss', 'scss'],
+ ['style.less', 'less'],
+ ['index.html', 'html'],
+ ['config.xml', 'xml'],
+ ['config.yaml', 'yaml'],
+ ['config.yml', 'yaml'],
+ ['config.toml', 'toml'],
+ ['script.sh', 'bash'],
+ ['script.bash', 'bash'],
+ ['script.zsh', 'bash'],
+ ['data.json', 'json'],
+ ['file.c', 'c'],
+ ['file.h', 'c'],
+ ['file.cpp', 'cpp'],
+ ['file.hpp', 'cpp'],
+ ['App.kt', 'kotlin'],
+ ['App.swift', 'swift'],
+ ['Program.cs', 'csharp'],
+ ['script.php', 'php'],
+ ['mod.lua', 'lua'],
+ ['analysis.r', 'r'],
+ ['module.rb', 'ruby'],
+ ])('%s -> %s', (filename, expected) => {
+ expect(langFromPath(filename)).toBe(expected);
+ });
+ });
+});
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/parseJsonField.test.ts b/client/src/components/Chat/Messages/Content/Parts/__tests__/parseJsonField.test.ts
new file mode 100644
index 0000000000..4d042387e2
--- /dev/null
+++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/parseJsonField.test.ts
@@ -0,0 +1,103 @@
+import parseJsonField from '../parseJsonField';
+
+describe('parseJsonField', () => {
+ describe('object args', () => {
+ it('returns the field value when present', () => {
+ expect(parseJsonField({ skillName: 'mySkill' }, 'skillName')).toBe('mySkill');
+ });
+
+ it('returns empty string when field is missing', () => {
+ expect(parseJsonField({ other: 'value' }, 'skillName')).toBe('');
+ });
+
+ it('returns empty string when field is null', () => {
+ expect(parseJsonField({ skillName: null }, 'skillName')).toBe('');
+ });
+
+ it('coerces numeric field values to string', () => {
+ expect(parseJsonField({ count: 42 }, 'count')).toBe('42');
+ });
+
+ it('handles different field names — file_path', () => {
+ expect(parseJsonField({ file_path: '/home/user/file.ts' }, 'file_path')).toBe(
+ '/home/user/file.ts',
+ );
+ });
+
+ it('handles different field names — command', () => {
+ expect(parseJsonField({ command: 'ls -la' }, 'command')).toBe('ls -la');
+ });
+ });
+
+ describe('undefined args', () => {
+ it('returns empty string for undefined', () => {
+ expect(parseJsonField(undefined, 'skillName')).toBe('');
+ });
+ });
+
+ describe('valid JSON string args', () => {
+ it('returns the field value when present', () => {
+ expect(parseJsonField('{"skillName":"mySkill"}', 'skillName')).toBe('mySkill');
+ });
+
+ it('returns empty string when field is missing from parsed JSON', () => {
+ expect(parseJsonField('{"other":"value"}', 'skillName')).toBe('');
+ });
+
+ it('handles different field names — file_path', () => {
+ expect(parseJsonField('{"file_path":"/tmp/out.txt"}', 'file_path')).toBe('/tmp/out.txt');
+ });
+
+ it('handles different field names — command', () => {
+ expect(parseJsonField('{"command":"echo hello"}', 'command')).toBe('echo hello');
+ });
+ });
+
+ describe('empty string args', () => {
+ it('returns empty string for empty string input', () => {
+ expect(parseJsonField('', 'skillName')).toBe('');
+ });
+ });
+
+ describe('partial/streaming JSON — regex fallback', () => {
+ it('extracts field from malformed partial JSON', () => {
+ const partial = '{"skillName":"mySkill","incomplete":';
+ expect(parseJsonField(partial, 'skillName')).toBe('mySkill');
+ });
+
+ it('returns empty string when field is absent from partial JSON', () => {
+ const partial = '{"other":"value","incomplete":';
+ expect(parseJsonField(partial, 'skillName')).toBe('');
+ });
+
+ it('unescapes \\n in regex-matched values', () => {
+ const partial = '{"command":"line1\\nline2","incomplete":';
+ expect(parseJsonField(partial, 'command')).toBe('line1\nline2');
+ });
+
+ it('unescapes \\" in regex-matched values', () => {
+ const partial = '{"command":"say \\"hello\\"","incomplete":';
+ expect(parseJsonField(partial, 'command')).toBe('say "hello"');
+ });
+
+ it('unescapes \\\\ in regex-matched values', () => {
+ const partial = '{"file_path":"C:\\\\Users\\\\file.txt","incomplete":';
+ expect(parseJsonField(partial, 'file_path')).toBe('C:\\Users\\file.txt');
+ });
+
+ it('handles whitespace between colon and value', () => {
+ const partial = '{"skillName" : "spaced","incomplete":';
+ expect(parseJsonField(partial, 'skillName')).toBe('spaced');
+ });
+
+ it('decodes \\\\n as literal backslash + n, not newline', () => {
+ const partial = '{"file_path":"C:\\\\note","incomplete":';
+ expect(parseJsonField(partial, 'file_path')).toBe('C:\\note');
+ });
+
+ it('preserves unknown escape sequences', () => {
+ const partial = '{"command":"tab\\there","incomplete":';
+ expect(parseJsonField(partial, 'command')).toBe('tab\\there');
+ });
+ });
+});
diff --git a/client/src/components/Chat/Messages/Content/Parts/index.ts b/client/src/components/Chat/Messages/Content/Parts/index.ts
index b0a418c819..a37bbadd86 100644
--- a/client/src/components/Chat/Messages/Content/Parts/index.ts
+++ b/client/src/components/Chat/Messages/Content/Parts/index.ts
@@ -9,3 +9,6 @@ export { default as ExecuteCode } from './ExecuteCode';
export { default as Summary } from './Summary';
export { default as AgentUpdate } from './AgentUpdate';
export { default as EditTextPart } from './EditTextPart';
+export { default as SkillCall } from './SkillCall';
+export { default as ReadFileCall } from './ReadFileCall';
+export { default as BashCall } from './BashCall';
diff --git a/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts b/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts
new file mode 100644
index 0000000000..986744cf3b
--- /dev/null
+++ b/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts
@@ -0,0 +1,26 @@
+/** Extracts a string field from tool call args, handling object, JSON string, and partial-JSON fallback. */
+export default function parseJsonField(
+ args: string | Record | undefined,
+ field: string,
+): string {
+ if (typeof args === 'object' && args !== null) {
+ return String(args[field] ?? '');
+ }
+ try {
+ const parsed = JSON.parse(args || '{}');
+ if (typeof parsed === 'object' && parsed !== null) {
+ return String(parsed[field] ?? '');
+ }
+ } catch {
+ // partial JSON during streaming; fall through to regex
+ }
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const re = new RegExp(`"${escaped}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`);
+ const match = args?.match(re);
+ if (!match) {
+ return '';
+ }
+ return match[1].replace(/\\(.)/g, (_, c: string) =>
+ c === 'n' ? '\n' : c === '"' ? '"' : c === '\\' ? '\\' : `\\${c}`,
+ );
+}
diff --git a/client/src/components/Chat/Messages/Content/Parts/useLazyHighlight.ts b/client/src/components/Chat/Messages/Content/Parts/useLazyHighlight.ts
new file mode 100644
index 0000000000..bd0883a033
--- /dev/null
+++ b/client/src/components/Chat/Messages/Content/Parts/useLazyHighlight.ts
@@ -0,0 +1,109 @@
+import React, { useState, useEffect, useRef } from 'react';
+
+interface HastText {
+ type: 'text';
+ value: string;
+}
+
+interface HastElement {
+ type: 'element';
+ tagName: string;
+ properties?: { className?: string[] };
+ children?: HastNode[];
+}
+
+type HastNode = HastText | HastElement;
+
+function hastToReact(nodes: HastNode[]): React.ReactNode[] {
+ return nodes.map((node, i) => {
+ if (node.type === 'text') {
+ return node.value;
+ }
+ return React.createElement(
+ node.tagName,
+ { key: i, className: node.properties?.className?.join(' ') },
+ node.children ? hastToReact(node.children) : undefined,
+ );
+ });
+}
+
+type LowlightModule = typeof import('lowlight');
+
+let lowlightPromise: Promise | null = null;
+let lowlightModule: LowlightModule | null = null;
+
+function loadLowlight(): Promise {
+ if (lowlightModule) {
+ return Promise.resolve(lowlightModule);
+ }
+ if (!lowlightPromise) {
+ lowlightPromise = import('lowlight').then((mod) => {
+ lowlightModule = mod;
+ return mod;
+ });
+ }
+ return lowlightPromise;
+}
+
+function highlightCode(mod: LowlightModule, code: string, lang: string): React.ReactNode[] {
+ if (lang === 'plaintext') {
+ return [code];
+ }
+ try {
+ const tree = mod.lowlight.registered(lang)
+ ? mod.lowlight.highlight(lang, code)
+ : mod.lowlight.highlightAuto(code);
+ return hastToReact(tree.children as HastNode[]);
+ } catch {
+ return [code];
+ }
+}
+
+export default function useLazyHighlight(
+ code: string | undefined,
+ lang: string,
+): React.ReactNode[] | null {
+ const [highlighted, setHighlighted] = useState(() => {
+ if (!code || !lowlightModule) {
+ return null;
+ }
+ return highlightCode(lowlightModule, code, lang);
+ });
+ const prevKey = useRef('');
+
+ useEffect(() => {
+ const key = `${lang}\0${code ?? ''}`;
+ if (key === prevKey.current) {
+ return;
+ }
+ prevKey.current = key;
+
+ if (!code) {
+ setHighlighted(null);
+ return;
+ }
+
+ if (lowlightModule) {
+ setHighlighted(highlightCode(lowlightModule, code, lang));
+ return;
+ }
+
+ let cancelled = false;
+ loadLowlight()
+ .then((mod) => {
+ if (!cancelled) {
+ setHighlighted(highlightCode(mod, code, lang));
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setHighlighted([code]);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [code, lang]);
+
+ return highlighted;
+}
diff --git a/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts b/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts
new file mode 100644
index 0000000000..3599221444
--- /dev/null
+++ b/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts
@@ -0,0 +1,54 @@
+import { useState, useEffect, useCallback } from 'react';
+import { useRecoilValue } from 'recoil';
+import { isError } from '~/components/Chat/Messages/Content/ToolOutput';
+import { useProgress, useExpandCollapse } from '~/hooks';
+import store from '~/store';
+
+interface ToolCallState {
+ showCode: boolean;
+ toggleCode: () => void;
+ expandStyle: React.CSSProperties;
+ expandRef: React.RefObject;
+ progress: number;
+ cancelled: boolean;
+ hasError: boolean;
+ hasOutput: boolean;
+ hasContent: boolean;
+}
+
+export default function useToolCallState(
+ initialProgress: number,
+ isSubmitting: boolean,
+ output: string,
+ hasInput: boolean,
+): ToolCallState {
+ const autoExpand = useRecoilValue(store.autoExpandTools);
+ const hasOutput = output.length > 0;
+ const hasError = hasOutput && isError(output);
+ const hasContent = hasInput || hasOutput;
+
+ const [showCode, setShowCode] = useState(() => autoExpand && hasContent);
+ const { style: expandStyle, ref: expandRef } = useExpandCollapse(showCode);
+
+ useEffect(() => {
+ if (autoExpand && hasContent) {
+ setShowCode(true);
+ }
+ }, [autoExpand, hasContent]);
+
+ const progress = useProgress(initialProgress);
+ const toggleCode = useCallback(() => setShowCode((prev) => !prev), []);
+ const cancelled = !isSubmitting && progress < 1 && !hasError;
+
+ return {
+ showCode,
+ toggleCode,
+ expandStyle,
+ expandRef,
+ progress,
+ cancelled,
+ hasError,
+ hasOutput,
+ hasContent,
+ };
+}
diff --git a/client/src/components/Chat/Messages/Content/ToolOutput/ToolIcon.tsx b/client/src/components/Chat/Messages/Content/ToolOutput/ToolIcon.tsx
index 4484287dd6..727cddfed0 100644
--- a/client/src/components/Chat/Messages/Content/ToolOutput/ToolIcon.tsx
+++ b/client/src/components/Chat/Messages/Content/ToolOutput/ToolIcon.tsx
@@ -1,7 +1,22 @@
import { Constants, isActionTool } from 'librechat-data-provider';
-import { Terminal, Globe, ImageIcon, ArrowRightLeft, FileSearch, Zap, Wrench } from 'lucide-react';
+import {
+ Terminal,
+ Globe,
+ ImageIcon,
+ ArrowRightLeft,
+ FileSearch,
+ FileText,
+ ScrollText,
+ Zap,
+ Wrench,
+} from 'lucide-react';
+import LangIcon from '~/components/Messages/Content/LangIcon';
import { cn } from '~/utils';
+function BashIcon({ className }: { className?: string }) {
+ return ;
+}
+
export type ToolIconType =
| 'mcp'
| 'execute_code'
@@ -9,6 +24,9 @@ export type ToolIconType =
| 'image_gen'
| 'agent_handoff'
| 'file_search'
+ | 'skill'
+ | 'read_file'
+ | 'bash_tool'
| 'action'
| 'generic';
@@ -19,6 +37,9 @@ const ICON_MAP: Record
image_gen: ImageIcon,
agent_handoff: ArrowRightLeft,
file_search: FileSearch,
+ skill: ScrollText,
+ read_file: FileText,
+ bash_tool: BashIcon,
action: Zap,
generic: Wrench,
};
@@ -45,6 +66,15 @@ export function getToolIconType(name: string): ToolIconType {
if (name === 'code_interpreter') {
return 'execute_code';
}
+ if (name === 'skill') {
+ return 'skill';
+ }
+ if (name === 'read_file') {
+ return 'read_file';
+ }
+ if (name === 'bash_tool') {
+ return 'bash_tool';
+ }
if (name.startsWith(Constants.LC_TRANSFER_TO_)) {
return 'agent_handoff';
}
diff --git a/client/src/components/Chat/Messages/Content/__tests__/ToolIcon.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ToolIcon.test.tsx
index 4937b547c8..ef7a1c33de 100644
--- a/client/src/components/Chat/Messages/Content/__tests__/ToolIcon.test.tsx
+++ b/client/src/components/Chat/Messages/Content/__tests__/ToolIcon.test.tsx
@@ -32,3 +32,29 @@ describe('getToolIconType - ACTN-01: Action delimiter detection', () => {
expect(getToolIconType(`${Constants.LC_TRANSFER_TO_}agent1`)).toBe('agent_handoff');
});
});
+
+describe('getToolIconType - SKILL-01: Skill tool icon types', () => {
+ it('returns "skill" for tool name "skill"', () => {
+ expect(getToolIconType('skill')).toBe('skill');
+ });
+
+ it('returns "read_file" for tool name "read_file"', () => {
+ expect(getToolIconType('read_file')).toBe('read_file');
+ });
+
+ it('returns "bash_tool" for tool name "bash_tool"', () => {
+ expect(getToolIconType('bash_tool')).toBe('bash_tool');
+ });
+
+ it('skill types take priority over the "generic" fallback', () => {
+ expect(getToolIconType('skill')).not.toBe('generic');
+ expect(getToolIconType('read_file')).not.toBe('generic');
+ expect(getToolIconType('bash_tool')).not.toBe('generic');
+ });
+
+ it('skill types take priority over the "action" fallback', () => {
+ expect(getToolIconType('skill')).not.toBe('action');
+ expect(getToolIconType('read_file')).not.toBe('action');
+ expect(getToolIconType('bash_tool')).not.toBe('action');
+ });
+});
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 1095c0b6e8..eb2122fc6c 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -840,6 +840,7 @@
"com_ui_collapse_chat": "Collapse Chat",
"com_ui_collapse_summary": "Collapse Summary",
"com_ui_collapse_thoughts": "Collapse Thoughts",
+ "com_ui_command_finished": "Finished running",
"com_ui_command_placeholder": "Optional: Enter a command for the prompt or name will be used",
"com_ui_command_usage_placeholder": "Select a Prompt by command or name",
"com_ui_complete": "Complete!",
@@ -1326,6 +1327,8 @@
"com_ui_provider": "Provider",
"com_ui_quality": "Quality",
"com_ui_read_aloud": "Read aloud",
+ "com_ui_read_file": "Read {{0}}",
+ "com_ui_reading_file": "Reading {{0}}",
"com_ui_redirect_uri": "Redirect URI",
"com_ui_redirect_uri_instructions": "Copy this redirect URI and configure it in your OAuth provider settings.",
"com_ui_redirecting_to_provider": "Redirecting to {{0}}, please wait...",
@@ -1394,6 +1397,7 @@
"com_ui_run_code": "Run Code",
"com_ui_run_code_error": "There was an error running the code",
"com_ui_running": "Running...",
+ "com_ui_running_command": "Running command",
"com_ui_save": "Save",
"com_ui_save_badge_changes": "Save badge changes?",
"com_ui_save_changes": "Save Changes",
@@ -1475,6 +1479,7 @@
"com_ui_skill_file_binary": "Binary file — cannot preview inline",
"com_ui_skill_file_download": "Download",
"com_ui_skill_file_load_error": "Failed to load file content",
+ "com_ui_skill_finished": "Ran {{0}}",
"com_ui_skill_instructions": "Instructions",
"com_ui_skill_instructions_placeholder": "Enter your skill instructions in markdown...",
"com_ui_skill_name_invalid": "Use lowercase letters, digits, and dashes only (kebab-case)",
@@ -1491,6 +1496,7 @@
"com_ui_skill_role_editor_desc": "Can view and edit the skill",
"com_ui_skill_role_owner_desc": "Full control over the skill including sharing and deletion",
"com_ui_skill_role_viewer_desc": "Can view and use the skill but cannot edit it",
+ "com_ui_skill_running": "Running {{0}}",
"com_ui_skill_sr_public": "Public skill",
"com_ui_skill_update_conflict": "Another edit was saved before yours. Reloading the latest version.",
"com_ui_skill_update_error": "Failed to save skill",
diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts
index c3d0dd859c..4618f4f49f 100644
--- a/packages/data-provider/src/types/assistants.ts
+++ b/packages/data-provider/src/types/assistants.ts
@@ -24,6 +24,9 @@ export enum Tools {
function = 'function',
memory = 'memory',
ui_resources = 'ui_resources',
+ skill = 'skill',
+ read_file = 'read_file',
+ bash_tool = 'bash_tool',
}
export enum EToolResources {