diff --git a/CHANGELOG.md b/CHANGELOG.md index 4267529..ae9a893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ ### Fixed +- **Destructive actions use an in-page confirmation, not a browser pop-up.** Deleting a chat thread or note, removing a model, deleting a custom mode, pulling an oversized model, and starting a benchmark all previously used the native `confirm()` dialog; they now use a styled in-dashboard modal that matches the rest of the UI. +- **The tray "Open Chat" entry shows your real hotkey.** It was hardcoded to `Ctrl+Shift+T` even after you rebound the key; it now reflects the configured `open_chat` binding (and updates when you change it). The default `open_chat` hotkey also moved off `Ctrl+Shift+T` (which collides with the browser "reopen closed tab") to `Ctrl+Alt+C`, mirroring the Alt-based note-capture key. + - **Hotkey actions no longer trample each other.** Grammar fix, note capture, and ask-in-chat share the clipboard; firing one while another's model call was in flight (10–30 s) could corrupt the clipboard save/restore dance, and a re-press of the same hotkey re-ran on stale state. A busy guard now makes them mutually exclusive — a second press gets a "still busy" toast instead. - **Your clipboard comes back immediately.** The grammar hotkey used to hold the captured selection in the clipboard for the whole model call; it is now restored within milliseconds (the result still lands in the clipboard for the paste), restores happen on *every* path including mid-capture errors, and a busy clipboard at restore time is retried instead of silently losing your copy. - The chat window now watches its parent via a kernel wait instead of spawning `tasklist` every 5 seconds forever; a hung toast PowerShell is killed instead of orphaned; the update-available dialog auto-dismisses after a minute. diff --git a/config/grammar_hotkey.config.example.json b/config/grammar_hotkey.config.example.json index 650151e..fd4f095 100644 --- a/config/grammar_hotkey.config.example.json +++ b/config/grammar_hotkey.config.example.json @@ -32,7 +32,7 @@ }, "hotkeys": { "grammar_fix": "^+g", - "open_chat": "^+t", + "open_chat": "^!c", "capture_note": "^!n", "ask_chat": "^+a" }, diff --git a/scripts/grammarFix.ahk b/scripts/grammarFix.ahk index fb2974c..a3ef994 100644 --- a/scripts/grammarFix.ahk +++ b/scripts/grammarFix.ahk @@ -19,13 +19,13 @@ chatHk := (*) => OpenWebDashboard("chat") noteHk := (*) => CaptureNote() askHk := (*) => AskWithSelection() Hotkey("^+g", gramHk) -Hotkey("^+t", chatHk) +Hotkey("^!c", chatHk) ; open chat — Ctrl+Alt+C. Was ^+t (Ctrl+Shift+T collides with the browser "reopen closed tab" and other apps); Alt is stable, mirrors note capture ^!n. Hotkey("^!n", noteHk) ; note capture — Ctrl+Alt+N. Was ^+n (keyboard ghosting on Shift+N for some users) and briefly ^+q (collides with Chrome's global "Quit Chrome" shortcut). Alt is stable + no app conflict. Hotkey("^+a", askHk) currentHotkeys := Map( "grammar_fix", "^+g", - "open_chat", "^+t", + "open_chat", "^!c", "capture_note", "^!n", "ask_chat", "^+a" ) @@ -41,7 +41,7 @@ hotkeyHandlers := Map( ; keys to turn off before applying any config-overridden bindings. lastRegistered := Map( "grammar_fix", "^+g", - "open_chat", "^+t", + "open_chat", "^!c", "capture_note", "^!n", "ask_chat", "^+a" ) diff --git a/scripts/ui/tray.ahk b/scripts/ui/tray.ahk index 513f8ca..4e90295 100644 --- a/scripts/ui/tray.ahk +++ b/scripts/ui/tray.ahk @@ -1,6 +1,9 @@ SetupTrayMenu_Impl() { + global currentHotkeys A_TrayMenu.Delete() - A_TrayMenu.Add("Open Chat`tCtrl+Shift+T", (*) => OpenWebDashboard_Impl("chat")) + ; Show the ACTUAL configured accelerator, not a hardcoded default — the user + ; can rebind open_chat in the Config tab. SetupTrayMenu re-runs on reload. + A_TrayMenu.Add("Open Chat`t" HumanHotkey(currentHotkeys["open_chat"]), (*) => OpenWebDashboard_Impl("chat")) A_TrayMenu.Add("Dashboard", (*) => OpenWebDashboard_Impl()) A_TrayMenu.Add() A_TrayMenu.Add("Quick toggles", BuildTogglesMenu_Impl()) diff --git a/scripts/ui/web/app.js b/scripts/ui/web/app.js index 505a0ec..f105908 100644 --- a/scripts/ui/web/app.js +++ b/scripts/ui/web/app.js @@ -30,6 +30,47 @@ function setStatus(id, message, ok = true) { el.className = ok ? "ok" : "bad"; } +// In-page confirmation modal. We never use native confirm()/alert()/prompt() — +// they break the dashboard's look and feel. Returns a Promise. All DOM +// via createElement/textContent (no innerHTML; CSP-safe). +function confirmDialog(message, okLabel = "Confirm") { + return new Promise((resolve) => { + const overlay = document.createElement("div"); + overlay.className = "modal-overlay"; + const box = document.createElement("div"); + box.className = "card modal-box"; + const msg = document.createElement("div"); + msg.className = "modal-msg"; + msg.textContent = message; // pre-wrap CSS preserves \n + const row = document.createElement("div"); + row.className = "card-actions modal-actions"; + const cancel = document.createElement("button"); + cancel.className = "btn"; + cancel.textContent = "Cancel"; + const ok = document.createElement("button"); + ok.className = "btn btn-danger"; + ok.textContent = okLabel; + row.append(cancel, ok); + box.append(msg, row); + overlay.append(box); + document.body.append(overlay); + const close = (val) => { + overlay.remove(); + document.removeEventListener("keydown", onKey); + resolve(val); + }; + function onKey(e) { + if (e.key === "Escape") close(false); + else if (e.key === "Enter") close(true); + } + cancel.addEventListener("click", () => close(false)); + ok.addEventListener("click", () => close(true)); + overlay.addEventListener("click", (e) => { if (e.target === overlay) close(false); }); + document.addEventListener("keydown", onKey); + ok.focus(); + }); +} + // Mirrors AHK HumanHotkey(): "^+g" -> "Ctrl+Shift+G". function humanHotkey(hk) { if (!hk) return "?"; @@ -393,7 +434,7 @@ async function moveNoteToBucket() { async function deleteCurrentNote() { if (!currentNoteRelpath) return; - if (!confirm("Delete this note from the vault?")) return; + if (!(await confirmDialog("Delete this note from the vault?", "Delete"))) return; try { const res = await action("note_delete", { relpath: currentNoteRelpath }); if (!res.ok) { setStatus("nr-status", res.error || "delete failed", false); return; } @@ -518,7 +559,7 @@ async function deleteCustomMode() { setStatus("cm-status", "⚠ Pick an existing custom mode to delete.", false); return; } - if (!window.confirm(`Delete custom mode '${id}'?`)) return; + if (!(await confirmDialog(`Delete custom mode '${id}'?`, "Delete"))) return; try { await action("apply_config_patch", { patch: { modes: { [id]: null } } }); setStatus("cm-status", `✅ Deleted '${id}'.`); @@ -755,7 +796,7 @@ async function setActiveModel() { async function removeModel() { const name = $("models-list").value; if (!name) return; - if (!window.confirm(`Remove model '${name}' from local storage?`)) return; + if (!(await confirmDialog(`Remove model '${name}' from local storage?`, "Remove"))) return; setStatus("config-status", `Removing ${name}…`); try { const out = await action("remove_model", { value: name }); @@ -777,7 +818,7 @@ async function pullModel() { const params = parseParamsB(name); if (modelBudget && params && params > modelBudget.max_params_b * 1.5) { const msg = `'${name}' looks like a ${params}B model — likely too big for this machine (${modelBudget.summary}). Pull anyway?`; - if (!window.confirm(msg)) return; + if (!(await confirmDialog(msg, "Pull anyway"))) return; } try { const state = await action("pull_start", { model: name }); @@ -873,7 +914,7 @@ async function runBenchmark() { const msg = benchProvider === "ollama" ? `Benchmark '${model}'?\n\nThis runs timed generations against Ollama for ~1–3 minutes. The server keeps serving, but responses will be slow during the run.` : `Benchmark '${model}'?\n\nThis runs flm bench for ~10–20 minutes, stops the server, and saturates the NPU. Hotkeys will be unresponsive until it finishes.`; - if (!window.confirm(msg)) return; + if (!(await confirmDialog(msg, "Run benchmark"))) return; try { await action("bench_start", { model }); setText("bench-status", benchProvider === "ollama" @@ -995,7 +1036,7 @@ function newChat() { } async function deleteChatThread(id) { - if (!confirm("Delete this conversation?")) return; + if (!(await confirmDialog("Delete this conversation?", "Delete"))) return; try { await action("chat_thread_delete", { thread_id: id }); if (id === chatThreadId) { chatThreadId = ""; renderTranscript([]); } diff --git a/scripts/ui/web/styles.css b/scripts/ui/web/styles.css index cc8dc42..5c1ef3b 100644 --- a/scripts/ui/web/styles.css +++ b/scripts/ui/web/styles.css @@ -556,3 +556,15 @@ footer { padding: 14px 24px 22px; } background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px; } + +/* ---- In-page confirm modal (replaces native confirm/alert) ---- */ +.modal-overlay { + position: fixed; inset: 0; z-index: 50; + background: rgba(0, 0, 0, 0.45); + display: flex; align-items: center; justify-content: center; padding: 20px; +} +.modal-box { max-width: 460px; width: 100%; box-shadow: var(--shadow-lift, 0 12px 32px rgba(0,0,0,0.35)); } +.modal-msg { white-space: pre-wrap; word-wrap: break-word; margin: 0 0 14px; line-height: 1.5; } +.modal-actions { justify-content: flex-end; } +.btn-danger { background: var(--warn); color: #fff; border-color: var(--warn); } +.btn-danger:hover { filter: brightness(1.08); } diff --git a/setup/defaults/grammar_hotkey.config.example.json b/setup/defaults/grammar_hotkey.config.example.json index 389bc17..b82d89f 100644 --- a/setup/defaults/grammar_hotkey.config.example.json +++ b/setup/defaults/grammar_hotkey.config.example.json @@ -24,7 +24,7 @@ }, "hotkeys": { "grammar_fix": "^+g", - "open_chat": "^+t", + "open_chat": "^!c", "capture_note": "^!n", "ask_chat": "^+a" },