Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,13 @@ The UI is a **hash-routed** SPA with ES modules under `static/js/`:

- `app.js` — routing and boot
- `projects.js`, `sessions.js`, `search.js`, `export.js` — route handlers
- `render/registry.js` — **tool dispatch registry** for session UI: `TOOL_USE_RENDERERS` and `TOOL_RESULT_RENDERERS` map tool name / `result_type` → render function (one module per type under `render/tool_use/` and `render/tool_result/`). Parallels backend `utils/tool_dispatch.py` (backend uses ordered predicates; frontend uses direct key lookup + fallback).
- `shared/markdown.js` — markdown + **DOMPurify** sanitization (do not render raw LLM HTML)
- `shared/state.js`, `shared/utils.js`, `shared/theme.js` — shared UI state and helpers

No bundler step — modern browsers load modules directly. Frontend unit tests use **vitest** + **jsdom** (`npm test`).
`sessions.js` keeps workspace/session orchestration and message bubbles; tool cards delegate to `render/registry.js`.

No bundler step — modern browsers load modules directly. Frontend unit tests use **vitest** + **jsdom** (`npm test`), including `static/js/render/registry.test.js` for registry wiring and renderer escaping.

## Continuous integration

Expand Down
81 changes: 81 additions & 0 deletions static/js/render/registry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { renderBashUse } from './tool_use/bash.js';
import { renderReadUse } from './tool_use/read.js';
import { renderWriteUse } from './tool_use/write.js';
import { renderEditUse } from './tool_use/edit.js';
import { renderGlobUse } from './tool_use/glob.js';
import { renderGrepUse } from './tool_use/grep.js';
import { renderTaskUse } from './tool_use/task.js';
import { renderTodoWriteUse } from './tool_use/todo_write.js';
import { renderAskUserQuestionUse } from './tool_use/ask_user_question.js';
import { renderWebFetchUse } from './tool_use/web_fetch.js';
import { renderWebSearchUse } from './tool_use/web_search.js';
import { renderToolUseFallback } from './tool_use/fallback.js';
import { getToolSummary } from './tool_use/summary.js';

import { renderBashResult } from './tool_result/bash.js';
import { renderFileReadResult } from './tool_result/file_read.js';
import { renderFileEditResult } from './tool_result/file_edit.js';
import { renderFileWriteResult } from './tool_result/file_write.js';
import { renderGlobResult } from './tool_result/glob.js';
import { renderGrepResult } from './tool_result/grep.js';
import { renderWebSearchResult } from './tool_result/web_search.js';
import { renderWebFetchResult } from './tool_result/web_fetch.js';
import { renderTaskResult } from './tool_result/task.js';
import { renderTodoWriteResult } from './tool_result/todo_write.js';
import { renderUserInputResult } from './tool_result/user_input.js';
import { renderPlanResult } from './tool_result/plan.js';
import { renderToolResultFallback } from './tool_result/fallback.js';
import { toolResultHasBody } from './tool_result/utils.js';

export { getToolSummary, toolResultHasBody };

