// ========================================== // Chat Switchboard — CM6 Chat Input Factory // ========================================== // Minimal markdown editor for the chat message // input. No line numbers, no gutter. Enter sends, // Shift+Enter inserts newline. Auto-growing height. // // Usage: // const editor = CM.chatInput(container, { // placeholder: 'Send a message...', // onSubmit: (text) => sendMessage(text), // onChange: (text) => updateInputTokens(text), // maxHeight: 200, // darkMode: true, // }); // // editor.getValue() // editor.setValue(str) // editor.focus() // editor.destroy() // ========================================== import { EditorView, keymap, drawSelection, dropCursor, highlightSpecialChars, placeholder as placeholderExt, ViewPlugin, Decoration } from '@codemirror/view'; import { EditorState, Compartment, RangeSetBuilder } from '@codemirror/state'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { syntaxHighlighting, defaultHighlightStyle, syntaxTree } from '@codemirror/language'; import { chatInputTheme } from './theme.mjs'; // ── Auto-Height Extension ─────────────────── /** * Extension that auto-grows the editor height to fit content, * up to maxHeight pixels. Mirrors the existing textarea behavior: * this.style.height = 'auto'; * this.style.height = Math.min(this.scrollHeight, 200) + 'px'; */ function autoHeight(maxHeight = 200) { return EditorView.updateListener.of((update) => { if (update.docChanged || update.geometryChanged) { const dom = update.view.dom; dom.style.height = 'auto'; const scrollH = update.view.contentDOM.scrollHeight; // Account for .cm-editor padding (12px top + 12px bottom from wrapper CSS) const padV = 24; const targetH = Math.min(scrollH + padV, maxHeight); dom.style.height = targetH + 'px'; dom.style.overflowY = scrollH + padV > maxHeight ? 'auto' : 'hidden'; } }); } // ── Fenced Code Block Decorations ─────────── // // Scans the markdown syntax tree for FencedCode nodes // and applies line decorations so they render as visual // code blocks (dark background, monospace, rounded corners) // inside the chat input — matching the claude.ai UX. const codeBlockDeco = ViewPlugin.fromClass(class { decorations; constructor(view) { this.decorations = this.buildDecorations(view); } update(update) { // Rebuild when content changes, viewport shifts, or the parser // finishes an incremental parse (tree reference changes) if (update.docChanged || update.viewportChanged || syntaxTree(update.startState) !== syntaxTree(update.state)) { this.decorations = this.buildDecorations(update.view); } } buildDecorations(view) { const builder = new RangeSetBuilder(); const tree = syntaxTree(view.state); const doc = view.state.doc; tree.iterate({ enter(node) { if (node.name !== 'FencedCode') return; const startLine = doc.lineAt(node.from); const endLine = doc.lineAt(node.to); for (let ln = startLine.number; ln <= endLine.number; ln++) { const line = doc.line(ln); let cls; if (ln === startLine.number) { cls = 'cm-codeblock cm-codeblock-open'; } else if (ln === endLine.number) { cls = 'cm-codeblock cm-codeblock-close'; } else { cls = 'cm-codeblock cm-codeblock-mid'; } // Single-line code block (just ```) if (startLine.number === endLine.number) { cls = 'cm-codeblock cm-codeblock-open cm-codeblock-close'; } builder.add(line.from, line.from, Decoration.line({ class: cls })); } }, }); return builder.finish(); } }, { decorations: (v) => v.decorations, }); // ── Code Block Shortcuts ──────────────────── /** * Input handler: triple backtick → fenced code block. * Triggers when user types the third ` completing ``` * at the start of a line (possibly preceded only by whitespace/text). */ function tripleBacktickHandler() { return EditorView.inputHandler.of((view, from, to, text) => { if (text !== '`') return false; const line = view.state.doc.lineAt(from); const textBeforeCursor = view.state.doc.sliceString(line.from, from); // Match `` at end of line (completing ```) — only at line start if (/^``$/.test(textBeforeCursor.trimStart()) && textBeforeCursor.trimStart() === '``') { const leadingWhitespace = textBeforeCursor.match(/^(\s*)/)[1]; const block = leadingWhitespace + '```\n' + leadingWhitespace + '\n' + leadingWhitespace + '```'; view.dispatch({ changes: { from: line.from, to, insert: block }, selection: { anchor: line.from + leadingWhitespace.length + 4 }, }); return true; } return false; }); } /** * Keybinding: Ctrl/Cmd+E wraps selection in inline code backticks, * or inserts a pair and places cursor between them. */ function inlineCodeKeymap() { return keymap.of([{ key: 'Mod-e', run: (view) => { const { from, to } = view.state.selection.main; if (from !== to) { const text = view.state.doc.sliceString(from, to); view.dispatch({ changes: { from, to, insert: '`' + text + '`' }, selection: { anchor: from + 1, head: to + 1 }, }); } else { view.dispatch({ changes: { from, to: from, insert: '``' }, selection: { anchor: from + 1 }, }); } return true; }, }]); } // ── Chat Input Factory ────────────────────── /** * Create a chat message input with markdown highlighting. * * @param {HTMLElement} target - Container element * @param {Object} opts * @param {string} [opts.placeholder='Send a message...'] * @param {Function} [opts.onSubmit] - Called with text on Enter (without Shift) * @param {Function} [opts.onChange] - Called with text on every edit * @param {number} [opts.maxHeight=200] - Max height in pixels before scrolling * @param {boolean} [opts.darkMode] - Auto-detected from body if omitted * @returns {{ getValue, setValue, focus, getView, destroy }} */ export function chatInput(target, opts = {}) { const { placeholder: placeholderText = 'Send a message...', onSubmit = null, onChange = null, maxHeight = 200, darkMode = document.documentElement.getAttribute('data-theme') !== 'light', } = opts; // Build extensions const extensions = [ // Markdown highlighting markdown({ base: markdownLanguage }), syntaxHighlighting(defaultHighlightStyle, { fallback: true }), // Code block shortcuts tripleBacktickHandler(), // ``` → fenced code block inlineCodeKeymap(), // Ctrl/Cmd+E → inline code // Code block visual decorations (WYSIWYG style) codeBlockDeco, // Base editing highlightSpecialChars(), history(), drawSelection(), dropCursor(), closeBrackets(), // Theme — minimal chrome, no gutters chatInputTheme, // Placeholder placeholderExt(placeholderText), // Auto-growing height autoHeight(maxHeight), // Keymaps — chat-specific: Enter sends, Shift+Enter newline keymap.of([ // Enter to submit (must be before defaultKeymap) { key: 'Enter', run: (view) => { if (onSubmit) { const text = view.state.doc.toString(); onSubmit(text); return true; } return false; }, }, // Shift+Enter to insert newline { key: 'Shift-Enter', run: (view) => { view.dispatch( view.state.replaceSelection('\n') ); return true; }, }, ...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap, ]), ]; // Change listener if (onChange) { extensions.push(EditorView.updateListener.of((update) => { if (update.docChanged) { onChange(update.state.doc.toString()); } })); } // Enable spell check and standard text input attributes extensions.push( EditorView.contentAttributes.of({ spellcheck: 'true', autocapitalize: 'sentences', autocorrect: 'on', }) ); // Single-line display mode: wrap long lines extensions.push(EditorView.lineWrapping); const view = new EditorView({ state: EditorState.create({ doc: '', extensions }), parent: target, }); // ── Public API ────────────────────────── return { getValue() { return view.state.doc.toString(); }, setValue(text) { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text || '' }, }); }, focus() { view.focus(); }, /** Access the underlying EditorView */ getView() { return view; }, /** Clean up */ destroy() { view.destroy(); }, }; }