用 Vibe Coding 实现一个不简单的 Ghostty 功能
原文由 Mitchell Hashimoto 于 发布,订阅该博客
我最近上线了一个不简单的 Ghostty 功能(无感知的 macOS 自动更新),它很大程度上是用 AI 开发的。
经常有人让我分享一些有分量的 AI 和智能体编程工具使用案例,而这次正好是个绝佳的机会,让我可以完整地复盘如何通过一个范围清晰、颇具复杂度且真实上线的功能来展示我的工作流程1。
本文将毫无保留、原汁原味地公开我在实现该功能过程中进行的每一次智能体编程会话,并在旁边补充一些关于流程与思考的背景说明。当然,对于好奇 token 成本的读者,我也会一并公开。
重要提示:其中也有大量人工编码。我几乎总会在 AI 完成工作后亲自再迭代一段时间。与其每次都重复一遍,不如在这里统一说明。因此,你可能会发现 AI 产出与最终代码之间存在一些差异,这是刻意为之。我认为,优秀的 AI 驾驭者首先是所在领域的专家,AI 只是助手,而非替代品。
功能简介
本文所讲的成品是 macOS 上无感知的更新提示功能。该功能会在终端窗口内显示更新状态,而不会通过创建窗口、抢夺焦点等方式打断用户的工作。
先来交代一下这个功能的背景(接下来你会发现,这里有个双关)。在一场备受瞩目的 OpenAI 主题演讲中,一次演示被 Ghostty 的更新弹窗粗暴打断了:

