Skip to content
Merged
128 changes: 128 additions & 0 deletions js/app/packages/core/component/AI/component/tool/BashCodeExecution.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import CaretDown from '@phosphor-icons/core/regular/caret-down.svg';
import CaretRight from '@phosphor-icons/core/regular/caret-right.svg';
import Terminal from '@phosphor-icons/core/regular/terminal.svg';
import type { BashCodeExecutionResult } from '@service-cognition/generated/tools/types';
import { createSignal, Match, Show, Switch } from 'solid-js';
import { BaseTool } from './BaseTool';
import { createToolRenderer } from './ToolRenderer';

const MAX_COMMAND_LENGTH = 80;
const MAX_OUTPUT_LINES = 3;

function truncateCommand(command: string): string {
const firstLine = command.split('\n')[0] ?? '';
const needsTruncation =
command.includes('\n') || firstLine.length > MAX_COMMAND_LENGTH;

if (needsTruncation) {
return `${firstLine.slice(0, MAX_COMMAND_LENGTH)}...`;
}
return firstLine;
}

function CodeFence(props: { content: string; maxLines: number }) {
const [expanded, setExpanded] = createSignal(false);

const lines = () => props.content.split('\n');
const needsTruncation = () => lines().length > props.maxLines;
const displayContent = () => {
if (expanded() || !needsTruncation()) {
return props.content;
}
return lines().slice(0, props.maxLines).join('\n');
};

return (
<div class="relative">
<Show when={needsTruncation()}>
<button
type="button"
class="text-ink-extra-muted hover:text-ink-muted absolute top-1 right-1 p-1"
onClick={() => setExpanded(!expanded())}
>
<Show when={expanded()} fallback={<CaretRight class="h-4 w-4" />}>
<CaretDown class="h-4 w-4" />
</Show>
</button>
</Show>
<pre
class="text-ink-muted bg-background-secondary overflow-x-auto rounded p-2 pr-8 font-mono text-xs whitespace-pre-wrap"
classList={{
'max-h-24 overflow-hidden': !expanded() && needsTruncation(),
}}
>
{displayContent()}
</pre>
</div>
);
}

function BashResult(props: { result: BashCodeExecutionResult }) {
const output = () => {
const parts: string[] = [];
if (props.result.stdout) {
parts.push(props.result.stdout);
}
if (props.result.stderr) {
parts.push(props.result.stderr);
}
return parts.join('\n');
};

const hasOutput = () => output().trim().length > 0;

return (
<div class="flex flex-col gap-1">
<Show when={props.result.return_code !== 0}>
<span class="text-ink-error">Exit code {props.result.return_code}</span>
</Show>
<Show when={hasOutput()}>
<CodeFence content={output()} maxLines={MAX_OUTPUT_LINES} />
</Show>
<Show when={!hasOutput() && props.result.return_code === 0}>
<span class="text-ink-muted">No output</span>
</Show>
</div>
);
}

const handler = createToolRenderer({
name: 'bash_code_execution',
renderCall: (ctx) => (
<BaseTool
icon={Terminal}
text="Executing bash"
renderContext={ctx.renderContext}
type="call"
>
<code class="text-ink-muted font-mono text-xs">
{truncateCommand(ctx.tool.data.command)}
</code>
</BaseTool>
),
renderResponse: (ctx) => {
const isError =
ctx.tool.data.content.type === 'bash_code_execution_tool_result_error';
return (
<BaseTool
icon={Terminal}
text="Executed bash"
renderContext={ctx.renderContext}
type="response"
>
<Switch>
<Match when={isError}>
<span class="text-ink-error">Execution failed</span>
</Match>
<Match when={!isError}>
<BashResult
result={ctx.tool.data.content as BashCodeExecutionResult}
/>
</Match>
</Switch>
</BaseTool>
);
},
});

export const bashCodeExecutionHandler = handler;
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import CaretDown from '@phosphor-icons/core/regular/caret-down.svg';
import CaretRight from '@phosphor-icons/core/regular/caret-right.svg';
import File from '@phosphor-icons/core/regular/file.svg';
import type {
TextEditorCodeExecutionContent,
TextEditorCodeExecutionResult,
} from '@service-cognition/generated/tools/types';

// Type aliases for backwards compatibility with discriminated union variants
type TextEditorCodeExecutionViewResult = TextEditorCodeExecutionResult & {
type: 'text_editor_code_execution_view_result';
};
type TextEditorCodeExecutionCreateResult = TextEditorCodeExecutionResult & {
type: 'text_editor_code_execution_create_result';
};
type TextEditorCodeExecutionStrReplaceResult = TextEditorCodeExecutionResult & {
type: 'text_editor_code_execution_str_replace_result';
};
import { createSignal, Match, Show, Switch } from 'solid-js';
import { BaseTool } from './BaseTool';
import { createToolRenderer } from './ToolRenderer';

const MAX_OUTPUT_LINES = 5;

function getCommandLabel(command: string): string {
switch (command) {
case 'view':
return 'Viewing file';
case 'create':
return 'Creating file';
case 'str_replace':
return 'Editing file';
default:
return 'Editing file';
}
}

