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

View File

@@ -693,9 +693,40 @@ async function deleteAdminExtension(id, name) {
} catch (e) { UI.toast(e.message, 'error'); }
}
// Track CM6 editor instances per extension edit form
var _extEditors = {};
// Listen for theme changes to update CM6 editors
if (typeof Events !== 'undefined') {
Events.on('theme.changed', (data) => {
const isDark = data.resolved === 'dark';
for (const editors of Object.values(_extEditors)) {
editors.manifest?.setDarkMode(isDark);
editors.script?.setDarkMode(isDark);
}
});
// Listen for keymap changes to update CM6 editors in real time
Events.on('keymap.changed', (data) => {
const mode = data.mode || 'standard';
for (const editors of Object.values(_extEditors)) {
editors.manifest?.setKeymap(mode);
editors.script?.setKeymap(mode);
}
});
}
function editAdminExtension(id) {
// Close any existing edit form
document.querySelectorAll('.ext-edit-form').forEach(el => el.remove());
// Close any existing edit form (and destroy CM6 instances)
document.querySelectorAll('.ext-edit-form').forEach(el => {
const eid = el.dataset.extId;
if (_extEditors[eid]) {
_extEditors[eid].manifest?.destroy();
_extEditors[eid].script?.destroy();
delete _extEditors[eid];
}
el.remove();
});
const ext = (UI._adminExtensions || []).find(e => e.id === id);
if (!ext) return UI.toast('Extension not found', 'error');
@@ -716,6 +747,15 @@ function editAdminExtension(id) {
</div>`
: '';
// Use container divs instead of textareas when CM6 is available
const useCM6 = !!window.CM?.codeEditor;
const manifestEditor = useCM6
? `<div id="extEdit-manifest-${id}" style="width:100%;min-height:180px;border:1px solid var(--border);border-radius:var(--radius,4px);overflow:hidden"></div>`
: `<textarea id="extEdit-manifest-${id}" rows="8" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2">${esc(manifestJSON)}</textarea>`;
const scriptEditor = useCM6
? `<div id="extEdit-script-${id}" style="width:100%;min-height:320px;border:1px solid var(--border);border-radius:var(--radius,4px);overflow:hidden"></div>`
: `<textarea id="extEdit-script-${id}" rows="16" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2;white-space:pre">${esc(script)}</textarea>`;
const formHTML = `
<div class="ext-edit-form" data-ext-id="${id}" style="border-top:1px solid var(--border);padding:12px 0;margin-top:8px">
${systemWarning}
@@ -729,14 +769,14 @@ function editAdminExtension(id) {
</div>
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Manifest JSON <span class="text-muted">(without _script)</span></label>
<textarea id="extEdit-manifest-${id}" rows="8" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2">${esc(manifestJSON)}</textarea>
${manifestEditor}
</div>
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Script <span class="text-muted">(${script.length.toLocaleString()} chars)</span></label>
<textarea id="extEdit-script-${id}" rows="16" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2;white-space:pre">${esc(script)}</textarea>
${scriptEditor}
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-small" onclick="this.closest('.ext-edit-form').remove()">Cancel</button>
<button class="btn-small" onclick="_closeExtEditForm('${id}')">Cancel</button>
<button class="btn-small btn-primary" onclick="saveAdminExtension('${id}')">Save Changes</button>
</div>
</div>
@@ -748,42 +788,80 @@ function editAdminExtension(id) {
if (row.querySelector(`[onclick*="editAdminExtension('${id}')"]`)) {
row.insertAdjacentHTML('afterend', formHTML);
// Tab key inserts tab in script textarea
const scriptEl = document.getElementById(`extEdit-script-${id}`);
if (scriptEl) {
scriptEl.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const start = scriptEl.selectionStart;
const end = scriptEl.selectionEnd;
scriptEl.value = scriptEl.value.substring(0, start) + ' ' + scriptEl.value.substring(end);
scriptEl.selectionStart = scriptEl.selectionEnd = start + 4;
}
});
if (useCM6) {
// Get user keybinding preference
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const keymapMode = prefs.editorKeymap || 'standard';
// Initialize CM6 editors
_extEditors[id] = {
manifest: CM.codeEditor(document.getElementById(`extEdit-manifest-${id}`), {
language: 'json',
value: manifestJSON,
lineNumbers: true,
keymap: keymapMode,
}),
script: CM.codeEditor(document.getElementById(`extEdit-script-${id}`), {
language: 'javascript',
value: script,
lineNumbers: true,
keymap: keymapMode,
}),
};
} else {
// Fallback: Tab key inserts tab in script textarea
const scriptEl = document.getElementById(`extEdit-script-${id}`);
if (scriptEl) {
scriptEl.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const start = scriptEl.selectionStart;
const end = scriptEl.selectionEnd;
scriptEl.value = scriptEl.value.substring(0, start) + ' ' + scriptEl.value.substring(end);
scriptEl.selectionStart = scriptEl.selectionEnd = start + 4;
}
});
}
}
break;
}
}
}
function _closeExtEditForm(id) {
if (_extEditors[id]) {
_extEditors[id].manifest?.destroy();
_extEditors[id].script?.destroy();
delete _extEditors[id];
}
document.querySelector(`.ext-edit-form[data-ext-id="${id}"]`)?.remove();
}
async function saveAdminExtension(id) {
const nameEl = document.getElementById(`extEdit-name-${id}`);
const descEl = document.getElementById(`extEdit-desc-${id}`);
const manifestEl = document.getElementById(`extEdit-manifest-${id}`);
const scriptEl = document.getElementById(`extEdit-script-${id}`);
if (!nameEl || !manifestEl || !scriptEl) return;
// Read from CM6 if available, otherwise from textarea
const editors = _extEditors[id];
const manifestText = editors?.manifest
? editors.manifest.getValue()
: document.getElementById(`extEdit-manifest-${id}`)?.value;
const scriptText = editors?.script
? editors.script.getValue()
: document.getElementById(`extEdit-script-${id}`)?.value;
if (!nameEl || manifestText == null || scriptText == null) return;
// Parse manifest and merge _script back in
let manifest;
try {
manifest = JSON.parse(manifestEl.value.trim() || '{}');
manifest = JSON.parse((manifestText || '').trim() || '{}');
} catch (e) {
return UI.toast('Invalid manifest JSON: ' + e.message, 'error');
}
const script = scriptEl.value;
if (script.trim()) {
manifest._script = script;
if (scriptText.trim()) {
manifest._script = scriptText;
}
try {
@@ -793,6 +871,7 @@ async function saveAdminExtension(id) {
manifest: manifest,
});
UI.toast('Extension updated — reload page to apply changes');
_closeExtEditForm(id);
await UI.loadAdminExtensions();
} catch (e) {
UI.toast(e.message, 'error');

View File

@@ -721,7 +721,6 @@ function initAttachments() {
function _initAttachmentListeners() {
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
const messageInput = document.getElementById('messageInput');
const chatArea = document.getElementById('chatMessages');
const lightbox = document.getElementById('lightbox');
@@ -741,8 +740,12 @@ function _initAttachmentListeners() {
}
// ── Smart Paste ─────────────────────────
if (messageInput) {
messageInput.addEventListener('paste', (e) => {
// Attach to the active input element (CM6 contentDOM or textarea)
const pasteTarget = (typeof ChatInput !== 'undefined' && ChatInput.getDom())
? ChatInput.getDom()
: document.getElementById('messageInput');
if (pasteTarget) {
pasteTarget.addEventListener('paste', (e) => {
if (!App.storageConfigured) return; // fall through to normal paste
const items = [...(e.clipboardData?.items || [])];

View File

@@ -4,6 +4,75 @@
// Chat management, send, stream, regenerate, edit, branch,
// summarize, per-chat model persistence.
// ── Chat Input Abstraction ──────────────────
// Wraps CM6 editor (when available) or fallback textarea.
// All code that reads/writes the message input goes through this.
const ChatInput = {
_editor: null, // CM6 instance, set during init
_textarea: null, // fallback textarea element
getValue() {
if (this._editor) return this._editor.getValue();
return this._textarea?.value || '';
},
setValue(text) {
if (this._editor) {
this._editor.setValue(text || '');
} else if (this._textarea) {
this._textarea.value = text || '';
this._textarea.style.height = 'auto';
}
},
focus() {
if (this._editor) this._editor.focus();
else this._textarea?.focus();
},
/** Get the DOM element (for paste listeners, etc.) */
getDom() {
if (this._editor) return this._editor.getView().contentDOM;
return this._textarea;
},
/** Get the wrapper DOM element (for resize, etc.) */
getWrapDom() {
if (this._editor) return this._editor.getView().dom;
return this._textarea;
},
/** Initialize — try CM6, fall back to textarea */
init() {
this._textarea = document.getElementById('messageInput');
const wrap = document.getElementById('messageInputWrap');
if (window.CM?.chatInput && wrap) {
// Hide the textarea, create CM6 editor in its place
this._textarea.style.display = 'none';
this._editor = CM.chatInput(wrap, {
placeholder: 'Send a message...',
onSubmit: () => sendMessage(),
onChange: () => updateInputTokens(),
maxHeight: 200,
});
DebugLog?.push?.('chat', '[CM6] Chat input initialized');
} else {
// Fallback: textarea with manual event handling
DebugLog?.push?.('chat', '[CM6] Not available — using textarea fallback');
this._textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
this._textarea.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
updateInputTokens();
});
}
},
};
// ── Summarize & Continue ──────────────────
async function summarizeAndContinue() {
@@ -266,7 +335,7 @@ async function newChat() {
Tokens._warningDismissed = false;
updateContextWarning(); updateChatTokenCount();
updateInputTokens();
document.getElementById('messageInput').focus();
ChatInput.focus();
if (window.innerWidth <= 768) {
document.getElementById('sidebar').classList.add('collapsed');
const ov = document.getElementById('sidebarOverlay');
@@ -377,15 +446,13 @@ function startRenameChat(chatId) {
// ── Send Message ─────────────────────────────
async function sendMessage() {
const input = document.getElementById('messageInput');
const text = input.value.trim();
const text = ChatInput.getValue().trim();
const hasAttachments = hasStagedAttachments();
// Need text or attachments, not generating, not blocked by uploads
if ((!text && !hasAttachments) || App.isGenerating || isSendBlocked()) return;
input.value = '';
input.style.height = 'auto';
ChatInput.setValue('');
// Snapshot attachment IDs before clearing staged state
const attachmentIds = getStagedAttachmentIds();
@@ -749,16 +816,8 @@ function _initChatListeners() {
UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success');
});
// Input
const input = document.getElementById('messageInput');
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
input.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
updateInputTokens();
});
// Input — CM6 or textarea fallback
ChatInput.init();
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {

View File

@@ -272,6 +272,11 @@ const DebugLog = {
snap.extensions = Extensions.debug();
}
// CM6 editor state
snap.cm6 = window.CM
? { version: window.CM.version, languages: Object.keys(window.CM.languages || {}) }
: { available: false };
return snap;
},

View File

@@ -69,8 +69,7 @@ function updateInputTokens() {
const el = document.getElementById('inputTokenCount');
if (!el) return;
const input = document.getElementById('messageInput');
const inputText = input?.value || '';
const inputText = (typeof ChatInput !== 'undefined') ? ChatInput.getValue() : '';
const inputTokens = Tokens.estimate(inputText);
if (!inputText.trim()) {

View File

@@ -11,17 +11,30 @@ Object.assign(UI, {
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const scale = prefs.scale || 100;
const msgFont = prefs.msgFont || 14;
const theme = prefs.theme || 'dark';
const keymap = prefs.editorKeymap || 'standard';
const scaleEl = document.getElementById('settingsScale');
const msgFontEl = document.getElementById('settingsMsgFont');
if (scaleEl) { scaleEl.value = scale; document.getElementById('scaleValue').textContent = scale + '%'; }
if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; }
// Highlight active theme button
document.querySelectorAll('#themeToggle .theme-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === theme);
});
// Highlight active keymap button
document.querySelectorAll('#keymapToggle .theme-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.keymap === keymap);
});
},
initAppearance() {
// Load saved prefs on startup
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14);
UI.applyTheme(prefs.theme || 'dark');
// Live preview on slider change
const scaleEl = document.getElementById('settingsScale');
@@ -36,6 +49,70 @@ Object.assign(UI, {
document.getElementById('msgFontValue').textContent = v + 'px';
UI.applyAppearance(parseInt(scaleEl?.value || 100), v);
});
// Theme toggle buttons
document.querySelectorAll('#themeToggle .theme-btn').forEach(btn => {
btn.addEventListener('click', () => {
const theme = btn.dataset.theme;
UI.applyTheme(theme);
document.querySelectorAll('#themeToggle .theme-btn').forEach(b =>
b.classList.toggle('active', b === btn)
);
const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
p.theme = theme;
localStorage.setItem('cs-appearance', JSON.stringify(p));
});
});
// Editor keymap toggle buttons
document.querySelectorAll('#keymapToggle .theme-btn').forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.keymap;
document.querySelectorAll('#keymapToggle .theme-btn').forEach(b =>
b.classList.toggle('active', b === btn)
);
// Persist
const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
p.editorKeymap = mode;
localStorage.setItem('cs-appearance', JSON.stringify(p));
// Notify open editors
if (typeof Events !== 'undefined' && Events.emit) {
Events.emit('keymap.changed', { mode });
}
});
});
// System theme change listener (for "system" mode)
UI._systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
UI._systemThemeQuery.addEventListener('change', () => {
const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
if (p.theme === 'system') UI.applyTheme('system');
});
},
/**
* Apply theme to the document.
* @param {'light'|'dark'|'system'} mode
*/
applyTheme(mode) {
let resolved = mode;
if (mode === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (resolved === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
} else {
document.documentElement.removeAttribute('data-theme');
}
// Publish event for CM6 editors and extensions
if (typeof Events !== 'undefined' && Events.emit) {
Events.emit('theme.changed', { mode, resolved });
}
},
/** Get the currently resolved theme ('dark' or 'light') */
getResolvedTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
},
applyAppearance(scale, msgFont) {