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
8 changes: 8 additions & 0 deletions src/main/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface ShortcutBinding {
modifier: ShortcutModifier;
}

export type NumberShortcutBehavior = 'ctrl-tasks' | 'cmd-tasks';

export interface KeyboardSettings {
commandPalette?: ShortcutBinding;
settings?: ShortcutBinding;
Expand All @@ -48,6 +50,7 @@ export interface KeyboardSettings {
newTask?: ShortcutBinding;
nextAgent?: ShortcutBinding;
prevAgent?: ShortcutBinding;
numberShortcutBehavior?: NumberShortcutBehavior;
}

export interface InterfaceSettings {
Expand Down Expand Up @@ -166,6 +169,7 @@ const DEFAULT_SETTINGS: AppSettings = {
newTask: { key: 'n', modifier: 'cmd' },
nextAgent: { key: ']', modifier: 'cmd+shift' },
prevAgent: { key: '[', modifier: 'cmd+shift' },
numberShortcutBehavior: 'ctrl-tasks',
},
interface: {
autoRightSidebarBehavior: false,
Expand Down Expand Up @@ -448,6 +452,10 @@ export function normalizeSettings(input: AppSettings): AppSettings {
newTask: normalizeBinding(keyboard.newTask, DEFAULT_SETTINGS.keyboard!.newTask!),
nextAgent: normalizeBinding(keyboard.nextAgent, DEFAULT_SETTINGS.keyboard!.nextAgent!),
prevAgent: normalizeBinding(keyboard.prevAgent, DEFAULT_SETTINGS.keyboard!.prevAgent!),
numberShortcutBehavior:
keyboard.numberShortcutBehavior === 'cmd-tasks'
? 'cmd-tasks'
: DEFAULT_SETTINGS.keyboard!.numberShortcutBehavior,
};
const platformTaskDefaults = getPlatformTaskSwitchDefaults();
const isLegacyArrowPair =
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/components/AppKeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ const AppKeyboardShortcuts: React.FC<AppKeyboardShortcutsProps> = ({
const { toggle: toggleRightSidebar } = useRightSidebar();
const { toggleTheme } = useTheme();
const { settings: keyboardSettings } = useKeyboardSettings();
const { handleNextTask, handlePrevTask, handleNewTask } = useTaskManagementContext();
const { handleNextTask, handlePrevTask, handleNewTask, handleSelectTaskByIndex } =
useTaskManagementContext();

useKeyboardShortcuts({
onToggleCommandPalette: handleToggleCommandPalette,
Expand All @@ -72,6 +73,7 @@ const AppKeyboardShortcuts: React.FC<AppKeyboardShortcutsProps> = ({
),
onSelectAgentTab: (tabIndex) =>
window.dispatchEvent(new CustomEvent('emdash:select-agent-tab', { detail: { tabIndex } })),
onSelectTask: handleSelectTaskByIndex,
onOpenInEditor: handleOpenInEditor,
onCloseModal: (
[
Expand Down
36 changes: 35 additions & 1 deletion src/renderer/components/KeyboardSettingsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'
import { ArrowBigUp, Command, RotateCcw } from 'lucide-react';
import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { toast } from '../hooks/use-toast';
import {
APP_SHORTCUTS,
Expand Down Expand Up @@ -144,7 +145,12 @@ const KeyboardSettingsCard: React.FC = () => {
const result: Record<string, ShortcutBinding> = {};
for (const shortcut of CONFIGURABLE_SHORTCUTS) {
const saved = keyboard?.[shortcut.settingsKey as keyof typeof keyboard];
result[shortcut.settingsKey] = saved ?? { key: shortcut.key, modifier: shortcut.modifier! };
// Only use saved if it's a ShortcutBinding (has key and modifier)
if (saved && typeof saved === 'object' && 'key' in saved && 'modifier' in saved) {
result[shortcut.settingsKey] = saved;
} else {
result[shortcut.settingsKey] = { key: shortcut.key, modifier: shortcut.modifier! };
}
}
return result as Record<ShortcutSettingsKey, ShortcutBinding>;
}, [settings?.keyboard]);
Expand Down Expand Up @@ -345,6 +351,34 @@ const KeyboardSettingsCard: React.FC = () => {
</div>
</div>
))}

{/* Number shortcuts behavior setting */}
<div className="flex items-center justify-between gap-4 border-t pt-4">
<div className="flex flex-1 flex-col gap-0.5">
<p className="text-sm">Number shortcuts (1-9)</p>
<p className="text-xs text-muted-foreground">
Choose which modifier switches tasks vs agent tabs
</p>
</div>
<Select
value={settings?.keyboard?.numberShortcutBehavior ?? 'ctrl-tasks'}
onValueChange={(next) =>
updateSettings({
keyboard: { numberShortcutBehavior: next as 'ctrl-tasks' | 'cmd-tasks' },
})
}
disabled={loading || saving}
>
<SelectTrigger className="w-auto shrink-0 gap-2 [&>span]:line-clamp-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ctrl-tasks">⌃N for tasks, ⌘N for agents</SelectItem>
<SelectItem value="cmd-tasks">⌘N for tasks, ⌃N for agents</SelectItem>
</SelectContent>
</Select>
</div>

{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
</div>
Expand Down
12 changes: 12 additions & 0 deletions src/renderer/components/TaskItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ interface TaskItemProps {
showDelete?: boolean;
showDirectBadge?: boolean;
primaryAction?: 'delete' | 'archive';
/** 1-9 index for keyboard shortcut badge display */
shortcutIndex?: number;
/** Which modifier to show in the badge: 'ctrl' (⌃) or 'cmd' (⌘) */
shortcutModifier?: 'ctrl' | 'cmd';
}

export const TaskItem: React.FC<TaskItemProps> = ({
Expand All @@ -73,6 +77,8 @@ export const TaskItem: React.FC<TaskItemProps> = ({
showDelete,
showDirectBadge = true,
primaryAction = 'delete',
shortcutIndex,
shortcutModifier = 'ctrl',
}) => {
const { totalAdditions, totalDeletions, isLoading } = useTaskChanges(task.path, task.id);
const { pr } = usePrStatus(task.path);
Expand Down Expand Up @@ -276,6 +282,12 @@ export const TaskItem: React.FC<TaskItemProps> = ({
/>
) : (
<>
{shortcutIndex !== undefined && (
<kbd className="flex-shrink-0 rounded bg-muted px-1 py-0.5 font-mono text-[10px] text-muted-foreground">
{shortcutModifier === 'cmd' ? '⌘' : '⌃'}
{shortcutIndex}
</kbd>
)}
{isPinned && (
<Pin
className="h-3 w-3 flex-shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
Expand Down
23 changes: 23 additions & 0 deletions src/renderer/components/sidebar/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,25 @@ export const LeftSidebar: React.FC<LeftSidebarProps> = ({

const { settings } = useAppSettings();
const taskHoverAction = settings?.interface?.taskHoverAction ?? 'delete';
const taskModifier = settings?.keyboard?.numberShortcutBehavior === 'cmd-tasks' ? 'cmd' : 'ctrl';

// Compute global shortcut indices for tasks across all projects (1-9)
const taskShortcutIndices = useMemo(() => {
const indices = new Map<string, number>();
let globalIndex = 0;
for (const project of sortedProjects) {
const tasks = (tasksByProjectId[project.id] ?? [])
.slice()
.sort((a, b) => (b.metadata?.isPinned ? 1 : 0) - (a.metadata?.isPinned ? 1 : 0));
for (const task of tasks) {
if (globalIndex < 9) {
indices.set(task.id, globalIndex + 1); // 1-indexed for display
}
globalIndex++;
}
}
return indices;
}, [sortedProjects, tasksByProjectId]);

const [forceOpenIds, setForceOpenIds] = useState<Set<string>>(new Set());
const prevTaskCountsRef = useRef<Map<string, number>>(new Map());
Expand Down Expand Up @@ -313,6 +332,8 @@ export const LeftSidebar: React.FC<LeftSidebarProps> = ({
)
.map((task) => {
const isActive = activeTask?.id === task.id;
// Get global shortcut index (1-9) for first 9 tasks across all projects
const shortcutIndex = taskShortcutIndices.get(task.id);
return (
<motion.div
key={task.id}
Expand All @@ -334,6 +355,8 @@ export const LeftSidebar: React.FC<LeftSidebarProps> = ({
onDelete={() => handleDeleteTask(typedProject, task)}
onArchive={() => onArchiveTask?.(typedProject, task)}
primaryAction={taskHoverAction}
shortcutIndex={shortcutIndex}
shortcutModifier={taskModifier}
/>
</motion.div>
);
Expand Down
75 changes: 71 additions & 4 deletions src/renderer/hooks/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,13 +280,57 @@ export function hasShortcutConflict(shortcut1: ShortcutConfig, shortcut2: Shortc
);
}

/**
* Get task selection index for Ctrl/Cmd+1-9 shortcuts.
* Returns 0-8 for keys 1-9 if the task modifier is pressed, null otherwise.
*/
export function getTaskSelectionIndex(
event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>,
useCmdForTasks: boolean,
isMac = isMacPlatform
): number | null {
// Determine which modifier to check based on setting
let hasTaskModifier: boolean;
if (useCmdForTasks) {
// User wants Cmd/Meta for tasks
hasTaskModifier = isMac ? event.metaKey && !event.ctrlKey : event.metaKey;
} else {
// Default: Ctrl for tasks (not Meta)
hasTaskModifier = event.ctrlKey && !event.metaKey;
}

if (!hasTaskModifier || event.altKey || event.shiftKey) {
return null;
}

const key = normalizeShortcutKey(event.key);
if (!/^[1-9]$/.test(key)) {
return null;
}

return Number(key) - 1;
}

/**
* Get agent tab selection index for Cmd/Ctrl+1-9 shortcuts.
* Returns 0-8 for keys 1-9 if the agent modifier is pressed, null otherwise.
*/
export function getAgentTabSelectionIndex(
event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>,
useCtrlForAgents: boolean,
isMac = isMacPlatform
): number | null {
const hasCommandModifier =
(isMac ? event.metaKey : event.metaKey || event.ctrlKey) && !event.shiftKey;
if (!hasCommandModifier || event.altKey) {
// Determine which modifier to check based on setting (inverse of tasks)
let hasAgentModifier: boolean;
if (useCtrlForAgents) {
// When tasks use Cmd, agents use Ctrl
hasAgentModifier = event.ctrlKey && !event.metaKey;
} else {
// Default: agents use Cmd/Meta (or Ctrl on non-Mac when Meta not available)
hasAgentModifier = isMac ? event.metaKey && !event.ctrlKey : event.metaKey || event.ctrlKey;
}

if (!hasAgentModifier || event.altKey || event.shiftKey) {
return null;
}

Expand Down Expand Up @@ -557,7 +601,30 @@ export function useKeyboardShortcuts(handlers: GlobalShortcutHandlers) {
}
}

const agentTabIndex = getAgentTabSelectionIndex(event);
// Skip number shortcuts when typing in editable fields
if (isEditableTarget) return;

// Handle number key shortcuts (1-9) for task/agent selection
const useCmdForTasks =
handlers.customKeyboardSettings?.numberShortcutBehavior === 'cmd-tasks';

// Check for task selection first (Ctrl+1-9 by default, or Cmd+1-9 if configured)
const taskIndex = getTaskSelectionIndex(event, useCmdForTasks);
if (taskIndex !== null) {
const isCommandPaletteOpen = Boolean(handlers.isCommandPaletteOpen);
if (isCommandPaletteOpen) {
event.preventDefault();
handlers.onCloseModal?.();
setTimeout(() => handlers.onSelectTask?.(taskIndex), 100);
return;
}
event.preventDefault();
handlers.onSelectTask?.(taskIndex);
return;
}

// Check for agent tab selection (Cmd+1-9 by default, or Ctrl+1-9 if tasks use Cmd)
const agentTabIndex = getAgentTabSelectionIndex(event, useCmdForTasks);
if (agentTabIndex === null) {
return;
}
Expand Down
40 changes: 40 additions & 0 deletions src/renderer/hooks/useTaskManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,45 @@ export function useTaskManagement() {
[activateProjectView, projects, openTaskModal]
);

const handleSelectTaskByIndex = useCallback(
(index: number) => {
// Sort projects using the same order as LeftSidebar (from localStorage)
let sortedProjects = projects;
try {
const stored = localStorage.getItem('sidebarProjectOrder');
if (stored) {
const projectOrder: string[] = JSON.parse(stored);
if (projectOrder.length > 0) {
sortedProjects = [...projects].sort((a, b) => {
const ai = projectOrder.indexOf(a.id);
const bi = projectOrder.indexOf(b.id);
if (ai === -1 && bi === -1) return 0;
if (ai === -1) return -1;
if (bi === -1) return 1;
return ai - bi;
});
}
}
} catch {
// Use default order if localStorage read fails
}

// Build a flat list of all tasks in sidebar order (by project, then pinned first within each)
const allTasksInOrder: Task[] = [];
for (const project of sortedProjects) {
const projectTasks = (tasksByProjectId[project.id] ?? [])
.slice()
.sort((a, b) => (b.metadata?.isPinned ? 1 : 0) - (a.metadata?.isPinned ? 1 : 0));
allTasksInOrder.push(...projectTasks);
}

if (index >= 0 && index < allTasksInOrder.length) {
handleSelectTask(allTasksInOrder[index]);
}
},
[projects, tasksByProjectId, handleSelectTask]
Comment on lines +392 to +428
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale project order causes wrong task selection

handleSelectTaskByIndex iterates projects (raw DB order from context), but the sidebar badge numbers are computed using sortedProjects (which respects the user-configurable projectOrder stored in localStorage — see LeftSidebar.tsx lines 110-120).

If the user has reordered their projects via drag-and-drop, the two orderings diverge. Pressing Ctrl+1 will select the task ranked first in DB order, but the ⌃1 badge will be shown on a different task (the first in the user's custom order). This means the shortcut silently selects the wrong task.

useTaskManagement doesn't have access to sortedProjects directly, so the fix should be to either:

  • Accept the sorted project list as a parameter to the callback, or
  • Move the index-to-task resolution into LeftSidebar where sortedProjects is available and pass the resolved Task up instead of a numeric index.
// e.g. accept a pre-computed ordered list
const handleSelectTaskByIndex = useCallback(
  (index: number, orderedTasks: Task[]) => {
    if (index >= 0 && index < orderedTasks.length) {
      handleSelectTask(orderedTasks[index]);
    }
  },
  [handleSelectTask]
);

);

// ---------------------------------------------------------------------------
// Delete task mutation
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1005,6 +1044,7 @@ export function useTaskManagement() {
handleTaskInterfaceReady,
openTaskModal,
handleSelectTask,
handleSelectTaskByIndex,
handleNextTask,
handlePrevTask,
handleNewTask,
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/types/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export type ShortcutModifier =
| 'cmd+shift'
| 'ctrl+shift';

export type NumberShortcutBehavior = 'ctrl-tasks' | 'cmd-tasks';

export interface ShortcutBinding {
key: string;
modifier: ShortcutModifier;
Expand All @@ -27,6 +29,7 @@ export interface KeyboardSettings {
nextAgent?: ShortcutBinding;
prevAgent?: ShortcutBinding;
openInEditor?: ShortcutBinding;
numberShortcutBehavior?: NumberShortcutBehavior;
}

export interface ShortcutConfig {
Expand Down Expand Up @@ -92,6 +95,9 @@ export interface GlobalShortcutHandlers {
onPrevAgent?: () => void;
onSelectAgentTab?: (tabIndex: number) => void;

// Task selection by number (Ctrl+1-9)
onSelectTask?: (index: number) => void;

// Open in editor
onOpenInEditor?: () => void;

Expand Down
Loading