我想确保这种事不再发生2。我选择的方案是让更新提示变得无感知。应用不再弹出窗口,而是在某个不会打扰用户的地方显示一个小型的、非模态的图形界面元素。
AI 之前的规划
于是我掏出了 AI 工具。 完全不是。 我先是大致构思了一下希望它如何运作。Ghostty 使用Sparkle,这是一个非常流行的 macOS 更新框架。我翻了翻它的文档,发现它支持通过 Obj-C 协议来自定义界面。虽然需要从零开始重写大量内容,但这是可行的。
好吧,后端我算是有了大致思路。至于前端,我其实不太确定(这也不是我的专长)。我有一个很模糊的想法:应该是在标题栏里嵌入一个小按钮,而且我知道 macOS 可以通过标题栏附件控制器在标题栏中实现自定义界面,但除此之外,对于它应该长什么样、有什么体验,我并没有太多概念。
不过,这已经足够起步了。AI 非常擅长做原型,所以即便清楚自己“不知道什么”,也足以开始。对整体蓝图,我已经有了足够清晰的把握。
第一次会话:界面原型
这是我的第一次智能体编程会话,起始提示词如下:
我想通过自定义 SPUUserDriver 来实现自定义的、无感知的更新通知和安装。让我们先来规划一下所需的自定义界面。我们只做界面。制定一个计划来创建能够展示 SPUUserDriver 所需各种状态的 SwiftUI 视图。我认为这些视图最适合出现在 macOS 窗口标题栏的右上角。制定一个把它放在那里的计划。请咨询 oracle。
常见问题:“oracle 是什么?”它是Amp 特有的只读子智能体,使用更慢、成本更高但通常更擅长思考的模型。我在做所有规划时都会咨询 oracle。
一开始,我决定先为界面做原型。
注意,我并没有让智能体直接去构建整个功能。原因有几个。首先,我自己都还不清楚想要什么样的界面和交互,自然没法指望 AI 在众多改动中替我理清这一点。其次,更小粒度的任务更容易审查、理解和迭代。
另一个值得注意的点是,我只让它制定计划,而不是写代码。由于这个请求本身就比较模糊,在让它投入大量工作(并消耗大量 token)之前,先审阅一下计划非常重要。
小技巧:与智能体交互式地制定一份全面的计划,对于任何有一定复杂度的任务来说,都是非常重要的第一步。我通常还会让智能体把它保存成类似 spec.md 这样的文件,这样在后续会话中我就可以说“参考 @spec.md 并完成某项任务”。
智能体给出的计划还算过得去,于是我就让它着手实现了。你可以在后面的对话中看到我是如何持续迭代的。
它做出的界面大方向上非常不错。虽然有很多细节问题(间距、颜色等),但看到这个界面给了我所需的灵感,让我明白了自己到底想要什么。
小技巧:我经常把 AI 当作灵感来源。在这个例子中,我保留了它生成的大部分(并非全部)界面代码,但很多时候我会让智能体先出一版,然后全部丢掉,自己手动重做。我发现从零到一的创作阶段非常困难且耗时,而 AI 在这方面是绝佳的缪斯。
撞墙了
你可以在第 11 到 14 轮对话中看到,我们已经进入了“胡言乱语区”。智能体生成的代码存在一个关键缺陷,而且它完全无法修复。我自己也不知道该怎么修。
我经常会做几次这样孤注一掷的尝试来修复缺陷。如果智能体能搞定,我就能顺便学习一下。如果搞不定,我的损失也很小。如果智能体修好了但我没看懂,我会把它回退掉。我不会上线自己看不懂的代码。在它尝试失败的同时,我也在另一个标签页里搜索问题、自己琢磨解决办法。
到这个时候,我就知道需要后退一步,复盘它做了什么,并自己制定计划。是时候静下心来学习和深入思考了。AI 不再是解决方案,反而成了负担。
清理会话
接下来的几次会话,我都在引导智能体清理代码。
第二次会话的重点是把一些方法移到更合适的位置:
把 pill 背景、前景和徽标相关的函数从 @macos/Sources/Features/Update/UpdateAccessoryView.swift 移到 @macos/Sources/Features/Update/UpdateViewModel.swift,并让它们更通用一些(background、foreground、badge)
第三次会话是为代码添加文档:
更新 @UpdateBadge.swift 的文档
小技巧:添加文档是非常重要的一步,因为这既能帮助你巩固自己对代码的理解,也能让未来阅读和修改这段代码的智能体更好地理解。我的经验是,当智能体既有自然语言描述又有代码本身时,表现会好得多。
第四次会话是将视图模型移到应用全局的位置,因为最初的实现把它放在了窗口作用域,而更新信息实际上是应用级别的。
把更新视图模型的数据移到 AppDelegate,因为更新信息将是应用全局的。
在这些过程中,我通常也会顺手做一些小的手动修改。
清理这一步非常重要。要有效地清理,你必须对代码有相当深入的理解,这就迫使我不能盲目接受 AI 写的代码。而经过良好整理和文档化的代码,也能让后续的智能体会话表现得更好。
我有时会半开玩笑地把这称为“反胡言乱语会话”。
直面“那个缺陷”
是时候回头解决最初那次会话中发现的那个缺陷了。我又花了几次会话尝试让智能体搞定。一开始说得很笼统,然后逐渐给出更具体的思路。
首先,是一次笼统的尝试会话:
对于标准的原生标签页,更新附属视图不可见。它应该在窗口标题栏中保持可见。
失败。接着,我说得更具体一些:
我们需要更新 @macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.swift 中标签栏的约束,将标签栏的右边缘与更新附属视图的左边缘对齐,以保持其可见。
失败。然后,我尝试了另一种具体的方案:
如果我们把 @macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift 改成让标签栏作为顶部附属视图,而不是底部视图,从而让标签页进入标题栏,会怎么样?
失败。最后再试一次:
@macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift 中的“右侧附属视图”和布局与 @macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift 中更新附属视图的设置冲突了。我们能否约束标签栏,使其始终显示在更新提示的左侧?
失败。
这期间,我也一直在通过手动研究和人力投入尝试自己解决这个问题。那些更具体的提示词,都是基于我在这个过程中学到的东西。但总体来看,显然还是行不通。
我觉得光靠自己搞不定,于是决定换个方向。对于这些有问题的标题栏样式,我打算把更新提示放在窗口右下角,以覆盖在内容视图之上的方式显示,而不是放在标题栏里。
反正我也需要支持这种方式,因为 Ghostty 有一个可以完全隐藏标题栏的配置。所以,即使我以后解决了标题栏样式的问题,也仍然需要支持这种模式。
我的下一次会话就带着这个非常具体的提示词推进了该计划:
为 @macos/Sources/Features/Update 系统增加对覆盖层方式的支持,在 @macos/Sources/Features/Terminal/TerminalView.swift 中实现。更新提示应该显示在窗口底部。它应该覆盖在文本之上(这样就不会让终端视图重新调整大小)。所有点击行为都应与附属视图保持一致。
这一次它完成得非常好。之后我做了大量手工打磨(移动文件、重命名等),但核心工作已经很扎实了。
这是该次会话后不久的功能演示视频,展示了对于特定标题栏样式或标题栏被隐藏时,更新提示如何出现在窗口的右下角:
开始后端工作
界面已经感觉足够好了。我记下了一些想要后续打磨的细节,但想先转向后端工作,主要是想看看会不会发现什么未知的未知,给我的计划带来变数。
我手动创建了一个文件,搭好了未完成函数的脚手架,并写上了各种 TODO 注释。然后开启了一次会话让它帮我补全:
补全 @macos/Sources/Features/Update/UpdateDriver.swift。必要时请阅读 Sparkle 文档以理解相关功能。 https://sparkle-project.org/documentation/api-reference/Protocols.html
小技巧:AI 非常擅长填空或“画出猫头鹰的其余部分”。我这种先搭好脚手架、写好描述性的函数名、参数、TODO 注释等模式,是我非常常用的做法,而且效果很好。
但这一次它实际上做得非常糟糕,我最后把这些代码全部扔掉了。它生成的代码虽然能跑,但显然思路是错的。它把很多不同的关注点混在了一起,而且在驱动中存储状态的方式也明显不对。
在研究了它的实现后,我意识到问题出在视图模型的结构不够合理,所以我转而进入清理模式,为 AI(以及如果我选择自己来写的话,也为人类)提供一个更好的框架。
再次大扫除
经验告诉我,前端界面和后端业务逻辑的整洁程度,往往取决于中间视图模型的质量。于是我花了一些时间手动重构了视图模型。这包括改用带标签的联合类型,而不是带有一堆可选值的结构体。我还重命名了一些类型,移动了位置。
凭经验我知道,中间这点小小的人工投入,会为后续无论是前端还是后端的智能体会话奠定成功的基础。完成后,我又进行了一连串的清理会话。
完成重构后,我做的第一件事就是让智能体再次“画完猫头鹰”,让它检查我的改动,并将依赖代码更新为新的风格,同时移除旧代码:
将 @macos/Sources/Features/Update/UpdateViewModel.swift 更新为只使用新的
UpdateState。把state2重命名为state(移除旧的 state)。
然后我让它移除更多无用代码:
我觉得我们可以去掉 UpdateUIActions。自从我们的 UpdateState 有了回调之后,它们就不再需要了。
接着,我在自己清理一些东西时把构建搞坏了。正好要去开会,于是就让智能体在我开会时帮我修复:
运行构建并修复错误
小技巧:“我搞砸了一堆东西,请帮我收拾烂摊子。”也是我经常让智能体做的事情。一般来说,这同样属于前面提到的填空模式。
之后,我又对一些视图进行了重构:
将 @macos/Sources/Features/Update/UpdatePopoverView.swift 中的每一个 case 都改成一个独立的 fileprivate Swift 视图,以类型化的值作为参数,这样我们就可以去掉那些 guard。
更多清理:
将 @macos/Sources/Features/Update/UpdateViewModel.swift 中的
iconName改为可选类型,在为空时返回 nil。并更新相关用法。
模拟测试
在第一次界面会话中,我让智能体创建了一些演示代码,这样无需进行真实的更新检查就能看到界面效果。但更新流程有多种场景,到目前为止我只测试了成功路径。
在下一次会话中,我将模拟代码提取到一个独立文件中,并让智能体创建更多场景:
将 @macos/Sources/App/macOS/AppDelegate.swift 中的更新模拟代码提取到 @macos/Sources/Features/Update 下的一个独立文件中。该文件应包含多个模拟场景(成功路径、未找到更新、错误等),以便我们轻松尝试不同的演示。
小技巧:智能体非常擅长生成测试和模拟代码。它在这里生成的模拟代码说实话写得相当粗糙,但能用就行,而且它不会包含在发布产物中,所以质量对我来说并不重要。除了你在会话中看到的基础清理外,我甚至懒得再去整理它。
随后我运行了各种模拟,发现了一些可以改进的交互体验。
最后冲刺
到此为止,我已经有了可用的后端和前端,现在需要把它们串联起来。
我的下一次会话让智能体来做这件事:
创建一个
UpdateController类,逻辑与 https://github.com/sparkle-project/Sparkle/blob/2.x/Sparkle/SPUStandardUpdaterController.m 相同,但针对的是我们自己的更新器类型。
这需要一些来回沟通和手工打磨,但最终还是完成了。
随后我又做了一些小的改进:
对于我们有 appcast 的“有可用更新”状态,请查看 https://sparkle-project.org/documentation/api-reference/Classes/SUAppcastItem.html,并在已设置的情况下显示其他相关元数据。例如,用于显示大小的 content length。
还有别的吗?
我给智能体的最后一个提示词,总是问它我还可能遗漏了什么。无论代码是我手动写的还是自动生成的,我都会这么做。
你觉得 @macos/Sources/Features/Update 功能还有哪些可以改进的地方?不要写代码。请咨询 oracle。考虑一下哪些部分的代码还可以补充更多单元测试。
这确实指出了一些真实存在的问题,于是我就让它直接去实现了。我发现直接告诉智能体“好,全部都做吧”,比逐项指定要做什么更省事,因为我之后总可以在选择性提交时轻松清理。
这次会话中一件有趣的事是,智能体开始钻进一个非常离谱的牛角尖,于是我出面叫停了它:
停停停。撤销所有 main actor 相关的改动。
我还注意到它在某处处理得相当糟糕,而其实有更好的办法:
对于错误信息,与其截断,有没有 SwiftUI 的标准做法?我们应该添加一个额外的界面元素,让用户可以查看完整信息。
成本与时间
这项工作总共进行了 16 次独立会话,在 Amp 上的 token 花费总计 15.98 美元3。我不去评判这到底算贵还是便宜,但对我个人而言,这比我在两天内在咖啡店的花费还要少。
我在这项功能上花费的总“挂钟时间”估计约为 8 小时。我每天在电脑前的时间只有大约 4 小时,第一次和最后一次提交横跨了 3 天。而且我也不是所有时间都在做这个功能。例如,在这几天中我在电脑前的时间里,还发布了一个 Ghostty 更新、在ThePrimeagen 的直播中做了一小时嘉宾,以及在 Zoo 的年度全员大会上做了客座演讲,都是在同一时段内完成的4。所以,我觉得 8 小时的估计已经算是宽松的了。
网上很多人争论 AI 是否能让你工作得更快。就这个例子而言,我认为借助 AI 我比完全靠自己完成得更快,特别是因为对我个人来说,迭代细微的 SwiftUI 样式非常枯燥耗时,而 AI 做这个非常拿手。
对我个人而言,快慢之争忽略了我最喜欢的一点:AI 可以在我离开去做别的事情时继续为我工作。这是我在一次清理会话期间为家人做早餐时拍的照片:

