Compare commits

..
3 Commits
Author SHA1 Message Date
yyyyyynnn 03cde5c341 Improve async UX and Skills MCP sync 2026-07-06 01:47:20 +08:00
yyyyyynnn 2ba23e5e77 Polish prompt async flow 2026-07-06 01:26:52 +08:00
yyyyyynnn 9cbd7bf351 Add Skills and MCP management 2026-07-06 01:11:13 +08:00
9 changed files with 1969 additions and 90 deletions
+22
View File
@@ -2,6 +2,28 @@
All notable changes to Codex-X will be documented here.
## [v0.2.21] - 2026-07-06
- 进一步降低 UI 卡顿风险:将启动检测、Codex 状态读取、Provider/官方配置/备份/cc-switch 导入等剩余同步命令迁移到后台 worker。
- 优化前端串行请求:提示词启用后的备份/提示词刷新改为并发,保存并启用自定义提示词时不再重复拉取列表。
- 完善【技能和 MCP】导入已有:读取 cc-switch `mcp_servers` 数据库,按 `enabled_codex` 纳管并同步到 Codex `config.toml [mcp_servers]`。
- 增强 Skills 检查更新:对带有 cc-switch 仓库元数据的 Skill 拉取 GitHub 仓库 ZIP 计算远程 hash,显示“有新版本 / 已是最新 / 远程检查失败”等状态。
## [v0.2.20] - 2026-07-06
- 继续优化指令提示词页性能:提示词列表/状态读取、保存、导入、启用、禁用等后端操作改为后台 worker,避免切页或启用模板时阻塞 UI。
- 修复外部自定义提示词重复保存问题:切换到内置模板前会按文件名与内容双重去重,并自动清理历史 `external-*` 重复项。
- 优化启动加载体验:启动页增加最短展示、退出淡出与动态过渡,避免从欢迎页突然跳到主页。
## [v0.2.19] - 2026-07-06
- 新增【技能和 MCP】页面:展示 Codex 当前已安装 Skills 与 MCP,支持导入已有、从 ZIP 安装 Skill、启用/禁用 Skill、启用/禁用 MCP。
- MCP 管理会读写 `~/.codex/config.toml` 的 `[mcp_servers]`,禁用后保留到 Codex-X SQLite,后续可一键重新启用。
- Skills 管理会扫描 `~/.codex/skills`,并可从 `~/.agents/skills`、`~/.cc-switch/skills` 导入到 Codex;禁用后移动到 Codex-X 禁用目录,避免直接删除。
- 优化多个潜在卡顿点:指令提示词 GitHub 检查改为延迟后台执行,远程拉取、会话扫描/同步、Skills/MCP 扫描均放入后台 blocking worker。
- 修复外部自定义提示词切换到内置提示词后重复出现的问题,并自动清理同名 `external-*` 重复项。
- 增加页面切换过渡、启动页动态光效和首次启动向导退出动画,降低页面突然跳转感。
## [v0.2.18] - 2026-07-05
- 指令提示词内置模板支持从 GitHub `examples/` 实时检查更新,并缓存到本地;启用内置模板时优先使用 GitHub 最新版本,离线时自动回退本地缓存或打包内置版本。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.2.18",
"version": "0.2.21",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+52 -1
View File
@@ -59,6 +59,15 @@ version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -337,18 +346,20 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.2.18"
version = "0.2.21"
dependencies = [
"chrono",
"dirs 5.0.1",
"rusqlite",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"thiserror 2.0.18",
"toml_edit 0.22.27",
"ureq",
"zip",
]
[[package]]
@@ -547,6 +558,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -4712,8 +4734,37 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.14.0",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.2.18"
version = "0.2.21"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
@@ -24,3 +24,5 @@ dirs = "5.0"
thiserror = "2.0"
rusqlite = { version = "0.31", features = ["bundled"] }
ureq = { version = "2.12", features = ["tls"] }
sha2 = "0.10"
zip = { version = "2.2", default-features = false, features = ["deflate"] }
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.2.18",
"version": "0.2.21",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
+321 -17
View File
@@ -34,7 +34,7 @@ import "./styles.css";
type Lang = "zh" | "en";
type ProviderMode = "list" | "form" | "official";
type InstructionMode = "list" | "form";
type Tab = "dashboard" | "provider" | "sessions" | "instruction" | "toml" | "settings" | "about";
type Tab = "dashboard" | "provider" | "sessions" | "skillsMcp" | "instruction" | "toml" | "settings" | "about";
type InstructionTemplate = {
id: string;
@@ -183,6 +183,46 @@ type SessionSyncResult = {
backupDir: string;
};
type ManagedSkill = {
id: string;
name: string;
description?: string | null;
directory: string;
enabled: boolean;
source: string;
path: string;
contentHash?: string | null;
updateStatus: string;
};
type ManagedMcpServer = {
id: string;
name: string;
transport: string;
enabled: boolean;
source: string;
summary: string;
command?: string | null;
url?: string | null;
configJson: unknown;
};
type SkillsMcpState = {
codexDir: string;
codexSkillsDir: string;
disabledSkillsDir: string;
skills: ManagedSkill[];
mcpServers: ManagedMcpServer[];
warnings: string[];
};
type SkillsMcpActionResult = {
importedSkills: number;
importedMcp: number;
message: string;
state: SkillsMcpState;
};
type DiagnosticItem = {
key: string;
label: string;
@@ -257,6 +297,7 @@ const dict = {
dashboard: "概览",
provider: "供应商",
sessions: "会话管理",
skillsMcp: "技能和MCP",
instruction: "指令提示词",
toml: "TOML",
settings: "设置",
@@ -364,6 +405,7 @@ const dict = {
dashboard: "Overview",
provider: "Provider",
sessions: "Sessions",
skillsMcp: "Skills & MCP",
instruction: "Prompt",
toml: "TOML",
settings: "Settings",
@@ -763,8 +805,10 @@ function App() {
const [releaseInfo, setReleaseInfo] = React.useState<ReleaseInfo>({ status: "idle" });
const [updatePromptOpen, setUpdatePromptOpen] = React.useState(false);
const [sessionStatus, setSessionStatus] = React.useState<SessionSyncStatus | null>(null);
const [skillsMcpState, setSkillsMcpState] = React.useState<SkillsMcpState | null>(null);
const [startupDiagnostics, setStartupDiagnostics] = React.useState<StartupDiagnostics | null>(null);
const [startupWizardOpen, setStartupWizardOpen] = React.useState(() => localStorage.getItem(STARTUP_WIZARD_SEEN_KEY) !== "1");
const [startupClosing, setStartupClosing] = React.useState(false);
const [sessionQuery, setSessionQuery] = React.useState("");
const [sessionGroupByCwd, setSessionGroupByCwd] = React.useState(true);
const [selectedSessionIds, setSelectedSessionIds] = React.useState<string[]>([]);
@@ -772,6 +816,8 @@ function App() {
const [backups, setBackups] = React.useState<BackupEntry[]>([]);
const [configDir, setConfigDir] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [bootVisible, setBootVisible] = React.useState(true);
const [bootLeaving, setBootLeaving] = React.useState(false);
const [toast, setToast] = React.useState<string>("");
const [error, setError] = React.useState<string>("");
const [providerForm, setProviderForm] = React.useState<SavedProvider>(defaultProviderForm);
@@ -783,7 +829,10 @@ function App() {
const [officialForm, setOfficialForm] = React.useState({ model: "gpt-5.5", authJson: "" });
const autoUpdateCheckedRef = React.useRef(false);
const promptImportRef = React.useRef<HTMLInputElement | null>(null);
const skillZipImportRef = React.useRef<HTMLInputElement | null>(null);
const promptUpdateCheckedRef = React.useRef(false);
const skillsMcpLoadedRef = React.useRef(false);
const bootStartedAtRef = React.useRef(Date.now());
const providerTomlPreview = React.useMemo(() => buildProviderTomlPreview(providerForm, state), [providerForm, state]);
const providerAuthPreview = React.useMemo(() => buildProviderAuthPreview(providerForm), [providerForm]);
const currentInstructionId = instructionIdFromPath(state?.instructionFile);
@@ -806,6 +855,18 @@ function App() {
}
}, [providerMode, providerTomlDirty, providerTomlPreview]);
React.useEffect(() => {
if (!state || !bootVisible || bootLeaving) return undefined;
const elapsed = Date.now() - bootStartedAtRef.current;
const minBootMs = 920;
const leaveDelay = Math.max(120, minBootMs - elapsed);
const leaveTimer = window.setTimeout(() => {
setBootLeaving(true);
window.setTimeout(() => setBootVisible(false), 300);
}, leaveDelay);
return () => window.clearTimeout(leaveTimer);
}, [bootLeaving, bootVisible, state]);
const currentProvider = state?.providers.find((p) => p.isCurrent);
const detectedRows = React.useMemo(() => {
@@ -889,6 +950,9 @@ function App() {
return (sessionStatus?.sessions || []).filter((item) => set.has(item.id) && item.needsSync).length;
}, [selectedSessionIds, sessionStatus?.sessions]);
const enabledSkillCount = skillsMcpState?.skills.filter((item) => item.enabled).length ?? 0;
const enabledMcpCount = skillsMcpState?.mcpServers.filter((item) => item.enabled).length ?? 0;
React.useEffect(() => {
setSelectedSessionIds((ids) => ids.filter((id) => (sessionStatus?.sessions || []).some((item) => item.id === id)));
}, [sessionStatus?.sessions]);
@@ -944,8 +1008,15 @@ function App() {
const handleActionResult = (result: ActionResult) => {
setState(result.state);
setToast(result.message);
invoke<BackupEntry[]>("list_backups").then(setBackups).catch(() => undefined);
invoke<SavedPrompt[]>("list_saved_prompts").then(setSavedPrompts).catch(() => undefined);
void Promise.all([
invoke<BackupEntry[]>("list_backups"),
invoke<SavedPrompt[]>("list_saved_prompts"),
])
.then(([backupList, promptList]) => {
setBackups(backupList);
setSavedPrompts(promptList);
})
.catch(() => undefined);
};
const enableInstruction = () =>
@@ -1002,11 +1073,9 @@ function App() {
async () => {
const saved = await invoke<SavedPrompt>("save_prompt", { prompt: normalizedPromptForm() });
const result = await invoke<ActionResult>("enable_saved_prompt", { configDir: configDir || null, id: saved.id });
const promptList = await invoke<SavedPrompt[]>("list_saved_prompts");
return { result, promptList };
return result;
},
({ result, promptList }) => {
setSavedPrompts(promptList);
(result) => {
setInstructionMode("list");
setEditingPromptId(null);
handleActionResult(result);
@@ -1061,8 +1130,10 @@ function App() {
};
const refreshBuiltinPrompts = async ({ quiet = false }: { quiet?: boolean } = {}) => {
setActionBusy("refreshPrompts");
if (!quiet) setLoading(true);
if (!quiet) {
setActionBusy("refreshPrompts");
setLoading(true);
}
try {
const list = await invoke<BuiltinPromptStatus[]>("refresh_builtin_prompts");
setBuiltinPromptStatus(list);
@@ -1075,8 +1146,10 @@ function App() {
} catch (e) {
if (!quiet) setError(String(e));
} finally {
if (!quiet) setLoading(false);
setActionBusy("");
if (!quiet) {
setLoading(false);
setActionBusy("");
}
}
};
@@ -1246,10 +1319,112 @@ function App() {
React.useEffect(() => {
if (tab !== "instruction" || promptUpdateCheckedRef.current) return;
promptUpdateCheckedRef.current = true;
void refreshBuiltinPrompts({ quiet: true });
const timer = window.setTimeout(() => {
void refreshBuiltinPrompts({ quiet: true });
}, 800);
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab]);
const loadSkillsMcp = React.useCallback(async ({ quiet = false }: { quiet?: boolean } = {}) => {
if (!quiet) {
setActionBusy("loadSkillsMcp");
setError("");
}
try {
const result = await invoke<SkillsMcpState>("get_skills_mcp_state", { configDir: configDir || null });
setSkillsMcpState(result);
} catch (e) {
if (!quiet) setError(String(e));
} finally {
if (!quiet) setActionBusy("");
}
}, [configDir]);
React.useEffect(() => {
if (tab !== "skillsMcp" || skillsMcpLoadedRef.current) return;
skillsMcpLoadedRef.current = true;
void loadSkillsMcp();
}, [tab, loadSkillsMcp]);
const importExistingSkillsMcp = async () => {
setActionBusy("importExistingSkillsMcp");
setError("");
try {
const result = await invoke<SkillsMcpActionResult>("import_existing_skills_mcp", { configDir: configDir || null });
setSkillsMcpState(result.state);
setToast(result.message);
} catch (e) {
setError(String(e));
} finally {
setActionBusy("");
}
};
const checkSkillUpdatesAction = async () => {
setActionBusy("checkSkillUpdates");
setError("");
try {
const result = await invoke<SkillsMcpState>("check_skill_updates", { configDir: configDir || null });
setSkillsMcpState(result);
setToast(lang === "zh" ? "Skills 更新状态已刷新" : "Skill update status refreshed");
} catch (e) {
setError(String(e));
} finally {
setActionBusy("");
}
};
const toggleSkillEnabled = async (id: string, enabled: boolean) => {
setActionBusy(`skill:${id}`);
setError("");
try {
const result = await invoke<SkillsMcpState>("toggle_codex_skill", { configDir: configDir || null, id, enabled });
setSkillsMcpState(result);
setToast(enabled ? (lang === "zh" ? "Skill 已启用" : "Skill enabled") : (lang === "zh" ? "Skill 已禁用" : "Skill disabled"));
} catch (e) {
setError(String(e));
} finally {
setActionBusy("");
}
};
const toggleMcpEnabled = async (id: string, enabled: boolean) => {
setActionBusy(`mcp:${id}`);
setError("");
try {
const result = await invoke<SkillsMcpState>("toggle_codex_mcp", { configDir: configDir || null, id, enabled });
setSkillsMcpState(result);
setToast(enabled ? (lang === "zh" ? "MCP 已启用" : "MCP enabled") : (lang === "zh" ? "MCP 已禁用" : "MCP disabled"));
void refresh();
} catch (e) {
setError(String(e));
} finally {
setActionBusy("");
}
};
const installSkillZipFile = async (file?: File | null) => {
if (!file) return;
if (!file.name.toLowerCase().endsWith(".zip")) {
setError(lang === "zh" ? "请选择 .zip 技能包" : "Please choose a .zip skill package");
return;
}
setActionBusy("installSkillZip");
setError("");
try {
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
const result = await invoke<SkillsMcpActionResult>("install_skill_zip", { configDir: configDir || null, fileName: file.name, bytes });
setSkillsMcpState(result.state);
setToast(result.message);
} catch (e) {
setError(String(e));
} finally {
setActionBusy("");
if (skillZipImportRef.current) skillZipImportRef.current.value = "";
}
};
const loadCcSwitchOfficialAuth = async (showToast = true) => {
const candidate = await invoke<OfficialAuthCandidate | null>("read_ccswitch_official_auth", { dbPath: null });
if (candidate) {
@@ -1397,13 +1572,18 @@ function App() {
const closeStartupWizard = () => {
localStorage.setItem(STARTUP_WIZARD_SEEN_KEY, "1");
setStartupWizardOpen(false);
setStartupClosing(true);
window.setTimeout(() => {
setStartupWizardOpen(false);
setStartupClosing(false);
}, 260);
};
const navItems: Array<[Tab, string, React.ReactNode]> = [
["dashboard", t.nav.dashboard, <Layers3 size={18} />],
["provider", t.nav.provider, <Zap size={18} />],
["sessions", t.nav.sessions, <History size={18} />],
["skillsMcp", t.nav.skillsMcp, <Layers3 size={18} />],
["instruction", t.nav.instruction, <Sparkles size={18} />],
["toml", t.nav.toml, <FileCode2 size={18} />],
["settings", t.nav.settings, <Settings size={18} />],
@@ -1495,7 +1675,7 @@ function App() {
)}
{startupWizardOpen && startupDiagnostics && (
<div className="startup-mask">
<div className={cx("startup-mask", startupClosing && "closing")}>
<div className="startup-card glass">
<div className="startup-head">
<div>
@@ -1542,10 +1722,14 @@ function App() {
</div>
)}
{!state ? (
<div className="panel glass center-panel">
<Loader2 className="spin" />
{(!state || bootVisible) ? (
<div className={cx("panel glass center-panel boot-panel", bootLeaving && "leaving")}>
<div className="boot-logo-wrap">
<div className="boot-logo">Codex-X</div>
<div className="boot-orbit" />
</div>
<p>{t.loadingConfig}</p>
<div className="boot-progress"><span /></div>
</div>
) : (
<>
@@ -1910,6 +2094,126 @@ function App() {
</section>
)}
{tab === "skillsMcp" && (
<section className="panel glass skills-mcp-panel">
<div className="panel-head provider-title-row">
<div>
<p className="eyebrow">Skills / MCP</p>
<h3>{lang === "zh" ? "技能和 MCP" : "Skills & MCP"}</h3>
<p className="muted-desc">{lang === "zh" ? "管理 Codex 当前可用的 Skills 与 MCP:导入已有、从 ZIP 安装、启用或禁用。" : "Manage Codex Skills and MCP servers: import existing items, install ZIP packages, enable or disable."}</p>
</div>
<div className="provider-title-actions">
<input
ref={skillZipImportRef}
className="hidden-file-input"
type="file"
accept=".zip,application/zip"
onChange={(e) => void installSkillZipFile(e.target.files?.[0])}
/>
<button className="ghost-btn add-provider-btn lively-btn" onClick={() => void loadSkillsMcp()} disabled={actionBusy === "loadSkillsMcp"}>
{actionBusy === "loadSkillsMcp" ? <Loader2 size={18} className="spin" /> : <RefreshCw size={18} />} {lang === "zh" ? "刷新" : "Refresh"}
</button>
<button className="secondary-btn add-provider-btn lively-btn" onClick={importExistingSkillsMcp} disabled={Boolean(actionBusy)}>
{actionBusy === "importExistingSkillsMcp" ? <Loader2 size={18} className="spin" /> : <Download size={18} />} {lang === "zh" ? "导入已有" : "Import existing"}
</button>
<button className="secondary-btn add-provider-btn lively-btn" onClick={() => skillZipImportRef.current?.click()} disabled={Boolean(actionBusy)}>
{actionBusy === "installSkillZip" ? <Loader2 size={18} className="spin" /> : <Upload size={18} />} {lang === "zh" ? "从 ZIP 安装" : "Install ZIP"}
</button>
<button className="primary-btn add-provider-btn lively-btn" onClick={checkSkillUpdatesAction} disabled={Boolean(actionBusy)}>
{actionBusy === "checkSkillUpdates" ? <Loader2 size={18} className="spin" /> : <Sparkles size={18} />} {lang === "zh" ? "检查更新" : "Check updates"}
</button>
</div>
</div>
{!skillsMcpState ? (
<div className="session-empty skills-loading">
<Loader2 className="spin" size={22} />
<span>{lang === "zh" ? "正在读取本地 Skills / MCP..." : "Loading local Skills / MCP..."}</span>
</div>
) : (
<>
<div className="skills-mcp-summary">
<StatCard icon={<Sparkles size={20} />} label={lang === "zh" ? "已启用 Skills" : "Enabled Skills"} value={`${enabledSkillCount}/${skillsMcpState.skills.length}`} ok={enabledSkillCount > 0} />
<StatCard icon={<TerminalSquare size={20} />} label={lang === "zh" ? "已启用 MCP" : "Enabled MCP"} value={`${enabledMcpCount}/${skillsMcpState.mcpServers.length}`} ok={enabledMcpCount > 0} />
<div className="skills-path-card">
<span>~/.codex/skills</span>
<code>{skillsMcpState.codexSkillsDir}</code>
</div>
</div>
<div className="skills-mcp-columns">
<section className="skills-mcp-card">
<div className="skills-mcp-card-head">
<div>
<strong>Skills</strong>
<p>{lang === "zh" ? "启用后会放入 Codex skills 目录;禁用会移动到 Codex-X 的禁用目录。" : "Enabled skills live in the Codex skills directory; disabled skills are moved into Codex-X storage."}</p>
</div>
<span className="mini-tag ok">{skillsMcpState.skills.length}</span>
</div>
<div className="skills-mcp-list">
{skillsMcpState.skills.length === 0 ? (
<div className="session-empty compact"><Sparkles size={18} /><span>{lang === "zh" ? "还没有发现 Skills,点击导入已有或从 ZIP 安装。" : "No Skills found. Import existing or install a ZIP."}</span></div>
) : skillsMcpState.skills.map((skill) => (
<article className={cx("skill-mcp-row", skill.enabled && "selected")} key={skill.id}>
<div className="instruction-icon custom"><Sparkles size={20} /></div>
<div className="skill-mcp-main">
<strong>{skill.name}</strong>
<p>{skill.description || (lang === "zh" ? "无描述" : "No description")}</p>
<code title={skill.path}>{skill.directory}</code>
</div>
<div className="skill-mcp-meta">
<span className={cx("mini-tag", skill.enabled ? "ok" : undefined)}>{skill.enabled ? (lang === "zh" ? "已启用" : "Enabled") : (lang === "zh" ? "已禁用" : "Disabled")}</span>
<small>{skill.updateStatus}</small>
</div>
<button className={cx(skill.enabled ? "ghost-btn" : "secondary-btn", "small", "lively-btn")} onClick={() => void toggleSkillEnabled(skill.id, !skill.enabled)} disabled={actionBusy === `skill:${skill.id}`}>
{actionBusy === `skill:${skill.id}` ? <Loader2 size={14} className="spin" /> : null}{skill.enabled ? (lang === "zh" ? "禁用" : "Disable") : (lang === "zh" ? "启用" : "Enable")}
</button>
</article>
))}
</div>
</section>
<section className="skills-mcp-card">
<div className="skills-mcp-card-head">
<div>
<strong>MCP</strong>
<p>{lang === "zh" ? "启用会写入 ~/.codex/config.toml 的 [mcp_servers];禁用会移除但保留在 Codex-X。" : "Enabling writes to ~/.codex/config.toml [mcp_servers]; disabling removes it but keeps a Codex-X copy."}</p>
</div>
<span className="mini-tag ok">{skillsMcpState.mcpServers.length}</span>
</div>
<div className="skills-mcp-list">
{skillsMcpState.mcpServers.length === 0 ? (
<div className="session-empty compact"><TerminalSquare size={18} /><span>{lang === "zh" ? "还没有发现 MCP。当前会从 config.toml 导入已有 MCP。" : "No MCP server found. Existing config.toml MCP entries can be imported."}</span></div>
) : skillsMcpState.mcpServers.map((server) => (
<article className={cx("skill-mcp-row", server.enabled && "selected")} key={server.id}>
<div className="instruction-icon"><TerminalSquare size={20} /></div>
<div className="skill-mcp-main">
<strong>{server.name || server.id}</strong>
<p title={server.summary}>{server.summary || server.transport}</p>
<code>{server.transport} · {server.source}</code>
</div>
<div className="skill-mcp-meta">
<span className={cx("mini-tag", server.enabled ? "ok" : undefined)}>{server.enabled ? (lang === "zh" ? "已启用" : "Enabled") : (lang === "zh" ? "已禁用" : "Disabled")}</span>
</div>
<button className={cx(server.enabled ? "ghost-btn" : "secondary-btn", "small", "lively-btn")} onClick={() => void toggleMcpEnabled(server.id, !server.enabled)} disabled={actionBusy === `mcp:${server.id}`}>
{actionBusy === `mcp:${server.id}` ? <Loader2 size={14} className="spin" /> : null}{server.enabled ? (lang === "zh" ? "关闭" : "Disable") : (lang === "zh" ? "开启" : "Enable")}
</button>
</article>
))}
</div>
</section>
</div>
{skillsMcpState.warnings.length > 0 && (
<div className="session-warning-list">
{skillsMcpState.warnings.map((item, index) => <p key={index}><AlertCircle size={15} /> {item}</p>)}
</div>
)}
</>
)}
</section>
)}
{tab === "instruction" && (
<section className="panel glass instruction-panel simple-instruction-panel">
{instructionMode === "list" ? (
+234
View File
@@ -2002,3 +2002,237 @@ html, body, #root {
white-space: nowrap;
text-overflow: ellipsis;
}
/* Smoother boot / first-run transitions */
.boot-panel {
position: relative;
min-height: 420px;
overflow: hidden;
animation: boot-panel-in .42s cubic-bezier(.2,.8,.2,1) both;
}
.boot-panel.leaving {
pointer-events: none;
animation: boot-panel-out .28s ease-in both;
}
.boot-panel::before {
content: "";
position: absolute;
width: 420px;
height: 420px;
border-radius: 999px;
background: radial-gradient(circle, rgba(45, 170, 255, .24), transparent 62%);
filter: blur(6px);
animation: boot-glow-drift 4.8s ease-in-out infinite;
}
.boot-logo-wrap {
position: relative;
width: 174px;
height: 174px;
display: grid;
place-items: center;
}
.boot-logo {
position: relative;
z-index: 2;
width: 126px;
height: 126px;
border-radius: 34px;
display: grid;
place-items: center;
color: #f7fbff;
font-weight: 900;
letter-spacing: -.06em;
font-size: 24px;
background: linear-gradient(145deg, rgba(33, 96, 255, .92), rgba(18, 218, 255, .74));
border: 1px solid rgba(255,255,255,.28);
box-shadow: 0 26px 70px rgba(20, 130, 255, .26), inset 0 1px 0 rgba(255,255,255,.38);
animation: boot-logo-float 2.8s ease-in-out infinite;
}
.boot-orbit {
position: absolute;
inset: 12px;
border-radius: 999px;
border: 1px solid rgba(113, 205, 255, .22);
border-top-color: rgba(89, 210, 255, .9);
border-right-color: rgba(108, 134, 255, .55);
animation: boot-orbit-spin 1.35s linear infinite;
}
.boot-progress {
width: min(320px, 68%);
height: 7px;
border-radius: 999px;
overflow: hidden;
background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.09);
}
.boot-progress span {
display: block;
width: 44%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, #43dfff, #6c7dff, transparent);
animation: boot-progress-run 1.25s ease-in-out infinite;
}
.startup-mask {
animation: startup-mask-in .24s ease-out both;
}
.startup-mask.closing {
animation: startup-mask-out .26s ease-in both;
}
.startup-card {
animation: startup-card-in .46s cubic-bezier(.16,1,.3,1) both;
}
.startup-mask.closing .startup-card {
animation: startup-card-out .24s ease-in both;
}
.startup-check-item {
animation: startup-item-in .42s cubic-bezier(.2,.8,.2,1) both;
}
.startup-check-item:nth-child(2) { animation-delay: .04s; }
.startup-check-item:nth-child(3) { animation-delay: .08s; }
.startup-check-item:nth-child(4) { animation-delay: .12s; }
@keyframes boot-panel-in {
from { opacity: 0; transform: translateY(10px) scale(.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes boot-panel-out {
from { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
to { opacity: 0; transform: translateY(-8px) scale(.992); filter: blur(4px); }
}
@keyframes boot-logo-float {
0%, 100% { transform: translateY(0); filter: brightness(1); }
50% { transform: translateY(-7px); filter: brightness(1.08); }
}
@keyframes boot-orbit-spin {
to { transform: rotate(360deg); }
}
@keyframes boot-progress-run {
0% { transform: translateX(-110%); }
100% { transform: translateX(250%); }
}
@keyframes boot-glow-drift {
0%, 100% { transform: translate(-120px, -80px) scale(.92); opacity: .75; }
50% { transform: translate(120px, 60px) scale(1.12); opacity: 1; }
}
@keyframes startup-mask-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes startup-mask-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes startup-card-in {
from { opacity: 0; transform: translateY(18px) scale(.975); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes startup-card-out {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(10px) scale(.985); }
}
@keyframes startup-item-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.boot-panel,
.boot-panel::before,
.boot-logo,
.boot-orbit,
.boot-progress span,
.startup-mask,
.startup-card,
.startup-check-item {
animation: none !important;
}
}
/* Page switch transition */
.content > .panel,
.content > .grid,
.content > .about-page,
.content > .settings-panel,
.content > .update-strip {
animation: page-soft-in .24s cubic-bezier(.2,.8,.2,1) both;
}
@keyframes page-soft-in {
from { opacity: 0; transform: translateY(8px) scale(.992); filter: blur(2px); }
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
}
/* Skills & MCP */
.skills-mcp-panel { display: grid; gap: 16px; }
.skills-mcp-summary {
display: grid;
grid-template-columns: minmax(180px, 240px) minmax(180px, 240px) 1fr;
gap: 14px;
align-items: stretch;
}
.skills-path-card {
min-height: 110px;
border-radius: 24px;
padding: 18px;
background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.035));
border: 1px solid rgba(255,255,255,.1);
display: grid;
align-content: center;
gap: 8px;
min-width: 0;
}
.skills-path-card span { color: #91a0bb; font-size: 13px; font-weight: 800; }
.skills-path-card code { color: #d9e6ff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.skills-mcp-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
align-items: start;
}
.skills-mcp-card {
border-radius: 26px;
padding: 18px;
background: rgba(4, 12, 27, .34);
border: 1px solid rgba(255,255,255,.09);
min-width: 0;
}
.skills-mcp-card-head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.skills-mcp-card-head strong { font-size: 18px; color: #f5f9ff; }
.skills-mcp-card-head p { margin: 5px 0 0; color: #8fa0bc; line-height: 1.55; font-size: 13px; }
.skills-mcp-list { display: grid; gap: 12px; max-height: calc(100vh - 365px); overflow: auto; padding-right: 4px; }
.skill-mcp-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
gap: 12px;
align-items: center;
min-height: 92px;
padding: 14px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(255,255,255,.065), rgba(255,255,255,.03));
border: 1px solid rgba(255,255,255,.09);
transition: transform .18s ease, border-color .18s ease, background .18s ease;
}
.skill-mcp-row:hover { transform: translateY(-1px); border-color: rgba(87, 173, 255, .35); }
.skill-mcp-row.selected { border-color: rgba(35, 207, 255, .62); background: linear-gradient(180deg, rgba(36,135,255,.16), rgba(17,75,112,.12)); }
.skill-mcp-main { min-width: 0; }
.skill-mcp-main strong { display: block; color: #f5f9ff; margin-bottom: 4px; }
.skill-mcp-main p { margin: 0 0 6px; color: #9caac4; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.skill-mcp-main code { display: block; color: #bcd0ef; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.skill-mcp-meta { display: grid; justify-items: end; gap: 6px; color: #8fa0bc; font-size: 12px; }
.session-empty.compact { min-height: 96px; }
.skills-loading { min-height: 280px; }
@media (max-width: 1280px) {
.skills-mcp-summary { grid-template-columns: 1fr; }
.skills-mcp-columns { grid-template-columns: 1fr; }
.skills-mcp-list { max-height: none; }
}
@media (prefers-reduced-motion: reduce) {
.content > .panel,
.content > .grid,
.content > .about-page,
.content > .settings-panel,
.content > .update-strip { animation: none !important; }
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.2.18",
"version": "0.2.21",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",