Archived
Add Markdown editor to chat composer
This commit is contained in:
@@ -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") {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user