function CodeFence(props: { content: string; maxLines: number }) {
const [expanded, setExpanded] = createSignal(false);

const lines = () => props.content.split('\n');
const needsTruncation = () => lines().length > props.maxLines;
const displayContent = () => {
if (expanded() || !needsTruncation()) {
return props.content;
}
return lines().slice(0, props.maxLines).join('\n');
};

return (
<div class="relative">
<Show when={needsTruncation()}>
<button
type="button"
class="text-ink-extra-muted hover:text-ink-muted absolute top-1 right-1 p-1"
onClick={() => setExpanded(!expanded())}
>
<Show when={expanded()} fallback={<CaretRight class="h-4 w-4" />}>
<CaretDown class="h-4 w-4" />
</Show>
</button>
</Show>
<pre
class="text-ink-muted bg-background-secondary overflow-x-auto rounded p-2 pr-8 font-mono text-xs whitespace-pre-wrap"
classList={{
'max-h-32 overflow-hidden': !expanded() && needsTruncation(),
}}
>
{displayContent()}
</pre>
</div>
);
}

function ViewResult(props: { result: TextEditorCodeExecutionViewResult }) {
const hasContent = () => !!props.result.content?.trim();

return (
<Show
when={hasContent()}
fallback={<span class="text-ink-muted">Empty file</span>}
>
<CodeFence
content={props.result.content ?? ''}
maxLines={MAX_OUTPUT_LINES}
/>
</Show>
);
}

function CreateResult(props: { result: TextEditorCodeExecutionCreateResult }) {
return (
<span class="text-ink-muted">
{props.result.is_file_update ? 'File updated' : 'File created'}
</span>
);
}

function StrReplaceResult(props: {
result: TextEditorCodeExecutionStrReplaceResult;
}) {
const diffContent = () => props.result.lines?.join('\n') ?? '';
const hasContent = () => diffContent().trim().length > 0;

return (
<Show
when={hasContent()}
fallback={<span class="text-ink-muted">Edit applied</span>}
>
<CodeFence content={diffContent()} maxLines={MAX_OUTPUT_LINES} />
</Show>
);
}

function TextEditorResult(props: { content: TextEditorCodeExecutionContent }) {
return (
<Switch>
<Match
when={
props.content.type === 'text_editor_code_execution_view_result' &&
props.content
}
>
{(result) => (
<ViewResult result={result() as TextEditorCodeExecutionViewResult} />
)}
</Match>
<Match
when={
props.content.type === 'text_editor_code_execution_create_result' &&
props.content
}
>
{(result) => (
<CreateResult
result={result() as TextEditorCodeExecutionCreateResult}
/>
)}
</Match>
<Match
when={
props.content.type ===
'text_editor_code_execution_str_replace_result' && props.content
}
>
{(result) => (
<StrReplaceResult
result={result() as TextEditorCodeExecutionStrReplaceResult}
/>
)}
</Match>
<Match
when={
props.content.type === 'text_editor_code_execution_tool_result_error'
}
>
<span class="text-ink-error">Failed</span>
</Match>
</Switch>
);
}

const handler = createToolRenderer({
name: 'text_editor_code_execution',
renderCall: (ctx) => (
<BaseTool
icon={File}
text={getCommandLabel(ctx.tool.data.command)}
renderContext={ctx.renderContext}
type="call"
>
<code class="text-ink-muted font-mono text-xs">{ctx.tool.data.path}</code>
</BaseTool>
),
renderResponse: (ctx) => {
return (
<BaseTool
icon={File}
text="File operation"
renderContext={ctx.renderContext}
type="response"
>
<TextEditorResult content={ctx.tool.data.content} />
</BaseTool>
);
},
});

export const textEditorCodeExecutionHandler = handler;
14 changes: 9 additions & 5 deletions js/app/packages/core/component/AI/component/tool/handler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import {
} from '@service-cognition/generated/tools/tool';
import { createStore } from 'solid-js/store';
import { Dynamic, Show } from 'solid-js/web';
import { bashCodeExecutionHandler } from './BashCodeExecution';
import { listChannelsHandler } from './ListChannels';
import { listDocumentsHandler } from './ListDocuments';
import { listEmailsHandler } from './ListEmails';
import { rewriteHandler } from './MarkdownRewrite';
import { readHandler } from './Read';
import { contentSearchHandler, nameSearchHandler } from './Search';
import { textEditorCodeExecutionHandler } from './TextEditorCodeExecution';
import type { RenderContext } from './ToolRenderer';
import { webFetchHandler } from './WebFetch';
import { webSearchHandler } from './WebSearch';
Expand All @@ -25,15 +27,17 @@ const [renderStore, setRenderStore] = createStore<
>({});

const toolHandlers: ToolHandlerMap<RenderContext> = {
bash_code_execution: bashCodeExecutionHandler,
ContentSearch: contentSearchHandler,
NameSearch: nameSearchHandler,
web_fetch: webFetchHandler,
web_search: webSearchHandler,
MarkdownRewrite: rewriteHandler,
Read: readHandler,
ListChannels: listChannelsHandler,
ListDocuments: listDocumentsHandler,
ListEmails: listEmailsHandler,
MarkdownRewrite: rewriteHandler,
NameSearch: nameSearchHandler,
Read: readHandler,
text_editor_code_execution: textEditorCodeExecutionHandler,
web_fetch: webFetchHandler,
web_search: webSearchHandler,
};

type ToolProps = {
Expand Down
1 change: 1 addition & 0 deletions js/app/packages/core/component/AI/constant/fileType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const SUPPORTED_CHAT_ATTACHMENT_BLOCKS: (BlockName | BlockAlias)[] = [
'pdf',
'md',
'code',
'csv',
'canvas',
'email',
'task',
Expand Down
Loading
Loading