猫咖
首页博客工具
搜索
语言
切换网站风格
选择主题颜色
点击特效
主题

猫咖 · 持续更新中,源码见 GitHub。

Index 21: Terminal UX —— Claude Code 风格终端交互

2026年8月21日
AI智能体Kotlin后端

系列

使用Kotlin从0开发一个ClaudeCode

系列

使用Kotlin从0开发一个ClaudeCode

进度 21 / 21

使用Kotlin从0开发一个ClaudeCode

上一篇

Index 20: Comprehensive Agent —— 全机制集成(收口)

下一篇

这已经是该系列最后一篇。

Index 21: Terminal UX —— Claude Code 风格终端交互

日期:2026-08-20 · 阶段四(多智能体协作)收官后第一个体验打磨 Index 前置:全部 s01-s20。功能已全,本 Index 只解决可用性。 Spec:docs/superpowers/specs/2026-08-20-s21-terminal-ux-design.md Plan: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 执行期间无任何反馈,结束才出最终文本:

TEXT
❯ 帮我看看 Main.kt
<……沉默 20 秒,用户不知道发生了什么……>
这个文件是 CLI 入口,分三层:clikt 参数定义、ConfigResolver 解析、依赖组装……

After —— 每一步即时可见:

TEXT
❯ 帮我看看 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. 核心设计与实现

架构全景

TEXT
┌────────────────────────────────────────────────────────┐
│ 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),六个事件类型,全部是纯数据:

KOTLIN
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 请求前一个 LlmRequestStartedAgentLoop.kt:105
每个工具调用恰好一对 ToolCallStarted / ToolCallFinishedexecuteOneTool 的 finish 统一出口,AgentLoop.kt:246-308
Started 在权限检查之前发出(权限菜单展示在两事件之间)AgentLoop.kt:248

配套的展示摘要函数 summarizeForDisplay(AgentEvent.kt:53-59)把任意多行文本折叠为单行、截断到 SUMMARY_MAX_LENGTH = 80 字符、截断时以 … 结尾:

KOTLIN
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,向后兼容:

