diff --git a/src/data.js b/src/data.js index 7830f42..8ee6a64 100644 --- a/src/data.js +++ b/src/data.js @@ -2750,11 +2750,16 @@ function loadSessionDetail(sessionId, project) { if (found.format === 'claude') { if (entry.type === 'user' || entry.type === 'assistant') { - const content = extractContent((entry.message || {}).content); + const rawContent = (entry.message || {}).content; + const content = extractContent(rawContent); if (content) { const msg = { role: entry.type, content: content.slice(0, 2000), uuid: entry.uuid || '' }; + if (entry.type === 'user') { + if (isFilteredClaudeStructuredMessage(content)) continue; + const structured = parseStructuredMessage('claude', entry.type, content, entry); + if (structured) msg.structured = structured; + } if (entry.type === 'assistant') { - const rawContent = (entry.message || {}).content; if (Array.isArray(rawContent)) { const tools = extractTools(rawContent); if (tools.length > 0) msg.tools = tools; @@ -2763,6 +2768,20 @@ function loadSessionDetail(sessionId, project) { messages.push(msg); } } + if (entry.type === 'queue-operation') { + const content = extractContent(entry.content); + if (content) { + const structured = parseStructuredMessage('claude', 'queue', content, entry); + if (structured) { + messages.push({ + role: 'queue', + content: content.slice(0, 2000), + uuid: entry.uuid || '', + structured: structured, + }); + } + } + } } else { // Codex format: response_item with payload if (entry.type === 'response_item' && entry.payload) { @@ -2772,7 +2791,7 @@ function loadSessionDetail(sessionId, project) { const content = extractContent(entry.payload.content); if (content && !isSystemMessage(content)) { const msg = { role: role, content: content.slice(0, 2000), uuid: '' }; - const structured = parseStructuredMessage('codex', role, content); + const structured = parseStructuredMessage('codex', role, content, entry); if (structured) msg.structured = structured; messages.push(msg); } @@ -3278,6 +3297,10 @@ function extractContent(raw) { return String(raw); } +const STRUCTURED_TAG_PATTERN = '[a-z_][a-z0-9_-]*'; +const STRUCTURED_WRAPPER_RE = new RegExp('^<(' + STRUCTURED_TAG_PATTERN + ')>\\s*([\\s\\S]*?)\\s*$', 'i'); +const STRUCTURED_FIELD_RE = new RegExp('<(' + STRUCTURED_TAG_PATTERN + ')>([\\s\\S]*?)', 'ig'); +const FILTERED_CLAUDE_STRUCTURED_TAGS = new Set(['local-command-caveat']); const CODEX_STRUCTURED_MESSAGE_FIELDS = { user_shell_command: [ { field: 'command', max_length: 0 }, @@ -3290,6 +3313,36 @@ const CODEX_STRUCTURED_MESSAGE_FIELDS = { ], }; +const CLAUDE_STRUCTURED_MESSAGE_FIELDS = { + slash_command: [ + { tag: 'command-name', field: 'command_name', max_length: 200 }, + { tag: 'command-message', field: 'command_message', max_length: 200 }, + { tag: 'command-args', field: 'command_args', max_length: 500, required: false }, + ], + bash_result: [ + { tag: 'bash-stdout', field: 'stdout', max_length: 1500, required: false }, + { tag: 'bash-stderr', field: 'stderr', max_length: 1500, required: false }, + ], + task_notification: [ + { tag: 'task-id', field: 'task_id', max_length: 120 }, + { tag: 'tool-use-id', field: 'tool_use_id', max_length: 120 }, + { tag: 'output-file', field: 'output_file', max_length: 500 }, + { tag: 'status', field: 'status', max_length: 40 }, + { tag: 'summary', field: 'summary', max_length: 300 }, + { tag: 'result', field: 'result', max_length: 1500, required: false }, + { tag: 'usage', field: 'usage', max_length: 0, required: false }, + ], + task_notification_monitor: [ + { tag: 'task-id', field: 'task_id', max_length: 120 }, + { tag: 'summary', field: 'summary', max_length: 300 }, + { tag: 'event', field: 'event', max_length: 1500 }, + ], + task_usage: [ + { tag: 'total_tokens', field: 'total_tokens', max_length: 40 }, + { tag: 'tool_uses', field: 'tool_uses', max_length: 40 }, + { tag: 'duration_ms', field: 'duration_ms', max_length: 40 }, + ], +}; function normalizeStructuredField(value) { return typeof value === 'string' ? value.trim() : ''; } @@ -3303,7 +3356,7 @@ function truncateStructuredField(value, maxLength) { function parseStructuredWrapper(content) { const trimmed = typeof content === 'string' ? content.trim() : ''; if (!trimmed) return null; - const match = trimmed.match(/^<([a-z_][a-z0-9_]*)>\s*([\s\S]*?)\s*<\/\1>$/i); + const match = trimmed.match(STRUCTURED_WRAPPER_RE); if (!match) return null; return { tag: match[1], body: match[2] }; } @@ -3312,8 +3365,11 @@ function parseStructuredFields(body, fieldDescriptors) { if (!body || !Array.isArray(fieldDescriptors) || fieldDescriptors.length === 0) return null; const fields = {}; - const allowedFields = new Set(fieldDescriptors.map(function(def) { return def.field; })); - const pattern = /<([a-z_][a-z0-9_]*)>([\s\S]*?)<\/\1>/ig; + const fieldsByTag = new Map(fieldDescriptors.map(function(def) { + return [def.tag || def.field, def]; + })); + // Clone the global regex so each parse starts with a clean lastIndex. + const pattern = new RegExp(STRUCTURED_FIELD_RE.source, STRUCTURED_FIELD_RE.flags); let cursor = 0; let match; @@ -3321,18 +3377,19 @@ function parseStructuredFields(body, fieldDescriptors) { if (body.slice(cursor, match.index).trim()) return null; const tag = match[1]; - if (!allowedFields.has(tag) || fields[tag] !== undefined) return null; + const def = fieldsByTag.get(tag); + if (!def || fields[def.field] !== undefined) return null; const value = normalizeStructuredField(match[2]); - if (!value) return null; - fields[tag] = value; + if (!value && def.required !== false) return null; + fields[def.field] = value; cursor = match.index + match[0].length; } if (body.slice(cursor).trim()) return null; - if (Object.keys(fields).length !== fieldDescriptors.length) return null; for (const def of fieldDescriptors) { + if (def.required === false) continue; if (!fields[def.field]) return null; } @@ -3347,6 +3404,15 @@ function applyStructuredFieldThresholds(fields, fieldDescriptors) { return result; } +function parseStructuredSingleTag(content, tag, fieldName, maxLength) { + const wrapped = parseStructuredWrapper(content); + if (!wrapped || wrapped.tag !== tag) return null; + const value = normalizeStructuredField(wrapped.body); + if (!value) return null; + const fields = {}; + fields[fieldName] = truncateStructuredField(value, maxLength || 0); + return fields; +} function parseCodexStructuredMessage(content) { const wrapped = parseStructuredWrapper(content); if (!wrapped) return null; @@ -3364,9 +3430,78 @@ function parseCodexStructuredMessage(content) { }; } -function parseStructuredMessage(agent, role, content) { - if (role !== 'user' || !content) return null; +function isFilteredClaudeStructuredMessage(content) { + const wrapped = parseStructuredWrapper(content); + return !!(wrapped && FILTERED_CLAUDE_STRUCTURED_TAGS.has(wrapped.tag)); +} + +function parseClaudeTaskNotification(content) { + const wrapped = parseStructuredWrapper(content); + if (!wrapped || wrapped.tag !== 'task-notification') return null; + + let fieldDescriptors = CLAUDE_STRUCTURED_MESSAGE_FIELDS.task_notification; + let fields = parseStructuredFields(wrapped.body, fieldDescriptors); + if (!fields) { + fieldDescriptors = CLAUDE_STRUCTURED_MESSAGE_FIELDS.task_notification_monitor; + fields = parseStructuredFields(wrapped.body, fieldDescriptors); + } + if (!fields) return null; + + const parsedFields = applyStructuredFieldThresholds(fields, fieldDescriptors); + if (parsedFields.usage) { + const usageFields = parseStructuredFields(parsedFields.usage, CLAUDE_STRUCTURED_MESSAGE_FIELDS.task_usage); + parsedFields.usage = usageFields + ? applyStructuredFieldThresholds(usageFields, CLAUDE_STRUCTURED_MESSAGE_FIELDS.task_usage) + : null; + } + + return { + agent: 'claude', + kind: 'task_notification', + fields: parsedFields, + }; +} + +function parseClaudeStructuredMessage(entry, content) { + if (!content) return null; + + const slashCommandFields = parseStructuredFields(content, CLAUDE_STRUCTURED_MESSAGE_FIELDS.slash_command); + if (slashCommandFields) { + return { + agent: 'claude', + kind: 'slash_command', + fields: applyStructuredFieldThresholds(slashCommandFields, CLAUDE_STRUCTURED_MESSAGE_FIELDS.slash_command), + }; + } + + const bashInputFields = parseStructuredSingleTag(content, 'bash-input', 'input', 0); + if (bashInputFields) { + return { agent: 'claude', kind: 'bash_input', fields: bashInputFields }; + } + + const bashResultFields = parseStructuredFields(content, CLAUDE_STRUCTURED_MESSAGE_FIELDS.bash_result); + if (bashResultFields) { + return { + agent: 'claude', + kind: 'bash_result', + fields: applyStructuredFieldThresholds(bashResultFields, CLAUDE_STRUCTURED_MESSAGE_FIELDS.bash_result), + }; + } + + const localCommandStdoutFields = parseStructuredSingleTag(content, 'local-command-stdout', 'output', 1500); + if (localCommandStdoutFields) { + return { agent: 'claude', kind: 'local_command_stdout', fields: localCommandStdoutFields }; + } + + return parseClaudeTaskNotification(content); +} + +function parseStructuredMessage(agent, role, content, entry) { + if (!content) return null; if (agent === 'codex') return parseCodexStructuredMessage(content); + if (agent === 'claude' && (role === 'user' || role === 'queue')) { + return parseClaudeStructuredMessage(entry, content); + } return null; } @@ -4951,5 +5086,11 @@ module.exports = { normalizeProjectPath, shortenHomePath, detectWindowsWslHomes, + parseStructuredWrapper, + parseStructuredFields, + parseClaudeTaskNotification, + parseClaudeStructuredMessage, + parseStructuredMessage, + isFilteredClaudeStructuredMessage, }, }; diff --git a/src/frontend/detail.js b/src/frontend/detail.js index 1e1065d..b763489 100644 --- a/src/frontend/detail.js +++ b/src/frontend/detail.js @@ -193,6 +193,11 @@ function closeDetail() { var structuredMessageRenderers = { 'codex:user_shell_command': renderCodexUserShellCommand, 'codex:user_action': renderCodexUserAction, + 'claude:slash_command': renderClaudeSlashCommand, + 'claude:bash_input': renderClaudeBashInput, + 'claude:bash_result': renderClaudeBashResult, + 'claude:local_command_stdout': renderClaudeLocalCommandStdout, + 'claude:task_notification': renderClaudeTaskNotification, }; function renderStructuredBlock(text) { @@ -203,6 +208,10 @@ function renderStructuredSection(label, text) { return '
' + escHtml(label) + '
' + renderStructuredBlock(text); } +function renderStructuredMeta(label, text) { + if (!text) return ''; + return '
' + escHtml(label) + ' ' + escHtml(text) + '
'; +} function renderCodexUserShellCommand(fields) { if (!fields || !fields.command || !fields.result) return ''; var html = '
'; @@ -224,6 +233,78 @@ function renderCodexUserAction(fields) { return html; } +function renderClaudeSlashCommand(fields) { + if (!fields || !fields.command_name || !fields.command_message) return ''; + var html = '
'; + html += '
Slash Command:
'; + html += renderStructuredMeta('Command:', fields.command_name); + html += renderStructuredMeta('Message:', fields.command_message); + if (fields.command_args) html += renderStructuredSection('Args:', fields.command_args); + html += '
'; + return html; +} + +function renderClaudeBashInput(fields) { + if (!fields || !fields.input) return ''; + var html = '
'; + html += '
Bash Input:
'; + html += renderStructuredSection('Command:', fields.input); + html += '
'; + return html; +} + +function renderClaudeBashResult(fields) { + if (!fields || (!fields.stdout && !fields.stderr)) return ''; + var html = '
'; + html += '
Bash Result:
'; + if (fields.stdout) html += renderStructuredSection('Stdout:', fields.stdout); + if (fields.stderr) html += renderStructuredSection('Stderr:', fields.stderr); + html += '
'; + return html; +} + +function renderClaudeLocalCommandStdout(fields) { + if (!fields || !fields.output) return ''; + var html = '
'; + html += '
Local Command Output:
'; + html += renderStructuredSection('Output:', fields.output); + html += '
'; + return html; +} + +function renderClaudeTaskUsage(usage) { + if (!usage) return ''; + var parts = []; + if (usage.total_tokens) parts.push(usage.total_tokens + ' tokens'); + if (usage.tool_uses) parts.push(usage.tool_uses + ' tools'); + if (usage.duration_ms) parts.push(usage.duration_ms + ' ms'); + if (parts.length === 0) return ''; + return renderStructuredMeta('Usage:', parts.join(', ')); +} + +function sanitizeClassToken(value, fallback) { + var token = String(value || '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''); + return token || (fallback || 'unknown'); +} + +function renderClaudeTaskNotification(fields) { + if (!fields || (!fields.summary && !fields.task_id && !fields.event && !fields.result)) return ''; + var html = '
'; + html += '
Task Notification:
'; + if (fields.status) { + var statusClass = sanitizeClassToken(fields.status, 'unknown'); + html += '
Status: ' + escHtml(fields.status) + '
'; + } + if (fields.summary) html += renderStructuredMeta('Summary:', fields.summary); + html += renderStructuredMeta('Task ID:', fields.task_id); + html += renderStructuredMeta('Tool Use ID:', fields.tool_use_id); + if (fields.output_file) html += renderStructuredSection('Output File:', fields.output_file); + if (fields.event) html += renderStructuredSection('Event:', fields.event); + if (fields.result) html += renderStructuredSection('Result:', fields.result); + html += renderClaudeTaskUsage(fields.usage); + html += '
'; + return html; +} function renderMessageContent(message) { var structured = message && message.structured; if (structured && structured.agent && structured.kind) { @@ -237,6 +318,12 @@ function renderMessageContent(message) { return '
' + escHtml((message && message.content) || '') + '
'; } +function getMessageRoleMeta(role) { + if (role === 'user') return { className: 'msg-user', label: 'You' }; + if (role === 'queue') return { className: 'msg-system', label: 'Queue' }; + if (role === 'system') return { className: 'msg-system', label: 'System' }; + return { className: 'msg-assistant', label: 'Assistant' }; +} function renderDetailMessages(container, messages) { var sort = localStorage.getItem('codedash-msg-sort') || 'asc'; var sorted = sort === 'desc' ? messages.slice().reverse() : messages; @@ -246,12 +333,11 @@ function renderDetailMessages(container, messages) { msgsHtml += ''; msgsHtml += '
'; sorted.forEach(function(m) { - var roleClass = m.role === 'user' ? 'msg-user' : 'msg-assistant'; - var roleLabel = m.role === 'user' ? 'You' : 'Assistant'; + var roleMeta = getMessageRoleMeta(m.role); var hasTools = m.tools && m.tools.length > 0; - msgsHtml += '
'; + msgsHtml += '
'; msgsHtml += '
'; - msgsHtml += '
' + roleLabel + '
'; + msgsHtml += '
' + roleMeta.label + '
'; msgsHtml += renderMessageContent(m); msgsHtml += '
'; if (hasTools) { diff --git a/src/frontend/styles.css b/src/frontend/styles.css index cb00532..9ed604b 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -1541,6 +1541,11 @@ body { border: 1px solid rgba(74, 222, 128, 0.15); } +.msg-system { + background: rgba(148, 163, 184, 0.08); + border: 1px solid rgba(148, 163, 184, 0.18); +} + .msg-role { font-size: 11px; font-weight: 600; @@ -1583,6 +1588,28 @@ body { color: var(--text-primary); } +.structured-task-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); +} + +.structured-status { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + background: rgba(96, 165, 250, 0.12); + border: 1px solid rgba(96, 165, 250, 0.18); + color: var(--text-primary); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; +} .structured-block { margin: 0; padding: 10px 12px; diff --git a/test/claude-structured-parse.test.js b/test/claude-structured-parse.test.js new file mode 100644 index 0000000..478f5ee --- /dev/null +++ b/test/claude-structured-parse.test.js @@ -0,0 +1,181 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const data = require('../src/data'); + +const { + parseClaudeStructuredMessage, + parseClaudeTaskNotification, + parseStructuredMessage, + isFilteredClaudeStructuredMessage, +} = data.__test; + +test('parses each Claude structured message type happy path', () => { + assert.deepEqual( + parseClaudeStructuredMessage({}, '/reviewRun checks--full'), + { + agent: 'claude', + kind: 'slash_command', + fields: { + command_name: '/review', + command_message: 'Run checks', + command_args: '--full', + }, + } + ); + + assert.deepEqual( + parseClaudeStructuredMessage({}, 'npm test'), + { + agent: 'claude', + kind: 'bash_input', + fields: { + input: 'npm test', + }, + } + ); + + assert.deepEqual( + parseClaudeStructuredMessage({}, 'okwarn'), + { + agent: 'claude', + kind: 'bash_result', + fields: { + stdout: 'ok', + stderr: 'warn', + }, + } + ); + + assert.deepEqual( + parseClaudeStructuredMessage({}, 'command output'), + { + agent: 'claude', + kind: 'local_command_stdout', + fields: { + output: 'command output', + }, + } + ); + + assert.deepEqual( + parseClaudeStructuredMessage({}, [ + '', + 'task-1', + 'tool-1', + '/tmp/out.txt', + 'completed', + 'Done', + 'Wrote file', + '42199', + '', + ].join('')), + { + agent: 'claude', + kind: 'task_notification', + fields: { + task_id: 'task-1', + tool_use_id: 'tool-1', + output_file: '/tmp/out.txt', + status: 'completed', + summary: 'Done', + result: 'Wrote file', + usage: { + total_tokens: '42', + tool_uses: '1', + duration_ms: '99', + }, + }, + } + ); +}); + +test('missing required field returns null', () => { + assert.equal( + parseStructuredMessage('claude', 'user', '/review', {}), + null + ); +}); + +test('optional fields can be absent without failing parse', () => { + assert.deepEqual( + parseClaudeStructuredMessage({}, '/reviewRun checks'), + { + agent: 'claude', + kind: 'slash_command', + fields: { + command_name: '/review', + command_message: 'Run checks', + command_args: '', + }, + } + ); + + assert.deepEqual( + parseClaudeTaskNotification([ + '', + 'task-1', + 'tool-1', + '/tmp/out.txt', + 'completed', + 'Done', + '', + ].join('')), + { + agent: 'claude', + kind: 'task_notification', + fields: { + task_id: 'task-1', + tool_use_id: 'tool-1', + output_file: '/tmp/out.txt', + status: 'completed', + summary: 'Done', + result: '', + usage: '', + }, + } + ); +}); + +test('malformed wrapper returns null', () => { + assert.equal(parseClaudeStructuredMessage({}, 'npm test'), null); +}); + +test('task notification keeps parsed fields when nested usage is malformed', () => { + assert.deepEqual( + parseClaudeTaskNotification([ + '', + 'task-1', + 'tool-1', + '/tmp/out.txt', + 'ok" onmouseover="alert(1)', + 'Done', + '42', + '', + ].join('')), + { + agent: 'claude', + kind: 'task_notification', + fields: { + task_id: 'task-1', + tool_use_id: 'tool-1', + output_file: '/tmp/out.txt', + status: 'ok" onmouseover="alert(1)', + summary: 'Done', + result: '', + usage: null, + }, + } + ); +}); + +test('filtered Claude structured messages are matched through the shared tag set', () => { + assert.equal( + isFilteredClaudeStructuredMessage('Heads up'), + true + ); + assert.equal( + isFilteredClaudeStructuredMessage('npm test'), + false + ); +});