import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { WorkspacePanelPlacement, WorkspacePanelTarget, } from "@/keyboard/keyboard-action-dispatcher"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { ShortcutKey } from "@/utils/format-shortcut"; import type { CommandCenterContribution, CommandCenterIcon } from "./contributions"; export interface WorkspaceCommandCenterLabelChoice { name: string; /** Whether the current workspace already carries this label. */ assigned: boolean; icon?: CommandCenterIcon; } export interface WorkspaceCommandCenterLabels { section: string; newAgent: string; newTerminal: string; newBrowser: string; splitRight: string; splitDown: string; changes: string; files: string; pullRequest: string; openPanel(name: string, placement: WorkspacePanelPlacement): string; previousTab: string; nextTab: string; closeCurrentTab: string; renameTab: string; reloadAgent: string; copyResumeCommand: string; copyAgentId: string; copyTerminalId: string; copyFilePath: string; closeTabsLeft: string; closeTabsRight: string; closeOtherTabs: string; focusPaneLeft: string; focusPaneRight: string; focusPaneUp: string; focusPaneDown: string; moveTabLeft: string; moveTabRight: string; moveTabUp: string; moveTabDown: string; closePane: string; toggleFocusMode: string; toggleExplorerSidebar: string; // Workspace management actions rename: string; copyPath: string; copyBranchName: string; pin: string; unpin: string; showSetup: string; labelsGroup: string; } export interface WorkspaceCommandCenterIcons { newAgent?: CommandCenterIcon; newTerminal?: CommandCenterIcon; newBrowser?: CommandCenterIcon; splitRight?: CommandCenterIcon; splitDown?: CommandCenterIcon; changes?: CommandCenterIcon; files?: CommandCenterIcon; pullRequest?: CommandCenterIcon; previousTab?: CommandCenterIcon; nextTab?: CommandCenterIcon; close?: CommandCenterIcon; rename?: CommandCenterIcon; reload?: CommandCenterIcon; copy?: CommandCenterIcon; focusPane?: CommandCenterIcon; moveTab?: CommandCenterIcon; focusMode?: CommandCenterIcon; explorerSidebar?: CommandCenterIcon; // Workspace management action icons copyPath?: CommandCenterIcon; copyBranchName?: CommandCenterIcon; pin?: CommandCenterIcon; unpin?: CommandCenterIcon; showSetup?: CommandCenterIcon; toggleFocusMode?: CommandCenterIcon; label?: CommandCenterIcon; git?(action: GitAction): CommandCenterIcon | undefined; } export interface WorkspaceCommandCenterShortcuts { newAgent?: ShortcutKey[][]; newTerminal?: ShortcutKey[][]; splitRight?: ShortcutKey[][]; splitDown?: ShortcutKey[][]; archiveWorkspace?: ShortcutKey[][]; previousTab?: ShortcutKey[][]; nextTab?: ShortcutKey[][]; closeCurrentTab?: ShortcutKey[][]; closePane?: ShortcutKey[][]; toggleFocusMode?: ShortcutKey[][]; toggleExplorerSidebar?: ShortcutKey[][]; pinWorkspace?: ShortcutKey[][]; } export interface WorkspaceCommandCenterSource { gitActions: GitActions; labels: WorkspaceCommandCenterLabels; icons: WorkspaceCommandCenterIcons; shortcuts: WorkspaceCommandCenterShortcuts; capabilities: { canSplitPanes: boolean; canOpenBrowserTabs: boolean; isGit: boolean; /** Host supports the `workspacePinning` feature. */ canPin: boolean; /** The workspace has setup commands or a setup error — same gate as the header menu. */ canShowSetup: boolean; }; activeTabKind: WorkspaceTabTarget["kind"] | null; activeTabIndex: number; activeTabCount: number; /** Null on a non-git workspace, or before gitRuntime resolves. Omits Copy branch name. */ currentBranch: string | null; isPinned: boolean; /** * The host's label catalog, each entry told whether the current workspace carries it. Null * before the catalog has loaded — omits the whole group rather than showing it empty. */ labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null; dispatch(action: KeyboardActionDefinition): void; runGitAction(action: GitAction): void; copyPath(): void; copyBranchName(): void; toggleLabel(name: string, assigned: boolean): void | Promise; } function buildGitContribution( source: WorkspaceCommandCenterSource, action: GitAction, rank: number, visibility: "always" | "query", ): CommandCenterContribution { return { id: `git:${action.id}`, group: "workspace", groupRank: -1, rank, keywords: [action.id, "git"], visibility, run: () => source.runGitAction(action), presentation: { kind: "action", title: action.label, sectionTitle: source.labels.section, icon: source.icons.git?.(action), shortcutKeys: action.id === "archive-workspace" ? source.shortcuts.archiveWorkspace : undefined, }, }; } function buildWorkspaceAction(input: { source: WorkspaceCommandCenterSource; id: string; rank: number; title: string; keywords: readonly string[]; icon?: CommandCenterIcon; shortcutKeys?: ShortcutKey[][]; action: KeyboardActionDefinition; visibility: "always" | "query"; }): CommandCenterContribution { return { id: input.id, group: "workspace", groupRank: -1, rank: input.rank, keywords: input.keywords, visibility: input.visibility, run: () => input.source.dispatch(input.action), presentation: { kind: "action", title: input.title, sectionTitle: input.source.labels.section, icon: input.icon, shortcutKeys: input.shortcutKeys, }, }; } type QueryActionInput = Omit[0], "source" | "visibility">; function buildQueryAction( source: WorkspaceCommandCenterSource, input: QueryActionInput, ): CommandCenterContribution { return buildWorkspaceAction({ ...input, source, visibility: "query" }); } function buildPanelContributions( source: WorkspaceCommandCenterSource, ): CommandCenterContribution[] { const contributions: CommandCenterContribution[] = []; const panelTargets: Array<{ target: WorkspacePanelTarget; name: string; icon?: CommandCenterIcon; }> = [ { target: "changes", name: source.labels.changes, icon: source.icons.changes }, { target: "files", name: source.labels.files, icon: source.icons.files }, { target: "pull-request", name: source.labels.pullRequest, icon: source.icons.pullRequest, }, ]; for (const [index, panel] of panelTargets.entries()) { if (panel.target !== "files" && !source.capabilities.isGit) continue; contributions.push( buildQueryAction(source, { id: `tab:open:${panel.target}`, rank: 4 + index, title: source.labels.openPanel(panel.name, "supporting"), keywords: ["open", panel.target, "tab", "supporting"], icon: panel.icon, action: { id: "workspace.tab.open", scope: "workspace", target: panel.target, placement: "supporting", }, }), ); if (!source.capabilities.canSplitPanes) continue; if (panel.target !== "pull-request") continue; const placements = ["side-pane", "focused-pane"] as const; for (const [placementIndex, placement] of placements.entries()) { contributions.push( buildQueryAction(source, { id: `tab:open:${panel.target}:${placement}`, rank: 10 + index * 2 + placementIndex, title: source.labels.openPanel(panel.name, placement), keywords: ["open", panel.target, "tab", placement, "pane"], icon: panel.icon, action: { id: "workspace.tab.open", scope: "workspace", target: panel.target, placement, }, }), ); } } return contributions; } function buildActiveTabContributions( source: WorkspaceCommandCenterSource, ): CommandCenterContribution[] { if (source.activeTabCount === 0) return []; const contributions: CommandCenterContribution[] = [ buildQueryAction(source, { id: "tab:previous", rank: 30, title: source.labels.previousTab, keywords: ["tab", "previous", "switch", "navigate"], icon: source.icons.previousTab, shortcutKeys: source.shortcuts.previousTab, action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: -1 }, }), buildQueryAction(source, { id: "tab:next", rank: 31, title: source.labels.nextTab, keywords: ["tab", "next", "switch", "navigate"], icon: source.icons.nextTab, shortcutKeys: source.shortcuts.nextTab, action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: 1 }, }), buildQueryAction(source, { id: "tab:close-current", rank: 32, title: source.labels.closeCurrentTab, keywords: ["tab", "close", "current"], icon: source.icons.close, shortcutKeys: source.shortcuts.closeCurrentTab, action: { id: "workspace.tab.close-current", scope: "workspace" }, }), ]; if (source.activeTabKind === "agent" || source.activeTabKind === "terminal") { contributions.push( buildQueryAction(source, { id: "tab:rename-current", rank: 33, title: source.labels.renameTab, keywords: ["tab", "rename", "current"], icon: source.icons.rename, action: { id: "workspace.tab.rename-current", scope: "workspace" }, }), buildQueryAction(source, { id: "tab:copy-id", rank: 34, title: source.activeTabKind === "agent" ? source.labels.copyAgentId : source.labels.copyTerminalId, keywords: ["tab", "copy", "id"], icon: source.icons.copy, action: { id: "workspace.tab.copy-id", scope: "workspace" }, }), ); } if (source.activeTabKind === "agent") { contributions.push( buildQueryAction(source, { id: "tab:reload-current", rank: 35, title: source.labels.reloadAgent, keywords: ["tab", "agent", "reload"], icon: source.icons.reload, action: { id: "workspace.tab.reload-current", scope: "workspace" }, }), buildQueryAction(source, { id: "tab:copy-resume-command", rank: 36, title: source.labels.copyResumeCommand, keywords: ["tab", "agent", "copy", "resume", "command"], icon: source.icons.copy, action: { id: "workspace.tab.copy-resume-command", scope: "workspace" }, }), ); } if (source.activeTabKind === "file") { contributions.push( buildQueryAction(source, { id: "tab:copy-file-path", rank: 37, title: source.labels.copyFilePath, keywords: ["tab", "file", "copy", "path"], icon: source.icons.copy, action: { id: "workspace.tab.copy-file-path", scope: "workspace" }, }), ); } if (source.activeTabIndex > 0) { contributions.push( buildQueryAction(source, { id: "tab:close-left", rank: 38, title: source.labels.closeTabsLeft, keywords: ["tab", "close", "left"], icon: source.icons.close, action: { id: "workspace.tab.close-left", scope: "workspace" }, }), ); } if (source.activeTabIndex >= 0 && source.activeTabIndex < source.activeTabCount - 1) { contributions.push( buildQueryAction(source, { id: "tab:close-right", rank: 39, title: source.labels.closeTabsRight, keywords: ["tab", "close", "right"], icon: source.icons.close, action: { id: "workspace.tab.close-right", scope: "workspace" }, }), ); } if (source.activeTabCount > 1) { contributions.push( buildQueryAction(source, { id: "tab:close-others", rank: 40, title: source.labels.closeOtherTabs, keywords: ["tab", "close", "others"], icon: source.icons.close, action: { id: "workspace.tab.close-others", scope: "workspace" }, }), ); } return contributions; } function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCenterContribution[] { const paneActions: Array<{ id: string; rank: number; title: string; keywords: string[]; action: KeyboardActionDefinition; icon?: CommandCenterIcon; shortcutKeys?: ShortcutKey[][]; }> = [ { id: "pane:split-right", rank: 50, title: source.labels.splitRight, keywords: ["split", "pane", "vertical"], icon: source.icons.splitRight, shortcutKeys: source.shortcuts.splitRight, action: { id: "workspace.pane.split.right", scope: "workspace" }, }, { id: "pane:split-down", rank: 51, title: source.labels.splitDown, keywords: ["split", "pane", "horizontal"], icon: source.icons.splitDown, shortcutKeys: source.shortcuts.splitDown, action: { id: "workspace.pane.split.down", scope: "workspace" }, }, { id: "pane:focus-left", rank: 52, title: source.labels.focusPaneLeft, keywords: ["pane", "focus", "left"], icon: source.icons.focusPane, action: { id: "workspace.pane.focus.left", scope: "workspace" }, }, { id: "pane:focus-right", rank: 53, title: source.labels.focusPaneRight, keywords: ["pane", "focus", "right"], icon: source.icons.focusPane, action: { id: "workspace.pane.focus.right", scope: "workspace" }, }, { id: "pane:focus-up", rank: 54, title: source.labels.focusPaneUp, keywords: ["pane", "focus", "up"], icon: source.icons.focusPane, action: { id: "workspace.pane.focus.up", scope: "workspace" }, }, { id: "pane:focus-down", rank: 55, title: source.labels.focusPaneDown, keywords: ["pane", "focus", "down"], icon: source.icons.focusPane, action: { id: "workspace.pane.focus.down", scope: "workspace" }, }, { id: "pane:move-tab-left", rank: 56, title: source.labels.moveTabLeft, keywords: ["pane", "tab", "move", "left"], icon: source.icons.moveTab, action: { id: "workspace.pane.move-tab.left", scope: "workspace" }, }, { id: "pane:move-tab-right", rank: 57, title: source.labels.moveTabRight, keywords: ["pane", "tab", "move", "right"], icon: source.icons.moveTab, action: { id: "workspace.pane.move-tab.right", scope: "workspace" }, }, { id: "pane:move-tab-up", rank: 58, title: source.labels.moveTabUp, keywords: ["pane", "tab", "move", "up"], icon: source.icons.moveTab, action: { id: "workspace.pane.move-tab.up", scope: "workspace" }, }, { id: "pane:move-tab-down", rank: 59, title: source.labels.moveTabDown, keywords: ["pane", "tab", "move", "down"], icon: source.icons.moveTab, action: { id: "workspace.pane.move-tab.down", scope: "workspace" }, }, { id: "pane:close", rank: 60, title: source.labels.closePane, keywords: ["pane", "close"], icon: source.icons.close, shortcutKeys: source.shortcuts.closePane, action: { id: "workspace.pane.close", scope: "workspace" }, }, { id: "pane:focus-mode-toggle", rank: 62, title: source.labels.toggleFocusMode, keywords: ["pane", "focus", "mode", "toggle"], icon: source.icons.focusMode, shortcutKeys: source.shortcuts.toggleFocusMode, action: { id: "workspace.focus.toggle", scope: "workspace" }, }, ]; return paneActions.map((action) => buildQueryAction(source, action)); } function buildWorkspaceCallback(input: { source: WorkspaceCommandCenterSource; id: string; rank: number; title: string; keywords: readonly string[]; icon?: CommandCenterIcon; shortcutKeys?: ShortcutKey[][]; run: () => void; visibility: "always" | "query"; }): CommandCenterContribution { return { id: input.id, group: "workspace", groupRank: -1, rank: input.rank, keywords: input.keywords, visibility: input.visibility, run: input.run, presentation: { kind: "action", title: input.title, sectionTitle: input.source.labels.section, icon: input.icon, shortcutKeys: input.shortcutKeys, }, }; } function buildCreationContributions( source: WorkspaceCommandCenterSource, ): CommandCenterContribution[] { const contributions: CommandCenterContribution[] = [ buildWorkspaceAction({ source, id: "tab:new-agent", rank: 0, title: source.labels.newAgent, keywords: ["tab", "new", "agent", "chat"], icon: source.icons.newAgent, shortcutKeys: source.shortcuts.newAgent, action: { id: "workspace.agent.new", scope: "workspace" }, visibility: "always", }), ]; const primary = source.gitActions.primary; if (primary) contributions.push(buildGitContribution(source, primary, 1, "always")); contributions.push( buildQueryAction(source, { id: "tab:new-terminal", rank: 2, title: source.labels.newTerminal, keywords: ["terminal", "shell", "console"], icon: source.icons.newTerminal, shortcutKeys: source.shortcuts.newTerminal, action: { id: "workspace.terminal.new", scope: "workspace" }, }), ); if (source.capabilities.canOpenBrowserTabs) { contributions.push( buildQueryAction(source, { id: "tab:new-browser", rank: 3, title: source.labels.newBrowser, keywords: ["browser", "web", "preview"], icon: source.icons.newBrowser, action: { id: "workspace.browser.new", scope: "workspace" }, }), ); } return contributions; } export function buildWorkspaceCommandCenterContributions( source: WorkspaceCommandCenterSource, ): CommandCenterContribution[] { const contributions = [ ...buildCreationContributions(source), ...buildPanelContributions(source), ...buildActiveTabContributions(source), ...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []), ]; for (const [index, action] of actionsWithoutPrimary(source.gitActions).entries()) { contributions.push(buildGitContribution(source, action, 100 + index, "query")); } if (source.capabilities.canPin) { contributions.push( buildWorkspaceAction({ source, id: "workspace:pin", rank: 20, title: source.isPinned ? source.labels.unpin : source.labels.pin, keywords: ["pin", "unpin", "favorite", "sticky"], icon: source.isPinned ? source.icons.unpin : source.icons.pin, shortcutKeys: source.shortcuts.pinWorkspace, action: { id: "workspace.pin", scope: "sidebar" }, visibility: "always", }), ); } contributions.push( buildWorkspaceAction({ source, id: "workspace:rename", rank: 21, title: source.labels.rename, keywords: ["rename", "title", "name", "label"], icon: source.icons.rename, action: { id: "workspace.rename", scope: "workspace" }, visibility: "query", }), buildWorkspaceCallback({ source, id: "workspace:copy-path", rank: 22, title: source.labels.copyPath, keywords: ["copy", "path", "directory", "folder", "cwd"], icon: source.icons.copyPath, run: source.copyPath, visibility: "query", }), ); // Omitted rather than present-and-failing: a non-git workspace has nothing to copy. if (source.currentBranch) { contributions.push( buildWorkspaceCallback({ source, id: "workspace:copy-branch-name", rank: 23, title: source.labels.copyBranchName, keywords: ["copy", "branch", "git", source.currentBranch], icon: source.icons.copyBranchName, run: source.copyBranchName, visibility: "query", }), ); } if (source.capabilities.canShowSetup) { contributions.push( buildWorkspaceAction({ source, id: "workspace:show-setup", rank: 24, title: source.labels.showSetup, keywords: ["setup", "provision", "install", "bootstrap"], icon: source.icons.showSetup, action: { id: "workspace.setup.show", scope: "workspace" }, visibility: "query", }), ); } // Toggle Explorer sidebar and Toggle focus mode belong here, NOT in root-registration.tsx: // their handlers live in workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so a // global registration would list two entries that silently no-op on /settings, /sessions, // /schedules and Home. Toggle sidebar (left) is global and stays in the root set — that is why // the three toggles render in two non-adjacent sections. Don't "tidy" them back together. contributions.push( buildWorkspaceAction({ source, id: "workspace:toggle-explorer-sidebar", rank: 25, title: source.labels.toggleExplorerSidebar, keywords: ["toggle", "sidebar", "explorer", "files", "changes"], icon: source.icons.explorerSidebar, shortcutKeys: source.shortcuts.toggleExplorerSidebar, action: { id: "sidebar.toggle.right", scope: "sidebar" }, visibility: "query", }), ); // `buildPaneContributions` already dispatches this same `workspace.focus.toggle` action as // `pane:focus-mode-toggle` once split panes are available, so only add the standalone entry // where that function is skipped — otherwise the palette lists "Toggle focus mode" twice. if (!source.capabilities.canSplitPanes) { contributions.push( buildWorkspaceAction({ source, id: "workspace:toggle-focus-mode", rank: 26, title: source.labels.toggleFocusMode, keywords: ["toggle", "focus", "zen", "distraction", "fullscreen"], icon: source.icons.toggleFocusMode, shortcutKeys: source.shortcuts.toggleFocusMode, action: { id: "workspace.focus.toggle", scope: "workspace" }, visibility: "query", }), ); } contributions.push(...buildLabelContributions(source)); return contributions; } /** * One choice per catalog label, ticked when the current workspace carries it. Selecting toggles * assignment rather than gating on `!selected` — unlike the model/mode choice groups this is a * multi-select, so picking an already-assigned label un-assigns it instead of no-op'ing. */ function buildLabelContributions( source: WorkspaceCommandCenterSource, ): CommandCenterContribution[] { if (!source.labelCatalog) return []; return source.labelCatalog.map((choice, index) => ({ id: `workspace:label:${choice.name}`, group: "workspace", groupRank: -1, rank: 30 + index, keywords: ["label", "tag", choice.name], visibility: "query" as const, run: () => source.toggleLabel(choice.name, !choice.assigned), presentation: { kind: "choice" as const, path: [source.labels.labelsGroup, choice.name] as const, icon: choice.icon, selected: choice.assigned, testId: `command-center-workspace-label-${choice.name}`, }, })); }