Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
150 changes: 21 additions & 129 deletions src/components/UnifiedBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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<Prompt | null>(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.
Expand Down Expand Up @@ -239,8 +211,16 @@ export function UnifiedBar() {
<div className="px-2 py-1.5 text-[13px] text-[var(--color-fg-faint)]">No prompts yet.</div>
)}
{enabledPrompts.map(p => (
<DropdownItem key={p.id} onSelect={() => { setFiringPrompt(p); setFiringBody(p.body); }}>
<span className="truncate">{p.title}</span>
<DropdownItem key={p.id} onSelect={() => openPromptFire(p)}>
<span className="min-w-0 flex-1 truncate">{p.title}</span>
{triggerKeys.get(p.id) && (
<kbd
title="⌘R quick-fire key"
className="ml-2 shrink-0 rounded border border-[var(--color-border-soft)] px-1 font-mono text-[10.5px] uppercase leading-[16px] text-[var(--color-fg-faint)]"
>
{triggerKeys.get(p.id)}
</kbd>
)}
</DropdownItem>
))}
<DropdownSeparator />
Expand All @@ -251,94 +231,6 @@ export function UnifiedBar() {
</DropdownMenu>
</DropdownRoot>

{/* 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 && (
<AppDialog
open
onOpenChange={(v) => { if (!v) setFiringPrompt(null); }}
title={`Run "${firingPrompt.title}"`}
description="Tweak the prompt if needed, then pick where it runs."
className="max-w-5xl"
>
<div className="mt-1 flex h-[58vh] gap-4">
{/* Left: editable prompt for THIS send (does not change the
saved library prompt). */}
<textarea
value={firingBody}
onChange={(e) => setFiringBody(e.target.value)}
spellCheck={false}
autoCorrect="off"
autoCapitalize="off"
autoComplete="off"
className="h-full min-w-0 flex-1 resize-none rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2.5 font-mono text-[12.5px] leading-relaxed text-[var(--color-fg)] outline-none focus:border-[var(--color-accent)]"
/>

{/* Right: where to run it. */}
<div className="flex w-[260px] shrink-0 flex-col gap-3 overflow-y-auto pr-0.5">
{liveAgents.length > 0 && (
<div className="flex flex-col gap-1.5">
<div className="px-0.5 text-[11px] font-medium uppercase tracking-wider text-[var(--color-fg-faint)]">Send to a running agent</div>
{liveAgents.map(a => {
const current = a.id === focusedAgentId;
const busy = a.workState === "working";
return (
<button
key={a.id}
onClick={() => { runPrompt(task.id, { ...firingPrompt, body: firingBody }, { kind: "agent", tabId: a.id }); setFiringPrompt(null); }}
className={cn(
"flex items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-colors",
current
? "border-[var(--color-border)] bg-[var(--color-bg-2)]"
: "border-[var(--color-border)] hover:border-[var(--color-accent-soft)] hover:bg-[var(--color-hover)]",
)}
>
<span className={cn("shrink-0", CLI_BRAND_COLOR[resolveIconId(a.cli, agents)])}>
<CliIcon cli={resolveIconId(a.cli, agents)} className="h-5 w-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] text-[var(--color-fg)]">
{tabLabel(a)}
</span>
{(current || busy) && (
<span className="block text-[10.5px]">
{current && <span className="text-[var(--color-accent)]">current</span>}
{current && busy && <span className="text-[var(--color-fg-faint)]"> · </span>}
{busy && <span className="text-[var(--color-fg-faint)]">busy</span>}
</span>
)}
</span>
</button>
);
})}
</div>
)}

<div className="flex flex-col gap-0.5">
<div className="mb-0.5 flex items-center gap-1 px-0.5 text-[11px] font-medium uppercase tracking-wider text-[var(--color-fg-faint)]">
<Plus className="h-3 w-3" /> Start a new agent
</div>
{newAgentChoices.length === 0 ? (
<div className="px-0.5 py-1 text-[12.5px] text-[var(--color-fg-faint)]">No agents available.</div>
) : newAgentChoices.map(a => (
<button
key={a.id}
onClick={() => { runPrompt(task.id, { ...firingPrompt, body: firingBody }, { kind: "new", cli: a.id }); setFiringPrompt(null); }}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-hover)]"
>
<span className={cn("shrink-0 opacity-80", CLI_BRAND_COLOR[a.icon_id])}>
<CliIcon cli={a.icon_id} className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1 truncate text-[12.5px] text-[var(--color-fg-dim)]">{a.display_name}</span>
</button>
))}
</div>
</div>
</div>
</AppDialog>
)}
{/* Send-to-main: only shown on actual worktrees, not the
repo-root pseudo-task (which IS the main checkout —
nothing to send). Hard-blocks on a dirty main checkout
Expand Down
12 changes: 12 additions & 0 deletions src/components/dialogs/Dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ import { FileFinderDialog } from "./FileFinderDialog";
import { FindInFilesDialog } from "./FindInFilesDialog";
import { ProjectPickerDialog } from "./ProjectPickerDialog";
import { CommandPalette } from "./CommandPalette";
import { PromptDestinationDialog } from "./PromptDestinationDialog";
import { PromptPalette } from "./PromptPalette";
import { Loader2 } from "lucide-react";

export function Dialogs() {
const openWelcome = useUI(s => s.openWelcome);
const busyMessage = useUI(s => s.busyMessage);
const promptLeaderActive = useUI(s => s.promptLeaderActive);

// Fire the welcome wizard on first launch (no settings.welcomed flag yet).
useEffect(() => {
Expand All @@ -51,6 +54,15 @@ export function Dialogs() {
<FindInFilesDialog />
<ProjectPickerDialog />
<CommandPalette />
<PromptDestinationDialog />
<PromptPalette />
{/* ⌘R quick-fire, armed: waiting for the follow-up trigger-key press.
See armPromptLeader in useShortcuts.ts. */}
{promptLeaderActive && (
<div className="fixed bottom-6 left-1/2 z-[55] -translate-x-1/2 rounded-full border border-[var(--color-border)] bg-[var(--color-bg-1)] px-3.5 py-1.5 text-[12.5px] text-[var(--color-fg-dim)] shadow-xl">
Prompt: press a key…
</div>
)}
{/* Blocking work overlay: shown while a slow IPC call is in flight
(archive task, etc.). Click-blocks the whole window so users
don't fire the action twice mid-wait. */}
Expand Down
Loading
Loading