export const TOOL_USE_RENDERERS = {
Bash: renderBashUse,
Read: renderReadUse,
Write: renderWriteUse,
Edit: renderEditUse,
Glob: renderGlobUse,
Grep: renderGrepUse,
Task: renderTaskUse,
TodoWrite: renderTodoWriteUse,
AskUserQuestion: renderAskUserQuestionUse,
WebFetch: renderWebFetchUse,
WebSearch: renderWebSearchUse,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const TOOL_RESULT_RENDERERS = {
bash: renderBashResult,
file_read: renderFileReadResult,
file_edit: renderFileEditResult,
file_write: renderFileWriteResult,
glob: renderGlobResult,
grep: renderGrepResult,
web_search: renderWebSearchResult,
web_fetch: renderWebFetchResult,
task: renderTaskResult,
todo_write: renderTodoWriteResult,
user_input: renderUserInputResult,
plan: renderPlanResult,
};

function getToolUseRenderer(name) {
return Object.prototype.hasOwnProperty.call(TOOL_USE_RENDERERS, name)
? TOOL_USE_RENDERERS[name]
: renderToolUseFallback;
}

function getToolResultRenderer(resultType) {
return Object.prototype.hasOwnProperty.call(TOOL_RESULT_RENDERERS, resultType)
? TOOL_RESULT_RENDERERS[resultType]
: renderToolResultFallback;
}

export function renderToolUse(tool) {
const name = tool.name || 'unknown';
return getToolUseRenderer(name)(tool);
}

export function renderToolResult(parsed) {
const rt = parsed.result_type || 'unknown';
return getToolResultRenderer(rt)(parsed);
}
Comment thread
clean6378-max-it marked this conversation as resolved.
157 changes: 157 additions & 0 deletions static/js/render/registry.test.js
Comment thread
clean6378-max-it marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest';
import {
TOOL_USE_RENDERERS,
TOOL_RESULT_RENDERERS,
renderToolUse,
renderToolResult,
getToolSummary,
} from './registry.js';
import { renderWebFetchUse } from './tool_use/web_fetch.js';

const CORE_TOOL_USE = ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'Task', 'TodoWrite', 'AskUserQuestion', 'WebFetch', 'WebSearch'];

const CORE_TOOL_RESULT = [
'bash',
'file_read',
'file_edit',
'file_write',
'glob',
'grep',
'web_search',
'web_fetch',
'task',
'todo_write',
'user_input',
'plan',
];

describe('TOOL_USE_RENDERERS', () => {
it('registers core tool names', () => {
for (const name of CORE_TOOL_USE) {
expect(TOOL_USE_RENDERERS[name], name).toBeTypeOf('function');
}
});

it('renderBashUse escapes HTML in command', () => {
const html = renderToolUse({
name: 'Bash',
input: { command: '<script>alert(1)</script>' },
});
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
});

it('renderReadUse escapes file path in body and summary', () => {
const html = renderToolUse({
name: 'Read',
input: { file_path: 'C:\\tmp\\<evil>.txt' },
});
expect(html).toContain('&lt;evil&gt;');
expect(html).not.toContain('<evil>');
});
});

describe('TOOL_RESULT_RENDERERS', () => {
it('registers core result types', () => {
for (const rt of CORE_TOOL_RESULT) {
expect(TOOL_RESULT_RENDERERS[rt], rt).toBeTypeOf('function');
}
});

it('renderBashResult escapes stdout', () => {
const html = renderToolResult({
result_type: 'bash',
exit_code: 0,
stdout: '<img onerror=alert(1)>',
});
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img');
});

it('renderBashResult avoids undefined in summary when exit_code missing', () => {
const html = renderToolResult({ result_type: 'bash' });
expect(html).toContain('Bash Result (unknown)');
expect(html).not.toContain('undefined');
});
});

describe('getToolSummary', () => {
it('formats Bash summary', () => {
expect(getToolSummary('Bash', { command: 'ls -la' })).toMatch(/Bash:/);
expect(getToolSummary('Bash', { command: 'ls -la' })).toContain('ls -la');
});

it('formats Read summary as plain text (escaping deferred to wrapToolUse)', () => {
expect(getToolSummary('Read', { file_path: 'a<b' })).toBe('Read: a<b');
});
});

