Vibing a Non-Trivial Ghostty Feature

Mitchell Hashimoto

Vibe 實作一個不簡單的 Ghostty 功能

原文由 Mitchell Hashimoto 發布,訂閱此部落格

我最近上線了一個不簡單的 Ghostty 功能(不打擾式的 macOS 自動更新),這個功能很大一部分是用 AI 開發的。

平常就常有人請我分享一些有份量的 AI 與 agentic coding 工具實際使用範例,而這次正好是個難得的機會,可以帶大家完整走一遍我是如何從零到上線,完成一個範圍明確、具挑戰性且真實落地的功能1

這篇文章會完整、未經刪改地公開我為了上線這個功能所進行的每一場 agentic coding 對話。同時,我也會補充一些關於流程與決策思路的背景脈絡。當然,對於好奇 token 花費的人,我也會一併公開。

重要提醒:當中也有大量的人工撰寫。我幾乎每次都會在 AI 做完後,自己再進去反覆調整好一陣子。與其每次都重申一次,我就在這裡一次講清楚。因此,你可能會發現 AI 產出的內容和最終的程式碼有些落差。這是刻意的,而且我認為厲害的 AI 駕駛者本身就是領域專家,是把 AI 當成助手,而不是取代品。


這項功能

這篇文章要介紹的完成品,就是 macOS 不打擾式的更新通知功能。這項功能會在終端機視窗內顯示更新狀態,而不會用跳出視窗、搶走焦點等方式打斷你的工作。

來交代一下催生這個功能的背景(之後你就會懂這個雙關)。在一場備受矚目的 OpenAI 發表會上,現場的 demo 被 Ghostty 的更新提示很煞風景地打斷了:

OpenAI Demo

我想確保這種事不再發生2。我決定採取的做法,是讓更新通知變得不打擾。與其彈出視窗,應用程式改為在某個不礙事的地方顯示一個小小的、非強制互動的圖形元件,不會打斷使用者。


AI 之前的規劃

所以我就掏出了 AI 工具。 才怪。我先自己擬了一個大致的運作規劃。Ghostty 使用 Sparkle,這是一個非常熱門的 macOS 更新框架。我翻了一下文件,發現它支援透過 Obj-C 協定來自訂 UI。雖然幾乎所有東西都得從頭自己重刻,但確實是可行的。

好,所以後端的方向我大概有了。至於前端,我其實不太確定(而且那也不是我的專長)。我腦中只有一個很模糊的想法:應該是在標題列裡嵌一個小按鈕,而且我知道 macOS 可以透過標題列附屬控制器在標題列放入自訂 UI,但除此之外,對於它該長什麼樣子、有什麼體驗,我其實沒什麼概念。

不過這樣就夠起步了。AI 是很強的原型工具,所以就算你清楚知道自己「不知道什麼」,也足以作為起點。我對整體大方向已經有足夠清楚的掌握。


第一次對話:UI 原型

以下是我的第一場 agentic coding 對話,一開始的提示是這樣:

我想透過客製化 SPUUserDriver 來實現自訂的、不打擾式的更新通知與安裝流程。我們先來規劃需要的自訂 UI。我們「只」做 UI。請為 SPUUserDriver 所需的各種狀態,規劃如何用 SwiftUI 視圖來呈現。我覺得最適合顯示這些狀態的地方,是在 macOS 視窗標題列的右上角。請擬一個把 UI 放到那裡的計畫。請諮詢 oracle。

常有人問:「什麼是 oracle?」它是 Amp 特有的唯讀子代理,使用較慢、成本較高但通常更擅長思考的模型。我在做規劃時都會諮詢 oracle。

一開始,我決定先做 UI 的原型。

注意,我並沒有叫 agent 直接去把整個功能做完。有幾個原因。首先,我自己都還不知道想要的 UI/UX 長什麼樣子,當然不可能指望 AI 在一堆其他變更中幫我搞定。其次,把工作拆成較小的區塊,更容易審視、理解與反覆修改。

另一個要注意的點是,我只請它先建立計畫,而不是直接寫程式。因為這個請求本身還很模糊,讓它先提出計畫、讓我審過之後再動手做大量的工作(並燒掉大量的 token),是很重要的。

小技巧:和 agent 互動式地擬出一份完整的計畫,是處理任何有點份量的任務時非常重要的一步。我通常也會請 agent 把計畫存成類似 spec.md 的檔案,這樣在後續的對話中,我就可以說「參考 @spec.md 來做某個任務」。

Agent 提出了一個還算可以接受的計畫,我就讓它直接去實作了。你可以在後續的對話紀錄中看到我是如何反覆調整的。

它做出來的 UI 大方向非常好。雖然有一大堆細節要打磨(間距、顏色等等),但看到 UI 後,我就靈光一現,知道自己想要什麼了。

小技巧:我很常用 AI 來找靈感。在這個案例中,我保留了它寫的不少(但不是全部)UI 程式碼,但我也常常是叫 agent 做一遍,然後全部丟掉、自己(手動)重做一遍。我覺得從零到一的創作階段非常困難又耗時,而 AI 非常適合當我的謬思。

