Add slash commands, and stop Escape and recall from leaving a stale draft
This commit is contained in:
@@ -792,6 +792,69 @@ vv_ai_profiles_script();
|
||||
}
|
||||
function clearDraft() { try { localStorage.removeItem(DRAFT_KEY); } catch (_) {} }
|
||||
|
||||
// ── Slash commands ───────────────────────────────────────────────────
|
||||
// Typed rather than clicked, for the panels that live on a screen with no pointer near them.
|
||||
// Everything here is also reachable by mouse or Alt-key; this is a third route to the same
|
||||
// controls, never the only route to any of them.
|
||||
function runCommand(raw) {
|
||||
const parts = raw.slice(1).trim().split(/\s+/);
|
||||
const cmd = (parts.shift() || '').toLowerCase();
|
||||
const arg = parts.join(' ');
|
||||
const clear = () => { $('input').value = ''; clearDraft(); };
|
||||
|
||||
// Toggles a checkbox the page owns, if it gave us one. Reports the resulting state rather
|
||||
// than the requested one, so a command against a control this profile does not have says so.
|
||||
const flip = (elId, label) => {
|
||||
const c = elId ? document.getElementById(elId) : null;
|
||||
if (!c) { noteLine(label + ' is not available on this profile.'); return; }
|
||||
c.checked = !c.checked;
|
||||
noteLine(label + (c.checked ? ' on' : ' off') + ' for the next question.');
|
||||
};
|
||||
|
||||
switch (cmd) {
|
||||
case 'new': clear(); newChat(); $('input').focus(); return;
|
||||
case 'saved': { clear(); const hb = $('hist-b'); if (hb) hb.click(); return; }
|
||||
case 'web': clear(); flip(o.webEl, 'Web search'); return;
|
||||
case 'think': clear(); flip(o.thinkEl, 'Reasoning'); return;
|
||||
case 'retry': clear(); lastAction('retry'); return;
|
||||
case 'edit': clear(); lastAction('edit'); return;
|
||||
|
||||
case 'profile': {
|
||||
clear();
|
||||
// Read from the rendered menu rather than a second list in JS — one source of truth for
|
||||
// which profiles exist, and it cannot drift from what the picker offers.
|
||||
const opts = Array.from(document.querySelectorAll(`#${P}-menu [data-prof]`))
|
||||
.map(b => b.dataset.prof);
|
||||
if (!arg) { noteLine('Profiles: ' + opts.join(', ')); return; }
|
||||
const want = opts.find(x => x.toLowerCase() === arg.toLowerCase())
|
||||
|| opts.find(x => x.toLowerCase().startsWith(arg.toLowerCase()));
|
||||
if (!want) { noteLine('No profile matching "' + arg + '". Try: ' + opts.join(', ')); return; }
|
||||
setProfile(want);
|
||||
noteLine('Profile is now ' + want + '.');
|
||||
return;
|
||||
}
|
||||
|
||||
case 'scope': {
|
||||
clear();
|
||||
const s = (typeof o.scope === 'function' ? o.scope() : (o.scope || ''));
|
||||
noteLine(s ? ('Scoped to ' + s) : 'Not scoped to anything in particular.');
|
||||
return;
|
||||
}
|
||||
|
||||
case 'help': case '?': {
|
||||
clear();
|
||||
noteLine('/new /saved /retry /edit /profile [name] /scope /web /think /help');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown commands are not sent to the model. A typo'd command is a typo, and answering it
|
||||
// as a question wastes 40 seconds to say it does not understand.
|
||||
noteLine('No such command: /' + cmd + ' — try /help');
|
||||
$('input').value = '';
|
||||
clearDraft();
|
||||
}
|
||||
|
||||
// ── Ask / poll ───────────────────────────────────────────────────────
|
||||
function send() {
|
||||
// Never fail silently on a stuck flag. A turn that ends without finish() — a throw, a poll
|
||||
@@ -806,6 +869,15 @@ vv_ai_profiles_script();
|
||||
const q = $('input').value.trim();
|
||||
if (!q) return;
|
||||
|
||||
// Slash commands are handled before anything else, so one is never sent to the model as a
|
||||
// question. Checked ahead of beforeSend too: a page claiming typed text wants questions,
|
||||
// not "/new".
|
||||
//
|
||||
// The pattern is deliberately strict — a bare word of letters, then whitespace or the end.
|
||||
// "/mnt/user/appdata is filling up" is a perfectly ordinary question on this machine, and a
|
||||
// looser "starts with a slash" test swallowed it as a command.
|
||||
if (/^\/[a-z?]+(\s|$)/i.test(q)) { runCommand(q); return; }
|
||||
|
||||
// A page may claim what was typed instead of asking it. The Scheduler does this once, after
|
||||
// an offer is accepted, when the next thing typed is the fix being recorded rather than a
|
||||
// question. Checked here rather than in a wrapper the page calls, because Ask and Ctrl+Enter
|
||||
@@ -1400,6 +1472,9 @@ vv_ai_profiles_script();
|
||||
e.preventDefault();
|
||||
inp.value = messages[i].content;
|
||||
inp.setSelectionRange(inp.value.length, inp.value.length);
|
||||
// Setting .value in script fires no input event, so the draft would still hold
|
||||
// whatever was there before the recall.
|
||||
saveDraft();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1411,7 +1486,11 @@ vv_ai_profiles_script();
|
||||
|| (histWrap && histWrap.classList.contains('open'));
|
||||
closeMenus();
|
||||
// Only clears once there is no menu left to close, so one Escape never does two things.
|
||||
if (!wasOpen && e.target === inp && inp.value !== '') { e.preventDefault(); inp.value = ''; }
|
||||
// clearDraft too, or Escape would empty the box and the text would reappear on the next
|
||||
// tab swap — an Escape that does not actually discard anything.
|
||||
if (!wasOpen && e.target === inp && inp.value !== '') {
|
||||
e.preventDefault(); inp.value = ''; clearDraft();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1599,6 +1678,7 @@ function vv_ai_chat_keys(): array {
|
||||
return [
|
||||
['keys' => ['Ctrl', 'Enter'], 'what' => 'Ask', 'id' => 'send'],
|
||||
['keys' => ['↑'], 'what' => 'Your last question, to edit', 'id' => 'recall'],
|
||||
['keys' => ['/'], 'what' => 'Command — /help lists them', 'id' => 'slash'],
|
||||
['keys' => ['Esc'], 'what' => 'Close a menu, or clear the box', 'id' => 'esc'],
|
||||
['keys' => ['Alt', 'N'], 'what' => 'New conversation', 'id' => 'new'],
|
||||
['keys' => ['Alt', 'E'], 'what' => 'Expand or collapse', 'id' => 'grow'],
|
||||
|
||||
Reference in New Issue
Block a user