Changeset 0.17.2 (#77)

This commit is contained in:
2026-02-28 11:58:27 +00:00
parent 856dc9b0ac
commit a008dac488
26 changed files with 3018 additions and 116 deletions

310
src/editor/chat-input.mjs Normal file
View File

@@ -0,0 +1,310 @@
// ==========================================
// 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();
},
};
}