撞牆了

你可以在第 11 到 14 次對話中看到,我們進入了混亂區。Agent 產生的程式碼有一個嚴重的 bug,而且它完全修不好。而我也不知道該怎麼修。

我常常會這樣做幾次最後一搏的嘗試來修 bug。如果 agent 能解出來,我就能從中學習。如果解不出來,對我來說也沒什麼損失。如果 agent 解出來但我看不懂,我就會把它 revert 掉。我不會上線自己看不懂的程式碼。在它嘗試失敗的同時,我也在另一個分頁搜尋問題、試著自己找出解法。

到了這個時候,我就知道該退一步,檢視它做了什麼,並自己想出對策。是時候自己去學習、批判性地思考了。AI 不再是解方,而是負擔。


整理階段

接下來的幾場對話,我都在引導 agent 去整理程式碼。

第二場對話聚焦在把一些方法移到我認為更合適的位置:

把藥丸背景、前景和徽章的函式,從 @macos/Sources/Features/Update/UpdateAccessoryView.swift 移到 @macos/Sources/Features/Update/UpdateViewModel.swift,並把它們改得更通用一點(background、foreground、badge)

第三場對話是為程式碼加上文件:

把 @UpdateBadge.swift 的文件補齊

小技巧:加上文件是很重要的一步,因為它有助於再次確認你自己對程式碼的理解,也能讓未來讀取、修改這段程式碼的 agent 更能掌握狀況。我發現當 agent 同時有自然語言的說明和程式碼本身時,表現會好得多。

第四場對話是把 view model 移到應用程式全域的位置,因為原本的實作把它放在視窗層級,但更新資訊其實是應用程式層級的。

把 update view model 的資料移到 AppDelegate,因為更新資訊會是應用程式全域的。

在這些過程中,我通常也會穿插一些小幅度的手動修改。

整理的步驟非常重要。要有效地整理,你必須對程式碼有相當程度的理解,這會迫使我不能盲目接受 AI 寫的程式碼。而整理得更好、文件更完整的程式碼,也能讓未來的 agentic 對話表現得更好。

我有時會半開玩笑地把這稱為「反混亂對話」。


面對「那個 Bug」

是時候回來處理在最初那場對話中發現的 bug 了。我又花了幾場對話試著讓 agent 找出解法。一開始講得很模糊,然後慢慢變得越來越具體,說明我會怎麼處理。

首先,是比較模糊的對話

對於標準的原生分頁來說,更新附屬視圖是看不到的。它應該要保持顯示在視窗的標題列上。

失敗。接著,我講得更具體一點

我們需要更新 @macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.swift 中分頁列的 constraints,讓分頁列的右緣對齊更新附屬視圖的左緣,這樣它才能保持可見。

失敗。接著,我嘗試另一種具體的做法

如果我們把 @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 也支援覆蓋式的做法。更新通知應該出現在視窗底部。它應該要疊在文字之上(所以不會去改變終端機視圖的大小)。所有的點擊行為都應該和附屬視圖的版本保持一致。

它這次做得非常好。我之後做了大量手動的美化(搬移東西、重新命名等等),但核心的實作是很扎實的。

以下是這場對話後不久的功能影片,展示了在特定標題列樣式或標題列被隱藏時,更新通知如何顯示在視窗的右下角:


開始做後端

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。這包括改用 tagged union,而不是帶有一堆 optional 的結構,並重新命名一些型別、搬移一些東西。

以我的經驗,這樣在中間做一點點手動整理,能讓 agent 在後續處理前端和後端的對話中更容易成功。完成後,我接著進行了一連串馬拉松式的整理對話。

做完重構後,我做的第一件事是請 agent 再次把貓頭鷹畫完,這次是請它檢視我的變更,並把相依的程式碼更新成新的寫法、移除舊的:

把 @macos/Sources/Features/Update/UpdateViewModel.swift 更新為只使用新的 UpdateState。把 state2 重新命名為 state(移除舊的 state)。

接著我請它移除額外沒用到的程式碼

我想我們可以把 UpdateUIActions 刪掉了。自從我們的 UpdateState 有了 callbacks 之後,它們就沒再用了。

然後我自己在整理一些東西時把建置弄壞了。剛好要去開會,所以我決定讓 agent 在我忙的時候幫我修好

執行建置並修掉錯誤

小技巧:「我把一堆東西弄壞了,請幫我收拾殘局。」這也是我很常用到 agent 的情境。我會說這大致上也符合前面提到的填空模式。

後來,我又開始針對一些視圖做重構

把 @macos/Sources/Features/Update/UpdatePopoverView.swift 中的每個 case 改成獨立的 fileprivate Swift 視圖,讓它以帶型別的值作為參數,這樣我們就可以移除那些 guards。

更多整理

把 @macos/Sources/Features/Update/UpdateViewModel.swift 中的 iconName 改成 optional,空白時回傳 nil。並更新使用的地方。


模擬測試

在第一場 UI 對話中,我有請 agent 產生一些 demo 程式碼,讓我在沒有實際檢查更新的情況下也能看到 UI 的效果。但更新流程有不少情境,到目前為止我只測了 happy path。

