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
179 changes: 179 additions & 0 deletions briefs/configurable-keybindings-and-external-link-modifiers.md

Large diffs are not rendered by default.

17 changes: 11 additions & 6 deletions docs/ADDING_NEW_CLI_TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,16 @@ binary.

## 7. Frontend

`shared/constants/agentLaunchPresets.ts` is the single list behind the toolbar pills,
Add Tool dropdowns (desktop + remote), and `mod+alt+N` hotkeys. Add one entry
(`platforms` gates unsupported OSes); `agentLaunchPresets.test.ts` pins the list
against the RunPane contract. Add the brand icon to
`shared/constants/agentLaunchPresets.ts` is the single list behind the toolbar pills
and Add Tool dropdowns (desktop + remote). Add one preset entry with its stable
`hotkeyId` (`platforms` gates unsupported OSes), then add the matching command and
default chord to `shared/constants/keyboardShortcuts.ts`.
`agentLaunchPresets.test.ts` and `keyboardShortcutCatalog.test.ts` pin preset,
catalog, platform, and default-chord parity. Add the brand icon to
`frontend/src/components/ui/BrandIcons.tsx` (`CLI_BRAND_ICONS`) and a search alias in
`frontend/src/components/settings/catalog.tsx`.
`frontend/src/components/settings/catalog.tsx`. Settings → Shortcuts, Help, and the
conflict engine read the catalog directly, so a new agent's launch command becomes
remappable (and conflict-checked against every other binding) with no further UI work.

## 8. Worktree file sync