对此有各种各样的否定声音,比如“我不想做饭时还在写代码”或“专注当下吧”之类。如果你想那样生活,也没问题。就我这个具体的例子而言,我是家里起得最早的人,我在其他人还在睡觉时准备早餐。我也不是每时每刻都在这样做。
总之,这套方式对我很管用。我真的不是在试图说服你。我与任何 AI 公司都没有财务关联。但作为一个在 AI 工具使用上颇有心得并且喜欢分享的人,大家经常让我举例说明。这就是我在这里做的事情。
结语
我认为这个功能很漂亮,运行得也很好,在经过最后的人工审查后,我将其合并了5。对于使用 tip 版的 Ghostty 用户来说,现在已经可以用上了。对于使用正式版本的用户,该功能将在 Ghostty 1.3 中提供。
我一直公开主张公开分享智能体编程会话的重要性6,原因之一是这是向他人有效传授这些工具使用方法的极佳途径。希望这篇文章能有所示范。
脚注
目前它仅面向每夜构建的 Beta 测试者(“tip”测试者)发布,但这个群体也有数千人。该功能已合并,将包含在下一个 Ghostty 稳定版中。 ↩
是的,这起到了很好的宣传效果。不,这不是故意的。而且,不,我也不是在庆祝这件事,因为你不会希望用户担心工具会利用他们。你希望他们相信工具是来帮忙的。我希望演示者(或者任何人)是真心想用 Ghostty,而关心这类细节正是其中的一部分。 ↩
不过最有诗意的选择本该是用 OpenAI Codex 来做这件事。我相信它也会做得很好!本文并非为 Amp 打广告,它只是我目前最常用的智能体编程工具。 ↩
我家里有个学步期的孩子,所以我的“电脑时间”非常固定且有限。 😁 ↩
“最终人工审查”超级超级超级重要。这本不该放在脚注里,但我找不到更合适的地方来强调这一点。请永远不要在未经彻底人工审查的情况下发布 AI 生成的代码。 ↩
我对此的理由本身也可以单独写成一篇博文,所以在这里除了已经说过的,我就不再进一步展开了。 ↩
随机一篇博客
评论
登录后参与讨论