mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 10:19:37 +00:00
fix(codex): harden dashboard config editing
This commit is contained in:
@@ -0,0 +1,214 @@
|
|||||||
|
export interface CompatibleCliDocLink {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
category: 'overview' | 'configuration' | 'byok' | 'reference';
|
||||||
|
source: 'factory' | 'provider' | 'openai' | 'github';
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompatibleCliProviderDocLink {
|
||||||
|
provider: string;
|
||||||
|
label: string;
|
||||||
|
apiFormat: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompatibleCliDocsReference {
|
||||||
|
providerValues: string[];
|
||||||
|
settingsHierarchy: string[];
|
||||||
|
notes: string[];
|
||||||
|
links: CompatibleCliDocLink[];
|
||||||
|
providerDocs: CompatibleCliProviderDocLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodexBinarySource = 'CCS_CODEX_PATH' | 'PATH' | 'missing';
|
||||||
|
|
||||||
|
export interface CodexBinaryDiagnostics {
|
||||||
|
installed: boolean;
|
||||||
|
path: string | null;
|
||||||
|
installDir: string | null;
|
||||||
|
source: CodexBinarySource;
|
||||||
|
version: string | null;
|
||||||
|
overridePath: string | null;
|
||||||
|
supportsConfigOverrides: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexConfigFileDiagnostics {
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
resolvedPath: string;
|
||||||
|
exists: boolean;
|
||||||
|
isSymlink: boolean;
|
||||||
|
isRegularFile: boolean;
|
||||||
|
sizeBytes: number | null;
|
||||||
|
mtimeMs: number | null;
|
||||||
|
parseError: string | null;
|
||||||
|
readError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexModelProviderDiagnostics {
|
||||||
|
name: string;
|
||||||
|
baseUrl: string | null;
|
||||||
|
envKey: string | null;
|
||||||
|
wireApi: string | null;
|
||||||
|
requiresOpenaiAuth: boolean;
|
||||||
|
supportsWebsockets: boolean;
|
||||||
|
hasQueryParams: boolean;
|
||||||
|
hasHttpHeaders: boolean;
|
||||||
|
usesExperimentalBearerToken: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexFeatureFlagDiagnostics {
|
||||||
|
name: string;
|
||||||
|
state: 'enabled' | 'disabled' | 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexProjectTrustDiagnostics {
|
||||||
|
path: string;
|
||||||
|
trustLevel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexMcpServerDiagnostics {
|
||||||
|
name: string;
|
||||||
|
transport: 'stdio' | 'streamable-http' | 'unknown';
|
||||||
|
enabled: boolean;
|
||||||
|
required: boolean;
|
||||||
|
startupTimeoutSec: number | null;
|
||||||
|
toolTimeoutSec: number | null;
|
||||||
|
enabledToolsCount: number;
|
||||||
|
disabledToolsCount: number;
|
||||||
|
usesInlineBearerToken: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexSupportMatrixEntry {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
supported: boolean;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexUserConfigDiagnostics {
|
||||||
|
model: string | null;
|
||||||
|
modelReasoningEffort: string | null;
|
||||||
|
modelProvider: string | null;
|
||||||
|
activeProfile: string | null;
|
||||||
|
approvalPolicy: string | null;
|
||||||
|
sandboxMode: string | null;
|
||||||
|
webSearch: string | null;
|
||||||
|
toolOutputTokenLimit: number | null;
|
||||||
|
personality: string | null;
|
||||||
|
topLevelKeys: string[];
|
||||||
|
profileCount: number;
|
||||||
|
profileNames: string[];
|
||||||
|
modelProviderCount: number;
|
||||||
|
modelProviders: CodexModelProviderDiagnostics[];
|
||||||
|
featureCount: number;
|
||||||
|
enabledFeatures: CodexFeatureFlagDiagnostics[];
|
||||||
|
disabledFeatures: CodexFeatureFlagDiagnostics[];
|
||||||
|
trustedProjectCount: number;
|
||||||
|
untrustedProjectCount: number;
|
||||||
|
projectTrust: CodexProjectTrustDiagnostics[];
|
||||||
|
mcpServerCount: number;
|
||||||
|
mcpServers: CodexMcpServerDiagnostics[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexDashboardDiagnostics {
|
||||||
|
binary: CodexBinaryDiagnostics;
|
||||||
|
file: CodexConfigFileDiagnostics;
|
||||||
|
workspacePath: string;
|
||||||
|
config: CodexUserConfigDiagnostics;
|
||||||
|
supportMatrix: CodexSupportMatrixEntry[];
|
||||||
|
warnings: string[];
|
||||||
|
docsReference: CompatibleCliDocsReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexRawConfigResponse {
|
||||||
|
path: string;
|
||||||
|
resolvedPath: string;
|
||||||
|
exists: boolean;
|
||||||
|
mtime: number;
|
||||||
|
rawText: string;
|
||||||
|
config: Record<string, unknown> | null;
|
||||||
|
parseError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexTopLevelSettingsPatch {
|
||||||
|
model?: string | null;
|
||||||
|
modelReasoningEffort?: string | null;
|
||||||
|
modelProvider?: string | null;
|
||||||
|
approvalPolicy?: string | null;
|
||||||
|
sandboxMode?: string | null;
|
||||||
|
webSearch?: string | null;
|
||||||
|
toolOutputTokenLimit?: number | null;
|
||||||
|
personality?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexProfilePatchValues extends CodexTopLevelSettingsPatch {}
|
||||||
|
|
||||||
|
export interface CodexModelProviderPatchValues {
|
||||||
|
displayName?: string | null;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
envKey?: string | null;
|
||||||
|
wireApi?: string | null;
|
||||||
|
requiresOpenaiAuth?: boolean | null;
|
||||||
|
supportsWebsockets?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexMcpServerPatchValues {
|
||||||
|
transport: 'stdio' | 'streamable-http';
|
||||||
|
command?: string | null;
|
||||||
|
args?: string[] | null;
|
||||||
|
url?: string | null;
|
||||||
|
enabled?: boolean | null;
|
||||||
|
required?: boolean | null;
|
||||||
|
startupTimeoutSec?: number | null;
|
||||||
|
toolTimeoutSec?: number | null;
|
||||||
|
enabledTools?: string[] | null;
|
||||||
|
disabledTools?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodexConfigPatchInput =
|
||||||
|
| {
|
||||||
|
kind: 'top-level';
|
||||||
|
expectedMtime?: number;
|
||||||
|
values: CodexTopLevelSettingsPatch;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'project-trust';
|
||||||
|
expectedMtime?: number;
|
||||||
|
path: string;
|
||||||
|
trustLevel: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'feature';
|
||||||
|
expectedMtime?: number;
|
||||||
|
feature: string;
|
||||||
|
enabled: boolean | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'profile';
|
||||||
|
expectedMtime?: number;
|
||||||
|
action: 'set-active' | 'upsert' | 'delete';
|
||||||
|
name: string;
|
||||||
|
values?: CodexProfilePatchValues;
|
||||||
|
setAsActive?: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'model-provider';
|
||||||
|
expectedMtime?: number;
|
||||||
|
action: 'upsert' | 'delete';
|
||||||
|
name: string;
|
||||||
|
values?: CodexModelProviderPatchValues;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'mcp-server';
|
||||||
|
expectedMtime?: number;
|
||||||
|
action: 'upsert' | 'delete';
|
||||||
|
name: string;
|
||||||
|
values?: CodexMcpServerPatchValues;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CodexConfigPatchResult extends CodexRawConfigResponse {
|
||||||
|
success: true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { parse } from 'smol-toml';
|
||||||
|
|
||||||
|
export interface SafeTomlObjectParseResult {
|
||||||
|
config: Record<string, unknown> | null;
|
||||||
|
parseError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTomlObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTomlObject(rawText: string): Record<string, unknown> {
|
||||||
|
const trimmed = rawText.trim();
|
||||||
|
if (!trimmed) return {};
|
||||||
|
|
||||||
|
const parsed = parse(rawText);
|
||||||
|
if (!isTomlObject(parsed)) {
|
||||||
|
throw new Error('TOML root must be a table.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeParseTomlObject(rawText: string): SafeTomlObjectParseResult {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
config: parseTomlObject(rawText),
|
||||||
|
parseError: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
config: null,
|
||||||
|
parseError: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,8 +134,7 @@ function setEnumStringField(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalized = value.trim();
|
const normalized = value.trim();
|
||||||
const currentValue = asString(target[key]);
|
if (!allowedValues.has(normalized)) {
|
||||||
if (!allowedValues.has(normalized) && normalized !== currentValue) {
|
|
||||||
throw new TomlFileValidationError(
|
throw new TomlFileValidationError(
|
||||||
`${label} must be one of: ${Array.from(allowedValues).join(', ')}.`
|
`${label} must be one of: ${Array.from(allowedValues).join(', ')}.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { parse, stringify } from 'smol-toml';
|
import { stringify } from 'smol-toml';
|
||||||
|
import { parseTomlObject } from '../../shared/toml-object';
|
||||||
|
|
||||||
export interface TomlFileDiagnostics {
|
export interface TomlFileDiagnostics {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -67,6 +68,111 @@ async function statPath(filePath: string): Promise<import('fs').Stats | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveConflictMtime(filePath: string): Promise<number> {
|
||||||
|
const stat = await statPath(filePath);
|
||||||
|
return stat?.mtimeMs ?? Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquireWriteLock(
|
||||||
|
lockPath: string,
|
||||||
|
targetPath: string,
|
||||||
|
fileLabel: string
|
||||||
|
): Promise<() => Promise<void>> {
|
||||||
|
let handle: Awaited<ReturnType<typeof fs.open>> | null = null;
|
||||||
|
try {
|
||||||
|
handle = await fs.open(lockPath, 'wx', 0o600);
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
if (code === 'EEXIST') {
|
||||||
|
const existingLock = await statPath(lockPath);
|
||||||
|
if (existingLock?.isSymbolicLink()) {
|
||||||
|
throw new Error(`Refusing to write: ${fileLabel}.lock is a symlink.`);
|
||||||
|
}
|
||||||
|
if (existingLock && !existingLock.isFile()) {
|
||||||
|
throw new Error(`Refusing to write: ${fileLabel}.lock is not a regular file.`);
|
||||||
|
}
|
||||||
|
throw new TomlFileConflictError(
|
||||||
|
'File is currently being written by another request. Refresh and retry.',
|
||||||
|
await resolveConflictMtime(targetPath)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
if (!handle) return;
|
||||||
|
try {
|
||||||
|
await handle.close();
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await fs.unlink(lockPath);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWritableTarget(
|
||||||
|
targetStat: import('fs').Stats | null,
|
||||||
|
fileLabel: string
|
||||||
|
): import('fs').Stats | null {
|
||||||
|
if (!targetStat) return null;
|
||||||
|
if (targetStat.isSymbolicLink()) {
|
||||||
|
throw new Error(`Refusing to write: ${fileLabel} is a symlink.`);
|
||||||
|
}
|
||||||
|
if (!targetStat.isFile()) {
|
||||||
|
throw new Error(`Refusing to write: ${fileLabel} is not a regular file.`);
|
||||||
|
}
|
||||||
|
return targetStat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertExpectedMtime(
|
||||||
|
targetStat: import('fs').Stats | null,
|
||||||
|
expectedMtime: number | undefined
|
||||||
|
): void {
|
||||||
|
if (!targetStat) {
|
||||||
|
if (expectedMtime !== undefined) {
|
||||||
|
throw new TomlFileConflictError('File modified externally.', Date.now());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime)) {
|
||||||
|
throw new TomlFileConflictError(
|
||||||
|
'File metadata not loaded. Refresh and retry.',
|
||||||
|
targetStat.mtimeMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (targetStat.mtimeMs !== expectedMtime) {
|
||||||
|
throw new TomlFileConflictError('File modified externally.', targetStat.mtimeMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyTargetUnchanged(
|
||||||
|
targetPath: string,
|
||||||
|
initialTargetStat: import('fs').Stats | null,
|
||||||
|
fileLabel: string
|
||||||
|
): Promise<void> {
|
||||||
|
const currentTargetStat = ensureWritableTarget(await statPath(targetPath), fileLabel);
|
||||||
|
|
||||||
|
if (!initialTargetStat) {
|
||||||
|
if (currentTargetStat) {
|
||||||
|
throw new TomlFileConflictError('File modified externally.', currentTargetStat.mtimeMs);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentTargetStat || currentTargetStat.mtimeMs !== initialTargetStat.mtimeMs) {
|
||||||
|
throw new TomlFileConflictError(
|
||||||
|
'File modified externally.',
|
||||||
|
currentTargetStat?.mtimeMs ?? Date.now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function parseTomlObjectText(
|
export function parseTomlObjectText(
|
||||||
rawText: string,
|
rawText: string,
|
||||||
fieldName = 'rawText'
|
fieldName = 'rawText'
|
||||||
@@ -75,21 +181,15 @@ export function parseTomlObjectText(
|
|||||||
throw new TomlFileValidationError(`${fieldName} must be a string.`);
|
throw new TomlFileValidationError(`${fieldName} must be a string.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = rawText.trim();
|
|
||||||
if (!trimmed) return {};
|
|
||||||
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
try {
|
||||||
parsed = parse(rawText);
|
return parseTomlObject(rawText);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new TomlFileValidationError(`Invalid TOML in ${fieldName}: ${(error as Error).message}`);
|
const message = (error as Error).message;
|
||||||
|
if (message === 'TOML root must be a table.') {
|
||||||
|
throw new TomlFileValidationError(`${fieldName} TOML root must be a table.`);
|
||||||
|
}
|
||||||
|
throw new TomlFileValidationError(`Invalid TOML in ${fieldName}: ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isObject(parsed)) {
|
|
||||||
throw new TomlFileValidationError(`${fieldName} TOML root must be a table.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stringifyTomlObject(config: Record<string, unknown>): string {
|
export function stringifyTomlObject(config: Record<string, unknown>): string {
|
||||||
@@ -170,45 +270,21 @@ export async function writeTomlFileAtomic(input: WriteTomlFileInput): Promise<Wr
|
|||||||
|
|
||||||
const targetPath = input.filePath;
|
const targetPath = input.filePath;
|
||||||
const targetDir = path.dirname(targetPath);
|
const targetDir = path.dirname(targetPath);
|
||||||
const tempPath = targetPath + '.tmp';
|
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
const lockPath = `${targetPath}.lock`;
|
||||||
const dirMode = input.dirMode ?? 0o700;
|
const dirMode = input.dirMode ?? 0o700;
|
||||||
const fileMode = input.fileMode ?? 0o600;
|
const fileMode = input.fileMode ?? 0o600;
|
||||||
|
|
||||||
await fs.mkdir(targetDir, { recursive: true, mode: dirMode });
|
await fs.mkdir(targetDir, { recursive: true, mode: dirMode });
|
||||||
|
|
||||||
const targetStat = await statPath(targetPath);
|
const releaseLock = await acquireWriteLock(lockPath, targetPath, fileLabel);
|
||||||
if (targetStat) {
|
|
||||||
if (targetStat.isSymbolicLink()) {
|
|
||||||
throw new Error(`Refusing to write: ${fileLabel} is a symlink.`);
|
|
||||||
}
|
|
||||||
if (!targetStat.isFile()) {
|
|
||||||
throw new Error(`Refusing to write: ${fileLabel} is not a regular file.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof input.expectedMtime !== 'number' || !Number.isFinite(input.expectedMtime)) {
|
|
||||||
throw new TomlFileConflictError(
|
|
||||||
'File metadata not loaded. Refresh and retry.',
|
|
||||||
targetStat.mtimeMs
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (Math.abs(targetStat.mtimeMs - input.expectedMtime) > 1000) {
|
|
||||||
throw new TomlFileConflictError('File modified externally.', targetStat.mtimeMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let wroteTemp = false;
|
let wroteTemp = false;
|
||||||
try {
|
try {
|
||||||
const existingTempStat = await statPath(tempPath);
|
const targetStat = ensureWritableTarget(await statPath(targetPath), fileLabel);
|
||||||
if (existingTempStat) {
|
assertExpectedMtime(targetStat, input.expectedMtime);
|
||||||
if (existingTempStat.isSymbolicLink()) {
|
|
||||||
throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`);
|
|
||||||
}
|
|
||||||
if (!existingTempStat.isFile()) {
|
|
||||||
throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.writeFile(tempPath, input.rawText, { mode: fileMode });
|
await fs.writeFile(tempPath, input.rawText, { mode: fileMode, flag: 'wx' });
|
||||||
wroteTemp = true;
|
wroteTemp = true;
|
||||||
|
|
||||||
const tempStat = await fs.lstat(tempPath);
|
const tempStat = await fs.lstat(tempPath);
|
||||||
@@ -219,6 +295,7 @@ export async function writeTomlFileAtomic(input: WriteTomlFileInput): Promise<Wr
|
|||||||
throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`);
|
throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await verifyTargetUnchanged(targetPath, targetStat, fileLabel);
|
||||||
await fs.rename(tempPath, targetPath);
|
await fs.rename(tempPath, targetPath);
|
||||||
wroteTemp = false;
|
wroteTemp = false;
|
||||||
|
|
||||||
@@ -240,5 +317,7 @@ export async function writeTomlFileAtomic(input: WriteTomlFileInput): Promise<Wr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await releaseLock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
|
import type { CompatibleCliDocsReference } from '../../shared/compatible-cli-contracts';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CompatibleCliDocLink,
|
||||||
|
CompatibleCliDocsReference,
|
||||||
|
CompatibleCliProviderDocLink,
|
||||||
|
CodexBinaryDiagnostics,
|
||||||
|
CodexBinarySource,
|
||||||
|
CodexConfigFileDiagnostics,
|
||||||
|
CodexConfigPatchInput,
|
||||||
|
CodexConfigPatchResult,
|
||||||
|
CodexDashboardDiagnostics,
|
||||||
|
CodexFeatureFlagDiagnostics,
|
||||||
|
CodexMcpServerDiagnostics,
|
||||||
|
CodexMcpServerPatchValues,
|
||||||
|
CodexModelProviderDiagnostics,
|
||||||
|
CodexModelProviderPatchValues,
|
||||||
|
CodexProfilePatchValues,
|
||||||
|
CodexProjectTrustDiagnostics,
|
||||||
|
CodexRawConfigResponse,
|
||||||
|
CodexSupportMatrixEntry,
|
||||||
|
CodexTopLevelSettingsPatch,
|
||||||
|
CodexUserConfigDiagnostics,
|
||||||
|
} from '../../shared/compatible-cli-contracts';
|
||||||
|
|
||||||
export type DroidBinarySource = 'CCS_DROID_PATH' | 'PATH' | 'missing';
|
export type DroidBinarySource = 'CCS_DROID_PATH' | 'PATH' | 'missing';
|
||||||
|
|
||||||
export interface DroidBinaryDiagnostics {
|
export interface DroidBinaryDiagnostics {
|
||||||
@@ -44,30 +69,6 @@ export interface DroidByokDiagnostics {
|
|||||||
customModels: DroidCustomModelDiagnostics[];
|
customModels: DroidCustomModelDiagnostics[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompatibleCliDocLink {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
url: string;
|
|
||||||
category: 'overview' | 'configuration' | 'byok' | 'reference';
|
|
||||||
source: 'factory' | 'provider' | 'openai' | 'github';
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompatibleCliProviderDocLink {
|
|
||||||
provider: string;
|
|
||||||
label: string;
|
|
||||||
apiFormat: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompatibleCliDocsReference {
|
|
||||||
providerValues: string[];
|
|
||||||
settingsHierarchy: string[];
|
|
||||||
notes: string[];
|
|
||||||
links: CompatibleCliDocLink[];
|
|
||||||
providerDocs: CompatibleCliProviderDocLink[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DroidDashboardDiagnostics {
|
export interface DroidDashboardDiagnostics {
|
||||||
binary: DroidBinaryDiagnostics;
|
binary: DroidBinaryDiagnostics;
|
||||||
files: {
|
files: {
|
||||||
@@ -88,183 +89,3 @@ export interface DroidRawSettingsResponse {
|
|||||||
settings: Record<string, unknown> | null;
|
settings: Record<string, unknown> | null;
|
||||||
parseError: string | null;
|
parseError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CodexBinarySource = 'CCS_CODEX_PATH' | 'PATH' | 'missing';
|
|
||||||
|
|
||||||
export interface CodexBinaryDiagnostics {
|
|
||||||
installed: boolean;
|
|
||||||
path: string | null;
|
|
||||||
installDir: string | null;
|
|
||||||
source: CodexBinarySource;
|
|
||||||
version: string | null;
|
|
||||||
overridePath: string | null;
|
|
||||||
supportsConfigOverrides: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CodexConfigFileDiagnostics = DroidConfigFileDiagnostics;
|
|
||||||
|
|
||||||
export interface CodexModelProviderDiagnostics {
|
|
||||||
name: string;
|
|
||||||
baseUrl: string | null;
|
|
||||||
envKey: string | null;
|
|
||||||
wireApi: string | null;
|
|
||||||
requiresOpenaiAuth: boolean;
|
|
||||||
supportsWebsockets: boolean;
|
|
||||||
hasQueryParams: boolean;
|
|
||||||
hasHttpHeaders: boolean;
|
|
||||||
usesExperimentalBearerToken: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexFeatureFlagDiagnostics {
|
|
||||||
name: string;
|
|
||||||
state: 'enabled' | 'disabled' | 'custom';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexProjectTrustDiagnostics {
|
|
||||||
path: string;
|
|
||||||
trustLevel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexMcpServerDiagnostics {
|
|
||||||
name: string;
|
|
||||||
transport: 'stdio' | 'streamable-http' | 'unknown';
|
|
||||||
enabled: boolean;
|
|
||||||
required: boolean;
|
|
||||||
startupTimeoutSec: number | null;
|
|
||||||
toolTimeoutSec: number | null;
|
|
||||||
enabledToolsCount: number;
|
|
||||||
disabledToolsCount: number;
|
|
||||||
usesInlineBearerToken: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexSupportMatrixEntry {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
supported: boolean;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexUserConfigDiagnostics {
|
|
||||||
model: string | null;
|
|
||||||
modelReasoningEffort: string | null;
|
|
||||||
modelProvider: string | null;
|
|
||||||
activeProfile: string | null;
|
|
||||||
approvalPolicy: string | null;
|
|
||||||
sandboxMode: string | null;
|
|
||||||
webSearch: string | null;
|
|
||||||
toolOutputTokenLimit: number | null;
|
|
||||||
personality: string | null;
|
|
||||||
topLevelKeys: string[];
|
|
||||||
profileCount: number;
|
|
||||||
profileNames: string[];
|
|
||||||
modelProviderCount: number;
|
|
||||||
modelProviders: CodexModelProviderDiagnostics[];
|
|
||||||
featureCount: number;
|
|
||||||
enabledFeatures: CodexFeatureFlagDiagnostics[];
|
|
||||||
disabledFeatures: CodexFeatureFlagDiagnostics[];
|
|
||||||
trustedProjectCount: number;
|
|
||||||
untrustedProjectCount: number;
|
|
||||||
projectTrust: CodexProjectTrustDiagnostics[];
|
|
||||||
mcpServerCount: number;
|
|
||||||
mcpServers: CodexMcpServerDiagnostics[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexDashboardDiagnostics {
|
|
||||||
binary: CodexBinaryDiagnostics;
|
|
||||||
file: CodexConfigFileDiagnostics;
|
|
||||||
workspacePath: string;
|
|
||||||
config: CodexUserConfigDiagnostics;
|
|
||||||
supportMatrix: CodexSupportMatrixEntry[];
|
|
||||||
warnings: string[];
|
|
||||||
docsReference: CompatibleCliDocsReference;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexRawConfigResponse {
|
|
||||||
path: string;
|
|
||||||
resolvedPath: string;
|
|
||||||
exists: boolean;
|
|
||||||
mtime: number;
|
|
||||||
rawText: string;
|
|
||||||
config: Record<string, unknown> | null;
|
|
||||||
parseError: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexTopLevelSettingsPatch {
|
|
||||||
model?: string | null;
|
|
||||||
modelReasoningEffort?: string | null;
|
|
||||||
modelProvider?: string | null;
|
|
||||||
approvalPolicy?: string | null;
|
|
||||||
sandboxMode?: string | null;
|
|
||||||
webSearch?: string | null;
|
|
||||||
toolOutputTokenLimit?: number | null;
|
|
||||||
personality?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexProfilePatchValues extends CodexTopLevelSettingsPatch {}
|
|
||||||
|
|
||||||
export interface CodexModelProviderPatchValues {
|
|
||||||
displayName?: string | null;
|
|
||||||
baseUrl?: string | null;
|
|
||||||
envKey?: string | null;
|
|
||||||
wireApi?: string | null;
|
|
||||||
requiresOpenaiAuth?: boolean | null;
|
|
||||||
supportsWebsockets?: boolean | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexMcpServerPatchValues {
|
|
||||||
transport: 'stdio' | 'streamable-http';
|
|
||||||
command?: string | null;
|
|
||||||
args?: string[] | null;
|
|
||||||
url?: string | null;
|
|
||||||
enabled?: boolean | null;
|
|
||||||
required?: boolean | null;
|
|
||||||
startupTimeoutSec?: number | null;
|
|
||||||
toolTimeoutSec?: number | null;
|
|
||||||
enabledTools?: string[] | null;
|
|
||||||
disabledTools?: string[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CodexConfigPatchInput =
|
|
||||||
| {
|
|
||||||
kind: 'top-level';
|
|
||||||
expectedMtime?: number;
|
|
||||||
values: CodexTopLevelSettingsPatch;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: 'project-trust';
|
|
||||||
expectedMtime?: number;
|
|
||||||
path: string;
|
|
||||||
trustLevel: string | null;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: 'feature';
|
|
||||||
expectedMtime?: number;
|
|
||||||
feature: string;
|
|
||||||
enabled: boolean | null;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: 'profile';
|
|
||||||
expectedMtime?: number;
|
|
||||||
action: 'set-active' | 'upsert' | 'delete';
|
|
||||||
name: string;
|
|
||||||
values?: CodexProfilePatchValues;
|
|
||||||
setAsActive?: boolean;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: 'model-provider';
|
|
||||||
expectedMtime?: number;
|
|
||||||
action: 'upsert' | 'delete';
|
|
||||||
name: string;
|
|
||||||
values?: CodexModelProviderPatchValues;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: 'mcp-server';
|
|
||||||
expectedMtime?: number;
|
|
||||||
action: 'upsert' | 'delete';
|
|
||||||
name: string;
|
|
||||||
values?: CodexMcpServerPatchValues;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CodexConfigPatchResult extends CodexRawConfigResponse {
|
|
||||||
success: true;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ const codexHome = path.join(testRoot, '.codex-home');
|
|||||||
const codexStubPath = path.join(testRoot, 'codex');
|
const codexStubPath = path.join(testRoot, 'codex');
|
||||||
|
|
||||||
function writeCodexStub(options?: { helpText?: string; version?: string }) {
|
function writeCodexStub(options?: { helpText?: string; version?: string }) {
|
||||||
const helpText = options?.helpText ?? ' -c, --config <key=value>\n -p, --profile <CONFIG_PROFILE>\n';
|
const helpText =
|
||||||
|
options?.helpText ?? ' -c, --config <key=value>\n -p, --profile <CONFIG_PROFILE>\n';
|
||||||
const version = options?.version ?? 'codex-cli 0.118.0-alpha.3';
|
const version = options?.version ?? 'codex-cli 0.118.0-alpha.3';
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -264,6 +265,20 @@ bearer_token = "secret"
|
|||||||
).rejects.toThrow(CodexRawConfigConflictError);
|
).rejects.toThrow(CodexRawConfigConflictError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects writes when expectedMtime differs by even 1ms', async () => {
|
||||||
|
const configPath = path.join(codexHome, 'config.toml');
|
||||||
|
fs.writeFileSync(configPath, 'model = "gpt-5.4"\n');
|
||||||
|
|
||||||
|
const current = await getCodexRawConfig();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
saveCodexRawConfig({
|
||||||
|
rawText: 'model = "gpt-5.4"\nprofile = "work"\n',
|
||||||
|
expectedMtime: current.mtime + 1,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(CodexRawConfigConflictError);
|
||||||
|
});
|
||||||
|
|
||||||
it('patches top-level settings and project trust through structured controls', async () => {
|
it('patches top-level settings and project trust through structured controls', async () => {
|
||||||
const result = await patchCodexConfig({
|
const result = await patchCodexConfig({
|
||||||
kind: 'top-level',
|
kind: 'top-level',
|
||||||
@@ -369,6 +384,39 @@ bearer_token = "secret"
|
|||||||
expect(profileResult.config?.profile).toBe('deep-review');
|
expect(profileResult.config?.profile).toBe('deep-review');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('patches streamable-http mcp servers through structured controls', async () => {
|
||||||
|
const result = await patchCodexConfig({
|
||||||
|
kind: 'mcp-server',
|
||||||
|
action: 'upsert',
|
||||||
|
name: 'remote',
|
||||||
|
values: {
|
||||||
|
transport: 'streamable-http',
|
||||||
|
url: 'https://example.test/mcp',
|
||||||
|
enabled: true,
|
||||||
|
required: true,
|
||||||
|
toolTimeoutSec: 45,
|
||||||
|
enabledTools: ['browser_snapshot'],
|
||||||
|
disabledTools: ['slow_tool'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.rawText).toContain('[mcp_servers.remote]');
|
||||||
|
expect(result.rawText).toContain('url = "https://example.test/mcp"');
|
||||||
|
expect(result.rawText).toContain('required = true');
|
||||||
|
|
||||||
|
const diagnostics = await getCodexDashboardDiagnostics();
|
||||||
|
expect(diagnostics.config.mcpServers).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'remote',
|
||||||
|
transport: 'streamable-http',
|
||||||
|
required: true,
|
||||||
|
toolTimeoutSec: 45,
|
||||||
|
enabledToolsCount: 1,
|
||||||
|
disabledToolsCount: 1,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects structured patches when config.toml is invalid', async () => {
|
it('rejects structured patches when config.toml is invalid', async () => {
|
||||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n[features\n');
|
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n[features\n');
|
||||||
|
|
||||||
@@ -431,4 +479,20 @@ bearer_token = "secret"
|
|||||||
})
|
})
|
||||||
).rejects.toThrow(CodexRawConfigValidationError);
|
).rejects.toThrow(CodexRawConfigValidationError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects invalid enum values even when they already exist in config.toml', async () => {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(codexHome, 'config.toml'),
|
||||||
|
'model = "gpt-5.4"\napproval_policy = "legacy"\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
patchCodexConfig({
|
||||||
|
kind: 'top-level',
|
||||||
|
values: {
|
||||||
|
approvalPolicy: 'legacy' as unknown as 'on-request' | 'never' | 'untrusted' | null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toThrow(CodexRawConfigValidationError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { Route } from 'lucide-react';
|
||||||
|
import { CodexFeaturesCard } from '@/components/compatible-cli/codex-features-card';
|
||||||
|
import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card';
|
||||||
|
import { CodexModelProvidersCard } from '@/components/compatible-cli/codex-model-providers-card';
|
||||||
|
import { CodexProfilesCard } from '@/components/compatible-cli/codex-profiles-card';
|
||||||
|
import { CodexProjectTrustCard } from '@/components/compatible-cli/codex-project-trust-card';
|
||||||
|
import { CodexTopLevelControlsCard } from '@/components/compatible-cli/codex-top-level-controls-card';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import type {
|
||||||
|
CodexConfigPatchInput,
|
||||||
|
CodexProfilePatchValues,
|
||||||
|
CodexTopLevelSettingsPatch,
|
||||||
|
} from '@/hooks/use-codex-types';
|
||||||
|
import type {
|
||||||
|
CodexFeatureCatalogEntry,
|
||||||
|
CodexMcpServerEntry,
|
||||||
|
CodexModelProviderEntry,
|
||||||
|
CodexProfileEntry,
|
||||||
|
CodexProjectTrustEntry,
|
||||||
|
CodexTopLevelSettingsView,
|
||||||
|
} from '@/lib/codex-config';
|
||||||
|
|
||||||
|
interface CodexControlCenterTabProps {
|
||||||
|
workspacePath: string;
|
||||||
|
activeProfile: string | null;
|
||||||
|
topLevelSettings: CodexTopLevelSettingsView;
|
||||||
|
projectTrustEntries: CodexProjectTrustEntry[];
|
||||||
|
profileEntries: CodexProfileEntry[];
|
||||||
|
modelProviderEntries: CodexModelProviderEntry[];
|
||||||
|
mcpServerEntries: CodexMcpServerEntry[];
|
||||||
|
featureCatalog: CodexFeatureCatalogEntry[];
|
||||||
|
featureState: Record<string, boolean | null>;
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason: string | null;
|
||||||
|
saving: boolean;
|
||||||
|
onPatch: (patch: CodexConfigPatchInput, successMessage: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodexControlCenterTab({
|
||||||
|
workspacePath,
|
||||||
|
activeProfile,
|
||||||
|
topLevelSettings,
|
||||||
|
projectTrustEntries,
|
||||||
|
profileEntries,
|
||||||
|
modelProviderEntries,
|
||||||
|
mcpServerEntries,
|
||||||
|
featureCatalog,
|
||||||
|
featureState,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
saving,
|
||||||
|
onPatch,
|
||||||
|
}: CodexControlCenterTabProps) {
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="space-y-4 pr-1">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Route className="h-4 w-4" />
|
||||||
|
Structured controls boundary
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
Guided controls write only the user-layer <code>config.toml</code>. They do not model
|
||||||
|
the full effective Codex runtime once trusted repo layers and CCS transient{' '}
|
||||||
|
<code>-c</code> overrides are involved.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Structured saves normalize TOML formatting and strip comments. Use the raw editor on
|
||||||
|
the right when exact layout matters.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<CodexTopLevelControlsCard
|
||||||
|
values={topLevelSettings}
|
||||||
|
providerNames={modelProviderEntries.map((entry) => entry.name)}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason={disabledReason}
|
||||||
|
saving={saving}
|
||||||
|
onSave={(values: CodexTopLevelSettingsPatch) =>
|
||||||
|
onPatch({ kind: 'top-level', values }, 'Saved top-level Codex settings.')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodexProjectTrustCard
|
||||||
|
workspacePath={workspacePath}
|
||||||
|
entries={projectTrustEntries}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason={disabledReason}
|
||||||
|
saving={saving}
|
||||||
|
onSave={(projectPath, trustLevel) =>
|
||||||
|
onPatch(
|
||||||
|
{ kind: 'project-trust', path: projectPath, trustLevel },
|
||||||
|
trustLevel ? 'Saved project trust entry.' : 'Removed project trust entry.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodexProfilesCard
|
||||||
|
activeProfile={activeProfile}
|
||||||
|
entries={profileEntries}
|
||||||
|
providerNames={modelProviderEntries.map((entry) => entry.name)}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason={disabledReason}
|
||||||
|
saving={saving}
|
||||||
|
onSave={(name, values: CodexProfilePatchValues, setAsActive) =>
|
||||||
|
onPatch(
|
||||||
|
{ kind: 'profile', action: 'upsert', name, values, setAsActive },
|
||||||
|
'Saved profile.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDelete={(name) =>
|
||||||
|
onPatch({ kind: 'profile', action: 'delete', name }, 'Deleted profile.')
|
||||||
|
}
|
||||||
|
onSetActive={(name) =>
|
||||||
|
onPatch({ kind: 'profile', action: 'set-active', name }, 'Set active profile.')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodexModelProvidersCard
|
||||||
|
entries={modelProviderEntries}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason={disabledReason}
|
||||||
|
saving={saving}
|
||||||
|
onSave={(name, values) =>
|
||||||
|
onPatch(
|
||||||
|
{ kind: 'model-provider', action: 'upsert', name, values },
|
||||||
|
'Saved model provider.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDelete={(name) =>
|
||||||
|
onPatch({ kind: 'model-provider', action: 'delete', name }, 'Deleted model provider.')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodexMcpServersCard
|
||||||
|
entries={mcpServerEntries}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason={disabledReason}
|
||||||
|
saving={saving}
|
||||||
|
onSave={(name, values) =>
|
||||||
|
onPatch({ kind: 'mcp-server', action: 'upsert', name, values }, 'Saved MCP server.')
|
||||||
|
}
|
||||||
|
onDelete={(name) =>
|
||||||
|
onPatch({ kind: 'mcp-server', action: 'delete', name }, 'Deleted MCP server.')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodexFeaturesCard
|
||||||
|
catalog={featureCatalog}
|
||||||
|
state={featureState}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason={disabledReason}
|
||||||
|
onToggle={(feature, enabled) =>
|
||||||
|
onPatch({ kind: 'feature', feature, enabled }, 'Saved feature toggle.')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import { ExternalLink, ShieldCheck } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import type {
|
||||||
|
CompatibleCliProviderDocLink,
|
||||||
|
CodexDashboardDiagnostics,
|
||||||
|
} from '@/hooks/use-codex-types';
|
||||||
|
|
||||||
|
const DEFAULT_CODEX_DOC_LINKS = [
|
||||||
|
{
|
||||||
|
id: 'codex-config-basic',
|
||||||
|
label: 'Codex Config Basics',
|
||||||
|
url: 'https://developers.openai.com/codex/config-basic',
|
||||||
|
description: 'Official user-layer setup, config location, and baseline configuration behavior.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'codex-config-advanced',
|
||||||
|
label: 'Codex Config Advanced',
|
||||||
|
url: 'https://developers.openai.com/codex/config-advanced',
|
||||||
|
description: 'Layering, trust, profiles, and advanced config behavior.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'codex-config-reference',
|
||||||
|
label: 'Codex Config Reference',
|
||||||
|
url: 'https://developers.openai.com/codex/config-reference',
|
||||||
|
description: 'Canonical upstream config surface for providers, MCP, features, and trust.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'codex-releases',
|
||||||
|
label: 'Codex GitHub Releases',
|
||||||
|
url: 'https://github.com/openai/codex/releases',
|
||||||
|
description: 'Track upstream release notes and fast-moving CLI changes.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_PROVIDER_DOCS: CompatibleCliProviderDocLink[] = [
|
||||||
|
{
|
||||||
|
provider: 'openai',
|
||||||
|
label: 'OpenAI Responses API',
|
||||||
|
apiFormat: 'Responses API',
|
||||||
|
url: 'https://platform.openai.com/docs/api-reference/responses',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderTextWithLinks(text: string): ReactNode[] {
|
||||||
|
const urlPattern = /https?:\/\/[^\s)]+/g;
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = urlPattern.exec(text)) !== null) {
|
||||||
|
const [url] = match;
|
||||||
|
const index = match.index;
|
||||||
|
|
||||||
|
if (index > cursor) {
|
||||||
|
nodes.push(text.slice(cursor, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.push(
|
||||||
|
<a
|
||||||
|
key={`${url}-${index}`}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="underline underline-offset-2 hover:text-foreground"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
cursor = index + url.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < text.length) {
|
||||||
|
nodes.push(text.slice(cursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes.length > 0 ? nodes : [text];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexDocsTabProps {
|
||||||
|
diagnostics: CodexDashboardDiagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
|
||||||
|
const docsReference = diagnostics.docsReference ?? {
|
||||||
|
notes: [],
|
||||||
|
links: [],
|
||||||
|
providerDocs: [],
|
||||||
|
providerValues: [],
|
||||||
|
settingsHierarchy: [],
|
||||||
|
};
|
||||||
|
const docsLinks = docsReference.links.length > 0 ? docsReference.links : DEFAULT_CODEX_DOC_LINKS;
|
||||||
|
const providerDocs =
|
||||||
|
docsReference.providerDocs.length > 0 ? docsReference.providerDocs : DEFAULT_PROVIDER_DOCS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="space-y-4 pr-1">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ShieldCheck className="h-4 w-4" />
|
||||||
|
Upstream notes
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm">
|
||||||
|
{docsReference.notes.map((note, index) => (
|
||||||
|
<p key={`${index}-${note}`} className="text-muted-foreground">
|
||||||
|
- {renderTextWithLinks(note)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Codex docs</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{docsLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.id}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="block rounded-md border px-2.5 py-2 transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-medium">{link.label}</span>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-[11px] text-muted-foreground">{link.description}</p>
|
||||||
|
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/90 underline underline-offset-2">
|
||||||
|
{link.url}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Provider / bridge reference
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{providerDocs.map((providerDoc) => (
|
||||||
|
<a
|
||||||
|
key={`${providerDoc.provider}-${providerDoc.url}`}
|
||||||
|
href={providerDoc.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="block rounded-md border px-2.5 py-2 transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-medium">{providerDoc.label}</span>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||||
|
provider: {providerDoc.provider} | format: {providerDoc.apiFormat}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/90 underline underline-offset-2">
|
||||||
|
{providerDoc.url}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{docsReference.providerValues.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Provider values: {docsReference.providerValues.join(', ')}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{docsReference.settingsHierarchy.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Settings hierarchy: {docsReference.settingsHierarchy.join(' -> ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Folder,
|
||||||
|
Info,
|
||||||
|
Route,
|
||||||
|
ShieldCheck,
|
||||||
|
TerminalSquare,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { QuickCommands } from '@/components/shared';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import type { CodexDashboardDiagnostics } from '@/hooks/use-codex-types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function formatTimestamp(value: number | null | undefined): string {
|
||||||
|
if (!value || !Number.isFinite(value)) return 'N/A';
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number | null | undefined): string {
|
||||||
|
if (!value || value <= 0) return '0 B';
|
||||||
|
if (value < 1024) return `${value} B`;
|
||||||
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-3 text-sm">
|
||||||
|
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||||
|
<span className={cn('text-right break-all', mono && 'font-mono text-xs')}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexOverviewTabProps {
|
||||||
|
diagnostics: CodexDashboardDiagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="space-y-4 pr-1">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
How Codex works in CCS
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p>Codex is a first-class runtime target in CCS, but it stays runtime-only in v1.</p>
|
||||||
|
<p>
|
||||||
|
Saved default targets for API profiles and variants still remain on Claude or Droid.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
CCS-backed Codex launches can apply transient <code>-c</code> overrides and inject
|
||||||
|
<code> CCS_CODEX_API_KEY</code>, so effective runtime values may not match this file
|
||||||
|
exactly.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<TerminalSquare className="h-4 w-4" />
|
||||||
|
Runtime install
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Status</span>
|
||||||
|
<Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}>
|
||||||
|
{diagnostics.binary.installed ? 'Detected' : 'Not found'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<DetailRow label="Detection source" value={diagnostics.binary.source} mono />
|
||||||
|
<DetailRow label="Binary path" value={diagnostics.binary.path || 'Not found'} mono />
|
||||||
|
<DetailRow
|
||||||
|
label="Install directory"
|
||||||
|
value={diagnostics.binary.installDir || 'N/A'}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow label="Version" value={diagnostics.binary.version || 'Unknown'} mono />
|
||||||
|
<DetailRow label="Alias commands" value="ccs-codex, ccsx" mono />
|
||||||
|
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||||
|
<span className="text-sm text-muted-foreground">--config override support</span>
|
||||||
|
<Badge variant={diagnostics.binary.supportsConfigOverrides ? 'default' : 'secondary'}>
|
||||||
|
{diagnostics.binary.supportsConfigOverrides ? 'Available' : 'Missing'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Folder className="h-4 w-4" />
|
||||||
|
Config file
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="rounded-md border p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium text-sm">User config</span>
|
||||||
|
{diagnostics.file.exists ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DetailRow label="Path" value={diagnostics.file.path} mono />
|
||||||
|
<DetailRow label="Resolved" value={diagnostics.file.resolvedPath} mono />
|
||||||
|
<DetailRow label="Size" value={formatBytes(diagnostics.file.sizeBytes)} />
|
||||||
|
<DetailRow label="Last modified" value={formatTimestamp(diagnostics.file.mtimeMs)} />
|
||||||
|
{diagnostics.file.parseError && (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
TOML warning: {diagnostics.file.parseError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{diagnostics.file.readError && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
Read warning: {diagnostics.file.readError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ShieldCheck className="h-4 w-4" />
|
||||||
|
Current user-layer summary
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<DetailRow label="Model" value={diagnostics.config.model || 'Not set'} mono />
|
||||||
|
<DetailRow
|
||||||
|
label="Model provider"
|
||||||
|
value={diagnostics.config.modelProvider || 'Not set'}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Active profile"
|
||||||
|
value={diagnostics.config.activeProfile || 'Not set'}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Approval policy"
|
||||||
|
value={diagnostics.config.approvalPolicy || 'Not set'}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Sandbox mode"
|
||||||
|
value={diagnostics.config.sandboxMode || 'Not set'}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow label="Web search" value={diagnostics.config.webSearch || 'Not set'} mono />
|
||||||
|
<Separator />
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<Badge variant="outline" className="justify-center">
|
||||||
|
providers: {diagnostics.config.modelProviderCount}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="justify-center">
|
||||||
|
profiles: {diagnostics.config.profileCount}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="justify-center">
|
||||||
|
enabled features: {diagnostics.config.enabledFeatures.length}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="justify-center">
|
||||||
|
MCP servers: {diagnostics.config.mcpServerCount}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{diagnostics.config.topLevelKeys.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
User-layer keys present
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{diagnostics.config.topLevelKeys.map((key) => (
|
||||||
|
<Badge key={key} variant="secondary" className="font-mono text-[10px]">
|
||||||
|
{key}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<QuickCommands
|
||||||
|
snippets={[
|
||||||
|
{
|
||||||
|
label: 'Native Codex',
|
||||||
|
command: 'ccs-codex',
|
||||||
|
description: 'Launch the native Codex runtime alias.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Short alias',
|
||||||
|
command: 'ccsx',
|
||||||
|
description: 'Launch the short Codex runtime alias.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Target override',
|
||||||
|
command: 'ccs codex --target codex "your prompt"',
|
||||||
|
description: 'Run a CCS profile on the native Codex target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Workspace trust',
|
||||||
|
command: `codex --profile ${diagnostics.config.activeProfile || 'default'}`,
|
||||||
|
description: 'Inspect the active profile directly in native Codex.',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Route className="h-4 w-4" />
|
||||||
|
Runtime vs provider
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="rounded-md border p-3 text-sm">
|
||||||
|
<p className="font-medium">Native Codex runtime</p>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Use <code>ccs-codex</code>, <code>ccsx</code>, or <code>--target codex</code>. CCS
|
||||||
|
launches the local Codex CLI and depends on native Codex capabilities such as{' '}
|
||||||
|
<code>--config</code> overrides.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-3 text-sm">
|
||||||
|
<p className="font-medium">Codex provider / bridge</p>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
CCS can route provider credentials transiently through CLIProxy. That is not the
|
||||||
|
same as editing local <code>config.toml</code>, and some routed values may never
|
||||||
|
persist here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Supported flows</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Flow</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Notes</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{diagnostics.supportMatrix.map((entry) => (
|
||||||
|
<TableRow key={entry.id}>
|
||||||
|
<TableCell className="font-mono text-xs">{entry.label}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={entry.supported ? 'default' : 'secondary'}>
|
||||||
|
{entry.supported ? 'Yes' : 'No'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">{entry.notes}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{diagnostics.warnings.length > 0 && (
|
||||||
|
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||||
|
Warnings
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1.5">
|
||||||
|
{diagnostics.warnings.map((warning) => (
|
||||||
|
<p key={warning} className="text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
- {warning}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
+22
-212
@@ -1,212 +1,22 @@
|
|||||||
export interface CompatibleCliDocLink {
|
export type {
|
||||||
id: string;
|
CompatibleCliDocLink,
|
||||||
label: string;
|
CompatibleCliDocsReference,
|
||||||
url: string;
|
CompatibleCliProviderDocLink,
|
||||||
category: 'overview' | 'configuration' | 'byok' | 'reference';
|
CodexBinaryDiagnostics,
|
||||||
source: 'factory' | 'provider' | 'openai' | 'github';
|
CodexBinarySource,
|
||||||
description: string;
|
CodexConfigFileDiagnostics,
|
||||||
}
|
CodexConfigPatchInput,
|
||||||
|
CodexConfigPatchResult,
|
||||||
export interface CompatibleCliProviderDocLink {
|
CodexDashboardDiagnostics,
|
||||||
provider: string;
|
CodexFeatureFlagDiagnostics,
|
||||||
label: string;
|
CodexMcpServerDiagnostics,
|
||||||
apiFormat: string;
|
CodexMcpServerPatchValues,
|
||||||
url: string;
|
CodexModelProviderDiagnostics,
|
||||||
}
|
CodexModelProviderPatchValues,
|
||||||
|
CodexProfilePatchValues,
|
||||||
export interface CompatibleCliDocsReference {
|
CodexProjectTrustDiagnostics,
|
||||||
providerValues: string[];
|
CodexRawConfigResponse,
|
||||||
settingsHierarchy: string[];
|
CodexSupportMatrixEntry,
|
||||||
notes: string[];
|
CodexTopLevelSettingsPatch,
|
||||||
links: CompatibleCliDocLink[];
|
CodexUserConfigDiagnostics,
|
||||||
providerDocs: CompatibleCliProviderDocLink[];
|
} from '@shared/compatible-cli-contracts';
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexBinaryDiagnostics {
|
|
||||||
installed: boolean;
|
|
||||||
path: string | null;
|
|
||||||
installDir: string | null;
|
|
||||||
source: 'CCS_CODEX_PATH' | 'PATH' | 'missing';
|
|
||||||
version: string | null;
|
|
||||||
overridePath: string | null;
|
|
||||||
supportsConfigOverrides: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexConfigFileDiagnostics {
|
|
||||||
label: string;
|
|
||||||
path: string;
|
|
||||||
resolvedPath: string;
|
|
||||||
exists: boolean;
|
|
||||||
isSymlink: boolean;
|
|
||||||
isRegularFile: boolean;
|
|
||||||
sizeBytes: number | null;
|
|
||||||
mtimeMs: number | null;
|
|
||||||
parseError: string | null;
|
|
||||||
readError: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexModelProviderDiagnostics {
|
|
||||||
name: string;
|
|
||||||
baseUrl: string | null;
|
|
||||||
envKey: string | null;
|
|
||||||
wireApi: string | null;
|
|
||||||
requiresOpenaiAuth: boolean;
|
|
||||||
supportsWebsockets: boolean;
|
|
||||||
hasQueryParams: boolean;
|
|
||||||
hasHttpHeaders: boolean;
|
|
||||||
usesExperimentalBearerToken: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexFeatureFlagDiagnostics {
|
|
||||||
name: string;
|
|
||||||
state: 'enabled' | 'disabled' | 'custom';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexProjectTrustDiagnostics {
|
|
||||||
path: string;
|
|
||||||
trustLevel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexMcpServerDiagnostics {
|
|
||||||
name: string;
|
|
||||||
transport: 'stdio' | 'streamable-http' | 'unknown';
|
|
||||||
enabled: boolean;
|
|
||||||
required: boolean;
|
|
||||||
startupTimeoutSec: number | null;
|
|
||||||
toolTimeoutSec: number | null;
|
|
||||||
enabledToolsCount: number;
|
|
||||||
disabledToolsCount: number;
|
|
||||||
usesInlineBearerToken: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexSupportMatrixEntry {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
supported: boolean;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexUserConfigDiagnostics {
|
|
||||||
model: string | null;
|
|
||||||
modelReasoningEffort: string | null;
|
|
||||||
modelProvider: string | null;
|
|
||||||
activeProfile: string | null;
|
|
||||||
approvalPolicy: string | null;
|
|
||||||
sandboxMode: string | null;
|
|
||||||
webSearch: string | null;
|
|
||||||
toolOutputTokenLimit: number | null;
|
|
||||||
personality: string | null;
|
|
||||||
topLevelKeys: string[];
|
|
||||||
profileCount: number;
|
|
||||||
profileNames: string[];
|
|
||||||
modelProviderCount: number;
|
|
||||||
modelProviders: CodexModelProviderDiagnostics[];
|
|
||||||
featureCount: number;
|
|
||||||
enabledFeatures: CodexFeatureFlagDiagnostics[];
|
|
||||||
disabledFeatures: CodexFeatureFlagDiagnostics[];
|
|
||||||
trustedProjectCount: number;
|
|
||||||
untrustedProjectCount: number;
|
|
||||||
projectTrust: CodexProjectTrustDiagnostics[];
|
|
||||||
mcpServerCount: number;
|
|
||||||
mcpServers: CodexMcpServerDiagnostics[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexDashboardDiagnostics {
|
|
||||||
binary: CodexBinaryDiagnostics;
|
|
||||||
file: CodexConfigFileDiagnostics;
|
|
||||||
workspacePath: string;
|
|
||||||
config: CodexUserConfigDiagnostics;
|
|
||||||
supportMatrix: CodexSupportMatrixEntry[];
|
|
||||||
warnings: string[];
|
|
||||||
docsReference: CompatibleCliDocsReference;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexRawConfigResponse {
|
|
||||||
path: string;
|
|
||||||
resolvedPath: string;
|
|
||||||
exists: boolean;
|
|
||||||
mtime: number;
|
|
||||||
rawText: string;
|
|
||||||
config: Record<string, unknown> | null;
|
|
||||||
parseError: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexTopLevelSettingsPatch {
|
|
||||||
model?: string | null;
|
|
||||||
modelReasoningEffort?: string | null;
|
|
||||||
modelProvider?: string | null;
|
|
||||||
approvalPolicy?: string | null;
|
|
||||||
sandboxMode?: string | null;
|
|
||||||
webSearch?: string | null;
|
|
||||||
toolOutputTokenLimit?: number | null;
|
|
||||||
personality?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CodexProfilePatchValues = CodexTopLevelSettingsPatch;
|
|
||||||
|
|
||||||
export interface CodexModelProviderPatchValues {
|
|
||||||
displayName?: string | null;
|
|
||||||
baseUrl?: string | null;
|
|
||||||
envKey?: string | null;
|
|
||||||
wireApi?: string | null;
|
|
||||||
requiresOpenaiAuth?: boolean | null;
|
|
||||||
supportsWebsockets?: boolean | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexMcpServerPatchValues {
|
|
||||||
transport: 'stdio' | 'streamable-http';
|
|
||||||
command?: string | null;
|
|
||||||
args?: string[] | null;
|
|
||||||
url?: string | null;
|
|
||||||
enabled?: boolean | null;
|
|
||||||
required?: boolean | null;
|
|
||||||
startupTimeoutSec?: number | null;
|
|
||||||
toolTimeoutSec?: number | null;
|
|
||||||
enabledTools?: string[] | null;
|
|
||||||
disabledTools?: string[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CodexConfigPatchInput =
|
|
||||||
| {
|
|
||||||
kind: 'top-level';
|
|
||||||
expectedMtime?: number;
|
|
||||||
values: CodexTopLevelSettingsPatch;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: 'project-trust';
|
|
||||||
expectedMtime?: number;
|
|
||||||
path: string;
|
|
||||||
trustLevel: string | null;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: 'feature';
|
|
||||||
expectedMtime?: number;
|
|
||||||
feature: string;
|
|
||||||
enabled: boolean | null;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: 'profile';
|
|
||||||
expectedMtime?: number;
|
|
||||||
action: 'set-active' | 'upsert' | 'delete';
|
|
||||||
name: string;
|
|
||||||
values?: CodexProfilePatchValues;
|
|
||||||
setAsActive?: boolean;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: 'model-provider';
|
|
||||||
expectedMtime?: number;
|
|
||||||
action: 'upsert' | 'delete';
|
|
||||||
name: string;
|
|
||||||
values?: CodexModelProviderPatchValues;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: 'mcp-server';
|
|
||||||
expectedMtime?: number;
|
|
||||||
action: 'upsert' | 'delete';
|
|
||||||
name: string;
|
|
||||||
values?: CodexMcpServerPatchValues;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CodexConfigPatchResult extends CodexRawConfigResponse {
|
|
||||||
success: true;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { parse as parseToml } from 'smol-toml';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ApiConflictError, withApiBase } from '@/lib/api-client';
|
import { ApiConflictError, withApiBase } from '@/lib/api-client';
|
||||||
|
import { safeParseTomlObject } from '@shared/toml-object';
|
||||||
import type {
|
import type {
|
||||||
CodexConfigPatchInput,
|
CodexConfigPatchInput,
|
||||||
CodexConfigPatchResult,
|
CodexConfigPatchResult,
|
||||||
@@ -23,30 +23,6 @@ interface SaveCodexRawConfigResponse {
|
|||||||
|
|
||||||
type PatchCodexConfigResponse = CodexConfigPatchResult;
|
type PatchCodexConfigResponse = CodexConfigPatchResult;
|
||||||
|
|
||||||
function parseCodexRawConfigText(rawText: string): {
|
|
||||||
config: Record<string, unknown> | null;
|
|
||||||
parseError: string | null;
|
|
||||||
} {
|
|
||||||
try {
|
|
||||||
const parsed = rawText.trim() ? parseToml(rawText) : {};
|
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
||||||
return {
|
|
||||||
config: null,
|
|
||||||
parseError: 'TOML root must be a table.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
config: parsed as Record<string, unknown>,
|
|
||||||
parseError: null,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
config: null,
|
|
||||||
parseError: (error as Error).message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchCodexDiagnostics(): Promise<CodexDashboardDiagnostics> {
|
async function fetchCodexDiagnostics(): Promise<CodexDashboardDiagnostics> {
|
||||||
const res = await fetch(withApiBase('/codex/diagnostics'));
|
const res = await fetch(withApiBase('/codex/diagnostics'));
|
||||||
if (!res.ok) throw new Error('Failed to fetch Codex diagnostics');
|
if (!res.ok) throw new Error('Failed to fetch Codex diagnostics');
|
||||||
@@ -111,7 +87,7 @@ export function useCodex() {
|
|||||||
queryClient.setQueryData<CodexRawConfig>(['codex-raw-config'], (current) => {
|
queryClient.setQueryData<CodexRawConfig>(['codex-raw-config'], (current) => {
|
||||||
const path = current?.path ?? '$CODEX_HOME/config.toml';
|
const path = current?.path ?? '$CODEX_HOME/config.toml';
|
||||||
const resolvedPath = current?.resolvedPath ?? path;
|
const resolvedPath = current?.resolvedPath ?? path;
|
||||||
const parsed = parseCodexRawConfigText(variables.rawText);
|
const parsed = safeParseTomlObject(variables.rawText);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path,
|
path,
|
||||||
|
|||||||
+31
-654
@@ -1,42 +1,13 @@
|
|||||||
import { type ReactNode, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { parse as parseToml } from 'smol-toml';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||||
import {
|
import { GripVertical, Loader2 } from 'lucide-react';
|
||||||
AlertTriangle,
|
import { CodexControlCenterTab } from '@/components/compatible-cli/codex-control-center-tab';
|
||||||
CheckCircle2,
|
import { CodexDocsTab } from '@/components/compatible-cli/codex-docs-tab';
|
||||||
ExternalLink,
|
|
||||||
Folder,
|
|
||||||
GripVertical,
|
|
||||||
Info,
|
|
||||||
Loader2,
|
|
||||||
Route,
|
|
||||||
ShieldCheck,
|
|
||||||
TerminalSquare,
|
|
||||||
XCircle,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useCodex } from '@/hooks/use-codex';
|
import { useCodex } from '@/hooks/use-codex';
|
||||||
import { isApiConflictError } from '@/lib/api-client';
|
import { isApiConflictError } from '@/lib/api-client';
|
||||||
|
import { CodexOverviewTab } from '@/components/compatible-cli/codex-overview-tab';
|
||||||
import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
|
import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
|
||||||
import { CodexFeaturesCard } from '@/components/compatible-cli/codex-features-card';
|
|
||||||
import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card';
|
|
||||||
import { CodexModelProvidersCard } from '@/components/compatible-cli/codex-model-providers-card';
|
|
||||||
import { CodexProfilesCard } from '@/components/compatible-cli/codex-profiles-card';
|
|
||||||
import { CodexProjectTrustCard } from '@/components/compatible-cli/codex-project-trust-card';
|
|
||||||
import { CodexTopLevelControlsCard } from '@/components/compatible-cli/codex-top-level-controls-card';
|
|
||||||
import { QuickCommands } from '@/components/shared';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import {
|
import {
|
||||||
KNOWN_CODEX_FEATURES,
|
KNOWN_CODEX_FEATURES,
|
||||||
@@ -47,121 +18,7 @@ import {
|
|||||||
readCodexProjectTrust,
|
readCodexProjectTrust,
|
||||||
readCodexTopLevelSettings,
|
readCodexTopLevelSettings,
|
||||||
} from '@/lib/codex-config';
|
} from '@/lib/codex-config';
|
||||||
import { cn } from '@/lib/utils';
|
import { safeParseTomlObject } from '@shared/toml-object';
|
||||||
|
|
||||||
const DEFAULT_CODEX_DOC_LINKS = [
|
|
||||||
{
|
|
||||||
id: 'codex-config-basic',
|
|
||||||
label: 'Codex Config Basics',
|
|
||||||
url: 'https://developers.openai.com/codex/config-basic',
|
|
||||||
description: 'Official user-layer setup, config location, and baseline configuration behavior.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'codex-config-advanced',
|
|
||||||
label: 'Codex Config Advanced',
|
|
||||||
url: 'https://developers.openai.com/codex/config-advanced',
|
|
||||||
description: 'Layering, trust, profiles, and advanced config behavior.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'codex-config-reference',
|
|
||||||
label: 'Codex Config Reference',
|
|
||||||
url: 'https://developers.openai.com/codex/config-reference',
|
|
||||||
description: 'Canonical upstream config surface for providers, MCP, features, and trust.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'codex-releases',
|
|
||||||
label: 'Codex GitHub Releases',
|
|
||||||
url: 'https://github.com/openai/codex/releases',
|
|
||||||
description: 'Track upstream release notes and fast-moving CLI changes.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const DEFAULT_PROVIDER_DOCS = [
|
|
||||||
{
|
|
||||||
provider: 'openai',
|
|
||||||
label: 'OpenAI Responses API',
|
|
||||||
apiFormat: 'Responses API',
|
|
||||||
url: 'https://platform.openai.com/docs/api-reference/responses',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function renderTextWithLinks(text: string): ReactNode[] {
|
|
||||||
const urlPattern = /https?:\/\/[^\s)]+/g;
|
|
||||||
const nodes: ReactNode[] = [];
|
|
||||||
let cursor = 0;
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
while ((match = urlPattern.exec(text)) !== null) {
|
|
||||||
const [url] = match;
|
|
||||||
const index = match.index;
|
|
||||||
|
|
||||||
if (index > cursor) {
|
|
||||||
nodes.push(text.slice(cursor, index));
|
|
||||||
}
|
|
||||||
|
|
||||||
nodes.push(
|
|
||||||
<a
|
|
||||||
key={`${url}-${index}`}
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="underline underline-offset-2 hover:text-foreground"
|
|
||||||
>
|
|
||||||
{url}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
cursor = index + url.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cursor < text.length) {
|
|
||||||
nodes.push(text.slice(cursor));
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodes.length > 0 ? nodes : [text];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(value: number | null | undefined): string {
|
|
||||||
if (!value || !Number.isFinite(value)) return 'N/A';
|
|
||||||
return new Date(value).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(value: number | null | undefined): string {
|
|
||||||
if (!value || value <= 0) return '0 B';
|
|
||||||
if (value < 1024) return `${value} B`;
|
|
||||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTomlObjectText(
|
|
||||||
text: string
|
|
||||||
): { valid: true; value: Record<string, unknown> } | { valid: false; error: string } {
|
|
||||||
try {
|
|
||||||
const parsed = text.trim() ? parseToml(text) : {};
|
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
||||||
return { valid: false, error: 'TOML root must be a table.' };
|
|
||||||
}
|
|
||||||
return { valid: true, value: parsed as Record<string, unknown> };
|
|
||||||
} catch (error) {
|
|
||||||
return { valid: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailRow({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
mono = false,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
mono?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start justify-between gap-3 text-sm">
|
|
||||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
|
||||||
<span className={cn('text-right break-all', mono && 'font-mono text-xs')}>{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CodexPage() {
|
export function CodexPage() {
|
||||||
const {
|
const {
|
||||||
@@ -183,10 +40,10 @@ export function CodexPage() {
|
|||||||
const rawBaseText = rawConfig?.rawText ?? '';
|
const rawBaseText = rawConfig?.rawText ?? '';
|
||||||
const rawEditorText = rawDraftText ?? rawBaseText;
|
const rawEditorText = rawDraftText ?? rawBaseText;
|
||||||
const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText;
|
const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText;
|
||||||
const rawEditorParsed = parseTomlObjectText(rawEditorText);
|
const rawEditorParsed = safeParseTomlObject(rawEditorText);
|
||||||
const rawEditorValidation = rawEditorParsed.valid
|
const rawEditorValidation = rawEditorParsed.parseError
|
||||||
? { valid: true as const }
|
? { valid: false as const, error: rawEditorParsed.parseError }
|
||||||
: { valid: false as const, error: rawEditorParsed.error };
|
: { valid: true as const };
|
||||||
const controlsConfig = rawConfig?.config ?? null;
|
const controlsConfig = rawConfig?.config ?? null;
|
||||||
const structuredControlsDisabled =
|
const structuredControlsDisabled =
|
||||||
rawConfigLoading || !rawConfig || rawConfigDirty || rawConfig?.parseError !== null;
|
rawConfigLoading || !rawConfig || rawConfigDirty || rawConfig?.parseError !== null;
|
||||||
@@ -271,7 +128,9 @@ export function CodexPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderOverview = () => {
|
const tabContentClassName = 'mt-0 h-full border-0 p-0 data-[state=inactive]:hidden';
|
||||||
|
|
||||||
|
const renderSidebar = () => {
|
||||||
if (diagnosticsLoading) {
|
if (diagnosticsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
@@ -289,19 +148,6 @@ export function CodexPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const docsReference = diagnostics.docsReference ?? {
|
|
||||||
notes: [],
|
|
||||||
links: [],
|
|
||||||
providerDocs: [],
|
|
||||||
providerValues: [],
|
|
||||||
settingsHierarchy: [],
|
|
||||||
};
|
|
||||||
const docsLinks =
|
|
||||||
docsReference.links.length > 0 ? docsReference.links : DEFAULT_CODEX_DOC_LINKS;
|
|
||||||
const providerDocs =
|
|
||||||
docsReference.providerDocs.length > 0 ? docsReference.providerDocs : DEFAULT_PROVIDER_DOCS;
|
|
||||||
const tabContentClassName = 'mt-0 h-full border-0 p-0 data-[state=inactive]:hidden';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="overview" className="flex h-full flex-col">
|
<Tabs defaultValue="overview" className="flex h-full flex-col">
|
||||||
<div className="shrink-0 px-4 pt-4">
|
<div className="shrink-0 px-4 pt-4">
|
||||||
@@ -314,498 +160,29 @@ export function CodexPage() {
|
|||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden px-4 pb-4 pt-3">
|
<div className="flex-1 min-h-0 overflow-hidden px-4 pb-4 pt-3">
|
||||||
<TabsContent value="overview" className={tabContentClassName}>
|
<TabsContent value="overview" className={tabContentClassName}>
|
||||||
<ScrollArea className="h-full">
|
<CodexOverviewTab diagnostics={diagnostics} />
|
||||||
<div className="space-y-4 pr-1">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
How Codex works in CCS
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
Codex is a first-class runtime target in CCS, but it stays runtime-only in v1.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Saved default targets for API profiles and variants still remain on Claude or
|
|
||||||
Droid.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
CCS-backed Codex launches can apply transient <code>-c</code> overrides and
|
|
||||||
inject <code>CCS_CODEX_API_KEY</code>, so effective runtime values may not
|
|
||||||
match this file exactly.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<TerminalSquare className="h-4 w-4" />
|
|
||||||
Runtime install
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Status</span>
|
|
||||||
<Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}>
|
|
||||||
{diagnostics.binary.installed ? 'Detected' : 'Not found'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<DetailRow label="Detection source" value={diagnostics.binary.source} mono />
|
|
||||||
<DetailRow
|
|
||||||
label="Binary path"
|
|
||||||
value={diagnostics.binary.path || 'Not found'}
|
|
||||||
mono
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
label="Install directory"
|
|
||||||
value={diagnostics.binary.installDir || 'N/A'}
|
|
||||||
mono
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
label="Version"
|
|
||||||
value={diagnostics.binary.version || 'Unknown'}
|
|
||||||
mono
|
|
||||||
/>
|
|
||||||
<DetailRow label="Alias commands" value="ccs-codex, ccsx" mono />
|
|
||||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
--config override support
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
diagnostics.binary.supportsConfigOverrides ? 'default' : 'secondary'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{diagnostics.binary.supportsConfigOverrides ? 'Available' : 'Missing'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<Folder className="h-4 w-4" />
|
|
||||||
Config file
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="rounded-md border p-3 space-y-1.5">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="font-medium text-sm">User config</span>
|
|
||||||
{diagnostics.file.exists ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DetailRow label="Path" value={diagnostics.file.path} mono />
|
|
||||||
<DetailRow label="Resolved" value={diagnostics.file.resolvedPath} mono />
|
|
||||||
<DetailRow label="Size" value={formatBytes(diagnostics.file.sizeBytes)} />
|
|
||||||
<DetailRow
|
|
||||||
label="Last modified"
|
|
||||||
value={formatTimestamp(diagnostics.file.mtimeMs)}
|
|
||||||
/>
|
|
||||||
{diagnostics.file.parseError && (
|
|
||||||
<p className="text-xs text-amber-600">
|
|
||||||
TOML warning: {diagnostics.file.parseError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{diagnostics.file.readError && (
|
|
||||||
<p className="text-xs text-destructive">
|
|
||||||
Read warning: {diagnostics.file.readError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<ShieldCheck className="h-4 w-4" />
|
|
||||||
Current user-layer summary
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<DetailRow label="Model" value={diagnostics.config.model || 'Not set'} mono />
|
|
||||||
<DetailRow
|
|
||||||
label="Model provider"
|
|
||||||
value={diagnostics.config.modelProvider || 'Not set'}
|
|
||||||
mono
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
label="Active profile"
|
|
||||||
value={diagnostics.config.activeProfile || 'Not set'}
|
|
||||||
mono
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
label="Approval policy"
|
|
||||||
value={diagnostics.config.approvalPolicy || 'Not set'}
|
|
||||||
mono
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
label="Sandbox mode"
|
|
||||||
value={diagnostics.config.sandboxMode || 'Not set'}
|
|
||||||
mono
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
label="Web search"
|
|
||||||
value={diagnostics.config.webSearch || 'Not set'}
|
|
||||||
mono
|
|
||||||
/>
|
|
||||||
<Separator />
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<Badge variant="outline" className="justify-center">
|
|
||||||
providers: {diagnostics.config.modelProviderCount}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="justify-center">
|
|
||||||
profiles: {diagnostics.config.profileCount}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="justify-center">
|
|
||||||
enabled features: {diagnostics.config.enabledFeatures.length}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="justify-center">
|
|
||||||
MCP servers: {diagnostics.config.mcpServerCount}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{diagnostics.config.topLevelKeys.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
User-layer keys present
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{diagnostics.config.topLevelKeys.map((key) => (
|
|
||||||
<Badge key={key} variant="secondary" className="font-mono text-[10px]">
|
|
||||||
{key}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<QuickCommands
|
|
||||||
snippets={[
|
|
||||||
{
|
|
||||||
label: 'Native Codex',
|
|
||||||
command: 'ccs-codex',
|
|
||||||
description: 'Launch the native Codex runtime alias.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Short alias',
|
|
||||||
command: 'ccsx',
|
|
||||||
description: 'Launch the short Codex runtime alias.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Target override',
|
|
||||||
command: 'ccs codex --target codex "your prompt"',
|
|
||||||
description: 'Run a CCS profile on the native Codex target.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Workspace trust',
|
|
||||||
command: `codex --profile ${diagnostics.config.activeProfile || 'default'}`,
|
|
||||||
description: 'Inspect the active profile directly in native Codex.',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<Route className="h-4 w-4" />
|
|
||||||
Runtime vs provider
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-3 md:grid-cols-2">
|
|
||||||
<div className="rounded-md border p-3 text-sm">
|
|
||||||
<p className="font-medium">Native Codex runtime</p>
|
|
||||||
<p className="mt-1 text-muted-foreground">
|
|
||||||
Use <code>ccs-codex</code>, <code>ccsx</code>, or{' '}
|
|
||||||
<code>--target codex</code>. CCS launches the local Codex CLI and depends on
|
|
||||||
native Codex capabilities such as <code>--config</code> overrides.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border p-3 text-sm">
|
|
||||||
<p className="font-medium">Codex provider / bridge</p>
|
|
||||||
<p className="mt-1 text-muted-foreground">
|
|
||||||
CCS can route provider credentials transiently through CLIProxy. That is not
|
|
||||||
the same as editing local <code>config.toml</code>, and some routed values
|
|
||||||
may never persist here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-base">Supported flows</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Flow</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Notes</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{diagnostics.supportMatrix.map((entry) => (
|
|
||||||
<TableRow key={entry.id}>
|
|
||||||
<TableCell className="font-mono text-xs">{entry.label}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={entry.supported ? 'default' : 'secondary'}>
|
|
||||||
{entry.supported ? 'Yes' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
|
||||||
{entry.notes}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{diagnostics.warnings.length > 0 && (
|
|
||||||
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
|
||||||
Warnings
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-1.5">
|
|
||||||
{diagnostics.warnings.map((warning) => (
|
|
||||||
<p key={warning} className="text-sm text-amber-800 dark:text-amber-300">
|
|
||||||
- {warning}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="controls" className={tabContentClassName}>
|
<TabsContent value="controls" className={tabContentClassName}>
|
||||||
<ScrollArea className="h-full">
|
<CodexControlCenterTab
|
||||||
<div className="space-y-4 pr-1">
|
workspacePath={diagnostics.workspacePath}
|
||||||
<Card>
|
activeProfile={diagnostics.config.activeProfile}
|
||||||
<CardHeader className="pb-2">
|
topLevelSettings={topLevelSettings}
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
projectTrustEntries={projectTrustEntries}
|
||||||
<Route className="h-4 w-4" />
|
profileEntries={profileEntries}
|
||||||
Structured controls boundary
|
modelProviderEntries={modelProviderEntries}
|
||||||
</CardTitle>
|
mcpServerEntries={mcpServerEntries}
|
||||||
</CardHeader>
|
featureCatalog={KNOWN_CODEX_FEATURES}
|
||||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
featureState={featureState}
|
||||||
<p>
|
disabled={structuredControlsDisabled}
|
||||||
Guided controls write only the user-layer <code>config.toml</code>. They do
|
disabledReason={controlsDisabledReason}
|
||||||
not model the full effective Codex runtime once trusted repo layers and CCS
|
saving={isPatchingConfig}
|
||||||
transient <code>-c</code> overrides are involved.
|
onPatch={runConfigPatch}
|
||||||
</p>
|
/>
|
||||||
<p>
|
|
||||||
Structured saves normalize TOML formatting and strip comments. Use the raw
|
|
||||||
editor on the right when exact layout matters.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<CodexTopLevelControlsCard
|
|
||||||
values={topLevelSettings}
|
|
||||||
providerNames={modelProviderEntries.map((entry) => entry.name)}
|
|
||||||
disabled={structuredControlsDisabled}
|
|
||||||
disabledReason={controlsDisabledReason}
|
|
||||||
saving={isPatchingConfig}
|
|
||||||
onSave={(values) =>
|
|
||||||
runConfigPatch({ kind: 'top-level', values }, 'Saved top-level Codex settings.')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CodexProjectTrustCard
|
|
||||||
workspacePath={diagnostics.workspacePath}
|
|
||||||
entries={projectTrustEntries}
|
|
||||||
disabled={structuredControlsDisabled}
|
|
||||||
disabledReason={controlsDisabledReason}
|
|
||||||
saving={isPatchingConfig}
|
|
||||||
onSave={(projectPath, trustLevel) =>
|
|
||||||
runConfigPatch(
|
|
||||||
{ kind: 'project-trust', path: projectPath, trustLevel },
|
|
||||||
trustLevel ? 'Saved project trust entry.' : 'Removed project trust entry.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CodexProfilesCard
|
|
||||||
activeProfile={diagnostics.config.activeProfile}
|
|
||||||
entries={profileEntries}
|
|
||||||
providerNames={modelProviderEntries.map((entry) => entry.name)}
|
|
||||||
disabled={structuredControlsDisabled}
|
|
||||||
disabledReason={controlsDisabledReason}
|
|
||||||
saving={isPatchingConfig}
|
|
||||||
onSave={(name, values, setAsActive) =>
|
|
||||||
runConfigPatch(
|
|
||||||
{ kind: 'profile', action: 'upsert', name, values, setAsActive },
|
|
||||||
'Saved profile.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onDelete={(name) =>
|
|
||||||
runConfigPatch({ kind: 'profile', action: 'delete', name }, 'Deleted profile.')
|
|
||||||
}
|
|
||||||
onSetActive={(name) =>
|
|
||||||
runConfigPatch(
|
|
||||||
{ kind: 'profile', action: 'set-active', name },
|
|
||||||
'Set active profile.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CodexModelProvidersCard
|
|
||||||
entries={modelProviderEntries}
|
|
||||||
disabled={structuredControlsDisabled}
|
|
||||||
disabledReason={controlsDisabledReason}
|
|
||||||
saving={isPatchingConfig}
|
|
||||||
onSave={(name, values) =>
|
|
||||||
runConfigPatch(
|
|
||||||
{ kind: 'model-provider', action: 'upsert', name, values },
|
|
||||||
'Saved model provider.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onDelete={(name) =>
|
|
||||||
runConfigPatch(
|
|
||||||
{ kind: 'model-provider', action: 'delete', name },
|
|
||||||
'Deleted model provider.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CodexMcpServersCard
|
|
||||||
entries={mcpServerEntries}
|
|
||||||
disabled={structuredControlsDisabled}
|
|
||||||
disabledReason={controlsDisabledReason}
|
|
||||||
saving={isPatchingConfig}
|
|
||||||
onSave={(name, values) =>
|
|
||||||
runConfigPatch(
|
|
||||||
{ kind: 'mcp-server', action: 'upsert', name, values },
|
|
||||||
'Saved MCP server.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onDelete={(name) =>
|
|
||||||
runConfigPatch(
|
|
||||||
{ kind: 'mcp-server', action: 'delete', name },
|
|
||||||
'Deleted MCP server.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CodexFeaturesCard
|
|
||||||
catalog={KNOWN_CODEX_FEATURES}
|
|
||||||
state={featureState}
|
|
||||||
disabled={structuredControlsDisabled}
|
|
||||||
disabledReason={controlsDisabledReason}
|
|
||||||
onToggle={(feature, enabled) =>
|
|
||||||
runConfigPatch({ kind: 'feature', feature, enabled }, 'Saved feature toggle.')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="docs" className={tabContentClassName}>
|
<TabsContent value="docs" className={tabContentClassName}>
|
||||||
<ScrollArea className="h-full">
|
<CodexDocsTab diagnostics={diagnostics} />
|
||||||
<div className="space-y-4 pr-1">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<ShieldCheck className="h-4 w-4" />
|
|
||||||
Upstream notes
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2 text-sm">
|
|
||||||
{docsReference.notes.map((note, index) => (
|
|
||||||
<p key={`${index}-${note}`} className="text-muted-foreground">
|
|
||||||
- {renderTextWithLinks(note)}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
<Separator />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
Codex docs
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{docsLinks.map((link) => (
|
|
||||||
<a
|
|
||||||
key={link.id}
|
|
||||||
href={link.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="block rounded-md border px-2.5 py-2 transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-xs font-medium">{link.label}</span>
|
|
||||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
|
||||||
{link.description}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/90 underline underline-offset-2">
|
|
||||||
{link.url}
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
Provider / bridge reference
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{providerDocs.map((providerDoc) => (
|
|
||||||
<a
|
|
||||||
key={`${providerDoc.provider}-${providerDoc.url}`}
|
|
||||||
href={providerDoc.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="block rounded-md border px-2.5 py-2 transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-xs font-medium">{providerDoc.label}</span>
|
|
||||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
|
||||||
provider: {providerDoc.provider} | format: {providerDoc.apiFormat}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/90 underline underline-offset-2">
|
|
||||||
{providerDoc.url}
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{docsReference.providerValues.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Provider values: {docsReference.providerValues.join(', ')}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{docsReference.settingsHierarchy.length > 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Settings hierarchy: {docsReference.settingsHierarchy.join(' -> ')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -816,7 +193,7 @@ export function CodexPage() {
|
|||||||
<div className="h-full min-h-0 overflow-hidden">
|
<div className="h-full min-h-0 overflow-hidden">
|
||||||
<PanelGroup direction="horizontal" className="h-full">
|
<PanelGroup direction="horizontal" className="h-full">
|
||||||
<Panel defaultSize={45} minSize={35}>
|
<Panel defaultSize={45} minSize={35}>
|
||||||
<div className="h-full border-r bg-muted/20">{renderOverview()}</div>
|
<div className="h-full border-r bg-muted/20">{renderSidebar()}</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
<PanelResizeHandle className="group flex w-2 cursor-col-resize items-center justify-center bg-border transition-colors hover:bg-primary/20">
|
<PanelResizeHandle className="group flex w-2 cursor-col-resize items-center justify-center bg-border transition-colors hover:bg-primary/20">
|
||||||
<GripVertical className="h-3 w-3 text-muted-foreground group-hover:text-primary" />
|
<GripVertical className="h-3 w-3 text-muted-foreground group-hover:text-primary" />
|
||||||
|
|||||||
Reference in New Issue
Block a user