我的相册
72.86M · 2026-02-13
同步至个人站点:为什么 Memo Code 先做 CLI:以及终端输入框到底有多难搞
大概四年前,我刚接触编程。学的是 C 语言,第一个程序当然是 hello world。
很简单,几行就写完。run 一下,弹出来一个 terminal(我已经忘了当时用的是什么:cmd?PowerShell?反正不重要),然后打印了一行:
“hello, world!”
从那以后,在我还没接触前端之前,我写的所有程序几乎都靠终端完成输入输出:猜数字、九九乘法表,再到后来刷算法,基本就是——写、跑、看终端、改、再跑。
那时候倒也没觉得有什么问题,只是偶尔会突然有点空虚: 难道以后我工作的成果,就是一直对着这个黑框框吗?
一开始我对“程序结果形态”的理解非常单一:
终端就是一切:日志、交互、结果展示,全在那儿。 它很直接,很原始,也很“学生气”。
后来我接触了前端。直到靠前端拿到第一份实习、第一份工作,我才对“程序结果形态”形成了第二种认知:
不仅仅是终端里几行日志,也可以是页面效果、动画、交互。 之后陆续做过小程序、App、桌面程序……那段时间里,终端更像“开发过程的工具”,而不是“产品本体”。
也就是从那时候开始,我对 terminal 的误解更深了: 它好像就应该是黑框框 + 命令 + 日志,仅此而已。
25 年下半年,自从 Gemini CLI 这类东西开始出现之后,我对“程序结果形态”有了第三种认知:
原来 terminal 也可以玩得这么花。
彩色输出、输入框、选择框、进度条……该有的都有。 当时我没怎么深入研究,只是隐约意识到:以前我对 terminal 的理解偏了,它并不等于“只能打印”。
后面开始做值班,又不得不把 Linux 命令捡起来:从 pwd / cat / tail / find,到 vi / vim……慢慢也熟练了。
直到最近两个月,我开始认真做我的 code agent:memo github.com/minorcell/m…
等我写好了 MVP,写好了 runtime,写好了 toolrouter、tools 等;下一步突然被一个看起来很“产品”、但本质上很“工程”的问题卡住了:
界面到底做什么? 传统 Web 页面?还是终端交互?
如果做传统 Web UI,我其实很拿手:加一个 HTTP server 包,再来个 Web UI 包就够了。 但现实是,市面上大多数 code agent(比如 claude code cli、codex cli)都是从终端交互做起的。后续再补 VSCode 插件、桌面版,甚至浏览器插件。
题外话:这里也不得不感慨一下——原来我大前端确实挺“六”的:只要界面能画出来,基本都能做。也更坚定一个想法:AI 时代,大前端技术只会更普及。
最终,可能是“理所当然”,也可能是对陌生技术栈的兴趣使然,我决定: memo code 的第一种产品形态,先做终端 CLI。
调研开源的 Gemini CLI 时,我发现他们用的是 Ink(React for CLI)。我也就直接跟了:选 Ink。
一开始真的很顺:
似乎都挺好……直到我碰到最难的一块:输入框。
以前做 Web app:
inputtextarea天然、顺滑、毫无心理负担。
但在终端里,多行输入并不是默认就“应该支持”的体验。甚至 Ink 的 input 组件,也只有单行。
这时候你才会意识到:在终端里,“输入框”不是 UI 控件,它更像是一个小型编辑器。
我一开始尝试的方案很朴素,比如:
表面看起来能用了。 但很快更真实的问题出现了:粘贴文本。
粘贴一段文本时,你会遇到:
这时候我才明白:我不是在做“多行 input”,我是在终端里硬写一个“半个 textarea”。
于是我最后认真设计了一套方案(写在这个 issue 里): github.com/minorcell/m…
我最后没有继续纠结“有没有更好的 input 组件”,而是换了一个思路:
把多行输入当成一个小型编辑器来做。 在 Ink 的限制里,把“编辑状态”和“渲染”解耦,然后在输入事件层做适配。
整个方案我拆成三个核心模块。
我不再把输入框当成“一个字符串”,而是当成一个状态机。
核心结构就是:
value: string(当前文本)cursor: number(光标在文本中的位置)听起来很简单,但一旦涉及多行、上下移动、终端折行,坑就开始密集出现:
这一层的目标只有一个: 不管 Ink 怎么渲染,我内部都能稳定得到“当前文本是什么 + 光标在哪里”。
真正让我没绷住的,其实是粘贴。
终端里粘贴一坨文本时,底层输入事件会被拆成很多个 keypress,然后 Ink / 渲染层每次都会触发更新。你会遇到一种非常诡异的现象:
你粘贴的是 A,但 UI 看起来像是 A 的碎片; 光标也像在“追不上输入”,最后漂到一个你完全无法理解的位置。
所以我做了一个“粘贴 burst 检测”:用启发式规则把粘贴从普通输入里识别出来,然后改成 缓冲 + 批量插入:
< 8ms 基本视为粘贴(人不可能这么快)≥ 16 时也按粘贴处理(对中文/emoji 路径更稳)pending → active → flush
先塞 buffer,等“粘贴结束”再一次性写入 value,避免每个字符都触发一轮复杂计算这一步做完之后,“粘贴残缺 / 光标乱跳”基本从玄学变成可控问题了。
终端输入要像编辑器,光靠“插入字符”是不够的,你还得补齐肌肉记忆:
还有一个关键点:逻辑行 vs 视觉行分离
n编辑用逻辑行,展示按视觉行计算,这样长段落在不同宽度终端也能保持一致体验。
同时,视觉换行还要能响应终端 resize(不然窗口一变宽/变窄,光标又漂移)。
这一层本质上就是: 把终端输入从“能打字”推到“像个 textarea”。
这套方案不可能一把梭把所有边界问题抹平。 不同终端模拟器、不同输入法路径、极端大文本性能……仍然需要持续打磨。
但至少到这里,我觉得我把剩下 20% 里最难受、最影响体验的那 15% 解决掉了:
memo 的这段实践,让我对终端交互有了更清晰的认知:
它不再只是我最开始学编程时那种“输入输出 + 打日志”。 它完全可以是简易版本的 Web App:有组件、有状态、有布局,甚至能长出一点“编辑器”的味道。
这感觉有点像当年最早的 HTML 刚出来时:朴素、克制,但足够表达。 而我现在做的,就是在这个黑框框里,把“能表达的东西”再往前推一点点。