diff --git a/src/providers/codex/renderer/identity.ts b/src/providers/codex/renderer/identity.ts index 8d25f3ab6..37b28830c 100644 --- a/src/providers/codex/renderer/identity.ts +++ b/src/providers/codex/renderer/identity.ts @@ -10,4 +10,9 @@ export const CODEX_IDENTITY = { * appended AFTER any config flags in real spawns; for the copyable * command there are no flags, so the plain form is correct. */ resumeCommand: (quotedSessionId: string) => `codex resume ${quotedSessionId}`, + /** Alt- chord for "split with this provider" (uppercase + * KeyboardEvent.code letter). The DEFAULT_PROVIDER uses the generic + * split chords instead; additional providers without a key get + * palette-only split commands. */ + splitShortcutKey: 'C', } as const diff --git a/src/providers/registry.renderer.capabilities.ts b/src/providers/registry.renderer.capabilities.ts index fb3809646..113be7ed9 100644 --- a/src/providers/registry.renderer.capabilities.ts +++ b/src/providers/registry.renderer.capabilities.ts @@ -42,6 +42,14 @@ export type RendererProviderCapabilities = { shortLabel: string spawnDescription: string resumeCommand: (quotedSessionId: string) => string + /** + * Alt- chord letter for "split with this provider" (#394 phase + * 4). Optional: the DEFAULT_PROVIDER is covered by the generic + * split-vertical/-horizontal commands, and a provider without a + * declared key still gets palette-only split commands — chords are + * a scarce resource, palette entries aren't. + */ + splitShortcutKey?: string conditionViews: Record renderToolUse?: (block: ToolUseBlock) => ReactNode | undefined renderToolResult?: ( diff --git a/src/renderer/src/features/prompt-templates/templates.ts b/src/renderer/src/features/prompt-templates/templates.ts index abcc6fe20..30c507278 100644 --- a/src/renderer/src/features/prompt-templates/templates.ts +++ b/src/renderer/src/features/prompt-templates/templates.ts @@ -1,3 +1,4 @@ +import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import { DEFAULT_PROVIDER, type AgentProviderKind } from '@shared/types/providerKind' import { formatWorktreeDumpPrompt } from '@renderer/features/worktrees/lib/formatWorktreeDump' import { loadWorktreeDump } from '@renderer/features/worktrees/lib/loadWorktreeDump' @@ -126,7 +127,7 @@ function buildActiveTabTranscriptPrompt( } agents.forEach((agent, index) => { - const label = agent.kind === 'codex' ? 'Codex' : 'Claude' + const label = getRendererProviderCapabilities(agent.kind).shortLabel const transcriptPath = agent.transcriptPath ?? '(transcript path not found)' lines.push( `## ${index + 1}. ${label} agent`, diff --git a/src/renderer/src/features/workspace/commands/paneCommands.ts b/src/renderer/src/features/workspace/commands/paneCommands.ts index 3e88b0f00..515dafc24 100644 --- a/src/renderer/src/features/workspace/commands/paneCommands.ts +++ b/src/renderer/src/features/workspace/commands/paneCommands.ts @@ -1,4 +1,5 @@ -import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind' +import { AGENT_PROVIDER_KINDS, DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind' +import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import { extractLastAssistantText } from '@renderer/lib/copyAssistant' import type { CommandContext, CommandDef } from '@renderer/features/command-palette/types' import { @@ -95,7 +96,7 @@ export const paneCommands: CommandDef[] = [ const sessionId = commandTargetSessionId(workspace) if (!sessionId) return const kind = workspace.state.sessions[sessionId]?.kind - if (kind !== 'claude' && kind !== 'codex') return + if (!isAgentProviderKind(kind)) return ui.openLinkedAgent(sessionId) }, }, @@ -270,22 +271,38 @@ export const paneCommands: CommandDef[] = [ shortcut: '⌥⇧T', run: ({ workspace }) => void workspace.splitFocused('horizontal', 'terminal'), }, - { - id: 'codex-vertical', - surface: 'grid', - title: 'New Codex Right', - description: '**What it does:** Opens a **Codex agent on the right**.\n\n**Use when:** You want Codex beside the current agent.\n\n**Notes:** In **Dispatch**, this creates a detached Codex agent instead.', - shortcut: '⌥C', - run: ({ workspace }) => void workspace.splitFocused('vertical', 'codex'), - }, - { - id: 'codex-horizontal', - surface: 'grid', - title: 'New Codex Below', - description: '**What it does:** Opens a **Codex agent below**.\n\n**Use when:** You want Codex in a stacked layout.\n\n**Notes:** In **Dispatch**, this creates a detached Codex agent instead.', - shortcut: '⌥⇧C', - run: ({ workspace }) => void workspace.splitFocused('horizontal', 'codex'), - }, + // Per-provider split commands, generated for every registered agent + // provider EXCEPT the default (#394 phase 4). The default provider + // is what the generic split-vertical/-horizontal commands spawn, so + // it doesn't need named variants; every additional provider gets + // "New Right/Below" palette entries automatically, with + // an ⌥ chord when its identity descriptor declares + // splitShortcutKey (codex: ⌥C/⌥⇧C — the ids stay `codex-vertical` + // etc. so user keybinding overrides keyed on command ids survive). + ...AGENT_PROVIDER_KINDS.filter(kind => kind !== DEFAULT_PROVIDER).flatMap(kind => { + const caps = getRendererProviderCapabilities(kind) + const chord = caps.splitShortcutKey + return [ + { + id: `${kind}-vertical`, + surface: 'grid' as const, + title: `New ${caps.shortLabel} Right`, + description: `**What it does:** Opens a **${caps.shortLabel} agent on the right**.\n\n**Use when:** You want ${caps.shortLabel} beside the current agent.\n\n**Notes:** In **Dispatch**, this creates a detached ${caps.shortLabel} agent instead.`, + ...(chord ? { shortcut: `⌥${chord}` } : {}), + run: ({ workspace }: CommandContext) => + void workspace.splitFocused('vertical', kind), + }, + { + id: `${kind}-horizontal`, + surface: 'grid' as const, + title: `New ${caps.shortLabel} Below`, + description: `**What it does:** Opens a **${caps.shortLabel} agent below**.\n\n**Use when:** You want ${caps.shortLabel} in a stacked layout.\n\n**Notes:** In **Dispatch**, this creates a detached ${caps.shortLabel} agent instead.`, + ...(chord ? { shortcut: `⌥⇧${chord}` } : {}), + run: ({ workspace }: CommandContext) => + void workspace.splitFocused('horizontal', kind), + }, + ] + }), { // `grid` surface — applies to nav-left/right/up/down below. // diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.ts b/src/renderer/src/features/workspace/commands/sessionCommands.ts index 14468584e..93f7e880d 100644 --- a/src/renderer/src/features/workspace/commands/sessionCommands.ts +++ b/src/renderer/src/features/workspace/commands/sessionCommands.ts @@ -1,4 +1,5 @@ import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind' +import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import type { CommandContext, CommandDef } from '@renderer/features/command-palette/types' import { runSaveDebugBundleCommand } from '@renderer/features/debug/saveDebugBundle' import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId' @@ -466,7 +467,7 @@ export const sessionCommands: CommandDef[] = [ const meta = sessionId ? workspace.state.sessions[sessionId] : null const kind = meta?.kind ?? DEFAULT_PROVIDER return { - label: kind === 'codex' ? 'Codex' : 'Claude', + label: getRendererProviderCapabilities(isAgentProviderKind(kind) ? kind : DEFAULT_PROVIDER).shortLabel, tone: 'neutral', } }, @@ -503,7 +504,7 @@ export const sessionCommands: CommandDef[] = [ const meta = sessionId ? workspace.state.sessions[sessionId] : null const kind = meta?.kind ?? DEFAULT_PROVIDER return { - label: kind === 'codex' ? 'Codex' : 'Claude', + label: getRendererProviderCapabilities(isAgentProviderKind(kind) ? kind : DEFAULT_PROVIDER).shortLabel, tone: 'neutral', } }, @@ -531,7 +532,7 @@ export const sessionCommands: CommandDef[] = [ const meta = sessionId ? workspace.state.sessions[sessionId] : null const kind = meta?.kind ?? DEFAULT_PROVIDER return { - label: kind === 'codex' ? 'Codex' : 'Claude', + label: getRendererProviderCapabilities(isAgentProviderKind(kind) ? kind : DEFAULT_PROVIDER).shortLabel, tone: 'neutral', } }, @@ -616,7 +617,7 @@ export const sessionCommands: CommandDef[] = [ const meta = sessionId ? workspace.state.sessions[sessionId] : null const kind = meta?.kind ?? DEFAULT_PROVIDER return { - label: kind === 'codex' ? 'Codex' : 'Claude', + label: getRendererProviderCapabilities(isAgentProviderKind(kind) ? kind : DEFAULT_PROVIDER).shortLabel, tone: 'neutral', } }, diff --git a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx index a7547184a..191e2315c 100644 --- a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx +++ b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx @@ -1,3 +1,4 @@ +import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { relativeTime } from '@renderer/lib/relativeTime' @@ -48,7 +49,8 @@ type ProjectRow = { } function providerLabel(kind: AgentProviderKind): string { - return kind === 'codex' ? 'Codex' : 'Claude' + // Registry-derived (#394 phase 4). + return getRendererProviderCapabilities(kind).shortLabel } export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { diff --git a/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx b/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx index a1d2ad016..07ba1370a 100644 --- a/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx +++ b/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx @@ -1,3 +1,8 @@ +import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' +import { + DEFAULT_PROVIDER, + isAgentProviderKind, +} from '@shared/types/providerKind' import { memo, useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' @@ -374,8 +379,12 @@ function dispatchAttentionLabel(runtime: { // In the narrow dispatch row we want the shorter "Claude" so the // badge doesn't crowd the worktree pill on row 2. function DispatchAgentBadge({ kind }: { kind: SessionKind | undefined }) { + // Registry-derived (#394 phase 4); terminal keeps its literal, + // undefined kind = pre-kind back-compat (Claude). const label = - kind === 'codex' ? 'Codex' : kind === 'terminal' ? 'Terminal' : 'Claude' + kind === 'terminal' + ? 'Terminal' + : getRendererProviderCapabilities(isAgentProviderKind(kind) ? kind : DEFAULT_PROVIDER).shortLabel const classes = kind === 'terminal' ? 'border-cyan-400/50 bg-cyan-400/10 text-cyan-200' : 'border-border bg-surface-hi text-muted' diff --git a/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.ts b/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.ts index 70c2a14b6..e0a3ca6ed 100644 --- a/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.ts +++ b/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.ts @@ -1,3 +1,4 @@ +import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import { useCallback } from 'react' import type { ProviderSwitchBatchAgent, SessionId } from '@renderer/workspace/types' @@ -21,7 +22,8 @@ import { switchAgentProvider } from '@renderer/workspace/hook/actions/providerSw // would silently drop every turn done after the switch. function providerLabel(kind: AgentProviderKind): string { - return kind === 'codex' ? 'Codex' : 'Claude' + // Registry-derived (#394 phase 4). + return getRendererProviderCapabilities(kind).shortLabel } function pluralAgents(n: number): string { diff --git a/src/renderer/src/workspace/tile-tree/useKeybinds.ts b/src/renderer/src/workspace/tile-tree/useKeybinds.ts index 5fffbeb03..4baa61242 100644 --- a/src/renderer/src/workspace/tile-tree/useKeybinds.ts +++ b/src/renderer/src/workspace/tile-tree/useKeybinds.ts @@ -1,3 +1,8 @@ +import { + AGENT_PROVIDER_KINDS, + DEFAULT_PROVIDER, +} from '@shared/types/providerKind' +import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import { useEffect } from 'react' import { useAppStore } from '@renderer/app-state/hooks' @@ -625,20 +630,23 @@ export function useKeybinds( void workspace.splitFocused('horizontal', 'terminal') return } - // --- Codex split: alt-c / alt-shift-c --- + // --- Per-provider splits: alt- / alt-shift- --- // - // Same grammar as the generic split bindings above: - // no shift = vertical/right, shift = horizontal/down. - // Uses e.code === 'KeyC' for the same macOS alt-letter reason - // as the others. - if (code === 'KeyC' && !shift) { - e.preventDefault() - void workspace.splitFocused('vertical', 'codex') - return - } - if (code === 'KeyC' && shift) { - e.preventDefault() - void workspace.splitFocused('horizontal', 'codex') + // Derived from each registered provider's identity descriptor + // (#394 phase 4; codex declares 'C' → ⌥C/⌥⇧C, matching the + // old hardcoded chord). Same grammar as the generic split + // bindings above: no shift = vertical/right, shift = + // horizontal/down. Matches on e.code (physical key) for the + // same macOS alt-letter reason as the others — alt+letter + // produces Unicode glyphs invisible to key-string matching. + // The DEFAULT_PROVIDER has no per-provider chord; it's what + // the generic ⌥D split spawns. + for (const providerKind of AGENT_PROVIDER_KINDS) { + if (providerKind === DEFAULT_PROVIDER) continue + const chordKey = getRendererProviderCapabilities(providerKind).splitShortcutKey + if (!chordKey || code !== `Key${chordKey}`) continue + e.preventDefault() + void workspace.splitFocused(shift ? 'horizontal' : 'vertical', providerKind) return } if (code === 'KeyW') {