diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx
index ca256fe2..cdf8b449 100644
--- a/ui/src/components/profiles/profile-create-dialog.tsx
+++ b/ui/src/components/profiles/profile-create-dialog.tsx
@@ -80,7 +80,7 @@ const schema = z.object({
opusModel: z.string().optional(),
sonnetModel: z.string().optional(),
haikuModel: z.string().optional(),
- target: z.enum(['claude', 'droid']),
+ target: z.enum(['claude', 'droid', 'codex']),
});
type FormData = z.infer
;
@@ -521,6 +521,7 @@ export function ProfileCreateDialog({
Claude Code (default)
Factory Droid
+ Codex CLI
@@ -529,6 +530,11 @@ export function ProfileCreateDialog({
{t('profileEditor.targetHintPreferredAlias')}{' '}
ccs-droid.
>
+ ) : targetValue === 'codex' ? (
+ <>
+ {t('profileEditor.targetHintPreferredAlias')}{' '}
+ ccsx.
+ >
) : (
<>
{t('profileEditor.targetHintClaudeDefault')}{' '}
@@ -541,6 +547,12 @@ export function ProfileCreateDialog({
{t('profileEditor.targetHintLegacyAlias')}{' '}
ccsd.
>
+ ) : targetValue === 'codex' ? (
+ <>
+ {' '}
+ {t('profileEditor.targetHintLegacyAlias')}{' '}
+ ccs-codex.
+ >
) : null}{' '}
{t('profileEditor.targetHintOverride')}{' '}
--target.
diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts
index e8159ef4..63f03870 100644
--- a/ui/src/lib/api-client.ts
+++ b/ui/src/lib/api-client.ts
@@ -99,7 +99,7 @@ async function request(url: string, options?: RequestInit): Promise {
}
// Types
-export type CliTarget = 'claude' | 'droid';
+export type CliTarget = 'claude' | 'droid' | 'codex';
export interface CliproxyBridgeMetadata {
provider: CLIProxyProvider;
diff --git a/ui/src/lib/support-updates-catalog.ts b/ui/src/lib/support-updates-catalog.ts
index 33c48e2d..8d3cbd61 100644
--- a/ui/src/lib/support-updates-catalog.ts
+++ b/ui/src/lib/support-updates-catalog.ts
@@ -70,7 +70,7 @@ export const SUPPORT_NOTICES: SupportNotice[] = [
'Use ccs-codex or ccsx for native Codex runs.',
'Use ccsxp for the built-in CCS Codex provider shortcut on native Codex.',
'Built-in Codex and Codex bridge profiles can run on native Codex with --target codex.',
- 'Saved default targets for API profiles and variants remain claude or droid.',
+ 'Saved default targets for API profiles and variants can now be claude, droid, or codex.',
],
actions: [
{
@@ -256,7 +256,7 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [
routes: [{ label: 'Codex CLI', path: '/codex' }],
commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex', 'ccs codex-api --target codex'],
notes:
- 'Runtime-only in v1. Saved default targets for API profiles and CLIProxy variants remain claude or droid.',
+ 'Saved default targets for API profiles and CLIProxy variants can now be claude, droid, or codex.',
},
{
id: 'codex-cliproxy',
diff --git a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx
index 9a29cb7d..4958cc72 100644
--- a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx
+++ b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx
@@ -80,17 +80,25 @@ describe('ImageAnalysisStatusSection', () => {
it('renders saved diagnostics for an active backend', () => {
render();
- expect(screen.getByText('Image-analysis backend')).toBeInTheDocument();
+ expect(screen.getByText('Image Analysis')).toBeInTheDocument();
expect(
screen.getByText(
/Saved runtime status for this profile\. Config stays in the JSON editor above; auth and proxy readiness are derived at runtime\./i
)
).toBeInTheDocument();
- expect(screen.getByText('Ready')).toBeInTheDocument();
+ expect(screen.getByText('Saved')).toBeInTheDocument();
+ expect(screen.getByText('CLIProxy Active')).toBeInTheDocument();
expect(
- screen.getByText(/Configured via Google Gemini\. Image and PDF reads use CLIProxy/i)
+ screen.getByText(
+ /Images and PDFs for this profile resolve through Google Gemini via CLIProxy\./i
+ )
).toBeInTheDocument();
+ expect(screen.getByText('Configured backend')).toBeInTheDocument();
+ expect(screen.getByText('Effective runtime')).toBeInTheDocument();
+ expect(screen.getByText('Hook persistence')).toBeInTheDocument();
expect(screen.getByText('Google Gemini')).toBeInTheDocument();
+ expect(screen.getByText('CLIProxy image analysis')).toBeInTheDocument();
+ expect(screen.getByText('Hook saved to profile')).toBeInTheDocument();
expect(screen.getByTitle(/\/api\/provider\/gemini/)).toBeInTheDocument();
expect(screen.getAllByText('Google Gemini ready')).toHaveLength(1);
expect(screen.getByText('Local CLIProxy ready')).toBeInTheDocument();
@@ -112,13 +120,14 @@ describe('ImageAnalysisStatusSection', () => {
/>
);
- expect(screen.getByText('Ready via mapping')).toBeInTheDocument();
+ expect(screen.getByText('CLIProxy Active')).toBeInTheDocument();
expect(
screen.getByText(
- /Configured via saved GitHub Copilot \(OAuth\) mapping\. Auth and runtime are ready/i
+ /Images and PDFs for this profile resolve through GitHub Copilot \(OAuth\) via a saved Image Analysis mapping\./i
)
).toBeInTheDocument();
expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
+ expect(screen.getByText('Saved Image Analysis mapping')).toBeInTheDocument();
expect(screen.getByText('claude-haiku-4.5')).toBeInTheDocument();
});
@@ -136,8 +145,11 @@ describe('ImageAnalysisStatusSection', () => {
expect(screen.getByText('Setup needed')).toBeInTheDocument();
expect(
- screen.getByText(/Configured for Google Gemini, but Profile hook is missing/i)
+ screen.getByText(
+ /Images and PDFs are configured for Google Gemini, but Profile hook is missing/i
+ )
).toBeInTheDocument();
+ expect(screen.getByText('Native file access')).toBeInTheDocument();
expect(screen.getByTitle(/native file access/i)).toBeInTheDocument();
});
@@ -163,11 +175,12 @@ describe('ImageAnalysisStatusSection', () => {
expect(screen.getByText('Needs auth')).toBeInTheDocument();
expect(
- screen.getByText(
- /Configured via GitHub Copilot \(OAuth\), but GitHub Copilot \(OAuth\) auth is missing/i
- )
+ screen.getByText(/This profile currently falls back to native file access\./i)
).toBeInTheDocument();
- expect(screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i)).toHaveLength(3);
+ expect(screen.getByText('Native file access')).toBeInTheDocument();
+ expect(
+ screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i).length
+ ).toBeGreaterThanOrEqual(3);
});
it('treats an idle local proxy as launchable instead of unavailable', () => {
@@ -183,12 +196,81 @@ describe('ImageAnalysisStatusSection', () => {
expect(screen.getByText('Starts on launch')).toBeInTheDocument();
expect(
- screen.getByText(/Auth is ready and CCS will start the local CLIProxy runtime on launch/i)
+ screen.getByText(
+ /Images and PDFs for this profile resolve through Google Gemini\. Auth is ready and CCS will start the local CLIProxy runtime when needed\./i
+ )
).toBeInTheDocument();
expect(screen.getByText('Local CLIProxy idle; starts on launch')).toBeInTheDocument();
+ expect(screen.getByText('Auth ready • Local CLIProxy starts on demand')).toBeInTheDocument();
expect(screen.getByTitle(/start local CLIProxy/i)).toBeInTheDocument();
});
+ it('shows saved Claude-side diagnostics when the current target bypasses the hook', () => {
+ render();
+
+ expect(screen.getByText('Target bypassed')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /Images and PDFs for this profile are configured to resolve through Google Gemini when the profile runs on Claude Code\./i
+ )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /Current default target: Factory Droid\. The diagnostics below describe the saved Claude-side Image Analysis hook/i
+ )
+ ).toBeInTheDocument();
+ expect(screen.getByText('Not active on Factory Droid')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /Factory Droid bypasses the Claude Read hook\. Switch the target back to Claude Code to use the saved backend shown here\./i
+ )
+ ).toBeInTheDocument();
+ expect(screen.getByTitle(/Factory Droid launch -> no Claude Read hook/i)).toBeInTheDocument();
+ });
+
+ it('keeps saved Claude-side auth failures visible when another target bypasses the hook', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Needs auth')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /Current default target: Codex CLI\. This launch path bypasses the Claude Read hook, and the saved Claude-side setup for GitHub Copilot \(OAuth\) still falls back to native file access\./i
+ )
+ ).toBeInTheDocument();
+ expect(
+ screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i).length
+ ).toBeGreaterThanOrEqual(1);
+ });
+
+ it('falls back to backend id when a display name is unavailable', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('ghcp')).toBeInTheDocument();
+ });
+
it('switches the panel to a live preview when the current editor JSON changes', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
@@ -305,4 +387,123 @@ describe('ImageAnalysisStatusSection', () => {
});
expect(screen.getByText('Google Gemini')).toBeInTheDocument();
});
+
+ it('marks the preview as refreshing when a newer editor preview is still loading', async () => {
+ let secondPreviewResolver: ((value: Response) => void) | null = null;
+ const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+
+ if (url.includes('/api/settings/glm/raw')) {
+ return Promise.resolve(
+ createJsonResponse({
+ profile: 'glm',
+ settings: {
+ env: {
+ ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
+ ANTHROPIC_AUTH_TOKEN: 'saved-token',
+ },
+ },
+ mtime: 1,
+ path: '/tmp/glm.settings.json',
+ imageAnalysisStatus: createStatus(),
+ })
+ );
+ }
+
+ if (url.includes('/api/settings/glm/image-analysis-status')) {
+ expect(init?.method).toBe('POST');
+ const body = JSON.parse(String(init?.body ?? '{}')) as {
+ settings?: { env?: Record };
+ };
+ const baseUrl = body.settings?.env?.ANTHROPIC_BASE_URL ?? '';
+
+ if (baseUrl.includes('/ghcp')) {
+ return Promise.resolve(
+ createJsonResponse({
+ imageAnalysisStatus: createStatus({
+ backendId: 'ghcp',
+ backendDisplayName: 'GitHub Copilot (OAuth)',
+ model: 'claude-haiku-4.5',
+ runtimePath: '/api/provider/ghcp',
+ authReadiness: 'ready',
+ authProvider: 'ghcp',
+ authDisplayName: 'GitHub Copilot (OAuth)',
+ }),
+ })
+ );
+ }
+
+ if (baseUrl.includes('/codex')) {
+ return new Promise((resolve) => {
+ secondPreviewResolver = resolve;
+ });
+ }
+ }
+
+ return Promise.reject(new Error(`Unexpected fetch: ${url}`));
+ });
+
+ vi.stubGlobal('fetch', fetchMock);
+
+ render();
+
+ expect(await screen.findByText('Google Gemini')).toBeInTheDocument();
+
+ fireEvent.change(screen.getByLabelText('raw config editor'), {
+ target: {
+ value: JSON.stringify(
+ {
+ env: {
+ ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/ghcp',
+ ANTHROPIC_AUTH_TOKEN: 'preview-token',
+ },
+ },
+ null,
+ 2
+ ),
+ },
+ });
+
+ expect(await screen.findByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
+
+ fireEvent.change(screen.getByLabelText('raw config editor'), {
+ target: {
+ value: JSON.stringify(
+ {
+ env: {
+ ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/codex',
+ ANTHROPIC_AUTH_TOKEN: 'preview-token-2',
+ },
+ },
+ null,
+ 2
+ ),
+ },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Refreshing')).toBeInTheDocument();
+ });
+ expect(
+ screen.getByText(/Refreshing the live preview from the current editor state\./i)
+ ).toBeInTheDocument();
+
+ secondPreviewResolver?.(
+ createJsonResponse({
+ imageAnalysisStatus: createStatus({
+ backendId: 'codex',
+ backendDisplayName: 'Codex',
+ model: 'gpt-5.4',
+ runtimePath: '/api/provider/codex',
+ authReadiness: 'ready',
+ authProvider: 'codex',
+ authDisplayName: 'Codex',
+ }),
+ })
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Codex')).toBeInTheDocument();
+ });
+ });
});