Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/providers/codex/renderer/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<key> 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
8 changes: 8 additions & 0 deletions src/providers/registry.renderer.capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ export type RendererProviderCapabilities = {
shortLabel: string
spawnDescription: string
resumeCommand: (quotedSessionId: string) => string
/**
* Alt-<key> 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<string, ConditionView>
renderToolUse?: (block: ToolUseBlock) => ReactNode | undefined
renderToolResult?: (
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/src/features/prompt-templates/templates.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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`,
Expand Down
53 changes: 35 additions & 18 deletions src/renderer/src/features/workspace/commands/paneCommands.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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)
},
},
Expand Down Expand Up @@ -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 <Provider> Right/Below" palette entries automatically, with
// an ⌥<key> 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.
//
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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',
}
},
Expand Down Expand Up @@ -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',
}
},
Expand Down Expand Up @@ -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',
}
},
Expand Down Expand Up @@ -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',
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 10 additions & 1 deletion src/renderer/src/workspace/dispatch/DispatchAgentList.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities'
import { useCallback } from 'react'

import type { ProviderSwitchBatchAgent, SessionId } from '@renderer/workspace/types'
Expand All @@ -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 {
Expand Down
34 changes: 21 additions & 13 deletions src/renderer/src/workspace/tile-tree/useKeybinds.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -625,20 +630,23 @@ export function useKeybinds(
void workspace.splitFocused('horizontal', 'terminal')
return
}
// --- Codex split: alt-c / alt-shift-c ---
// --- Per-provider splits: alt-<key> / alt-shift-<key> ---
//
// 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') {
Expand Down