diff --git a/CLAUDE.md b/CLAUDE.md index c5b984b..c88ec60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ Works: - **Providers:** apps from `shell.appLibrary`, the whole Omarchy menu (about 400 items, with breadcrumbs, guards, ✓ state and font/power-profile provider rows), and the built-in "Omarchy Menu". - **Ranking state:** frecency (+100 per launch, 10-day half-life) and learned search terms in `db/frecency.json`. Aliases (exact → top; duplicates rejected), favourites (Ctrl+Shift+F, reorder Ctrl+Alt+↑↓) and disabled commands live in `db/settings.json` `commands`. - **Navigation:** pushed Omarchy submenus (search within them), the alias editor, back with Esc / Ctrl+[ / Backspace on an empty query / the header arrow, and Shift+Esc to pop to root. -- **Keyboard:** Ctrl+K action panel (filterable, per-row shortcuts), ↵ / Ctrl+↵ / Ctrl+Shift+↵ for the first three actions, Ctrl+1–9, Ctrl+↑↓ to jump sections, PageUp/PageDown, Ctrl+N/P (emacs), ↑ on an empty root for search history, Ctrl+Shift+C copy, Alt+Shift+D disable, Ctrl + / − / 0 zoom. +- **Keyboard:** Ctrl+K action panel (filterable, per-row shortcuts), ↵ / Ctrl+↵ / Ctrl+Shift+↵ for the first three actions, Ctrl+1–9, Ctrl+↑↓ to jump sections, PageUp/PageDown, Ctrl+N/P (emacs), ↑ on an empty root for search history, Ctrl+Shift+C copy, Alt+Shift+D disable, Ctrl + / − / 0 zoom. Numpad digits, operators and decimal separators work in queries, aliases and action filters; keypad Enter and Ctrl shortcuts retain their actions. - **Feedback:** toasts in the footer, a HUD through Omarchy's OSD when the window closes, and a 2 px loading bar while guards and providers refresh (on every open, about 250 ms in the background). - **Window behaviour:** - The `escapeKeyBehavior` setting works both ways. @@ -133,7 +133,7 @@ Works: ↵ copies the answer (HUD), Ctrl+↵ pastes it through `omarchy-clipboard-paste-text`, Ctrl+Shift+C copies "question = answer". Each one saves to **Calculator History** (`db/calculator.json`, 4096 rows / 90 days): a built-in command and pushed view, searchable, Ctrl+D deletes an entry, Ctrl+Shift+D deletes all. RPC: `calculator.calculate|historyGetAll|historyDelete|historyDeleteAll|refreshCurrencies|getCurrencies`. A bare number, word or quantity (`42`, `firefox`, `5 kg`) never produces a row, because the section takes ↵. - **Latency:** show → mapped in 37–44 ms, hide in 9–25 ms (`tests/smoke.sh`). -- **Tests:** root search, calculator and default-agent hand-off coverage (`node --test tests/*.test.js`). +- **Tests:** root search, calculator, default-agent hand-off and launcher typing/keypad shortcut coverage (`node --test tests/*.test.js`). **Default-agent hand-off (fork, 2026-10-20):** user-authorized addition outside the original phase plan. The root fallback "Ask default agent" and Ctrl+Alt+Enter pass the query to `omarchy-agent --prompt` as a literal argv element. `AgentLauncher.qml` checks the configured agent/terminal with a bounded, question-free preflight, prevents overlapping submissions and cancels on hide/unload. `Launcher.qml` dismisses the overlay before launching through `Util.execArgv`. Errors leave the query visible. There are no new dependencies, model APIs, credentials or question-history writes. Existing CLI approval behaviour is inherited. Portable checks do not establish live terminal focus, authentication, or provider compatibility; verify those on Omarchy. diff --git a/plugin/ui/LauncherWindow.qml b/plugin/ui/LauncherWindow.qml index 8053637..a6a7cfb 100644 --- a/plugin/ui/LauncherWindow.qml +++ b/plugin/ui/LauncherWindow.qml @@ -393,8 +393,10 @@ PanelWindow { } function isTyping(event) { + // Qt marks numpad digits and operators with KeypadModifier, not a shortcut. + var modifiers = event.modifiers & ~Qt.KeypadModifier return event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127 - && (event.modifiers === Qt.NoModifier || event.modifiers === Qt.ShiftModifier) + && (modifiers === Qt.NoModifier || modifiers === Qt.ShiftModifier) } Connections { diff --git a/tests/launcher-input.test.js b/tests/launcher-input.test.js new file mode 100644 index 0000000..23efead --- /dev/null +++ b/tests/launcher-input.test.js @@ -0,0 +1,110 @@ +const test = require("node:test") +const assert = require("node:assert/strict") +const fs = require("node:fs") +const vm = require("node:vm") + +// Run the actual QML handlers without a desktop or Qt dependency in CI. +const source = fs.readFileSync(require.resolve("../plugin/ui/LauncherWindow.qml"), "utf8") +const handlers = ["isTyping", "handleKey"].map((name) => { + const match = source.match(new RegExp(`^ function ${name}\\(event\\) \\{[\\s\\S]*?^ \\}`, "m")) + assert.ok(match, `missing QML handler ${name}`) + return match[0] +}).join("\n") + +// Qt::KeyboardModifier and Qt::Key values from qnamespace.h. +const Qt = { + NoModifier: 0, ShiftModifier: 0x02000000, ControlModifier: 0x04000000, + AltModifier: 0x08000000, MetaModifier: 0x10000000, + KeypadModifier: 0x20000000, GroupSwitchModifier: 0x40000000, + Key_Escape: 0x01000000, Key_Backspace: 0x01000003, + Key_Return: 0x01000004, Key_Enter: 0x01000005, + Key_Up: 0x01000013, Key_Down: 0x01000015, + Key_PageUp: 0x01000016, Key_PageDown: 0x01000017, + Key_Space: 32, Key_Plus: 43, Key_Minus: 45, Key_Equal: 61, + Key_0: 48, Key_1: 49, Key_9: 57, Key_BracketLeft: 91, + Key_C: 67, Key_D: 68, Key_F: 70, Key_K: 75, Key_N: 78, Key_P: 80 +} + +function input(kind = "root") { + const actions = [] + const panel = { filter: "", activateSelected() { actions.push("panel") } } + const window = { + view: { kind }, query: "", actionPanelOpen: false, + setQuery(text) { this.query = text }, + performNth(n) { actions.push(n) }, + nthItem(n) { return n - 1 }, + perform(action, index) { actions.push([action, index]) } + } + const api = vm.runInNewContext(`${handlers}\n({ isTyping, handleKey })`, { + Qt, window, actionPanel: panel, Util: { editsFilter() { return false } } + }) + window.isTyping = api.isTyping + return { ...api, window, panel, actions } +} + +function key(text, modifiers = Qt.KeypadModifier) { + return { text, key: text.toUpperCase().charCodeAt(0), modifiers } +} + +test("numpad digits, operators and decimal separators are printable", () => { + const { isTyping } = input() + for (const text of "0123456789+-/*.,") + for (const modifiers of [Qt.KeypadModifier, Qt.KeypadModifier | Qt.ShiftModifier]) + assert.equal(isTyping(key(text, modifiers)), true, text) +}) + +test("normal typing still accepts plain and Shift-modified text", () => { + const { isTyping } = input() + for (const text of "aA5+-/*., ") + for (const modifiers of [Qt.NoModifier, Qt.ShiftModifier]) + assert.equal(isTyping(key(text, modifiers)), true, text) +}) + +test("keypad flags do not turn shortcut modifiers into text", () => { + const { isTyping } = input() + for (const modifier of [Qt.ControlModifier, Qt.AltModifier, Qt.MetaModifier, Qt.GroupSwitchModifier]) + for (const extra of [0, Qt.KeypadModifier, Qt.KeypadModifier | Qt.ShiftModifier]) + assert.equal(isTyping(key("5", modifier | extra)), false) +}) + +test("empty text, control characters and multi-character text are not typing", () => { + const { isTyping } = input() + for (const text of ["", "\r", "\n", "\t", "\x1b", "\x7f", "12"]) + assert.equal(Boolean(isTyping(key(text))), false, JSON.stringify(text)) +}) + +test("numpad text reaches the root query and alias editor", () => { + for (const kind of ["root", "alias"]) { + const { handleKey, window, actions } = input(kind) + const text = kind === "root" ? "2+3*4-6/2+0.5" : "56" + for (const character of text) assert.equal(handleKey(key(character)), true) + assert.equal(window.query, text) + assert.deepEqual(actions, []) + } +}) + +test("numpad text filters actions without changing the root query", () => { + const { handleKey, window, panel, actions } = input() + window.query = "2+2" + window.actionPanelOpen = true + for (const text of "+5") assert.equal(handleKey(key(text)), true) + assert.equal(panel.filter, "+5") + assert.equal(window.query, "2+2") + assert.deepEqual(actions, []) +}) + +test("keypad Enter and Ctrl-number shortcuts keep their actions", () => { + for (const [modifiers, expected] of [ + [Qt.KeypadModifier, 0], + [Qt.KeypadModifier | Qt.ControlModifier, 1], + [Qt.KeypadModifier | Qt.ControlModifier | Qt.ShiftModifier, 2] + ]) { + const { handleKey, window, actions } = input() + assert.equal(handleKey({ key: Qt.Key_Enter, text: "\r", modifiers }), true) + assert.deepEqual(actions, [expected]) + assert.equal(window.query, "") + } + const { handleKey, actions } = input() + assert.equal(handleKey(key("1", Qt.ControlModifier | Qt.KeypadModifier)), true) + assert.deepEqual(actions, [["open", 0]]) +})