在我的下一場對話中,我把模擬程式碼抽到獨立的檔案,並請 agent 建立更多情境:

把 @macos/Sources/App/macOS/AppDelegate.swift 中的更新模擬程式碼抽到 @macos/Sources/Features/Update 底下一個獨立的檔案。這應該要包含多種模擬情境(happy path、找不到更新、錯誤等等),這樣我們就可以輕鬆嘗試不同的 demo。

小技巧: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 的提示,永遠是問它還有什麼我可能漏掉的地方。不管程式碼是我自己手寫的還是 AI 寫的,我都會這麼做。

你覺得 @macos/Sources/Features/Update 這個功能還有什麼可以改進的地方嗎?先不要寫程式。請諮詢 oracle。想想看有哪些部分的程式碼還可以多加一些單元測試。

這指出了一些真正的問題,所以我就請它直接動手改了。我發現直接跟 agent 說「好,全部幫我做一做」比逐項指定要做什麼還容易,因為之後我還可以在選擇性的 commit 中輕鬆整理。

這場對話中有個有趣的插曲是,agent 開始往一個非常瘋狂的兔子洞鑽,所以我出手阻止了它:

停停停。把所有 main actor 的東西都 revert 掉。

我也注意到它在某個地方用了比較差的做法,明明有更好的方式:

對於錯誤訊息,與其截斷它,難道沒有 SwiftUI 標準的做法嗎?我們應該多加一個 UI 元件,讓他們可以查看完整的訊息。


花費與時間

整個工作總共花了 16 場獨立的對話,在 Amp 上的 token 花費總計 $15.983。我不打算臆測這樣算貴還是便宜,但對我個人來說,這比我在做這個功能的兩天內在咖啡店花的錢還少。

我估計在這個功能上花的實際「時鐘時間」大約是 8 小時。我一天大概只有 4 小時能坐在電腦前,第一個和最後一個 commit 橫跨了 3 天。但我也不是所有時間都在做這個功能。舉例來說,我在同一段「電腦工作」時間內還發布了一個 Ghostty 更新、在 ThePrimeagen 上當了一個小時的來賓,還在 Zoo 的年度全體會議上做了客座分享4。所以,我覺得 8 小時的估計算是很寬鬆了。

網路上很多人爭論 AI 到底有沒有讓你工作得更快。在這個案例中,我認為我比完全自己來要更快地完成了上線,特別是因為對我個人來說,反覆調整 SwiftUI 細節樣式是非常繁瑣、耗時的事,而 AI 在這方面做得非常好。

我覺得對我個人而言,更快/更慢的爭論忽略了我最喜歡的一點:AI 可以在我離開去做別的事時幫我工作。這張照片就是我在為家人做早餐時,拍下的一場整理對話的進行狀況:

邊做早餐邊 Vibe

對於這件事,總會出現各種否定說法,像是「我才不想一邊煮飯一邊寫程式」或「專心一點」之類的。如果你想那樣過,那就沒關係。以我這個具體的例子來說,我是家裡最早起的人,我在其他人還在睡覺時準備早餐。我也不是一天到晚每個清醒的時刻都在這樣做。

總之就是:這對我來說很管用。我真的、真的不是想說服你。我和任何 AI 公司都沒有金錢上的關聯。但作為一個在使用 AI 工具上有很多成功經驗、也喜歡分享的人,大家常常請我舉例說明。這篇文章就是在做這件事。


結語

我覺得這個功能很漂亮、運作得很好,在經過最後的人工審查後,我就把它合併了5。對於使用 tip 版本的 Ghostty 使用者來說,現在已經可以用了。對於使用正式標籤版本的使用者,這個功能將會在 Ghostty 1.3 中提供。

我一直大力提倡公開分享 agentic coding 對話的重要性6,原因之一是這是一種非常強大的方式,可以幫助他人更有效地學會如何使用這些工具。希望這篇文章能有所幫助。

註解

  1. 目前只有推送給 nightly 測試者(「tip」測試者),但那也是一個有數千人的群體。已經合併,之後會包含在下一個 Ghostty 穩定版中。

  2. 是的,那是很棒的行銷。不是,不是我故意的。而且,不,我也不是在為此歡呼,因為你不會希望使用者害怕工具會占他們便宜。你會希望他們信任這個工具是來幫忙的。我希望簡報者(或任何人)是想要使用 Ghostty 的,而在乎這種小地方就是其中的一部分。

  3. 最有詩意的做法本來應該是用 OpenAI Codex 來做這個。相信它也會做得很棒!這篇文章不是在幫 Amp 打廣告,它只是剛好是我目前最常用到的 agentic coding 工具。

  4. 我家有個小小孩,所以我的「電腦時間」非常固定、也非常有限。😁

  5. 「最後的人工審查」超級無敵重要。這本來不該只是個註解,但我找不到更適合強調的地方。請絕對不要在沒有經過徹底人工審查的情況下就上線 AI 寫的程式碼。

  6. 我這麼做的理由本身也可以寫成另一篇部落格文章,所以除了已經說過的之外,我在這裡就不再多做解釋了。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言