describe('renderToolUse fallback', () => {
it('uses JSON fallback for unknown tools', () => {
const html = renderToolUse({
name: 'UnknownToolXYZ',
input: { foo: 'bar' },
});
expect(html).toContain('tool-call');
expect(html).toContain('&quot;foo&quot;');
expect(TOOL_USE_RENDERERS.UnknownToolXYZ).toBeUndefined();
});

it('uses fallback when name is an inherited property (e.g. constructor)', () => {
const html = renderToolUse({
name: 'constructor',
input: { foo: 'bar' },
});
expect(html).toContain('tool-call');
expect(html).toContain('&quot;foo&quot;');
});

it('dispatches WebFetch to registered renderer (not generic unknown-tool fallback)', () => {
expect(TOOL_USE_RENDERERS.WebFetch).toBe(renderWebFetchUse);
const html = renderToolUse({
name: 'WebFetch',
input: { url: 'https://example.com' },
});
expect(html).toContain('tool-call');
expect(html).toContain('example.com');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('renders WebSearch via registry', () => {
const html = renderToolUse({
name: 'WebSearch',
input: { query: 'vitest registry' },
});
expect(html).toContain('tool-call');
expect(html).toContain('vitest registry');
});
});

describe('renderTodoWriteResult', () => {
it('summary count matches parsed.todos length when todos are present', () => {
const html = renderToolResult({
result_type: 'todo_write',
todo_count: 99,
todos: [
{ status: 'pending', content: 'one' },
{ status: 'completed', content: 'two' },
],
});
expect(html).toContain('Todos updated (2 items)');
expect(html).not.toContain('99 items');
});
});

describe('renderToolResult fallback', () => {
it('renders summary-only for unknown result types', () => {
const html = renderToolResult({ result_type: 'custom_type' });
expect(html).toContain('Tool result (custom_type)');
expect(html).toContain('tool-result');
});

it('uses fallback when result_type is an inherited property (e.g. constructor)', () => {
const html = renderToolResult({ result_type: 'constructor' });
expect(html).toContain('Tool result (constructor)');
expect(html).toContain('tool-result');
expect(Object.prototype.hasOwnProperty.call(TOOL_RESULT_RENDERERS, 'constructor')).toBe(false);
});
});
23 changes: 23 additions & 0 deletions static/js/render/tool_result/bash.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { esc, truncate } from '../../shared/utils.js';
import { finishToolResult } from './common.js';

export function renderBashResult(parsed) {
const exitCode = parsed.exit_code;
let status;
if (parsed.interrupted) {
status = 'interrupted';
} else if (parsed.is_error) {
status = typeof exitCode === 'number' ? `error (exit ${exitCode})` : 'error';
} else if (exitCode === 0) {
status = 'success';
} else if (typeof exitCode === 'number') {
status = `exit ${exitCode}`;
} else {
status = 'unknown';
}
const summary = `Bash Result (${status})`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let body = '';
if (parsed.stdout) body += `<div class="tool-call-section"><div class="tool-call-section-title">stdout</div><pre><code>${esc(truncate(parsed.stdout, 2000))}</code></pre></div>`;
if (parsed.stderr) body += `<div class="tool-call-section"><div class="tool-call-section-title">stderr</div><pre style="border-left:3px solid var(--danger)"><code>${esc(truncate(parsed.stderr, 1000))}</code></pre></div>`;
return finishToolResult(summary, body);
}
8 changes: 8 additions & 0 deletions static/js/render/tool_result/common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { esc } from '../../shared/utils.js';

export function finishToolResult(summary, body) {
if (!body) {
return `<div class="tool-result"><span class="tool-result-summary">${esc(summary)}</span></div>`;
}
return `<details class="tool-result"><summary class="tool-result-summary">${esc(summary)}</summary><div class="tool-call-body">${body}</div></details>`;
}
7 changes: 7 additions & 0 deletions static/js/render/tool_result/fallback.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { finishToolResult } from './common.js';

export function renderToolResultFallback(parsed) {
const rt = parsed.result_type || 'unknown';
const summary = `Tool result (${rt})`;
return finishToolResult(summary, '');
}
6 changes: 6 additions & 0 deletions static/js/render/tool_result/file_edit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { finishToolResult } from './common.js';

export function renderFileEditResult(parsed) {
const summary = `Edited: ${parsed.file_path || ''}`;
return finishToolResult(summary, '');
}
7 changes: 7 additions & 0 deletions static/js/render/tool_result/file_read.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { finishToolResult } from './common.js';

export function renderFileReadResult(parsed) {
const numLines = parsed.num_lines ? ` (${parsed.num_lines} lines)` : '';
const summary = `Read: ${parsed.file_path || ''}${numLines}`;
return finishToolResult(summary, '');
}
6 changes: 6 additions & 0 deletions static/js/render/tool_result/file_write.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { finishToolResult } from './common.js';

export function renderFileWriteResult(parsed) {
const summary = `Wrote: ${parsed.file_path || ''}`;
return finishToolResult(summary, '');
}
7 changes: 7 additions & 0 deletions static/js/render/tool_result/glob.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { finishToolResult } from './common.js';

export function renderGlobResult(parsed) {
const trunc = parsed.truncated ? ' (truncated)' : '';
const summary = `Glob: ${parsed.num_files || 0} files found${trunc}`;
return finishToolResult(summary, '');
}
6 changes: 6 additions & 0 deletions static/js/render/tool_result/grep.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { finishToolResult } from './common.js';

export function renderGrepResult(parsed) {
const summary = `Grep: ${parsed.num_files || 0} files, ${parsed.num_lines || 0} lines`;
return finishToolResult(summary, '');
}
6 changes: 6 additions & 0 deletions static/js/render/tool_result/plan.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { finishToolResult } from './common.js';

export function renderPlanResult(parsed) {
const summary = `Plan: ${parsed.file_path || ''}`;
return finishToolResult(summary, '');
}
13 changes: 13 additions & 0 deletions static/js/render/tool_result/task.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { finishToolResult } from './common.js';

export function renderTaskResult(parsed) {
const status = parsed.status || 'completed';
const dur = parsed.total_duration_ms;
const durStr = dur ? ` (${(dur / 1000).toFixed(1)}s)` : '';
const tokStr = parsed.total_tokens ? `, ${parsed.total_tokens.toLocaleString()} tokens` : '';
const toolStr = parsed.total_tool_use_count ? `, ${parsed.total_tool_use_count} tool calls` : '';
let summary = `Task ${status}${durStr}${tokStr}${toolStr}`;
if (parsed.retrieval_status) summary = `Task retrieval: ${parsed.retrieval_status}`;
if (parsed.description) summary = `Task launched: ${parsed.description}`;
return finishToolResult(summary, '');
}
18 changes: 18 additions & 0 deletions static/js/render/tool_result/todo_write.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { esc } from '../../shared/utils.js';
import { finishToolResult } from './common.js';

export function renderTodoWriteResult(parsed) {
const hasTodos = parsed.todos && parsed.todos.length;
const count = hasTodos
? parsed.todos.length
: (typeof parsed.todo_count === 'number' ? parsed.todo_count : 0);
const summary = `Todos updated (${count} items)`;
let body = '';
if (hasTodos) {
for (const t of parsed.todos) {
const icon = {'completed': '\u2705', 'in_progress': '\u23f3', 'pending': '\u2b1c'}[t.status] || '\u2b1c';
body += `<div>${icon} ${esc(t.content || '')}</div>`;
}
}
return finishToolResult(summary, body);
}
19 changes: 19 additions & 0 deletions static/js/render/tool_result/user_input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { esc } from '../../shared/utils.js';
import { finishToolResult } from './common.js';

export function renderUserInputResult(parsed) {
const summary = 'User input received';
let body = '';
const qs = parsed.questions || [];
const ans = parsed.answers || {};
for (const q of qs) {
body += `<div class="tool-call-section"><strong>Q:</strong> ${esc(q.question || '')}</div>`;
}
const ansKeys = Object.keys(ans);
if (ansKeys.length) {
for (const k of ansKeys) {
body += `<div class="tool-call-section"><strong>A:</strong> ${esc(String(ans[k]))}</div>`;
}
}
return finishToolResult(summary, body);
}
Loading
Loading