用 Vibe Coding 实现一个不简单的 Ghostty 功能
我最近发布了一个不简单的 Ghostty 功能(macOS 上不打扰用户的自动更新),它大部分是用 AI 开发的。
经常有人请我分享一些使用 AI 和 agentic coding(智能体编程)工具的不简单实例,而这次正好是一个绝佳机会,可以围绕一个范围明确、不简单、真实上线交付的功能,完整展示我的工作流程1。
这篇文章会完整、未经删改地分享我在实现这个功能过程中的每一次 agentic coding 会话。同时,我也会补充一些关于我的流程和思考的背景说明。当然,对于好奇的读者,我也会公布这些会话的 token 花费。
重要:其中也有大量的人工编码。在 AI 完成工作之后,我几乎总是会上手自己迭代一段时间。与其在每个环节反复强调这一点,不如在这里一次性说清楚。因此,你可能会看到 AI 产出的内容与最终代码之间存在一些差异。这是有意为之的,而且我认为优秀的 AI 驾驭者应当是各自领域的专家,把 AI 当作助手而非替代品来使用。
这个功能
这篇博文所讲的成品功能是 macOS 上的不打扰式更新通知功能。该功能直接在终端窗口内显示更新状态,不会通过创建窗口、抢占焦点等方式打断用户的工作。
先交代一下促成这个功能的背景(一语双关,你很快就会明白)。在一次高调的 OpenAI 主题演讲中,演示被一个 Ghostty 更新弹窗粗暴地打断了:

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

对此有各种各样的质疑,比如“我不想一边做饭一边写代码”,或者“活在当下一点”,诸如此类。如果你愿意那样生活,那没问题。就我这个具体例子而言,我是家里最早起床的人,趁其他人还在睡觉时准备早餐。我并不是醒着的每一刻都在这么干。
说了这么多,其实就是一句话:这套方法对我有效。我真的、真的不是在试图说服你。我与任何 AI 公司都没有经济关联。但作为一个在使用 AI 工具上颇有心得、又喜欢谈论它的人,总有人请我分享实例。这就是我在这里做的事。
结语
我觉得这个功能很漂亮,运行良好,经过最后一轮人工审查后我合并了它5。对于使用 tip 版本的 Ghostty 用户,它现在已经可用了。对于使用正式版本(tagged release)的 Ghostty 用户,这个功能将在 Ghostty 1.3 中提供。
我一直大声疾呼公开分享 agentic coding 会话的重要性6,原因之一是,这是一种极其有力的方式,可以教会他人如何有效地使用这些工具。希望这篇文章能起到示范作用。
脚注
它目前仅推送给 nightly beta 测试者("tip" 测试者),但那是一个数千人的群体。它已合并,并将包含在下一个稳定版 Ghostty 发布中。 ↩
是的,那是很好的营销。不,那不是故意的。而且不,我并不为此庆祝,因为你不会希望用户担心工具会占他们便宜。你要让用户相信它是来帮忙的。我希望演讲者(其实任何人都是)想要使用 Ghostty,而在意这类事情正是其中一部分。 ↩
最有诗意的做法本是用 OpenAI Codex 来做这件事。我相信它会干得很棒!这篇文章不是 Amp 的广告,只是碰巧它是我目前用得最多的 agentic coding 工具。 ↩
我家里有个刚学会走路的孩子,所以我的“电脑时间”非常有规律也非常有限。😁 ↩
“最终人工审查”是极其极其极其重要的。这本不该是个脚注,但我实在找不到更好的地方来强调这一点。请千万不要在没有彻底人工审查的情况下发布 AI 写的代码。 ↩
我这么做的理由本身就足以写成另一篇博文,所以除了已经说的这些,我不打算在这里进一步解释了。 ↩
随机一篇博客