Add Markdown editor to chat composer

This commit is contained in:
Federico Jaramillo Martinez
2026-05-12 11:46:51 +02:00
parent 01f7d72716
commit cd2f6e2425
2 changed files with 125 additions and 57 deletions
+110 -48
View File
@@ -1,3 +1,8 @@
import { defaultKeymap, history, historyKeymap, indentWithTab, insertNewlineAndIndent } from "@codemirror/commands";
import { markdown, deleteMarkupBackward, insertNewlineContinueMarkup } from "@codemirror/lang-markdown";
import { EditorSelection, EditorState, Compartment } from "@codemirror/state";
import { EditorView, keymap, placeholder } from "@codemirror/view";
import { defaultHighlightStyle, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language";
import { LitElement, html, type PropertyValues } from "lit"; import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js"; import { customElement, property, query, state } from "lit/decorators.js";
import { api, type FileSuggestion, type SessionStatus, type SlashCommand } from "../api"; import { api, type FileSuggestion, type SessionStatus, type SlashCommand } from "../api";
@@ -18,11 +23,14 @@ export class PromptEditor extends LitElement {
@property({ attribute: false }) onStop?: () => void; @property({ attribute: false }) onStop?: () => void;
@property({ attribute: false }) onSelectModel?: () => void; @property({ attribute: false }) onSelectModel?: () => void;
@property({ attribute: false }) onSelectThinking?: () => void; @property({ attribute: false }) onSelectThinking?: () => void;
@query("textarea") private textarea?: HTMLTextAreaElement; @query(".markdown-editor") private editorHost?: HTMLDivElement;
@state() private draft = ""; @state() private draft = "";
@state() private completions: CompletionItem[] = []; @state() private completions: CompletionItem[] = [];
@state() private selectedIndex = 0; @state() private selectedIndex = 0;
private requestVersion = 0; private requestVersion = 0;
private editor: EditorView | undefined;
private readonly editableCompartment = new Compartment();
private readonly readOnlyCompartment = new Compartment();
protected override willUpdate(changed: PropertyValues<this>) { protected override willUpdate(changed: PropertyValues<this>) {
if (!changed.has("sessionId")) return; if (!changed.has("sessionId")) return;
@@ -33,8 +41,19 @@ export class PromptEditor extends LitElement {
this.selectedIndex = 0; this.selectedIndex = 0;
} }
override firstUpdated(): void {
this.createEditor();
}
protected override updated(changed: PropertyValues) { protected override updated(changed: PropertyValues) {
if (changed.has("draft") || changed.has("sessionId")) this.resizeTextarea(); if (changed.has("disabled")) this.updateEditorDisabledState();
if (changed.has("draft") || changed.has("sessionId")) this.syncEditorDoc();
}
override disconnectedCallback(): void {
this.editor?.destroy();
this.editor = undefined;
super.disconnectedCallback();
} }
override render() { override render() {
@@ -44,15 +63,7 @@ export class PromptEditor extends LitElement {
return html` return html`
<footer class=${shellMode ? "shell-mode" : ""}> <footer class=${shellMode ? "shell-mode" : ""}>
<div class="editor-wrap"> <div class="editor-wrap">
<textarea <div class=${`markdown-editor${this.disabled ? " markdown-editor-disabled" : ""}`} aria-label="Message pi" aria-disabled=${this.disabled ? "true" : "false"}></div>
.value=${this.draft}
?disabled=${this.disabled}
@input=${(event: Event) => {
if (event.target instanceof HTMLTextAreaElement) this.updateDraft(event.target.value);
}}
@keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}
placeholder="Message pi... Use / for commands, @ for files"
></textarea>
${shellMode ? html`<div class="mode-hint">Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null} ${shellMode ? html`<div class="mode-hint">Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null} ${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null}
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => { this.pick(item); }}></autocomplete-menu> <autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => { this.pick(item); }}></autocomplete-menu>
@@ -68,7 +79,7 @@ export class PromptEditor extends LitElement {
} }
focusInput() { focusInput() {
this.textarea?.focus(); this.editor?.focus();
} }
private renderCompactStatus() { private renderCompactStatus() {
@@ -84,11 +95,60 @@ export class PromptEditor extends LitElement {
`; `;
} }
private resizeTextarea() { private createEditor() {
const textarea = this.textarea; if (!this.editorHost || this.editor !== undefined) return;
if (!textarea) return; this.editor = new EditorView({
textarea.style.height = "auto"; parent: this.editorHost,
textarea.style.height = `${String(textarea.scrollHeight)}px`; state: EditorState.create({
doc: this.draft,
extensions: [
history(),
markdown(),
indentOnInput(),
indentUnit.of(" "),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
EditorView.lineWrapping,
placeholder("Message pi... Use / for commands, @ for files"),
this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
EditorView.updateListener.of((update) => {
if (update.docChanged) this.updateDraft(update.state.doc.toString());
}),
keymap.of([
{ key: "ArrowDown", run: () => this.moveCompletion(1) },
{ key: "ArrowUp", run: () => this.moveCompletion(-1) },
{ key: "Escape", run: () => this.closeCompletions() },
{ key: "Enter", run: () => this.handleEditorEnter() },
{ key: "Shift-Enter", run: (view) => insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view) },
{ key: "Tab", run: (view) => this.handleEditorTab(view) },
{ key: "Shift-Tab", run: (view) => indentWithTab.shift?.(view) ?? false },
{ key: "Backspace", run: (view) => deleteMarkupBackward(view) },
...historyKeymap,
...defaultKeymap,
]),
],
}),
});
}
private syncEditorDoc() {
const editor = this.editor;
if (!editor) return;
const current = editor.state.doc.toString();
if (current === this.draft) return;
editor.dispatch({
changes: { from: 0, to: current.length, insert: this.draft },
selection: EditorSelection.cursor(this.draft.length),
});
}
private updateEditorDisabledState() {
this.editor?.dispatch({
effects: [
this.editableCompartment.reconfigure(EditorView.editable.of(!this.disabled)),
this.readOnlyCompartment.reconfigure(EditorState.readOnly.of(this.disabled)),
],
});
} }
private updateDraft(value: string) { private updateDraft(value: string) {
@@ -139,7 +199,7 @@ export class PromptEditor extends LitElement {
} }
private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileKind?: FileSuggestion["kind"]; fileMode?: "file" | "path"; quoted?: boolean } | undefined { private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileKind?: FileSuggestion["kind"]; fileMode?: "file" | "path"; quoted?: boolean } | undefined {
const cursor = this.textarea?.selectionStart ?? this.draft.length; const cursor = this.editor?.state.selection.main.head ?? this.draft.length;
const beforeCursor = this.draft.slice(0, cursor); const beforeCursor = this.draft.slice(0, cursor);
const quotedTrigger = this.currentQuotedTrigger(beforeCursor, cursor); const quotedTrigger = this.currentQuotedTrigger(beforeCursor, cursor);
if (quotedTrigger !== undefined) return quotedTrigger; if (quotedTrigger !== undefined) return quotedTrigger;
@@ -162,52 +222,54 @@ export class PromptEditor extends LitElement {
return undefined; return undefined;
} }
private handleKeyDown(event: KeyboardEvent) { private moveCompletion(delta: number): boolean {
if (!this.completions.length) return false;
this.selectedIndex = (this.selectedIndex + delta + this.completions.length) % this.completions.length;
return true;
}
private closeCompletions(): boolean {
if (!this.completions.length) return false;
this.completions = [];
return true;
}
private handleEditorEnter(): boolean {
if (this.completions.length) { if (this.completions.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
this.selectedIndex = (this.selectedIndex + 1) % this.completions.length;
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
this.selectedIndex = (this.selectedIndex - 1 + this.completions.length) % this.completions.length;
return;
}
if (event.key === "Tab" || event.key === "Enter") {
event.preventDefault();
const completion = this.completions[this.selectedIndex]; const completion = this.completions[this.selectedIndex];
if (completion !== undefined) this.pick(completion); if (completion !== undefined) this.pick(completion);
return; return true;
} }
if (event.key === "Escape") { this.send(this.canSteer || this.isCompacting ? "followUp" : undefined);
event.preventDefault(); return true;
this.completions = [];
return;
} }
private handleEditorTab(view: EditorView): boolean {
if (this.completions.length) {
const completion = this.completions[this.selectedIndex];
if (completion !== undefined) this.pick(completion);
return true;
} }
if (event.key === "Tab") {
const trigger = this.currentTrigger(); const trigger = this.currentTrigger();
if (trigger?.kind === "file") { if (trigger?.kind === "file") {
event.preventDefault();
void this.refreshCompletions(); void this.refreshCompletions();
return; return true;
}
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
this.send(this.canSteer || this.isCompacting ? "followUp" : undefined);
} }
return indentWithTab.run?.(view) ?? false;
} }
private pick(item: CompletionItem) { private pick(item: CompletionItem) {
const editor = this.editor;
if (!editor) return;
const suffix = item.kind === "file" && (item.insertText.endsWith("/") || item.cursorOffset !== undefined) ? "" : " "; const suffix = item.kind === "file" && (item.insertText.endsWith("/") || item.cursorOffset !== undefined) ? "" : " ";
const cursor = item.replaceFrom + (item.cursorOffset ?? item.insertText.length) + suffix.length; const cursor = item.replaceFrom + (item.cursorOffset ?? item.insertText.length) + suffix.length;
const after = item.insertText.endsWith("\"") && this.draft.slice(item.replaceTo).startsWith("\"") ? this.draft.slice(item.replaceTo + 1) : this.draft.slice(item.replaceTo); const replaceTo = item.insertText.endsWith("\"") && this.draft.slice(item.replaceTo).startsWith("\"") ? item.replaceTo + 1 : item.replaceTo;
this.draft = `${this.draft.slice(0, item.replaceFrom)}${item.insertText}${suffix}${after}`; editor.dispatch({
if (this.sessionId !== undefined && this.sessionId !== "") saveDraft(this.sessionId, this.draft); changes: { from: item.replaceFrom, to: replaceTo, insert: `${item.insertText}${suffix}` },
selection: EditorSelection.cursor(cursor),
scrollIntoView: true,
});
this.completions = []; this.completions = [];
void this.updateComplete.then(() => this.textarea?.setSelectionRange(cursor, cursor));
} }
private send(streamingBehavior?: "steer" | "followUp") { private send(streamingBehavior?: "steer" | "followUp") {
+9 -3
View File
@@ -320,11 +320,17 @@ export const promptEditorStyles = css`
.compact-status > button { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; } .compact-status > button { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.select-model { max-width: min(42vw, 320px); } .select-model { max-width: min(42vw, 320px); }
.select-thinking { max-width: 110px; } .select-thinking { max-width: 110px; }
textarea { box-sizing: border-box; width: 100%; min-height: 54px; max-height: 220px; resize: none; overflow-y: auto; border-radius: 8px; border: 1px solid #30363d; background: #0d1117; color: #e6edf3; padding: 8px; font: 16px/1.4 system-ui, sans-serif; } textarea, .markdown-editor .cm-editor { box-sizing: border-box; width: 100%; min-height: 54px; max-height: 220px; resize: none; overflow: hidden; border-radius: 8px; border: 1px solid #30363d; background: #0d1117; color: #e6edf3; font: 16px/1.4 system-ui, sans-serif; }
.shell-mode textarea { border-color: #3fb950; box-shadow: 0 0 0 1px #3fb95055; } textarea { overflow-y: auto; padding: 8px; }
.markdown-editor .cm-scroller { max-height: 220px; overflow-y: auto; font-family: system-ui, sans-serif; line-height: 1.4; }
.markdown-editor .cm-content { min-height: 38px; padding: 8px; caret-color: #e6edf3; }
.markdown-editor .cm-line { padding: 0; }
.markdown-editor .cm-placeholder { color: #6e7681; }
.markdown-editor .cm-focused { outline: none; }
.shell-mode textarea, .shell-mode .markdown-editor .cm-editor { border-color: #3fb950; box-shadow: 0 0 0 1px #3fb95055; }
.mode-hint { position: absolute; right: 8px; bottom: 8px; max-width: calc(100% - 16px); border: 1px solid #238636; border-radius: 999px; background: #0f2a16; color: #3fb950; padding: 2px 8px; font-size: 12px; pointer-events: none; } .mode-hint { position: absolute; right: 8px; bottom: 8px; max-width: calc(100% - 16px); border: 1px solid #238636; border-radius: 999px; background: #0f2a16; color: #3fb950; padding: 2px 8px; font-size: 12px; pointer-events: none; }
button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; } button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; }
button:disabled, textarea:disabled { opacity: .5; cursor: not-allowed; } button:disabled, textarea:disabled, .markdown-editor-disabled .cm-editor { opacity: .5; cursor: not-allowed; }
@media (max-width: 640px) { @media (max-width: 640px) {
footer { gap: 8px; padding: 8px; } footer { gap: 8px; padding: 8px; }
.actions { gap: 6px; } .actions { gap: 6px; }