Skip to content

Commit f63684c

Browse files
committed
move prompt editor outside of the tiptap package
1 parent 9d8a75e commit f63684c

File tree

3 files changed

+374
-1
lines changed

3 files changed

+374
-1
lines changed

apps/desktop/src/ai/prompts/details.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useCallback, useEffect, useState } from "react";
22

33
import { commands as templateCommands } from "@hypr/plugin-template";
4-
import { PromptEditor } from "@hypr/tiptap/prompt";
54
import { Button } from "@hypr/ui/components/ui/button";
65

6+
import { PromptEditor } from "./editor";
7+
78
import * as main from "~/store/tinybase/store/main";
89
import {
910
AVAILABLE_FILTERS,
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { EditorState, type Extension } from "@codemirror/state";
2+
import { EditorView } from "@codemirror/view";
3+
import CodeMirror from "@uiw/react-codemirror";
4+
import readOnlyRangesExtension from "codemirror-readonly-ranges";
5+
import { useCallback, useMemo } from "react";
6+
7+
import { jinjaLanguage, jinjaLinter, readonlyVisuals } from "./jinja";
8+
9+
export interface ReadOnlyRange {
10+
from: number;
11+
to: number;
12+
}
13+
14+
interface PromptEditorProps {
15+
value: string;
16+
onChange?: (value: string) => void;
17+
placeholder?: string;
18+
readOnly?: boolean;
19+
readOnlyRanges?: ReadOnlyRange[];
20+
variables?: string[];
21+
filters?: string[];
22+
}
23+
24+
export function PromptEditor({
25+
value,
26+
onChange,
27+
placeholder,
28+
readOnly = false,
29+
readOnlyRanges = [],
30+
variables = [],
31+
filters = [],
32+
}: PromptEditorProps) {
33+
const getReadOnlyRanges = useCallback(
34+
(_state: EditorState) => {
35+
if (readOnly || readOnlyRanges.length === 0) {
36+
return [];
37+
}
38+
39+
return readOnlyRanges.map((range) => ({
40+
from: range.from,
41+
to: range.to,
42+
}));
43+
},
44+
[readOnly, readOnlyRanges],
45+
);
46+
47+
const getRangesForVisuals = useCallback(() => {
48+
return readOnlyRanges;
49+
}, [readOnlyRanges]);
50+
51+
const extensions = useMemo(() => {
52+
const exts: Extension[] = [
53+
jinjaLanguage(variables, filters),
54+
jinjaLinter(),
55+
];
56+
57+
if (!readOnly && readOnlyRanges.length > 0) {
58+
exts.push(readOnlyRangesExtension(getReadOnlyRanges));
59+
exts.push(readonlyVisuals(getRangesForVisuals));
60+
}
61+
62+
return exts;
63+
}, [
64+
readOnly,
65+
readOnlyRanges,
66+
getReadOnlyRanges,
67+
getRangesForVisuals,
68+
variables,
69+
filters,
70+
]);
71+
72+
const theme = useMemo(
73+
() =>
74+
EditorView.theme({
75+
"&": {
76+
height: "100%",
77+
fontFamily:
78+
"var(--font-mono, 'Menlo', 'Monaco', 'Courier New', monospace)",
79+
fontSize: "13px",
80+
lineHeight: "1.6",
81+
},
82+
".cm-content": {
83+
padding: "8px 0",
84+
},
85+
".cm-line": {
86+
padding: "0 12px",
87+
},
88+
".cm-scroller": {
89+
overflow: "auto",
90+
},
91+
"&.cm-focused": {
92+
outline: "none",
93+
},
94+
".cm-placeholder": {
95+
color: "#999",
96+
fontStyle: "italic",
97+
},
98+
".cm-readonly-region": {
99+
backgroundColor: "rgba(0, 0, 0, 0.04)",
100+
borderRadius: "2px",
101+
},
102+
".cm-tooltip-autocomplete": {
103+
border: "1px solid #e5e7eb",
104+
borderRadius: "6px",
105+
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
106+
backgroundColor: "#fff",
107+
},
108+
".cm-tooltip-autocomplete ul li": {
109+
padding: "4px 8px",
110+
},
111+
".cm-tooltip-autocomplete ul li[aria-selected]": {
112+
backgroundColor: "#f3f4f6",
113+
},
114+
".cm-diagnostic-error": {
115+
borderBottom: "2px wavy #ef4444",
116+
},
117+
".cm-lintPoint-error:after": {
118+
borderBottomColor: "#ef4444",
119+
},
120+
}),
121+
[],
122+
);
123+
124+
return (
125+
<CodeMirror
126+
value={value}
127+
onChange={onChange}
128+
placeholder={placeholder}
129+
readOnly={readOnly}
130+
basicSetup={{
131+
lineNumbers: false,
132+
foldGutter: false,
133+
highlightActiveLineGutter: false,
134+
highlightActiveLine: false,
135+
}}
136+
extensions={[theme, ...extensions]}
137+
height="100%"
138+
/>
139+
);
140+
}
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import {
2+
type Completion,
3+
type CompletionContext,
4+
type CompletionResult,
5+
type CompletionSource,
6+
} from "@codemirror/autocomplete";
7+
import { closePercentBrace, jinja } from "@codemirror/lang-jinja";
8+
import { type Diagnostic, linter } from "@codemirror/lint";
9+
import { type Extension, RangeSetBuilder } from "@codemirror/state";
10+
import {
11+
Decoration,
12+
type DecorationSet,
13+
type EditorView,
14+
ViewPlugin,
15+
type ViewUpdate,
16+
} from "@codemirror/view";
17+
18+
function filterCompletionSource(filters: string[]): CompletionSource {
19+
const filterCompletions: Completion[] = filters.map((f) => ({
20+
label: f,
21+
type: "function",
22+
detail: "filter",
23+
}));
24+
25+
return (context: CompletionContext): CompletionResult | null => {
26+
const { state, pos } = context;
27+
const textBefore = state.sliceDoc(Math.max(0, pos - 50), pos);
28+
29+
const pipeMatch = textBefore.match(/\|\s*(\w*)$/);
30+
if (pipeMatch) {
31+
const word = context.matchBefore(/\w*/);
32+
return {
33+
from: word?.from ?? pos,
34+
options: filterCompletions,
35+
validFor: /^\w*$/,
36+
};
37+
}
38+
39+
return null;
40+
};
41+
}
42+
43+
export function jinjaLanguage(
44+
variables: string[],
45+
filters: string[],
46+
): Extension[] {
47+
const variableCompletions: Completion[] = variables.map((v) => ({
48+
label: v,
49+
type: "variable",
50+
}));
51+
52+
const jinjaSupport = jinja({
53+
variables: variableCompletions,
54+
});
55+
56+
const exts: Extension[] = [jinjaSupport, closePercentBrace];
57+
58+
if (filters.length > 0) {
59+
exts.push(
60+
jinjaSupport.language.data.of({
61+
autocomplete: filterCompletionSource(filters),
62+
}),
63+
);
64+
}
65+
66+
return exts;
67+
}
68+
69+
const readonlyMark = Decoration.mark({ class: "cm-readonly-region" });
70+
71+
export function readonlyVisuals(
72+
getRanges: () => Array<{ from: number; to: number }>,
73+
): Extension {
74+
return ViewPlugin.fromClass(
75+
class {
76+
decorations: DecorationSet;
77+
78+
constructor(view: EditorView) {
79+
this.decorations = this.buildDecorations(view);
80+
}
81+
82+
buildDecorations(_view: EditorView): DecorationSet {
83+
const builder = new RangeSetBuilder<Decoration>();
84+
const ranges = getRanges().sort((a, b) => a.from - b.from);
85+
86+
for (const { from, to } of ranges) {
87+
builder.add(from, to, readonlyMark);
88+
}
89+
90+
return builder.finish();
91+
}
92+
93+
update(update: ViewUpdate) {
94+
if (update.docChanged || update.viewportChanged) {
95+
this.decorations = this.buildDecorations(update.view);
96+
}
97+
}
98+
},
99+
{ decorations: (v) => v.decorations },
100+
);
101+
}
102+
103+
const STATEMENT_REGEX = /\{%[\s\S]*?%\}/g;
104+
105+
interface JinjaBlock {
106+
type: "if" | "for" | "block" | "macro";
107+
keyword: string;
108+
start: number;
109+
end: number;
110+
}
111+
112+
export function jinjaLinter(): Extension {
113+
return linter((view) => {
114+
const diagnostics: Diagnostic[] = [];
115+
const doc = view.state.doc.toString();
116+
117+
let pos = 0;
118+
119+
while (pos < doc.length) {
120+
if (doc.slice(pos, pos + 2) === "{{") {
121+
const endPos = doc.indexOf("}}", pos + 2);
122+
if (endPos === -1) {
123+
diagnostics.push({
124+
from: pos,
125+
to: pos + 2,
126+
severity: "error",
127+
message: "Unclosed expression: missing }}",
128+
});
129+
break;
130+
}
131+
pos = endPos + 2;
132+
continue;
133+
}
134+
135+
if (doc.slice(pos, pos + 2) === "{%") {
136+
const endPos = doc.indexOf("%}", pos + 2);
137+
if (endPos === -1) {
138+
diagnostics.push({
139+
from: pos,
140+
to: pos + 2,
141+
severity: "error",
142+
message: "Unclosed statement: missing %}",
143+
});
144+
break;
145+
}
146+
pos = endPos + 2;
147+
continue;
148+
}
149+
150+
if (doc.slice(pos, pos + 2) === "{#") {
151+
const endPos = doc.indexOf("#}", pos + 2);
152+
if (endPos === -1) {
153+
diagnostics.push({
154+
from: pos,
155+
to: pos + 2,
156+
severity: "error",
157+
message: "Unclosed comment: missing #}",
158+
});
159+
break;
160+
}
161+
pos = endPos + 2;
162+
continue;
163+
}
164+
165+
pos++;
166+
}
167+
168+
const blockStack: JinjaBlock[] = [];
169+
170+
const openingKeywords = ["if", "for", "block", "macro"];
171+
const closingKeywords = ["endif", "endfor", "endblock", "endmacro"];
172+
173+
for (const match of doc.matchAll(STATEMENT_REGEX)) {
174+
if (match.index === undefined) continue;
175+
176+
const content = match[0].slice(2, -2).trim();
177+
const parts = content.split(/\s+/);
178+
const keyword = parts[0];
179+
180+
if (openingKeywords.includes(keyword)) {
181+
blockStack.push({
182+
type: keyword as JinjaBlock["type"],
183+
keyword,
184+
start: match.index,
185+
end: match.index + match[0].length,
186+
});
187+
} else if (closingKeywords.includes(keyword)) {
188+
const expectedOpening = keyword.slice(3);
189+
const lastBlock = blockStack.pop();
190+
191+
if (!lastBlock) {
192+
diagnostics.push({
193+
from: match.index,
194+
to: match.index + match[0].length,
195+
severity: "error",
196+
message: `Unexpected ${keyword}: no matching opening block`,
197+
});
198+
} else if (lastBlock.type !== expectedOpening) {
199+
diagnostics.push({
200+
from: match.index,
201+
to: match.index + match[0].length,
202+
severity: "error",
203+
message: `Mismatched block: expected end${lastBlock.type}, found ${keyword}`,
204+
});
205+
}
206+
} else if (keyword === "elif" || keyword === "else") {
207+
if (
208+
blockStack.length === 0 ||
209+
blockStack[blockStack.length - 1].type !== "if"
210+
) {
211+
diagnostics.push({
212+
from: match.index,
213+
to: match.index + match[0].length,
214+
severity: "error",
215+
message: `${keyword} outside of if block`,
216+
});
217+
}
218+
}
219+
}
220+
221+
for (const unclosed of blockStack) {
222+
diagnostics.push({
223+
from: unclosed.start,
224+
to: unclosed.end,
225+
severity: "error",
226+
message: `Unclosed ${unclosed.keyword} block: missing end${unclosed.type}`,
227+
});
228+
}
229+
230+
return diagnostics;
231+
});
232+
}

0 commit comments

Comments
 (0)