Index 21: Terminal UX —— Claude Code 风格终端交互
日期:2026-08-20 · 阶段四(多智能体协作)收官后第一个体验打磨 Index 前置:全部 s01-s20。功能已全,本 Index 只解决可用性。 Spec:
docs/superpowers/specs/2026-08-20-s21-terminal-ux-design.mdPlan:docs/superpowers/plans/2026-08-20-s21-terminal-ux.md
1. 目标
把 cat-code 的终端交互从旧式 readLine() REPL 升级为 Claude Code 风格:JLine3 输入(历史 / 编辑 / 补全)+ 工具调用实时可见 + Spinner 状态行 + Esc 中断 + 内联权限菜单。
s01 到 s20 把智能体"内部"做到了极致——工具、权限、Hook、Todo、子智能体、技能、压缩、记忆、系统提示、错误恢复、任务、后台、cron、团队、协议、自治、worktree、MCP、综合协调。但这一切都装在一个用户几乎看不见进程内部的 REPL 里:问一个问题,屏幕沉默 20 秒,然后突然吐出一大段文本。用户不知道智能体在想什么、在调什么工具、卡住了还是正在工作。
Index 21 用 UI 与核心解耦的事件流 把 agent 的内部过程实时呈现给用户,让"看见"成为可能。
Before —— agent 执行期间无任何反馈,结束才出最终文本:
❯ 帮我看看 Main.kt
<……沉默 20 秒,用户不知道发生了什么……>
这个文件是 CLI 入口,分三层:clikt 参数定义、ConfigResolver 解析、依赖组装……After —— 每一步即时可见:
❯ 帮我看看 Main.kt
✶ Thinking… (esc to interrupt)
⏺ Read(Main.kt)
⎿ 113 lines read
✶ Thinking…
这个文件是 CLI 入口,分三层:clikt 参数定义、ConfigResolver 解析、依赖组装……工具调用、结果摘要、等待状态全部实时可见;回合中按 Esc/Ctrl+C 立即可中断;权限审批从一行 (y/n) 问答升级为方向键可选的 Yes / No / Always / Never 菜单。
本 Index 同时保持优雅降级:stdin 非 TTY(管道 / CI)、--no-tui、或 JLine/Mordant 初始化失败时,自动回退纯文本 REPL——功能无损,只是不漂亮了。
2. 为什么需要
现实问题
s20 完成后 cat-code 是一个功能完整的 CLI 智能体,但旧 REPL 有三个让用户痛到不行的问题:
1. 工具调用不可见——"智能体是个黑箱"
用户问"帮我看看 Main.kt",智能体内部实际发生的是:LLM 请求 → 返回 read_file 工具调用 → 执行读取 → 再发一次 LLM 请求 → 生成回复。整个过程在 handleChat(repl/ReplLoop.kt:958)里 runBlocking { agentLoop.run(input) } 一行挡住,只有最终文本 println(response) 会冒出来。工具调了几次、读了多少行、哪个工具卡住了——一概不可见。用户只能干等,或怀疑进程死了。
2. 无中断——"卡住了只能干瞪眼"
LLM 请求、工具执行(如 bash 跑一个 30 秒的编译)期间,没有任何键盘中断手段。用户不能按 Esc 说"停",只能 Ctrl+C 杀掉整个进程——会话里的一切(历史、记忆、后台任务)全部丢失。这是可用性上最刺眼的一个洞。
3. 输入裸奔——"没有历史、没有编辑、没有补全"
旧 readLine() 没有行编辑的退格以外的任何能力:上一条命令?没有历史。输错一个字母?只能重打整行。/ 命令记不全?没有 Tab 补全。长期使用的终端工具连基本的 Readline 体验都没有。
设计原则
| 原则 | 依据 |
|---|---|
UI 与核心解耦 —— AgentLoop 只发事件(AgentEvent),不认识终端;ui/ 包消费事件渲染 | 延续"包依赖单向"(agent 不依赖 ui/repl);核心逻辑不被终端技术绑架,测试仍走 FakeLLMProvider |
最小侵入 AgentLoop —— 只给 AgentLoopHooks 加一个 onEvent 回调,不改 run() 签名、不动既有 hooks 语义 | 子智能体(s06)/teammate(s15)复用 AgentLoop 时传 null 即静默,零成本;AgentLoopHooks 全部字段带默认值,向后兼容 |
优雅降级 —— stdin 非 TTY / --no-tui / 初始化失败时回退纯文本 | 管道脚本、CI、SSH 无 TTY、以及任何未知终端环境都不能因为"装饰"而坏掉——ReadLinePrompter 保留不删 |
| 渲染 fail-open —— 渲染异常绝不中断 agent | 渲染是"锦上添花",agent 的推理与执行是主干。emit 吞普通异常记 warn,CancellationException 除外 |
| 不做 token 级流式 —— Provider 是非流式 API,本 Index 只让"事件"流式 | 流式 LLM API 是后续独立 Index 的事;YAGNI,不过度设计 |
3. 核心设计与实现
架构全景
┌────────────────────────────────────────────────────────┐
│ ui/ (新包) │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ TerminalUI │ │ Renderer │ │ Interrupt │ │
│ │ (JLine3 │ │ (Mordant: │ │ Watcher │ │
│ │ LineReader,│ │ spinner, │ │ (Esc 中断 │ │
│ │ 历史/补全) │ │ 工具卡片, │ │ 当前回合) │ │
│ └──────┬──────┘ │ markdown) │ └─────────────┘ │
│ │ └──────▲───────┘ │
│ │ │ AgentEvent (sealed) │
└─────────┼─────────────────┼────────────────────────────┘
│ 用户输入 │ onEvent 回调
┌─────────▼─────────────────┴────────────────────────────┐
│ agent/AgentLoop ── AgentLoopHooks 新增 │
│ onEvent: ((AgentEvent) -> Unit)? │
│ 在 LLM 请求前/后、工具执行前/后、压缩时发事件 │
└────────────────────────────────────────────────────────┘
│ 权限 ASK
┌─────────▼──────────────────────────────────────────────┐
│ permission/UserPrompter 新实现 ui/InlinePrompter │
│ (JLine raw-mode 选择菜单: Yes/No/Always/Never) │
└────────────────────────────────────────────────────────┘模块依赖(单向):agent(发出 AgentEvent,不认识终端)→ ui(Renderer 消费并渲染);repl → ui + agent(组装);ui → permission(InlinePrompter 实现 UserPrompter)。agent 包不依赖 ui,保持可插拔——这也是 s06 子智能体、s15 teammate 复用 AgentLoop 时传 onEvent = null 即静默的根基。
技术栈:JLine 3.30.16(输入层)+ Mordant 3.0.2 + mordant-markdown 3.0.2(渲染层),build.gradle.kts:39-41。mordant 3.0.2 显式声明与 clikt 5 传递依赖的 3.0.1 对齐。
1. AgentEvent —— 事件契约(agent/AgentEvent.kt)
核心是 sealed interface AgentEvent(AgentEvent.kt:21),六个事件类型,全部是纯数据:
sealed interface AgentEvent {
/** 即将向 LLM 发起请求(第 [iteration] 轮) */
data class LlmRequestStarted(val iteration: Int) : AgentEvent
/** LLM 返回的非空文本(TOOL_USE 与 END_TURN 路径都会发出) */
data class AssistantText(val content: String) : AgentEvent
/** 工具调用开始处理(权限检查之前) */
data class ToolCallStarted(val name: String, val inputSummary: String) : AgentEvent
/** 工具调用结束(含权限拒绝/hook 阻止/异常,此时 [isError]=true) */
data class ToolCallFinished(
val name: String,
val durationMs: Long,
val summary: String,
val isError: Boolean
) : AgentEvent
/** s08 上下文压缩触发 */
data class ContextCompacted(val event: CompactEvent) : AgentEvent
/** 回合结束(run 即将返回) */
data object TurnFinished : AgentEvent
}发射保证(AgentEvent.kt:15-19 的 KDoc 白纸黑字写死):
| 保证 | 实现位置 |
|---|---|
每次 run() 恰好一个 TurnFinished(含 return/异常/取消全部路径) | AgentLoop.run 的 try/finally,AgentLoop.kt:169-172 |
每次 LLM 请求前一个 LlmRequestStarted | AgentLoop.kt:105 |
每个工具调用恰好一对 ToolCallStarted / ToolCallFinished | executeOneTool 的 finish 统一出口,AgentLoop.kt:246-308 |
Started 在权限检查之前发出(权限菜单展示在两事件之间) | AgentLoop.kt:248 |
配套的展示摘要函数 summarizeForDisplay(AgentEvent.kt:53-59)把任意多行文本折叠为单行、截断到 SUMMARY_MAX_LENGTH = 80 字符、截断时以 … 结尾:
internal fun summarizeForDisplay(text: String, max: Int = SUMMARY_MAX_LENGTH): String {
val oneLine = text.lineSequence().firstOrNull()
?.replace(Regex("\\s+"), " ")
?.trim()
?: ""
return if (oneLine.length > max) oneLine.take(max - 1) + "…" else oneLine
}工具结果(可能是多行 JSON / 大文件内容)经它压缩成一行 ⎿ 113 lines read,LLM 原样看不到——只有展示用。
2. AgentLoopHooks.onEvent —— 唯一的注入点(agent/AgentLoopHooks.kt)
只加一个字段(AgentLoopHooks.kt:66),带默认值 null,向后兼容:
/**
* s21: UI 事件流回调。AgentLoop 在 LLM 请求/工具执行/压缩/回合结束时
* 同步发出 [AgentEvent]。为 null(默认)时静默——子智能体(s06)、
* teammate(s15)复用 AgentLoop 无需改动。
*
* 回调内抛出的异常被 AgentLoop 吞掉并记 warn 日志(渲染 fail-open);
* CancellationException 除外,必须传播。
*/
val onEvent: ((AgentEvent) -> Unit)? = null不改 run() 签名、不动既有 hooks 语义是本 Index 的核心约束之一——s06/s15 用同一个 AgentLoop,给它们传 onEvent = null 就完全静默,零成本。
3. AgentLoop 事件发射(agent/AgentLoop.kt)
三处修改,全部是"贴边"改动,不碰既有控制流。
(a)私有 emit 帮助函数(AgentLoop.kt:179-187)——渲染 fail-open 的落点:
private fun emit(event: AgentEvent) {
try {
hooks.onEvent?.invoke(event)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.warn(e) { "UI event handler threw for ${event::class.simpleName}" }
}
}普通异常吞掉记 warn(渲染炸了绝不让 agent 跟着死),但 CancellationException 必须传播——中断语义不能被渲染层吞掉。
(b)run() 的 try/finally 兜底(AgentLoop.kt:71 / 169-172):
} finally {
// s21: 恰好一个 TurnFinished——含 return/异常/取消全部路径
emit(AgentEvent.TurnFinished)
}这是"每个回合恰好一个 TurnFinished"的硬保证。当初如果只靠各 return 分支手动发,任何一个早退路径(MAX_TOKENS、ERROR、maxIterations)漏掉一次,Renderer 的 spinner 就不会被清、回合间状态就错了。finally 一劳永逸。
LLM 请求前/后各发一个事件(AgentLoop.kt:105 / 112-114):
emit(AgentEvent.LlmRequestStarted(iteration))
val response = llmProvider.chat(
messages = requestMessages,
options = options,
tools = toolDefinitions
)
logger.info { "LLM response: stopReason=${response.stopReason}, iteration=$iteration, toolCalls=${response.toolCalls.size}" }
if (response.content.isNotBlank()) {
emit(AgentEvent.AssistantText(response.content))
}注意 AssistantText 的发出条件 isNotBlank()——TOOL_USE 路径里 LLM 常先吐一句"让我先看一下",然后带工具调用,这两者都会触发 AssistantText;纯 TOOL_USE(空文本)不触发。
(c)executeOneTool 的 finish 统一出口(AgentLoop.kt:246-308):
private suspend fun executeOneTool(toolCall: ToolCall): ToolResult {
// s21: Started 在权限检查之前发出——权限菜单展示在 ⏺ 行与 ⎿ 行之间
emit(AgentEvent.ToolCallStarted(toolCall.name, summarizeForDisplay(toolCall.input.toString())))
val startNanos = System.nanoTime()
// 统一出口:每个 Started 恰好配一个 Finished(含拒绝/阻止/异常路径)
fun finish(result: ToolResult): ToolResult {
val durationMs = (System.nanoTime() - startNanos) / NANOS_PER_MILLI
emit(AgentEvent.ToolCallFinished(
toolCall.name, durationMs, summarizeForDisplay(result.content), result.isError
))
return result
}
// hook 回调自身抛异常(非取消)时兜底:仍以 isError=true 的 Finished 收尾,
// 保证 Started/Finished 成对;[CancellationException] 必须传播
return try {
// 1. 权限检查(Index 03)—— 安全决策,最先执行
if (hooks.onBeforeToolExecute != null) {
val approved = hooks.onBeforeToolExecute.invoke(toolCall)
if (!approved) {
logger.info { "Tool execution denied by permission pipeline: ${toolCall.name}" }
return finish(ToolResult(toolCall.id, "Permission denied: ${toolCall.name}", isError = true))
}
}
// 2. PreToolUse hooks(Index 04)…
// 3. 实际执行工具(含 UnknownToolException / Exception 捕获)
// 4. PostToolUse hooks(Index 04)…
finish(finalResult)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.error(e) { "Hook callback threw during tool call: ${toolCall.name}" }
finish(ToolResult(toolCall.id, "Error: hook failure: ${e.message}", isError = true))
}
}finish 是局部的唯一出口——权限拒绝、hook 阻止、工具异常、hook 自身抛异常这四条路径全部经它收尾,Started/Finished 必然成对。NANOS_PER_MILLI 提取为 companion 常量(AgentLoop.kt:58),符合魔法值禁令。
4. Renderer —— 事件到屏幕(ui/Renderer.kt)
消费 AgentEvent 的 Mordant 渲染器。事件 → 渲染映射:
| 事件 | 渲染 |
|---|---|
LlmRequestStarted | 启动 spinner 状态行 ✶ Thinking… (esc to interrupt)(Renderer.kt:66) |
AssistantText | 停 spinner,mordant-markdown 渲染正文(Renderer.kt:67-70) |
ToolCallStarted | 停 spinner,粗体打印 ⏺ ToolName(inputSummary)(Renderer.kt:71-76) |
ToolCallFinished | 停 spinner, ⎿ summary (12ms)(灰字);isError 时 ✗ summary(红字)(Renderer.kt:77-82) |
ContextCompacted | 提示行 ⚡ context compacted: before → after tokens(Renderer.kt:83-86) |
TurnFinished | 停 spinner,空行分隔(Renderer.kt:87-90) |
两个可单测的纯函数(Renderer.kt:25-26 / 35-36):
internal fun formatToolCallLine(name: String, inputSummary: String): String =
"⏺ $name($inputSummary)"
internal fun formatToolResultLine(summary: String, isError: Boolean, durationMs: Long): String =
if (isError) " ✗ $summary" else " ⎿ $summary (${durationMs}ms)"AssistantText 用 mordant-markdown 把 markdown 渲染进终端:
is AgentEvent.AssistantText -> {
stopSpinner()
synchronized(lock) { terminal.print(Markdown(event.content)) }
}线程安全是本类的一个关键设计点。spinner 由 Mordant 的 Animation 后台线程刷新,与 agent 协程的事件调用并发——所有终端写经 synchronized(lock) 串行化(Renderer.kt:59),onEvent 里每个写操作都包了锁。
spinner 的取舍——startSpinner(Renderer.kt:114-125)只刷事件驱动首帧,不做周期刷新:
private fun startSpinner() {
if (!enableSpinner) return
synchronized(lock) {
if (spinner != null) return
val anim = terminal.textAnimation<String> { frame -> "$frame Thinking… (esc to interrupt)" }
spinner = anim
// Mordant Animation:update 触发重绘;周期帧由调用方驱动
anim.update(SPINNER_FRAMES[frameIndex++ % SPINNER_FRAMES.size])
}
// 帧推进由 repeat 协程驱动太重——采用事件驱动帧推进:
// 后续事件到来时 spinner 自然被替换/停止。静态首帧已足够提示"进行中"。
}因为 AgentLoop 是阻塞式调用,周期刷新需要额外线程,且与权限菜单/中断监听抢终端的风险大于收益。静态首帧(⠋ Thinking…)已足够让用户知道"正在工作"。详见开发过程记录。
stopSpinner(Renderer.kt:127-139)包 try-catch——Mordant 动画停止在异常终端上可能抛错,绝不能让它打断 agent。
Renderer 还承接 ReplLoop 回合间的三类输出,统一图标前缀:后台完成通知 🔔(Renderer.kt:95-96)、teammate 消息 📨(Renderer.kt:99-100)、普通信息/错误(Renderer.kt:103 / 106-107)。pauseSpinner()(Renderer.kt:112)供权限菜单接管屏幕时暂停 spinner。
5. TerminalUI —— JLine3 输入层(ui/TerminalUI.kt)
持有 JLine Terminal + LineReader,提供行编辑、上下历史、/ 命令 Tab 补全:
class TerminalUI(private val commandNames: () -> Set<String>) : AutoCloseable {
/** 底层 JLine 终端(InterruptWatcher 复用同一实例做 raw-mode 读取) */
val jlineTerminal: Terminal = TerminalBuilder.builder()
.system(true)
.build()
private val reader: LineReader
init {
val historyFile = historyFilePath()
reader = LineReaderBuilder.builder()
.terminal(jlineTerminal)
.history(DefaultHistory())
.completer(SlashCommandCompleter.JLineAdapter(commandNames))
.variable(LineReader.HISTORY_FILE, historyFile)
.variable(LineReader.HISTORY_SIZE, HISTORY_MAX_ENTRIES)
.build()
logger.debug { "TerminalUI initialized, history=$historyFile" }
}要点:
- 历史持久化到
~/.cat-code/history(TerminalUI.kt:79-83),上限 2000 条(TerminalUI.kt:77),跨会话保留——这是旧readLine()完全缺失的能力。 /命令补全的动态源:commandNames是() -> Set<String>延迟函数,来自ReplCommandRegistry.allNames()(ReplCommandRegistry.kt:45)——补全候选与实际可执行命令永不漂移。readInput()的三态返回(TerminalUI.kt:60-69):正常文本、EOF(Ctrl+D)返回null、提示符处 Ctrl+C 返回空串""——后两者语义不同,调用方据此决定"退出 REPL"还是"继续等输入"(回合内中断由 InterruptWatcher 负责,二者职责清晰)。
TUI 支持检测(TerminalUI.kt:91-97)——isTuiSupported 三个条件:
fun isTuiSupported(noTui: Boolean): Boolean {
if (noTui) return false
if (System.console() == null) return false // 管道/CI/重定向
val term = System.getenv("TERM") ?: ""
if (term.isBlank() || term == "dumb") return false
return true
}System.console() == null 一次挡住管道 / CI / 重定向;TERM=dumb 挡住最简终端。
6. SlashCommandCompleter —— 补全纯函数(ui/SlashCommandCompleter.kt)
过滤逻辑抽成纯函数 suggest(SlashCommandCompleter.kt:24-27),JLine 适配器只做类型转换:
fun suggest(buffer: String, commandNames: Set<String>): List<String> {
if (!buffer.startsWith("/")) return emptyList()
return commandNames.filter { it.startsWith(buffer) }.sorted()
}非 / 开头不补全(普通输入交给 JLine 自己的历史补全逻辑);/ 开头按前缀过滤、字典序返回。
7. PermissionMenu + InlinePrompter —— 内联权限菜单
(a)MenuState 状态机 + PermissionKeymap 键映射(ui/PermissionMenu.kt) —— 纯逻辑、可单测:
data class MenuState(val selected: Int = 0) {
fun up(): MenuState = MenuState((selected - 1 + OPTIONS.size) % OPTIONS.size)
fun down(): MenuState = MenuState((selected + 1) % OPTIONS.size)
fun decision(): PermissionDecision = OPTIONS[selected].second
companion object {
val OPTIONS: List<Pair<String, PermissionDecision>> = listOf(
"Yes" to PermissionDecision.ALLOW,
"No" to PermissionDecision.DENY,
"Always allow this tool" to PermissionDecision.ALLOW_ALWAYS,
"Never allow this tool" to PermissionDecision.DENY_ALWAYS
)
}
}方向键 ↑↓ 用取模实现循环移动(末位再上移回到首位)。PermissionKeymap 把直选键 y/n/a/d(大小写不敏感)与 Esc 映射为直接决策(PermissionMenu.kt:50-57),Esc 一律安全降级为 DENY。
(b)InlinePrompter(ui/InlinePrompter.kt) —— JLine raw mode 逐键读取的交互实现,替换 ReadLinePrompter(旧实现保留供回退)。核心循环(InlinePrompter.kt:78-111):
val reader: NonBlockingReader = jlineTerminal.reader()
while (true) {
val ch = reader.read(KEY_POLL_MS)
if (ch == NonBlockingReader.READ_EXPIRED) continue
// stdin 关闭(EOF):安全降级为 No,避免死循环
if (ch == NonBlockingReader.EOF) {
menuOpen = false
return finish(writer, PermissionDecision.DENY)
}
// Esc 可能是裸按(= No),也可能是方向键序列 ESC [ A/B 的首字节——
// 先解析后续字节再定性,否则方向键会被误判为 No
if (ch == PermissionKeymap.KEY_ESC) {
when (readArrowSuffix(reader)) {
KEY_ARROW_UP -> { state = state.up(); renderMenu(writer, state); continue }
KEY_ARROW_DOWN -> { state = state.down(); renderMenu(writer, state); continue }
else -> {
menuOpen = false
return finish(writer, PermissionDecision.DENY)
}
}
}
// 直选键 y/n/a/d
PermissionKeymap.decisionForKey(ch)?.let { decision ->
menuOpen = false
return finish(writer, decision)
}
if (ch == PermissionKeymap.KEY_ENTER) {
menuOpen = false
return finish(writer, state.decision())
}
}方向键 3 字节序列与 Esc 的冲突处理是本类最微妙的细节(InlinePrompter.kt:91-100 + 134-142):raw mode 下方向键是 ESC [ A / ESC [ B 3 字节序列,逐字节读取会先读到 27(Esc)被误判为 No。解决:读到 27 后带超时地继续读后续字节,readArrowSuffix 依次校验 91([)和 65/66(A/B):
private fun readArrowSuffix(reader: NonBlockingReader): Int? {
val second = reader.read(KEY_POLL_MS)
if (second != KEY_CSI_BRACKET) return null
return when (reader.read(KEY_POLL_MS)) {
KEY_ARROW_UP -> KEY_ARROW_UP
KEY_ARROW_DOWN -> KEY_ARROW_DOWN
else -> null
}
}裸按 Esc(后两字节超时返回)→ DENY;方向键 → 移动选中项。KEY_ARROW_UP/DOWN、KEY_CSI_BRACKET、KEY_POLL_MS 全部提取为常量(InlinePrompter.kt:16-25)。
菜单渲染的两个"防花屏"细节:
- 清行尾
clr_eol(InlinePrompter.kt:157-161)——被取消选中的行旧内容带" ←"后缀(更长),println不会擦除残留,必须显式清到行尾再换行。这就是"幽灵箭头残留"的修复点(见开发过程记录)。 - 光标定位用
Terminal.puts而非 writer 转写(InlinePrompter.kt:159-165行内注释)——puts直接向终端输出控制序列并返回成功与否,不能经writer.print转写(会经 LineReader 缓冲处理,导致光标控制序列错位)。
onMenuStart/onMenuEnd 配对(InlinePrompter.kt:66-67 / 120-121)——ReplLoop 注入 pauseSpinner + watcher.pause / watcher.resume(见 Task 6),菜单激活期间中断监听挂起,避免抢输入。
8. InterruptWatcher —— 回合中 Esc/Ctrl+C 中断(ui/InterruptWatcher.kt)
agent 回合运行期间在独立守护线程上轮询 JLine 终端输入流,读到中断键即回调 onInterrupt(ReplLoop 注入 job.cancel()):
class InterruptWatcher(private val jlineTerminal: Terminal) {
private val active = AtomicBoolean(false)
private val paused = AtomicBoolean(false)
@Volatile private var thread: Thread? = null循环主体(InterruptWatcher.kt:63-95):
val t = Thread {
val attrs: Attributes = jlineTerminal.enterRawMode()
try {
val input = jlineTerminal.input()
while (active.get() && thread === Thread.currentThread()) {
if (paused.get()) {
Thread.sleep(POLL_MS)
continue
}
if (input.available() > 0) {
val ch = input.read()
if (isInterruptKey(ch)) {
logger.info { "Interrupt key received (code=$ch)" }
onInterrupt()
}
} else {
Thread.sleep(POLL_MS)
}
}
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
} catch (e: Exception) {
logger.warn(e) { "InterruptWatcher loop failed" }
} finally {
if (thread === Thread.currentThread() || thread == null) {
jlineTerminal.setAttributes(attrs)
}
}
}
t.isDaemon = true
t.name = "interrupt-watcher"
thread = t
t.start()设计要点:
- 只读中断键:
isInterruptKey(InterruptWatcher.kt:21-22)=Esc(27) || Ctrl+C(3)。raw mode 下ISIG关闭,Ctrl+C 作为普通字节3到达。 - 轮询而非阻塞读:
available() > 0才读,否则sleep(POLL_MS)——让线程能响应stop()的active.set(false)。 pause/resume规避与 InlinePrompter 的输入争抢(InterruptWatcher.kt:111/114):权限菜单激活期间 ReplLoop 调pause(),监听线程空转不读流——菜单内 Esc 由菜单自身处理为 No。- 代际守卫(
thread === Thread.currentThread()):解决 stop/start 竞态下"旧线程醒来把新代际的 raw mode 还原成 cooked mode"的僵尸线程问题,见开发过程记录的两轮修复。
9. ReplLoop 改造 —— 双路径(repl/ReplLoop.kt)
start() 分流(ReplLoop.kt:194-215):
fun start() {
val tuiSupported = TerminalUI.isTuiSupported(noTui)
if (tuiSupported) {
try {
initTuiComponents()
} catch (e: Exception) {
logger.warn(e) { "TUI initialization failed, falling back to plain REPL" }
// TerminalUI 已创建但后续组件构造失败:先 close 再置 null,避免 JLine 终端泄漏
terminalUI?.close()
renderer = null
terminalUI = null
interruptWatcher = null
userPrompter = ReadLinePrompter()
println("(TUI unavailable, falling back to plain mode)")
startLegacy()
return
}
startTui()
return
}
startLegacy()
}原 start() 的整个方法体重命名为 private fun startLegacy()(ReplLoop.kt:244),逻辑不变;startTui()(ReplLoop.kt:562)复制其全部子系统初始化,只替换交互层。两路径共享的子系统初始化刻意不抽公共方法——本 Index 不做过度工程,后续需要时再重构(KDoc ReplLoop.kt:558-560 明说)。
TUI 组件构建(initTuiComponents,ReplLoop.kt:224-236):
private fun initTuiComponents() {
val tui = TerminalUI { commandRegistry.allNames() }
terminalUI = tui
renderer = Renderer(Terminal())
interruptWatcher = InterruptWatcher(tui.jlineTerminal)
// s21: TUI 路径的权限交互改用内联菜单(菜单接管屏幕期间暂停 spinner + 中断监听)
userPrompter = InlinePrompter(
jlineTerminal = tui.jlineTerminal,
onMenuStart = { renderer?.pauseSpinner(); interruptWatcher?.pause() },
onMenuEnd = { interruptWatcher?.resume() }
)
}注意 userPrompter 从 val 改成了 var(ReplLoop.kt:159)——TUI 路径把它从 ReadLinePrompter 替换为 InlinePrompter,legacy 路径保持原样。PermissionPipeline(ReplLoop.kt:596-604)与 teammate 权限冒泡 drainPermissionRequests(ReplLoop.kt:1026)都引用这个成员,一处替换全局生效。
Renderer 注入——AgentLoopHooks 的 onEvent 接到 Renderer(ReplLoop.kt:773):
onEvent = { e -> renderer?.onEvent(e) } // s21: agent 事件流 → Renderer 实时呈现handleChatTui 回合处理(ReplLoop.kt:883-904)——支持中断的核心:
private fun handleChatTui(input: String) {
val watcher = interruptWatcher!!
val job = Job()
watcher.start { job.cancel() }
try {
runBlocking {
withContext(job) {
agentLoop.run(input)
}
}
} catch (e: CancellationException) {
renderer?.printError("⚠ interrupted by user")
// 补占位 ASSISTANT 消息,保持 user/assistant 交替(防 API 拒绝连续 USER)
if (agentLoop.messagesHistory.lastOrNull()?.role == Role.USER) {
agentLoop.messagesHistory.add(
Message(Role.ASSISTANT, INTERRUPTED_PLACEHOLDER)
)
}
} finally {
watcher.stop()
}
}中断链路:Esc → watcher 回调 job.cancel() → withContext(job) 抛 CancellationException → 捕获后渲染 ⚠ interrupted by user。LLM 的 ktor 请求随协程取消自动关闭连接;工具执行不挂起则跑到完成点才被取消(spec 明示不强制 kill 工具线程,不安全)。取消后向 messagesHistory 补一条 [Interrupted by user] ASSISTANT 占位(ReplLoop.kt:1072),保持 user/assistant 交替——否则下一轮请求出现连续 USER 消息,Anthropic API 会拒绝(这与 s01 的 sanitizeHistory 是同一类防御)。
回合间 drain 走 Renderer——drainNotifications(ReplLoop.kt:976-987)与 drainTeamMessages(ReplLoop.kt:1001-1014)用 if (renderer != null) … else println(…) 判空分流,TUI 路径经 Renderer 统一输出(🔔/📨 图标前缀),legacy 路径保持原 println——改动最小。
欢迎信息——TUI 路径单行提示 + 快捷键说明(ReplLoop.kt:1057-1061):
private fun printWelcomeTui(skillCount: Int) {
val skillNote = if (skillCount > 0) " · $skillCount skills" else ""
renderer?.printInfo("🐱 Cat-Code s21$skillNote · /help for commands · esc to interrupt · ctrl+d to quit")
renderer?.printInfo("")
}退出清理:TUI 路径在 finally 里多一句 terminalUI?.close()(ReplLoop.kt:869)——JLine 终端关闭(raw mode 还原、输入流释放)。
10. Main.kt —— --no-tui 开关(Main.kt)
private val cliNoTui: Boolean by option("--no-tui", help = "Disable TUI, use plain REPL").flag() // s21: TUI 开关——CLI 走 boolean flag(bare --no-tui 生效);env/.env 维度仍走 ConfigResolver.string
val noTui = cliNoTui || r.string("", KEY_NO_TUI, default = "false").toBooleanStrictOrNull() ?: false--no-tui 是裸 flag(无需值),env .env 维度仍走 CAT_CODE_NO_TUI(Main.kt:101)的字符串解析。noTui 传入 ReplLoop(Main.kt:88)。
11. logback.xml —— 产物模式日志收敛(src/main/resources/logback.xml)
问题:仓库原本没有任何 logback 配置——logback 在无配置时回退到默认行为:DEBUG 起全量打到 stdout。手动验收真实终端时,AgentLoop/AnthropicProvider/ContextCompactor 的 DEBUG/INFO 日志行插队进 spinner 与工具卡片之间,TUI 交互体验被严重污染("产品跑起来了但没法看")。
方案(控制台收敛 + 文件留全量,三行核心):
| 目标 | 配置 | 说明 |
|---|---|---|
| 控制台仅 WARN+ | CONSOLE appender + ThresholdFilter(level = ${CONSOLE_LEVEL:-WARN}) | 产物模式下 TUI 干净;可 -DCONSOLE_LEVEL=DEBUG 恢复开发期全量 |
| 全量落盘 | FILE appender(~/.cat-code/logs/cat-code.log) | DEBUG 全量可追溯;与历史(~/.cat-code/history)、任务(~/.cat-code/tasks.json)同一目录族 |
| 滚动策略 | SizeAndTimeBasedRollingPolicy | 10MB × 按天,gzip 归档,保留 7 天 / 上限 100MB |
<!-- 控制台:产物模式仅 WARN+;开发可 -DCONSOLE_LEVEL=DEBUG 打开 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${CONSOLE_LEVEL:-WARN}</level>
</filter>
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} -- %msg%n</pattern>
</encoder>
</appender>关键实现细节:
root level保持DEBUG,控制台用ThresholdFilter收敛而非直接root=WARN——同一个 logger 事件同时流向两个 appender:控制台被过滤到 WARN+,文件端拿到全量 DEBUG。如果直接把 root 设为 WARN,文件日志也跟着丢 INFO/DEBUG,调试就无据可查了。这是"一份日志、两种视图"的关键取舍。${CONSOLE_LEVEL:-WARN}是 logback 的属性默认值语法:-D系统属性或JAVA_OPTS传入CONSOLE_LEVEL时用它,否则回退WARN。阈值用变量而不是硬编码,开发/产物模式切换只差一个 JVM 参数。- start script 传 JVM 参数要用
JAVA_OPTS:build/install/cat-code/bin/cat-code的脚本把位置参数全部传给应用(clikt),-D直接跟在命令后会变成未知命令行选项;正确姿势是JAVA_OPTS="-DCONSOLE_LEVEL=DEBUG" cat-code(脚本第 242 行把DEFAULT_JVM_OPTS $JAVA_OPTS $CAT_CODE_OPTS拼给 JVM)。
验证(PTY 实机):正常启动交互 0 行日志混入;JAVA_OPTS="-DCONSOLE_LEVEL=DEBUG" 下 INFO/DEBUG 回到控制台(实测 17 行 INFO);文件日志持续记录全量。
数据流:一个回合的完整事件时序
用户输入 (TerminalUI.readInput)
→ ReplLoop.handleChatTui → agentLoop.run(input) [coroutine, busy=true]
├─ onEvent(LlmRequestStarted(1)) Renderer: spinner 起
│ InterruptWatcher: 开始监听
├─ LLM 返回 TOOL_USE
├─ onEvent(AssistantText(...)) 若有文本: 停 spinner, 渲染 markdown
├─ onEvent(ToolCallStarted(...)) "⏺ Read(Main.kt)"
│ └─ 权限 ASK → InlinePrompter 菜单(onMenuStart 暂停 spinner + watcher,结束后恢复)
├─ onEvent(ToolCallFinished(...)) " ⎿ 113 lines read"(灰字, 12ms)
├─ iteration 2: LlmRequestStarted spinner 再起
├─ LLM 返回 END_TURN
├─ onEvent(AssistantText(...)) markdown 渲染
└─ onEvent(TurnFinished) watcher 停, spinner 清
→ 回合间 drain(通知/inbox/权限冒泡/计划审阅)→ Renderer 统一渲染
→ TerminalUI.readInput 再次出现 ❯中断时序:Esc → watcher 回调 job.cancel() → AgentLoop.run 抛 CancellationException → finally 里的 TurnFinished 照发 → ReplLoop 捕获 → 渲染 ⚠ interrupted by user → 历史补 ASSISTANT 占位消息 → 回合干净收尾,REPL 继续。
错误处理
| 场景 | 行为 | 实现 |
|---|---|---|
| stdin 非 TTY(管道/CI) | 自动回退纯文本 + ReadLinePrompter,功能无损 | TerminalUI.isTuiSupported 检测(TerminalUI.kt:91-97) |
--no-tui / CAT_CODE_NO_TUI=1 | 强制旧路径(调试/脚本用) | Main.kt:63 |
| JLine/Mordant 初始化失败 | catch 后回退纯文本 + warn 日志;已创建的 TerminalUI close 防泄漏 | ReplLoop.kt:197-210 |
| Esc 中断 LLM 请求中 | cancel 协程;ktor 请求随协程取消关闭连接;历史补占位 ASSISTANT 消息 | ReplLoop.kt:893-900 |
| Esc 中断工具执行中 | 不强制 kill 工具线程(不安全),协程取消,回合结束,结果不入历史 | withContext(job) 取消语义 |
| 权限菜单中 Esc / EOF | 一律视为 No(安全降级,避免死循环) | InlinePrompter.kt:84-87 / 91-100 |
| Renderer 渲染异常 | fail-open:emit 吞普通异常记 warn,绝不中断 agent;CancellationException 传播 | AgentLoop.kt:179-187 |
| LLM 错误 / maxIterations | 事件流正常收尾(TurnFinished 由 finally 兜底),错误文本红色样式输出 | AgentLoop.kt:169-172 |
| 工具权限拒绝 / hook 阻止 / 工具异常 / hook 抛异常 | ToolCallFinished(isError=true) 成对发出 | AgentLoop.kt:246-308 |
| DEBUG/INFO 日志刷屏 TUI | 产物模式控制台仅 WARN+(ThresholdFilter),全量 DEBUG 落盘 ~/.cat-code/logs/cat-code.log | src/main/resources/logback.xml |
| 回合中方向键误触 | 首字节 27 被误判为中断——已知限制(见开发过程记录) | InterruptWatcher.kt:38-40 KDoc |
与前后 Index 的衔接
- s06 / s15:
AgentLoopHooks.onEvent带默认值null,子智能体/teammate 复用 AgentLoop 时零改动、零成本静默。 - s08:
ContextCompacted事件与既有onCompact回调并行发出(AgentLoop.kt:90/146),不破坏 s08 的日志语义。 - s15:teammate 权限冒泡
drainPermissionRequests经userPrompter成员复用InlinePrompter——菜单一处实现,主/teammate 共用。 - s13 / s15:回合间 drain 通知/teammate 消息改走 Renderer,统一样式。
- s01:中断后补 ASSISTANT 占位消息,与
sanitizeHistory同属"防连续 USER 消息"防御族。
4. 测试策略
4 个测试类,全部 Kotest StringSpec + 手写 Fake(不用 mock 框架):
| 测试类 | 覆盖点 |
|---|---|
AgentEventEmissionTest | END_TURN 直达路径事件序列(LlmRequestStarted→AssistantText→TurnFinished,无工具事件);TOOL_USE 路径工具事件成对且 inputSummary 含 payload;权限拒绝 → ToolCallFinished(isError=true) 且 Started/Finished 成对;onPreToolUse 抛异常 → ToolCallFinished("hook failure") 仍发出且 run 正常继续;onEvent 抛异常不影响 run 返回(渲染 fail-open);summarizeForDisplay 折叠多行/截断到 80/以 … 结尾 |
RendererTest | formatToolCallLine 渲染 ⏺ 行;formatToolResultLine 正常灰字 ⎿ / 错误带 ✗;ToolCallStarted/Finished 事件写入 TerminalRecorder 输出;AssistantText 渲染 markdown 正文;TurnFinished 输出空行;printNotification/printTeamMessage 带 🔔/📨 前缀;重复 TurnFinished 不崩(事件幂等) |
SlashCommandCompleterTest | 非 / 开头不补全;前缀过滤(/he→/help);仅 / 列出全部按字典序;无匹配返回空 |
PermissionMenuTest | 默认选中 Yes(ALLOW);down/up 循环移动不越界;四选项顺序 Yes/No/Always/Never;y/n/a/d(含大写)直选;Esc=DENY;未映射键返回 null |
**JLine 真实终端不可测的取舍(纯函数抽取策略)**是本 Index 可测性设计的核心:
JLine 的 TerminalBuilder 在单测环境(无真实 TTY)下不可实例化,因此输入层 TerminalUI、InterruptWatcher(线程 + 真实终端)刻意不做单测,手动验收覆盖。所有可测逻辑全部抽纯函数:
- 补全过滤逻辑 →
SlashCommandCompleter.suggest(buffer, names)(纯函数) - 按键 → 决策映射 →
PermissionKeymap.decisionForKey(ch)(纯函数) - 菜单状态机 →
MenuState.up()/down()/decision()(纯 data class) - 事件 → 文本映射 →
formatToolCallLine/formatToolResultLine(纯函数) - 摘要 →
summarizeForDisplay(纯函数)
Renderer 依赖 Mordant Terminal 接口——测试注入 TerminalRecorder(RendererTest.kt:13-17),捕获输出断言,spinner 关闭(enableSpinner = false)只验静态输出。
测试设计上值得记录的坑:
- "成对性"断言要同时数 Started 和 Finished ——
AgentEventEmissionTest的权限拒绝用例最初只断Finished.size == 1,后来补上Started.size == 1(AgentEventEmissionTest.kt:98)——只断一头,Started多发了/漏发配对的回归测不出来。 - hook 抛异常路径的断言用哨兵文本 ——
onPreToolUse抛异常用例断言summary.contains("hook failure")(AgentEventEmissionTest.kt:129)——这个哨兵是 AgentLoop catch 分支(AgentLoop.kt:307)构造的固定文案,不会与工具自然输出撞车。 - Esc 用例用常量而非魔法数 ——
PermissionMenuTest最初写decisionForKey(27),审查后改为decisionForKey(PermissionKeymap.KEY_ESC)(PermissionMenuTest.kt:39)——魔法值禁令在测试里同样生效。
5. 开发过程记录
s21 经历了完整的 spec → plan → subagent-driven execution 流程,且执行中踩了 9 个真实问题(含 2 个修复后又引入回归的二轮修复),全部值得记录。
5.1 ToolCallStarted 从"权限后"改到"权限前"(设计时序修正)
初稿直觉是把 ToolCallStarted 放在权限检查之后——"工具被批准了才算真正开始"。但 Spec 审查时发现这会让渲染时序错位:权限菜单会出现在 ⏺ 行之前,用户看到的顺序是"权限菜单 → ⏺ 工具 → ⎿ 结果",与 Claude Code 的实际时序(⏺ 行先出现,菜单浮在它下面)不符。改为权限检查之前发出(AgentLoop.kt:248),菜单自然展示在 ⏺ 与 ⎿ 之间,且权限拒绝/阻止时 ToolCallFinished(isError=true) 成对收尾,用户能清楚看到"这个工具被拒了"。这个决策在 spec §组件拆解("权限检查之前——权限菜单展示在 ⏺ 行与 ⎿ 行之间")与 KDoc(AgentEvent.kt:19)中都有记录。
5.2 hook-throw 路径 Started/Finished 成对性修复(commit f084e34)
问题:最初的 executeOneTool 只在四个既有分支(权限拒绝/阻止/成功/异常)里调 finish,但hook 回调自身抛异常(如 onPreToolUse 的实现炸了)时,异常直接逃逸出 executeOneTool,ToolCallFinished 不会发出——Started 发出后 Finished 缺失,Renderer 状态错乱。
修复:整个 hook 调用链包进 return try { … } catch (e: Exception) { finish(ToolResult(…, "Error: hook failure: ${e.message}", isError = true)) }(AgentLoop.kt:262-308),CancellationException 单独 catch 传播。同时把 run() 的 try/finally 收窄——最初 try 只包了 for 循环,sanitizeHistory() 和 messagesHistory.add(USER) 在 try 外,这两个"早于循环"的步骤若抛异常,TurnFinished 也缺失。修复后 try 从方法体最开头就包住(AgentLoop.kt:71),确保任何路径都有 TurnFinished。配套测试补了"onPreToolUse 抛异常 → ToolCallFinished(isError=true) 仍发出且 run 正常继续"用例(AgentEventEmissionTest.kt:105-130)。
5.3 InlinePrompter 幽灵箭头残留 + onMenuStart 配对修复(commit e2617f7)
审查发现的 4 个问题,一次修掉:
- 幽灵箭头残留 —— 菜单重绘时,被取消选中的行旧内容带
" ←"后缀(更长),println不擦除行尾残留,向下移动后上一行末尾留下一个←幽灵。修复:renderMenu里writer.print(...)后接jlineTerminal.puts(InfoCmp.Capability.clr_eol)显式清到行尾(InlinePrompter.kt:157-161)。 - onMenuStart/onMenuEnd 不配对 —— 最初
onMenuStart()在enterRawMode()之前调用;若enterRawMode()抛异常,onMenuEnd()(在finally里)不会执行,ReplLoop 注入的watcher.resume()永不触发 → 中断监听永久暂停。修复:onMenuStart()移到enterRawMode()成功之后、try之内(InlinePrompter.kt:66-67),与finally的onMenuEnd()严格配对。 - 异常路径菜单花屏 —— 菜单弹出后若抛异常(如
NonBlockingReader读取异常),光标停在中途,后续输出覆盖菜单区。修复:加menuOpen标志,finally里若menuOpen为 true 补一次clearMenu(InlinePrompter.kt:112-119)。注意menuOpen声明在try外——Kotlin 中 try 块内变量对finally不可见。 - 测试魔法值 ——
PermissionMenuTest的decisionForKey(27)改decisionForKey(PermissionKeymap.KEY_ESC)。
5.4 InterruptWatcher 僵尸线程代际守卫——两轮修复(commits 4c294ba → 883b491)
Round 1(4c294ba):引入代际概念。
问题:stop() 里 thread?.join(STOP_JOIN_TIMEOUT_MS) 限时等待后无条件置 thread = null。若监听线程没在超时内退出(如 onInterrupt 回调阻塞、Thread.sleep 未醒),旧线程仍活着,且其 finally 会执行 jlineTerminal.setAttributes(attrs)——把终端从 raw mode 还原成 cooked mode。而此时新一轮回合的 start() 可能已创建新监听线程进入 raw mode,旧线程的还原动作就把新代际的 raw mode 破坏掉,终端交互彻底失灵。
修复:引入代际语义。thread 是 @Volatile 引用,每次 start() 登记当前代际;循环条件(InterruptWatcher.kt:67)与 finally 的还原(InterruptWatcher.kt:91)都校验 thread === Thread.currentThread()——旧代际醒来发现"不在册"即自行退出,且不执行还原(还原只由在册代际负责)。
Round 2(883b491):round1 引入新回归。
round1 的 finally 里改成 if (thread === Thread.currentThread()) 后,出现新回归:当 stop() 已把 thread 置 null(join 超时后)且旧线程恰好正常退出,此时 thread == null,旧线程的还原被跳过——终端属性永远留在 raw mode,后续输入全是乱码。
修复:条件放宽为 if (thread === Thread.currentThread() || thread == null)(InterruptWatcher.kt:91-93)——thread == null 时无在册代际,还原必然安全(并发 start() 在登记新线程之前,新线程尚未 enterRawMode,无冲突;新线程稍后自行重新进入 raw mode)。
5.5 --no-tui 从需值选项改裸 flag(commit d8b3116)
最初的实现沿用了既有 CLI 选项风格,--no-tui 是 String by option("--no-tui").default("") 的需值选项——用户必须敲 --no-tui=true(或 --no-tui false)才生效,--no-tui 裸敲会因缺少参数报错。这对一个布尔开关是反直觉的。审查后改为 clikt 的 .flag()(Main.kt:41),裸 --no-tui 即生效;env/.env 维度仍走 ConfigResolver.string 解析 CAT_CODE_NO_TUI(Main.kt:63)。
5.6 start() try 收窄——回退只覆盖初始化,回合异常上抛(commit d8b3116)
最初的 start() 把 startTui() 整个包进 try-catch,任何异常(包括子系统初始化、回合循环的异常)都触发"回退纯文本"。审查发现三个问题:
- 回退提示误导用户 —— 用户跑了几十个回合后某次 LLM 调用抛异常,被捕获成"TUI unavailable, falling back to plain mode",暗示环境问题,实际是业务异常;
- 对话记忆丢失 —— 回退调用
startLegacy()重新初始化一整套子系统,之前 TUI 回合里的messagesHistory全丢; - 子系统双份初始化 ——
startLegacy()里toolRegistry、memoryStore等全部重新构造一遍。
修复:try 只包 initTuiComponents()(ReplLoop.kt:197-210)——JLine/Mordant 组件构造失败才回退(并 terminalUI?.close() 防 JLine 终端泄漏);startTui() 的子系统初始化与回合循环异常正常上抛(与 legacy 路径同构,CLI 非零退出)。KDoc(ReplLoop.kt:190-192)把这条取舍写死。
5.7 Mordant TerminalRecorder API 经 javap 验证
计划 Self-Review 标记"已知风险:Mordant TerminalRecorder.output() 方法名"——计划里给测试写的是 output(),但 mordant 3.0.2 的实际 API 可能是别的名字。执行者没有硬猜,而是对 jar 里的 TerminalRecorder 做 javap 验证实际方法名,确认 output() 存在后照用(RendererTest.kt:36)。类似地,JLine NonBlockingReader.read(timeout) 的超时返回常量名(READ_EXPIRED)也在实现时按实际 API 核对。这类"以实际 API 为准微调测试、接口契约不变"的处理,是 TDD 中处理第三方库不确定性的标准姿势。
5.8 spinner 放弃周期刷新改事件驱动首帧(设计取舍)
计划初稿设想 spinner 用独立协程周期重绘帧(Braille 旋转动画)。实现时发现两个现实约束:
AgentLoop是阻塞式调用(runBlocking内同步跑),周期刷新必须另起线程——与权限菜单(raw mode 接管屏幕)、中断监听(轮询输入流)抢终端的风险大于收益;- Mordant 的
Animation需要外部驱动update才重绘,周期刷新等于再造一个定时器线程。
取舍:只刷事件驱动首帧——LlmRequestStarted 时显示一行静态 spinner 帧(Renderer.kt:114-125),后续事件到来时 spinner 被自然替换/停止。静态首帧(⠋ Thinking… (esc to interrupt))已足够让用户知道"正在工作"。SPINNER_FRAME_MS(Renderer.kt:17)保留为常量,未来升级周期帧时可复用。
5.9 回合中方向键误触中断的已知限制
InterruptWatcher 是单字节轮询,无法区分裸 Esc 与方向键序列(ESC [ A)的首字节——回合中用户按方向键会误触发中断。这是有意的取舍:
- 权限菜单期间已由
pause()规避(菜单内方向键由 InlinePrompter 自己的序列解析处理); - 回合进行中方向键对用户本无意义(Claude Code 回合中方向键也无功能);
- 完整 ESC 序列解析会让中断监听复杂化、且引入新的时序 bug 风险。
该限制写在 InterruptWatcher KDoc(InterruptWatcher.kt:38-40)与 spec §错误处理中,作为已知限制接受。
5.10 日志刷屏——产物模式 logback 配置(commit 5baf35c)
触发:真实 TTY 手动验收时,发现 AgentLoop/AnthropicProvider/ContextCompactor 的 DEBUG/INFO 日志行插队进 spinner 与工具卡片之间——"TUI 功能全对,但屏幕没法看"。
根因排查:src/main/resources/ 下没有 logback 配置文件。logback-classic 无配置时的默认回退是所有 logger DEBUG 起打到 stdout——从 s01 起项目就在裸奔默认日志,只是旧 REPL 的纯文本输出让日志行"看起来正常",TUI 一出现、渲染层和日志同屏竞争,问题才显形。
修复:新增 src/main/resources/logback.xml(见 §3.11),核心是 root=DEBUG + 控制台 ThresholdFilter 收敛到 WARN+ + 文件 appender 收全量。
验证:
- PTY 实机:交互全程 0 行日志混入 TUI(
grep INFO/DEBUG计数为 0); - 开发期覆盖:
JAVA_OPTS="-DCONSOLE_LEVEL=DEBUG"下 INFO/DEBUG 回到控制台(实测 17 行 INFO); - 文件追溯:
~/.cat-code/logs/cat-code.log全量 DEBUG 持续写入。
决策记录:控制台收敛用了 ThresholdFilter 而非直接 root=WARN——前者让文件端保住全量、调试不丢证据。若判错:文件日志缺 DEBUG,代价是排查效率而非正确性,且改回一行即可。另记一个坑:start script 的 -D 会传给应用而非 JVM,必须走 JAVA_OPTS(§3.11 已写)。
后续思考:logback 配置本身是"零代码的运维基础设施",但它的缺失暴露了一个更普遍的问题——功能 Index 完成 ≠ 可用性完成。日志收敛、终端检测、回退路径这些"不做也测不过"的体验项,恰恰是最容易在功能冲刺里被漏掉的。这正好呼应本 Index 的起点:s01-s20 把机制全部打通,但"像一个产品一样被使用"是另一回事。
6. 下一站
s21 把"可用性"补上了,但交互体验仍有三块明显的续作空间:
- 流式 Provider(token 级输出) —— 本 Index 只让"事件"流式,LLM 回复仍是整段到达。接入流式 LLM API 后,
AssistantText可以升级为 token 级增量渲染,用户看着文字逐字出现。这是最大的体验提升点,需要新的流式 Provider 实现 +AgentEvent增加增量事件类型。 - 多行输入编辑器 —— 当前
readInput()是单行;支持 Shift+Enter 续行的多行编辑需要 JLine 的LineReader多行配置 + 输入缓冲区的状态机,适合后续按需 Index。 - diff 渲染 ——
Write工具目前只显示路径+行数摘要(⎿ 42 lines written);渲染文件变更的 diff(增删行、高亮)需要 Read 工具结果携带 diff 数据 + Renderer 增加 diff 渲染分支。 - Windows TERM 检测 ——
isTuiSupported的TERM检测在 Windows(TERM常为空或非标准)下可能误判,需针对 Windows Terminal / conhost 单独验证;当前 CLI 主要面向 Unix 系终端。
s21 留给后续最大的架构资产是事件流这个解耦模式:AgentLoop 只发事件、ui/ 包只消费渲染——任何新的呈现形式(流式 token、diff 卡片、甚至未来的分栏 TUI)都只需扩展 AgentEvent 枚举 + Renderer.onEvent 的 when 分支,核心零改动。这个"核心发事件、外层消费"的边界,正是整个 Index 设计中最值得迁移到未来功能的那块砖。
下一篇(规划中):流式 Provider —— token 级输出的第一步,让"看到智能体在思考"升级为"看到智能体在打字"。


