Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
71 changes: 71 additions & 0 deletions static/js/render/registry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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,
};

export function renderToolUse(tool) {
const name = tool.name || 'unknown';
const fn = TOOL_USE_RENDERERS[name] ?? renderToolUseFallback;
return fn(tool);
}

export function renderToolResult(parsed) {
const rt = parsed.result_type || 'unknown';
const fn = TOOL_RESULT_RENDERERS[rt] ?? renderToolResultFallback;
return fn(parsed);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
Comment thread
clean6378-max-it marked this conversation as resolved.
125 changes: 125 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,125 @@
import { describe, it, expect } from 'vitest';
import {
TOOL_USE_RENDERERS,
TOOL_RESULT_RENDERERS,
renderToolUse,
renderToolResult,
getToolSummary,
} from './registry.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', () => {
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 with escaped path', () => {
expect(getToolSummary('Read', { file_path: 'a<b' })).toContain('&lt;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('renders WebFetch via registry (JSON body, not unknown-tool path)', () => {
const html = renderToolUse({
name: 'WebFetch',
input: { url: 'https://example.com' },
});
expect(html).toContain('tool-call');
expect(html).toContain('example.com');
expect(html).toContain('<pre><code>');
});

it('renders WebSearch via registry', () => {
const html = renderToolUse({
name: 'WebSearch',
input: { query: 'vitest registry' },
});
expect(html).toContain('tool-call');
expect(html).toContain('vitest registry');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

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');
});
});
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, '');
}
15 changes: 15 additions & 0 deletions static/js/render/tool_result/todo_write.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { esc } from '../../shared/utils.js';
import { finishToolResult } from './common.js';

export function renderTodoWriteResult(parsed) {
const count = parsed.todo_count || 0;
const summary = `Todos updated (${count} items)`;
let body = '';
if (parsed.todos && parsed.todos.length) {
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>`;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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);
}
8 changes: 8 additions & 0 deletions static/js/render/tool_result/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function toolResultHasBody(parsed) {
const rt = parsed.result_type || 'unknown';
if (rt === 'bash') return !!(parsed.stdout || parsed.stderr);
if (rt === 'todo_write') return !!(parsed.todos && parsed.todos.length);
if (rt === 'user_input') return true;
if (rt === 'task' && (parsed.total_duration_ms || parsed.retrieval_status || parsed.description)) return true;
return false;
Comment thread
clean6378-max-it marked this conversation as resolved.
}
6 changes: 6 additions & 0 deletions static/js/render/tool_result/web_fetch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { finishToolResult } from './common.js';

export function renderWebFetchResult(parsed) {
const summary = `Fetch: ${parsed.url || ''} (${parsed.status_code || '?'})`;
return finishToolResult(summary, '');
}
6 changes: 6 additions & 0 deletions static/js/render/tool_result/web_search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { finishToolResult } from './common.js';

export function renderWebSearchResult(parsed) {
const summary = `Search: "${parsed.query || ''}" - ${parsed.result_count || 0} results`;
return finishToolResult(summary, '');
}
14 changes: 14 additions & 0 deletions static/js/render/tool_use/ask_user_question.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { esc } from '../../shared/utils.js';
import { getToolSummary } from './summary.js';
import { wrapToolUse } from './common.js';

export function renderAskUserQuestionUse(tool) {
const inp = tool.input || {};
const summary = getToolSummary('AskUserQuestion', inp);
let body = '';
const questions = inp.questions || [];
for (const q of questions) {
body += `<div class="tool-call-section"><strong>Q:</strong> ${esc(q.question || '')}</div>`;
}
return wrapToolUse(summary, body);
}
12 changes: 12 additions & 0 deletions static/js/render/tool_use/bash.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { esc } from '../../shared/utils.js';
import { getToolSummary } from './summary.js';
import { wrapToolUse } from './common.js';

export function renderBashUse(tool) {
const inp = tool.input || {};
const summary = getToolSummary('Bash', inp);
let body = '';
body += `<div class="tool-call-section"><div class="tool-call-section-title">Command</div><pre><code>${esc(inp.command || '')}</code></pre></div>`;
if (inp.description) body += `<div class="tool-call-section"><div class="tool-call-section-title">Description</div><div>${esc(inp.description)}</div></div>`;
return wrapToolUse(summary, body);
}
Loading
Loading