diff --git a/electron/menu.ts b/electron/menu.ts index 24b3ad1c2..9717a6fa6 100644 --- a/electron/menu.ts +++ b/electron/menu.ts @@ -170,8 +170,13 @@ export function createApplicationMenu( label: "Terminal", submenu: [ { - label: "New Terminal", + label: "Duplicate Panel", accelerator: "CommandOrControl+T", + click: () => sendAction("duplicate-panel"), + }, + { + label: "New Terminal", + accelerator: "CommandOrControl+Alt+T", click: () => sendAction("new-terminal"), }, ...(buildAgentMenuItems().length > 0 diff --git a/shared/types/keymap.ts b/shared/types/keymap.ts index 9194f8337..8ea198211 100644 --- a/shared/types/keymap.ts +++ b/shared/types/keymap.ts @@ -109,6 +109,7 @@ export type KeyAction = | "terminal.moveToDock" | "terminal.moveToGrid" | "terminal.watch" + | "terminal.duplicate" | "terminal.background" | "terminal.contextMenu" | "terminal.stashInput" @@ -266,6 +267,7 @@ export const KEY_ACTION_VALUES: ReadonlySet = new Set([ "terminal.moveToDock", "terminal.moveToGrid", "terminal.watch", + "terminal.duplicate", "terminal.contextMenu", "terminal.stashInput", "terminal.popStash", diff --git a/src/hooks/useMenuActions.ts b/src/hooks/useMenuActions.ts index 92c1f8fb4..c85572246 100644 --- a/src/hooks/useMenuActions.ts +++ b/src/hooks/useMenuActions.ts @@ -79,6 +79,7 @@ export function useMenuActions(options: UseMenuActionsOptions): void { } const menuToActionMap: Record = { + "duplicate-panel": "terminal.duplicate", "new-terminal": "terminal.new", "new-worktree": "worktree.createDialog.open", "open-settings": "app.settings", diff --git a/src/services/KeybindingService.ts b/src/services/KeybindingService.ts index 3a2cc7a66..aa1c5af16 100644 --- a/src/services/KeybindingService.ts +++ b/src/services/KeybindingService.ts @@ -51,10 +51,18 @@ const DEFAULT_KEYBINDINGS: KeybindingConfig[] = [ category: "Navigation", }, { - actionId: "terminal.new", + actionId: "terminal.duplicate", combo: "Cmd+T", scope: "global", priority: 0, + description: "Duplicate focused panel", + category: "Terminal", + }, + { + actionId: "terminal.new", + combo: "Cmd+Alt+T", + scope: "global", + priority: 0, description: "New terminal", category: "Terminal", }, diff --git a/src/services/__tests__/KeybindingService.test.ts b/src/services/__tests__/KeybindingService.test.ts index 99beede31..81ded4110 100644 --- a/src/services/__tests__/KeybindingService.test.ts +++ b/src/services/__tests__/KeybindingService.test.ts @@ -161,19 +161,25 @@ describe("KeybindingService", () => { it("does not report conflicts for bindings disabled by empty override list", () => { const service = new KeybindingService(); - (service as unknown as { overrides: Map }).overrides.set("terminal.new", []); + (service as unknown as { overrides: Map }).overrides.set( + "terminal.duplicate", + [] + ); const conflicts = service.findConflicts("Cmd+T"); - expect(conflicts.some((binding) => binding.actionId === "terminal.new")).toBe(false); + expect(conflicts.some((binding) => binding.actionId === "terminal.duplicate")).toBe(false); }); it("surfaces empty effective combo for disabled overrides", () => { const service = new KeybindingService(); - (service as unknown as { overrides: Map }).overrides.set("terminal.new", []); + (service as unknown as { overrides: Map }).overrides.set( + "terminal.duplicate", + [] + ); const all = service.getAllBindingsWithEffectiveCombos(); - const binding = all.find((entry) => entry.actionId === "terminal.new") as + const binding = all.find((entry) => entry.actionId === "terminal.duplicate") as | (KeybindingConfig & { effectiveCombo: string }) | undefined; @@ -181,6 +187,16 @@ describe("KeybindingService", () => { expect(binding?.effectiveCombo).toBe(""); }); + it("binds Cmd+T to terminal.duplicate by default", () => { + const service = new KeybindingService(); + expect(service.getBinding("terminal.duplicate")?.combo).toBe("Cmd+T"); + }); + + it("binds Cmd+Alt+T to terminal.new by default", () => { + const service = new KeybindingService(); + expect(service.getBinding("terminal.new")?.combo).toBe("Cmd+Alt+T"); + }); + it("matchesEvent returns true for Shift+F10", () => { setPlatform("MacIntel"); diff --git a/src/services/actions/__tests__/actionDefinitions.adversarial.test.ts b/src/services/actions/__tests__/actionDefinitions.adversarial.test.ts index 17e7a8278..bacb61a37 100644 --- a/src/services/actions/__tests__/actionDefinitions.adversarial.test.ts +++ b/src/services/actions/__tests__/actionDefinitions.adversarial.test.ts @@ -470,6 +470,88 @@ describe("terminal action hardening", () => { }) ); }); + + it("duplicates focused panel when called with undefined args (keybinding path)", async () => { + const actions = buildRegistry(registerTerminalActions); + const duplicate = actions.get("terminal.duplicate")!(); + const addTerminal = vi.fn().mockResolvedValue("copy-id"); + + useTerminalStore.setState({ + terminals: [createTerminal({ id: "term-a", title: "My Shell" })], + focusedId: "term-a", + addTerminal, + } as never); + + await duplicate.run(undefined, {} as never); + + expect(addTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + type: "terminal", + title: "My Shell (copy)", + }) + ); + }); + + it("duplicates the lone non-trashed panel when focusedId is null", async () => { + const actions = buildRegistry(registerTerminalActions); + const duplicate = actions.get("terminal.duplicate")!(); + const addTerminal = vi.fn().mockResolvedValue("copy-id"); + + useTerminalStore.setState({ + terminals: [ + createTerminal({ id: "term-only", title: "Lonely" }), + createTerminal({ id: "term-trash", location: "trash" }), + ], + focusedId: null, + addTerminal, + } as never); + + await duplicate.run(undefined, {} as never); + + expect(addTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Lonely (copy)", + }) + ); + }); + + it("falls back to creating a new terminal when no panels exist", async () => { + const actions = buildRegistry(registerTerminalActions); + const duplicate = actions.get("terminal.duplicate")!(); + const addTerminal = vi.fn().mockResolvedValue("new-id"); + + useTerminalStore.setState({ + terminals: [], + focusedId: null, + addTerminal, + } as never); + + await duplicate.run(undefined, {} as never); + + expect(addTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + type: "terminal", + cwd: "/repo", + location: "grid", + }) + ); + }); + + it("does nothing when multiple panels exist but none is focused", async () => { + const actions = buildRegistry(registerTerminalActions); + const duplicate = actions.get("terminal.duplicate")!(); + const addTerminal = vi.fn().mockResolvedValue("copy-id"); + + useTerminalStore.setState({ + terminals: [createTerminal({ id: "term-a" }), createTerminal({ id: "term-b" })], + focusedId: null, + addTerminal, + } as never); + + await duplicate.run(undefined, {} as never); + + expect(addTerminal).not.toHaveBeenCalled(); + }); }); describe("panel action hardening", () => { diff --git a/src/services/actions/definitions/terminalSpawnActions.ts b/src/services/actions/definitions/terminalSpawnActions.ts index 232c96b35..247ce8e09 100644 --- a/src/services/actions/definitions/terminalSpawnActions.ts +++ b/src/services/actions/definitions/terminalSpawnActions.ts @@ -2,6 +2,7 @@ import type { ActionCallbacks, ActionRegistry } from "../actionTypes"; import { TerminalTypeSchema } from "./schemas"; import { z } from "zod"; import { useTerminalStore } from "@/store/terminalStore"; +import { buildPanelDuplicateOptions } from "@/services/terminal/panelDuplicationService"; export function registerTerminalSpawnActions( actions: ActionRegistry, callbacks: ActionCallbacks @@ -28,34 +29,37 @@ export function registerTerminalSpawnActions( actions.set("terminal.duplicate", () => ({ id: "terminal.duplicate", - title: "Duplicate Terminal", - description: "Create a duplicate of the terminal", + title: "Duplicate Panel", + description: "Duplicate the focused panel, or create a new terminal if no panels exist", category: "terminal", kind: "command", danger: "safe", scope: "renderer", - argsSchema: z.object({ terminalId: z.string().optional() }), + argsSchema: z.object({ terminalId: z.string().optional() }).optional(), run: async (args: unknown) => { - const { terminalId } = args as { terminalId?: string }; + const { terminalId } = (args as { terminalId?: string } | undefined) ?? {}; const state = useTerminalStore.getState(); - const targetId = terminalId ?? state.focusedId; + const nonTrashed = state.terminals.filter((t) => t.location !== "trash"); + const targetId = + terminalId ?? state.focusedId ?? (nonTrashed.length === 1 ? nonTrashed[0].id : undefined); + if (targetId) { const terminal = state.terminals.find((t) => t.id === targetId); if (!terminal) return; - const location = terminal.location === "trash" ? "grid" : (terminal.location ?? "grid"); - + const location = + terminal.location === "grid" || terminal.location === "dock" ? terminal.location : "grid"; + const options = await buildPanelDuplicateOptions(terminal, location); + if (terminal.title) { + options.title = `${terminal.title} (copy)`; + } + await state.addTerminal(options); + } else if (nonTrashed.length === 0) { await state.addTerminal({ - kind: terminal.kind, - type: terminal.type, - agentId: terminal.agentId, - cwd: terminal.cwd, - location, - title: terminal.title ? `${terminal.title} (copy)` : undefined, - worktreeId: terminal.worktreeId, - command: terminal.command, - isInputLocked: terminal.isInputLocked, - browserUrl: terminal.browserUrl, + type: "terminal", + cwd: callbacks.getDefaultCwd(), + location: "grid", + worktreeId: callbacks.getActiveWorktreeId(), }); } },