平时用 Codex 做开发助手时,我习惯把它独立放在一个窗口里,旁边再开一个草稿窗口记录思路、保存临时代码片段。这样用确实顺手,但有一个很实际的痛点:一旦把 Codex 窗口拖到另一块屏幕,或者切换了桌面布局,旁边的草稿窗口不会跟着走。每次都要手动把两个窗口重新摆到一起,遇到正在排查问题的场景特别打断思路。
于是我用 Python 写了一个小工具:一个无边框悬浮小窗,会自动“贴”在 Codex 窗口右侧。Codex 窗口移动到哪,它就跟着移动到哪;Codex 最小化,它就自动隐藏;Codex 恢复,它再自动出现。这篇文章就把完整的实现思路、代码、常见问题和工程化建议分享出来。无论你使用的是 Codex CLI、Codex 桌面端,还是把 Codex 跑在 VS Code 终端里,这个方案都适用。
1. 为什么要做“跟随窗口”
Codex 这类 AI 编程助手,实际使用时通常不是单独一个软件,而是有多种使用形态。有些开发者喜欢用 Codex CLI,直接在终端里对话;有些用桌面客户端;还有一些会在 VS Code 里安装 Codex 插件,作为侧边栏工具使用。窗口形态不同,但共同需求是一样的:Codex 内容区旁边,往往需要另一个辅助窗口来放上下文、临时笔记、生成的代码片段,或者用来展示任务状态。
如果不做任何处理,辅助窗口和主窗口之间的位置关系是“静态”的。你第一次把它们摆放好,可能只维持到下次拖动窗口之前。一旦移动了 Codex 窗口,辅助窗口还留在原地,看起来就会很散乱。多显示器环境下问题更明显,副屏上的 Codex 窗口和主屏上的笔记窗口经常隔得很远。
“跟随窗口”解决的就是这个动态布局问题。它本质上是一个位置同步工具:监听目标窗口的位置变化,然后实时调整辅助窗口的坐标。用户只需要用一次鼠标,把辅助小窗拖到目标窗口旁边,之后无论怎么移动 Codex,辅助小窗都会保持相对位置,不需要再手动整理。
这个方案听起来很“玩具”,但在日常开发里非常实用,尤其是需要长时间盯着 Codex 输出的时候。辅助窗口可以展示命令执行状态、记录问答历史、放参考文档,甚至做成一个简单的“复制板”。下面我们开始拆解具体实现。
2. 窗口跟随的核心原理
在 Windows 系统中,每个可见应用的顶层窗口都有一个句柄(HWND)。操作系统通过句柄管理窗口的位置、大小、可见性、层级等属性。要实现窗口跟随,关键就是两件事:
第一,拿到目标窗口的句柄。Codex 窗口可能是桌面客户端的顶层窗口,也可能是终端窗口,或者 VS Code 主窗口。无论哪种情况,只要有句柄,就能读取到它的屏幕坐标。
第二,读取目标窗口的位置和大小,设置辅助窗口的位置。Windows 提供了GetWindowRect这样的 API,可以拿到窗口左上角和右下角的屏幕坐标;再通过SetWindowPos、MoveWindow,或者直接设置 Tkinter 窗口的geometry,就能把辅助窗口移动到目标窗口旁边。
需要强调的是,“跟随”并不等同于“置顶”。置顶是让窗口始终浮在其他窗口上方,不改变位置;而跟随是让辅助窗口的位置随着目标窗口变化。当然,实际开发中两者经常一起使用,辅助窗口既要贴住 Codex,又要保持浮在最上层,这样无论切到什么应用,旁边的小窗都能看到。
实现方式上,最常见的是“轮询”方案:每隔 200 到 300 毫秒读取一次目标窗口位置,如果位置变化超过阈值,就更新辅助窗口坐标。这种方式简单、稳定,而且足够应付日常使用。更高级的方案是监听窗口事件,比如使用WinEventHook订阅窗口移动事件,但代码复杂度会高很多。对于这个场景,轮询已经足够。
3. 环境准备与版本说明
本文示例代码使用 Python 和 pywin32 库,主要运行环境是 Windows 10 或 Windows 11。
你需要在机器上安装:
- Python 3.8 或更高版本。
- pywin32,用于调用 Windows API。
- psutil,如果希望按进程名匹配窗口,这个库是可选的。
安装命令如下:
pip install pywin32 psutil如果你的环境已经安装了pywin32,但命令行找不到win32gui模块,可以检查一下 Python 版本是否与 pywin32 版本匹配,或者重新执行一次安装:
pip install --upgrade pywin32本文示例中的界面部分使用 Python 自带的 Tkinter,不需要额外安装。Tkinter 是 Python 标准库的一部分,正常情况下安装 Python 时就会带上。如果你运行import tkinter报错,说明当前 Python 发行版没有包含 Tk 支持,需要重新安装带 Tk 的 Python 版本。
版本相关说明:Codex 产品形态更新较快,实际窗口标题可能随版本变化。本文示例不会把窗口标题写死,而是提供了一个关键字参数,运行命令时动态传入。如果你的 Codex 窗口标题不叫 “Codex”,只需要换一个关键字即可。
示例项目结构如下:
codex-follower/ ├── main.py ├── find_window.py ├── rect_util.py └── follower.py其中find_window.py负责查找目标窗口句柄,rect_util.py负责读取窗口坐标和处理 DPI 缩放,follower.py是核心的跟随窗口界面,main.py是启动入口。
4. 核心代码实现
下面按文件拆分,每一步都会说明代码的作用。
4.1 查找 Codex 窗口句柄
查找窗口句柄有两种常见方式:按窗口标题关键字匹配,或者按进程名匹配。
先来看最稳妥的标题匹配方式。你可以在运行工具时传入--title Codex,程序会在所有可见顶层窗口中搜索标题包含 “Codex” 的窗口。为了避免匹配到不相关的窗口,还可以同时指定进程名。
# find_window.py import win32gui import win32process import psutil class WindowFinder: """按标题或进程名查找目标窗口句柄。""" def __init__(self, title_keyword=None, process_name=None): self.title_keyword = title_keyword self.process_name = process_name self.hwnd = None def _match(self, hwnd): if not win32gui.IsWindowVisible(hwnd): return False if self.title_keyword: title = win32gui.GetWindowText(hwnd) if self.title_keyword.lower() in title.lower(): return True if self.process_name: try: _, pid = win32process.GetWindowThreadProcessId(hwnd) proc_name = psutil.Process(pid).name().lower() except Exception: proc_name = "" if proc_name == self.process_name.lower(): return True return False def find(self): self.hwnd = None def enum_proc(hwnd, lparam): if self._match(hwnd): self.hwnd = hwnd return False return True win32gui.EnumWindows(enum_proc, None) return self.hwndwin32gui.EnumWindows会枚举当前系统所有顶层窗口,回调函数返回True表示继续遍历,返回False表示停止。IsWindowVisible用来过滤被隐藏的窗口,避免匹配到系统内部窗口。
这里有个细节需要关注:如果 Codex 是跑在终端里,窗口标题可能是终端标题,而不是 “Codex”。比如你在 Windows Terminal 里运行codex命令,窗口句柄属于 Windows Terminal,不属于 Codex 进程。这种情况下建议用终端窗口的标题关键字,或者在终端标题里加上 “Codex” 字样。
4.2 读取窗口坐标与 DPI 处理
拿到窗口句柄后,可以用GetWindowRect读取窗口的屏幕坐标。这个函数返回四个值:左边界、上边界、右边界、下边界。用右边界减去左边界就是窗口宽度,下边界减去上边界就是窗口高度。
高分屏环境下需要额外注意 DPI 缩放。Windows 默认会对没有声明 DPI 感知的程序进行坐标缩放。如果你的 Python 程序没有调用 DPI 感知函数,GetWindowRect拿到的坐标可能和实际屏幕坐标不一致,导致辅助窗口位置偏到错误的地方。
# rect_util.py import ctypes import win32gui class Rect: def __init__(self, left, top, right, bottom): self.left = left self.top = top self.right = right self.bottom = bottom @property def width(self): return self.right - self.left @property def height(self): return self.bottom - self.top def get_window_rect(hwnd): left, top, right, bottom = win32gui.GetWindowRect(hwnd) return Rect(left, top, right, bottom) def set_dpi_awareness(): try: ctypes.windll.shcore.SetProcessDpiAwareness(2) except Exception: try: ctypes.windll.user32.SetProcessDPIAware() except Exception: passSetProcessDpiAwareness(2)表示按每个显示器的 DPI 感知模式运行,适用于多显示器且缩放比例不同的场景。如果系统版本较老,不支持这个接口,就回退到SetProcessDPIAware。
这段代码应该在创建任何窗口之前调用,否则 Tkinter 窗口可能已经按缩放后的虚拟坐标初始化,后续坐标换算会不一致。
4.3 实现跟随窗口界面
接下来是核心的follower.py。这个文件里使用 Tkinter 创建了一个无边框置顶小窗,然后通过after定时器循环读取 Codex 窗口位置,并移动小窗。
选择after而不是threading是因为 Tkinter 的界面操作必须在主线程中完成。如果你在子线程里直接修改 Tkinter 控件,轻则界面卡顿,重则崩溃。after是 Tkinter 官方推荐的定时任务方式,既不会阻塞界面,又能安全地访问控件。
# follower.py import tkinter as tk import win32gui from find_window import WindowFinder from rect_util import get_window_rect class FollowWindow: def __init__( self, finder, width=240, height=180, offset_x=20, offset_y=0, interval_ms=300, ): self.finder = finder self.width = width self.height = height self.offset_x = offset_x self.offset_y = offset_y self.interval_ms = interval_ms self.running = True self.paused = False self.last_pos = None self.hidden = False self.root = tk.Tk() self.root.title("跟随小窗") self.root.overrideredirect(True) self.root.attributes("-topmost", True) self.root.configure(bg="#2b2b2b") self._build_ui() self.finder.find() def _build_ui(self): self.title_label = tk.Label( self.root, text="跟随中", fg="#ffffff", bg="#2b2b2b", font=("Microsoft YaHei", 12), ) self.title_label.pack(pady=10) tip_label = tk.Label( self.root, text="拖动 Codex 窗口,我会自动跟在旁边", fg="#aaaaaa", bg="#2b2b2b", font=("Microsoft YaHei", 9), ) tip_label.pack(padx=10) btn_frame = tk.Frame(self.root, bg="#2b2b2b") btn_frame.pack(pady=8) self.pause_btn = tk.Button( btn_frame, text="暂停", command=self.toggle_pause, width=6 ) self.pause_btn.grid(row=0, column=0, padx=5) close_btn = tk.Button( btn_frame, text="退出", command=self.stop, width=6 ) close_btn.grid(row=0, column=1, padx=5) def toggle_pause(self): self.paused = not self.paused self.pause_btn.config(text="继续" if self.paused else "暂停") self.title_label.config(text="已暂停" if self.paused else "跟随中") def _update_position(self): if not self.running: return if not self.paused: hwnd = self.finder.hwnd if hwnd is None or not win32gui.IsWindow(hwnd): hwnd = self.finder.find() if hwnd: if win32gui.IsIconic(hwnd): if not self.hidden: self.root.withdraw() self.hidden = True else: rect = get_window_rect(hwnd) target_x = rect.left + rect.width + self.offset_x target_y = rect.top + self.offset_y if ( self.last_pos is None or abs(target_x - self.last_pos[0]) > 2 or abs(target_y - self.last_pos[1]) > 2 ): self.root.geometry( f"{self.width}x{self.height}+{target_x}+{target_y}" ) self.last_pos = (target_x, target_y) if self.hidden or not self.root.winfo_viewable(): self.root.deiconify() self.hidden = False else: if not self.hidden: self.root.withdraw() self.hidden = True self.last_pos = None self.root.after(self.interval_ms, self._update_position) def run(self): self.root.after(self.interval_ms, self._update_position) self.root.mainloop() def stop(self): self.running = False self.root.destroy()代码里的跟随逻辑可以和实际场景对应起来。
第一,目标窗口最小化时,IsIconic返回True。这时候如果继续读取窗口坐标,拿到的可能是最小化状态的坐标,不适合用来计算跟随位置,所以直接隐藏辅助窗口。
第二,目标窗口恢复后,辅助窗口需要重新显示。这里通过self.hidden状态和root.winfo_viewable()判断,避免反复调用deiconify造成闪烁。
第三,坐标更新加了 2 像素的阈值。如果 Codex 窗口只是原地不动或轻微抖动,辅助窗口不会频繁调用geometry,减少界面刷新压力。
4.4 启动入口
main.py负责接收命令行参数,初始化查找器和跟随窗口。通过命令行传参,可以让工具不依赖写死的窗口标题,适应不同使用方式。
# main.py import argparse from find_window import WindowFinder from follower import FollowWindow from rect_util import set_dpi_awareness def parse_args(): parser = argparse.ArgumentParser(description="Codex 跟随窗口小工具") parser.add_argument("--title", default="Codex", help="窗口标题关键字") parser.add_argument("--process", default=None, help="进程名,例如 codex.exe") parser.add_argument("--width", type=int, default=240, help="小窗宽度") parser.add_argument("--height", type=int, default=180, help="小窗高度") parser.add_argument("--offset-x", type=int, default=20, help="距离目标窗口的横向偏移") parser.add_argument("--offset-y", type=int, default=0, help="距离目标窗口的纵向偏移") parser.add_argument("--interval", type=int, default=300, help="轮询间隔毫秒") return parser.parse_args() if __name__ == "__main__": args = parse_args() set_dpi_awareness() finder = WindowFinder( title_keyword=args.title, process_name=args.process, ) app = FollowWindow( finder, width=args.width, height=args.height, offset_x=args.offset_x, offset_y=args.offset_y, interval_ms=args.interval, ) app.run()启动命令很简单:
python main.py --title Codex如果你希望小窗显示在 Codex 窗口右侧更远一点,可以调整横向偏移:
python main.py --title Codex --offset-x 30如果你希望按进程名匹配,可以这样运行:
python main.py --process codex.exe注意,当 Codex 以 CLI 方式跑在 Windows Terminal 里时,真正可见的窗口进程是 Windows Terminal,不是 codex。因此优先使用--title指定终端标题关键字。
5. 运行与验证
启动后,屏幕上会出现一个深色的小悬浮窗,默认显示“跟随中”。如果你已经打开了 Codex 窗口,小窗会自动出现在 Codex 窗口右侧约 20 像素的位置。
可以按照下面的步骤验证功能。
第一步,拖动 Codex 窗口,观察小窗是否跟着移动。小窗应该保持相对位置不变,始终停留在 Codex 窗口的右侧。
第二步,最小化 Codex 窗口。小窗会自动隐藏,而不是停留在原地。
第三步,恢复 Codex 窗口。小窗会自动出现,并再次回到正确位置。
第四步,点击小窗上的“暂停”按钮,小窗会停止跟随,并显示“已暂停”。这时候再拖动 Codex 窗口,小窗不会移动。再次点击按钮,恢复跟随。
第五步,点击“退出”按钮,程序结束,小窗消失。
如果你运行后小窗没有出现,运行下面的脚本,查看当前系统所有可见窗口的标题,确认目标窗口标题关键字是否正确。
# list_windows.py import win32gui def list_windows(): def cb(hwnd, extra): if win32gui.IsWindowVisible(hwnd): title = win32gui.GetWindowText(hwnd) if title: print(hwnd, title) return True win32gui.EnumWindows(cb, None) if __name__ == "__main__": list_windows()这个脚本会把所有带标题的可见窗口列出来。找到你的 Codex 窗口,把标题中的关键部分作为--title参数传入即可。
6. 常见问题与排查
窗口跟随工具本身不复杂,但实际使用中经常会遇到几个固定问题。下面整理成表格,方便快速排查。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 小窗一直没有出现 | 目标窗口标题没有匹配到 | 使用 list_windows.py 查看实际窗口标题,调整--title |
| 小窗出现在错误的位置 | DPI 缩放未处理 | 启动时调用set_dpi_awareness(),并且要在创建窗口前调用 |
| 小窗闪烁或抖动 | 轮询间隔太短,坐标变化检测不灵敏 | 把--interval调到 300 以上,同时保持阈值判断 |
| 小窗跟着终端走,而不是跟着 Codex 内容走 | Codex 跑在终端内部,窗口属于终端进程 | 按终端窗口标题匹配,或者改用 Codex 桌面端 |
| 小窗在 Codex 恢复后没有重新出现 | 隐藏状态判断异常 | 检查winfo_viewable()和hidden标志的配合逻辑 |
| 找了很多次都找不到窗口 | Codex 窗口被最小化或隐藏 | 先恢复 Codex 窗口,再启动小工具 |
最常见的问题是“找不到窗口”。这里需要说明一下,窗口枚举只能找到可见的顶层窗口。如果 Codex 窗口被最小化到任务栏,或者以托盘图标方式运行,顶层窗口可能仍然存在,但可见性状态会有变化。因此建议先让 Codex 窗口正常显示,再启动跟随工具。
另一个需要注意的问题是“目标窗口被关闭后重新打开”。Codex 桌面端如果退出再启动,窗口句柄会变化。当前示例代码已经在轮询过程中判断IsWindow无效时重新调用finder.find(),所以能处理这种情况。但如果是按标题匹配,而新打开的窗口标题和旧窗口不同,就需要重新调整参数。
7. 工程化建议与扩展方向
这个跟随窗口做成小工具后,还可以继续完善几个方向。
首先是打包成 exe。用 PyInstaller 打包后,可以脱离 Python 环境直接运行,适合日常开机启动。
pip install pyinstaller pyinstaller -F -w main.py-F表示打包成单个可执行文件,-w表示不显示命令行黑窗口。打包后的 exe 可以直接放到桌面或任务栏,配合 Codex 使用。
其次是增加全局快捷键。现在暂停和继续需要通过点击小窗按钮完成,但小窗本身比较小,点击不太方便。可以引入pynput库,注册一个全局快捷键,比如Ctrl + Alt + F,用来切换暂停状态。需要注意,全局快捷键的回调运行在独立线程中,不能直接修改 Tkinter 控件,需要通过root.after把操作调度回主线程。
再次是支持窗口相对位置选择。目前小窗固定显示在目标窗口右侧,可以通过参数增加--position right、--position left、--position top、--position bottom等模式。根据位置参数计算坐标时,只需要调整目标窗口四个边界的计算方式。
多显示器环境下,建议配合SetProcessDpiAwareness(2)使用。这样每个显示器上的坐标都是真实的物理像素坐标,辅助窗口在跨屏拖动时不会出现明显的偏移。
最后是安全边界。这种窗口跟随工具本质上是读取和设置窗口位置,属于常见桌面自动化能力。使用时要保持合理用途,比如辅助自己开发、做学习实验。不要把它用于未经授权的窗口控制或干扰其他人使用。代码中也尽量不要把轮询间隔设置得太短,这会给系统带来无意义的资源消耗。300 毫秒的轮询间隔已经足够平滑。
8. 总结与下一步
这个“会跟着 Codex 走的窗口”本质上是一个最小可用的 Windows 窗口跟随工具。通过EnumWindows枚举窗口、GetWindowRect读取坐标、Tkintergeometry设置位置,配合定时轮询,就实现了辅助窗口自动贴住主窗口的效果。
代码本身不依赖复杂框架,核心逻辑可以迁移到其他场景。比如让一个工具栏跟随浏览器窗口,让一个状态面板跟随游戏窗口,或者让一个监控小窗跟随数据库客户端窗口。理解了窗口句柄、窗口坐标、DPI 感知这几个基础概念之后,你就能根据实际需求定制自己的桌面辅助工具。
下一步你可以尝试两件事:一是把代码里的跟随逻辑抽象成独立函数,接入pynput全局热键;二是用 PyInstaller 打包成 exe,集成到日常开发环境中。如果本文对你有帮助,可以收藏备用,后面遇到窗口布局问题再翻出来看看。