commit 5a0b76b4659e423a0e33f3e101d0015d2f9e1481 Author: multipleof4 Date: Wed Sep 2 14:30:25 2026 -0700 Initial commit: add user markdown rendering plugin diff --git a/README.md b/README.md new file mode 100644 index 0000000..584837c --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# dsh-user-markdown-plugin + +A Cordis plugin for DeepSeek Harness (DSH) Web GUI that extends user message rendering to support full Markdown formatting (headings, bold, italic, inline code, code blocks, lists, blockquotes, tables, links, and reference chips). + +## Features + +- **Markdown Rendering for User Messages**: Renders code blocks, inline code, lists, headings, tables, blockquotes, bold/italic text, links, and `@file`/`@session` reference chips in user message bubbles. +- **Keyed Slot Registration**: Injects into the `conversation.chat.node` slot for keys `user` and `steering`. +- **Copy Action & Image Support**: Preserves image attachments and adds a hover copy action button. + +## Usage + +### As a Dynamic Cordis Plugin + +In a running DSH session, define and activate the plugin using `cordis_define` and `cordis_run`. diff --git a/lib/client.js b/lib/client.js new file mode 100644 index 0000000..b5c12ae --- /dev/null +++ b/lib/client.js @@ -0,0 +1,346 @@ +/** + * Client half of dsh-user-markdown-plugin. + * Registers a user message Chat Node renderer for 'user' and 'steering' keys + * that parses and renders Markdown formatted text. + */ +export function apply(ctx) { + const slots = ctx.get('slots'); + if (!slots) return; + + function contentParts(content) { + if (!Array.isArray(content)) { + if (typeof content === 'string') return { text: content, images: [], rest: [] }; + return { text: '', images: [], rest: [] }; + } + const texts = []; + const images = []; + const rest = []; + for (const block of content) { + if (block && block.type === 'text' && typeof block.text === 'string') { + texts.push(block.text); + } else if (block && block.type === 'image' && block.attachment !== undefined) { + images.push({ attachment: block.attachment }); + } else if (block) { + rest.push(block); + } + } + return { text: texts.join(''), images, rest }; + } + + function renderInline(text, referenceLabels) { + if (!text) return null; + const refLabels = Array.isArray(referenceLabels) ? referenceLabels : []; + const tokens = []; + const regex = /(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*\*([^*]+)\*\*\*|___([^_]+)___)|(\*\*([^*]+)\*\*|__([^_]+)__)|(\*([^*]+)\*|_([^_]+)_)|(~~([^~]+)~~)|((?:^|\s)(?:\/[\w-]+|@"[^"\n]+"|@[^\s]+))/g; + let lastIndex = 0; + let match; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + tokens.push({ type: 'text', content: text.slice(lastIndex, match.index) }); + } + if (match[1]) { + tokens.push({ type: 'code', content: match[1].slice(1, -1) }); + } else if (match[2]) { + tokens.push({ type: 'link', label: match[3], url: match[4] }); + } else if (match[5]) { + tokens.push({ type: 'bold_italic', content: match[6] || match[7] }); + } else if (match[8]) { + tokens.push({ type: 'bold', content: match[9] || match[10] }); + } else if (match[11]) { + tokens.push({ type: 'italic', content: match[12] || match[13] }); + } else if (match[14]) { + tokens.push({ type: 'strike', content: match[15] }); + } else if (match[16]) { + const raw = match[16]; + const leadingSpace = raw.match(/^\s*/)[0]; + if (leadingSpace) { + tokens.push({ type: 'text', content: leadingSpace }); + } + tokens.push({ type: 'ref', content: raw.trim() }); + } + lastIndex = regex.lastIndex; + } + + if (lastIndex < text.length) { + tokens.push({ type: 'text', content: text.slice(lastIndex) }); + } + + return tokens.map((token, idx) => { + switch (token.type) { + case 'code': + return React.createElement('code', { key: idx, className: 'dsh-user-inline-code' }, token.content); + case 'link': + return React.createElement('a', { + key: idx, + href: token.url, + target: '_blank', + rel: 'noopener noreferrer', + className: 'dsh-user-link' + }, token.label); + case 'bold_italic': + return React.createElement('strong', { key: idx }, React.createElement('em', null, token.content)); + case 'bold': + return React.createElement('strong', { key: idx }, token.content); + case 'italic': + return React.createElement('em', { key: idx }, token.content); + case 'strike': + return React.createElement('del', { key: idx }, token.content); + case 'ref': { + const label = token.content; + const isSession = refLabels.includes(label.slice(1)); + const refKind = isSession ? 'session' : label.startsWith('@') ? (label.endsWith('/') ? 'folder' : 'file') : 'command'; + const displayLabel = refKind === 'session' ? label.slice(1) : label.startsWith('@') ? label.slice(1).replace(/^"|"$/g, '').split(/[\\/]/).filter(Boolean).pop() || label.slice(1) : label; + return React.createElement('span', { + key: idx, + className: 'dsh-user-ref-chip', + 'data-ref-chip': refKind, + title: label + }, displayLabel); + } + case 'text': + default: + return token.content; + } + }); + } + + function parseMarkdownBlocks(text) { + const blocks = []; + const lines = text.split('\n'); + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (line.trim().startsWith('```')) { + const lang = line.trim().slice(3).trim(); + const codeLines = []; + i++; + while (i < lines.length && !lines[i].trim().startsWith('```')) { + codeLines.push(lines[i]); + i++; + } + if (i < lines.length) i++; + blocks.push({ type: 'code', lang, code: codeLines.join('\n') }); + continue; + } + + if (line.trim() === '') { + i++; + continue; + } + + const headingMatch = line.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + blocks.push({ type: 'heading', level: headingMatch[1].length, text: headingMatch[2] }); + i++; + continue; + } + + if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) { + blocks.push({ type: 'hr' }); + i++; + continue; + } + + if (/^\s*>\s?(.*)$/.test(line)) { + const quoteLines = []; + while (i < lines.length && /^\s*>\s?(.*)$/.test(lines[i])) { + quoteLines.push(lines[i].replace(/^\s*>\s?/, '')); + i++; + } + blocks.push({ type: 'blockquote', text: quoteLines.join('\n') }); + continue; + } + + if (/^\s*[-*+]\s+(.*)$/.test(line) || /^\s*\d+\.\s+(.*)$/.test(line)) { + const isOrdered = /^\s*\d+\.\s+(.*)$/.test(line); + const items = []; + const itemRegex = isOrdered ? /^\s*\d+\.\s+(.*)$/ : /^\s*[-*+]\s+(.*)$/; + while (i < lines.length && itemRegex.test(lines[i])) { + const m = lines[i].match(itemRegex); + if (m) items.push(m[1]); + i++; + } + blocks.push({ type: isOrdered ? 'ol' : 'ul', items }); + continue; + } + + if (line.trim().startsWith('|') && line.trim().endsWith('|') && i + 1 < lines.length && /^\s*\|?\s*:?-+:?\s*\|/.test(lines[i + 1])) { + const parseRow = (l) => l.trim().slice(1, -1).split('|').map(c => c.trim()); + const headers = parseRow(lines[i]); + i += 2; + const rows = []; + while (i < lines.length && lines[i].trim().startsWith('|')) { + rows.push(parseRow(lines[i])); + i++; + } + blocks.push({ type: 'table', headers, rows }); + continue; + } + + const paraLines = []; + while ( + i < lines.length && + lines[i].trim() !== '' && + !lines[i].trim().startsWith('```') && + !lines[i].match(/^#{1,6}\s+/) && + !/^\s*(---|\*\*\*|___)\s*$/.test(lines[i]) && + !/^\s*>\s?/.test(lines[i]) && + !/^\s*[-*+]\s+/.test(lines[i]) && + !/^\s*\d+\.\s+/.test(lines[i]) + ) { + paraLines.push(lines[i]); + i++; + } + if (paraLines.length > 0) { + blocks.push({ type: 'p', text: paraLines.join('\n') }); + } + } + + return blocks; + } + + function UserMarkdownRenderer({ text, referenceLabels }) { + const blocks = parseMarkdownBlocks(text); + return React.createElement('div', { className: 'dsh-user-markdown-body' }, + blocks.map((block, index) => { + switch (block.type) { + case 'code': + return React.createElement('pre', { key: index, className: 'dsh-user-code-block' }, + block.lang ? React.createElement('div', { className: 'dsh-user-code-lang' }, block.lang) : null, + React.createElement('code', null, block.code) + ); + case 'heading': { + const tag = 'h' + block.level; + return React.createElement(tag, { key: index, className: 'dsh-user-h' + block.level }, + renderInline(block.text, referenceLabels) + ); + } + case 'hr': + return React.createElement('hr', { key: index, className: 'dsh-user-hr' }); + case 'blockquote': + return React.createElement('blockquote', { key: index, className: 'dsh-user-blockquote' }, + renderInline(block.text, referenceLabels) + ); + case 'ul': + return React.createElement('ul', { key: index, className: 'dsh-user-ul' }, + block.items.map((item, itemIdx) => + React.createElement('li', { key: itemIdx }, renderInline(item, referenceLabels)) + ) + ); + case 'ol': + return React.createElement('ol', { key: index, className: 'dsh-user-ol' }, + block.items.map((item, itemIdx) => + React.createElement('li', { key: itemIdx }, renderInline(item, referenceLabels)) + ) + ); + case 'table': + return React.createElement('table', { key: index, className: 'dsh-user-table' }, + React.createElement('thead', null, + React.createElement('tr', null, + block.headers.map((h, hIdx) => + React.createElement('th', { key: hIdx }, renderInline(h, referenceLabels)) + ) + ) + ), + React.createElement('tbody', null, + block.rows.map((r, rIdx) => + React.createElement('tr', { key: rIdx }, + r.map((cell, cIdx) => + React.createElement('td', { key: cIdx }, renderInline(cell, referenceLabels)) + ) + ) + ) + ) + ); + case 'p': + default: + return React.createElement('p', { key: index, className: 'dsh-user-p' }, + renderInline(block.text, referenceLabels) + ); + } + }) + ); + } + + function UserMarkdownMessageNodeView(props) { + const node = props ? props.node : null; + const data = node ? node.data : {}; + const content = data ? data.content : []; + const referenceLabels = data ? data.referenceLabels : []; + const parts = contentParts(content); + const text = parts.text; + const images = parts.images; + const rest = parts.rest; + const renderMessageImages = props ? props.renderMessageImages : null; + + const showBubble = text !== '' || (rest && rest.length > 0); + + const [copied, setCopied] = React.useState(false); + const handleCopy = React.useCallback(function() { + if (typeof navigator !== 'undefined' && navigator.clipboard) { + navigator.clipboard.writeText(text).then(function() { + setCopied(true); + setTimeout(function() { setCopied(false); }, 2000); + }); + } + }, [text]); + + return React.createElement('div', { + className: 'dsh-user-msg-row', + 'data-time-hover-root': true + }, [ + React.createElement('div', { + key: 'stack', + className: 'dsh-user-msg-stack' + }, [ + renderMessageImages ? renderMessageImages({ images: images, align: 'end' }) : null, + showBubble ? React.createElement('div', { + key: 'bubble', + className: 'dsh-user-msg-bubble' + }, [ + React.createElement(UserMarkdownRenderer, { + key: 'md', + text: text, + referenceLabels: referenceLabels + }), + rest && rest.length > 0 ? rest.map(function(block, i) { + return React.createElement('pre', { key: 'rest-' + i, className: 'dsh-user-rest-block' }, + JSON.stringify(block, null, 2) + ); + }) : null + ]) : null, + referenceLabels && referenceLabels.length > 0 ? React.createElement('div', { + key: 'ref-summary', + className: 'dsh-user-ref-summary' + }, referenceLabels.join(', ')) : null + ]), + React.createElement('div', { + key: 'actions', + className: 'dsh-user-msg-actions' + }, [ + React.createElement('button', { + key: 'copy', + type: 'button', + onClick: handleCopy, + className: 'dsh-user-copy-btn', + title: copied ? 'Copied!' : 'Copy message' + }, copied ? '✓' : '📋') + ]) + ]); + } + + slots.inject('conversation.chat.node', () => { + slots.register({ + name: 'conversation.chat.node', + key: 'user' + }, UserMarkdownMessageNodeView); + + slots.register({ + name: 'conversation.chat.node', + key: 'steering' + }, UserMarkdownMessageNodeView); + }); +} diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..e173a8b --- /dev/null +++ b/lib/index.js @@ -0,0 +1,7 @@ +/** + * Host half of dsh-user-markdown-plugin. + * User message markdown rendering is entirely browser-side. + */ +export function apply(ctx) { + // No host side effects required. +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fbb9dce --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "dsh-user-markdown-plugin", + "version": "0.1.0", + "description": "Cordis plugin for DeepSeek Harness that applies Markdown rendering to user messages in the Web GUI", + "type": "module", + "main": "lib/index.js", + "exports": { + ".": "./lib/index.js", + "./client": "./lib/client.js" + }, + "dsh": { + "client": { + "platform": "web" + } + }, + "license": "MIT" +}