anvilsign in

collin/strudel-claude

1// The system prompt, opened up for editing. The server's PROMPT.md is the
2// default and stays the server's; an edit made here is this browser's alone,
3// kept in localStorage and sent with each request. Nothing is sent while the
4// text still matches the default, so the common case remains "whatever the
5// server ships" and a server-side prompt update isn't frozen out by a stale copy.
6//
7// It's a full-screen editor rather than one of the header popovers because the
8// prompt is ~23 KB of Markdown — the popovers are sized for a key or a dropdown.
9import { closeAll } from './panel.js';
10
11const KEY = 'strudel-claude-system-prompt';
12
13// The server's default, once fetched. Null until then — which is also the
14// "backend is down" answer, so the editor stays read-only rather than offering
15// a Reset it can't perform.
16let defaultPrompt = null;
17
18const stored = () => {
19 try {
20 return localStorage.getItem(KEY) || '';
21 } catch {
22 return '';
23 }
24};
25
26const store = (text) => {
27 try {
28 if (text) localStorage.setItem(KEY, text);
29 else localStorage.removeItem(KEY);
30 } catch {
31 /* storage blocked — the edit just won't survive a reload */
32 }
33};
34
35/** The override to send, or '' to let the server use PROMPT.md. */
36export const getSystemPrompt = () => stored();
37
38export function initPromptUI() {
39 const button = document.getElementById('prompt-btn');
40 const label = document.getElementById('prompt-state');
41 const modal = document.getElementById('prompt-modal');
42 const field = document.getElementById('prompt-text');
43 const note = document.getElementById('prompt-note');
44 const saveBtn = document.getElementById('prompt-save');
45 const resetBtn = document.getElementById('prompt-reset');
46 const closeBtn = document.getElementById('prompt-close');
47
48 const render = () => {
49 const custom = Boolean(stored());
50 label.textContent = custom ? 'Prompt (edited)' : 'System prompt';
51 button.classList.toggle('prompt-custom', custom);
52 resetBtn.hidden = !custom || defaultPrompt === null;
53 note.textContent =
54 defaultPrompt === null
55 ? "Couldn't load the default prompt from the server — save will still work, but there's nothing to reset to."
56 : custom
57 ? 'Edited — this copy is stored in this browser and sent with each request.'
58 : "The server's PROMPT.md. Edit and save to use your own version instead.";
59 };
60
61 const open = () => {
62 closeAll(); // a header popover left open would float over the overlay
63 field.value = stored() || defaultPrompt || '';
64 modal.hidden = false;
65 button.setAttribute('aria-expanded', 'true');
66 field.focus();
67 // Focusing a textarea leaves the caret at the end, which for a document this
68 // long means opening on the last line. Start at the top, where the prompt does.
69 field.setSelectionRange(0, 0);
70 field.scrollTop = 0;
71 render();
72 };
73
74 const close = () => {
75 modal.hidden = true;
76 button.setAttribute('aria-expanded', 'false');
77 button.focus();
78 };
79
80 const save = () => {
81 const text = field.value.trim();
82 // Saving the default verbatim means "no override" — otherwise the copy
83 // would silently pin this browser to today's PROMPT.md forever.
84 store(text && text !== defaultPrompt?.trim() ? text : '');
85 render();
86 close();
87 };
88
89 button.addEventListener('click', (e) => {
90 e.stopPropagation();
91 if (modal.hidden) open();
92 else close();
93 });
94
95 saveBtn.addEventListener('click', save);
96 closeBtn.addEventListener('click', close);
97 resetBtn.addEventListener('click', () => {
98 store('');
99 field.value = defaultPrompt || '';
100 render();
101 });
102
103 // Click the backdrop (not the dialog) to dismiss.
104 modal.addEventListener('click', (e) => {
105 if (e.target === modal) close();
106 });
107 modal.addEventListener('keydown', (e) => {
108 // The global Escape handler closes popovers; this is a modal, so it swallows
109 // the key rather than letting both fire.
110 e.stopPropagation();
111 if (e.key === 'Escape') close();
112 else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) save();
113 });
114
115 render();
116
117 fetch('/api/prompt', { headers: { 'ngrok-skip-browser-warning': 'true' } })
118 .then((r) => (r.ok ? r.json() : null))
119 .then((data) => {
120 if (typeof data?.prompt !== 'string') return;
121 defaultPrompt = data.prompt;
122 // If the editor is already open showing an empty default, fill it in.
123 if (!modal.hidden && !field.value) field.value = defaultPrompt;
124 render();
125 })
126 .catch(() => render());
127}