Expand All @@ -107,4 +111,5 @@ guide in a format the CLI actually reads (Cursor: `.cursor/rules/*.mdc`).
Every step above lands test-first: `agentIdentity.test.ts`, `<tool>Launch.test.ts`,
`terminalPanelManager.test.ts`, `agentResume.test.ts`, `manifests.test.ts` +
`agentStatusPipeline.test.ts` (real captured bytes), `runpane.test.ts` (agent matrix +
doctor), `agentLaunchPresets.test.ts`, `scripts/test-runpane-contract.js`.
doctor), `agentLaunchPresets.test.ts`, `keyboardShortcutCatalog.test.ts`,
`scripts/test-runpane-contract.js`.
14 changes: 4 additions & 10 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ function App() {
const { currentError, clearError } = useErrorStore();
const { sessions, isLoaded } = useSessionStore();
const activeSessionId = useSessionStore(state => state.activeSessionId);
const { fetchConfig, config: appConfig } = useConfigStore();
const { fetchConfig, subscribeToUpdates, config: appConfig } = useConfigStore();
const terminalShortcuts = appConfig?.terminalShortcuts ?? EMPTY_TERMINAL_SHORTCUTS;
const { isVisible: shortcutHintsVisible } = useShortcutHintsOverlay();
useFocusedSurfaceScrolling(activeSessionId);
Expand Down Expand Up @@ -218,31 +218,27 @@ function App() {
useHotkey({
id: 'open-command-palette',
label: 'Open Command Palette',
keys: 'mod+shift+p',
category: 'navigation',
action: () => setIsCommandPaletteOpen(true),
});

useHotkey({
id: 'toggle-sidebar',
label: 'Toggle Sidebar',
keys: 'mod+b',
category: 'view',
action: handleToggleSidebar,
});

useHotkey({
id: 'open-settings',
label: 'Open Settings',
keys: 'mod+,',
category: 'navigation',
action: () => openSettings(),
});

useHotkey({
id: 'focus-sidebar',
label: 'Focus Sidebar',
keys: 'mod+shift+e',
category: 'navigation',
action: () => {
if (sidebarCollapsed) handleToggleSidebar();
Expand All @@ -259,7 +255,6 @@ function App() {
useHotkey({
id: 'open-shortcut-settings',
label: 'Open Shortcut Settings',
keys: 'mod+alt+/',
category: 'shortcuts',
action: () => {
openSettings({ category: 'shortcuts', setting: 'terminal-shortcuts' });
Expand All @@ -269,7 +264,6 @@ function App() {
useHotkey({
id: 'new-session',
label: 'New Pane',
keys: 'mod+n',
category: 'session',
action: () => {
if (activeProject) setShowCreateSessionDialog(true);
Expand All @@ -279,7 +273,6 @@ function App() {
useHotkey({
id: 'new-project',
label: 'New Project',
keys: 'mod+shift+n',
category: 'navigation',
action: () => setShowAddProjectDialog(true),
});
Expand All @@ -289,8 +282,9 @@ function App() {

// Load config on app startup
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
void fetchConfig();
return subscribeToUpdates();
}, [fetchConfig, subscribeToUpdates]);

// Detect unclean shutdown from previous session and notify user
useEffect(() => {
Expand Down
90 changes: 51 additions & 39 deletions frontend/src/components/Help.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,76 @@
import { useMemo } from 'react';
import { GitBranch, Terminal, Folder, Zap, MessageSquare, Settings, Bell, History } from 'lucide-react';
import { Modal, ModalHeader, ModalBody } from './ui/Modal';
import { useHotkeyStore, type HotkeyDefinition } from '../stores/hotkeyStore';
import { formatKeyDisplay, CATEGORY_LABELS } from '../utils/hotkeyUtils';
import type { ShortcutCategory } from '../../../shared/constants/keyboardShortcuts';
import { useConfigStore } from '../stores/configStore';
import { CATEGORY_LABELS, CATEGORY_ORDER, formatKeyDisplay } from '../utils/hotkeyUtils';
import { rendererPlatform } from '../utils/platformUtils';
import { buildShortcutMap, REFERENCE_ROWS, type ShortcutMapRow } from '../utils/shortcutMap';
import { Kbd } from './ui/Kbd';

function KeyboardShortcutsSection() {
const hotkeys = useHotkeyStore((s) => s.hotkeys);
const allHotkeys = useMemo(
() =>
Array.from(hotkeys.values())
.filter((def) => !def.devOnly || process.env.NODE_ENV === 'development')
.filter((def) => def.showInPalette !== false)
.filter((h) => !h.enabled || h.enabled()),
[hotkeys]
);
const config = useConfigStore((s) => s.config);
const { rows } = useMemo(() => buildShortcutMap({
overridesRaw: config?.keyboardShortcutOverrides,
terminalShortcuts: config?.terminalShortcuts,
customCommands: config?.customCommands,
environment: rendererPlatform(),
}), [config?.keyboardShortcutOverrides, config?.terminalShortcuts, config?.customCommands]);

const grouped = allHotkeys.reduce<Record<string, HotkeyDefinition[]>>((acc, def) => {
if (!acc[def.category]) acc[def.category] = [];
acc[def.category].push(def);
return acc;
}, {});
const grouped = useMemo(() => {
const byCategory = new Map<ShortcutCategory, ShortcutMapRow[]>();
for (const row of rows) {
const group = byCategory.get(row.category) ?? [];
group.push(row);
byCategory.set(row.category, group);
}
return CATEGORY_ORDER.flatMap((category) => {
const group = byCategory.get(category);
return group ? [{ category, rows: group }] : [];
});
}, [rows]);

return (
<section>
<h3 className="text-lg font-semibold text-text-primary mb-3">
Keyboard Shortcuts
</h3>
<div className="space-y-4">
{/* Static shortcut not in registry (scoped input handler) */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-text-secondary">Send Input / Continue Conversation</span>
<Kbd size="md">{formatKeyDisplay('mod+enter')}</Kbd>
</div>
</div>
{/* Dynamic shortcuts from registry */}
{Object.entries(grouped).map(([category, hotkeys]) => {
// SAFETY: grouped is keyed by HotkeyDefinition category values.
const hotkeyCategory = category as HotkeyDefinition['category'];
return <div key={category}>
{grouped.map(({ category, rows: groupRows }) => (
<div key={category}>
<h4 className="text-sm font-medium text-text-tertiary mb-2">
{CATEGORY_LABELS[hotkeyCategory] ?? category}
{CATEGORY_LABELS[category]}
</h4>
<div className="space-y-2">
{hotkeys.map((hotkey) => (
<div key={hotkey.id} className="flex justify-between items-center">
<span className="text-text-secondary">{hotkey.label}</span>
{hotkey.keys ? (
<Kbd size="md">
{formatKeyDisplay(hotkey.keys)}
</Kbd>
{groupRows.map((row) => (
<div key={row.id} className="flex justify-between items-center gap-3">
<span className="text-text-secondary">
{row.label}
{row.availability === 'unavailable-platform' && (
<span className="ml-2 text-xs text-text-muted">unavailable on this platform</span>
)}
</span>
{row.effectiveChord ? (
<Kbd size="md">{formatKeyDisplay(row.effectiveChord)}</Kbd>
) : (
<span className="text-xs text-text-muted italic">palette only</span>
<span className="text-xs text-text-muted italic">unassigned</span>
)}
</div>
))}
</div>
</div>;
})}
</div>
))}
<div>
<h4 className="text-sm font-medium text-text-tertiary mb-2">Terminal / native — not remappable</h4>
<div className="space-y-2">
{REFERENCE_ROWS.map((reference) => (
<div key={reference.id} className="flex justify-between items-center">
<span className="text-text-secondary">{reference.label}</span>
<Kbd size="md">{formatKeyDisplay(reference.chord)}</Kbd>
</div>
))}
</div>
</div>
</div>
</section>
);
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/ProjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ export const ProjectView: React.FC<ProjectViewProps> = ({
toggleDetail: () => setDetailVisible((v) => !v),
showInspector: (tab) => { setInspectorTab(tab); setDetailVisible(true); },
addTerminal: () => { void handlePanelCreate('terminal'); },
addTerminalWithOptions: (options) => { void handlePanelCreate('terminal', options); },
tabCount: () => workingPanels.length,
selectTab: (index) => { const panel = workingPanels[index]; if (panel) handlePanelSelect(panel); },
cycleTab: (direction) => {
Expand Down
Loading
Loading