From a323c592cb9239270e26737e2e0e9da5844984b4 Mon Sep 17 00:00:00 2001 From: Adam Matan Date: Fri, 10 Jul 2026 13:05:34 -0400 Subject: [PATCH] feat: prompt-triggered keyboard shortcuts and quick-fire palette Adds per-prompt keyboard shortcuts (a leader-key sequence, e.g. R then a letter/number) that fire a saved prompt straight into the focused or a picked agent, plus a command palette (Shift+Cmd+R) for browsing and firing prompts without memorizing keys. - src/lib/shortcuts.ts: effectiveTriggerKeys() reserves per-prompt overrides first, then auto-assigns free 1-9/a-z slots stably across enable/disable toggles. - src/lib/promptFire.ts: shared fireOrPickDestination() logic (focused vs. live-agent-tab resolution) extracted so the leader-key shortcut, the palette, and the dropdown all resolve destinations identically. - src/hooks/useShortcuts.ts: leader-key arm/disarm state machine, capture phase + stopImmediatePropagation to intercept the follow-up key before xterm/CodeMirror, 2s timeout and Escape to cancel. - src/components/dialogs/PromptDestinationDialog.tsx: modal picker extracted out of UnifiedBar.tsx for choosing a destination when a prompt can't auto-resolve one. - src/components/dialogs/PromptPalette.tsx: non-modal command palette for browsing/firing prompts by name. - src/store/prompts.ts, src/store/ui.ts: persist per-prompt triggerKeys and add promptFire/palette UI state. - src/components/settings/PromptLibrarySection.tsx: shortcut recorder UI for assigning/clearing per-prompt override keys. - docs/shortcuts.md: document the new shortcut system. Co-Authored-By: Claude Sonnet 5 --- docs/shortcuts.md | 13 ++ src/components/UnifiedBar.tsx | 150 +++-------------- src/components/dialogs/Dialogs.tsx | 12 ++ .../dialogs/PromptDestinationDialog.tsx | 134 +++++++++++++++ src/components/dialogs/PromptPalette.tsx | 156 ++++++++++++++++++ .../settings/PromptLibrarySection.tsx | 69 +++++++- src/hooks/useShortcuts.ts | 69 +++++++- src/lib/promptFire.ts | 49 ++++++ src/lib/shortcuts.ts | 8 + src/store/prompts.ts | 61 ++++++- src/store/ui.ts | 30 ++++ 11 files changed, 614 insertions(+), 137 deletions(-) create mode 100644 src/components/dialogs/PromptDestinationDialog.tsx create mode 100644 src/components/dialogs/PromptPalette.tsx create mode 100644 src/lib/promptFire.ts diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 1a52b98..3169691 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -16,3 +16,16 @@ ## Glyphs `bindingGlyphs(b)` returns `["⌥","⇧","⌘", key]`. Help modal uses raw glyphs (⌘ ⌥ ⇧); settings editor uses `glyphLabel` (Cmd/Ctrl, Option/Alt). `isValidBinding` requires Cmd/Ctrl or Option to prevent swallowing normal typing. + +## Leader-key shortcuts (⌘R prompt quick-fire) + +`prompt-quick-fire` (default ⌘R) is the first shortcut that doesn't fit the plain `SHORTCUT_DEFS` + `case` recipe above: it's a two-key LEADER sequence, not a single simultaneous chord. Pressing it doesn't fire anything by itself — it arms a transient "press a key" mode (`useUI().promptLeaderActive`, shown as a hint pill mounted in `Dialogs.tsx`) and installs a one-shot, capture-phase `keydown` listener (`armPromptLeader` in `src/hooks/useShortcuts.ts`) that: + +- matches the next keystroke against each enabled prompt's EFFECTIVE trigger key (`effectiveTriggerKeys` in `src/store/prompts.ts` — a manual per-prompt override, else the next free slot in the default `1-9, a-z` sequence) and fires it at the focused agent tab (`fireOrPickDestination` in `src/lib/promptFire.ts`, falling back to the shared destination-picker dialog when there's no focused live agent) +- cancels on `Escape`, an unmapped key, or a ~2s idle timeout + +The follow-up key MUST be captured before a focused terminal/xterm or editor/CodeMirror sees it, hence capture-phase + `stopImmediatePropagation` — same technique the Settings → Shortcuts key recorder uses, just re-armed after the leader key instead of after a click. + +`prompt-palette` (default ⇧⌘R) is a plain single-chord shortcut (fits the normal recipe) that opens `PromptPalette.tsx`: a searchable list of prompts (fuzzy-filtered by title only), Enter runs the highlighted one via the same `fireOrPickDestination` path. + +Per-prompt trigger keys are configurable in Settings → Prompts (`PromptLibrarySection.tsx`) and shown as badges in the Prompts dropdown (`UnifiedBar.tsx`) and the palette — NOT in `SHORTCUT_DEFS` (they're prompt data, not app shortcuts), so they don't show up in the Shortcuts help modal or settings editor. diff --git a/src/components/UnifiedBar.tsx b/src/components/UnifiedBar.tsx index 83c1f54..8a8259e 100644 --- a/src/components/UnifiedBar.tsx +++ b/src/components/UnifiedBar.tsx @@ -4,7 +4,7 @@ // The whole strip is a drag region so the user can move the window from any // empty space, with `no-drag` opted-in on every interactive child. -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useApp, useActiveTask } from "@/store/app"; import { Button } from "@/components/ui/Button"; @@ -14,25 +14,20 @@ import { Check } from "lucide-react"; import { PanelLeft, PanelRight, FolderOpen, Archive, Sun, Moon, Monitor, ArrowUpToLine, Sunrise, Droplet, Binary, Code2, Eye, Flower2, - MessageSquareText, Library, Plus, Palette, + MessageSquareText, Library, Palette, } from "lucide-react"; import { CliIcon, CLI_BRAND_COLOR, resolveIconId } from "@/icons/cli"; import { TaskLocationIcon } from "@/components/TaskLocationIcon"; import { effectiveSandboxMode } from "@/lib/types"; import { SandboxIcon } from "@/components/SandboxIcon"; -import type { TerminalTab } from "@/lib/types"; -import { findLeaf } from "@/lib/splitTree"; import { UpdaterBanner } from "@/components/UpdaterBanner"; import { WaitingAgentsPill } from "@/components/WaitingAgentsPill"; import { openPath, themesDir, taskSendDiffToMain } from "@/lib/ipc"; import { archiveAndRefresh } from "@/lib/archiveTask"; -import { visibleCliIds, isTerminalEntry, tabLabel } from "@/lib/agents"; -import { AppDialog } from "@/components/ui/Dialog"; import { DropdownRoot, DropdownTrigger, DropdownMenu, DropdownItem, DropdownSeparator, } from "@/components/ui/Dropdown"; -import { usePromptLibrary, type Prompt } from "@/store/prompts"; -import { runPrompt } from "@/lib/runPrompt"; +import { usePromptLibrary, effectiveTriggerKeys } from "@/store/prompts"; import { useUI } from "@/store/ui"; import { usePrefs, resolveTheme } from "@/store/prefs"; import { useIsFullscreen } from "@/hooks/useIsFullscreen"; @@ -53,37 +48,14 @@ export function UnifiedBar() { const task = useActiveTask(); const proj = useApp(s => task ? s.projects.find(p => p.id === task.project_id) : null); const openSettings = useApp(s => s.openSettings); - const enabledPrompts = usePromptLibrary(s => s.prompts).filter(p => p.enabled); - // Live agents in the active task = the prompt destinations (+ New agent). - // Run/Setup tabs are terminals with live PTYs too, but a dev server is not - // a prompt destination — exclude them. - const taskTabs = useApp(s => (task ? s.tabs[task.id] : undefined)); - const liveAgents = (taskTabs ?? []).filter( - (t): t is TerminalTab => t.type === "terminal" && !!t.ptyId && !(t as TerminalTab).runTab, - ); - const focusedAgentId = useApp(s => { - if (!task) return undefined; - const tree = s.splitTree[task.id]; - if (tree) { - const leaf = findLeaf(tree, s.activePaneId[task.id] ?? ""); - if (leaf?.activeTabId) return leaf.activeTabId; - } - return s.activeTab[task.id]; - }); - // Picking a prompt opens a destination modal (running agents + new-agent - // CLIs) instead of a submenu, which flipped to the wrong side near the edge. - const [firingPrompt, setFiringPrompt] = useState(null); - // Editable copy of the prompt body for this send only (does not touch the - // saved library prompt). Seeded when a prompt is picked. - const [firingBody, setFiringBody] = useState(""); - const detectedClis = useApp(s => s.detectedClis); - // Spawnable agents for "start a new agent" — installed + enabled, no plain - // terminal entries. Same list the new-tab (+) menu offers. Memoized so the - // always-mounted bar doesn't rebuild the visibility Set per-agent each render. - const newAgentChoices = useMemo(() => { - const vis = visibleCliIds(agents.map(x => x.id), agents, detectedClis); - return agents.filter(a => vis.has(a.id) && !isTerminalEntry(a)); - }, [agents, detectedClis]); + const allPrompts = usePromptLibrary(s => s.prompts); + const enabledPrompts = allPrompts.filter(p => p.enabled); + const triggerKeys = effectiveTriggerKeys(allPrompts); + // Picking a prompt opens the shared destination modal (running agents + + // new-agent CLIs) — a modal, not a submenu, which flipped to the wrong + // side near the window edge. Shared (not local state) so the ⌘R + // quick-fire / ⇧⌘R palette fallback paths can open the same dialog. + const openPromptFire = useUI(s => s.openPromptFire); const themeMode = usePrefs(s => s.themeMode); const setThemeMode = usePrefs(s => s.setThemeMode); // When the user picked an explicit theme, show that theme's icon. @@ -239,8 +211,16 @@ export function UnifiedBar() {
No prompts yet.
)} {enabledPrompts.map(p => ( - { setFiringPrompt(p); setFiringBody(p.body); }}> - {p.title} + openPromptFire(p)}> + {p.title} + {triggerKeys.get(p.id) && ( + + {triggerKeys.get(p.id)} + + )} ))} @@ -251,94 +231,6 @@ export function UnifiedBar() { - {/* Destination picker: where should this prompt run? A running - agent (send / queue), or a new agent with the CLI of your - choice. A modal, not a submenu (which flipped to the wrong - side near the window edge). */} - {firingPrompt && ( - { if (!v) setFiringPrompt(null); }} - title={`Run "${firingPrompt.title}"`} - description="Tweak the prompt if needed, then pick where it runs." - className="max-w-5xl" - > -
- {/* Left: editable prompt for THIS send (does not change the - saved library prompt). */} -