99dfbbde58
* feat(plugins): add keybindings and modal window capabilities
Add two independent plugin UI capabilities.
Keybindings: plugins declare keybindings in the manifest under
ui.keybindings ({id, default, description}), validated backend-side
(combo grammar + unique ids, requires a UI bundle) and exposed to the
frontend. Plugin JS binds a handler via registry.registerKeybinding(id,
handler); a global dispatcher fires it. Combos are user-overridable in
Settings > Keyboard Shortcuts (namespaced plugin:{pluginId}:{id}) with
conflict detection against core and other plugins.
Modals: host.openModal({title?, content, size?, dismissible?}) returns a
{ close() } handle and renders in a single host-owned Dialog isolated by
PluginErrorBoundary, cleaned up on plugin unload.
Fixture plugin, PLUGIN-API.md / spec, and a Playwright e2e demonstrate a
keybinding handler opening a modal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): address review feedback on keybinding dispatch and validation
Core shortcuts now take precedence over plugin keybindings on a colliding
combo (defaultPrevented cooperation between the two capture-phase
dispatchers), a throwing plugin handler no longer aborts dispatch to other
plugins, and the keybinding combo grammar is tightened: the backend validator
and frontend parser both drop the undocumented control/super modifier
aliases and reject shift+digit/symbol combos that can never actually fire
(KeyboardEvent.key reports the shifted character, not the token).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): stop conflicting core shortcut from double-firing with plugin keybindings
The earlier defaultPrevented guard only covered the central useAppShortcuts
dispatcher. Per-component core shortcuts registered via useKeyboardShortcut
run in the bubble phase, after the capture-phase plugin keybinding dispatcher,
so a plugin-handled-and-prevented combo still triggered the core action too.
Guard useKeyboardShortcut (and use-editor-keybinds, another capture-phase core
listener) on event.defaultPrevented so exactly one action fires per combo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): make core shortcuts always win over plugin keybindings
Panel-search, task-switcher, and other core listeners never checked
event.defaultPrevented, so a plugin bound to the same combo (e.g. Cmd/Ctrl+F)
could still fire alongside them. Instead of patching every core listener,
usePluginShortcuts now computes the set of effective core shortcut combos
(registry, resolved through user overrides, plus FIND_IN_PANEL/SAVE) and
skips invoking any plugin handler whose combo is in that set, so the
matching core listener always handles the event exactly once.
Also adds a hook-level regression test for useEditorKeybinds' existing
defaultPrevented guard, and fixes a leaked capture-phase window listener in
use-keyboard-shortcut.test.ts that could bleed into later tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): keep Cmd+K closing the command panel after shortcut-precedence guard
cmdk's built-in vim bindings intercept Ctrl/Cmd+K and +P as list-navigation
shortcuts and call preventDefault() before the event reaches our window-level
useKeyboardShortcut listeners. Once that hook started bailing on
event.defaultPrevented (added to fix core/plugin shortcut double-firing), the
palette's own Cmd+K close/reopen toggle got silently suppressed whenever focus
was inside the open panel. Disable cmdk's vim bindings on the command palette
so its Ctrl/Cmd+K and +P always reach the app-level toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(plugins): document keybindings and modal window plugin APIs
Fill in the public author-facing docs that the internal plugin-keybindings
and modal-window contract (PLUGIN-API.md, spec.md) already covers: the
registry.registerKeybinding / host.openModal JS surface, the ui.keybindings
manifest field with its combo grammar, and a capability mention on the
Plugins overview page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
185 lines
6.1 KiB
TypeScript
185 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import { useDockviewStore } from "@/lib/state/dockview-store";
|
|
import { useAppStoreApi } from "@/components/state-provider";
|
|
import { markSessionPanelUserActivationIntent } from "@/components/task/session-tab-activation-intent";
|
|
import { createUserShell } from "@/lib/api/domains/user-shell-api";
|
|
import { matchesShortcut } from "@/lib/keyboard/utils";
|
|
import { getShortcut, type StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
|
|
import type { DockviewApi } from "dockview-react";
|
|
|
|
function handleTabNavigation(e: KeyboardEvent, api: DockviewApi) {
|
|
const activePanel = api.activePanel;
|
|
if (!activePanel) return;
|
|
|
|
const panels = activePanel.group.panels;
|
|
if (panels.length <= 1) return;
|
|
|
|
const currentIndex = panels.findIndex((p) => p.id === activePanel.id);
|
|
if (currentIndex === -1) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const direction = e.code === "BracketLeft" ? -1 : 1;
|
|
const nextIndex = (currentIndex + direction + panels.length) % panels.length;
|
|
const nextPanel = panels[nextIndex];
|
|
markSessionPanelUserActivationIntent(nextPanel.id);
|
|
nextPanel.api.setActive();
|
|
}
|
|
|
|
function handleTerminalToggle(
|
|
e: KeyboardEvent,
|
|
api: DockviewApi,
|
|
previousPanelIdRef: React.MutableRefObject<string | null>,
|
|
getEnvironmentId: () => string | null,
|
|
getTaskID: () => string | null,
|
|
) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const activePanel = api.activePanel;
|
|
const isTerminalFocused = activePanel?.id.startsWith("terminal-") ?? false;
|
|
|
|
if (isTerminalFocused) {
|
|
const prevId = previousPanelIdRef.current;
|
|
const target = prevId ? api.getPanel(prevId) : api.getPanel("chat");
|
|
if (target) target.api.setActive();
|
|
previousPanelIdRef.current = null;
|
|
return;
|
|
}
|
|
|
|
if (activePanel) {
|
|
previousPanelIdRef.current = activePanel.id;
|
|
}
|
|
|
|
const terminalPanel = api.panels.find((p) => p.id.startsWith("terminal-"));
|
|
if (terminalPanel) {
|
|
terminalPanel.api.setActive();
|
|
return;
|
|
}
|
|
|
|
const environmentId = getEnvironmentId();
|
|
if (!environmentId) return;
|
|
const taskID = getTaskID();
|
|
|
|
createUserShell(environmentId, { taskId: taskID ?? undefined })
|
|
.then((result) => {
|
|
const title = result.displayName ?? result.label ?? "Terminal";
|
|
useDockviewStore
|
|
.getState()
|
|
.addTerminalPanel(result.terminalId, undefined, environmentId, taskID ?? undefined, title);
|
|
})
|
|
.catch((err) => {
|
|
console.warn("Failed to create terminal shell:", err);
|
|
});
|
|
}
|
|
|
|
function handleBottomTerminal(
|
|
e: KeyboardEvent,
|
|
appStore: ReturnType<typeof useAppStoreApi>,
|
|
previousFocusRef: React.MutableRefObject<Element | null>,
|
|
overrides: StoredShortcutOverrides | undefined,
|
|
): boolean {
|
|
// Toggle bottom terminal panel (default: Cmd/Ctrl+J).
|
|
// Note: no isEditableTarget guard here. The default binding is not a standard text
|
|
// editing shortcut, and we must preventDefault even when an xterm textarea
|
|
// is focused — otherwise the un-prevented event causes escape-sequence
|
|
// artifacts (e.g. trailing "R" from cursor-position reports during resize).
|
|
if (matchesShortcut(e, getShortcut("BOTTOM_TERMINAL", overrides))) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const isOpen = appStore.getState().bottomTerminal.isOpen;
|
|
|
|
if (!isOpen) {
|
|
// Opening: save the currently focused element to restore later
|
|
previousFocusRef.current = document.activeElement;
|
|
}
|
|
|
|
appStore.getState().toggleBottomTerminal();
|
|
|
|
if (isOpen) {
|
|
// Closing: restore focus to the previously focused element
|
|
const prev = previousFocusRef.current;
|
|
if (prev instanceof HTMLElement && prev.isConnected) {
|
|
prev.focus({ preventScroll: true });
|
|
} else {
|
|
// Fallback: focus the chat panel
|
|
const api = useDockviewStore.getState().api;
|
|
const chatPanel = api?.getPanel("chat");
|
|
if (chatPanel) chatPanel.api.setActive();
|
|
}
|
|
previousFocusRef.current = null;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Global editor keybinds for dockview:
|
|
* - Cmd/Ctrl+Shift+[ / ] — navigate prev/next tab in active group
|
|
* - Ctrl+` — toggle terminal focus
|
|
* - Cmd/Ctrl+J — toggle bottom terminal panel
|
|
*
|
|
* Note: `TOGGLE_SIDEBAR` (Cmd/Ctrl+B) is handled app-wide by `useAppShortcuts`
|
|
* (mounted near the root), not here, so it works on every route.
|
|
*/
|
|
export function useEditorKeybinds() {
|
|
const previousPanelIdRef = useRef<string | null>(null);
|
|
const previousFocusRef = useRef<Element | null>(null);
|
|
const appStore = useAppStoreApi();
|
|
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
// Another capture-phase core dispatcher (`useAppShortcuts`) or a
|
|
// plugin keybinding (`usePluginShortcuts`) may have already handled
|
|
// and prevented this event — bail out so a combo shared with tab
|
|
// navigation, terminal toggle, or BOTTOM_TERMINAL fires only once.
|
|
if (e.defaultPrevented) return;
|
|
|
|
const api = useDockviewStore.getState().api;
|
|
if (!api) return;
|
|
|
|
const overrides = appStore.getState().userSettings.keyboardShortcuts;
|
|
|
|
const isTabNav =
|
|
(e.metaKey || e.ctrlKey) &&
|
|
e.shiftKey &&
|
|
(e.code === "BracketLeft" || e.code === "BracketRight");
|
|
|
|
if (isTabNav) {
|
|
handleTabNavigation(e, api);
|
|
return;
|
|
}
|
|
|
|
const isTerminalToggle = e.ctrlKey && !e.metaKey && !e.shiftKey && e.code === "Backquote";
|
|
|
|
if (isTerminalToggle) {
|
|
handleTerminalToggle(
|
|
e,
|
|
api,
|
|
previousPanelIdRef,
|
|
() => {
|
|
const state = appStore.getState();
|
|
const sid = state.tasks.activeSessionId;
|
|
return sid ? (state.environmentIdBySessionId[sid] ?? null) : null;
|
|
},
|
|
() => appStore.getState().tasks?.activeTaskId ?? null,
|
|
);
|
|
return;
|
|
}
|
|
|
|
handleBottomTerminal(e, appStore, previousFocusRef, overrides);
|
|
};
|
|
|
|
// Use capture phase so we receive events before xterm.js
|
|
window.addEventListener("keydown", handler, true);
|
|
return () => window.removeEventListener("keydown", handler, true);
|
|
}, [appStore]);
|
|
}
|