打造Codex CLI会话清理工具:自研Rust原生UI框架实践
自己做了一个专门给 Codex CLI 用的会话清理工具顺手设计了一套基于 Rust 的原生 UI 框架。这篇文章不只想展示“我写了什么”更想把两件容易混淆的事情讲清楚第一Codex 这类本地 AI 编程工具的会话数据为什么会膨胀清理时到底要清哪些东西第二为什么在 2025 年还有人愿意自己造 Rust GUI 的轮子自研 UI 框架真的有必要吗。如果你正在被 Codex、Cursor 或者类似 AI 编程助手生成的本地会话文件困扰想知道能不能安全清理或者你正在犹豫要不要学习 Rust GUI 开发、想看看“从零设计 UI 框架”的真实路径这篇文章会比较合适。文中会给出完整的清理策略、UI 框架核心设计思路、关键代码片段以及我在实际开发中踩到的一些坑。先说结论Codex 本地会话清理这件事难点不在“删除文件”而在“识别哪些数据值得保留、哪些可以删除、删除之前如何兜底”。而 Rust 原生 UI 框架这件事真正的价值也不在“重写一个 egui 或者 iced”而在于你可以把 UI 的设计粒度完全控制在自己手里配合小工具开发反而比套用通用框架更顺手。1. 为什么需要“Codex 会话完整清理”这个工具Codex CLI 出现之后很多开发者把它当成了终端里的主力 AI 编程助手。你让它写函数、查报错、重构代码它会通过命令行直接和你的仓库交互。和 web 端的 ChatGPT 不一样Codex CLI 更贴近本地工程环境也就意味着它会产生大量本地数据。这些数据大致分为几类会话历史每一次对话的上下文、消息记录、调用信息日志文件操作日志、网络请求日志、错误日志临时文件模型调用过程中的中间产物、缓存配置文件与认证信息这个一般不建议随便清理。问题在于Codex CLI 自身对“会话管理”做得并不算完善。你通过命令行查看历史会话时能看到的只是很粗略的信息想要按项目、按时间、按模型批量清理几乎只能手动去翻目录。而对于高频使用者来说这些数据在几周内就可能膨胀到数百 MB 甚至更多。有人可能会说几百 MB 对现代硬盘不算什么。没错但它带来的不只是磁盘占用问题还有几个隐藏成本隐私风险。本地可能保留了你完整的业务代码上下文长期不清理是一种安全隐患。性能影响。某些 Codex CLI 版本在启动或切换项目时会扫描或加载本地历史数据数据越多启动就越慢。数据混乱。会话一多你很难找到之前某次重要的讨论记录历史记录变成了负担。故障恢复成本。日志和临时文件可能出现损坏导致 CLI 行为异常而清理工具恰恰没有跟上。我想做一个足够完整的清理工具原因就在这里。它不应该只是“帮用户删掉 .codex 目录”因为那样太粗暴了会在删除过程中把配置文件、认证信息、还在使用的会话数据全部误删。真正有价值的工具应该做到先扫描、再展示、然后由用户选择、最后安全删除并且删除之前能够备份。2. 清理工具怎么做会话数据形态与清理策略要做一个清理工具第一步不是写 UI而是搞清楚目标数据长什么样。从 Codex CLI 的常见存储习惯来看它的本地数据通常放在用户主目录下的.codex目录中。目录下一般会有会话相关的子目录、日志目录和配置文件。不同版本的 Codex CLI 在目录结构上可能存在差异这一点需要以实际安装版本为准。这个清理工具的定位是“完整清理”所以我把它拆成了三个模块第一扫描模块。递归遍历 Codex 数据目录识别出会话文件、日志文件、临时文件和配置文件。配置文件要单独标记不能进入默认清理列表。这个模块的核心难点是“识别”而不是“遍历”。比如哪些文件属于同一个会话哪些日志可以安全删除哪些文件是当前正在写入的都需要做判断。第二分析与展示模块。扫描完之后要给用户呈现出可读的信息会话数量、总占用空间、每个会话的创建时间、最后活跃时间、可能属于哪个项目。这个模块是 UI 层的核心也是自研 UI 框架发挥价值的地方。第三清理与备份模块。用户可以勾选要清理的会话工具会先把目标数据压缩为一个备份包再执行删除。删除后还要做一次校验确认目标文件确实被移除并生成清理报告。备份文件默认放在用户指定的目录不会放在被清理的目录里面。为什么这么设计因为删除操作在本地工具里是最需要慎重对待的能力。如果一上来就是“清理全部”用户很容易在不理解后果的情况下把有用的历史会话清掉。工程师做工具应当默认把兜底机制放在前面。3. 为什么自己设计 Rust 原生 UI 框架这可能是很多人最不理解的部分。Codex 清理工具用 Python 加一个命令行交互就能做甚至写一个 Shell 脚本都能搞定为什么要大费周章设计一套 Rust 原生 UI 框架这个判断要从“工具的用户体验”和“Rust 桌面开发的现状”两个角度来看。3.1 现有方案的问题如果要做桌面 UI最直接的方案是 Electron 或者 Tauri。Electron 的问题比较明显包体体积大、内存占用高做一个清理小工具要背上一个 Chromium 运行时技术上是杀鸡用牛刀。Tauri 比 Electron 轻很多但它本质上还是依赖 Web 前端技术栈涉及系统 WebView 的兼容问题而且对 Rust 开发者来说写前端仍然需要维护 HTML/CSS/JS 三件套。那 Rust 生态里的原生 UI 框架呢egui即时模式 GUI写起来很直接但它的渲染依赖eframe或egui-wgpu等后端内部层级较深。iced保留模式仿 Elm 架构理念很好但组件生态和布局能力还在完善中。slint商业友好的方案有自己的 DSL学习成本不算低。gpuiZed 编辑器团队开源的高性能 UI 框架理念很先进不过它对 macOS 的适配最成熟跨平台能力还在发展。这些框架都有可取之处但它们解决的是“通用 GUI 开发”的问题而不是“精简工具”的问题。对于清理工具这种界面不复杂、但要求响应快、启动快、体积小的应用通用框架其实带了很多用不上的复杂度。3.2 自研 UI 框架的设计目标自己设计 UI 框架目标不是“替代 egui 或 iced”而是做一套刚好够小工具使用的最小 GUI 引擎同时保留扩展能力。我给这套框架定了几个原则原生控件渲染不依赖系统 WebView也不引入完整浏览器引擎界面描述逻辑统一在 Rust 代码里简单直接事件循环、布局、绘制分层清晰方便以后扩展不追求完整富文本、复杂动画那些不是小工具的核心需求。用一句话概括做一个“面向工具类应用的Rust 原生、声明式、可扩展的最小 UI 框架”。这个方向看起来很小其实对学习 Rust 系统编程、图形渲染、事件处理都很有帮助。它不是重新发明轮子而是把轮子按自己的需求重新设计一遍。4. 自研 UI 框架的核心架构设计一套 UI 框架无论多简单至少要包含四层应用模型、事件循环、控件树、绘制后端。下面逐个说明。4.1 应用模型与状态管理自研 UI 框架里我参考了经典的前后端分离思路但不直接复刻某种前端架构。应用被抽象为一个Apptrait开发者要实现init、update、view三个方法init创建初始状态update接收事件并修改状态view根据当前状态产生界面描述。这个思路和 Elm 架构有相似之处但我不把它叫做仿 Elm因为在实际实现中状态管理和事件分发都做了更适合 Rust 的简化。核心数据模型类似这样// 简化示意非完整源码 pub trait App { type State; type Event; fn init(mut self) - Self::State; fn update(mut self, state: mut Self::State, event: Self::Event); fn view(self, state: Self::State) - Node; }这样做的好处是界面和逻辑是分离的。清理工具的扫描逻辑、备份逻辑都不需要关心界面的按钮布局只需要产生事件由update处理状态变更再由view重新生成界面。4.2 事件循环事件循环是 GUI 程序的发动机。我实现了一个比较小的跨平台事件循环它的工作方式很朴素从操作系统接收窗口事件、鼠标事件、键盘事件转换成框架内部的Event推送给应用层。// 简化示意非完整源码 pub enum Event { Init, MouseMoved { x: f32, y: f32 }, MouseClicked { x: f32, y: f32 }, KeyPressed { key: Key }, WindowResized { width: u32, height: u32 }, Custom(String), }事件循环在 Rust 里需要面对一个实际问题借用的生命周期。状态在update中被修改但事件循环需要同时持有窗口、渲染器和状态如果不设计好所有权结构Rust 的借用检查器会让你怀疑人生。我的做法是窗口和渲染器放在一个Host对象中应用状态单独放在AppState中事件循环逐帧执行先处理操作系统事件再调用应用的update最后调用view并渲染。4.3 控件树与绘制层自研框架的控件树没有走虚拟 DOM 那样复杂的 diff 算法路线而是选择了一种更务实的策略每次状态变化后重新生成控件树但只对实际发生变化的控件调用绘制函数。对于小工具场景这个策略已经非常够用。绘制层我把它设计为分层绘图上下文。每个控件可以在一个Canvas上绘制矩形、文字、线条、简单图标。底层可以对接软件渲染也可以对接 GPU 后端二者通过统一的Canvastrait 隔离。// 简化示意非完整源码 pub struct Canvas { pub width: u32, pub height: u32, pub pixels: Vecu32, } impl Canvas { pub fn fill_rect(mut self, x: u32, y: u32, w: u32, h: u32, color: u32); pub fn draw_text(mut self, x: u32, y: u32, text: str, size: f32, color: u32); pub fn draw_line(mut self, x1: u32, y1: u32, x2: u32, y2: u32, color: u32); }很多人会担心纯软件渲染的性能但实际上清理工具这种界面绝大多数帧只有少量重绘。Rust 的内存安全保证加上足够简单的绘制 API很多场景下反而不需要引入 GPU 绘制的复杂度。5. 清理工具的完整实现思路UI 框架设计好之后清理工具就变成了“框架的一个应用”。它的功能基本上是围绕会话数据生命周期设计的。5.1 扫描模块扫描模块的输入是 Codex 数据目录的路径。工具会遍历目录识别出所有会话文件。判断“哪些文件属于同一个会话”一般通过目录名和文件命名的关联完成不同版本的 Codex CLI 规则不同所以我的扫描模块保留了一个配置项允许用户调整文件过滤规则。扫描结果统一记录为一个结构体// 简化示意非完整源码 pub struct SessionRecord { pub session_id: String, pub dir_path: PathBuf, pub file_count: usize, pub total_size: u64, pub last_active: SystemTime, pub is_protected: bool, }is_protected这个字段很关键它标记的是“是否属于当前正在使用的会话”或者“配置文件和认证信息所在目录”。默认情况下被标记的会话不允许直接清理。5.2 展示与交互在自研 UI 框架上清理工具的主界面由三个区域组成左侧是会话列表用表格形式展示会话标识、最后活跃时间、文件数量、占用空间右上角是选中会话的详情包括内容预览和风险提示下方是清理操作区包含备份开关、清理按钮、重置按钮。因为界面是自己设计的所以能做到“只展示必要的信息不做多余装饰”。通过状态管理事件用户可以勾选一个或多个会话再点击清理。每次勾选操作都会触发一次状态更新并重新生成控件树。5.3 清理与备份清理操作采取“先备份再删除”的策略。备份流程是把选中的会话目录复制为一个压缩归档放到用户指定的备份目录确认归档生成成功后再删除原目录删除完成后重新扫描一次确认目标目录已从列表移除。这里有一个容易踩的坑删除正在进行写入的目录会失败甚至可能破坏正在运行的 Codex 进程。所以清理前要做两步检查检查 Codex 进程是否正在运行检查目标会话是否有被占用文件。如果进程正在运行工具会提示用户先关闭 Codex CLI。这个提示虽然简单但如果没做用户很容易遇到“目录删不掉”的困惑。6. 核心代码示例从扫描到清理下面给出清理工具中三个核心代码片段方便你理解整体实现逻辑。它们都是经过简化的示意代码真实项目里需要补充错误处理和边界判断。6.1 扫描 Codex 会话目录// 简化示意扫描目录并构建会话列表 use std::fs; use std::path::{Path, PathBuf}; use std::time::SystemTime; #[derive(Debug)] pub struct SessionRecord { pub session_id: String, pub dir_path: PathBuf, pub file_count: usize, pub total_size: u64, pub last_active: SystemTime, pub is_protected: bool, } pub fn scan_sessions(codex_dir: Path, protected_names: [str]) - VecSessionRecord { let mut records Vec::new(); if !codex_dir.exists() { return records; } for entry in fs::read_dir(codex_dir).expect(failed to read codex dir) { let entry match entry { Ok(e) e, Err(_) continue, }; let path entry.path(); if !path.is_dir() { continue; } let dir_name path .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_default(); // 目录名不在保护列表里才可能作为会话目录 let is_protected protected_names.iter().any(|name| *name dir_name); if is_protected { continue; } let mut file_count 0; let mut total_size 0u64; let mut last_active SystemTime::now(); for file in walk_dir(path) { file_count 1; if let Ok(metadata) fs::metadata(file) { total_size metadata.len(); if let Ok(modified) metadata.modified() { if modified last_active { last_active modified; } } } } records.push(SessionRecord { session_id: dir_name, dir_path: path.clone(), file_count, total_size, last_active, is_protected, }); } records } fn walk_dir(dir: Path) - VecPathBuf { let mut result Vec::new(); if let Ok(entries) fs::read_dir(dir) { for entry in entries.flatten() { let path entry.path(); if path.is_dir() { result.extend(walk_dir(path)); } else { result.push(path); } } } result }这段代码做的事情并不复杂但它定义了清理工具的“视野”哪些目录进入会话列表哪些目录被保护。protected_names是配置文件、认证信息所在的目录名在默认情况下不会出现在会话列表里。6.2 备份并删除会话// 简化示意先备份再删除避免误删无法恢复 use std::fs::{self, File}; use std::io::Write; use std::path::{Path, PathBuf}; pub fn backup_session(session: SessionRecord, backup_dir: Path) - ResultPathBuf, String { let backup_file backup_dir.join(format!({}.tar, session.session_id)); // 简化示意这里用 tar 命令行工具做归档真实项目可改用 tar crate let status std::process::Command::new(tar) .arg(-cf) .arg(backup_file) .arg(session.dir_path) .status(); match status { Ok(s) if s.success() Ok(backup_file), _ Err(format!(backup failed: {}, session.session_id)), } } pub fn delete_session(session: SessionRecord, dry_run: bool) - Result(), String { if dry_run { println!([dry-run] would delete {:?}, session.dir_path); return Ok(()); } fs::remove_dir_all(session.dir_path) .map_err(|e| format!(delete failed: {}, e)) } pub fn run_cleanup(sessions: [SessionRecord], backup_dir: Path, dry_run: bool) - ResultVecPathBuf, String { let mut backups Vec::new(); for session in sessions { if session.is_protected { continue; } if !dry_run { let backup backup_session(session, backup_dir) .map_err(|e| format!(backup error: {}, e))?; backups.push(backup); } delete_session(session, dry_run)?; } Ok(backups) }这段代码给出了一个非常安全的清理路径先备份再删除。dry_run参数用于演练模式用户在正式执行前可以先模拟一遍。不要小看dry_run它能让用户确认“我要做的操作”和“工具实际做法的差异”避免产生“我以为只清理了一个会话结果全部会话都没了”的悲剧。6.3 UI 框架中的事件更新// 简化示意UI 框架里的事件与状态更新 pub enum ToolEvent { ScanFinished(VecSessionRecord), SelectSession(String, bool), CleanRequested, CleanProgress { done: usize, total: usize }, CleanFinished { removed: usize }, ShowError(String), } pub struct ToolState { pub records: VecSessionRecord, pub selected: std::collections::HashSetString, pub is_cleaning: bool, pub error_message: OptionString, } pub fn update_state(state: mut ToolState, event: ToolEvent) { match event { ToolEvent::ScanFinished(records) { state.records records; state.selected.clear(); state.is_cleaning false; } ToolEvent::SelectSession(id, selected) { if selected { state.selected.insert(id); } else { state.selected.remove(id); } } ToolEvent::CleanRequested { state.is_cleaning true; } ToolEvent::CleanProgress { .. } { // 更新进度条状态 } ToolEvent::CleanFinished { removed } { state.is_cleaning false; // 清理完成刷新列表 } ToolEvent::ShowError(msg) { state.is_cleaning false; state.error_message Some(msg); } } }这个更新函数只做状态变更不直接操作界面。界面层在每次状态变化后通过view函数重新生成控件树。这样的设计让状态可测试、可预测也方便以后为工具添加命令行模式——同一个状态机既能驱动 GUI也能驱动终端输出。7. 运行验证与效果评估一个工具写出来必须回答三个问题能扫出来吗能清掉吗会不会误删我的验证路径是这样的第一步准备一个包含多份历史会话的 Codex 数据目录。在测试环境复制一份完整数据避免干扰真实 Codex 使用。第二步启动工具点击“扫描”等待扫描模块返回会话列表。正常情况下列表会显示每个会话的目录标识、文件数、占用空间和最后活跃时间。这里能直观看到哪些会话数据量大、哪些已经长时间没有活跃。这一步验证的是“可见性”。第三步勾选一些确定可以清理的会话开启备份执行清理。清理完成后回到文件系统确认目标目录已经删除备份归档已经生成其他目录没有被动过。这一步验证的是“安全性”。第四步尝试清空所有会话但不勾选备份确认 UI 会弹出风险提示并要求确认。这一步验证的是“兜底机制”。如果扫描结果为空首先要检查数据目录路径是否正确。Codex CLI 在部分平台上的默认数据目录不同如果你的数据没有被扫出来优先确认这个路径。如果清理时报错“目录正在使用”不要强制删除关闭 Codex CLI 进程后再试。这个工具的本质不是“把磁盘空间腾出来”而是“让本地 AI 会话数据处于一个可管理、可审计、可恢复的状态”。用户清掉不用的历史会话长期收益比那几百 MB 空间大得多。8. 常见问题与排查思路下面是我开发和测试这个工具过程中整理出的一些典型问题。如果你自己动手做同类工具可以直接参考。问题现象可能原因排查方式解决方案扫描结果为空数据目录路径不正确检查 Codex CLI 默认数据路径是否包含会话文件在设置中手动指定正确的目录列表里出现不应清理的目录保护名单配置缺失查看保护名单是否覆盖配置、认证目录补充保护目录名默认不要勾选备份执行失败备份目录无写权限或磁盘空间不足查看备份目录权限和可用空间更换备份目录或先释放空间删除时提示目录被占用Codex CLI 进程正在运行查看系统进程列表中是否仍有 codex 进程关闭 Codex CLI 后重新执行清理UI 卡顿或不刷新事件循环阻塞检查是否有耗时操作直接放在 UI 线程将扫描、备份、删除放到后台线程通过事件通知 UI清理完成后会话仍然存在删除后未重新扫描检查清理流程是否调用了刷新逻辑清理完成后自动重新扫描并更新列表这里最值得强调的是第二条。任何清理工具都不能默认“所有数据都可以删除”。配置、认证、密钥这类数据一旦误删轻则需要重新登录重则可能影响本地安全环境。保护名单就是把安全边界用代码固化下来。9. 造轮子项目的工程建议与开源实践从“自己设计 UI 框架”到“完成一个清理工具”再到“把项目开源”整个过程里收获最大的不是 UI 框架本身而是对软件边界的理解。给打算做类似项目的 Rust 开发者几条建议。第一造轮子之前先明确边界。自研 UI 框架不需要一开始就支持主题皮肤、富文本、复杂的 flex 布局。先支持 10 个控件、3 种布局、2 种事件把一个真实小工具跑起来比设计一套 200 页的架构文档有价值得多。第二UI 框架的渲染后端可以后置。先用软件渲染跑通逻辑再考虑对接 GPU。软件渲染在简单界面上足够流畅而且更容易调试。没有业务数据支撑时过早优化渲染性能是浪费时间。第三清理类工具的“安全设计”应该放在功能之前。备份、dry-run、保护名单、二次确认这四件事必须在核心删除逻辑写完后立刻补上而不是等 MVP 之后再加。第四开源项目要重视文档和最小复现用例。这个清理工具虽然不算复杂但我会在 README 里写清楚支持哪些 Codex 版本、数据目录怎么设置、备份策略是什么。很多用户不会看源码但他们会认真看 README。第五AI 编程工具的数据管理会越来越重要。现在不只是 CodexCursor、Cline、众多终端类 AI Agent 都在本地产生会话数据和缓存。如果这个项目能抽象出通用的“会话清理能力”它甚至可以服务多个 AI 工具。从长期看这类治理工具的需求还会增长。10. 总结与下一步这个项目最核心的三件事我再用一两句话说清楚Codex 本地会话清理难点在于识别与兜底。识别决定了你会不会误删兜底决定了你删错了还能不能恢复。自研 Rust 原生 UI 框架核心收获是把 GUI 的主动权拿回自己手里。对于功能聚焦的小工具通用框架带来的抽象成本和依赖成本往往超过收益。开源的意义不只是把代码放出去。对于一个造轮子项目它更像是一份“设计决策记录”把为什么这么设计、踩过哪些坑、适合什么场景全部暴露给后来者。如果你也打算做一个类似的小工具我的建议是先用命令行走通清理逻辑再用最笨的 UI 方式把它可视化最后才去考虑有没有必要为它专门造一套 UI 框架。如果连命令行版本都解决不了你的实际痛点那 UI 框架再漂亮也只是一个 demo。这个项目的下一步我会先完善跨平台支持的细节补充更多 Codex 版本的适配同时把 UI 框架的控件库继续扩展。如果你也在关注 Rust 原生 GUI、或者正在想怎么管理本地 AI 工具的会话数据欢迎收藏这篇文章也欢迎去看一下这个开源项目从代码里能找到更多比文章更具体的实现细节。