KOTLIN
    /**
     * 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 的落点:

KOTLIN
    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):

KOTLIN
        } finally {
            // s21: 恰好一个 TurnFinished——含 return/异常/取消全部路径
            emit(AgentEvent.TurnFinished)
        }

这是"每个回合恰好一个 TurnFinished"的硬保证。当初如果只靠各 return 分支手动发,任何一个早退路径(MAX_TOKENS、ERROR、maxIterations)漏掉一次,Renderer 的 spinner 就不会被清、回合间状态就错了。finally 一劳永逸。

LLM 请求前/后各发一个事件(AgentLoop.kt:105 / 112-114):

KOTLIN
                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):

KOTLIN
    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):

KOTLIN
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 渲染进终端:

KOTLIN
            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)只刷事件驱动首帧,不做周期刷新:

KOTLIN
    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 补全:

KOTLIN
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 三个条件:

KOTLIN
        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 适配器只做类型转换:

KOTLIN
    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) —— 纯逻辑、可单测:

KOTLIN
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):

KOTLIN
            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):

KOTLIN
    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()):

KOTLIN
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):

KOTLIN
        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):

KOTLIN
    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):

KOTLIN
    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):

KOTLIN
                onEvent = { e -> renderer?.onEvent(e) }   // s21: agent 事件流 → Renderer 实时呈现

handleChatTui 回合处理(ReplLoop.kt:883-904)——支持中断的核心:

KOTLIN
    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):

KOTLIN
    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)

KOTLIN
    private val cliNoTui: Boolean by option("--no-tui", help = "Disable TUI, use plain REPL").flag()
KOTLIN
        // 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)同一目录族
滚动策略SizeAndTimeBasedRollingPolicy10MB × 按天,gzip 归档,保留 7 天 / 上限 100MB
XML
<!-- 控制台:产物模式仅 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);文件日志持续记录全量。

数据流:一个回合的完整事件时序

TEXT
用户输入 (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.logsrc/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 框架):

测试类覆盖点
AgentEventEmissionTestEND_TURN 直达路径事件序列(LlmRequestStarted→AssistantText→TurnFinished,无工具事件);TOOL_USE 路径工具事件成对且 inputSummary 含 payload;权限拒绝 → ToolCallFinished(isError=true) 且 Started/Finished 成对;onPreToolUse 抛异常 → ToolCallFinished("hook failure") 仍发出且 run 正常继续;onEvent 抛异常不影响 run 返回(渲染 fail-open);summarizeForDisplay 折叠多行/截断到 80/以 … 结尾
RendererTestformatToolCallLine 渲染 ⏺ 行;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)只验静态输出。

测试设计上值得记录的坑:

  1. "成对性"断言要同时数 Started 和 Finished —— AgentEventEmissionTest 的权限拒绝用例最初只断 Finished.size == 1,后来补上 Started.size == 1(AgentEventEmissionTest.kt:98)——只断一头,Started 多发了/漏发配对的回归测不出来。
  2. hook 抛异常路径的断言用哨兵文本 —— onPreToolUse 抛异常用例断言 summary.contains("hook failure")(AgentEventEmissionTest.kt:129)——这个哨兵是 AgentLoop catch 分支(AgentLoop.kt:307)构造的固定文案,不会与工具自然输出撞车。
  3. 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 个问题,一次修掉:

  1. 幽灵箭头残留 —— 菜单重绘时,被取消选中的行旧内容带 " ←" 后缀(更长),println 不擦除行尾残留,向下移动后上一行末尾留下一个 ← 幽灵。修复:renderMenu 里 writer.print(...) 后接 jlineTerminal.puts(InfoCmp.Capability.clr_eol) 显式清到行尾(InlinePrompter.kt:157-161)。
  2. onMenuStart/onMenuEnd 不配对 —— 最初 onMenuStart() 在 enterRawMode() 之前调用;若 enterRawMode() 抛异常,onMenuEnd()(在 finally 里)不会执行,ReplLoop 注入的 watcher.resume() 永不触发 → 中断监听永久暂停。修复:onMenuStart() 移到 enterRawMode() 成功之后、try 之内(InlinePrompter.kt:66-67),与 finally 的 onMenuEnd() 严格配对。
  3. 异常路径菜单花屏 —— 菜单弹出后若抛异常(如 NonBlockingReader 读取异常),光标停在中途,后续输出覆盖菜单区。修复:加 menuOpen 标志,finally 里若 menuOpen 为 true 补一次 clearMenu(InlinePrompter.kt:112-119)。注意 menuOpen 声明在 try 外——Kotlin 中 try 块内变量对 finally 不可见。
  4. 测试魔法值 —— 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,任何异常(包括子系统初始化、回合循环的异常)都触发"回退纯文本"。审查发现三个问题:

  1. 回退提示误导用户 —— 用户跑了几十个回合后某次 LLM 调用抛异常,被捕获成"TUI unavailable, falling back to plain mode",暗示环境问题,实际是业务异常;
  2. 对话记忆丢失 —— 回退调用 startLegacy() 重新初始化一整套子系统,之前 TUI 回合里的 messagesHistory 全丢;
  3. 子系统双份初始化 —— 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 旋转动画)。实现时发现两个现实约束:

  1. AgentLoop 是阻塞式调用(runBlocking 内同步跑),周期刷新必须另起线程——与权限菜单(raw mode 接管屏幕)、中断监听(轮询输入流)抢终端的风险大于收益;
  2. 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 级输出的第一步,让"看到智能体在思考"升级为"看到智能体在打字"。

目录

当前章节:Index 21: Terminal UX —— Claude Code 风格终端交互

  • 1. Index 21: Terminal UX —— Claude Code 风格终端交互
  • 2. 1. 目标
  • 3. 2. 为什么需要
  • 4. 现实问题
  • 5. 设计原则
  • 6. 3. 核心设计与实现
  • 7. 架构全景
  • 8. 1. AgentEvent —— 事件契约(agent/AgentEvent.kt)
  • 9. 2. AgentLoopHooks.onEvent —— 唯一的注入点(agent/AgentLoopHooks.kt)
  • 10. 3. AgentLoop 事件发射(agent/AgentLoop.kt)
  • 11. 4. Renderer —— 事件到屏幕(ui/Renderer.kt)
  • 12. 5. TerminalUI —— JLine3 输入层(ui/TerminalUI.kt)
  • 13. 6. SlashCommandCompleter —— 补全纯函数(ui/SlashCommandCompleter.kt)
  • 14. 7. PermissionMenu + InlinePrompter —— 内联权限菜单
  • 15. 8. InterruptWatcher —— 回合中 Esc/Ctrl+C 中断(ui/InterruptWatcher.kt)
  • 16. 9. ReplLoop 改造 —— 双路径(repl/ReplLoop.kt)
  • 17. 10. Main.kt —— --no-tui 开关(Main.kt)
  • 18. 11. logback.xml —— 产物模式日志收敛(src/main/resources/logback.xml)
  • 19. 数据流:一个回合的完整事件时序
  • 20. 错误处理
  • 21. 与前后 Index 的衔接
  • 22. 4. 测试策略
  • 23. 5. 开发过程记录
  • 24. 5.1 ToolCallStarted 从"权限后"改到"权限前"(设计时序修正)
  • 25. 5.2 hook-throw 路径 Started/Finished 成对性修复(commit f084e34)
  • 26. 5.3 InlinePrompter 幽灵箭头残留 + onMenuStart 配对修复(commit e2617f7)
  • 27. 5.4 InterruptWatcher 僵尸线程代际守卫——两轮修复(commits 4c294ba → 883b491)
  • 28. 5.5 --no-tui 从需值选项改裸 flag(commit d8b3116)
  • 29. 5.6 start() try 收窄——回退只覆盖初始化,回合异常上抛(commit d8b3116)
  • 30. 5.7 Mordant TerminalRecorder API 经 javap 验证
  • 31. 5.8 spinner 放弃周期刷新改事件驱动首帧(设计取舍)
  • 32. 5.9 回合中方向键误触中断的已知限制
  • 33. 5.10 日志刷屏——产物模式 logback 配置(commit 5baf35c)
  • 34. 6. 下一站
回到顶部

相关推荐

查看全部文章
Index 20: Comprehensive Agent —— 全机制集成(收口)

Index 20: Comprehensive Agent —— 全机制集成(收口)

2026年8月13日

作为收官之作,把 s01-s19 的二十个核心机制整合为一个全面智能体:统一的状态查询与运行周期、自治认领与工作区隔离的贯通,以及 /status 命令的全局可视化。全机制协同运转,标志着 Cat-Code 从零完整复刻 Claude Code 核心能力的收官。

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

2026年8月13日

引入 MCP(Model Context Protocol)插件机制,通过多传输适配与通道路由,把外部 MCP 服务器的工具动态接入智能体的工具池。工具注册从静态编译期扩展为运行时动态组装,让智能体能力随外部服务即插即用。

Index 18: Worktree Isolation —— 任务-目录绑定

Index 18: Worktree Isolation —— 任务-目录绑定

2026年8月13日

引入工作区隔离机制,把每个任务绑定到独立的 git worktree 目录,让并行执行的智能体互不干扰。通过任务-目录绑定与隔离环境的自动创建回收,多智能体可以在各自的工作副本中安全并行开发。