最近一篇博客在 HN 上被顶到了首页:作者用 Claude 全程 vibe-coding(不读代码、只发 prompt、凭手感推动项目前进)造了一个 GPU 感知的 Kubernetes TUI 工具 k10s,7 个月、30 个周末、234 个 commit,最后把整个项目归档,决定从零用 Rust 重写。
他不是反 AI,恰恰相反——他是认真用过 AI 全程编码的人,在他写下"I’m going back to writing code by hand"的时候,给出了一份非常具体的 postmortem:AI 写得动的是 feature,写不动的是 architecture。
整篇 HN 讨论的核心冲突也在这里:大家都在晒"AI 一晚 ship 多少 feature"的当下,这位作者站出来说——feature 没问题,是骨架烂了。今天我们把他这篇博客原原本本地拆开来讲一讲。
先用三句话给你一个心智锚点,方便你边读边对照:
- 有个人花 7 个月,几乎全靠让 AI 写代码,做出一个工具。
- 工具不是被某个 bug 弄垮的,是整个"骨架"烂掉了。
- 他归纳出 5 条规矩,告诉你 AI 在哪几个地方一定会写出烂骨架,以及该怎么提前防住。
一、什么是 k10s,它是怎么烂掉的
先交代下背景。k10s 是作者做的一个 TUI(terminal UI,跑在终端里、用键盘操作的全屏文字界面)工具。
如果你完全不熟 Kubernetes,可以这样想:Kubernetes 是当下最主流的"集群操作系统",公司里管成千上万台机器的人都在用。k10s 是给这些工程师用的一个仪表盘,黑底白字、键盘操作,类似一个 top + 数据库可视化的混合体。同类工具里最有名的叫 k9s,作者这个新工具的差异点是:专门关注 GPU。因为现在很多公司用集群跑 AI 训练,一台带 GPU 的机器一小时要 32 美元,闲一秒都是钱。所以他想做给跑 NVIDIA 集群、关心 GPU 利用率、关心 DCGM 指标(NVIDIA 官方的 GPU 监控数据采集器)的人用——可以理解为 “k9s 的 GPU 版”。
技术栈是 Go + Bubble Tea。Bubble Tea 是一个基于 Elm 架构的 TUI 框架——一句话讲不直观,给你一个比喻:
把它想象成一台只有一个总开关的机器。所有按钮、所有传感器、所有定时器都不直接动机器,而是写一张小纸条扔进一个箱子里;机器旁边坐着一个值班的人,他严格按顺序拿纸条、按纸条上的内容改机器的状态。Bubble Tea 里那个值班的人就叫 Update(),"纸条"就叫 message。整个程序只有一份全局 state,谁也不能绕过值班的人直接伸手去拧机器——这套架构本来非常清爽。
记住这个值班大叔的画面,等下你会看到作者是怎么把这套优雅设计写成相反的样子的。
k10s 最核心的卖点是一个叫 fleet view 的视图——一张表格罗列每个节点的 GPU 分配、利用率、温度、功耗、显存,闲置黄色、繁忙绿色、爆满红色,一目了然。
<figure><img src=“images/gpuview.png” alt=“k10s 的 fleet view 截图,表格展示了多台 g4dn.xlarge 节点的 GPU 使用情况,包含 Instance、Compute、Usage 等列,Usage 列用紫色/橙色/黄色横条表示分配比例”></figure>
如图所示,每一行是集群里的一个节点,Compute 列写着 gpu/nvidia 1×(带一张 NVIDIA GPU),Usage 列那一条彩色横杠就是当前 GPU 的分配占用情况——这个视图本身确实很对 GPU 集群运维的胃口。
前几个星期作者形容是"魔法"。“加个 pods 视图,带实时刷新” — 一句话,搞定。资源列表、命名空间过滤、日志流、describe 面板、键盘导航,一个接一个落地。3 个周末就把 k9s 的核心功能 clone 了个差不多。他说自己写代码的速度大概是平时的 10 倍。
然后他想加上真正的卖点:GPU fleet view。Claude 也是一击即中——一次生成了 FleetView 结构体、tab 过滤(GPU/CPU/All)、自定义渲染、分配条,界面漂亮。
接下来他敲了 :rs pods 想切回 pods 视图(k10s 仿照 k9s 的命令面板语法,:rs 是切换资源类型的命令)。
什么都没渲染出来。表格空的。实时刷新停了。切到 nodes,看到的是 fleet view 残留下来的过滤数据。切回 fleet,tab 计数又错了。
用作者的原话:“the god object had consumed itself.” 上帝对象把自己吃了。
所以这就是事情的起点:不是某一行代码写错了,是整个程序的"骨头"已经长歪了,按哪里都不对。
二、God Object:AI 编码的标志性"气味"
这是作者整篇博客的核心隐喻,他用了一个很妙的类比:
就像 em-dash(破折号 —)是 AI 写作的气味,god-object(上帝对象)就是 AI 编码的气味。
什么叫 god object?字面意思就是"上帝对象"——一个全能、全知、什么都管的超级对象。在面向对象设计里这是公认的反模式,因为它把本来应该分散在多个职责单元里的状态全捏在一处,导致任何一处改动都可能引发不可预测的后果。看一眼他贴出的 Model 结构体就明白了:
type Model struct {
// 第三方 UI 组件
table table.Model
paginator paginator.Model
commandInput textinput.Model
help help.Model
// 集群信息和状态
k8sClient *k8s.Client
currentGVR schema.GroupVersionResource
resourceWatcher watch.Interface
resources []k8s.OrderedResourceFields
listOptions metav1.ListOptions
clusterInfo *k8s.ClusterInfo
logLines []k8s.LogLine
describeContent string
currentNamespace string
navigationHistory *NavigationHistory
logView *LogViewState
describeView *DescribeViewState
viewMode ViewMode
viewWidth int
viewHeight int
err error
pluginRegistry *plugins.Registry
helpModal *HelpModal
describeViewport *DescribeViewport
logViewport *LogViewport
logStreamCancel func()
// ...还有更多
}
UI 组件、K8s 客户端、每个视图各自的状态、导航历史、缓存、鼠标处理——全都塞在一个 struct 里。而所谓的 Update() 方法是一个 500 行的函数,里面是一个 110 个 case 的 switch 在分发消息。
model.go 整个文件 1690 行。换句话说,所有视图共享同一个 state,所有按键、所有消息、所有后台事件都涌进同一个 500 行的 Update() 里被分发——任何一个 case 写错,理论上都可能影响别的视图。
作者在博客里有句话特别戳人,几乎可以当作 vibe-coding 这个词的活体定义:“7 个月里我一直在 prompt、ship,从来没坐下来真正读过 Claude 写的代码。我看 diff、确认编译通过、跑通 happy path,然后下一个。”——这就是 vibe coding 最危险的姿势。直到某个东西彻底坏掉、prompt 已经救不回来,他第一次坐下来认真读那 1690 行代码,“我被吓到了”。
三、五条戒律:从废墟里挖出来的
作者把他踩过的坑提炼成五条戒律,每一条都配一个具体的反例代码片段,以及他建议你写进 CLAUDE.md / AGENTS.md 的 directive(指令)。
先说明 CLAUDE.md 是个什么东西:它就是一份放在项目根目录的 markdown 文档。Claude(或别的 AI 编程助手)每次开工前会先读它,你可以把它理解成 “AI 的工作守则”。作者这五条戒律的最终形态全都是"该往这份守则里加哪几句话"——因为他发现,AI 不会主动给自己定规矩,但你写下来它就会照做。
下面一条条看。
戒律 1:AI 只写 feature,不会写 architecture
每次他让 Claude 加一个 feature,Claude 都漂亮地交付。fleet view 一次就通,日志流也通,鼠标支持也通。问题是:每个 feature 都是在"先把这个搞通"的语境下被实现的,对另外 49 个共用同一份 state 的 feature 一无所知。
最典型的就是 resourcesLoadedMsg 这个 handler——每次切换视图都会跑(顺带提一下:下面代码里反复出现的 m.currentGVR.Resource,GVR 是 Kubernetes API 里 Group/Version/Resource 的缩写,意思就是"这是哪种资源对象",比如 pods、nodes、deployments):
case resourcesLoadedMsg:
m.logLines = nil // 加载资源时清空日志行
m.horizontalOffset = 0 // 切资源时重置横向滚动
if m.currentGVR != msg.gvr && m.resourceWatcher != nil {
m.resourceWatcher.Stop()
m.resourceWatcher = nil
}
m.currentGVR = msg.gvr
m.currentNamespace = msg.namespace
// ...
// 节点视图特殊处理:先存全量、分类、再过滤
if msg.gvr.Resource == k8s.ResourceNodes && m.fleetView != nil {
m.allResources = msg.resources
m.allCreationTimes = msg.creationTimes
if len(msg.rawObjects) > 0 {
m.fleetView.ClassifyAndCount(m.rawObjectPtrs())
}
m.applyFleetFilter()
} else {
m.resources = msg.resources
// ...
}
那个 if msg.gvr.Resource == k8s.ResourceNodes && m.fleetView != nil 看到了吗?这就是 fleet view 在通用资源加载路径里被特殊处理。每加一个需要点定制行为的新视图,就得在这里多加一个分支,每个分支还得手动清理"上一个视图"残留的对应字段——否则你切过去就会看见前一个视图泄漏过来的鬼影数据。
他数了一下整个文件里 = nil 这种清理语句出现了多少次:
m.logLines = nil // 加载资源时清空日志
m.allResources = nil // 不在节点视图时清空 fleet 数据
m.resources = nil // 加载日志时清空资源
m.resources = nil // 加载 describe 视图时清空资源
m.logLines = nil // 加载 describe 视图时清空日志
m.resources = nil // 加载 yaml 视图时清空资源
m.logLines = nil // 加载 yaml 视图时清空日志
m.logLines = nil // ... 另外两处在别的 handler 里
m.logLines = nil
9 处分散在 1690 行里的手动置空。漏一个,你就有鬼影。AI 看不到这个模式的腐化,因为每次 prompt 只动一条代码路径。
他建议你在 CLAUDE.md 里写:
# Architecture Invariants(架构不变式)
- 每个 view 实现 View trait。views 之间不允许互相访问状态。
- 所有异步数据通过 AppMsg 变体送达。后台任务禁止直接改字段。
- 加一个新 view 不应该需要修改任何已有的 view。
- App 结构体只是一个薄路由层。它只负责导航和消息分发,别的什么都不管。
他说,AI 不会主动发明这些规矩,但你写下来,它就会照着做。
所以这一条的关键不是"AI 不行",而是 “AI 没有把全局看在眼里的义务,把全局看清楚是你的活”。
戒律 2:god object 是 AI 的默认产物
为什么 AI 会默认走向"一个 struct 装一切"?因为这条路最短、最快满足当前 prompt、最少仪式。但代价是键位分发会变成噩梦。
看一眼 s 这个键的实际分发逻辑:
case m.config.KeyBind.For(config.ActionToggleAutoScroll, key):
if m.currentGVR.Resource == k8s.ResourceLogs {
m.logView.Autoscroll = !m.logView.Autoscroll
if m.logView.Autoscroll {
m.table.GotoBottom()
}
return m, nil
}
// pods 和 containers 视图下是 shell exec
if m.currentGVR.Resource == k8s.ResourcePods {
// ... 20 行查找选中 pod、拿名字、命名空间 ...
return m, m.commandWithPreflights(
m.execIntoPod(selectedName, selectedNamespace),
m.requireConnection,
)
}
if m.currentGVR.Resource == k8s.ResourceContainers {
// ... 进容器的 exec 逻辑 ...
return m, m.commandWithPreflights(m.execIntoContainer(), m.requireConnection)
}
return m, nil
一个键,三种行为:在 logs 视图里是"切换 autoscroll",在 pods 视图里是"进 pod shell",在 containers 视图里是"进容器 shell"。全挤在一个扁平的 switch 里,因为根本没有"每个视图自己的键位表"这种东西。
AI 为什么这么写?因为他当初说"给 pods 加 shell 支持",AI 就找到最近的 key handler 把代码塞进去了——最短路径。
类似的 m.currentGVR.Resource == 在整个文件里被当 type discriminator 用了 20 多次。不是类型判断,是字符串比较。每加一个新视图,每个 handler 都得改。
他给的 directive 是:
# State Ownership Rules(状态归属规则)
- 永远不要在 App/Model struct 里加视图专有的字段。
- 每个 view 都是独立 struct,实现 View trait/interface。
- 每个 view 声明自己的键位表。app 把按键派发给当前激活的 view。
- 要加键位,加到对应 view 的 keymap 里,不要加到全局。
- 加 view = 加一个新文件。如果你的改动需要动已有的 view,停下来问我。
他总结得很到位:AI 永远走最短路径(“再加一个 if 分支”),你要做的就是让最短路径同时也是正确路径——办法就是把护栏写进它每次都会读的那个文件里。
所以第二条戒律可以记成一句话:当你的项目里出现"超级对象 + 巨型 switch"的组合,说明 AI 在用最短路径替你延寿,但这条路通向悬崖。
戒律 3:速度幻觉会撑爆你的 scope
这一条是心理层面的,他认为是最危险的一条。
k10s 一开始的定位非常清楚:GPU 集群操作员用的工具。一个小众但精准的用户群。但 vibe coding 让一切都变得"便宜"——“哎,加个 pods 视图只要一个 session?那再加 deployments、services、命令面板、鼠标支持、contexts、namespaces 也都加上吧。”
<figure><img src=“images/resourceview.png” alt=“k10s 的通用 pod 列表视图截图,显示了 130 个 pod,包含 Name、Status、Restarts、Age、Namespace、Pod IP、Node 等列,下方还有一个 rs no 的命令输入”></figure>
不知不觉,他在造 k9s——一个通用 Kubernetes TUI、给所有人用。原因就一个:AI 让每个 feature 看起来都是"白嫖"的。
但其实根本不是白嫖。每个 feature 都是 god object 里又长出来的一根分支。看一下他贴的 keymap 结构体:
type keyMap struct {
Up, Down, Left, Right key.Binding
GotoTop, GotoBottom key.Binding
AllNS, DefaultNS key.Binding
Enter, Back key.Binding
Command, Quit key.Binding
Fullscreen key.Binding // log view
Autoscroll key.Binding // log view(同时也是 pods 里的 shell!)
ToggleTime key.Binding // log view
WrapText key.Binding // log + describe view
CopyLogs key.Binding // log view
ToggleLineNums key.Binding // describe view
Describe key.Binding // resource views
YamlView key.Binding // resource views
Edit key.Binding // resource views
Shell key.Binding // pods(和 Autoscroll 冲突!)
FilterLogs key.Binding // log view
FleetTabNext key.Binding // 只在 fleet view 有效
FleetTabPrev key.Binding // 只在 fleet view 有效
}
所有视图共用一张扁平 keymap,旁边的注释标注每个键属于哪个视图。Autoscroll 和 Shell 都是 s,靠 dispatch 时检查 m.currentGVR.Resource 来区分。这能跑——但你没办法在局部理解任何一个键位,必须沿着 500 行的 Update 函数追一遍才知道这个键到底在干嘛。
值得点明一下:“它能跑"不等于"它是对的”——AI 优化的恰恰是前者,而你需要的是后者。
复杂度在悄悄堆积,而 velocity 仪表盘上还是绿油油的"你在 ship!"
他给的 directive 是写一份明确说明"我们不为谁服务"的 vision doc,并把 scope 边界放进 CLAUDE.md:
# Scope(不要超出这个范围)
k10s 是给 GPU 集群操作员用的,不是给所有 Kubernetes 用户用的。
支持的视图:fleet、node-detail、gpu-detail、workload。就这些。
不要加通用资源视图(pods、deployments、services)。
不要加任何重复 k9s 已有功能的特性。
如果一个 feature 请求不能服务到正在跑 GPU 训练任务的人,拒掉。
他特别强调:vibe coding 让你以为你有无限的实现预算。你没有。你有无限的"行数预算"(AI 会无限地产代码),但你的复杂度预算和以前一样有限。架构能支撑的 feature 数量是固定的,跟你写得多快没关系。CLAUDE.md 里的 scope 段,就是你提前替自己说"不"——免得在那个 velocity 嗨翻天的当下你一时冲动答应了。
所以第三条戒律是心理战:不是怕 AI 写错,是怕 AI 让"加 feature"这件事感觉太便宜。
戒律 4:positional data 是一颗定时炸弹
k10s 里每个 Kubernetes API 拉回来的资源,立刻就被压平成:
type OrderedResourceFields []string
列的身份完全靠位置确定。看一下 fleet view 的排序函数:
func sortFilteredResources(rows []k8s.OrderedResourceFields, times []time.Time, tab FleetTab) {
sort.SliceStable(indices, func(a, b int) bool {
ra := rows[indices[a]]
rb := rows[indices[b]]
switch tab {
case FleetTabGPU:
// 按 Alloc 列升序排序(下标 3)
allocA, allocB := "", ""
if len(ra) > 3 {
allocA = ra[3]
}
if len(rb) > 3 {
allocB = rb[3]
}
return allocA < allocB
// ... 其他 case ...
}
})
}
ra[3] 是 Alloc,ra[2] 是 Compute,ra[0] 是 Name。这些是 magic number(魔法数字)。唯一把"下标 3"和"Alloc"绑在一起的东西,是一行注释和 resource.views.json 里定义的列顺序:
{
"nodes": {
"fields": [
{ "name": "Name", "weight": 0.28 },
{ "name": "Instance", "weight": 0.15 },
{ "name": "Compute", "weight": 0.12 },
{ "name": "Alloc", "weight": 0.12 },
...
]
}
}
在 Instance 和 Compute 之间插一个新列试试看?所有 ra[2]、ra[3] 的代码全部静默错位。具体症状是:你点击"按 Alloc 排序",结果表格实际上是按 Compute 列排的——但 UI 上没有任何报错,你根本发现不了;或者更糟,节点名字之间互相比较 GPU 占用率谁高谁低,结果在 UI 上看起来"也排了",只是排得离谱。编译器帮不了你,因为全都是 []string。JSON 又没法表达排序行为和条件渲染,所以这些逻辑只能写在 Go 代码里,并且硬编码了 JSON 里的位置假设。
AI 为什么这么写?因为"拉数据→渲染表格"最短路径就是 []string,能立刻满足任何 table widget。typed struct 要多写好多脚手架代码。AI 走快路,你六个月后调试"为什么 Name 列的值出现在 Alloc 列"。
他给的 directive:
# Data Representation(数据表示)
- 永远不要把结构化数据压平成 []string、Vec 或位置数组。
- 所有数据以 typed struct(FleetNode、PodInfo 等)的形式流动,
直到 render() 调用为止。
- 列的身份来自 struct 字段名,不是数组下标。
- 排序函数操作 typed 字段,禁止 row[3] 这种位置访问。
- 生成 display 字符串的唯一位置是 render()/view() 函数内部。
然后让 typed struct 让非法状态无法被构造(“make impossible states impossible”,Elm/Rust 社区的经典口号):
struct FleetNode {
name: String,
instance_type: String,
compute_class: ComputeClass,
alloc: GpuAlloc,
}
字段名了,你就不可能用错列排序,不可能把 Alloc 字符串当 Name 比——编译器帮你强制保证。核心思路是:用类型系统在编译期把"出错的可能性"消灭掉,而不是在运行期祈祷检查到位。AI 默认会选 Vec<String>,因为更快满足 prompt;你的 CLAUDE.md 让 typed 路径变成"阻力最小的路径"。
所以第四条戒律可以这样记:[]string 加魔法下标 = 把代码的正确性押在你六个月后的记忆力上。
戒律 5:AI 不会主动管 state transition
Bubble Tea 框架有一个优雅的设计:Update() 是唯一能改 state 的地方,由消息驱动。但 k10s 把这条违反了。updateTableMsg 这个 handler 里返回了一个闭包,这个闭包在 goroutine 里直接改 Model 的字段:
case updateTableMsg:
return m, func() tea.Msg {
// 阻塞等待有人发来 update 消息
// ... 然后在这个 goroutine 里直接读写 m.resources、m.table、m.viewWidth ...
if savedCursor >= rowCount {
savedCursor = rowCount - 1
}
m.table.SetCursor(savedCursor)
return updateTableMsg{}
}
这个返回的函数(tea.Cmd)被 Bubble Tea 在另一个 goroutine 里跑。它读写 m.resources、m.table、m.viewWidth。同时,View() 在主 goroutine 里也在读这些字段。没有锁。没有 mutex。
形象点说:主线程正在画一张表格,后台线程正在改这张表格的数据。绝大多数时候后台改得早一点或晚一点,相安无事;可一旦两个人凑巧在同一瞬间动同一行——画的那个看到的就是"半新半旧"的乱码。这就是 data race。出 bug 的概率可能只有 1%,但它一旦出现你完全没法稳定复现——这就是经典的"99% 的时候是对的"。
他给的 directive:
# Concurrency Rules(并发规则)
- 后台任务(watchers、scrapers、API 调用)禁止直接改 UI state。
- 后台任务通过 channel 把结果作为 typed message 发出来。
- 只有主事件循环能根据收到的消息施加 state 修改。
- render()/view() 是纯函数。没有副作用、没有 I/O、没有 channel 操作。
- 如果你需要从异步代码里更新 state,定义一个新的 AppMsg 变体。
正确的形态是:
// 后台任务:
tx.send(AppMsg::FleetData(nodes)).await;
// 主循环:
match msg {
AppMsg::FleetData(nodes) => {
self.fleet_view.update_nodes(nodes);
}
}
没有共享可变状态、没有 data race、没有"99% 能跑"。
所以第五条戒律记住一句:还记得开头那个"值班大叔"吗?后台任务只许塞纸条,不许伸手拧机器。
<figure><img src=“images/logswork.png” alt=“k10s 的日志视图截图,左侧是大量 JSON 格式的日志行(包含 controllers 的 reconciler 输出),底部是状态栏显示 wrap、time、line numbers 等控制项”></figure>
顺带一提,作者在贴出"鼠标也能复制日志吧,能出什么问题?"这张图(如图,k10s 日志视图里密密麻麻的 JSON 日志行)的时候,配文的意思是反讽的——这正是他后来踩坑的源头之一:鼠标支持本身就是个充满异步状态副作用的地雷区。
四、他现在在做什么
作者决定用 Rust 重写。但他强调了一句很重要的话:选 Rust 不是因为 Rust 更好,而是因为"那是我能驾驭的语言。我写过足够多 Rust,能在某段代码不对劲时——还说不清为什么——就先感觉到。这种本能是 vibe-coding 替代不了的。AI 递给你的代码看起来都很合理,你得有一种’这是垃圾’的嗅觉。"
这句话否定了一个常见的错误推论:"换个更严格的语言(Rust、TypeScript)就能解决 AI vibe-coding 的问题。"作者说不是——是你得是能 review 这门语言的人。
另一个变化更简单:所有架构设计先用手写出来,再写一行代码。不是模糊的设计文档,是具体的接口、消息类型、所有权规则。那些 AI 一直做错的架构决定,现在变成"在第一个 prompt 之前先用文字定下来"。
至于这套做法够不够防止重写出来的版本再次崩塌——他自己直接说:“Whether that’s enough to keep the rewrite from collapsing under its own weight… I’ll find out.”(我也不知道够不够,等着瞧吧。)这种诚实是这篇文章可信度的来源之一——它不是一份兜售方法论的鸡汤,是一份还没尘埃落定的复盘。
五、给所有还在 vibe coding 的人的总结
最后说一句这位作者的立场:他不是 AI 反对者,他是真的拿 234 个 commit、30 个周末、7 个月跑通完整循环的人,下结论之前把 1690 行的 model.go 字字读完。把他整篇 postmortem 浓缩成一段:
- AI 写 feature,不写 architecture。它最快满足你当下的 prompt,对系统的全局一无所知。
- god object 是 AI 的默认气味。就像 em-dash 是 AI 写作的指纹。一切 state 塞进一个 struct,500 行的 switch,分散的
= nil清理——都是它。 - velocity 是幻觉。AI 让 feature 看起来"白嫖",但你的复杂度预算没变,scope 撑爆只是迟早的事。
- positional data 是定时炸弹。
[]string加魔法下标看着干净,半年后你查不出为什么列错位。typed struct 让非法状态无法存在。 - state transition 必须人来定。后台任务只发消息,主循环才能改状态——这条规则你不能省,不管 AI 多想抄近路。
最重要的一点:这些规则 AI 不会替你发明,但你写在 CLAUDE.md / AGENTS.md 里它就会照着做。所以与其在事后骂 AI 写得烂,不如把架构护栏写在它每次都会读到的那个文件里。
让 AI 写 feature,让人写 architecture。
参考资料
- 原文:I’m going back to writing code by hand by k10s devlog
- HN 讨论:Hacker News Thread
- k10s 归档仓库(Go 版):github.com/shvbsle/k10s/tree/archive/go-v0.4.0
- k10s 主仓库:github.com/shvbsle/k10s
- Bubble Tea TUI 框架:github.com/charmbracelet/bubbletea
- “Making impossible states impossible” 这个说法来自 Elm/Rust 社区