尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

用Tkinter给Codex CLI做悬浮状态窗:跟随窗口与鼠标实时显示任务进度

用Tkinter给Codex CLI做悬浮状态窗:跟随窗口与鼠标实时显示任务进度 在终端里使用 Codex CLI 处理编码任务时最容易被忽略的就是任务状态。代码改动、命令执行、错误信息都混在滚动输出里一旦终端切走屏幕上只剩一个可能早已过时的提示。为了把状态从终端里“拎”出来我写了一个会跟着 Codex 走的悬浮窗口它无边框、置顶、可以半透明既能在 Codex 终端窗口旁边跟随也能黏在鼠标附近持续显示当前阶段。这篇文章会把完整的实现思路拆开讲清楚悬浮窗骨架怎么做如何用数据源隔离 Codex 输出和界面刷新窗口位置怎么跟随鼠标或跟随 Codex 终端以及最常踩的坑和排查路径。最终的代码只依赖 Python 标准库和一个 Tkinter不引入重型 GUI 框架适合快速改成自己的版本。1. 先理解这个悬浮窗要解决什么问题1.1 Codex CLI 使用场景里的信息盲区Codex CLI 是 OpenAI 提供的命令行编程智能体工具开发者可以在终端里用自然语言描述需求让它辅助生成代码、解释代码片段或执行一些常规工程操作。它的核心交互发生在终端窗口里而终端窗口的信息承载能力其实很有限输出会滚动、状态会被新的日志顶掉、错误信息可能要翻好几屏才能看到。在真实工作流里问题更容易出现在切换场景。比如你在 IDE 里写代码在终端里跑 Codex在浏览器里查资料。当你切到 IDE 时Codex 终端已经被其他窗口盖住你根本不知道它是在思考、在跑命令还是已经报错。如果只靠耳朵等提示音又会在多任务时漏掉关键节点。这个悬浮窗解决的不是“让 Codex 跑得更快”而是“让 Codex 的状态更容易被注意到”。它只做两件事持续接收 Codex 的输出内容把当前阶段抽象成一个短标签显示在置顶窗口里。窗口本身很轻不占任务栏不会抢焦点随时可以用鼠标拖动到屏幕角落。1.2 “跟随”到底指什么三种跟随模式“跟着 Codex 走”这个标题很容易被误解成窗口会自己飘到 Codex 旁边。实际上在桌面应用里“跟随”需要落到具体的位置算法上。我在实现中定义了三种模式对应三种不同使用习惯。模式用途实现要点窗口跟随让悬浮窗贴到 Codex 终端窗口旁边终端移动时窗口跟着移动定时获取目标窗口矩形坐标用偏移量计算新位置鼠标跟随让悬浮窗跟着鼠标指针走适合频繁切换操作区读取鼠标屏幕坐标加上偏移量后重设窗口位置手动模式关闭自动跟随把窗口拖到固定位置长期停留不主动调用定位逻辑只保留鼠标拖动能力窗口跟随模式和鼠标跟随模式不是互斥的实际使用中可以用快捷键或右键菜单随时切换。比如平时保持手动模式放在屏幕右下角需要盯 Codex 时切到窗口跟随模式让它贴到终端旁边。2. 环境准备Tkinter 标准库方案结构越简单越容易跑通2.1 运行环境要求这个项目的依赖非常少。GUI 使用 Python 自带的 Tkinter进程输出读取使用标准库subprocess窗口位置获取在 Windows 上可以直接用ctypes调用 Win32 API。除非你要在 macOS 或 Linux 上做完整的“窗口跟随”否则不需要额外安装第三方库。运行环境要求说明操作系统Windows 10 或更高版本窗口跟随实现以 Win32 API 为例Python3.9 或更高版本使用标准库 dataclass 和类型注解TkinterPython 自带Windows 安装 Python 时默认包含Codex CLI按官方文档安装并完成登录本文不展开安装细节只处理本地输出展示如果想在 macOS 上实现窗口跟随需要换成Quartz相关 API或者在终端里用osascript读取窗口信息。在 Linux 上一般借助wmctrl或xdotool。本文的完整代码以 Windows 为主其他平台可以沿用同样的架构只替换窗口定位这一层。注意代码中读取 Codex 子进程输出的方式依赖 Codex 命令本身能将日志写到标准输出或指定日志文件。交互式 TUI 模式不一定走 stdout 管道后面会给出日志文件兜底方案。2.2 项目结构和文件规划代码按职责拆成三个文件config.py维护窗口参数sources.py管理数据源main.py放 GUI 和主循环。这种拆分的好处是后续把fake数据源换成真实 Codex 日志时不需要改动窗口代码。codex-floating-window/ ├── config.py ├── sources.py └── main.pyconfig.py里用一个 dataclass 集中管理所有可调参数。Tkinter 的悬浮窗高频参数不多集中放一个文件里改起来很直观。from dataclasses import dataclass dataclass class WindowConfig: width: int 220 height: int 90 offset_x: int 20 offset_y: int 0 alpha: float 0.92 font_family: str Microsoft YaHei font_size: int 10 poll_interval_ms: int 200 follow_mode: str window codex_cmd: str codex exec \帮我检查当前目录下的代码构建问题\ log_file: str 其中follow_mode支持window、mouse、manual三个值。codex_cmd是真实数据源要执行的命令log_file留空时从子进程标准输出读取不为空时改为监控日志文件。3. 第一版实现先做出一个能拖、能置顶的悬浮窗3.1 无边框悬浮窗的骨架先完成最基础的窗口。Tkinter 默认窗口带系统标题栏和边框会占任务栏、抢焦点不符合悬浮窗的预期。用overrideredirect(True)去掉边框用attributes(-topmost, True)让窗口保持在普通窗口前面。import tkinter as tk from tkinter import ttk class FloatingWindow: def __init__(self, root: tk.Tk, config: WindowConfig): self.root root self.config config self._pinned True root.overrideredirect(True) root.attributes(-topmost, True) root.attributes(-alpha, config.alpha) self.frame tk.Frame(root, bg#2b2b2b) self.frame.pack(fillboth, expandTrue) self.status_label tk.Label( self.frame, text等待任务, fg#e6e6e6, bg#2b2b2b, font(config.font_family, config.font_size, bold), anchorw, ) self.status_label.pack(padx8, pady(6, 2), fillx) self.latest_label tk.Label( self.frame, text尚未收到日志, fg#9d9d9d, bg#2b2b2b, font(config.font_family, config.font_size - 1), anchorw, justifyleft, wraplengthconfig.width - 20, ) self.latest_label.pack(padx8, pady(0, 6), fillx)-alpha参数控制窗口整体透明度0.92 表示基本不透明但隐约能看到底下内容。wraplength让最新日志文本超过窗口宽度时自动换行避免把窗口撑开。去掉边框之后没有系统关闭按钮也没有最小化和最大化行为所以必须在程序内部自己处理拖动、关闭、置顶切换。这也是很多悬浮窗实现“看起来简单跑起来不听话”的原因。3.2 绑定鼠标事件实现拖动和关闭无边框窗口的拖动逻辑很简单记录鼠标按下时的位置偏移在拖动时用当前鼠标位置减去偏移得到新的窗口坐标。需要把事件绑定到窗口上的所有控件否则点击文本区域时拖动无效。def _bind_mouse(self): for widget in (self.root, self.frame, self.status_label, self.latest_label): widget.bind(Button-1, self._on_drag_start) widget.bind(B1-Motion, self._on_drag_motion) widget.bind(Double-Button-1, self._toggle_pin) widget.bind(Button-3, self._show_context_menu) def _on_drag_start(self, event): self._drag_x event.x self._drag_y event.y def _on_drag_motion(self, event): x self.root.winfo_x() event.x - self._drag_x y self.root.winfo_y() event.y - self._drag_y self.root.geometry(f{x}{y}) def _toggle_pin(self, _event): self._pinned not self._pinned self.root.attributes(-topmost, self._pinned) def _show_context_menu(self, event): menu tk.Menu(self.root, tearoff0) menu.add_command(label置顶切换, commandself._toggle_pin) menu.add_command(label退出, commandself.root.destroy) menu.tk_popup(event.x_root, event.y_root)右键菜单提供一个退出入口避免出现“窗口关不掉只能结束进程”的尴尬。双击窗口可以临时取消置顶再双击恢复。这些交互细节在无边框窗口里不是可选项而是必须补上的基础能力。4. 接入数据源先跑假日志再对接 Codex 输出4.1 用数据源抽象隔离界面和命令GUI 只需要知道“当前状态”和“最新一行日志”不该关心数据来自 Codex 命令还是日志文件。所以在正式对接 Codex 之前先抽象一层数据源。import queue import random import subprocess import threading import time class BaseSource: def start(self): raise NotImplementedError def poll(self) - list: raise NotImplementedError所有数据源都往内部队列里写入文本GUI 主循环每隔poll_interval_ms毫秒调用poll()取走新增行。这样处理有一个重要的原因Tkinter 不是线程安全的子线程直接修改控件会造成闪烁、卡死甚至崩溃。用队列中转之后只有主线程会修改界面。class SourceWithQueue(BaseSource): def __init__(self): self.queue queue.Queue() def poll(self) - list: lines [] while True: try: lines.append(self.queue.get_nowait()) except queue.Empty: break return lines这个队列会在窗口关闭、程序退出时自动被回收不需要手动清理。如果打算长期运行也可以把队列改为最大长度比如queue.Queue(maxsize200)避免日志量过大时内存持续增长。4.2 模拟数据源不限环境快速验证最开始不要直接跑 Codex先用一个模拟数据源验证界面交互。它能生成循环状态日志保证不依赖任何外部环境。class FakeSource(SourceWithQueue): STATUSES [等待, 思考, 执行, 完成, 错误] def start(self): def runner(): index 0 while True: status self.STATUSES[index % len(self.STATUSES)] self.queue.put(status) self.queue.put(f模拟日志第 {index 1} 行当前阶段{status}) index 1 time.sleep(0.8) threading.Thread(targetrunner, daemonTrue).start()先用模拟数据源跑起来确认窗口能拖动、能置顶、能刷新再切换真实数据源。这样做能把“界面问题”和“命令输出问题”分开排查避免一上来就面对一团乱麻。4.3 真实数据源读取 Codex 子进程或日志文件真实数据源有两种输入方式。第一种是直接启动 Codex 命令读取标准输出和标准错误合并后的内容。注意不同 Codex 版本的命令行格式可能不一样codex exec ...只是示例落地前先确认本机安装版本的 CLI 参数。class ProcessSource(SourceWithQueue): def __init__(self, cmd: str, log_file: str ): super().__init__() self.cmd cmd self.log_file log_file def start(self): def runner(): if self.log_file: self._read_log_file() else: self._read_stdout() threading.Thread(targetrunner, daemonTrue).start() def _read_stdout(self): process subprocess.Popen( self.cmd, shellTrue, stdoutsubprocess.PIPE, stderrsubprocess.STDOUT, textTrue, encodingutf-8, errorsreplace, ) for line in process.stdout: self.queue.put(line.rstrip()) def _read_log_file(self): with open(self.log_file, r, encodingutf-8, errorsreplace) as f: f.seek(0, 2) while True: line f.readline() if line: self.queue.put(line.rstrip()) else: time.sleep(0.2)第二种是监控日志文件。Codex 如果以交互式 TUI 模式运行输出渲染在终端界面上不一定通过 stdout 管道暴露。这时候可以把 Codex 的输出重定向到文件或者直接监控 Codex 自己生成的日志文件然后用_read_log_file追读文件尾。shellTrue在示例里可以让命令写法更自然但生产环境要小心不要把未经校验的用户输入拼进命令字符串。更好的做法是把命令拆分成长度参数列表不要走 shell 解析。5. 让窗口“跟着 Codex 走”窗口位置跟随的两种实现5.1 跟随鼠标实现鼠标跟随是三种模式里最简单的一种。Tkinter 提供winfo_pointerx()和winfo_pointery()获取鼠标当前屏幕坐标然后跟窗口尺寸做偏移。def follow_mouse(self): x self.root.winfo_pointerx() self.config.offset_x y self.root.winfo_pointery() self.config.offset_y self.root.geometry(f{x}{y})这个实现适合键盘鼠标频繁操作的人。窗口会黏着鼠标让你在任何角落都能看到最新状态。但要注意偏移量不能太大否则鼠标移动路径会挡住窗口的大部分内容。5.2 跟随 Codex 终端窗口从进程到坐标窗口跟随模式需要定时获取 Codex 终端窗口的坐标。Windows 上可以用ctypes.windll.user32调用 Win32 API不需要安装 pywin32。具体做法是枚举所有窗口按标题关键字找到 Codex 终端再读取它的矩形区域。import ctypes from ctypes import wintypes def _find_window_containing(keyword: str): results [] def callback(hwnd, _extra): length user32.GetWindowTextLengthW(hwnd) if length 0: buf ctypes.create_unicode_buffer(length 1) user32.GetWindowTextW(hwnd, buf, length 1) if keyword.lower() in buf.value.lower(): results.append(hwnd) return True user32.EnumWindows(callback, 0) return results[0] if results else None def _get_window_rect(hwnd): class RECT(ctypes.Structure): _fields_ [ (left, wintypes.LONG), (top, wintypes.LONG), (right, wintypes.LONG), (bottom, wintypes.LONG), ] rect RECT() user32.GetWindowRect(hwnd, ctypes.byref(rect)) return { left: rect.left, top: rect.top, width: rect.right - rect.left, height: rect.bottom - rect.top, }EnumWindows会遍历当前桌面的顶层窗口按窗口标题里是否包含Codex来筛选。为了让这个逻辑更稳定建议在启动 Codex 之前手动把终端窗口标题改成固定值。Windows 的 cmd 或 PowerShell 里执行title Codex即可。user32 ctypes.windll.user32 def follow_window(self, keywordCodex): hwnd _find_window_containing(keyword) if not hwnd: return False rect _get_window_rect(hwnd) x rect[left] rect[width] self.config.offset_x y rect[top] self.config.offset_y self.root.geometry(f{x}{y}) return True这段代码会把悬浮窗放到 Codex 终端右侧偏上的位置。如果终端移动到另一个显示器下一次轮询时窗口也会跟过去。5.3 位置和大小参数化窗口位置不是写死的全部收敛在WindowConfig里。这样每台机器的屏幕尺寸不一样、个人偏好不一样只改 config 就行不用动逻辑。参数默认值含义调整影响width220窗口宽度太小会截断日志太大会挡屏幕height90窗口高度决定最多显示几行内容offset_x20相对目标位置的横向偏移决定窗口贴在终端右侧多远offset_y0相对目标位置的纵向偏移决定窗口和终端顶部的对齐方式alpha0.92透明度太透明容易看不清过高会挡住内容poll_interval_ms200GUI 主循环刷新间隔过小 CPU 占用高过大状态延迟明显follow_modewindowwindow/mouse/manual控制窗口位置更新策略poll_interval_ms需要单独说一句Tkinter 的after轮询机制不是实时回调它只是告诉 Tk 主循环“多少毫秒后再执行一次”。200 毫秒足够感知大多数状态变化又不会让 CPU 空转。不要为了追求“秒刷”把间隔调到 10 毫秒悬浮窗每秒刷新 100 次没有实际意义。6. 运行验证与日志观测6.1 启动参数和预期效果先创建main.py的主入口把数据源和悬浮窗串起来。import time import tkinter as tk from config import WindowConfig from sources import BaseSource, FakeSource, ProcessSource class App: def __init__(self, root: tk.Tk, source: BaseSource, config: WindowConfig): self.root root self.source source self.config config self.window FloatingWindow(root, config) self._last_follow_time 0.0 def start(self): self.source.start() self._poll() def _poll(self): lines self.source.poll() for line in lines: self._update(line) now time.time() if self.config.follow_mode mouse: self.window.follow_mouse() elif self.config.follow_mode window and now - self._last_follow_time 1.0: self.window.follow_window() self._last_follow_time now self.root.after(self.config.poll_interval_ms, self._poll) def _update(self, line: str): status parse_status(line) self.window.status_label.config(textfCodex {status}) self.window.latest_label.config(textline[:120]) def parse_status(line: str) - str: text line.lower() for status, keywords in { 思考: [thinking, planning, analyzing, 思考], 执行: [running, executing, bash, 执行], 完成: [done, success, completed, 完成], 错误: [error, failed, exception, 错误], }.items(): if any(keyword in text for keyword in keywords): return status return 等待 if __name__ __main__: config WindowConfig(follow_modewindow) source FakeSource() root tk.Tk() app App(root, source, config) app.start() root.mainloop()模拟模式启动命令python main.py预期效果屏幕出现一个深色小窗口显示Codex 等待或Codex 思考等状态最下面一行日志不断变化窗口可以拖动双击可以取消或恢复置顶右键菜单可以退出。切换真实 Codex 模式时把数据源换成ProcessSource同时设置config.codex_cmd。先保持follow_modemanual确认数据能刷新再切到window模式。6.2 验证清单验证项操作预期结果鼠标拖动按住窗口空白区域拖拽窗口跟着鼠标移动不脱手置顶切换双击窗口窗口切换到非置顶状态再双击恢复右键退出右键窗口选择退出进程结束托盘不留残留日志刷新观察模拟数据源状态和最新日志行周期变化跟随鼠标设置 follow_modemouse移动鼠标窗口出现在鼠标附近跟随窗口设置 follow_modewindow 后启动 Codex 终端悬浮窗贴到 Codex 终端右侧中文显示让数据源输出中文日志界面不出现乱码这个清单也是日常改动代码后的回归测试项。改参数、改布局、改数据源至少要把这几项跑一遍。7. 常见问题与排查路径7.1 窗口无法拖动、无法关闭现象鼠标按住窗口移动无效或者窗口没有退出入口。原因事件只绑定到了根窗口没有绑定到内部Label和Frame无边框窗口没有系统标题栏关闭按钮也不存在。检查方式确认_bind_mouse是否遍历了所有控件。解决方案把事件统一绑定到root、frame、两个Label提供右键菜单退出。预防建议以后新增控件时把_bind_mouse里的绑定列表同步更新。7.2 界面不刷新、卡顿现象窗口能显示但日志一直不更新或者主界面操作明显卡顿。原因最常见的是在子线程里直接调用Label.config(text...)违反 Tkinter 线程安全约束另一个原因是poll_interval_ms太短主循环被高频刷新占满。检查方式在数据源写入队列时打印日志先确认队列里有没有数据再确认主循环是否在正常执行。解决方案所有控件更新放到主线程子线程只负责queue.put把poll_interval_ms调到 100 到 300 毫秒之间。预防建议严格保持“数据源线程只写队列GUI 主线程只读队列”的模型。7.3 子进程输出中文乱码现象Codex 输出的中文变成锟斤拷一类乱码。原因Windows 控制台默认编码和管道读取编码不一致常见于中文版系统。检查方式打印原始字节或确认subprocess.Popen使用的encoding参数。解决方案读取时显式指定encodingutf-8并加errorsreplace防止个别字符中断如果 Codex 输出使用系统默认编码则改成encodinggbk。预防建议优先让 Codex 把日志写到 utf-8 文件再让程序监控文件避免经过控制台编码转换。7.4 窗口坐标在多显示器和高 DPI 下偏移现象跟随窗口时窗体位置和目标窗口有明显偏差不同显示器缩放比例不一致时偏差更明显。原因GetWindowRect返回的是物理像素坐标而 Tk 的窗口坐标受系统 DPI 虚拟化影响。检查方式打印GetWindowRect返回值和winfo_x()/winfo_y()对比差值。解决方案在 Windows 上给程序加入 DPI 感知声明或者根据缩放比例换算坐标多显示器场景下不要硬编码偏移量。预防建议在config里预留scale_factor参数遇到缩放问题先手动换算验证。7.5 topmost 失效现象设置了-topmost但悬浮窗仍被某些窗口盖住。原因部分应用会把自己设置为更高层级的置顶窗口Tkinter 的置顶是请求式的不是系统级强行置顶。检查方式观察是否每次都被同一个程序覆盖比如播放器、显示器的 OSD、其他悬浮工具。解决方案在用户需要时重新触发置顶比如双击窗口恢复topmost必要时使用系统级置顶工具或在合适场景下接受“大部分时间置顶”。预防建议不要把悬浮窗设计成必须永远在最前面的关键路径保持可拖出屏幕边缘待命。8. 最佳实践与扩展方向8.1 先分层再考虑长期使用这个悬浮窗的开发过程应该分成三个层次不要一上来就想着 “完整版”。第一层是模拟数据源。只验证窗口外观、拖动、置顶、退出这些 UI 能力不依赖任何外部环境。第二层是日志文件数据源让窗口监控一个真实写入的文本文件验证编码、文件追读、跨平台文件路径。第三层才是直接对接 Codex 命令验证子进程读取、异常处理和终端交互兼容性。长期使用场景还需要额外考虑配置外置化把WindowConfig改成读取本地配置文件而不是改源码日志落盘方便关闭后排查启动时自动检查 Codex 终端窗口是否存在不存在时退回手动模式打包成独立 exe 时确认 Tk 资源被正确包含。8.2 下一步可以加入的能力当前版本只展示一行最新日志和状态标签。扩展方向很多但要控制边界。可以解析 Codex 会话文件把状态之间的时间差计算出来形成任务耗时提示可以在状态跳转到“错误”时让窗口边框变为红色只靠颜色变化就能快速感知异常可以增加托盘图标让窗口可以隐藏和呼出也可以同时监听多个终端窗口为每个 Codex 会话维护一条状态记录。不建议把所有功能都塞进这个小窗口。悬浮窗的信息密度越高越容易挡住工作视线。个人使用中一行状态加一行最新日志已经覆盖了大多数场景。8.3 发布前检查清单检查项具体操作子进程命令是否安全不用shellTrue拼接不可信输入改为参数列表形式子进程异常是否处理启动 Codex 失败时界面是否给出提示而不是静默退出队列是否限制长度长时间运行后内存是否稳定窗口查找是否可靠Codex 终端标题是否能被关键字匹配到DPI 是否处理多显示器缩放下窗口位置是否准确编码是否固定数据源读取是否显式指定编码退出是否干净右键退出能否结束所有线程和子进程打包是否完整打包 exe 后 Tk 资源是否包含回归测试是否通过遍历本文第 6 节的验证清单整个项目最核心的技术判断是把数据源和 UI 分离用队列做线程通信用配置对象管理所有参数。这三件事做到位后续无论换 Codex 版本、换日志文件路径还是从 Windows 迁到其他平台改动成本都很小。对想练手的新手来说这个项目的难度刚好卡在“会写基础语法”和“能处理真实桌面应用细节”之间值得完整跑一遍。
返回列表