
如果你经常在终端里使用 Codex 写代码大概率会遇到这样的场景主窗口旁边放着一个提示词备忘、任务清单或者 API 调用统计的小面板想让它一直待在 Codex 旁边。但你拖一下 Codex 窗口小面板就留在原地还得回头再拖回去来回几次就很烦躁。做这个小项目时我一开始想的不是写一套复杂 GUI而是把问题拆成三个动作找窗口、读坐标、改位置。沿着这条线做完之后发现它并不难却把 Windows 窗口编程的几个关键知识点完整串了一遍比如窗口句柄、窗口矩形、DPI 感知、轮询和事件钩子。这篇文章会完整还原这个“会跟着 Codex 走的窗口”从思路到代码的实现过程。即使你还没有用过 Codex只要你在终端或 IDE 里使用任何需要“辅助信息窗”的开发工具这套方案都能直接复用到你的工作流里。1. 为什么要做一个会跟着 Codex 走的窗口Codex 最常见的运行形态是 CLI也就是在终端里以对话方式写代码。它不像 IDE 那样自带一个侧边栏开发者往往需要在另一个窗口里放参考资料、待办清单、快捷键说明甚至是临时记录的一段上下文。问题在于这些辅助信息是“静态”的。主窗口移动之后辅助窗口不会跟着动。于是你每次调整窗口布局都要重新拖一遍所有面板。如果你的桌面是单屏窗口之间还会互相遮挡如果是双屏副屏上的资料窗口还会被其他应用盖住需要不停切换。这个项目解决的不是“怎么用 Codex”而是“怎么让 Codex 周围的窗口布局更稳定”。它本质上是一个窗口布局自动化工具识别目标窗口读取它的坐标和尺寸然后让辅助窗口始终保持相对位置。听起来很像“贴靠工具”但贴靠工具解决的是窗口排列问题这个方案解决的是“主窗口变化后辅助窗口自动跟随”的问题。对开发者的价值不只是方便更重要的是它演示了 Windows 窗口编程的最小闭环。你不需要写钩子、不需要注入进程只需要用系统公开的 API 做窗口枚举和坐标同步就能得到一个实用的小工具。2. 跟随窗的核心原理与方案选型2.1 窗口句柄与窗口矩形Windows 里每一个可见窗口都有一个唯一标识叫窗口句柄也就是 HWND。它不是一个数字那么简单而是你操作窗口的通行证。通过 HWND你可以读取窗口标题、获取窗口位置、设置窗口状态。窗口在屏幕上的位置用矩形表示即 RECT 结构。它包含四个值left、top、right、bottom。左上角是 (left, top)右下角是 (right, bottom)所以窗口宽度就是 right - left高度就是 bottom - top。这里的坐标系是屏幕坐标系原点在屏幕左上角。注意多显示器环境下副屏的坐标可能是负值这是正常的处理时不能假设坐标永远为正。2.2 三种实现方案对比实现“跟随”主要有三条技术路线方案原理优点缺点适合阶段轮询定时调用 GetWindowRect 读取目标窗口位置再调整自身窗口位置实现简单逻辑清晰容易排查有少量 CPU 占用延迟取决于轮询间隔初版、个人使用事件钩子调用 SetWinEventHook 监听窗口移动、缩放事件事件驱动延迟低资源占用小代码复杂回调函数要处理跨进程事件还要注意回调超时进阶、长期使用无边框强制跟随把辅助窗做成无边框子窗口直接绑定在主窗口坐标系上跟随最稳定几乎无延迟需要处理无边框拖动、焦点、任务栏等边界问题特殊定制场景我的初版选择的是轮询。原因很简单对于一个个人工具来说150 毫秒的轮询间隔已经完全够用人眼几乎感知不到延迟而且代码量少出问题时容易定位。后面的最佳实践章节里我会讲怎么从轮询平滑升级到事件钩子。2.3 DPI 缩放是第一个大坑如果你不做任何 DPI 处理直接读取窗口坐标会发现一个奇怪的现象窗口在副屏或高缩放比例显示器上位置总是偏的。这是因为 Windows 的 DPI 虚拟化机制。进程默认被系统认为是“不感知 DPI”的系统给你返回的坐标可能经过了缩放换算和你实际看到的屏幕坐标不一致。解决办法很简单在程序入口调用 SetProcessDpiAwareness 或 SetProcessDPIAware。Python 里可以直接通过 ctypes 调用系统 APItry: ctypes.windll.shcore.SetProcessDpiAwareness(1) except Exception: ctypes.windll.user32.SetProcessDPIAware()这段代码要放在创建任何窗口之前。这样 tkinter 窗口的像素坐标和系统坐标才会一致后续读取和设置位置才不会出现偏移。3. 环境准备与前置条件这个项目的运行环境并不复杂不需要安装大型依赖。项目要求操作系统Windows 10 或 Windows 11Python3.9 及以上需要支持 tkinter第三方依赖无只使用标准库和 ctypes可选打包工具PyInstaller用于生成 exe代码里用到的核心模块有三个ctypes调用 Windows API比如 EnumWindows、GetWindowRect、OpenProcess。tkinter创建辅助窗口本身。json保存和读取配置文件。关于 Codex 的窗口形态你需要先确认自己用的是什么。如果你用的是终端里的 Codex CLI那目标窗口是终端窗口可能是 Windows Terminal、conhost也可能是 VS Code 的集成终端。如果你用的是 Codex 桌面版或 IDE 插件那目标窗口就是对应的应用窗口。这里有个容易踩坑的点Codex CLI 运行时终端窗口的标题往往不是“Codex”而是当前目录、shell 名称或正在编辑的文件名。所以按标题关键字匹配不一定可靠更稳妥的方式是列出所有可见窗口让用户自己选择。这也是完整代码里我选择用窗口列表而不是直接按标题匹配的原因。如果你使用的是第三方模型 API 服务只需要保证 Codex 本身能正常对话即可本工具不干预模型配置和网络配置它只关心窗口坐标。4. 核心步骤一窗口枚举与识别4.1 用 Windows API 找到窗口第一步是枚举当前所有可见窗口。Windows 提供了 EnumWindows 函数它会遍历当前桌面所有顶层窗口。枚举时每个窗口会回调我们的处理函数。我们需要做的就是在回调里过滤掉不可见窗口、空标题窗口然后把窗口标题、进程名读出来。先给一个最小验证示例直接运行可以打印出包含 Codex 关键字的窗口。建议你把这个脚本当作“探针”先跑通再看完整工程。# quick_test.py import ctypes from ctypes import wintypes user32 ctypes.windll.user32 user32.SetProcessDPIAware() target_hwnd None def enum_proc(hwnd, _): global target_hwnd if not user32.IsWindowVisible(hwnd): return True length user32.GetWindowTextLengthW(hwnd) if length 0: return True buf ctypes.create_unicode_buffer(length 1) user32.GetWindowTextW(hwnd, buf, length 1) if codex in buf.value.lower(): target_hwnd hwnd return False return True Callback ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) user32.EnumWindows(Callback(enum_proc), 0) if target_hwnd: rect wintypes.RECT() user32.GetWindowRect(target_hwnd, ctypes.byref(rect)) print(找到窗口:, hex(target_hwnd)) print(f窗口位置: ({rect.left}, {rect.top})) print(f窗口尺寸: {rect.right - rect.left} x {rect.bottom - rect.top}) else: print(未找到包含 Codex 关键字的窗口)运行方式python quick_test.py如果终端窗口标题里包含 codex你会看到类似输出找到窗口: 0x1f0abc 窗口位置: (120, 80) 窗口尺寸: 960 x 6004.2 为什么不能只靠标题匹配上面这个脚本适合快速验证 API 调用但不适合作为正式工具的查找逻辑。原因有三个第一标题未必包含 codex。Windows Terminal 打开多个标签页时标题可能显示为当前路径或 shell 名称Codex CLI 只是其中一个子进程。第二同名进程可能有多个窗口。比如你开了三个 VS Code 窗口每个窗口里都有一个集成终端。第三标题会动态变化。终端里运行的任务不同标题可能随时变化导致刚才匹配到的句柄失效。所以完整实现里我会把所有可见窗口列出来显示进程名和标题让用户手动选择。这是最稳的方案适合个人工具场景。4.3 读取窗口对应的进程名枚举窗口时如果想显示进程名需要先通过 GetWindowThreadProcessId 拿到进程 ID再通过 OpenProcess 和 QueryFullProcessImageNameW 获取进程路径。pid wintypes.DWORD() user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) handle kernel32.OpenProcess(0x1000, False, pid.value) if handle: image_buf ctypes.create_unicode_buffer(1024) size wintypes.DWORD(1024) if kernel32.QueryFullProcessImageNameW(handle, 0, image_buf, ctypes.byref(size)): exe image_buf.value.rsplit(\\, 1)[-1] kernel32.CloseHandle(handle)0x1000 是 PROCESS_QUERY_LIMITED_INFORMATION 权限足够查询进程路径不需要管理员权限。这也是一个安全边界上的设计只读信息不做任何向其他进程注入内容的操作。5. 核心步骤二读取窗口位置并同步坐标找到目标窗口后下一步就是读取它的位置并计算辅助窗口应该出现在哪里。5.1 GetWindowRect 读取窗口矩形GetWindowRect 接收一个 HWND输出窗口在屏幕上的矩形坐标。def get_window_rect(hwnd): rect wintypes.RECT() user32.GetWindowRect(hwnd, ctypes.byref(rect)) return rect.left, rect.top, rect.right, rect.bottom注意GetWindowRect 返回的是窗口的“屏幕坐标”不是相对某个父窗口的坐标。这个坐标已经包含了窗口边框适合直接用于计算屏幕布局。5.2 跟随坐标的计算公式假设辅助窗口固定宽度为 width高度为 height与目标窗口的间距为 gap。如果要让辅助窗口始终停靠在 Codex 窗口的右侧那么它的左上角坐标就是x 目标窗口 right gapy 目标窗口 top其他方向同理停靠方向x 坐标y 坐标右侧right gaptop左侧left - gap - widthtop顶部lefttop - gap - height底部leftbottom gap这里要注意左侧和顶部会出现负坐标尤其是辅助窗口放在主窗口左边时x 可能是负数。tkinter 的 geometry 方法支持负坐标所以不用担心。5.3 轮询同步循环初版可以选择 tkinter 的 after 方法实现轮询而不是 while 循环加 sleep。原因是 after 不会阻塞 tkinter 的事件循环窗口仍然能正常响应用户操作。def poll_loop(self): if self.following: self.sync_once() self.root.after(self.poll_interval, self.poll_loop)这里 every 150ms 检查一次对窗口移动的响应基本无感知延迟。如果调成 50ms会稍微顺滑一点但 CPU 占用会上升对于个人工具150ms 是性价比最高的值。同步时还需要做一个优化先判断当前窗口位置是否已经和目标位置一致一致就不重复设置 geometry。否则即使目标窗口没动辅助窗口也会不断刷新坐标可能造成轻微闪烁。if x ! cur_x or y ! cur_y or w ! cur_w or h ! cur_h: self.root.geometry(f{w}x{h}{x}{y})6. 完整代码一个可用的 Codex 跟随窗口下面给出完整的 main.py。这个程序会打开一个辅助窗口列出所有可见窗口你可以选择 Codex 所在的窗口然后开启跟随。6.1 前置说明保存以下代码为 main.py在同目录下直接运行python main.py不需要安装第三方依赖。6.2 main.py 完整代码# main.py import ctypes import json import os import re import tkinter as tk from tkinter import messagebox from ctypes import wintypes # DPI 处理必须在创建窗口之前 try: ctypes.windll.shcore.SetProcessDpiAwareness(1) except Exception: ctypes.windll.user32.SetProcessDPIAware() user32 ctypes.windll.user32 kernel32 ctypes.windll.kernel32 EnumWindowsProc ctypes.WINFUNCTYPE( wintypes.BOOL, wintypes.HWND, wintypes.LPARAM ) CONFIG_FILE os.path.join(os.path.dirname(os.path.abspath(__file__)), config.json) def list_visible_windows(): 返回所有可见窗口的 (hwnd, title, exe) 列表 windows [] def each_window(hwnd, _lparam): if not user32.IsWindowVisible(hwnd): return True length user32.GetWindowTextLengthW(hwnd) if length 0: return True buf ctypes.create_unicode_buffer(length 1) user32.GetWindowTextW(hwnd, buf, length 1) title buf.value if not title: return True pid wintypes.DWORD() user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) exe handle kernel32.OpenProcess(0x1000, False, pid.value) if handle: image_buf ctypes.create_unicode_buffer(1024) size wintypes.DWORD(1024) if kernel32.QueryFullProcessImageNameW(handle, 0, image_buf, ctypes.byref(size)): exe image_buf.value.rsplit(\\, 1)[-1] kernel32.CloseHandle(handle) windows.append((hwnd, title, exe)) return True user32.EnumWindows(EnumWindowsProc(each_window), 0) return windows def get_window_rect(hwnd): 读取窗口的屏幕坐标矩形 rect wintypes.RECT() user32.GetWindowRect(hwnd, ctypes.byref(rect)) return rect.left, rect.top, rect.right, rect.bottom class FollowerApp: SIDE_NAMES {右侧: right, 左侧: left, 顶部: top, 底部: bottom} def __init__(self, root): self.root root self.root.title(Codex 跟随窗) self.root.geometry(520x460100100) self.target_hwnd None self.following False self.side right self.width 360 self.height 240 self.gap 8 self.poll_interval 150 self.windows [] self.config self.load_config() self._build_ui() self.root.after(self.poll_interval, self.poll_loop) # ---------- UI ---------- def _build_ui(self): top tk.Frame(self.root) top.pack(filltk.X, padx8, pady6) tk.Button(top, text刷新窗口, commandself.refresh_windows).pack(sidetk.LEFT) self.selected_info tk.Label(top, text未选择目标窗口, fggray) self.selected_info.pack(sidetk.LEFT, padx8) self.listbox tk.Listbox(self.root, height12) self.listbox.pack(filltk.BOTH, expandTrue, padx8, pady4) self.listbox.bind(ListboxSelect, self.on_select_window) control tk.LabelFrame(self.root, text跟随设置) control.pack(filltk.X, padx8, pady6) tk.Label(control, text停靠方向).grid(row0, column0, stickyw, padx4, pady4) self.side_var tk.StringVar(value右侧) side_menu tk.OptionMenu(control, self.side_var, *self.SIDE_NAMES.keys()) side_menu.grid(row0, column1, stickyw, padx4) self.follow_var tk.BooleanVar(valueFalse) tk.Checkbutton(control, text开启跟随, variableself.follow_var, commandself.toggle_follow).grid( row0, column2, padx8 ) self.topmost_var tk.BooleanVar(valueself.config.get(topmost, False)) tk.Checkbutton(control, text窗口置顶, variableself.topmost_var, commandself.toggle_topmost).grid( row1, column0, stickyw, padx4, pady4 ) self.status_label tk.Label(self.root, text状态等待选择目标窗口, fgblue) self.status_label.pack(filltk.X, padx8, pady6) self.refresh_windows() # ---------- 配置 ---------- def load_config(self): default { side: right, width: 360, height: 240, gap: 8, poll_interval_ms: 150, topmost: False } try: with open(CONFIG_FILE, r, encodingutf-8) as f: loaded json.load(f) default.update(loaded) except Exception: pass self.side default[side] self.width default[width] self.height default[height] self.gap default[gap] self.poll_interval default[poll_interval_ms] return default def save_config(self): self.config.update({ side: self.side, width: self.width, height: self.height, gap: self.gap, poll_interval_ms: self.poll_interval, topmost: self.topmost_var.get() }) with open(CONFIG_FILE, w, encodingutf-8) as f: json.dump(self.config, f, ensure_asciiFalse, indent2) # ---------- 窗口列表 ---------- def refresh_windows(self): self.windows list_visible_windows() self.listbox.delete(0, tk.END) for hwnd, title, exe in self.windows: display f{hwnd} | {exe} | {title[:40]} self.listbox.insert(tk.END, display) self.status_label.config( textf共发现 {len(self.windows)} 个可见窗口请选择 Code