# Step 02: REPL 循环与用户输入

一句话导读:用 Node 的 readline 搭一个最小 REPL——while(true) 里 await ask() 等一行输入,非空就回显,exit 就退出。几行代码,却是 Claude Code 整个交互体验的最底层骨架。


# 一、这一步做了什么(What)

step02/index.ts 做三件事:用 createInterface 连接 stdin/stdout;把回调式的 rl.question 包成返回 Promise 的 ask();在一个无限循环里反复「读输入 → 处理 → 回到读」。当前处理逻辑只是回显,但这个 Read-Eval-Print Loop 的形状,往后 50 步都不会变——只是把中间的「Eval」从回显换成「调用 Claude」。


# 二、面试官视角:为什么要把回调包成 Promise?(Why)

面试题:rl.question(q, callback) 本来就能用,为什么非要用 new Promise 包一层?

因为回调风格和 async/await 风格无法直接混用,而整个 Claude Code 是 async 的世界(API 调用、文件读写、工具执行全是 await)。如果输入层还停留在回调,主循环就得写成回调地狱:

// 不包装:回调嵌套,无法用 while 循环
rl.question('> ', input => {
  callAI(input, reply => {
    rl.question('> ', input2 => { /* 又一层... */ });
  });
});
1
2
3
4
5
6

包装成 Promise 后,主循环变回线性可读的样子:

while (true) {
  const input = await ask('> ');   // 一行读输入
  const reply = await callAI(input); // 一行调 AI
  console.log(reply);
}
1
2
3
4
5
维度 裸回调 包成 Promise
控制流 嵌套,越写越深 线性,while 里顺序读
错误处理 每层单独处理 统一 try/catch
与 async 世界 割裂 无缝 await

这个「回调转 Promise」是 Node 里最基础也最高频的适配动作。看透它,才理解为什么 REPL 能写成朴素的 while(true) { await ... }。


# 三、原理:readline 与 REPL 循环(How)

# ask() 的真实实现

const rl = createInterface({
  input: process.stdin,
  output: process.stdout,
  terminal: true,   // 启用终端模式
});

function ask(question: string): Promise<string> {
  return new Promise(resolve => {
    rl.question(question, answer => {
      resolve(answer.trim());  // 回调触发时 resolve,await 才拿到值
    });
  });
}
1
2
3
4
5
6
7
8
9
10
11
12
13

resolve 被塞进了回调里——用户按下回车、回调触发的那一刻,Promise 才 fulfilled,外层 await ask() 才拿到这一行。Promise 的完成时机 = 用户敲回车的时机,这是把「事件」翻译成「值」的经典手法。

# readline 悄悄替你做的事

terminal: true 不只是显示文字,它让 Node 接管 tty:

  1. 行缓冲 —— 逐字符累积,回车才触发回调(而不是每敲一个键就触发)
  2. 终端控制 —— 光标移动、退格、历史上下翻的底层支持
  3. 信号处理 —— Ctrl+C / Ctrl+D 触发 close 事件

代码里用 rl.on('close', ...) 接住了退出信号,打印「再见」后 process.exit(0),实现优雅退出。

# REPL 数据流

createInterface 连接 stdin/stdout
   ↓
while(true):
   await ask('> 你说:')   ←── 阻塞在这,等用户敲回车
   ↓ 拿到 input
   input 为空? → continue(跳过)
   input === 'exit'? → break(退出循环)
   ↓ 否则
   处理(当前=回显;未来=调 Claude)
   ↓
   回到 while 顶部
1
2
3
4
5
6
7
8
9
10
11

「空输入 continue、exit break」这两个守卫看似琐碎,却是所有 REPL 的标配——先处理边界,再处理主流程。


# 四、深入追问(面试常见 follow-up)

Q:while(true) 是死循环,为什么不会把 CPU 跑满 100%? A:因为循环体第一件事就是 await ask(),而 ask() 在等 I/O(用户输入)。await 会把控制权交还给事件循环,当前任务挂起,进程进入空闲、等待 stdin 可读的事件。CPU 完全空转不了——它在睡觉,直到用户敲回车才被唤醒。这是「异步死循环」和「同步死循环」的本质区别。

Q:为什么要 answer.trim()?不 trim 会怎样? A:终端输入常带首尾空格或意外的空白。不 trim 的话,input === 'exit' 会因为 'exit ' 带空格而判断失败,用户以为退出了却没退。trim() 是输入归一化——在业务判断前先把杂质去掉,是防御式编程的基本功。

Q:真实 Claude Code 的输入框比这复杂几十倍,这一步还有意义吗? A:有。真实版的 PromptInput/ 有 21 个文件,支持多行输入、粘贴检测、语法高亮、/ 命令补全、历史搜索——但它们全是在这个 Promise 之上叠加的体验层。最底层的「读一行、返回一个值」永远是 readline.question 这个模式。抓住不变的内核,增量的复杂度才好理解。


# 五、设计权衡

terminal: true 换来了 tty 的便利,代价是这份代码只能跑在真实终端里——管道输入(echo hi | node index.ts)或 CI 环境下 tty 行为会退化。学习阶段假设「人坐在终端前」,这个假设成立;但真实版必须同时处理非交互输入(headless / 管道模式),这也是为什么它的输入层要膨胀到 21 个文件。


# 六、与真实源码的对照

我们的实现 Claude Code 源码
createInterface + rl.question components/PromptInput/(21 文件)
ask() 回调转 Promise hooks/useTextInput.ts
while(true) REPL entrypoints/cli.tsx 主循环
Ctrl+C → close 退出 真实版有确认弹窗、中断当前请求

# 七、一句话总结

Step 02 = 一个 Promise 化的 while(true):用 readline 读一行、把回调包成 ask() 让主循环能线性 await、用空输入和 exit 两个守卫收边。REPL 的形状定死了——后面所有步骤都是往这个 while 中间塞更聪明的「Eval」。

# 下一步

cd step03 && npm start —— 把这坨代码拆进四个文件,看看「模块化」到底解决了什么。