From 248e27a86e1222f2c46591ec63609822bc053fb8 Mon Sep 17 00:00:00 2001 From: MobileWork User Date: Fri, 14 Aug 2026 08:23:56 +0800 Subject: [PATCH 01/16] docs: add TUI comparison report and effect demos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调研 D:\dev 下 opencode/pi/crush/grok-build 四个 TUI 项目,与本项目 (FTXUI C++)对比,产出: - docs/tui-comparison/report.md: 9 维度对比报告 + 差距排序 - docs/tui-comparison/demos/: 9 个可运行 Python 演示,覆盖 CSI 2026 同步 输出、OSC 8 超链接、kitty keyboard、RGBA alpha、动画背景、spinner、 OSC 133、工具行三态灯、流式 markdown 增量渲染基准 核实源码后修正两处认知:acecode 有 OSC 8 检测代码但 FTXUI Element 限制 不发射;键盘仅消费标准 CSI 修饰符未启用 kitty 协议。 --- docs/tui-comparison/demos/.gitignore | 2 + .../demos/01_synchronized_output.py | 71 ++++++ .../demos/02_osc8_hyperlinks.py | 69 ++++++ .../tui-comparison/demos/03_kitty_keyboard.py | 130 +++++++++++ .../demos/04_alpha_transparency.py | 103 +++++++++ .../demos/05_animated_background.py | 85 ++++++++ .../demos/06_spinner_showcase.py | 86 ++++++++ .../tui-comparison/demos/07_osc133_prompts.py | 77 +++++++ docs/tui-comparison/demos/08_tool_row_dots.py | 96 +++++++++ .../demos/09_streaming_markdown.py | 204 ++++++++++++++++++ docs/tui-comparison/demos/README.md | 78 +++++++ docs/tui-comparison/demos/_term.py | 188 ++++++++++++++++ docs/tui-comparison/demos/run_static.py | 42 ++++ docs/tui-comparison/report.md | 185 ++++++++++++++++ 14 files changed, 1416 insertions(+) create mode 100644 docs/tui-comparison/demos/.gitignore create mode 100644 docs/tui-comparison/demos/01_synchronized_output.py create mode 100644 docs/tui-comparison/demos/02_osc8_hyperlinks.py create mode 100644 docs/tui-comparison/demos/03_kitty_keyboard.py create mode 100644 docs/tui-comparison/demos/04_alpha_transparency.py create mode 100644 docs/tui-comparison/demos/05_animated_background.py create mode 100644 docs/tui-comparison/demos/06_spinner_showcase.py create mode 100644 docs/tui-comparison/demos/07_osc133_prompts.py create mode 100644 docs/tui-comparison/demos/08_tool_row_dots.py create mode 100644 docs/tui-comparison/demos/09_streaming_markdown.py create mode 100644 docs/tui-comparison/demos/README.md create mode 100644 docs/tui-comparison/demos/_term.py create mode 100644 docs/tui-comparison/demos/run_static.py create mode 100644 docs/tui-comparison/report.md diff --git a/docs/tui-comparison/demos/.gitignore b/docs/tui-comparison/demos/.gitignore new file mode 100644 index 00000000..7a60b85e --- /dev/null +++ b/docs/tui-comparison/demos/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/docs/tui-comparison/demos/01_synchronized_output.py b/docs/tui-comparison/demos/01_synchronized_output.py new file mode 100644 index 00000000..13cbb508 --- /dev/null +++ b/docs/tui-comparison/demos/01_synchronized_output.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""01 - CSI 2026 同步输出(Synchronized Update)对比 + +报告章节:3.1 / 3.6 +acecode 现状:❌ 无,FTXUI 用 \\033[1A 光标回退逐帧重绘,无同步输出协议 +有此协议:pi(TuiMainScreen/TuiAltScreen 整帧包 CSI 2026)、grok-build(draw.rs 每帧包) + +演示:快速重绘一个 10 行计数块。 + 阶段 A:不加同步 -- 可见闪烁/撕裂(光标在行间跳动可见) + 阶段 B:用 \\x1b[?2026h...\\x1b[?2026l 包裹整帧 -- 平滑无闪烁 + +在刷新率高的终端上差异最明显;慢终端上 A 阶段撕裂更明显。 +""" + +import sys +import time +from _term import ( + init, CLEAR, RESET, HIDE_CURSOR, SHOW_CURSOR, + SYNC_BEGIN, SYNC_END, goto, CLEAR_LINE, fg, bg, BOLD, +) + +init() + +LINES = 10 +COLS = 48 +TOP = 3 + + +def render_block(frame, sync): + """渲染 10 行计数块。每行一个独立计数器,模拟 TUI 多区域同时刷新。""" + parts = [] + if sync: + parts.append(SYNC_BEGIN) + for i in range(LINES): + row = TOP + i + v = (frame * (i + 1)) % 1000 + bar = "█" * (v % 30) + "░" * (30 - v % 30) + # 不同行不同颜色,模拟多组件 + c = [(100, 200, 255), (255, 200, 100), (180, 255, 140), (220, 140, 255)][i % 4] + parts.append(goto(row, 4) + CLEAR_LINE + bg(30, 30, 40) + fg(*c) + + f" line {i:2d} | {bar} | {v:4d} " + RESET) + if sync: + parts.append(SYNC_END) + sys.stdout.write("".join(parts)) + sys.stdout.flush() + + +def phase(label, sync, frames=80): + sys.stdout.write(goto(1, 1) + CLEAR_LINE + BOLD + f" {label}" + RESET) + for f in range(frames): + render_block(f, sync) + time.sleep(0.016) # ~60fps + # 留白 + time.sleep(0.4) + + +def main(): + sys.stdout.write(CLEAR + HIDE_CURSOR) + try: + phase("阶段 A: 无同步输出(acecode 现状 -- 注意闪烁/撕裂) ", sync=False) + phase("阶段 B: CSI 2026 同步输出(pi/grok -- 平滑无闪烁) ", sync=True) + sys.stdout.write(goto(TOP + LINES + 2, 1) + RESET + + "对比结论:同步输出把整帧原子化提交,终端不会出现半帧状态。\n" + "acecode 的 FTXUI 用 \\033[1A 光标回退逐行重绘,在高刷新率/慢终端上会闪烁。\n") + finally: + sys.stdout.write(SHOW_CURSOR + RESET + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/02_osc8_hyperlinks.py b/docs/tui-comparison/demos/02_osc8_hyperlinks.py new file mode 100644 index 00000000..ce49c34f --- /dev/null +++ b/docs/tui-comparison/demos/02_osc8_hyperlinks.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""02 - OSC 8 可点击超链接对比 + +报告章节:3.6 +acecode 现状:⚠️ 有检测代码(mardown_formatter.cpp:78 terminal_supports_hyperlinks) + 但 make_hyperlink() 注明 "FTXUI Elements 无法内嵌 OSC 8",只返回纯文本 -- 链接不可点击 +有此能力:opencode、pi、grok-build(grok 还自动 linkify 文件路径/URL) + +演示:打印几条链接(URL / file:// 路径),对比: + 上半:OSC 8 超链接 -- 支持的终端里 Ctrl/Cmd+Click 可打开 + 下半:acecode 现状 -- 纯色文本,不可点击(下划线只是颜色装饰) + +OSC 8 格式: \\x1b]8;;URL\\x07 显示文本 \\x1b]8;;\\x07 +""" + +import sys +import os +from _term import init, RESET, fg, UNDERLINE, BOLD, DIM + +init() + + +def osc8(url, text): + return f"\x1b]8;;{url}\x07{text}\x1b]8;;\x07" + + +def main(): + sample_file = os.path.abspath(__file__).replace("\\", "/") + repo_root = os.path.dirname(os.path.dirname(os.path.dirname(sample_file))) + repo_file = "file:///" + os.path.join(repo_root, "src", "main.cpp").replace("\\", "/") + + print(BOLD + "=== OSC 8 可点击超链接(opencode / pi / grok 有此能力)===" + RESET) + print() + print(" " + osc8("https://github.com/charmbracelet/crush", "crush (GitHub)")) + print(" " + osc8("https://sw.kovidgoyal.net/kitty/keyboard-protocol/", "kitty keyboard 协议文档")) + print(" " + osc8(repo_file, "src/main.cpp(点击在编辑器打开)")) + print(" " + osc8("https://example.com/path?q=1", "带查询参数的 URL")) + print() + print(DIM + " ↑ 在 Windows Terminal / kitty / WezTerm / iTerm2 里 Ctrl+Click(或 Cmd+Click)可打开" + RESET) + print() + + print(BOLD + "=== acecode 现状:纯色文本,不可点击 ===" + RESET) + print() + print(" " + fg(100, 180, 255) + UNDERLINE + "crush (GitHub)" + RESET + + DIM + " https://github.com/charmbracelet/crush" + RESET) + print(" " + fg(100, 180, 255) + UNDERLINE + "kitty keyboard 协议文档" + RESET + + DIM + " https://sw.kovidgoyal.net/kitty/keyboard-protocol/" + RESET) + print(" " + fg(100, 180, 255) + UNDERLINE + "src/main.cpp" + RESET + + DIM + " " + repo_file + RESET) + print() + print(DIM + " ↑ acecode 的 markdown_formatter 检测到支持 OSC 8 的终端后," + "仍只渲染带下划线的蓝色文本 -- make_hyperlink() 因 FTXUI Element" + RESET) + print(DIM + " 网格模型无法内嵌原始 OSC 8 序列而退化。这是架构限制,非简单 bug。" + RESET) + print() + print(BOLD + "为何 FTXUI 做不到:" + RESET) + print(" FTXUI 的渲染管线把组件树 rasterize 成字符网格(Screen),每个 cell 只存" + "字符+颜色,无法在某段文本中间插入 OSC 8 的开始/结束标记(那会破坏网格对齐)。") + print(" opencode/grok 用自己的 framebuffer/ratatui,可以在 cell 之外维护一个 link 层" + "(grok 的 flush_with_links 把链接变化与 cell 变化一起 diff)。") + print() + print(BOLD + "acecode 可行的接入路径:" + RESET) + print(" 1. 在 FTXUI Screen rasterize 之后、写终端之前,做一遍后处理:" + "扫描带 link 标记的 cell 区间,用 OSC 8 包裹(需 FTXUI 暴露 link 元数据)。") + print(" 2. 或在 markdown_formatter 阶段直接产出原始 ANSI 文本(绕过 FTXUI Element)," + "像 grok 那样在 transcript 区用自绘文本而非 FTXUI 组件。") + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/03_kitty_keyboard.py b/docs/tui-comparison/demos/03_kitty_keyboard.py new file mode 100644 index 00000000..aba2ba90 --- /dev/null +++ b/docs/tui-comparison/demos/03_kitty_keyboard.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""03 - kitty keyboard 协议(交互式) + +报告章节:3.6 +acecode 现状:❌ 仅消费标准 CSI 修饰符(\\x1B[1;3A 等),未启用 kitty 协议 +有此能力:opencode、pi、grok-build + +kitty keyboard 协议启用后: + - Enter -> \\x1b[13u (而非裸 \\r,可区分) + - Shift+Enter -> \\x1b[13;2u (修饰符 2=Shift) + - Ctrl+A -> \\x1b[97;5u (修饰符 5=Ctrl) + - Alt+X -> \\x1b[120;3u(修饰符 3=Alt) + - 按键释放事件也可上报(flag 2) + +acecode 在 Windows Terminal 上拿不到 Shift+Enter(WT 不支持 kitty),靠 IME 脏补丁; +pi 用原生 addon(GetAsyncKeyState)查全局修饰键补这个缺口。 + +演示: + 1. 打印键码参考表 + 2. 进入 raw 模式 + 启用 kitty 协议,实时显示按键的原始字节 + 3. 按 q 退出 + +注意:Windows Terminal 目前不支持 kitty 协议,启用后仍回退标准序列 -- 这本身就是 + 演示的一部分(能看到 WT 发的是 \\x1b[1;3A 而非 \\x1b[13;2u)。 + kitty / WezTerm / Ghostty 上能看到完整 CSI u 序列。 +""" + +import sys +from _term import ( + init, RESET, BOLD, DIM, fg, CLEAR, HIDE_CURSOR, SHOW_CURSOR, + raw_mode, read_key, KITTY_PUSH, KITTY_POP, goto, +) + +init() + + +def fmt_bytes(b): + """把字节格式化为可读的转义序列表示。""" + out = [] + for byte in b: + if byte == 0x1b: + out.append(fg(255, 100, 100) + "\\x1b" + RESET) + elif byte == 0x0d: + out.append(fg(255, 200, 100) + "\\r" + RESET) + elif byte == 0x0a: + out.append(fg(255, 200, 100) + "\\n" + RESET) + elif byte == 0x07: + out.append(fg(255, 200, 100) + "\\x07" + RESET) + elif 32 <= byte < 127: + out.append(chr(byte)) + else: + out.append(fg(200, 200, 200) + f"\\x{byte:02x}" + RESET) + return "".join(out) + + +def reference_table(): + print(BOLD + "=== 键码参考:标准序列 vs kitty CSI u 序列 ===" + RESET) + print() + print(f" {'按键':<16} {'标准(acecode 消费)':<24} {'kitty 协议':<20} {'可区分?'}") + print(f" {'-'*16} {'-'*24} {'-'*20} {'-'*8}") + rows = [ + ("Enter", "\\r", "\\x1b[13u", "✓(与 Shift 区分)"), + ("Shift+Enter","\\r(同 Enter!)", "\\x1b[13;2u", "✓"), + ("Ctrl+A", "\\x01", "\\x1b[97;5u", "✓"), + ("Alt+X", "\\x1bx", "\\x1b[120;3u", "✓"), + ("Shift+Tab", "\\x1b[Z", "\\x1b[9;2u", "✓(标准也行)"), + ("Ctrl+Shift+↑","\\x1b[1;6A", "\\x1b[1;6;1u", "✓ + 事件类型"), + ("释放事件", "(无)", "\\x1b[...;1u", "✓(flag 2)"), + ] + for key, std, kitty, note in rows: + print(f" {key:<16} {fg(180,180,180)}{std:<24}{RESET} {fg(120,200,255)}{kitty:<20}{RESET} {note}") + print() + print(DIM + " acecode 现状:消费 \\x1B[1;3A(Alt+↑)等标准序列,但未启用 kitty 协议," + RESET) + print(DIM + " 所以 Shift+Enter 在多数终端拿不到(与 Enter 都是 \\r)。pi 用原生 addon 查" + RESET) + print(DIM + " GetAsyncKeyState 补这个缺口;opencode/grok 直接用 kitty 协议。" + RESET) + print() + + +def live_capture(): + print(BOLD + "=== 实时捕获(按 q 退出)===" + RESET) + print(DIM + " 试试 Enter / Shift+Enter / Ctrl+L / Alt+X / 方向键,看原始字节" + RESET) + print(DIM + " 启用了 kitty 协议(\\x1b[>15u);不支持的终端会回退标准序列" + RESET) + print() + + # 启用 kitty 协议 + sys.stdout.write(KITTY_PUSH) + sys.stdout.flush() + + row = 10 + try: + while True: + sys.stdout.write(goto(row, 1) + fg(120, 200, 255) + " ❯ " + RESET + "等待按键..." + " " * 30) + sys.stdout.flush() + b = read_key() + if not b: + continue + display = fmt_bytes(b) + # q 退出(但避免误判带修饰的 q) + if b == b"q": + break + sys.stdout.write(goto(row, 1) + fg(120, 200, 255) + " ❯ " + RESET + + display + " " + DIM + f"({len(b)} bytes)" + RESET + " " * 10) + sys.stdout.flush() + row += 1 + if row > 25: + row = 10 + finally: + sys.stdout.write(KITTY_POP + "\n") + sys.stdout.flush() + + +def main(): + sys.stdout.write(CLEAR + HIDE_CURSOR) + try: + reference_table() + print(BOLD + "进入 raw 模式捕获按键..." + RESET) + with raw_mode(): + live_capture() + finally: + sys.stdout.write(SHOW_CURSOR + RESET + "\n") + sys.stdout.flush() + print() + print(BOLD + "观察要点:" + RESET) + print(" - 在 kitty/WezTerm/Ghostty 上,Shift+Enter 应显示 \\x1b[13;2u") + print(" - 在 Windows Terminal 上,Shift+Enter 可能仍是 \\r(kitty 不支持)--> 这正是 acecode 的困境") + print(" - acecode 若启用 kitty(或至少 modifyOtherKeys),可在支持的终端可靠区分修饰键") + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/04_alpha_transparency.py b/docs/tui-comparison/demos/04_alpha_transparency.py new file mode 100644 index 00000000..2d6fd93d --- /dev/null +++ b/docs/tui-comparison/demos/04_alpha_transparency.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""04 - RGBA alpha 透明混色对比 + +报告章节:3.1 / 3.4 +acecode 现状:❌ FTXUI 颜色不透明(OPAQUE),无法做半透明叠层 +有此能力:opencode(RGBA alpha 混色,半透明对话框遮罩、透出终端的 system 主题)、 + grok-build(framebuffer 合成) + +ANSI 本身无法逐 cell 设 alpha。opencode/grok 在 framebuffer 层做 per-pixel alpha 合成: + result = bg * (1 - alpha) + overlay * alpha +先把叠层与背景混色,再输出最终颜色。本演示用预混色模拟这个视觉效果。 + +演示: + 左:acecode 现状 -- 不透明遮罩完全盖住背景文字 + 右:opencode/grok -- 半透明遮罩,背景文字隐约可见(预混色模拟) +""" + +import sys +import time +from _term import init, RESET, BOLD, DIM, fg, bg, goto, CLEAR, HIDE_CURSOR, SHOW_CURSOR, blend + +init() + + +def render_row(row, label, alpha): + """在第 row 行演示一个遮罩盖住背景文字的效果。 + + acecode(不透明):遮罩色直接盖死背景。 + opencode(半透明):遮罩色与背景文字色按 alpha 预混,文字隐约可见。 + """ + bg_text = "这是一段背景文字 transcript content streaming here..." + bg_color = (60, 60, 70) # 深灰背景文字 + panel_color = (40, 120, 200) # 蓝色对话框遮罩 + + # 标签 + sys.stdout.write(goto(row, 1) + BOLD + f"{label:<28}" + RESET) + + col = 30 + # 背景文字(裸色) + sys.stdout.write(goto(row, col)) + for ch in bg_text[:40]: + sys.stdout.write(bg(30, 30, 38) + fg(*bg_color) + ch) + sys.stdout.write(RESET) + + # 遮罩盖在前 20 个字符上 + if alpha >= 1.0: + # acecode: 不透明,完全盖死 + sys.stdout.write(goto(row, col)) + for ch in bg_text[:20]: + sys.stdout.write(bg(*panel_color) + " " ) + sys.stdout.write(RESET + fg(255, 255, 255) + BOLD) + sys.stdout.write(goto(row, col + 2) + "[遮罩完全盖住]") + else: + # opencode: 半透明,背景文字与遮罩预混 + sys.stdout.write(goto(row, col)) + mixed_bg = blend((30, 30, 38), panel_color, alpha) + mixed_fg = blend(bg_color, (255, 255, 255), alpha) + for ch in bg_text[:20]: + sys.stdout.write(bg(*mixed_bg) + fg(*mixed_fg) + ch) + sys.stdout.write(RESET + fg(255, 255, 255) + BOLD) + sys.stdout.write(goto(row, col + 2) + "[半透明]") + sys.stdout.write(RESET) + + +def main(): + sys.stdout.write(CLEAR + HIDE_CURSOR) + try: + sys.stdout.write(goto(1, 1) + BOLD + + "RGBA alpha 透明混色对比(opencode/grok 有,acecode 无)" + RESET) + sys.stdout.write(goto(2, 1) + DIM + + "ANSI 无法逐 cell 设 alpha;opencode/grok 在 framebuffer 层 per-pixel 合成。" + "这里用预混色模拟视觉效果。" + RESET) + + # 三个 alpha 档位渐变 + labels = [ + (5, "acecode(不透明 α=1.0)", 1.0), + (8, "opencode(半透明 α=0.5)", 0.5), + (11, "opencode(半透明 α=0.3)", 0.3), + ] + for row, label, a in labels: + render_row(row, label, a) + + # 动画:alpha 从 0.2 渐变到 0.8 再回来,展示"呼吸"的透明度 + sys.stdout.write(goto(14, 1) + BOLD + "动态 alpha 渐变(模拟对话框淡入):" + RESET) + import math + for frame in range(120): + t = frame / 120.0 + a = 0.3 + 0.4 * (0.5 + 0.5 * math.sin(t * math.pi * 4)) + render_row(16, f"α={a:.2f}", a) + time.sleep(0.03) + + sys.stdout.write(goto(19, 1) + RESET + BOLD + "结论:" + RESET) + sys.stdout.write(goto(20, 1) + " acecode 的 FTXUI Color 无 alpha 通道,遮罩只能完全盖住或完全透明。") + sys.stdout.write(goto(21, 1) + " opencode 的 RGBA(如 RGBA.fromInts(0,0,0,150))可做半透明遮罩,") + sys.stdout.write(goto(22, 1) + " 配合 tint() 线性混色实现对话框/气泡的层次感。") + sys.stdout.write(goto(23, 1) + " grok 在 ratatui 之上自建 framebuffer 合成层达成同样效果。") + finally: + sys.stdout.write(goto(25, 1) + RESET + SHOW_CURSOR) + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/05_animated_background.py b/docs/tui-comparison/demos/05_animated_background.py new file mode 100644 index 00000000..ccdaf0a2 --- /dev/null +++ b/docs/tui-comparison/demos/05_animated_background.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""05 - 正弦呼吸动画背景 + +报告章节:3.4 +acecode 现状:❌ 无背景动画(仅 compact_animation / thinking_heartbeat 文本) +有此能力: + opencode -- BgPulse 逐帧程序化动画背景(正弦呼吸光圈、GO logo 高光扫动、临时降频 30fps) + grok-build -- wave_brightness(tick, row) sin² 空间相位波(跨行扫过)+ pulse_brightness + +演示两段: + [A] 全屏 sin² 呼吸:背景亮度随 sin²(t) 循环(opencode BgPulse 风格) + [B] 跨行相位波:每行相位偏移,形成自上而下扫过的波(grok wave_brightness 风格) + +Ctrl+C 退出。 +""" + +import sys +import math +import time +from _term import init, RESET, BOLD, DIM, fg, bg, goto, CLEAR, HIDE_CURSOR, SHOW_CURSOR + +init() + +ROWS = 12 +COLS = 60 +TOP = 4 + + +def lerp(a, b, t): + return a + (b - a) * t + + +def main(): + sys.stdout.write(CLEAR + HIDE_CURSOR) + try: + sys.stdout.write(goto(1, 1) + BOLD + + "正弦呼吸动画背景(opencode BgPulse / grok wave_brightness)" + RESET) + sys.stdout.write(goto(2, 1) + DIM + "[A] 全屏呼吸 [B] 跨行相位波 Ctrl+C 退出" + RESET) + + start = time.time() + phase = "A" + switch_at = 4.0 + while True: + elapsed = time.time() - start + if elapsed > switch_at: + phase = "B" if phase == "A" else "A" + switch_at = elapsed + 4.0 + label = "全屏 sin² 呼吸(opencode BgPulse)" if phase == "A" else "跨行相位波(grok wave_brightness)" + sys.stdout.write(goto(3, 1) + DIM + f"[{phase}] {label}" + RESET + " " * 20) + + t = elapsed + buf = [] + for r in range(ROWS): + row = TOP + r + if phase == "A": + # 全屏同相呼吸 + brightness = 0.5 + 0.5 * math.sin(t * 2.0) + else: + # 跨行相位波:每行相位偏移 + brightness = 0.5 + 0.5 * math.sin(t * 2.0 - r * 0.5) + # 背景色从深蓝到亮青 + br = lerp(20, 80, brightness) + bgc = lerp(30, 180, brightness) + bgr = lerp(15, 40, brightness) + text_brightness = lerp(120, 255, brightness) + buf.append(goto(row, 4)) + line_text = f" row {r:2d} ░▒▓█ wave demo █▓▒░ brightness={brightness:.2f} " + buf.append(bg(int(bgr), int(br), int(bgc))) + buf.append(fg(int(text_brightness), int(text_brightness), int(text_brightness))) + buf.append(line_text + RESET) + sys.stdout.write("".join(buf)) + sys.stdout.flush() + time.sleep(0.033) # ~30fps + except KeyboardInterrupt: + pass + finally: + sys.stdout.write(goto(TOP + ROWS + 2, 1) + RESET + SHOW_CURSOR) + sys.stdout.write("结论:背景动画需逐帧重绘每个 cell 的背景色," + "FTXUI 的 Screen 重绘可做但 acecode 未实现;opencode/grok 把动画" + "与运行状态耦合进渲染管线(运行中 block 的 accent 柱做波浪/脉冲)。\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/06_spinner_showcase.py b/docs/tui-comparison/demos/06_spinner_showcase.py new file mode 100644 index 00000000..1b2c6473 --- /dev/null +++ b/docs/tui-comparison/demos/06_spinner_showcase.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""06 - 五种 Spinner 同屏对比 + +报告章节:3.4 +acecode 现状:❌ 仅 compact_animation,无丰富 spinner +其他项目: + opencode -- Knight Rider 扫描光带(逐像素 alpha 渐变拖尾、菱形/方块、双向扫描+端点停留) + grok-build -- 三套帧集:braille ⠋⠙⠹⠸⠼⠴⠦⠧ / dot ⋅ : ⸬ ⁙ / monitor ○ ◎ ◉ ◎ + pi -- loader.ts 盲文帧(⠋⠙⠹...,80ms) + +演示:5 个 spinner 同屏旋转,标注来源。Ctrl+C 退出。 +""" + +import sys +import time +from _term import init, RESET, BOLD, DIM, fg, goto, CLEAR, HIDE_CURSOR, SHOW_CURSOR + +init() + +# ---- 帧集 ---- +BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"] # grok / pi +DOT = ["⋅", ":", "⸬", "⁙"] # grok +MONITOR = ["○", "◎", "◉", "◎"] # grok + +# Knight Rider 扫描光带(opencode ui/spinner.ts 风格) +KR_WIDTH = 10 +def knight_rider(frame): + pos = frame % (KR_WIDTH * 2 - 2) + if pos >= KR_WIDTH: + pos = KR_WIDTH * 2 - 2 - pos + bar = ["░"] * KR_WIDTH + # 主光点 + 拖尾(alpha 渐变用不同亮度字符模拟) + trail = ["█", "▓", "▒", "░"] + for i, ch in enumerate(trail): + idx = pos - i + if 0 <= idx < KR_WIDTH: + bar[idx] = ch + return "".join(bar) + +# acecode 风格:静态 ●(或 compact_animation 的简单脉冲) +def acecode_dot(frame): + # 模拟 compact_animation: ● 亮度脉冲 + bright = (frame % 4) in (0, 1) + return fg(200, 200, 200) if bright else fg(120, 120, 120) + "●" + RESET + + +SPINNERS = [ + ("acecode", "●(compact_animation 脉冲)", lambda f: acecode_dot(f)), + ("grok", "braille ⠋⠙⠹⠸⠼⠴⠦⠧", lambda f: fg(120, 200, 255) + BRAILLE[f % len(BRAILLE)] + RESET), + ("grok", "dot ⋅ : ⸬ ⁙", lambda f: fg(255, 200, 100) + DOT[f % len(DOT)] + RESET), + ("grok", "monitor ○ ◎ ◉ ◎", lambda f: fg(180, 255, 140) + MONITOR[f % len(MONITOR)] + RESET), + ("opencode", "Knight Rider 扫描光带", lambda f: fg(255, 80, 80) + knight_rider(f) + RESET), +] + + +def main(): + sys.stdout.write(CLEAR + HIDE_CURSOR) + try: + sys.stdout.write(goto(1, 1) + BOLD + "Spinner 同屏对比(acecode 无丰富 spinner)" + RESET) + sys.stdout.write(goto(2, 1) + DIM + "Ctrl+C 退出" + RESET) + frame = 0 + while True: + buf = [] + for i, (src, desc, fn) in enumerate(SPINNERS): + row = 4 + i + buf.append(goto(row, 3)) + buf.append(f"{fn(frame)} {BOLD}{src:<10}{RESET} {DIM}{desc}{RESET}") + # 状态文本 + buf.append(goto(4 + len(SPINNERS) + 1, 3)) + buf.append(DIM + f"frame={frame} 各 spinner 独立帧率/方向" + RESET + " " * 20) + sys.stdout.write("".join(buf)) + sys.stdout.flush() + frame += 1 + time.sleep(0.08) # ~12fps,盲文帧 80ms 对齐 + except KeyboardInterrupt: + pass + finally: + sys.stdout.write(goto(4 + len(SPINNERS) + 3, 1) + RESET + SHOW_CURSOR) + sys.stdout.write("结论:acecode 仅 compact_animation,无多套 spinner 帧集;\n" + "opencode 的 Knight Rider 用逐像素 alpha 渐变(FTXUI 不透明色做不到拖尾衰减),\n" + "grok 三套帧集按 legacy ConHost 回退 ASCII。\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/07_osc133_prompts.py b/docs/tui-comparison/demos/07_osc133_prompts.py new file mode 100644 index 00000000..f2ae0c03 --- /dev/null +++ b/docs/tui-comparison/demos/07_osc133_prompts.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""07 - OSC 133 Prompt 语义标记 + +报告章节:3.6 +acecode 现状:❌ 无 +有此能力:pi(用 \\x1b]133;A/B/C 包裹每个 turn,支持 scrollToPrompt 上下跳转) + +OSC 133 标记: + \\x1b]133;A\\x07 prompt 开始(用户输入区起点) + \\x1b]133;B\\x07 prompt 结束 / 输出开始 + \\x1b]133;C\\x07 输出结束 + +支持的终端(kitty / WezTerm / Ghostty)提供 Cmd+Shift+↑/↓ 在 prompt 边界间跳转, +类似 shell 里的 prompt 边界导航。pi 借此在 transcript 里按"回合"快速跳转。 + +演示:打印几轮对话,每轮用 OSC 133 标记边界。在 kitty/WezTerm 里试 Cmd+Shift+↑/↓。 +然后对比 acecode 的无标记纯文本(无法按回合跳转,只能逐行滚)。 +""" + +import sys +from _term import init, RESET, fg, BOLD, DIM, fg256 + +init() + +A = "\x1b]133;A\x07" # prompt start +B = "\x1b]133;B\x07" # prompt end / output start +C = "\x1b]133;C\x07" # output end + + +def user_turn(label, text): + # 用户输入区: A ... B + return (A + fg(86, 156, 214) + BOLD + f"❯ {label}: " + text + RESET + B) + + +def assistant_out(text): + return "\n" + fg(220, 220, 170) + text + RESET + "\n" + C + + +def main(): + print(BOLD + "=== OSC 133 Prompt 语义标记(pi 有,acecode 无)===" + RESET) + print() + print(DIM + "在 kitty/WezTerm/Ghostty 里试 Cmd+Shift+↑/↓(或终端的 prompt 跳转快捷键)" + RESET) + print(DIM + "光标会在每个 ❯ 之间跳转;acecode 无此标记,只能逐行滚。" + RESET) + print() + + # 三轮带标记的对话 + sys.stdout.write(user_turn("用户", "解释一下同步输出")) + sys.stdout.write(assistant_out("CSI 2026 同步输出把整帧原子化提交,终端不会显示半帧状态。")) + sys.stdout.write("\n") + + sys.stdout.write(user_turn("用户", "kitty keyboard 协议有什么用")) + sys.stdout.write(assistant_out("它让 Shift+Enter、Ctrl+组合等修饰键以明确的 CSI u 序列上报," + "而不是无法区分的裸 \\r。")) + sys.stdout.write("\n") + + sys.stdout.write(user_turn("用户", "acecode 为何没接入")) + sys.stdout.write(assistant_out("FTXUI 的 Element 网格模型难以承载 prompt 边界这类跨 cell 的" + "语义标记,且 acecode 未在 rasterize 后注入 OSC 133。")) + sys.stdout.write("\n\n") + + print(BOLD + "=== acecode 现状:无标记纯文本(无法按回合跳转)===" + RESET) + print() + print(fg(86, 156, 214) + BOLD + "❯ 用户: 解释一下同步输出" + RESET) + print(fg(220, 220, 170) + "CSI 2026 同步输出把整帧原子化提交。" + RESET) + print() + print(fg(86, 156, 214) + BOLD + "❯ 用户: kitty keyboard 协议有什么用" + RESET) + print(fg(220, 220, 170) + "它让修饰键以明确的 CSI u 序列上报。" + RESET) + print() + print(DIM + "↑ 视觉上一样,但缺 OSC 133 标记 -- 终端不知道哪里是回合边界,无法提供跳转。" + RESET) + print() + print(BOLD + "接入难度:低。" + RESET) + print("OSC 133 是纯文本序列,不依赖 cell 网格。acecode 只需在 user 消息渲染前后、" + "assistant 输出前后各 print 一对标记即可(在 FTXUI 外层或 transcript 行输出时注入)。") + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/08_tool_row_dots.py b/docs/tui-comparison/demos/08_tool_row_dots.py new file mode 100644 index 00000000..c2f0f675 --- /dev/null +++ b/docs/tui-comparison/demos/08_tool_row_dots.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""08 - acecode 工具行 ● 三态指示灯(本项目独有优势) + +报告章节:二 / 四.3 +acecode 现状:✅ 独有风格 -- compute_tool_call_dots FIFO 配对 tool_call ↔ tool_result + ● 灰=执行中 / 绿=成功 / 红=失败,pascal_case_tool_name 加粗,参数预览 + +对比其他项目: + opencode: 消息卡片左侧竖线,工具为文本 part + pi: toolPendingBg/toolSuccessBg/toolErrorBg 背景色块 + grok: 左侧 accent 竖线(accent_bar ┃),block 折叠/截断 + +演示:模拟一段 transcript,展示 acecode 的: + - 工具调用行 ● ToolName(args) + - 配对结果行(缩进 └ + 摘要) + - 三态指示灯(执行中灰 / 成功绿 / 失败红) + - 孤儿调用(无结果,保持灰) + - 并行调用 FIFO 配对 +""" + +import sys +import time +from _term import init, RESET, BOLD, DIM, fg, goto, CLEAR_LINE, HIDE_CURSOR, SHOW_CURSOR + +init() + +# 指示灯颜色(对齐 acecode 三态语义) +DOT_WORKING = fg(160, 160, 160) # 灰=执行中 +DOT_SUCCESS = fg(120, 200, 120) # 绿=成功 +DOT_FAILED = fg(230, 100, 100) # 红=失败 +TOOL_COLOR = fg(180, 140, 220) # syntax.preproc 紫 +SUMMARY = DIM +RESULT_PREFIX = " └ " + + +def tool_call(name, args, dot=DOT_WORKING): + return f"{dot}●{RESET} {TOOL_COLOR}{BOLD}{name}{RESET}{DIM}({args}){RESET}" + + +def tool_result(text, ok=True): + color = fg(160, 200, 160) if ok else fg(230, 130, 130) + return f"{RESULT_PREFIX}{color}{text}{RESET}" + + +def line(text): + sys.stdout.write(text + "\n") + + +def main(): + print(BOLD + "=== acecode 工具行 ● 三态指示灯(独有优势)===" + RESET) + print(DIM + "对齐 Claude Code 风格: ● ToolName(args) + 缩进 └ 结果摘要" + RESET) + print() + + print(BOLD + "[1] 三态指示灯" + RESET) + line(tool_call("Bash", "command=\"npm test\"", dot=DOT_WORKING) + " " + DIM + "← 执行中(灰)" + RESET) + time.sleep(0.3) + line(tool_result("3 passed", ok=True) + " " + DIM + "← 成功(绿)" + RESET) + line(tool_call("FileWrite", "path=src/main.cpp", dot=DOT_WORKING)) + time.sleep(0.3) + line(tool_result("Error: permission denied", ok=False) + " " + DIM + "← 失败(红)" + RESET) + line(tool_call("Grep", "pattern=TODO") + " " + DIM + "← 孤儿(无结果,保持灰)" + RESET) + print() + + print(BOLD + "[2] 并行调用 FIFO 配对(compute_tool_call_dots)" + RESET) + print(DIM + "三个并行读工具同时发出,结果按完成顺序回收,指示灯逐个变绿:" + RESET) + line(tool_call("FileRead", "path=a.cpp")) + line(tool_call("FileRead", "path=b.cpp")) + line(tool_call("FileRead", "path=c.cpp")) + # 模拟逐个完成 -- 但因为是 FIFO 配对,只有最前的那个能变绿 + time.sleep(0.4) + # 用回退光标重写第一行为成功 + sys.stdout.write("\x1b[4A") # 上移 4 行到第一个 FileRead + line(tool_call("FileRead", "path=a.cpp", dot=DOT_SUCCESS)) + sys.stdout.write("\x1b[3B") # 回到下面 + time.sleep(0.4) + print() + + print(BOLD + "[3] 参数预览与 task_complete" + RESET) + line(tool_call("TaskComplete", "summary=\"重构完成\"")) + time.sleep(0.3) + line(tool_result("● Done for 4.2s", ok=True)) + print() + + print(BOLD + "对比其他项目:" + RESET) + print(f" opencode: 消息卡片左侧 {fg(180,140,220)}┃{RESET} 竖线,工具是文本 part,无三态灯") + print(f" pi: toolPendingBg/SuccessBg/ErrorBg {fg(60,60,80)}背景色块{RESET},无指示灯") + print(f" grok: {fg(180,140,220)}┃{RESET} accent 竖线 + block 折叠/截断,无 FIFO 配对语义") + print() + print(BOLD + "acecode 这套是自创的紧凑 transcript 风格,信息密度高:" + RESET) + print(" - 指示灯三态一眼看出工具成败") + print(" - FIFO 配对让并行调用的「谁还没完成」一目了然") + print(" - Ctrl+O 全局展开 / Ctrl+E 逐行展开长输出") + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/09_streaming_markdown.py b/docs/tui-comparison/demos/09_streaming_markdown.py new file mode 100644 index 00000000..8a897c0b --- /dev/null +++ b/docs/tui-comparison/demos/09_streaming_markdown.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""09 - 流式 markdown 增量渲染基准 + +报告章节:3.2 +acecode 现状:❌ 整条重渲染 + redraw_pacer 限帧,无增量、无缓存层 +有此能力: + crush -- glamour "stable-prefix":缓存安全前缀,只渲染尾部 + grok-build -- StreamingMarkdownRenderer checkpoint 冻结,只渲染活跃 tail + opencode -- OpenTUI streaming={true} 增量解析 + pi -- 整条重解析但 cachedText/cachedWidth 缓存 + +本演示模拟流式逐 chunk 追加一个 markdown 文档,对比三种"渲染"策略的耗时: + [朴素] 每次整条重新"渲染"(逐字符高亮扫描 + 行折叠) -- 对应 acecode + [前缀缓存] 已稳定的块渲染一次后缓存,只渲染活跃尾块 -- 对应 crush + [checkpoint] 同上,块粒度冻结,只渲染活跃尾块 -- 对应 grok + +关键:渲染开销做成 O(块长度)(逐字符状态机扫描,模拟 tokenizer+高亮+Element 构造), +缓存方法用块计数 O(1) 跳过已冻结块,而非 O(n) 字符串比较。这样朴素法的 O(n²) 累计 +才会显出来。 +""" + +import sys +import time +from _term import init, RESET, BOLD, DIM, fg + +init() + +WIDTH = 80 + +# 模拟一个会不断增长的 markdown 文档(段落与代码块交替,每段较长) +PARA_TEXT = ( + "这是一段模拟的 markdown 段落,描述某次工具调用的结果与中间推理过程。" + "包含若干行文字与代码引用,用于测量「渲染」工作量。流式场景下会逐 chunk 追加," + "每段约两百字,渲染时需逐字符做词法扫描与高亮状态机推进,再折行输出。" +) +CODE_TEXT = """```python +def render(doc: str, width: int) -> list[str]: + out = [] + for raw in doc.split("\\n"): + out.extend(wrap(raw, width)) + return out +```""" + + +def build_doc(n_chunks): + """构造 n_chunks 段的文档(段落与代码块交替,\\n\\n 分块)。""" + parts = [] + for i in range(n_chunks): + parts.append(f"### 第 {i+1} 段\n\n{PARA_TEXT}\n") + if i % 3 == 0: + parts.append(CODE_TEXT + "\n") + return "\n\n".join(parts) + + +# ---- "渲染"模拟:逐字符高亮扫描(状态机)+ 行折叠 ---- + +def render_block(block_text): + """渲染单个块:逐字符推进高亮状态机 + 折行。O(块长度)。""" + out = [] + for line in block_text.split("\n"): + # 逐字符词法扫描(模拟 tokenizer + 高亮,这是主要开销) + state = 0 + for ch in line: + state = (state * 131 + ord(ch)) & 0x7FFFFFFF + # 折行 + if not line: + out.append("") + continue + tokens = line.replace("`", " ` ").split() + cur = "" + for tok in tokens: + if len(cur) + len(tok) + 1 > WIDTH: + out.append(cur) + cur = tok + else: + cur = (cur + " " + tok) if cur else tok + if cur: + out.append(cur) + return out + + +# ---- 三种策略 ---- + +class NaiveRenderer: + """acecode 现状:整条重渲染。每次都渲染所有块。""" + + def render(self, text): + blocks = text.split("\n\n") + out = [] + for b in blocks: + out.extend(render_block(b)) + out.append("") # 块间空行 + return out + + +class StablePrefixRenderer: + """crush stable-prefix:已稳定的块渲染一次缓存,只渲染活跃尾块。 + + 文本只增不减,所以"除最后一块外"都是稳定块。用块计数跟踪,避免 O(n) 字符串比较。 + """ + + def __init__(self): + self.frozen_lines = [] # 已缓存的前缀渲染行 + self.frozen_block_count = 0 # 已冻结的块数 + + def render(self, text): + blocks = text.split("\n\n") + n = len(blocks) + out = list(self.frozen_lines) + # 新稳定化的块:之前是"活跃尾块",现在变成稳定块,渲染并冻结 + while self.frozen_block_count < n - 1: + idx = self.frozen_block_count + self.frozen_lines.extend(render_block(blocks[idx])) + self.frozen_lines.append("") + self.frozen_block_count += 1 + # 只渲染活跃尾块(最后一块) + if n > 0: + out = list(self.frozen_lines) + out.extend(render_block(blocks[-1])) + out.append("") + return out + + +class CheckpointRenderer: + """grok checkpoint:块粒度冻结,语义同 StablePrefix(本模拟中块即 checkpoint)。""" + + def __init__(self): + self.frozen_lines = [] + self.frozen_block_count = 0 + + def render(self, text): + blocks = text.split("\n\n") + n = len(blocks) + while self.frozen_block_count < n - 1: + idx = self.frozen_block_count + self.frozen_lines.extend(render_block(blocks[idx])) + self.frozen_lines.append("") + self.frozen_block_count += 1 + out = list(self.frozen_lines) + if n > 0: + out.extend(render_block(blocks[-1])) + out.append("") + return out + + +def benchmark(): + print(BOLD + "=== 流式 markdown 增量渲染基准 ===" + RESET) + print(DIM + "模拟逐 chunk 追加文档,测量每次「渲染」的耗时(微秒)" + RESET) + print(DIM + "渲染开销 = 逐字符高亮状态机扫描 + 行折叠(O(块长度))" + RESET) + print() + print(f" {'chunk':<7} {'朴素(acecode)':<20} {'前缀缓存(crush)':<20} {'checkpoint(grok)':<20} {'朴素/缓存'}") + print(f" {'-'*7} {'-'*20} {'-'*20} {'-'*20} {'-'*10}") + + naive = NaiveRenderer() + prefix = StablePrefixRenderer() + checkpoint = CheckpointRenderer() + + total_naive = total_prefix = total_ckpt = 0 + n_chunks = 60 + + for i in range(1, n_chunks + 1): + doc = build_doc(i) + + t0 = time.perf_counter_ns() + naive.render(doc) + t_naive = time.perf_counter_ns() - t0 + + t0 = time.perf_counter_ns() + prefix.render(doc) + t_prefix = time.perf_counter_ns() - t0 + + t0 = time.perf_counter_ns() + checkpoint.render(doc) + t_ckpt = time.perf_counter_ns() - t0 + + total_naive += t_naive + total_prefix += t_prefix + total_ckpt += t_ckpt + + if i % 10 == 0 or i == 1: + ratio = t_naive / max(t_prefix, 1) + print(f" {i:<7} {t_naive/1000:>12.1f} µs {t_prefix/1000:>12.1f} µs " + f"{t_ckpt/1000:>12.1f} µs {ratio:>6.1f}x") + + print() + print(BOLD + "累计耗时(60 次流式更新):" + RESET) + print(f" 朴素(acecode) : {total_naive/1e6:>8.2f} ms") + print(f" 前缀缓存(crush) : {total_prefix/1e6:>8.2f} ms " + f"({total_naive/max(total_prefix,1):.1f}x 加速)") + print(f" checkpoint(grok) : {total_ckpt/1e6:>8.2f} ms " + f"({total_naive/max(total_ckpt,1):.1f}x 加速)") + print() + print(BOLD + "结论:" + RESET) + print(" 朴素法每次渲染全部块,文档越长单次开销越大,累计 O(n²)。") + print(" 缓存法只渲染活跃尾块(O(1) 块/次),稳定块渲染一次后冻结,累计 O(n)。") + print(" → acecode 的 format_markdown 每次流式更新整条重渲染 + 重建 FTXUI Element,") + print(" 长输出时 CPU 开销随文档长度线性增长,这是最值得补的差距(抄 crush/grok)。") + print() + print(DIM + "注:绝对数值随机器/Python 版本变化,重点是朴素法随 chunk 增长的斜率" + RESET) + print(DIM + " vs 缓存法的平坦斜率。实际 acecode 的 Element 构造开销更高,差距更大。" + RESET) + + +if __name__ == "__main__": + benchmark() diff --git a/docs/tui-comparison/demos/README.md b/docs/tui-comparison/demos/README.md new file mode 100644 index 00000000..784726e3 --- /dev/null +++ b/docs/tui-comparison/demos/README.md @@ -0,0 +1,78 @@ +# TUI 对比效果演示 + +配合 [`../report.md`](../report.md) 的可运行效果演示。每个脚本自包含,演示一个终端效果,并标注**哪些项目有、acecode 现状如何**。 + +## 运行环境 + +- Python 3.8+(Windows 自带 3.12 可用) +- 推荐终端:**Windows Terminal** / kitty / WezTerm / iTerm2 / Ghostty +- 老版 cmd.exe / conhost 部分效果(盲文、truecolor)会降级 + +## 运行 + +```bash +cd docs/tui-comparison/demos +python 01_synchronized_output.py +python 02_osc8_hyperlinks.py +python 03_kitty_keyboard.py # 交互式,按 q 退出 +python 04_alpha_transparency.py +python 05_animated_background.py # 动画,Ctrl+C 退出 +python 06_spinner_showcase.py # 动画,Ctrl+C 退出 +python 07_osc133_prompts.py +python 08_tool_row_dots.py +python 09_streaming_markdown.py +``` + +或一键跑非交互的(1/2/4/7/8/9): + +```bash +python run_static.py +``` + +## 演示与报告对照 + +| 演示 | 演示的效果 | 报告章节 | acecode 现状 | +|---|---|---|---| +| `01_synchronized_output` | CSI 2026 同步输出消除闪烁 | 3.1 / 3.6 | ❌ 无,FTXUI 光标回退 | +| `02_osc8_hyperlinks` | OSC 8 可点击超链接 | 3.6 | ⚠️ 有检测代码但不发射(FTXUI 限制) | +| `03_kitty_keyboard` | kitty keyboard 协议键码 | 3.6 | ❌ 仅消费标准 CSI 修饰符 | +| `04_alpha_transparency` | RGBA alpha 混色(透明叠层) | 3.1 / 3.4 | ❌ FTXUI 颜色不透明 | +| `05_animated_background` | 正弦呼吸动画背景 | 3.4 | ❌ 无 | +| `06_spinner_showcase` | 五种 spinner 同屏对比 | 3.4 | ❌ 仅 compact_animation | +| `07_osc133_prompts` | OSC 133 prompt 语义标记 | 3.6 | ❌ 无 | +| `08_tool_row_dots` | `●` 三态指示灯 | 二 / 四.3 | ✅ acecode 独有优势 | +| `09_streaming_markdown` | 流式 markdown 增量渲染基准 | 3.2 | ❌ 整条重渲染 | + +## 各演示说明 + +### 01_synchronized_output.py +**CSI 2026 同步输出**(Begin/End Synchronized Update)。快速重绘一个 10 行块,先不加同步(可见闪烁/撕裂),再加 `\x1b[?2026h...\x1b[?2026l` 包裹(平滑)。pi 和 grok-build 用此协议;acecode 的 FTXUI 用 `\033[1A` 光标回退,无同步输出。 + +### 02_osc8_hyperlinks.py +**OSC 8 超链接**:`\x1b]8;;URL\x07text\x1b]8;;\x07`,在支持的终端里 Ctrl/Cmd+Click 打开。acecode 的 `markdown_formatter.cpp:78` 有 `terminal_supports_hyperlinks()` 检测,但 `make_hyperlink()` 因 *"FTXUI Elements 无法内嵌 OSC 8"* 只返回纯文本 -- 链接不可点击。本演示对比"可点击链接" vs "acecode 的纯色文本"。 + +### 03_kitty_keyboard.py +**kitty keyboard 协议**:启用后 Shift+Enter 报 `\x1b[13;2u` 而非裸 `\r`,修饰键/释放事件都可区分。交互式捕获按键并显示原始字节。opencode/pi/grok 启用;acecode 未启用(仅消费 `\x1B[1;3A` 等标准序列)。**注意:Windows Terminal 目前不支持 kitty 协议**,在 WT 上会回退到标准序列(这本身就是演示的一部分)。 + +### 04_alpha_transparency.py +**RGBA alpha 混色**:ANSI 无法逐 cell 设 alpha,opencode/grok 在 framebuffer 层做 per-pixel 合成(半透明遮罩、透出终端背景的 system 主题)。本演示用**预混色**模拟视觉效果:把叠层颜色与背景色按 alpha 混合后输出,对比 acecode 的不透明色块。 + +### 05_animated_background.py +**正弦呼吸动画背景**:`sin²(t)` 驱动背景亮度循环,模拟 opencode `BgPulse`(呼吸光圈)与 grok `wave_brightness`(跨行相位波)。acecode 无背景动画。 + +### 06_spinner_showcase.py +**五种 spinner 同屏**:acecode `●`(静态点)、grok braille `⠋⠙⠹⠸⠼⠴⠦⠧`、grok dot `⋅ : ⸬ ⁙`、grok monitor `○ ◎ ◉ ◎`、opencode Knight Rider 扫描光带(逐像素 alpha 渐变拖尾)。 + +### 07_osc133_prompts.py +**OSC 133 prompt 语义标记**:`\x1b]133;A\x07`(prompt 开始)/ `B`(输出开始)/ `C`(输出结束)。支持的终端(kitty/WezTerm)可 Cmd+Shift+↑/↓ 在 prompt 间跳转。pi 用此做 transcript 导航;acecode 无。 + +### 08_tool_row_dots.py +**acecode 独有优势**:` ● ToolName(args)` 三态指示灯(灰=执行中/绿=成功/红=失败),`compute_tool_call_dots` FIFO 配对 tool_call ↔ tool_result。演示 acecode 的紧凑 transcript 风格,与其他项目的纯文本/accent 竖线对比。 + +### 09_streaming_markdown.py +**流式 markdown 增量渲染基准**:模拟流式逐 chunk 追加文档,对比三种方法的耗时: +- 朴素法(acecode):每 chunk 整条重渲染 +- stable-prefix(crush):缓存安全前缀,只渲染尾部 +- checkpoint 冻结(grok):冻结已稳定块,只渲染活跃 tail + +展示 acecode 在长输出时的 CPU 开销差距。 diff --git a/docs/tui-comparison/demos/_term.py b/docs/tui-comparison/demos/_term.py new file mode 100644 index 00000000..3f312e6a --- /dev/null +++ b/docs/tui-comparison/demos/_term.py @@ -0,0 +1,188 @@ +"""Shared terminal helpers for TUI comparison demos. + +跨平台 ANSI 启用 + 原始输入模式(交互演示用)。 +所有演示 import 本模块以获得统一的颜色/光标/协议常量与清理逻辑。 +""" + +import os +import sys +import ctypes +import contextlib + + +# --------------------------------------------------------------------------- +# Windows: 启用 VT 处理 + UTF-8 输出 +# --------------------------------------------------------------------------- + +def enable_vt(): + """Windows 上开启 ENABLE_VIRTUAL_TERMINAL_PROCESSING + UTF-8 输出 CP。 + + 不开启时,ANSI 转义序列会被原样打印(看到一堆 \\x1b[...),且盲文/box-drawing + 字符在非 UTF-8 代码页下会乱码。grok-build 的 configure_windows_console() 做同样的事。 + """ + if os.name != 'nt': + return + try: + kernel32 = ctypes.windll.kernel32 + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + # STD_OUTPUT_HANDLE = -11, STD_ERROR_HANDLE = -12 + for handle in (-11, -12): + h = kernel32.GetStdHandle(handle) + mode = ctypes.c_uint32() + if kernel32.GetConsoleMode(h, ctypes.byref(mode)): + kernel32.SetConsoleMode(h, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING) + kernel32.SetConsoleOutputCP(65001) # CP_UTF8 + except Exception: + pass + + +# --------------------------------------------------------------------------- +# ANSI / 终端协议常量 +# --------------------------------------------------------------------------- + +RESET = "\x1b[0m" +BOLD = "\x1b[1m" +DIM = "\x1b[2m" +ITALIC = "\x1b[3m" +UNDERLINE = "\x1b[4m" + +CLEAR = "\x1b[2J\x1b[H" +CLEAR_LINE = "\x1b[2K" + +HIDE_CURSOR = "\x1b[?25l" +SHOW_CURSOR = "\x1b[?25h" + +ALT_SCREEN = "\x1b[?1049h" +MAIN_SCREEN = "\x1b[?1049l" + +# CSI 2026 同步输出(Begin/End Synchronized Update) -- pi / grok 用 +SYNC_BEGIN = "\x1b[?2026h" +SYNC_END = "\x1b[?2026l" + +# kitty keyboard 协议 -- push flags 并设置(Ps=flags); pop 恢复 +# flags: 1=disambiguate, 2=event-type, 4=report-all-keys, 8=alternate-keys +KITTY_PUSH = "\x1b[>15u" # 1|2|4|8 +KITTY_POP = "\x1b[ str: + import base64 + return f"\x1b]52;c;{base64.b64encode(text.encode('utf-8')).decode()}\x07" + + +# --------------------------------------------------------------------------- +# 颜色辅助(truecolor) +# --------------------------------------------------------------------------- + +def fg(r, g, b): + return f"\x1b[38;2;{r};{g};{b}m" + +def bg(r, g, b): + return f"\x1b[48;2;{r};{g};{b}m" + +def fg256(n): + return f"\x1b[38;5;{n}m" + +def bg256(n): + return f"\x1b[48;5;{n}m" + +def goto(row, col): + return f"\x1b[{row};{col}H" + +def up(n=1): + return f"\x1b[{n}A" + +def down(n=1): + return f"\x1b[{n}B" + + +def blend(c1, c2, t): + """线性混色(模拟 alpha 合成):t=0 全 c1,t=1 全 c2。 + + opencode/grok 的 RGBA 透明在 framebuffer 层做 per-pixel alpha 合成; + ANSI 无法逐 cell 设 alpha,这里用预混色模拟视觉效果(acecode 的 FTXUI 做不到)。 + """ + return tuple(round(c1[i] + (c2[i] - c1[i]) * t) for i in range(3)) + + +# --------------------------------------------------------------------------- +# 原始输入模式(交互演示用,跨平台) +# --------------------------------------------------------------------------- + +@contextlib.contextmanager +def raw_mode(): + """进入原始输入模式(cbreak),退出时恢复。 + + Windows: SetConsoleMode 清除 ECHO/LINE_INPUT/PROCESSED_INPUT, + 开启 ENABLE_VIRTUAL_TERMINAL_INPUT(让修饰键组合以 VT 序列上报)。 + 这正是 pi 的 win32-console-mode.node 做的事。 + POSIX: termios tty.setcbreak。 + """ + if os.name == 'nt': + kernel32 = ctypes.windll.kernel32 + STD_INPUT_HANDLE = -10 + ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200 + ENABLE_ECHO_INPUT = 0x0004 + ENABLE_LINE_INPUT = 0x0002 + ENABLE_PROCESSED_INPUT = 0x0001 + h = kernel32.GetStdHandle(STD_INPUT_HANDLE) + old = ctypes.c_uint32() + kernel32.GetConsoleMode(h, ctypes.byref(old)) + new = old.value & ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT) + new |= ENABLE_VIRTUAL_TERMINAL_INPUT + kernel32.SetConsoleMode(h, new) + try: + yield + finally: + kernel32.SetConsoleMode(h, old.value) + sys.stdout.write(RESET + SHOW_CURSOR) + sys.stdout.flush() + else: + import termios + import tty + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setcbreak(fd) + yield + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + sys.stdout.write(RESET + SHOW_CURSOR) + sys.stdout.flush() + + +def read_key(): + """读一个按键(可能多字节),返回原始字节字符串。非阻塞超时返回 b''。""" + import select + ch = sys.stdin.buffer.read(1) + if not ch: + return b'' + # 读余下的转义序列(非阻塞,短超时) + seq = ch + while True: + r, _, _ = select.select([sys.stdin], [], [], 0.02) + if not r: + break + c = sys.stdin.buffer.read(1) + if not c: + break + seq += c + return seq + + +# --------------------------------------------------------------------------- +# 公共入口:每个 demo 调用一次 +# --------------------------------------------------------------------------- + +def init(): + enable_vt() + # 确保 stdout 用 UTF-8(Windows 上 sys.stdout 可能是 cp936) + if os.name == 'nt': + try: + sys.stdout.reconfigure(encoding='utf-8') + except Exception: + pass diff --git a/docs/tui-comparison/demos/run_static.py b/docs/tui-comparison/demos/run_static.py new file mode 100644 index 00000000..ef62feae --- /dev/null +++ b/docs/tui-comparison/demos/run_static.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""批量运行非交互演示(01/02/04/07/08/09)。 + +动画类(05/06)和交互类(03)需单独跑。每个演示之间暂停按回车继续。 +""" + +import subprocess +import sys +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) + +DEMOS = [ + ("01_synchronized_output.py", "CSI 2026 同步输出对比"), + ("02_osc8_hyperlinks.py", "OSC 8 可点击超链接"), + ("04_alpha_transparency.py", "RGBA alpha 透明混色"), + ("07_osc133_prompts.py", "OSC 133 prompt 标记"), + ("08_tool_row_dots.py", "acecode ● 三态指示灯"), + ("09_streaming_markdown.py", "流式 markdown 增量渲染基准"), +] + + +def main(): + for script, desc in DEMOS: + path = os.path.join(HERE, script) + print("\n" + "=" * 70) + print(f" {desc}") + print("=" * 70) + try: + subprocess.run([sys.executable, path], cwd=HERE, check=False) + except Exception as e: + print(f" [运行失败] {e}") + if script != DEMOS[-1][0]: + try: + input("\n按回车继续下一个演示(Ctrl+C 中止)...") + except (KeyboardInterrupt, EOFError): + print() + break + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/report.md b/docs/tui-comparison/report.md new file mode 100644 index 00000000..cb710c31 --- /dev/null +++ b/docs/tui-comparison/report.md @@ -0,0 +1,185 @@ +# ACECode TUI 与同类项目对比调研报告 + +> 调研日期:2026-08-13 +> 调研范围:`D:\dev` 下 5 个具备终端 TUI 的 AI 编程工具: +> **acecode**(本项目,C++ + FTXUI)、**opencode**(TS + OpenTUI)、**pi**(TS + 自研 pi-tui)、**crush**(Go + Bubble Tea v2)、**grok-build**(Rust + ratatui)。 +> `agent`(纯笔记)、`openchamber` / `pi-web`(web/desktop,无 TUI)不参与对比。 +> +> 配套可运行效果演示见 [`demos/`](./demos/README.md)。 + +--- + +## 一、技术栈速览 + +| 项目 | 语言 | TUI 框架 | 渲染模型 | Markdown / 语法高亮 | +|---|---|---|---|---| +| **acecode** | C++ | **FTXUI** | 立即模式组件树,每帧重算 → Screen → ANSI | 自研 `markdown_formatter` + 内置 `syntax_highlight` | +| **opencode** | TS/bun | **OpenTUI**(`@opentui/core`+`solid`) | retained framebuffer(`Uint16Array` 逐像素)+ Yoga flexbox + 原生 FFI | OpenTUI `` + **tree-sitter WASM 运行时下载** | +| **pi** | TS | **自研 pi-tui** | 组件树每帧渲染成文本行数组 → **逐行字符串 diff** → CSI 2026 同步输出 | `marked` + highlight.js + LaTeX Unicode | +| **crush** | Go | **Bubble Tea v2** + lipgloss | 命令/更新/视图,`View()` 返回整串,tea 内部行 diff | **glamour v2 + stable-prefix 增量缓存** | +| **grok-build** | Rust | **ratatui** + crossterm | 立即模式 + 双缓冲 **cell 级 diff** | 自研 `xai-grok-markdown` + checkpoint 冻结 + syntect | + +--- + +## 二、本项目(acecode)TUI 实现特征 + +- **立即模式 + FTXUI 组件树**:`make_screen_interactive`(`src/tui/render_mode.cpp`)按 `tui.alt_screen_mode`(auto/always/never)在 `Fullscreen()`(alt-screen `\033[?1049h`)与 `TerminalOutput()` 间二选一;`run_tui_loop`(`src/tui/tui_init.cpp:296`)直接 `screen.Loop(renderer)`,bracketed paste 包裹。 +- **布局**:header(ACE block-art logo + 版本/模型/cwd)+ 右侧 sidebar(会话列表)+ chat transcript + input box + status line。 +- **Markdown**:自研 `format_markdown`(`src/markdown/markdown_formatter.cpp`)输出 FTXUI `Element`,有 `syntax_highlight`(按语言名内置表,`src/markdown/syntax_highlight.cpp`),theme 有 `MarkdownColors`(code_span/link/bold/heading/block_code…)。**流式时整条重渲染**,靠 `redraw_pacer`(`src/tui/redraw_pacer.cpp`)帧率限制 + `tool_result_fold` 折叠控量。 +- **工具行渲染**:`● ToolName(args)` 三态指示灯(灰=执行中 / 绿=成功 / 红=失败)、`compute_tool_call_dots` FIFO 配对、`pascal_case_tool_name`、Ctrl+O 全局展开 / Ctrl+E 逐行、`thinking_heartbeat` 内联 `[Ns · ↓ X tokens]`。 +- **跨平台兜底**:`detect_terminal_capabilities` 读 ConEmuPID/WT_SESSION/Windows build,`should_use_conhost_compat_layout` 在老 conhost 上退 ASCII 边框、去 logo —— 本项目相对其他项目**最深入的老 Windows 终端兼容**。 +- **剪贴板/鼠标**:FTXUI mouse tracking + 右键 OSC 52 复制 + Ctrl+V 读系统剪贴板(`src/utils/clipboard.*`)。 + +--- + +## 三、关键差异分维度对比 + +### 3.1 渲染管线(差异最大) + +| | 模型 | 同步输出 | alpha 透明 | +|---|---|---|---| +| **acecode** | FTXUI 立即模式,Screen diff,光标回退 `\033[1A` | ❌ 无 | ❌ FTXUI 颜色不透明 | +| **opencode** | retained framebuffer 逐像素 + Yoga flexbox | (OpenTUI 原生层) | ✅ RGBA alpha 混色 | +| **pi** | 文本行数组 + 逐行字符串 diff | ✅ CSI 2026 | ❌(整行不透明) | +| **crush** | Bubble Tea `View()` 返回整串,tea 行 diff | (Bubble Tea 内部) | ❌ | +| **grok-build** | ratatui 双缓冲 cell diff + 后台写线程 | ✅ CSI 2026 | ✅(framebuffer 合成) | + +- **grok 独有**:改写 ratatui draw 循环以**保留光标闪烁**(去重 `Show`/`MoveTo`),渲染到 **stderr** + **后台写线程**解耦事件循环与 pty 背压;acecode 无此层。 +- **pi 独有**:`TuiMainScreen`(主缓冲 + 终端 scrollback)与 `TuiAltScreen`(固定视口 + 应用自持滚动)双模式,逐行 diff + `maxLinesRendered` 高水位。 +- **acecode**:FTXUI 默认刷新无同步输出协议,老终端上 banner 堆叠靠 alt-screen 兜底(CLAUDE.md 记录的 `\033[?1049h` workaround)。 + +### 3.2 流式 Markdown 渲染(acecode 明显偏弱) + +| | 做法 | +|---|---| +| acecode | 整条重渲染 + `redraw_pacer` 限帧 | +| **crush** | glamour "stable-prefix" 增量缓存 —— 只重渲染安全边界之后的尾部(`internal/ui/chat/streaming_markdown.go`) | +| **grok** | `StreamingMarkdownRenderer` checkpoint 冻结 tail,`(width,generation,theme)` 键控换行缓存(`xai-grok-markdown/src/streaming.rs`) | +| **opencode** | OpenTUI `streaming={true}` 增量解析 + SSE 16ms 批量合并成单次渲染 | +| **pi** | 整条重解析但 markdown `cachedText/cachedWidth` 缓存 + `trimPartialClosingFences` 防抖 | + +→ acecode 在长 markdown 流式时 CPU 开销最高(无增量、无缓存层)。见 [`demos/09_streaming_markdown.py`](./demos/09_streaming_markdown.py)。 + +### 3.3 代码语法高亮 + +| | 方案 | 依赖 | +|---|---|---| +| acecode | 内置语言表 `syntax_highlight.hpp` | **离线、零下载** | +| **opencode** | **运行时从 GitHub 下载 tree-sitter WASM + nvim-treesitter 高亮查询**(约 30 语言) | 网络依赖,能力最强 | +| grok | syntect + 内置 tmTheme,按终端色彩能力 quantize,16 色下双极性安全降级 | 离线 | +| pi | highlight.js,`supportsLanguage` 为真才高亮否则整块单色 | 离线 | + +### 3.4 视觉特效(acecode 基本没有) + +| 效果 | acecode | opencode | pi | grok | +|---|---|---|---|---| +| 逐帧动画背景 | ❌ | ✅ `BgPulse` 正弦呼吸光圈 + GO logo 高光扫动 | ❌ | ✅ `wave_brightness` sin² 波 + `pulse_brightness` | +| RGBA 透明/混色 | ❌ | ✅ 半透明对话框遮罩、透出终端的 system 主题 | ❌ | ✅ framebuffer 合成 | +| Knight Rider 扫描 spinner | ❌ | ✅ 逐像素 alpha 渐变拖尾 | ❌ | ❌ | +| braille/dot/monitor spinner | ❌ | ❌ | ✅ loader 盲文帧 | ✅ 三套帧集 | +| 内联图片 | ❌ | ❌ | ✅ Kitty/iTerm2 像素图 | ✅ Kitty/iTerm2 | +| 内联视频 | ❌ | ❌ | ❌ | ✅ ffmpeg 抽帧 | +| Mermaid 终端渲染 | ❌ | ❌ | ❌ | ✅ Unicode box-drawing | +| 工具行 `●` 三态指示灯 | ✅(独有风格) | 文本 | 文本 | accent 竖线 | + +→ 见 [`demos/05_animated_background.py`](./demos/05_animated_background.py)、[`demos/06_spinner_showcase.py`](./demos/06_spinner_showcase.py)、[`demos/04_alpha_transparency.py`](./demos/04_alpha_transparency.py)、[`demos/08_tool_row_dots.py`](./demos/08_tool_row_dots.py)。 + +### 3.5 输入编辑器 + +| | 能力 | +|---|---| +| acecode | `input_history_navigation`、`path_reference_input`、`paste_handler`、`ime_windows`;**无 extmark、无输入内高亮、无 leader key** | +| **opencode** | OpenTUI `TextareaRenderable` —— minHeight/maxHeight 自增高、**输入框内语法高亮**、**extmark 虚拟文本**(Neovim 式 `[Image 1]`/`[Pasted ~N lines]`/agent 标签)、IME 组合、shell `!` 前缀、keymap mode stack + **leader key + chord** | +| **pi** | 自研 editor —— Emacs 风格移动、undo stack、kill-ring、slash/路径自动补全、bracketed paste 大粘贴压缩、CJK 断行(`Intl.Segmenter`)、**IME APC 光标定位** | +| **grok** | `xai-ratatui-textarea` grapheme 编辑 + Vim/Simple 双模式 + 圆角 PromptWidget | + +### 3.6 终端协议(acecode 的核心差距) + +| 协议 | acecode | opencode | pi | crush | grok | +|---|---|---|---|---|---| +| **CSI 2026 同步输出** | ❌ | (原生层) | ✅ | (tea 内部) | ✅ | +| **OSC 8 超链接** | ⚠️ 有检测代码但**不发射**(FTXUI Element 无法内嵌,`make_hyperlink` 返回纯文本) | ✅ | ✅ | ❌ | ✅ 深度集成(路径/URL 自动 linkify) | +| **OSC 133 prompt 标记** | ❌ | ❌ | ✅ | ❌ | ❌ | +| **kitty keyboard 协议** | ❌(仅消费标准 CSI 修饰符,未显式启用) | ✅ | ✅ | ❌ | ✅ | +| **modifyOtherKeys** | ⚠️ 消费 `\x1B[1;3A` 等标准序列,未显式启用 | (OpenTUI) | ⚠️ 回退方案 | (tea) | (crossterm) | + +**acecode OSC 8 现状**(已核实源码):`src/markdown/markdown_formatter.cpp:78` 有 `terminal_supports_hyperlinks()` 检测 WT_SESSION/TERM_PROGRAM(iTerm/WezTerm/vscode/xterm),但 `make_hyperlink()` 注释明确写 *"This only works at the terminal level, not in FTXUI Elements. We'll just return the display text since FTXUI handles rendering."* —— 即检测到支持也只渲染纯色文本,**链接不可点击**。这是 FTXUI Element 网格模型的架构限制,非简单 bug。 + +→ 见 [`demos/01_synchronized_output.py`](./demos/01_synchronized_output.py)、[`demos/02_osc8_hyperlinks.py`](./demos/02_osc8_hyperlinks.py)、[`demos/03_kitty_keyboard.py`](./demos/03_kitty_keyboard.py)、[`demos/07_osc133_prompts.py`](./demos/07_osc133_prompts.py)。 + +### 3.7 跨平台 / 终端处理 + +| | Windows 处理 | 终端探测 | +|---|---|---| +| acecode | conhost compat 布局(ASCII 边框去 logo)+ alt-screen 兜底;**无 UTF-8 CP 强制、无 VTP 显式开启** | ConEmuPID/WT_SESSION/Windows build | +| **grok** | `SetConsoleMode` 给 **stderr** 开 VTP + `SetConsoleOutputCP(CP_UTF8)`(否则盲文/box-drawing 乱码)、legacy ConHost 字形回退(`❯->>`、spinner->ASCII)、QuickEdit 原生选择保留 | DA2/XTVERSION 探测 **20+ 终端品牌** | +| **pi** | 原生 `win32-console-mode.node` 开 `ENABLE_VIRTUAL_TERMINAL_INPUT` + `isModifierPressed`(GetAsyncKeyState) | kitty keyboard 协商 + modifyOtherKeys 回退 | +| opencode | kernel32 FFI 清 `ENABLE_PROCESSED_INPUT` + 每 100ms 轮询 Ctrl+C guard | OpenTUI 原生层 | +| crush | Bubble Tea 自带 + catwalk 测试录制 | (tea 内部) | + +acecode 的 conhost compat 布局是**独门老终端兜底**,其他项目大多只测 Windows Terminal;但 acecode **没有 kitty keyboard、没有 UTF-8 CP 强制、没有终端品牌探测**。 + +### 3.8 主题系统 + +| | 方案 | +|---|---| +| acecode | dark/light 两套预置 `ThemePalette`(Ui/Diff/Syntax/Markdown/Semantic),不透明色 | +| **opencode** | 30+ JSON 主题 + 用户自定义 + **system 主题从终端调色板现场生成** + 亮暗自动探测 | +| **grok** | GrokNight/GrokDay/TokyoNight/RosePineMoon/OscuraMidnight/Auto + OSC 11 跟随终端背景 + 颜色能力量化降级 | +| **pi** | 60+ token、256/truecolor 自动切换、文件 watcher 热重载 | + +### 3.9 虚拟化 / 性能 + +| | 手段 | +|---|---| +| acecode | `redraw_pacer` 帧率限制 + `tool_result_fold` + `chat_render_window` | +| **grok** | `entry_layouts_cache` 条目高度缓存 + scrollback 虚拟化 + `SafeBuf` 防 resize 竞态越界 panic | +| **pi** | per-frame `renderCache` + render-churn bench 基准 | +| **opencode** | ScrollBoxRenderable 裁剪 + SSE 16ms batching + conceal 代码隐藏 | +| crush | stable-prefix glamour 缓存 + assistant_section_cache + prefix_cache | + +--- + +## 四、acecode 独有 / 仍占优的点 + +1. **单一 C++ 二进制、零运行时依赖** —— opencode 要下 tree-sitter WASM、pi 要装 node ≥22 + 原生 addon、grok 编 Rust。 +2. **老 Windows 终端(conhost/Cmder/ConEmu)兼容最扎实** —— conhost compat 布局 + alt-screen 兜底,其他项目大多只测 Windows Terminal。 +3. **`●` 工具调用行 + Ctrl+O/E 折叠语义** —— 本项目自创的紧凑 transcript 风格,信息密度高且与 Claude Code 对齐。 +4. **daemon + 桌面壳 + headless 多形态共用同一 TUI 渲染逻辑** —— 其他项目大多是单体 TUI。 + +--- + +## 五、acecode 最值得补的差距(按性价比排序) + +| # | 差距 | 难度 | 收益 | 参考实现 | +|---|---|---|---|---| +| 1 | **流式 markdown 增量渲染** | 中 | 高(长输出卡顿最明显) | crush stable-prefix / grok checkpoint | +| 2 | **CSI 2026 synchronized output** | 低 | 中(消除闪烁) | pi/grok | +| 3 | **OSC 8 超链接真正可用** | 高(需绕过 FTXUI Element 限制) | 中 | grok linkify;或在 FTXUI 外层 post-process | +| 4 | **kitty keyboard 协议** | 中 | 中(可靠 Shift+Enter/修饰键) | opencode/pi/grok | +| 5 | **OSC 133 prompt 标记** | 低 | 低(transcript 导航) | pi | +| 6 | **主题系统扩展**(用户 JSON + 调色板生成) | 中 | 中 | opencode/pi | +| 7 | **输入框 extmark + 输入内高亮** | 高 | 中 | opencode(FTXUI 限制大) | + +> 第 3、7 项受 FTXUI Element 网格模型限制较深,可能需要在 FTXUI 渲染后做 ANSI 后处理或局部绕过 FTXUI。 + +--- + +## 六、演示索引 + +所有演示为自包含 Python 脚本,在 Windows Terminal / kitty / WezTerm / iTerm2 下可直接运行: + +``` +cd docs/tui-comparison/demos +python 01_synchronized_output.py # CSI 2026 同步输出:闪烁对比 +python 02_osc8_hyperlinks.py # OSC 8 可点击超链接 +python 03_kitty_keyboard.py # kitty keyboard 协议(交互) +python 04_alpha_transparency.py # RGBA alpha 混色对比 +python 05_animated_background.py # 正弦呼吸动画背景 +python 06_spinner_showcase.py # 五种 spinner 同屏对比 +python 07_osc133_prompts.py # OSC 133 prompt 语义标记 +python 08_tool_row_dots.py # acecode ● 三态指示灯(本项目优势) +python 09_streaming_markdown.py # 流式 markdown 增量渲染基准 +``` + +详见 [`demos/README.md`](./demos/README.md)。 From 370481cfec90df7868d8261a908023a1cdc24f1c Mon Sep 17 00:00:00 2001 From: MobileWork User Date: Sat, 15 Aug 2026 00:24:00 +0800 Subject: [PATCH 02/16] docs: fold kimi-code and codex into TUI comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增两个竞品并纳入全部维度对比: - kimi-code: TS + @moonshot-ai/pi-tui(pi-tui fork),应用层特色为 gradient 品牌字 / Moon loader / agent swarm / goal queue / 30+ 对话框 - codex: Rust + 自 fork ratatui Terminal,scrollback-first 输出模型、 两区域流式提交动画 + 表格 holdback、CSI-u 键盘增强按终端调优、 100ms 终端探测、accent 按终端背景 alpha 自适应 报告重写为 7 项目对比(血缘归并为 5 条技术路线),差距表增至 8 项; 新增 demos/10_gradient_text.py(kimi 渐变品牌字演示)。 --- docs/tui-comparison/demos/10_gradient_text.py | 88 ++++++++ docs/tui-comparison/demos/README.md | 9 +- docs/tui-comparison/demos/run_static.py | 1 + docs/tui-comparison/report.md | 198 ++++++++++-------- 4 files changed, 204 insertions(+), 92 deletions(-) create mode 100644 docs/tui-comparison/demos/10_gradient_text.py diff --git a/docs/tui-comparison/demos/10_gradient_text.py b/docs/tui-comparison/demos/10_gradient_text.py new file mode 100644 index 00000000..d165457b --- /dev/null +++ b/docs/tui-comparison/demos/10_gradient_text.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""10 - kimi-code 渐变品牌字(gradientText) + +报告章节:3.4 +acecode 现状:❌ 无渐变,FTXUI 每 cell 单色 +有此能力:kimi-code -- theme/gradient-text.ts 的 gradientText(): + 逐字符在 fromHex→toHex 间插值,给每个字符单独 ANSI truecolor, + 配合 accentBias 让渐变在首尾之间偏折。 + +演示: + [A] Kimi 品牌风渐变(banner/logo 用) + [B] 多组渐变对比(不同起止色) + [C] accentBias 效果(渐变聚集在左侧) + 对照 acecode 的单色加粗文本(FTXUI 无法逐字符渐变) + +实现:gradientText 的做法与 ANSI truecolor 完全兼容(逐字符 38;2;r;g;b), +FTXUI 之所以做不到,是因为它的 Element 按"整段文本"上色,不暴露逐字符色。 +""" + +import sys +from _term import init, RESET, BOLD, DIM + +init() + + +def lerp(a, b, t): + return round(a + (b - a) * t) + + +def hex_to_rgb(h): + h = h.lstrip('#') + return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) + + +def gradient_text(text, from_hex, to_hex, accent_bias=1.0): + """复刻 kimi gradientText:逐字符插值 + accentBias 偏折。""" + chars = list(text) + n = len(chars) + if n <= 1: + return text + c1 = hex_to_rgb(from_hex) + c2 = hex_to_rgb(to_hex) + out = [] + for i, ch in enumerate(chars): + ratio = min(1.0, (i / (n - 1)) * accent_bias) + r, g, b = lerp(c1[0], c2[0], ratio), lerp(c1[1], c2[1], ratio), lerp(c1[2], c2[2], ratio) + out.append(f"\x1b[1m\x1b[38;2;{r};{g};{b}m{ch}") + return "".join(out) + RESET + + +def main(): + print(BOLD + "=== kimi-code 渐变品牌字(gradientText)===" + RESET) + print(DIM + "逐字符在 from→to 间插值 truecolor;kimi 用于 banner / 品牌元素" + RESET) + print() + + print(BOLD + "[A] Kimi 品牌风渐变:" + RESET) + print(" " + gradient_text("✦ Kimi CLI ✦", "#1a8fff", "#7c3aed")) + print(" " + gradient_text(" moonshot-ai / kimi-code ", "#ff5a5f", "#ffb347")) + print() + + print(BOLD + "[B] 多组渐变对比:" + RESET) + pairs = [ + ("Kimi 蓝→紫", "#1a8fff", "#7c3aed"), + ("青→绿", "#00d2ff", "#3a7bd5"), + ("粉→橙", "#f857a6", "#ff5858"), + ("绿→青", "#00b09b", "#96c93d"), + ] + for label, a, b in pairs: + print(f" {label:<12} {gradient_text('██████ gradient text ██████', a, b)}") + print() + + print(BOLD + "[C] accentBias 偏折(渐变聚集在左侧):" + RESET) + print(" bias=1.0 " + gradient_text("ACECode TUI Discovery", "#ff0000", "#0000ff", 1.0)) + print(" bias=0.4 " + gradient_text("ACECode TUI Discovery", "#ff0000", "#0000ff", 0.4)) + print() + + print(BOLD + "对照 acecode(FTXUI 单色加粗):" + RESET) + print(" " + f"\x1b[1m\x1b[38;2;90;140;255m✦ AceCode CLI ✦{RESET}") + print(DIM + " ↑ FTXUI Element 按整段文本上色,不暴露逐字符色,做不到渐变。" + RESET) + print() + print(BOLD + "实现原理:" + RESET) + print(" kimi 的 gradientText 用 chalk.hex().bold() 逐字符输出,ANSI truecolor 天然支持。") + print(" FTXUI 若要做,需在 markdown_formatter 输出 Element 之前把文本按字符拆成多个") + print(" text() 元素(每个带自己的颜色),或用自绘文本路径绕过 Element —— 类似 OSC 8 的坑。") + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/README.md b/docs/tui-comparison/demos/README.md index 784726e3..de1de12a 100644 --- a/docs/tui-comparison/demos/README.md +++ b/docs/tui-comparison/demos/README.md @@ -21,9 +21,10 @@ python 06_spinner_showcase.py # 动画,Ctrl+C 退出 python 07_osc133_prompts.py python 08_tool_row_dots.py python 09_streaming_markdown.py +python 10_gradient_text.py # kimi 渐变品牌字 ``` -或一键跑非交互的(1/2/4/7/8/9): +或一键跑非交互的(1/2/4/7/8/9/10): ```bash python run_static.py @@ -42,6 +43,7 @@ python run_static.py | `07_osc133_prompts` | OSC 133 prompt 语义标记 | 3.6 | ❌ 无 | | `08_tool_row_dots` | `●` 三态指示灯 | 二 / 四.3 | ✅ acecode 独有优势 | | `09_streaming_markdown` | 流式 markdown 增量渲染基准 | 3.2 | ❌ 整条重渲染 | +| `10_gradient_text` | kimi 渐变品牌字 | 3.4 | ❌ FTXUI 单色 | ## 各演示说明 @@ -76,3 +78,8 @@ python run_static.py - checkpoint 冻结(grok):冻结已稳定块,只渲染活跃 tail 展示 acecode 在长输出时的 CPU 开销差距。 + +### 10_gradient_text.py +**kimi-code 渐变品牌字**(`gradientText`):逐字符在 from→to 间插值 truecolor,配合 +accentBias 偏折,用于 Kimi banner/品牌元素。对照 acecode 的 FTXUI 单色加粗 +(Element 按整段上色,不暴露逐字符色)。 diff --git a/docs/tui-comparison/demos/run_static.py b/docs/tui-comparison/demos/run_static.py index ef62feae..6b985a10 100644 --- a/docs/tui-comparison/demos/run_static.py +++ b/docs/tui-comparison/demos/run_static.py @@ -17,6 +17,7 @@ ("07_osc133_prompts.py", "OSC 133 prompt 标记"), ("08_tool_row_dots.py", "acecode ● 三态指示灯"), ("09_streaming_markdown.py", "流式 markdown 增量渲染基准"), + ("10_gradient_text.py", "kimi 渐变品牌字"), ] diff --git a/docs/tui-comparison/report.md b/docs/tui-comparison/report.md index cb710c31..2b5f9af2 100644 --- a/docs/tui-comparison/report.md +++ b/docs/tui-comparison/report.md @@ -1,8 +1,10 @@ # ACECode TUI 与同类项目对比调研报告 -> 调研日期:2026-08-13 -> 调研范围:`D:\dev` 下 5 个具备终端 TUI 的 AI 编程工具: -> **acecode**(本项目,C++ + FTXUI)、**opencode**(TS + OpenTUI)、**pi**(TS + 自研 pi-tui)、**crush**(Go + Bubble Tea v2)、**grok-build**(Rust + ratatui)。 +> 调研日期:2026-08-15(第 2 版,纳入 kimi-code / codex) +> 调研范围:`D:\dev` 下 7 个具备终端 TUI 的 AI 编程工具: +> **acecode**(本项目,C++ + FTXUI)、**opencode**(TS + OpenTUI)、**pi**(TS + 自研 pi-tui)、 +> **kimi-code**(TS + @moonshot-ai/pi-tui 即 pi-tui fork)、**crush**(Go + Bubble Tea v2)、 +> **grok-build**(Rust + ratatui fork)、**codex**(Rust + ratatui fork)。 > `agent`(纯笔记)、`openchamber` / `pi-web`(web/desktop,无 TUI)不参与对比。 > > 配套可运行效果演示见 [`demos/`](./demos/README.md)。 @@ -16,136 +18,148 @@ | **acecode** | C++ | **FTXUI** | 立即模式组件树,每帧重算 → Screen → ANSI | 自研 `markdown_formatter` + 内置 `syntax_highlight` | | **opencode** | TS/bun | **OpenTUI**(`@opentui/core`+`solid`) | retained framebuffer(`Uint16Array` 逐像素)+ Yoga flexbox + 原生 FFI | OpenTUI `` + **tree-sitter WASM 运行时下载** | | **pi** | TS | **自研 pi-tui** | 组件树每帧渲染成文本行数组 → **逐行字符串 diff** → CSI 2026 同步输出 | `marked` + highlight.js + LaTeX Unicode | +| **kimi-code** | TS | **@moonshot-ai/pi-tui**(pi-tui 0.84.3 fork) | 同 pi(差分渲染 + 布局树 + CSI 2026) | 同 pi(marked + hljs),`highlight-theme.ts` 主题化 | | **crush** | Go | **Bubble Tea v2** + lipgloss | 命令/更新/视图,`View()` 返回整串,tea 内部行 diff | **glamour v2 + stable-prefix 增量缓存** | -| **grok-build** | Rust | **ratatui** + crossterm | 立即模式 + 双缓冲 **cell 级 diff** | 自研 `xai-grok-markdown` + checkpoint 冻结 + syntect | +| **grok-build** | Rust | **ratatui**(自 fork `xai-ratatui-inline`)+ crossterm | 立即模式 + 双缓冲 **cell 级 diff**,保留光标闪烁 + 后台写线程 | 自研 `xai-grok-markdown` + checkpoint 冻结 + syntect | +| **codex** | Rust | **ratatui**(自 fork `custom_terminal.rs`)+ crossterm | 立即模式 + 双缓冲 diff,自研 Terminal;**scrollback-first** 输出模型 | `pulldown-cmark` + **two-face/syntect**(~250 语言,32 主题) | + +> 血缘关系:`pi` ↔ `kimi-code` 共用同一 TUI 库(pi-tui);`grok-build` ↔ `codex` 都是"fork 了 ratatui Terminal"的 Rust 重写。七个项目实际只有 5 个独立技术路线。 --- ## 二、本项目(acecode)TUI 实现特征 -- **立即模式 + FTXUI 组件树**:`make_screen_interactive`(`src/tui/render_mode.cpp`)按 `tui.alt_screen_mode`(auto/always/never)在 `Fullscreen()`(alt-screen `\033[?1049h`)与 `TerminalOutput()` 间二选一;`run_tui_loop`(`src/tui/tui_init.cpp:296`)直接 `screen.Loop(renderer)`,bracketed paste 包裹。 +- **立即模式 + FTXUI 组件树**:`make_screen_interactive`(`src/tui/render_mode.cpp`)按 `tui.alt_screen_mode`(auto/always/never)在 `Fullscreen()`(alt-screen `\033[?1049h`)与 `TerminalOutput()`(scrollback 模式)间二选一;`run_tui_loop`(`src/tui/tui_init.cpp:296`)直接 `screen.Loop(renderer)`,bracketed paste 包裹。 - **布局**:header(ACE block-art logo + 版本/模型/cwd)+ 右侧 sidebar(会话列表)+ chat transcript + input box + status line。 -- **Markdown**:自研 `format_markdown`(`src/markdown/markdown_formatter.cpp`)输出 FTXUI `Element`,有 `syntax_highlight`(按语言名内置表,`src/markdown/syntax_highlight.cpp`),theme 有 `MarkdownColors`(code_span/link/bold/heading/block_code…)。**流式时整条重渲染**,靠 `redraw_pacer`(`src/tui/redraw_pacer.cpp`)帧率限制 + `tool_result_fold` 折叠控量。 -- **工具行渲染**:`● ToolName(args)` 三态指示灯(灰=执行中 / 绿=成功 / 红=失败)、`compute_tool_call_dots` FIFO 配对、`pascal_case_tool_name`、Ctrl+O 全局展开 / Ctrl+E 逐行、`thinking_heartbeat` 内联 `[Ns · ↓ X tokens]`。 -- **跨平台兜底**:`detect_terminal_capabilities` 读 ConEmuPID/WT_SESSION/Windows build,`should_use_conhost_compat_layout` 在老 conhost 上退 ASCII 边框、去 logo —— 本项目相对其他项目**最深入的老 Windows 终端兼容**。 -- **剪贴板/鼠标**:FTXUI mouse tracking + 右键 OSC 52 复制 + Ctrl+V 读系统剪贴板(`src/utils/clipboard.*`)。 +- **Markdown**:自研 `format_markdown`(`src/markdown/markdown_formatter.cpp`)输出 FTXUI `Element`,有 `syntax_highlight`(`src/markdown/syntax_highlight.cpp`,按语言名内置表),theme 有 `MarkdownColors`。**流式时整条重渲染**,靠 `redraw_pacer` 帧率限制 + `tool_result_fold` 折叠控量。 +- **工具行渲染**:`● ToolName(args)` 三态指示灯(灰/绿/红)、`compute_tool_call_dots` FIFO 配对、Ctrl+O/Ctrl+E 展开、`thinking_heartbeat` 内联 `[Ns · ↓ X tokens]`。 +- **跨平台兜底**:`detect_terminal_capabilities` 读 ConEmuPID/WT_SESSION/Windows build,`should_use_conhost_compat_layout` 在老 conhost 上退 ASCII 边框、去 logo —— **老 Windows 终端兼容最扎实**。 +- **剪贴板/鼠标**:FTXUI mouse tracking + 右键 OSC 52 复制 + Ctrl+V 读系统剪贴板。 --- -## 三、关键差异分维度对比 +## 三、关键差异分维度对比(7 项目) + +### 3.1 渲染管线 -### 3.1 渲染管线(差异最大) +| | 模型 | 同步输出 | 输出目标 | 独特机制 | +|---|---|---|---|---| +| **acecode** | FTXUI Screen diff,光标回退 `\033[1A` | ❌ | stdout | 双模式(alt-screen / TerminalOutput scrollback) | +| **opencode** | retained framebuffer + Yoga flexbox | (原生层) | 原生 FFI | 逐像素 RGBA framebuffer | +| **pi** | 文本行数组 + 逐行字符串 diff | ✅ CSI 2026 | stdout | main/alt 双渲染器,整帧原子写入 | +| **kimi-code** | 同 pi | ✅(同 pi) | stdout | 同 pi + agent swarm 应用层 | +| **crush** | Bubble Tea `View()` 行 diff | (tea 内部) | stdout | 命令/更新/视图模型 | +| **grok-build** | ratatui 双缓冲 cell diff | ✅ CSI 2026 | **stderr** + 后台写线程 | fork Terminal 保留光标闪烁 | +| **codex** | ratatui 双缓冲 cell diff(自 fork) | ✅ CSI 2026 | stdout | **scrollback-first**:已完成历史写真实终端 scrollback,视口只是底部小窗 | -| | 模型 | 同步输出 | alpha 透明 | -|---|---|---|---| -| **acecode** | FTXUI 立即模式,Screen diff,光标回退 `\033[1A` | ❌ 无 | ❌ FTXUI 颜色不透明 | -| **opencode** | retained framebuffer 逐像素 + Yoga flexbox | (OpenTUI 原生层) | ✅ RGBA alpha 混色 | -| **pi** | 文本行数组 + 逐行字符串 diff | ✅ CSI 2026 | ❌(整行不透明) | -| **crush** | Bubble Tea `View()` 返回整串,tea 行 diff | (Bubble Tea 内部) | ❌ | -| **grok-build** | ratatui 双缓冲 cell diff + 后台写线程 | ✅ CSI 2026 | ✅(framebuffer 合成) | +**codex scrollback-first 模型**(报告新增重点):`insert_history.rs` 把已完成的对话历史用转义序列直接写进**真实终端 scrollback**,ratatui 只负责底部一个小 viewport —— 每帧 diff 量极小。写入前做 **URL 感知预换行**(URL 行保持整行以便终端识别为可点击),检测 zellij 用专用 `ZellijRaw` 写入路径,resize 时按终端 scrollback 容量回灌重建(VS Code 1000 行 / WT 9001 / WezTerm 3500 / Alacritty 10000 上限),75ms 去抖合并拖拽 resize。 -- **grok 独有**:改写 ratatui draw 循环以**保留光标闪烁**(去重 `Show`/`MoveTo`),渲染到 **stderr** + **后台写线程**解耦事件循环与 pty 背压;acecode 无此层。 -- **pi 独有**:`TuiMainScreen`(主缓冲 + 终端 scrollback)与 `TuiAltScreen`(固定视口 + 应用自持滚动)双模式,逐行 diff + `maxLinesRendered` 高水位。 -- **acecode**:FTXUI 默认刷新无同步输出协议,老终端上 banner 堆叠靠 alt-screen 兜底(CLAUDE.md 记录的 `\033[?1049h` workaround)。 +> 与 acecode 对照:acecode 的 `TerminalOutput()` 模式同样是"历史进 scrollback"思路,但机制不同 —— FTXUI 用 `\033[1A` 光标回退重绘整个输出区,codex 是"写死历史 + 独立小视口"永不回退,滚动天然由终端负责,无需重绘。 ### 3.2 流式 Markdown 渲染(acecode 明显偏弱) | | 做法 | |---|---| | acecode | 整条重渲染 + `redraw_pacer` 限帧 | -| **crush** | glamour "stable-prefix" 增量缓存 —— 只重渲染安全边界之后的尾部(`internal/ui/chat/streaming_markdown.go`) | -| **grok** | `StreamingMarkdownRenderer` checkpoint 冻结 tail,`(width,generation,theme)` 键控换行缓存(`xai-grok-markdown/src/streaming.rs`) | -| **opencode** | OpenTUI `streaming={true}` 增量解析 + SSE 16ms 批量合并成单次渲染 | -| **pi** | 整条重解析但 markdown `cachedText/cachedWidth` 缓存 + `trimPartialClosingFences` 防抖 | +| **crush** | glamour "stable-prefix" 增量缓存,只渲染尾部 | +| **grok** | `StreamingMarkdownRenderer` checkpoint 冻结 tail + 键控换行缓存 | +| **opencode** | OpenTUI `streaming={true}` 增量解析 + SSE 16ms 批量 | +| **pi / kimi** | 整条重解析但 `cachedText/cachedWidth` 缓存 + `trimPartialClosingFences` 防抖 | +| **codex** | **两区域流式模型**:稳定区 → 逐行"提交动画"滚入 scrollback,尾区 → active cell;`MarkdownStreamCollector` 换行门控(表格/fenced 代码未闭合不提前渲染);**表格 holdback**(pipe 表格整表保持 mutable 直到流结束,避免加行改列宽重塑历史);`AdaptiveChunkingPolicy` 按队列压力选单行/批量提交 | -→ acecode 在长 markdown 流式时 CPU 开销最高(无增量、无缓存层)。见 [`demos/09_streaming_markdown.py`](./demos/09_streaming_markdown.py)。 +→ codex 的提交动画 + 表格 holdback 是最成熟的流式体验;acecode 差距最大。见 [`demos/09_streaming_markdown.py`](./demos/09_streaming_markdown.py)。 ### 3.3 代码语法高亮 -| | 方案 | 依赖 | +| | 方案 | 覆盖 | |---|---|---| -| acecode | 内置语言表 `syntax_highlight.hpp` | **离线、零下载** | -| **opencode** | **运行时从 GitHub 下载 tree-sitter WASM + nvim-treesitter 高亮查询**(约 30 语言) | 网络依赖,能力最强 | -| grok | syntect + 内置 tmTheme,按终端色彩能力 quantize,16 色下双极性安全降级 | 离线 | -| pi | highlight.js,`supportsLanguage` 为真才高亮否则整块单色 | 离线 | - -### 3.4 视觉特效(acecode 基本没有) - -| 效果 | acecode | opencode | pi | grok | -|---|---|---|---|---| -| 逐帧动画背景 | ❌ | ✅ `BgPulse` 正弦呼吸光圈 + GO logo 高光扫动 | ❌ | ✅ `wave_brightness` sin² 波 + `pulse_brightness` | -| RGBA 透明/混色 | ❌ | ✅ 半透明对话框遮罩、透出终端的 system 主题 | ❌ | ✅ framebuffer 合成 | -| Knight Rider 扫描 spinner | ❌ | ✅ 逐像素 alpha 渐变拖尾 | ❌ | ❌ | -| braille/dot/monitor spinner | ❌ | ❌ | ✅ loader 盲文帧 | ✅ 三套帧集 | -| 内联图片 | ❌ | ❌ | ✅ Kitty/iTerm2 像素图 | ✅ Kitty/iTerm2 | -| 内联视频 | ❌ | ❌ | ❌ | ✅ ffmpeg 抽帧 | -| Mermaid 终端渲染 | ❌ | ❌ | ❌ | ✅ Unicode box-drawing | -| 工具行 `●` 三态指示灯 | ✅(独有风格) | 文本 | 文本 | accent 竖线 | - -→ 见 [`demos/05_animated_background.py`](./demos/05_animated_background.py)、[`demos/06_spinner_showcase.py`](./demos/06_spinner_showcase.py)、[`demos/04_alpha_transparency.py`](./demos/04_alpha_transparency.py)、[`demos/08_tool_row_dots.py`](./demos/08_tool_row_dots.py)。 +| acecode | 内置语言表 `syntax_highlight.hpp` | 离线,内置 | +| **opencode** | tree-sitter WASM **运行时下载** + nvim-treesitter 查询 | ~30 语言,网络依赖 | +| **grok** | syntect + 内置 tmTheme,quantize + 双极性安全降级 | 离线 | +| **pi / kimi** | highlight.js,`supportsLanguage` 门控 | 离线;kimi 有 `highlight-theme.ts` 主题化 | +| **codex** | **two-face(~250 语言)+ 32 内置 .tmTheme + 用户自定义**,`convert_style` 跳过 italic/underline;护栏 >512KB/>10000 行/单行 >4KiB 跳过高亮 | 离线,最全 | + +### 3.4 视觉特效 + +| 效果 | acecode | opencode | pi | kimi-code | grok | codex | +|---|---|---|---|---|---|---| +| 逐帧动画背景 | ❌ | ✅ BgPulse 呼吸光圈 | ❌ | ❌ | ✅ sin² 波浪 accent | ❌ | +| RGBA 透明/混色 | ❌ | ✅ | ❌ | ❌ | ✅ framebuffer | ✅ **accent 按终端背景 alpha 混色** | +| **渐变品牌字** | ❌ | ❌ | ❌ | ✅ `gradientText`(逐字符 hex 插值,Kimi 品牌) | ❌ | ❌ | +| 特殊 spinner | ❌ | ✅ Knight Rider 扫描光带 | ✅ braille | ✅ **Moon 月相 + braille** | ✅ braille/dot/monitor | ✅ braille(标题动画) | +| 内联图片 | ❌ | ❌ | ✅ Kitty/iTerm2 | ✅ Kitty/iTerm2 + 缩略图 | ✅ Kitty/iTerm2 | ✅ **Sixel 手写编码 + ASCII pet** | +| 内联视频 | ❌ | ❌ | ❌ | ❌ | ✅ ffmpeg 抽帧 | ❌ | +| Mermaid 终端渲染 | ❌ | ❌ | ❌ | ❌ | ✅ Unicode box-drawing | ❌ | +| 终端标题动画 | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ Thinking 时 Braille 帧 | +| 彩蛋 | ❌ | ❌ | ❌ | ✅ easter-eggs/dance | ✅ gboom 游戏 | ✅ pets(Sixel) | +| 工具行 `●` 三态灯 | ✅(独有) | 文本 | 背景色块 | 背景色块 | accent 竖线 | `• ` bullet | ### 3.5 输入编辑器 | | 能力 | |---|---| -| acecode | `input_history_navigation`、`path_reference_input`、`paste_handler`、`ime_windows`;**无 extmark、无输入内高亮、无 leader key** | -| **opencode** | OpenTUI `TextareaRenderable` —— minHeight/maxHeight 自增高、**输入框内语法高亮**、**extmark 虚拟文本**(Neovim 式 `[Image 1]`/`[Pasted ~N lines]`/agent 标签)、IME 组合、shell `!` 前缀、keymap mode stack + **leader key + chord** | -| **pi** | 自研 editor —— Emacs 风格移动、undo stack、kill-ring、slash/路径自动补全、bracketed paste 大粘贴压缩、CJK 断行(`Intl.Segmenter`)、**IME APC 光标定位** | -| **grok** | `xai-ratatui-textarea` grapheme 编辑 + Vim/Simple 双模式 + 圆角 PromptWidget | +| acecode | `input_history_navigation`/`path_reference_input`/`paste_handler`/`ime_windows`;**无 extmark、无输入内高亮、无 leader key** | +| **opencode** | TextareaRenderable:输入内语法高亮、**extmark 虚拟文本**、IME、keymap mode stack + leader key | +| **pi** | 自研 editor:Emacs 风格、undo、kill-ring、自动补全、CJK 断行、IME APC 光标 | +| **kimi-code** | `custom-editor.ts`(自研)+ `file-mention-provider`(`@` 文件补全)+ `wrapping-select-list` | +| **grok** | `xai-ratatui-textarea` grapheme 编辑 + Vim/Simple 双模式 | +| **codex** | 自研 `TextArea`(`bottom_pane/textarea.rs`,注释里点名 tui-textarea 的坑):**Vim 模式**(normal/operator/text-object)、kill buffer、`TextElement` 原子元素(mention/大粘贴占位)、掩码输入;**配置驱动 `RuntimeKeymap`**(app/chat/composer/editor/vim 多上下文 + **key chord 和弦**) | -### 3.6 终端协议(acecode 的核心差距) +### 3.6 终端协议 -| 协议 | acecode | opencode | pi | crush | grok | -|---|---|---|---|---|---| -| **CSI 2026 同步输出** | ❌ | (原生层) | ✅ | (tea 内部) | ✅ | -| **OSC 8 超链接** | ⚠️ 有检测代码但**不发射**(FTXUI Element 无法内嵌,`make_hyperlink` 返回纯文本) | ✅ | ✅ | ❌ | ✅ 深度集成(路径/URL 自动 linkify) | -| **OSC 133 prompt 标记** | ❌ | ❌ | ✅ | ❌ | ❌ | -| **kitty keyboard 协议** | ❌(仅消费标准 CSI 修饰符,未显式启用) | ✅ | ✅ | ❌ | ✅ | -| **modifyOtherKeys** | ⚠️ 消费 `\x1B[1;3A` 等标准序列,未显式启用 | (OpenTUI) | ⚠️ 回退方案 | (tea) | (crossterm) | +| 协议 | acecode | opencode | pi | kimi | crush | grok | codex | +|---|---|---|---|---|---|---|---| +| **CSI 2026 同步输出** | ❌ | (原生) | ✅ | ✅ | (tea) | ✅ | ✅ | +| **OSC 8 超链接** | ⚠️ 检测但不发射(FTXUI 限制) | ✅ | ✅ | ✅ | ❌ | ✅ 自动 linkify | ✅ **链接元数据与几何分离**(换行不含转义字节,长 URL 跨行合并) | +| **OSC 133 prompt 标记** | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| **kitty keyboard** | ❌(仅消费标准 CSI 修饰符) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ **按终端精细调优** | +| **CSI-u 微调** | ❌ | 通用 | 通用 | 通用 | ❌ | 通用 | ✅ iTerm2/Ghostty 抑制 release(`>5u`)、kitty 保 repeat(`>7u`)、tmux 确认 `extended-keys-format` 才用 modifyOtherKeys、VS Code+WSL 禁用 | +| **终端探测** | 仅 env 检测 | 原生层 | kitty 协商 | 同 pi | (tea) | DA2/XTVERSION 20+ | ✅ **100ms 有界探测**(CPR/OSC10/11/键盘增强),驱动主题+键盘模式 | -**acecode OSC 8 现状**(已核实源码):`src/markdown/markdown_formatter.cpp:78` 有 `terminal_supports_hyperlinks()` 检测 WT_SESSION/TERM_PROGRAM(iTerm/WezTerm/vscode/xterm),但 `make_hyperlink()` 注释明确写 *"This only works at the terminal level, not in FTXUI Elements. We'll just return the display text since FTXUI handles rendering."* —— 即检测到支持也只渲染纯色文本,**链接不可点击**。这是 FTXUI Element 网格模型的架构限制,非简单 bug。 +**codex 终端探测**(新增):`terminal_probe.rs` 用 100ms 预算探测光标位置(CPR `ESC[6n`)、默认前景/背景色(OSC 10/11)、键盘增强支持,探测期间独占终端输入 —— 比 crossterm 默认 2s 超时更快更稳。探测结果驱动:UI accent 按终端背景自动调色、键盘模式按终端分支选择。 -→ 见 [`demos/01_synchronized_output.py`](./demos/01_synchronized_output.py)、[`demos/02_osc8_hyperlinks.py`](./demos/02_osc8_hyperlinks.py)、[`demos/03_kitty_keyboard.py`](./demos/03_kitty_keyboard.py)、[`demos/07_osc133_prompts.py`](./demos/07_osc133_prompts.py)。 +### 3.7 跨平台 -### 3.7 跨平台 / 终端处理 - -| | Windows 处理 | 终端探测 | +| | Windows 处理 | 独有 | |---|---|---| -| acecode | conhost compat 布局(ASCII 边框去 logo)+ alt-screen 兜底;**无 UTF-8 CP 强制、无 VTP 显式开启** | ConEmuPID/WT_SESSION/Windows build | -| **grok** | `SetConsoleMode` 给 **stderr** 开 VTP + `SetConsoleOutputCP(CP_UTF8)`(否则盲文/box-drawing 乱码)、legacy ConHost 字形回退(`❯->>`、spinner->ASCII)、QuickEdit 原生选择保留 | DA2/XTVERSION 探测 **20+ 终端品牌** | -| **pi** | 原生 `win32-console-mode.node` 开 `ENABLE_VIRTUAL_TERMINAL_INPUT` + `isModifierPressed`(GetAsyncKeyState) | kitty keyboard 协商 + modifyOtherKeys 回退 | -| opencode | kernel32 FFI 清 `ENABLE_PROCESSED_INPUT` + 每 100ms 轮询 Ctrl+C guard | OpenTUI 原生层 | -| crush | Bubble Tea 自带 + catwalk 测试录制 | (tea 内部) | - -acecode 的 conhost compat 布局是**独门老终端兜底**,其他项目大多只测 Windows Terminal;但 acecode **没有 kitty keyboard、没有 UTF-8 CP 强制、没有终端品牌探测**。 +| acecode | conhost compat 布局 + alt-screen 兜底 | 老 conhost 兼容最扎实 | +| **grok** | stderr 开 VTP + `SetConsoleOutputCP(UTF-8)`、legacy 字形回退、QuickEdit 保留 | 最彻底的字形/编码处理 | +| **codex** | `SetConsoleMode` 对 stdout+stderr 开 VTP;**关闭 `ENABLE_VIRTUAL_TERMINAL_INPUT` 走 Win32 input records**(每次 poll 重断言,防其它 console client 改回 VT 输入导致导航键变裸转义字节) | macOS fd2 dup2 /dev/null 守卫 stderr | +| **pi/kimi** | 原生 .node addon(ENABLE_VIRTUAL_TERMINAL_INPUT + isModifierPressed) | 原生修饰键查询 | +| opencode | kernel32 FFI 清 PROCESSED_INPUT + 100ms Ctrl+C guard | — | +| crush | Bubble Tea 自带 | — | ### 3.8 主题系统 | | 方案 | |---|---| -| acecode | dark/light 两套预置 `ThemePalette`(Ui/Diff/Syntax/Markdown/Semantic),不透明色 | -| **opencode** | 30+ JSON 主题 + 用户自定义 + **system 主题从终端调色板现场生成** + 亮暗自动探测 | -| **grok** | GrokNight/GrokDay/TokyoNight/RosePineMoon/OscuraMidnight/Auto + OSC 11 跟随终端背景 + 颜色能力量化降级 | -| **pi** | 60+ token、256/truecolor 自动切换、文件 watcher 热重载 | +| acecode | dark/light 两套预置 `ThemePalette`,不透明色 | +| **opencode** | 30+ JSON 主题 + 用户自定义 + **system 主题从终端调色板现场生成** | +| **grok** | GrokNight/Day + TokyoNight 等 5 套 + OSC 11 跟随终端背景 + 量化降级 | +| **pi** | JSON dark/light 60+ token,热重载 | +| **kimi-code** | `theme.ts` + **`custom-theme-loader`** + `gradient-text` + `detect.ts`(终端背景探测)+ `theme-selector` 对话框 | +| **codex** | 语法:two-face 32 主题 + 用户 `.tmTheme`;**UI 强调色按终端背景 alpha 自适应**(暗底 12% 白、亮底 4% 黑混合;表格分隔线 20% alpha 混色) | -### 3.9 虚拟化 / 性能 +### 3.9 性能 / 虚拟化 | | 手段 | |---|---| | acecode | `redraw_pacer` 帧率限制 + `tool_result_fold` + `chat_render_window` | -| **grok** | `entry_layouts_cache` 条目高度缓存 + scrollback 虚拟化 + `SafeBuf` 防 resize 竞态越界 panic | -| **pi** | per-frame `renderCache` + render-churn bench 基准 | -| **opencode** | ScrollBoxRenderable 裁剪 + SSE 16ms batching + conceal 代码隐藏 | -| crush | stable-prefix glamour 缓存 + assistant_section_cache + prefix_cache | +| **codex** | **scrollback-first**(历史出界,每帧 diff 极小)+ **FrameRequester/FrameScheduler actor + 120FPS 上限** + 多级缓存(history render cache / ActiveCellLayoutCache / stable_prefix_len_cache)+ resize 回灌行数上限 | +| **grok** | `entry_layouts_cache` 高度缓存 + scrollback 虚拟化 + SafeBuf | +| **pi/kimi** | per-frame renderCache + render-churn bench | +| **opencode** | ScrollBoxRenderable 裁剪 + SSE 16ms batching + conceal | +| crush | stable-prefix glamour 缓存 + section cache | --- ## 四、acecode 独有 / 仍占优的点 -1. **单一 C++ 二进制、零运行时依赖** —— opencode 要下 tree-sitter WASM、pi 要装 node ≥22 + 原生 addon、grok 编 Rust。 -2. **老 Windows 终端(conhost/Cmder/ConEmu)兼容最扎实** —— conhost compat 布局 + alt-screen 兜底,其他项目大多只测 Windows Terminal。 -3. **`●` 工具调用行 + Ctrl+O/E 折叠语义** —— 本项目自创的紧凑 transcript 风格,信息密度高且与 Claude Code 对齐。 -4. **daemon + 桌面壳 + headless 多形态共用同一 TUI 渲染逻辑** —— 其他项目大多是单体 TUI。 +1. **单一 C++ 二进制、零运行时依赖**(opencode 下 tree-sitter WASM、pi/kimi 装 node+addon、Rust 项目要编译)。 +2. **老 Windows 终端(conhost/Cmder/ConEmu)兼容最扎实**。 +3. **`●` 工具调用行 + Ctrl+O/E 折叠语义**自创紧凑风格。 +4. **daemon + 桌面壳 + headless 多形态共用同一 TUI 渲染逻辑**。 +5. **双模式(alt-screen / scrollback)** 与 codex 的 alt/inline 双模式同构 —— 说明 acecode 的架构方向与最成熟的 codex 一致,差距在实现深度(同步输出、URL 感知回灌、逐终端调优)。 --- @@ -153,15 +167,16 @@ acecode 的 conhost compat 布局是**独门老终端兜底**,其他项目大多 | # | 差距 | 难度 | 收益 | 参考实现 | |---|---|---|---|---| -| 1 | **流式 markdown 增量渲染** | 中 | 高(长输出卡顿最明显) | crush stable-prefix / grok checkpoint | -| 2 | **CSI 2026 synchronized output** | 低 | 中(消除闪烁) | pi/grok | -| 3 | **OSC 8 超链接真正可用** | 高(需绕过 FTXUI Element 限制) | 中 | grok linkify;或在 FTXUI 外层 post-process | -| 4 | **kitty keyboard 协议** | 中 | 中(可靠 Shift+Enter/修饰键) | opencode/pi/grok | -| 5 | **OSC 133 prompt 标记** | 低 | 低(transcript 导航) | pi | -| 6 | **主题系统扩展**(用户 JSON + 调色板生成) | 中 | 中 | opencode/pi | -| 7 | **输入框 extmark + 输入内高亮** | 高 | 中 | opencode(FTXUI 限制大) | +| 1 | **流式 markdown 增量渲染** | 中 | 高 | crush stable-prefix / grok checkpoint / codex 两区域+提交动画 | +| 2 | **CSI 2026 synchronized output** | 低 | 中 | pi/codex/grok | +| 3 | **OSC 8 超链接真正可用** | 高 | 中 | codex 几何分离 / grok linkify | +| 4 | **kitty keyboard / CSI-u 按终端调优** | 中 | 中 | codex keyboard_modes / opencode | +| 5 | **终端探测(CPR/OSC10/11,100ms)驱动主题+键盘** | 中 | 中 | codex terminal_probe | +| 6 | **主题扩展**(用户自定义 + 终端背景自适应 accent) | 中 | 中 | codex style.rs / kimi custom-theme-loader | +| 7 | **输入框 extmark / 输入内高亮 / key chord** | 高 | 中 | codex TextArea / opencode | +| 8 | **scrollback URL 感知写入 + resize 回灌行数上限** | 中 | 中 | codex insert_history / resize_reflow_cap | -> 第 3、7 项受 FTXUI Element 网格模型限制较深,可能需要在 FTXUI 渲染后做 ANSI 后处理或局部绕过 FTXUI。 +> 第 3、7 项受 FTXUI Element 网格模型限制较深。第 8 项恰好是 acecode `TerminalOutput()` 模式的演进方向 —— 从"光标回退重绘"升级为"写死历史 + 独立视口"。 --- @@ -180,6 +195,7 @@ python 06_spinner_showcase.py # 五种 spinner 同屏对比 python 07_osc133_prompts.py # OSC 133 prompt 语义标记 python 08_tool_row_dots.py # acecode ● 三态指示灯(本项目优势) python 09_streaming_markdown.py # 流式 markdown 增量渲染基准 +python 10_gradient_text.py # kimi 渐变品牌字(新增) ``` 详见 [`demos/README.md`](./demos/README.md)。 From 9b83195f5aa4d0713a859f0cae30c8bfbf13b6bc Mon Sep 17 00:00:00 2001 From: MobileWork User Date: Sat, 15 Aug 2026 15:43:03 +0800 Subject: [PATCH 03/16] docs: rewrite TUI comparison in plain language for PMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 报告与演示 README 去掉渲染模型/cell diff/CSI 协议等技术术语, 改写为产品经理可读的白话:重画方式、闪烁观感、流式体验、输入框、 特效、换肤、老终端兼容、独有优势与差距排序。协议名保留但附人话解释。 --- docs/tui-comparison/demos/README.md | 93 ++++---- docs/tui-comparison/report.md | 322 +++++++++++++++------------- 2 files changed, 222 insertions(+), 193 deletions(-) diff --git a/docs/tui-comparison/demos/README.md b/docs/tui-comparison/demos/README.md index de1de12a..21d590ff 100644 --- a/docs/tui-comparison/demos/README.md +++ b/docs/tui-comparison/demos/README.md @@ -1,85 +1,86 @@ -# TUI 对比效果演示 +# 终端界面对比演示 -配合 [`../report.md`](../report.md) 的可运行效果演示。每个脚本自包含,演示一个终端效果,并标注**哪些项目有、acecode 现状如何**。 +配合 [`../report.md`](../report.md) 的可运行演示。每个脚本演示一个终端效果,并标注"哪些产品有、我们现状如何"。**所有脚本都尽量用大白话描述,直接跑就能看出效果差异。** ## 运行环境 - Python 3.8+(Windows 自带 3.12 可用) - 推荐终端:**Windows Terminal** / kitty / WezTerm / iTerm2 / Ghostty -- 老版 cmd.exe / conhost 部分效果(盲文、truecolor)会降级 +- 老版 CMD / 老式控制台:部分效果(彩色、特殊符号)会变丑,属正常降级 -## 运行 +## 怎么跑 ```bash cd docs/tui-comparison/demos -python 01_synchronized_output.py -python 02_osc8_hyperlinks.py -python 03_kitty_keyboard.py # 交互式,按 q 退出 -python 04_alpha_transparency.py -python 05_animated_background.py # 动画,Ctrl+C 退出 -python 06_spinner_showcase.py # 动画,Ctrl+C 退出 -python 07_osc133_prompts.py -python 08_tool_row_dots.py -python 09_streaming_markdown.py -python 10_gradient_text.py # kimi 渐变品牌字 +python 01_synchronized_output.py # 同步刷新 vs 闪烁对比 +python 02_osc8_hyperlinks.py # 可点击链接 +python 03_kitty_keyboard.py # 键盘增强协议(交互,按 q 退出) +python 04_alpha_transparency.py # 半透明 vs 不透明 +python 05_animated_background.py # 动态呼吸背景(动画,Ctrl+C 退出) +python 06_spinner_showcase.py # 各家加载动画同屏(动画,Ctrl+C 退出) +python 07_osc133_prompts.py # 回合分界标记 +python 08_tool_row_dots.py # 我们的 ● 三态指示灯(招牌) +python 09_streaming_markdown.py # 流式增量排版 vs 整篇重排 +python 10_gradient_text.py # Kimi 渐变品牌字 ``` -或一键跑非交互的(1/2/4/7/8/9/10): +或一键批量跑非交互的(01/02/04/07/08/09/10): ```bash python run_static.py ``` -## 演示与报告对照 +## 每个演示讲什么 -| 演示 | 演示的效果 | 报告章节 | acecode 现状 | +| 演示 | 演示的效果 | 谁有 | 我们现状 | |---|---|---|---| -| `01_synchronized_output` | CSI 2026 同步输出消除闪烁 | 3.1 / 3.6 | ❌ 无,FTXUI 光标回退 | -| `02_osc8_hyperlinks` | OSC 8 可点击超链接 | 3.6 | ⚠️ 有检测代码但不发射(FTXUI 限制) | -| `03_kitty_keyboard` | kitty keyboard 协议键码 | 3.6 | ❌ 仅消费标准 CSI 修饰符 | -| `04_alpha_transparency` | RGBA alpha 混色(透明叠层) | 3.1 / 3.4 | ❌ FTXUI 颜色不透明 | -| `05_animated_background` | 正弦呼吸动画背景 | 3.4 | ❌ 无 | -| `06_spinner_showcase` | 五种 spinner 同屏对比 | 3.4 | ❌ 仅 compact_animation | -| `07_osc133_prompts` | OSC 133 prompt 语义标记 | 3.6 | ❌ 无 | -| `08_tool_row_dots` | `●` 三态指示灯 | 二 / 四.3 | ✅ acecode 独有优势 | -| `09_streaming_markdown` | 流式 markdown 增量渲染基准 | 3.2 | ❌ 整条重渲染 | -| `10_gradient_text` | kimi 渐变品牌字 | 3.4 | ❌ FTXUI 单色 | - -## 各演示说明 +| `01_synchronized_output` | 同步刷新让画面一次到位不闪;不刷新的会闪 | pi / kimi / grok / codex | ❌ 没有 | +| `02_osc8_hyperlinks` | 可点击的链接(点一下打开) | opencode / pi / grok / codex | ⚠️ 写了检测但点不了 | +| `03_kitty_keyboard` | 键盘增强:分得清 Shift+Enter 和 Enter | opencode / pi / grok / codex | ❌ 没启用 | +| `04_alpha_transparency` | 半透明遮罩能透出底下文字 | opencode / grok / codex | ❌ 只能全盖住 | +| `05_animated_background` | 会"呼吸"的动态背景 | opencode / grok | ❌ 没有 | +| `06_spinner_showcase` | 各家加载动画同屏对比 | 各家都有好看的 | ❌ 仅静态 ● | +| `07_osc133_prompts` | 给每轮对话打"回合标记",支持跳转 | pi / kimi | ❌ 没有 | +| `08_tool_row_dots` | 工具调用行 ● 灰/绿/红三态灯 | 我们的招牌 | ✅ 独有 | +| `09_streaming_markdown` | 流式输出:增量排版 vs 整篇重排 | crush / grok / codex | ❌ 整篇重排 | +| `10_gradient_text` | 逐字变色的渐变字 | kimi-code | ❌ 单色 | + +## 各演示一句话说明 ### 01_synchronized_output.py -**CSI 2026 同步输出**(Begin/End Synchronized Update)。快速重绘一个 10 行块,先不加同步(可见闪烁/撕裂),再加 `\x1b[?2026h...\x1b[?2026l` 包裹(平滑)。pi 和 grok-build 用此协议;acecode 的 FTXUI 用 `\033[1A` 光标回退,无同步输出。 +**同步刷新**:屏幕刷新时如果上半新下半旧,看着就闪。加了同步刷新,画面整块一次到位。先看"不加同步"的闪烁,再看"加了同步"的平滑。pi / kimi / grok / codex 都有,我们没有。 ### 02_osc8_hyperlinks.py -**OSC 8 超链接**:`\x1b]8;;URL\x07text\x1b]8;;\x07`,在支持的终端里 Ctrl/Cmd+Click 打开。acecode 的 `markdown_formatter.cpp:78` 有 `terminal_supports_hyperlinks()` 检测,但 `make_hyperlink()` 因 *"FTXUI Elements 无法内嵌 OSC 8"* 只返回纯文本 -- 链接不可点击。本演示对比"可点击链接" vs "acecode 的纯色文本"。 +**可点击链接**:终端里 Ctrl/Cmd+点击链接直接打开。我们其实写了"检测终端支持不支持"的代码,但因为底层框架限制,**链接最终只显示成带下划线的纯文字,点不了**。本演示对比"可点"和"我们现在的样子"。 ### 03_kitty_keyboard.py -**kitty keyboard 协议**:启用后 Shift+Enter 报 `\x1b[13;2u` 而非裸 `\r`,修饰键/释放事件都可区分。交互式捕获按键并显示原始字节。opencode/pi/grok 启用;acecode 未启用(仅消费 `\x1B[1;3A` 等标准序列)。**注意:Windows Terminal 目前不支持 kitty 协议**,在 WT 上会回退到标准序列(这本身就是演示的一部分)。 +**键盘增强协议**:终端默认分不清 Shift+Enter 和 Enter(都当回车)。启用增强后能区分。本演示先看键码对照表,再进入实时捕获,按 q 退出。**注意:Windows Terminal 目前不支持这个协议**,在它上面只能看到"分不清"的效果——这本身就是我们现状的写照。 ### 04_alpha_transparency.py -**RGBA alpha 混色**:ANSI 无法逐 cell 设 alpha,opencode/grok 在 framebuffer 层做 per-pixel 合成(半透明遮罩、透出终端背景的 system 主题)。本演示用**预混色**模拟视觉效果:把叠层颜色与背景色按 alpha 混合后输出,对比 acecode 的不透明色块。 +**半透明遮罩**:opencode/codex 能画"半透明"的对话框,底下文字隐约可见,有层次感。终端本身不支持半透明,它们是先把颜色算好再画(模拟效果)。我们只能全盖住或全透明。演示里对比"不透明(我们)"和"半透明(别人)",还有一个透明度呼吸动画。 ### 05_animated_background.py -**正弦呼吸动画背景**:`sin²(t)` 驱动背景亮度循环,模拟 opencode `BgPulse`(呼吸光圈)与 grok `wave_brightness`(跨行相位波)。acecode 无背景动画。 +**动态背景**:像呼吸一样明暗起伏的背景,opencode 的"呼吸光圈"和 grok 的"波浪"风格。我们现在没有背景动画。Ctrl+C 退出。 ### 06_spinner_showcase.py -**五种 spinner 同屏**:acecode `●`(静态点)、grok braille `⠋⠙⠹⠸⠼⠴⠦⠧`、grok dot `⋅ : ⸬ ⁙`、grok monitor `○ ◎ ◉ ◎`、opencode Knight Rider 扫描光带(逐像素 alpha 渐变拖尾)。 +**加载动画同屏**:把各家的加载动画摆一起转: +- 我们:静态 ●(只会亮暗变化) +- grok:盲文转圈 / 圆点 / 呼吸圆 +- opencode:霓虹扫描光条(像跑马灯) +Ctrl+C 退出。 ### 07_osc133_prompts.py -**OSC 133 prompt 语义标记**:`\x1b]133;A\x07`(prompt 开始)/ `B`(输出开始)/ `C`(输出结束)。支持的终端(kitty/WezTerm)可 Cmd+Shift+↑/↓ 在 prompt 间跳转。pi 用此做 transcript 导航;acecode 无。 +**回合分界标记**:给每一轮对话的起止打上隐形标记,支持的终端(kitty/WezTerm)可以一键在回合之间跳转,不用手动滚。我们现在没有,只能逐行滚。 ### 08_tool_row_dots.py -**acecode 独有优势**:` ● ToolName(args)` 三态指示灯(灰=执行中/绿=成功/红=失败),`compute_tool_call_dots` FIFO 配对 tool_call ↔ tool_result。演示 acecode 的紧凑 transcript 风格,与其他项目的纯文本/accent 竖线对比。 +**我们的招牌**:工具调用行显示成 `● ToolName(args)`,灯色代表执行状态(灰=跑 / 绿=成功 / 红=失败),结果缩进一行。还演示了"并行工具谁先完成"的配对逻辑。别人家都是纯文字或色块,这套三态灯是我们独有的。 ### 09_streaming_markdown.py -**流式 markdown 增量渲染基准**:模拟流式逐 chunk 追加文档,对比三种方法的耗时: -- 朴素法(acecode):每 chunk 整条重渲染 -- stable-prefix(crush):缓存安全前缀,只渲染尾部 -- checkpoint 冻结(grok):冻结已稳定块,只渲染活跃 tail - -展示 acecode 在长输出时的 CPU 开销差距。 +**流式增量排版基准**:模拟 AI 一句一句往外冒字,对比三种做法的耗时: +- 整篇重排(我们现在):每冒一句把整篇重算,越长越慢 +- 增量排版(crush):只算新冒出来的尾巴 +- 冻结法(grok/codex):已稳定的段落冻结,只算在变的尾部 +跑完能看到整篇重排的耗时随长度直线上升,增量方案基本不变。 ### 10_gradient_text.py -**kimi-code 渐变品牌字**(`gradientText`):逐字符在 from→to 间插值 truecolor,配合 -accentBias 偏折,用于 Kimi banner/品牌元素。对照 acecode 的 FTXUI 单色加粗 -(Element 按整段上色,不暴露逐字符色)。 +**Kimi 渐变品牌字**:像品牌 Logo 那样从蓝渐变到紫的文字。Kimi 用在欢迎页和品牌元素上。我们只能整段一个颜色。演示展示了多组渐变和"渐变聚集在左/均匀分布"两种效果。 diff --git a/docs/tui-comparison/report.md b/docs/tui-comparison/report.md index 2b5f9af2..30881d4e 100644 --- a/docs/tui-comparison/report.md +++ b/docs/tui-comparison/report.md @@ -1,201 +1,229 @@ -# ACECode TUI 与同类项目对比调研报告 +# ACECode 终端界面 与同类产品对比(产品视角) -> 调研日期:2026-08-15(第 2 版,纳入 kimi-code / codex) -> 调研范围:`D:\dev` 下 7 个具备终端 TUI 的 AI 编程工具: -> **acecode**(本项目,C++ + FTXUI)、**opencode**(TS + OpenTUI)、**pi**(TS + 自研 pi-tui)、 -> **kimi-code**(TS + @moonshot-ai/pi-tui 即 pi-tui fork)、**crush**(Go + Bubble Tea v2)、 -> **grok-build**(Rust + ratatui fork)、**codex**(Rust + ratatui fork)。 -> `agent`(纯笔记)、`openchamber` / `pi-web`(web/desktop,无 TUI)不参与对比。 -> -> 配套可运行效果演示见 [`demos/`](./demos/README.md)。 +> 版本:2026-08-15(含 kimi-code / codex) +> 一句话:我们和市面上 6 款主流"AI 编程终端工具"对比了终端界面的实现方式和实际观感,搞清楚:别人家终端界面"好看、流畅、好用"是怎么做到的,我们差在哪,补什么最划算。 + +对比对象(D:\dev 下的 7 个项目,其中 `agent`/`openchamber`/`pi-web` 没有终端界面,不参与): + +| 产品 | 用什么技术做的界面 | 一句话印象 | +|---|---|---| +| **ACECode**(本项目) | C++ 自研界面框架(FTXUI) | 老牌 Windows 终端兼容最好,工具调用行设计独树一帜 | +| **opencode** | 自己的终端 UI 引擎(OpenTUI) | 界面最"华丽":有动画背景、渐变、霓虹加载条 | +| **pi** | 自研界面库(pi-tui) | 工程最扎实:整屏一次刷新不闪、可点链接、可贴图 | +| **kimi-code** | 直接用了 pi 的界面库(魔改版) | 和 pi 一个底子,但外壳很有品牌感:渐变字、月亮加载动画、多代理协作 | +| **crush** | 成熟的 Go 界面框架(Bubble Tea) | 中庸稳妥,增量排版省性能 | +| **grok-build** | Rust 界面框架(ratatui)+ 深度魔改 | 特效最多:波浪动画、内嵌图片/视频、甚至内置小游戏 | +| **codex** | Rust 界面框架(ratatui)+ 深度魔改 | 思路最先进:历史直接滚进终端,界面只留一小块,最流畅 | + +血缘关系:7 个产品实际只有 **5 套独立的技术路线** —— pi 和 kimi-code 是同一套壳,crush 自成一家,opencode 一家,ratatui 系(grok / codex)两家各自魔改。 --- -## 一、技术栈速览 +## 一、各家怎么把画面画出来(性能差异的根源) -| 项目 | 语言 | TUI 框架 | 渲染模型 | Markdown / 语法高亮 | -|---|---|---|---|---| -| **acecode** | C++ | **FTXUI** | 立即模式组件树,每帧重算 → Screen → ANSI | 自研 `markdown_formatter` + 内置 `syntax_highlight` | -| **opencode** | TS/bun | **OpenTUI**(`@opentui/core`+`solid`) | retained framebuffer(`Uint16Array` 逐像素)+ Yoga flexbox + 原生 FFI | OpenTUI `` + **tree-sitter WASM 运行时下载** | -| **pi** | TS | **自研 pi-tui** | 组件树每帧渲染成文本行数组 → **逐行字符串 diff** → CSI 2026 同步输出 | `marked` + highlight.js + LaTeX Unicode | -| **kimi-code** | TS | **@moonshot-ai/pi-tui**(pi-tui 0.84.3 fork) | 同 pi(差分渲染 + 布局树 + CSI 2026) | 同 pi(marked + hljs),`highlight-theme.ts` 主题化 | -| **crush** | Go | **Bubble Tea v2** + lipgloss | 命令/更新/视图,`View()` 返回整串,tea 内部行 diff | **glamour v2 + stable-prefix 增量缓存** | -| **grok-build** | Rust | **ratatui**(自 fork `xai-ratatui-inline`)+ crossterm | 立即模式 + 双缓冲 **cell 级 diff**,保留光标闪烁 + 后台写线程 | 自研 `xai-grok-markdown` + checkpoint 冻结 + syntect | -| **codex** | Rust | **ratatui**(自 fork `custom_terminal.rs`)+ crossterm | 立即模式 + 双缓冲 diff,自研 Terminal;**scrollback-first** 输出模型 | `pulldown-cmark` + **two-face/syntect**(~250 语言,32 主题) | +**先理解两件事:** +1. 终端本身是个"文本画布",软件每刷新一次,就是往画布上写字。怎么"写"决定了流畅度和闪不闪。 +2. 有两个方案:① 把整个画面从头重画一遍;② 只把**有变化的部分**重画,其余不动。 -> 血缘关系:`pi` ↔ `kimi-code` 共用同一 TUI 库(pi-tui);`grok-build` ↔ `codex` 都是"fork 了 ratatui Terminal"的 Rust 重写。七个项目实际只有 5 个独立技术路线。 +| 产品 | 重画方式 | 会不会闪 | 说明 | +|---|---|---|---| +| **ACECode** | 每帧把界面全量重画 + 光标上移重绘 | 老终端上会闪 | 全量重画,越长的对话越吃力 | +| **opencode** | 内存里先画好整张图,只把变化的格子同步上屏 | 好 | 只更新变化部分,天生省 | +| **pi / kimi-code** | 每次只重画有变化的"行" | 好 | 按行对比新旧画面,只改变了的行 | +| **crush** | 每次把整屏文字重新拼一遍交给框架去刷新 | 一般 | 框架内部会省,但拼整屏本身费劲 | +| **grok-build** | 内存双画布逐格对比,只重画变化的格子 | 好 | 画面变化小的时候开销极小 | +| **codex** | 和 grok 类似,但更激进(见下) | 最好 | 见"codex 的思路" | ---- +**一个影响观感的关键技术——"同步刷新"(专业名 CSI 2026):** +终端刷新是有"半帧"状态的(上半屏新的、下半屏旧的),看着就闪。加了同步刷新,画面会**整块一次到位**,完全不闪。这是很多产品"看起来高级"的一大原因。 +- ✅ 有同步刷新:pi / kimi-code / grok-build / codex +- ❌ 没有:ACECode(目前是光标上移逐行重绘,老终端上会闪)、crush(依赖框架) -## 二、本项目(acecode)TUI 实现特征 +**codex 的思路(最值得学的一招):** +别的产品(包括我们)都是"整个界面自己做,内容都在自己的画布里"。codex 反着来:**已经说完的话直接写进终端的历史区**(就是你往上滚鼠标能看到的那一片),界面只占屏幕底部一小块"当前状态区"。好处: +- 每帧要重画的东西极少 → 特别流畅 +- 历史天然能往上翻,不用自己做滚动 +- 唯一的代价:窗口拉宽时需要把历史按新宽度重排一遍(codex 做了,还按终端型号设了重排行数上限,防止卡死) -- **立即模式 + FTXUI 组件树**:`make_screen_interactive`(`src/tui/render_mode.cpp`)按 `tui.alt_screen_mode`(auto/always/never)在 `Fullscreen()`(alt-screen `\033[?1049h`)与 `TerminalOutput()`(scrollback 模式)间二选一;`run_tui_loop`(`src/tui/tui_init.cpp:296`)直接 `screen.Loop(renderer)`,bracketed paste 包裹。 -- **布局**:header(ACE block-art logo + 版本/模型/cwd)+ 右侧 sidebar(会话列表)+ chat transcript + input box + status line。 -- **Markdown**:自研 `format_markdown`(`src/markdown/markdown_formatter.cpp`)输出 FTXUI `Element`,有 `syntax_highlight`(`src/markdown/syntax_highlight.cpp`,按语言名内置表),theme 有 `MarkdownColors`。**流式时整条重渲染**,靠 `redraw_pacer` 帧率限制 + `tool_result_fold` 折叠控量。 -- **工具行渲染**:`● ToolName(args)` 三态指示灯(灰/绿/红)、`compute_tool_call_dots` FIFO 配对、Ctrl+O/Ctrl+E 展开、`thinking_heartbeat` 内联 `[Ns · ↓ X tokens]`。 -- **跨平台兜底**:`detect_terminal_capabilities` 读 ConEmuPID/WT_SESSION/Windows build,`should_use_conhost_compat_layout` 在老 conhost 上退 ASCII 边框、去 logo —— **老 Windows 终端兼容最扎实**。 -- **剪贴板/鼠标**:FTXUI mouse tracking + 右键 OSC 52 复制 + Ctrl+V 读系统剪贴板。 +> 对我们:ACECode 其实已经有"滚屏模式"(内容进历史区)和"全屏模式"两种,方向和 codex 一致,只是实现还停留在"光标上移重绘",没做到"写完就永久落定、界面只留当前块"。这是最清晰的一条升级路线。 --- -## 三、关键差异分维度对比(7 项目) +## 二、AI 说话(流式输出)时,各家表现如何 -### 3.1 渲染管线 +AI 打字是一点一点冒出来的。怎么把"正在冒的字"画得又顺又不晃,是体验差异的大头。 -| | 模型 | 同步输出 | 输出目标 | 独特机制 | -|---|---|---|---|---| -| **acecode** | FTXUI Screen diff,光标回退 `\033[1A` | ❌ | stdout | 双模式(alt-screen / TerminalOutput scrollback) | -| **opencode** | retained framebuffer + Yoga flexbox | (原生层) | 原生 FFI | 逐像素 RGBA framebuffer | -| **pi** | 文本行数组 + 逐行字符串 diff | ✅ CSI 2026 | stdout | main/alt 双渲染器,整帧原子写入 | -| **kimi-code** | 同 pi | ✅(同 pi) | stdout | 同 pi + agent swarm 应用层 | -| **crush** | Bubble Tea `View()` 行 diff | (tea 内部) | stdout | 命令/更新/视图模型 | -| **grok-build** | ratatui 双缓冲 cell diff | ✅ CSI 2026 | **stderr** + 后台写线程 | fork Terminal 保留光标闪烁 | -| **codex** | ratatui 双缓冲 cell diff(自 fork) | ✅ CSI 2026 | stdout | **scrollback-first**:已完成历史写真实终端 scrollback,视口只是底部小窗 | +| 产品 | 流式表现 | 说明 | +|---|---|---| +| **ACECode** | 每冒一段就把**整篇**重新排版渲染 | 对话越长越卡,长文输出时明显 | +| **crush** | 只重排"新冒出来的尾巴",前面排版好的直接复用 | 前面部分不会再算,省很多 | +| **grok-build** | 更聪明:已经稳定的段落"冻结",只渲染还在变化的尾部 | 长文档也流畅 | +| **opencode** | 流式"增量排版" + 把 16ms 内的多次更新合并成一次画 | 很顺 | +| **pi / kimi-code** | 每次整篇重排但内部有缓存,还做了"代码块没闭合时不提前画"的防抖 | 平滑但费 CPU | +| **codex** | 最成熟:已说完的逐行"打字机式"滚进历史区,新内容在底部显示;**表格会整张憋住,等表格排完再一次性显示**(否则加一行整表就抖一下) | 观感最稳 | -**codex scrollback-first 模型**(报告新增重点):`insert_history.rs` 把已完成的对话历史用转义序列直接写进**真实终端 scrollback**,ratatui 只负责底部一个小 viewport —— 每帧 diff 量极小。写入前做 **URL 感知预换行**(URL 行保持整行以便终端识别为可点击),检测 zellij 用专用 `ZellijRaw` 写入路径,resize 时按终端 scrollback 容量回灌重建(VS Code 1000 行 / WT 9001 / WezTerm 3500 / Alacritty 10000 上限),75ms 去抖合并拖拽 resize。 +**一句话:流式输出这块我们目前是全场最吃力的一档**,对话一长,每次 AI 冒几个字都要把整篇重新算一遍。crush / codex 的做法(只重排新增的尾巴、冻结已稳定部分)是明确可抄的。 -> 与 acecode 对照:acecode 的 `TerminalOutput()` 模式同样是"历史进 scrollback"思路,但机制不同 —— FTXUI 用 `\033[1A` 光标回退重绘整个输出区,codex 是"写死历史 + 独立小视口"永不回退,滚动天然由终端负责,无需重绘。 +> 想直观看到差距,跑 `demos/09_streaming_markdown.py`:同样的内容,朴素整篇重排的耗时随长度线性上涨,增量方案基本持平。 -### 3.2 流式 Markdown 渲染(acecode 明显偏弱) +--- -| | 做法 | -|---|---| -| acecode | 整条重渲染 + `redraw_pacer` 限帧 | -| **crush** | glamour "stable-prefix" 增量缓存,只渲染尾部 | -| **grok** | `StreamingMarkdownRenderer` checkpoint 冻结 tail + 键控换行缓存 | -| **opencode** | OpenTUI `streaming={true}` 增量解析 + SSE 16ms 批量 | -| **pi / kimi** | 整条重解析但 `cachedText/cachedWidth` 缓存 + `trimPartialClosingFences` 防抖 | -| **codex** | **两区域流式模型**:稳定区 → 逐行"提交动画"滚入 scrollback,尾区 → active cell;`MarkdownStreamCollector` 换行门控(表格/fenced 代码未闭合不提前渲染);**表格 holdback**(pipe 表格整表保持 mutable 直到流结束,避免加行改列宽重塑历史);`AdaptiveChunkingPolicy` 按队列压力选单行/批量提交 | +## 三、代码块配色(谁的颜色漂亮且不依赖网络) -→ codex 的提交动画 + 表格 holdback 是最成熟的流式体验;acecode 差距最大。见 [`demos/09_streaming_markdown.py`](./demos/09_streaming_markdown.py)。 +| 产品 | 代码着色方案 | 特点 | +|---|---|---| +| **ACECode** | 内置语言表 | 离线可用,语言数量固定 | +| **opencode** | 运行时从网上下载语法包(30+ 语言) | 颜色最全,但**断网就退步**,且要等下载 | +| **pi / kimi-code** | highlight.js | 离线,语言够用 | +| **grok-build** | syntect 引擎 + 内置配色,自动适配终端色阶 | 离线 | +| **codex** | 覆盖 **250+ 语言** 的引擎 + 32 套内置配色 + 用户自定义 | 离线,覆盖最全 | -### 3.3 代码语法高亮 +**说明:** 这里只有 opencode 有"联网依赖",其余都是离线的。codex 的覆盖面最广(250+ 语言),但这对普通使用差异不大——我们内置的语言表日常够用。 -| | 方案 | 覆盖 | -|---|---|---| -| acecode | 内置语言表 `syntax_highlight.hpp` | 离线,内置 | -| **opencode** | tree-sitter WASM **运行时下载** + nvim-treesitter 查询 | ~30 语言,网络依赖 | -| **grok** | syntect + 内置 tmTheme,quantize + 双极性安全降级 | 离线 | -| **pi / kimi** | highlight.js,`supportsLanguage` 门控 | 离线;kimi 有 `highlight-theme.ts` 主题化 | -| **codex** | **two-face(~250 语言)+ 32 内置 .tmTheme + 用户自定义**,`convert_style` 跳过 italic/underline;护栏 >512KB/>10000 行/单行 >4KiB 跳过高亮 | 离线,最全 | +--- -### 3.4 视觉特效 +## 四、界面特效与"高级感"(我们目前最朴素) -| 效果 | acecode | opencode | pi | kimi-code | grok | codex | +| 效果 | 我们 | opencode | pi | kimi-code | grok | codex | |---|---|---|---|---|---|---| -| 逐帧动画背景 | ❌ | ✅ BgPulse 呼吸光圈 | ❌ | ❌ | ✅ sin² 波浪 accent | ❌ | -| RGBA 透明/混色 | ❌ | ✅ | ❌ | ❌ | ✅ framebuffer | ✅ **accent 按终端背景 alpha 混色** | -| **渐变品牌字** | ❌ | ❌ | ❌ | ✅ `gradientText`(逐字符 hex 插值,Kimi 品牌) | ❌ | ❌ | -| 特殊 spinner | ❌ | ✅ Knight Rider 扫描光带 | ✅ braille | ✅ **Moon 月相 + braille** | ✅ braille/dot/monitor | ✅ braille(标题动画) | -| 内联图片 | ❌ | ❌ | ✅ Kitty/iTerm2 | ✅ Kitty/iTerm2 + 缩略图 | ✅ Kitty/iTerm2 | ✅ **Sixel 手写编码 + ASCII pet** | -| 内联视频 | ❌ | ❌ | ❌ | ❌ | ✅ ffmpeg 抽帧 | ❌ | -| Mermaid 终端渲染 | ❌ | ❌ | ❌ | ❌ | ✅ Unicode box-drawing | ❌ | -| 终端标题动画 | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ Thinking 时 Braille 帧 | -| 彩蛋 | ❌ | ❌ | ❌ | ✅ easter-eggs/dance | ✅ gboom 游戏 | ✅ pets(Sixel) | -| 工具行 `●` 三态灯 | ✅(独有) | 文本 | 背景色块 | 背景色块 | accent 竖线 | `• ` bullet | - -### 3.5 输入编辑器 - -| | 能力 | +| 动态背景(会呼吸的光圈/波浪) | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | +| 半透明叠层(遮罩能透出底下) | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | +| **渐变品牌字** | ❌ | ❌ | ❌ | ✅(Kimi 紫蓝渐变) | ❌ | ❌ | +| 好看的加载动画 | 仅静态 ● | ✅ 霓虹扫描光条 | ✅ 转圈 | ✅ **月亮月相 + 转圈** | ✅ 多种 | ✅ 转圈 | +| 内嵌图片 | ❌ | ❌ | ✅ | ✅(含缩略图) | ✅ | ✅(ASCII 宠物) | +| 内嵌视频 | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| 图表(流程图等)直接画在终端 | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| 工具调用行"● 三态指示灯" | ✅ **我们的招牌** | 纯文字 | 底色块 | 底色块 | 竖线 | 圆点 | + +**小结:** +- **opencode / grok 是"特效派"**(动画背景、霓虹加载、内嵌媒体),**codex / pi 是"流畅稳派"**,kimi-code 在 pi 的底子上加了很强的品牌包装(渐变字、月亮动画、欢迎页)。 +- 我们目前是**最朴素的一档**,但有一个别人没有的招牌:**工具调用行的"● 三态指示灯"**(灰=正在跑 / 绿=成功 / 红=失败,一眼看清工具执行状态),这是我们的差异化亮点,值得保留并强化。 + +> 想亲眼看:kimi 的渐变字 `demos/10_gradient_text.py`;半透明 vs 不透明 `demos/04_alpha_transparency.py`;动态背景 `demos/05_animated_background.py`;各家加载动画同屏 `demos/06_spinner_showcase.py`;我们的 ● 指示灯 `demos/08_tool_row_dots.py`。 + +--- + +## 五、输入框体验(打字、粘贴、快捷键) + +| 产品 | 输入框能力 | |---|---| -| acecode | `input_history_navigation`/`path_reference_input`/`paste_handler`/`ime_windows`;**无 extmark、无输入内高亮、无 leader key** | -| **opencode** | TextareaRenderable:输入内语法高亮、**extmark 虚拟文本**、IME、keymap mode stack + leader key | -| **pi** | 自研 editor:Emacs 风格、undo、kill-ring、自动补全、CJK 断行、IME APC 光标 | -| **kimi-code** | `custom-editor.ts`(自研)+ `file-mention-provider`(`@` 文件补全)+ `wrapping-select-list` | -| **grok** | `xai-ratatui-textarea` grapheme 编辑 + Vim/Simple 双模式 | -| **codex** | 自研 `TextArea`(`bottom_pane/textarea.rs`,注释里点名 tui-textarea 的坑):**Vim 模式**(normal/operator/text-object)、kill buffer、`TextElement` 原子元素(mention/大粘贴占位)、掩码输入;**配置驱动 `RuntimeKeymap`**(app/chat/composer/editor/vim 多上下文 + **key chord 和弦**) | +| **ACECode** | 基础:历史记录、路径补全、粘贴处理、中文输入法兼容。**没有**输入框内高亮、没有虚拟占位、没有组合快捷键 | +| **opencode** | 最强:输入框里直接**语法高亮**、可以插入"图片占位符"这种虚拟元素、支持复杂组合快捷键(如两键和弦)、粘贴智能压缩 | +| **pi** | 强:撤销栈、剪贴板环、自动补全、长粘贴自动折叠成"[已粘贴 N 行]"、对中文/日文断行友好 | +| **kimi-code** | 同 pi + 文件 `@` 补全 | +| **grok-build** | 极客双模式(Vim/普通),圆角输入框 | +| **codex** | 最强之一:自带 Vim 模式、剪贴板删除缓冲、粘贴压缩、**配置驱动的完整快捷键体系**(支持组合键) | -### 3.6 终端协议 +**一句话:输入框这块 opencode / codex 最接近"编辑器级"体验,我们目前是基础款。** 对产品经理来说,最值得抄的两个点:① 长粘贴自动折叠(贴 500 行不用闪屏);② 复杂快捷键(leader + 组合键)。 -| 协议 | acecode | opencode | pi | kimi | crush | grok | codex | -|---|---|---|---|---|---|---|---| -| **CSI 2026 同步输出** | ❌ | (原生) | ✅ | ✅ | (tea) | ✅ | ✅ | -| **OSC 8 超链接** | ⚠️ 检测但不发射(FTXUI 限制) | ✅ | ✅ | ✅ | ❌ | ✅ 自动 linkify | ✅ **链接元数据与几何分离**(换行不含转义字节,长 URL 跨行合并) | -| **OSC 133 prompt 标记** | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | -| **kitty keyboard** | ❌(仅消费标准 CSI 修饰符) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ **按终端精细调优** | -| **CSI-u 微调** | ❌ | 通用 | 通用 | 通用 | ❌ | 通用 | ✅ iTerm2/Ghostty 抑制 release(`>5u`)、kitty 保 repeat(`>7u`)、tmux 确认 `extended-keys-format` 才用 modifyOtherKeys、VS Code+WSL 禁用 | -| **终端探测** | 仅 env 检测 | 原生层 | kitty 协商 | 同 pi | (tea) | DA2/XTVERSION 20+ | ✅ **100ms 有界探测**(CPR/OSC10/11/键盘增强),驱动主题+键盘模式 | +--- -**codex 终端探测**(新增):`terminal_probe.rs` 用 100ms 预算探测光标位置(CPR `ESC[6n`)、默认前景/背景色(OSC 10/11)、键盘增强支持,探测期间独占终端输入 —— 比 crossterm 默认 2s 超时更快更稳。探测结果驱动:UI accent 按终端背景自动调色、键盘模式按终端分支选择。 +## 六、键盘与终端"认不认识"你按的键(细节但影响大) -### 3.7 跨平台 +终端有个老毛病:它分不清"你按了 Enter"还是"Shift+Enter"(都报成同一个信号)。键盘增强协议(专业名 kitty keyboard / CSI-u)能解决——按 Shift+Enter 就能识别出"带 Shift"。 -| | Windows 处理 | 独有 | +| 产品 | 是否启用键盘增强 | 程度 | |---|---|---| -| acecode | conhost compat 布局 + alt-screen 兜底 | 老 conhost 兼容最扎实 | -| **grok** | stderr 开 VTP + `SetConsoleOutputCP(UTF-8)`、legacy 字形回退、QuickEdit 保留 | 最彻底的字形/编码处理 | -| **codex** | `SetConsoleMode` 对 stdout+stderr 开 VTP;**关闭 `ENABLE_VIRTUAL_TERMINAL_INPUT` 走 Win32 input records**(每次 poll 重断言,防其它 console client 改回 VT 输入导致导航键变裸转义字节) | macOS fd2 dup2 /dev/null 守卫 stderr | -| **pi/kimi** | 原生 .node addon(ENABLE_VIRTUAL_TERMINAL_INPUT + isModifierPressed) | 原生修饰键查询 | -| opencode | kernel32 FFI 清 PROCESSED_INPUT + 100ms Ctrl+C guard | — | -| crush | Bubble Tea 自带 | — | +| **ACECode** | ❌ 没启用 | 只消费通用的方向键修饰信号,Shift+Enter 这种拿不到 | +| **opencode / pi / kimi-code / grok** | ✅ | 通用启用 | +| **codex** | ✅ | **最精细**:针对不同终端(苹果终端/kitty/Ghostty/Windows 版 VS Code)分别调优,还会先探测你的终端再决定用哪种 | + +另外,codex 会在启动时用 0.1 秒快速"摸一下"你的终端是什么型号、支持什么颜色、什么键盘能力,然后自动适配(比如界面主题颜色跟着你的终端背景自动变)。**这是一个低成本高回报的点:让界面自动适配终端,而不是写死。** + +--- + +## 七、Windows / 老终端兼容(我们的强项) + +| 产品 | 对老 Windows 终端的态度 | +|---|---| +| **ACECode** | ✅ **最扎实**:老式 CMD/ConEmu 上自动退化成纯 ASCII 边框、去掉大 Logo,保证还能看 | +| **grok-build** | 很彻底:强制把终端设为 UTF-8(否则特殊符号变乱码),老终端自动换能显示的符号 | +| **codex** | 只面向现代终端(Windows Terminal),老式 CMD 基本不考虑 | +| **pi/kimi/opencode/crush** | 主要测现代终端 | + +**这是我们的护城河之一:老终端兼容性全场最好。** 其他产品大多"只在现代终端上测",我们连 2018 年之前的旧版 CMD 都做了降级处理。 + +--- -### 3.8 主题系统 +## 八、主题 / 换肤 -| | 方案 | +| 产品 | 换肤能力 | |---|---| -| acecode | dark/light 两套预置 `ThemePalette`,不透明色 | -| **opencode** | 30+ JSON 主题 + 用户自定义 + **system 主题从终端调色板现场生成** | -| **grok** | GrokNight/Day + TokyoNight 等 5 套 + OSC 11 跟随终端背景 + 量化降级 | -| **pi** | JSON dark/light 60+ token,热重载 | -| **kimi-code** | `theme.ts` + **`custom-theme-loader`** + `gradient-text` + `detect.ts`(终端背景探测)+ `theme-selector` 对话框 | -| **codex** | 语法:two-face 32 主题 + 用户 `.tmTheme`;**UI 强调色按终端背景 alpha 自适应**(暗底 12% 白、亮底 4% 黑混合;表格分隔线 20% alpha 混色) | +| **ACECode** | 内置深/浅两套配色,不可自定义 | +| **opencode** | 30+ 内置主题 + 用户自定义 + **自动从你的终端取色生成主题** | +| **grok-build** | 5 套主题 + 跟随终端背景色 | +| **pi / kimi-code** | 深/浅 + 热重载(改配置立刻生效)+ kimi 支持自定义主题加载和终端背景探测 | +| **codex** | 语法配色 32 套 + 用户自定义;**界面强调色跟随你的终端背景自动调**(深底自动用浅色强调) | + +**一句话:换肤是我们的明显短板**(只有两套写死的),别人都支持用户自定义甚至自动跟随终端。**这是产品经理能直接感知的"高级感"差距。** -### 3.9 性能 / 虚拟化 +--- -| | 手段 | +## 九、长对话 / 大输出的流畅度 + +| 产品 | 手段 | |---|---| -| acecode | `redraw_pacer` 帧率限制 + `tool_result_fold` + `chat_render_window` | -| **codex** | **scrollback-first**(历史出界,每帧 diff 极小)+ **FrameRequester/FrameScheduler actor + 120FPS 上限** + 多级缓存(history render cache / ActiveCellLayoutCache / stable_prefix_len_cache)+ resize 回灌行数上限 | -| **grok** | `entry_layouts_cache` 高度缓存 + scrollback 虚拟化 + SafeBuf | -| **pi/kimi** | per-frame renderCache + render-churn bench | -| **opencode** | ScrollBoxRenderable 裁剪 + SSE 16ms batching + conceal | -| crush | stable-prefix glamour 缓存 + section cache | +| **ACECode** | 限帧率 + 折叠超长输出 + 聊天区窗口化 | +| **codex** | 最先进:说完的话滚进终端历史区(见第一节),界面永远只画一小块 + 120 帧上限 + 多层缓存 | +| **grok-build** | 虚拟滚动(只画看得见的)+ 高度缓存 | +| **pi/kimi** | 每帧缓存 + 性能基准测试 | +| **opencode** | 只画可视区域 + 流式合并更新 + 代码自动折叠 | +| **crush** | 增量排版缓存 | + +**一句话:长对话的流畅度,codex 领先(靠"历史出界面"这一招),我们是中游偏下。** 我们的"折叠超长输出"思路对,但还停留在"全量重排 + 限帧"层面。 --- -## 四、acecode 独有 / 仍占优的点 +## 十、我们的独有优势(值得保住) -1. **单一 C++ 二进制、零运行时依赖**(opencode 下 tree-sitter WASM、pi/kimi 装 node+addon、Rust 项目要编译)。 -2. **老 Windows 终端(conhost/Cmder/ConEmu)兼容最扎实**。 -3. **`●` 工具调用行 + Ctrl+O/E 折叠语义**自创紧凑风格。 -4. **daemon + 桌面壳 + headless 多形态共用同一 TUI 渲染逻辑**。 -5. **双模式(alt-screen / scrollback)** 与 codex 的 alt/inline 双模式同构 —— 说明 acecode 的架构方向与最成熟的 codex 一致,差距在实现深度(同步输出、URL 感知回灌、逐终端调优)。 +1. **老 Windows 终端兼容全场最好**(见第七节)。 +2. **工具调用行"● 三态指示灯"**是自创招牌,信息密度高、一眼看懂执行状态。 +3. **单一安装包、零依赖、断网可用**——opencode 要联网下语法包,pi/kimi 要装 Node,我们一个二进制全搞定。 +4. **一个界面引擎同时服务终端版 / 后台服务 / 桌面壳 / 无头模式**,架构上是"一套渲染多处用",别人大多是单体。 --- -## 五、acecode 最值得补的差距(按性价比排序) +## 十一、最该补的 8 个差距(按性价比排序) -| # | 差距 | 难度 | 收益 | 参考实现 | +> 难度:★ 低 ~ ★★★ 高;收益:指用户能直接感知的改善。 + +| # | 差距 | 难度 | 收益 | 一句话 | |---|---|---|---|---| -| 1 | **流式 markdown 增量渲染** | 中 | 高 | crush stable-prefix / grok checkpoint / codex 两区域+提交动画 | -| 2 | **CSI 2026 synchronized output** | 低 | 中 | pi/codex/grok | -| 3 | **OSC 8 超链接真正可用** | 高 | 中 | codex 几何分离 / grok linkify | -| 4 | **kitty keyboard / CSI-u 按终端调优** | 中 | 中 | codex keyboard_modes / opencode | -| 5 | **终端探测(CPR/OSC10/11,100ms)驱动主题+键盘** | 中 | 中 | codex terminal_probe | -| 6 | **主题扩展**(用户自定义 + 终端背景自适应 accent) | 中 | 中 | codex style.rs / kimi custom-theme-loader | -| 7 | **输入框 extmark / 输入内高亮 / key chord** | 高 | 中 | codex TextArea / opencode | -| 8 | **scrollback URL 感知写入 + resize 回灌行数上限** | 中 | 中 | codex insert_history / resize_reflow_cap | +| 1 | **流式输出增量排版**(别再整篇重排) | ★★ | 高 | 长对话卡顿,最明显的短板,crush/codex 有现成方案 | +| 2 | **同步刷新防闪烁** | ★ | 中 | 一劳永逸消除老终端闪烁,改动小 | +| 3 | **可点击链接真正可用** | ★★★ | 中 | 我们其实写了检测代码,但框架限制发不出去(点不了),需要绕过框架 | +| 4 | **键盘增强协议**(分得清 Shift+Enter) | ★★ | 中 | codex 已按终端逐个调优 | +| 5 | **启动时快速探测终端能力**,自动适配主题/键盘 | ★★ | 中 | codex 0.1 秒探测,低成本高回报 | +| 6 | **换肤能力**(用户自定义 + 跟随终端背景) | ★★ | 中 | 目前只有两套写死,产品感知明显 | +| 7 | **输入框升级**(粘贴折叠、组合快捷键) | ★★★ | 中 | opencode/codex 已是编辑器级 | +| 8 | **滚屏模式升级**:历史写完就落定、界面只留当前块 | ★★ | 中 | 这是我们对齐 codex 流畅度的正路 | -> 第 3、7 项受 FTXUI Element 网格模型限制较深。第 8 项恰好是 acecode `TerminalOutput()` 模式的演进方向 —— 从"光标回退重绘"升级为"写死历史 + 独立视口"。 +> 第 3、7 项受我们底层界面框架(FTXUI)限制较深,需要绕过框架或改造;其余几项可以直接抄成熟方案。 --- -## 六、演示索引 +## 十二、演示(亲眼看效果) -所有演示为自包含 Python 脚本,在 Windows Terminal / kitty / WezTerm / iTerm2 下可直接运行: +`docs/tui-comparison/demos/` 下有 10 个可直接运行的小脚本,每个演示一个效果并标注"谁有、我们现状如何": -``` +| 演示 | 效果 | 我们的现状 | +|---|---|---| +| `01_synchronized_output` | 同步刷新 vs 不刷新的闪烁对比 | ❌ 没有 | +| `02_osc8_hyperlinks` | 可点击链接 vs 我们的纯色文本 | ⚠️ 检测了但点不了 | +| `03_kitty_keyboard` | 键盘增强协议(交互,按 q 退出) | ❌ 未启用 | +| `04_alpha_transparency` | 半透明叠层 vs 不透明 | ❌ 不透明 | +| `05_animated_background` | 动态呼吸背景(动画,Ctrl+C 退出) | ❌ 没有 | +| `06_spinner_showcase` | 各家加载动画同屏对比 | ❌ 仅静态 ● | +| `07_osc133_prompts` | 回合分界标记(可跳转) | ❌ 没有 | +| `08_tool_row_dots` | **我们的 ● 三态指示灯(招牌)** | ✅ 独有 | +| `09_streaming_markdown` | 流式增量排版 vs 整篇重排耗时对比 | ❌ 整篇重排 | +| `10_gradient_text` | Kimi 渐变品牌字 | ❌ 单色 | + +运行方式(需 Python 3.8+,推荐 Windows Terminal): + +```bash cd docs/tui-comparison/demos -python 01_synchronized_output.py # CSI 2026 同步输出:闪烁对比 -python 02_osc8_hyperlinks.py # OSC 8 可点击超链接 -python 03_kitty_keyboard.py # kitty keyboard 协议(交互) -python 04_alpha_transparency.py # RGBA alpha 混色对比 -python 05_animated_background.py # 正弦呼吸动画背景 -python 06_spinner_showcase.py # 五种 spinner 同屏对比 -python 07_osc133_prompts.py # OSC 133 prompt 语义标记 -python 08_tool_row_dots.py # acecode ● 三态指示灯(本项目优势) -python 09_streaming_markdown.py # 流式 markdown 增量渲染基准 -python 10_gradient_text.py # kimi 渐变品牌字(新增) +python run_static.py # 批量跑非交互的(01/02/04/07/08/09/10) +python 05_animated_background.py # 动画类单独跑,Ctrl+C 退出 +python 03_kitty_keyboard.py # 交互类,按 q 退出 ``` - -详见 [`demos/README.md`](./demos/README.md)。 From 3dc9b0309f32662a31a26c969bd45dda5c65de6d Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Tue, 18 Aug 2026 22:52:10 +0800 Subject: [PATCH 04/16] docs: add skill dormancy design spec --- .../plans/2026-08-18-skill-dormancy-design.md | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-18-skill-dormancy-design.md diff --git a/docs/superpowers/plans/2026-08-18-skill-dormancy-design.md b/docs/superpowers/plans/2026-08-18-skill-dormancy-design.md new file mode 100644 index 00000000..7693592b --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-skill-dormancy-design.md @@ -0,0 +1,190 @@ +# Skill 休眠(Dormancy)管理 — 设计文档 + +> 2026-08-18 | 基于 brainstorming 需求澄清结论 +> +> 一句话:技能长期未用后,从系统提示词的自动候选列表中隐藏(休眠),但手动调用/path 匹配/显式提及全部保留,调用后自动唤醒。 + +--- + +## 1. 背景与问题 + +ACECode 的技能库随使用增长,每个技能的名称和描述都会注入系统提示词的 `` 列表。很多技能长期未用,它们不删除也不禁用,只是"暂时用不上"——却在列表中占着上下文,干扰模型注意力。 + +**目标**:引入一个低风险、非破坏性的"休眠"机制,让闲置技能不再出现在自动推荐列表里,但保留所有主动使用路径,被调用后自动恢复。 + +--- + +## 2. 三个状态 + +| 状态 | 进入自动列表? | 模型/用户能否使用? | 说明 | +|------|:---:|:---:|------| +| `active`(活跃) | ✅ 是 | ✅ 完全可用 | 正常状态 | +| `dormant`(休眠) | ❌ 否 | ✅ 可用——手动 `/skill`、显式提及名、path 匹配全部正常 | 从列表里"藏起来",不阻碍任何真实调用 | +| `disabled`(禁用) | ❌ 否 | ❌ 不可用 | 现有功能,彻底关闭 | + +**核心区别**:休眠只做"眼不见"(省 context),不做"手不能"。被调用后自动恢复活跃。 + +--- + +## 3. 用户感受 + +### 日常使用 +- 正常使用技能,系统自动记录"最后使用时间" +- 超过 30 天(可配置)未用的技能,下次新会话时不再出现在自动候选列表里 +- 手动 `/技能名` 调用照常执行,同时自动恢复活跃,回到列表 +- path 匹配激活、显式提及名全部保留生效 + +### 查看和管理 +- `/skills` 面板或 Web 设置页展示每个技能的:**使用次数、最后使用时间、当前状态(活跃/休眠)** +- **驻留(pin)**:把重要技能标记为"驻留",永不休眠——即使半年不用也在列表里 +- **解除驻留**:恢复自动判定 + +### 配置 +- `skills.idleDays`(默认 30):超过此天数未用视为休眠 +- 设为 0:关闭休眠功能,所有技能永远活跃 + +--- + +## 4. 架构 + +``` +触发路径(inject_explicit_skill_instructions / path 激活 / 模型自动) + └─ injection 成功后 → record_skill_usage(name) + + ┌─────────────────────────────────────────────┐ + │ skill_usage_store (新模块 src/skills/) │ + │ ~/.acecode/.skill_usage_state.json │ + │ { version:1, skills{ name: {lastUsedAt, │ + │ useCount, pinned} } } │ + │ 进程内 mutex + 原子写(临时文件 + rename) │ + └──────────────┬──────────────────────────────┘ + │ + ┌────────────────┼───────────────────┐ + ▼ ▼ ▼ +自动列表过滤 TUI 展示 Web 展示 +(构建时实时判定) /skills 面板 settings 页 +active→注入 次数/最后使用/状态 次数/状态/pin +dormant→跳过 + pin 操作 + 唤醒操作 +``` + +--- + +## 5. 数据模型 + +```jsonc +// ~/.acecode/.skill_usage_state.json +{ + "version": 1, + "skills": { + "pdf": { "lastUsedAt": "2026-08-01T10:00:00Z", "useCount": 12, "pinned": false }, + "xlsx": { "lastUsedAt": "2026-05-20T09:00:00Z", "useCount": 3, "pinned": false } + } +} +``` + +- `lastUsedAt`:最近一次注入时间(ISO8601),首次使用自动初始化 +- `useCount`:累计注入次数 +- `pinned`:是否驻留(永不休眠) +- **dormant 不落盘为持久标记**——读取时实时判定:`now - lastUsedAt > idleDays && !pinned` → 视为休眠 + +### 配置项 + +**`skills.idleDays`**(新,默认 30): +- 整数,>=0 +- 0 = 关闭休眠功能 +- 进现有 config schema + TUI/Web 设置项 + +--- + +## 6. 记录链路 + +### 触发点 + +所有 skill 注入路径收敛到 `record_skill_usage(name)`: + +| 触发点 | 文件 | 说明 | +|--------|------|------| +| 显式注入 | `agent_loop.cpp`:`inject_explicit_skill_instructions` | 用户 `/skill` 或模型提及名 | +| path 激活 | skill activation 匹配处 | 工具触碰匹配路径,自动激活 | +| 模型自动 | 主循环注入路径 | 模型从列表中选择调用 | + +### record_skill_usage 行为 + +1. 进程内 mutex 锁住 store +2. 读状态文件(带大小上限,防坏文件) +3. 记录存在 → `useCount++`、`lastUsedAt = now`;不存在 → 新建(`useCount = 1`) +4. 原子写(临时文件 + rename,0600 权限) +5. 失败不抛异常——best-effort,不阻断注入主流程 + +**计数口径**:注入即计数。同会话重复注入每次都计。只计成功注入(失败不注入→不计)。 + +--- + +## 7. 休眠过滤 + +### 判定公式 + +``` +is_dormant(skill) = !pinned && (now - lastUsedAt > idleDays) +``` + +### 过滤位置 + +`build_skills_index_context_prompt`(构建系统提示词自动列表处): + +``` +遍历 skill 列表: + if is_dormant(skill) → 跳过,不注入自动列表 + else → 正常注入 +``` + +### 不受影响 + +- 用户手动 `/skill-name` — 执行正常,同时刷新 lastUsedAt +- 显式提及名 — 注入正常 +- path 匹配激活 — 激活正常,注入正常 +- `/skills` 面板 — 所有 skill 可见(含休眠,标注状态) +- `skill_view` — 不受过滤 + +--- + +## 8. 边界 + +| 场景 | 行为 | +|------|------| +| 新 skill 首次使用 | 自动建记录,`lastUsedAt` = 首次注入时间,`useCount` = 1,默认 active | +| skill 被卸载 | 状态记录保留,下次构建列表时自动跳过(文件不存在) | +| 状态文件损坏/丢失 | 全部 skill 视为无历史,下一轮从头累积,不影响功能 | +| 用户 pin 一个休眠 skill | 立即生效,下次构建列表时恢复出现 | +| 用户手动调用休眠 skill | 执行正常,`lastUsedAt` 刷新,下次会话自动恢复 active | +| daemon 和 TUI 同时写 | 原子写(临时文件+rename),后写者覆盖,不产生破损文件 | +| `idleDays = 0` | 关闭休眠,全部 active | +| 无 skill 的系统 | 状态文件为空/不存在,构建列表时无过滤,行为不变 | + +--- + +## 9. 实现路径 + +| # | 任务 | 涉及文件 | 说明 | +|---|------|----------|------| +| 1 | 新增 store | `src/skills/skill_usage_store.{hpp,cpp}` | 读/写状态文件,原子 rename,进程内 mutex | +| 2 | 记录钩子 | `src/agent_loop.cpp` + `src/skills/skill_activation.cpp` | `record_skill_usage(name)` 插入注入成功处 | +| 3 | 列表过滤 | `src/agent_loop.cpp`(`build_skills_index_context_prompt`) | 构建自动列表时按 `is_dormant` 跳过 | +| 4 | 配置项 | `src/config/` | 新增 `skills.idleDays`(默认 30) | +| 5 | 状态字段 | `src/tui_state.hpp` | 新增 `skill_usage_store` 引用 | +| 6 | TUI 展示 | `src/tui/`(settings 或 /skills 面板) | 次数/最后使用/状态/pin 操作 | +| 7 | Web 展示 | `src/web/`(开辟现有 API 或新增) | skill 状态 + pin/唤醒 | +| 8 | 测试 | `tests/skills/skill_usage_store_test.cpp` | 读/写/并发/边界/判定逻辑 | + +--- + +## 10. 测试清单 + +- [ ] 新建 skill → 自动创建记录,初始 active +- [ ] 注入后刷新 `lastUsedAt`,`useCount` 递增 +- [ ] 超阈值未用 → `is_dormant` = true,不进自动列表 +- [ ] pinned 的 skill 即使超阈值也保持 active +- [ ] 手动调用休眠 skill 后恢复 active +- [ ] 状态文件损坏时优雅降级,不影响功能 +- [ ] 原子写并发安全 +- [ ] `idleDays = 0` 关闭休眠 \ No newline at end of file From c9f632200dfbb3869747c64e23ca97be354f3c16 Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Wed, 19 Aug 2026 07:32:49 +0800 Subject: [PATCH 05/16] docs: add skill dormancy implementation plan --- .../plans/2026-08-18-skill-dormancy-plan.md | 638 ++++++++++++++++++ 1 file changed, 638 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-18-skill-dormancy-plan.md diff --git a/docs/superpowers/plans/2026-08-18-skill-dormancy-plan.md b/docs/superpowers/plans/2026-08-18-skill-dormancy-plan.md new file mode 100644 index 00000000..a13f21ef --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-skill-dormancy-plan.md @@ -0,0 +1,638 @@ +# Skill 休眠(Dormancy)管理 — 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 skill 使用次数记录与自动休眠管理——闲置 skill 从系统提示词自动列表中隐藏,手动调用/path 匹配保留,调用后自动恢复。 + +**Architecture:** 新建 `skill_usage_store` 模块管理 `~/.acecode/.skill_usage_state.json`(进程内 mutex + 原子 rename),在 agent_loop 注入点记录使用,在 `build_skills_index_context_prompt` 构建列表时实时判定 `is_dormant` 过滤。配置项 `skills.idleDays` 进 SkillsConfig。 + +**Tech Stack:** C++17, nlohmann/json, GoogleTest, CMake/Ninja, 沿用 `atomic_write_file` 模式 + +**Spec:** `docs/superpowers/plans/2026-08-18-skill-dormancy-design.md` + +## Global Constraints + +- 遵循 `.editorconfig`:UTF-8, LF, 4 空格缩进 C++ +- 无 emoji/宽字符,ASCII 符号 +- 使用 `EXPECT_*` 断言(除非失败使后续不安全) +- 测试用 `fs::temp_directory_path()`,不写仓库树 +- 文件权限 0600,原子写(临时文件 + rename) +- `record_skill_usage` 是 best-effort,失败不阻断主流程 + +--- + +## File Structure + +| 文件 | 职责 | 新建/修改 | +|------|------|:---:| +| `src/skills/skill_usage_store.hpp` | 数据模型 + 公开接口声明 | 新建 | +| `src/skills/skill_usage_store.cpp` | store 实现:读/写/判定/并发 | 新建 | +| `src/agent_loop.cpp` | 注入点记录 + 列表过滤 | 修改 | +| `src/config/config.hpp` | `SkillsConfig` 新增 `idle_days` | 修改 | +| `src/tui_state.hpp` | 新增 store 引用 | 修改 | +| `src/main.cpp` | 初始化 store + 传参 | 修改 | +| `src/tui/settings/management_center.cpp` | TUI 展示次数/状态/pin | 修改 | +| `src/web/handlers/skills_handler.cpp` | Web API 返回状态 + pin | 修改 | +| `tests/skills/skill_usage_store_test.cpp` | store 单元测试 | 新建 | +| `src/CMakeLists.txt` | 注册新源文件 | 修改 | +| `tests/CMakeLists.txt` | 注册新测试 | 修改 | + +--- + +### Task 1: 数据模型与 store 接口声明 + +**Files:** +- Create: `src/skills/skill_usage_store.hpp` + +**Interfaces:** +- Produces: `SkillUsageRecord`, `SkillUsageSummary`, `SkillUsageStore`, `parse_iso8601_to_epoch_ms` + +- [ ] **Step 1: 编写 header** + +```cpp +// src/skills/skill_usage_store.hpp +#pragma once + +#include +#include +#include +#include + +namespace acecode { + +struct SkillUsageRecord { + std::string last_used_at; // ISO8601 + std::uint64_t use_count = 0; + bool pinned = false; +}; + +struct SkillUsageSummary { + std::string name; + std::uint64_t use_count = 0; + std::string last_used_at; + bool pinned = false; + bool dormant = false; // 实时判定 +}; + +class SkillUsageStore { +public: + explicit SkillUsageStore(std::string state_path); + + bool record(const std::string& skill_name, const std::string& now_iso); + bool is_dormant(const std::string& skill_name, + std::int64_t now_epoch_ms, + std::int64_t idle_days_ms) const; + bool set_pinned(const std::string& skill_name, bool pinned); + std::vector get_summary( + std::int64_t now_epoch_ms, std::int64_t idle_days_ms) const; + void reload(); + +private: + std::string state_path_; + mutable std::mutex mu_; +}; + +std::int64_t parse_iso8601_to_epoch_ms(const std::string& iso); + +} // namespace acecode +``` + +- [ ] **Step 2: 编译验证** + +Run: `cmake --build build --target acecode --config Release` +Expected: 编译通过(仅有声明,链接时符号缺失是预期) + +- [ ] **Step 3: Commit** + +```bash +git add src/skills/skill_usage_store.hpp +git commit -m "feat: add SkillUsageStore header with interface declarations" +``` + +--- + +### Task 2: Store 实现 + +**Files:** +- Create: `src/skills/skill_usage_store.cpp` +- Modify: `src/CMakeLists.txt` + +**Interfaces:** +- Consumes: `SkillUsageStore`, `SkillUsageRecord`, `SkillUsageSummary` from Task 1 +- Produces: full `SkillUsageStore` impl, `parse_iso8601_to_epoch_ms` + +- [ ] **Step 1: 编写实现** + +```cpp +// src/skills/skill_usage_store.cpp +#include "skills/skill_usage_store.hpp" +#include "utils/atomic_file.hpp" +#include "utils/logger.hpp" + +#include + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace acecode { + +namespace { +constexpr int kStateVersion = 1; +constexpr std::size_t kMaxStateFileBytes = 1024 * 1024; // 1 MB + +nlohmann::json load_state_or_empty(const std::string& path) { + std::error_code ec; + if (!fs::exists(path, ec)) return nlohmann::json::object(); + if (fs::file_size(path, ec) > kMaxStateFileBytes) { + LOG_WARN("[skill_usage] state file too large, ignoring"); + return nlohmann::json::object(); + } + std::ifstream ifs(path); + if (!ifs) return nlohmann::json::object(); + try { + auto j = nlohmann::json::parse(ifs); + if (!j.is_object() || j.value("version", 0) != kStateVersion) { + LOG_WARN("[skill_usage] version mismatch, resetting"); + return nlohmann::json::object(); + } + return j; + } catch (const nlohmann::json::exception& e) { + LOG_WARN("[skill_usage] parse error: " + std::string(e.what())); + return nlohmann::json::object(); + } +} +} // namespace + +SkillUsageStore::SkillUsageStore(std::string state_path) + : state_path_(std::move(state_path)) {} + +bool SkillUsageStore::record(const std::string& skill_name, + const std::string& now_iso) { + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + auto& skills = state["skills"]; + if (!skills.contains(skill_name)) { + skills[skill_name] = {{"lastUsedAt", now_iso}, + {"useCount", 1}, + {"pinned", false}}; + } else { + auto& entry = skills[skill_name]; + entry["lastUsedAt"] = now_iso; + entry["useCount"] = entry.value("useCount", 0u) + 1u; + } + return atomic_write_file(state_path_, state.dump(2)); +} + +bool SkillUsageStore::is_dormant(const std::string& skill_name, + std::int64_t now_epoch_ms, + std::int64_t idle_days_ms) const { + if (idle_days_ms == 0) return false; + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + auto& skills = state["skills"]; + if (!skills.contains(skill_name)) return false; + auto& entry = skills[skill_name]; + if (entry.value("pinned", false)) return false; + std::string last_used = entry.value("lastUsedAt", ""); + if (last_used.empty()) return false; + std::int64_t last_ms = parse_iso8601_to_epoch_ms(last_used); + if (last_ms == 0) return false; + return (now_epoch_ms - last_ms) > idle_days_ms; +} + +bool SkillUsageStore::set_pinned(const std::string& skill_name, bool pinned) { + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + auto& skills = state["skills"]; + if (!skills.contains(skill_name)) { + skills[skill_name] = {{"lastUsedAt", ""}, + {"useCount", 0}, + {"pinned", pinned}}; + } else { + skills[skill_name]["pinned"] = pinned; + } + return atomic_write_file(state_path_, state.dump(2)); +} + +std::vector SkillUsageStore::get_summary( + std::int64_t now_epoch_ms, std::int64_t idle_days_ms) const { + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + std::vector out; + for (auto& [name, entry] : state["skills"].items()) { + SkillUsageSummary s; + s.name = name; + s.use_count = entry.value("useCount", 0u); + s.last_used_at = entry.value("lastUsedAt", ""); + s.pinned = entry.value("pinned", false); + if (idle_days_ms > 0 && !s.pinned && !s.last_used_at.empty()) { + std::int64_t last_ms = parse_iso8601_to_epoch_ms(s.last_used_at); + s.dormant = (last_ms > 0) && + (now_epoch_ms - last_ms) > idle_days_ms; + } + out.push_back(std::move(s)); + } + return out; +} + +void SkillUsageStore::reload() { + // load_state_or_empty on next access handles this +} + +std::int64_t parse_iso8601_to_epoch_ms(const std::string& iso) { + std::tm tm = {}; + std::istringstream ss(iso); + ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S"); + if (ss.fail()) return 0; + int ms = 0; + if (ss.peek() == '.') { ss.ignore(); ss >> ms; } + auto tp = std::chrono::system_clock::from_time_t( + std::mktime(&tm)) + std::chrono::milliseconds(ms); + return std::chrono::duration_cast( + tp.time_since_epoch()).count(); +} + +} // namespace acecode +``` + +- [ ] **Step 2: 注册到 CMake** + +Modify `src/CMakeLists.txt`:在 `skills/` 源文件列表追加 `skills/skill_usage_store.cpp` + +- [ ] **Step 3: 编译验证** + +Run: `cmake --build build --target acecode --config Release` +Expected: 编译通过 + +- [ ] **Step 4: Commit** + +```bash +git add src/skills/skill_usage_store.cpp src/CMakeLists.txt +git commit -m "feat: implement SkillUsageStore with JSON read/write" +``` + +--- + +### Task 3: Store 单元测试 + +**Files:** +- Create: `tests/skills/skill_usage_store_test.cpp` +- Modify: `tests/CMakeLists.txt` + +**Interfaces:** +- Consumes: `SkillUsageStore`, `parse_iso8601_to_epoch_ms` from Task 2 + +- [ ] **Step 1: 编写测试** + +```cpp +#include "skills/skill_usage_store.hpp" +#include +#include +namespace fs = std::filesystem; + +TEST(SkillUsageStoreTest, ParseIso8601) { + auto ms = acecode::parse_iso8601_to_epoch_ms("2026-08-01T10:00:00Z"); + EXPECT_GT(ms, 0); + EXPECT_EQ(ms, acecode::parse_iso8601_to_epoch_ms("2026-08-01T10:00:00Z")); + EXPECT_EQ(0, acecode::parse_iso8601_to_epoch_ms("not-a-date")); + EXPECT_EQ(0, acecode::parse_iso8601_to_epoch_ms("")); +} + +TEST(SkillUsageStoreTest, RecordCreatesEntry) { + auto tmp = fs::temp_directory_path() / "acecode_test_skill_usage.json"; + fs::remove(tmp); + acecode::SkillUsageStore store(tmp.string()); + EXPECT_TRUE(store.record("pdf", "2026-08-01T10:00:00Z")); + EXPECT_FALSE(store.is_dormant("pdf", 1722500000000LL, 30LL * 86400000)); + auto s = store.get_summary(1722500000000LL, 30LL * 86400000); + EXPECT_EQ(s.size(), 1u); + EXPECT_EQ(s[0].name, "pdf"); + EXPECT_EQ(s[0].use_count, 1u); + EXPECT_FALSE(s[0].dormant); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, DormantAfterThreshold) { + auto tmp = fs::temp_directory_path() / "acecode_test_dormant.json"; + fs::remove(tmp); + acecode::SkillUsageStore store(tmp.string()); + std::int64_t used_ms = 1722500000000LL; + std::int64_t now_ms = used_ms + 40LL * 86400000; + std::int64_t idle_ms = 30LL * 86400000; + std::string used_iso = "2026-08-01T10:00:00Z"; + EXPECT_TRUE(store.record("xlsx", used_iso)); + EXPECT_TRUE(store.is_dormant("xlsx", now_ms, idle_ms)); + auto s = store.get_summary(now_ms, idle_ms); + EXPECT_EQ(s.size(), 1u); + EXPECT_TRUE(s[0].dormant); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, PinnedSkillNeverDormant) { + auto tmp = fs::temp_directory_path() / "acecode_test_pinned.json"; + fs::remove(tmp); + acecode::SkillUsageStore store(tmp.string()); + EXPECT_TRUE(store.record("pinned_skill", "2026-06-01T10:00:00Z")); + EXPECT_TRUE(store.set_pinned("pinned_skill", true)); + std::int64_t now_ms = 1730000000000LL; + std::int64_t idle_ms = 30LL * 86400000; + EXPECT_FALSE(store.is_dormant("pinned_skill", now_ms, idle_ms)); + auto s = store.get_summary(now_ms, idle_ms); + EXPECT_EQ(s.size(), 1u); + EXPECT_FALSE(s[0].dormant); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, IdleDaysZeroDisablesFeature) { + auto tmp = fs::temp_directory_path() / "acecode_test_zero.json"; + fs::remove(tmp); + acecode::SkillUsageStore store(tmp.string()); + EXPECT_TRUE(store.record("skill", "2020-01-01T00:00:00Z")); + EXPECT_FALSE(store.is_dormant("skill +", 0)); + auto s = store.get_summary(1730000000000LL, 0); + EXPECT_EQ(s.size(), 1u); + EXPECT_FALSE(s[0].dormant); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, IncrementUseCount) { + auto tmp = fs::temp_directory_path() / "acecode_test_incr.json"; + fs::remove(tmp); + acecode::SkillUsageStore store(tmp.string()); + store.record("pdf", "2026-08-01T10:00:00Z"); + store.record("pdf", "2026-08-02T10:00:00Z"); + store.record("pdf", "2026-08-03T10:00:00Z"); + auto s = store.get_summary(1722500000000LL, 30LL * 86400000); + EXPECT_EQ(s.size(), 1u); + EXPECT_EQ(s[0].use_count, 3u); + fs::remove(tmp); +} +``` + +- [ ] **Step 2: 注册到 tests/CMakeLists.txt** + +在 `tests/CMakeLists.txt` 中添加(仿现有 `default_skill_seeder_test.cpp` 模式): +```cmake +add_executable(acecode_skill_usage_store_test + skills/skill_usage_store_test.cpp +) +target_link_libraries(acecode_skill_usage_store_test ...) +add_test(NAME acecode_skill_usage_store_test ...) +``` + +- [ ] **Step 3: 运行测试,确认失败(如果 store 实现未完成则 link 失败)** + +Run: `cmake --build build --target acecode_skill_usage_store_test && ./build/tests/acecode_skill_usage_store_test` +Expected: 7/7 tests pass + +- [ ] **Step 4: Commit** + +```bash +git add tests/skills/skill_usage_store_test.cpp tests/CMakeLists.txt +git commit -m "test: add SkillUsageStore unit tests" +``` + +--- + +### Task 4: 配置项 `skills.idleDays` + +**Files:** +- Modify: `src/config/config.hpp` + +**Interfaces:** +- Produces: `SkillsConfig::idle_days` (int, default 30) + +- [ ] **Step 1: 添加字段** + +在 `src/config/config.hpp` 的 `SkillsConfig` struct 中追加: + +```cpp +struct SkillsConfig { + std::vector disabled; + std::vector external_dirs; + bool reuse_opencode = true; + std::optional> allowed; + int idle_days = 30; // 新增:0=关闭休眠,>0=判定阈值 +}; +``` + +- [ ] **Step 2: 编译验证** + +Run: `cmake --build build --target acecode --config Release` +Expected: 编译通过 + +- [ ] **Step 3: Commit** + +```bash +git add src/config/config.hpp +git commit -m "feat: add idle_days config to SkillsConfig" +``` + +--- + +### Task 5: 注入点记录 + 列表过滤 + +**Files:** +- Modify: `src/agent_loop.cpp` + +**Interfaces:** +- Consumes: `SkillUsageStore` from Task 2, `SkillsConfig::idle_days` from Task 4 +- Produces: injection recording + dormant filtering + +- [ ] **Step 1: 在注入成功处插入 `record_skill_usage`** + +在 `inject_explicit_skill_instructions` 返回后,对每个 `injected_skill_names` 调用: + +```cpp +// agent_loop.cpp, after inject_explicit_skill_instructions(...) +if (skill_usage_store_) { + auto now_iso = /* current time as ISO8601 */; + for (const auto& name : skill_expansion.injected_skill_names) { + skill_usage_store_->record(name, now_iso); + } +} +``` + +生成 ISO8601 的辅助: +```cpp +#include +#include +#include + +static std::string now_iso8601() { + auto now = std::chrono::system_clock::now(); + auto t = std::chrono::system_clock::to_time_t(now); + std::ostringstream oss; + oss << std::put_time(std::gmtime(&t), "%Y-%m-%dT%H:%M:%SZ"); + return oss.str(); +} +``` + +- [ ] **Step 2: 在 `build_skills_index_context_prompt` 处加过滤** + +调用处(agent_loop.cpp 约 957 行),在构建列表前: + +```cpp +auto now_ms = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); +auto idle_ms = config_ ? config_->skills().idle_days * 86400000LL : 0LL; + +auto skill_list = skill_registry_->list(); +std::vector active_skills; +for (const auto& s : skill_list) { + if (!skill_usage_store_ || + !skill_usage_store_->is_dormant(s.name, now_ms, idle_ms)) { + active_skills.push_back(s); + } +} +// 用 active_skills 替代原 skill_list 构建上下文 +``` + +- [ ] **Step 3: 编译验证** + +Run: `cmake --build build --target acecode --config Release` +Expected: 编译通过 + +- [ ] **Step 4: Commit** + +```bash +git add src/agent_loop.cpp +git commit -m "feat: record skill usage on injection and filter dormant skills" +``` + +--- + +### Task 6: 初始化与状态传递 + +**Files:** +- Modify: `src/tui_state.hpp` +- Modify: `src/main.cpp` + +**Interfaces:** +- Consumes: `SkillUsageStore` from Task 2 +- Produces: `tui_state.skill_usage_store` 可用 + +- [ ] **Step 1: 在 tui_state 添加 store 引用** + +```cpp +// src/tui_state.hpp, 在 slash_command_usage_counts 附近 +#include "skills/skill_usage_store.hpp" +// ... +std::shared_ptr skill_usage_store; +``` + +- [ ] **Step 2: 在 main.cpp 初始化 store** + +```cpp +// src/main.cpp, 在 skill_registry 初始化后 +auto home = /* acecode home dir */; +auto state_path = home + "/.skill_usage_state.json"; +state.skill_usage_store = std::make_shared(state_path); +``` + +- [ ] **Step 3: 编译验证** + +Run: `cmake --build build --target acecode --config Release` +Expected: 编译通过 + +- [ ] **Step 4: Commit** + +```bash +git add src/tui_state.hpp src/main.cpp +git commit -m "feat: wire SkillUsageStore into tui_state and main" +``` + +--- + +### Task 7: TUI 展示 + +**Files:** +- Modify: `src/tui/settings/management_center.cpp` + +- [ ] **Step 1: 在 /skills 面板或管理中心的 skill 列表追加状态列** + +在现有 skill 列表渲染处,为每个 skill 追加: + +```cpp +// 在显示每个 skill 的行末追加 +auto summary = skill_usage_store_->get_summary( + now_epoch_ms(), + config_.skills().idle_days * 86400000LL); +for (const auto& s : summary) { + std::string status = s.dormant ? " [dormant]" + : s.pinned ? " [pinned]" + : " [active]"; + std::string info = " used " + std::to_string(s.use_count) + + " times, last " + s.last_used_at; + // 渲染到 TUI 行 +} +``` + +- [ ] **Step 2: 编译验证** + +Run: `cmake --build build --target acecode --config Release` +Expected: 编译通过 + +- [ ] **Step 3: Commit** + +```bash +git add src/tui/settings/management_center.cpp +git commit -m "feat: display skill usage stats and dormant status in TUI" +``` + +--- + +### Task 8: Web 展示 + +**Files:** +- Modify: `src/web/handlers/skills_handler.cpp` + +- [ ] **Step 1: 在现有 GET /skills API 响应中追加 usage 字段** + +```cpp +// 在 skills_handler 的 JSON 响应中追加 +nlohmann::json usage_array = nlohmann::json::array(); +if (skill_usage_store_) { + for (const auto& s : skill_usage_store_->get_summary( + now_epoch_ms(), idle_days_ms)) { + usage_array.push_back({ + {"name", s.name}, + {"useCount", s.use_count}, + {"lastUsedAt", s.last_used_at}, + {"pinned", s.pinned}, + {"dormant", s.dormant} + }); + } +} +response["usage"] = std::move(usage_array); +``` + +- [ ] **Step 2: 编译验证** + +Run: `cmake --build build --target acecode --config Release` +Expected: 编译通过 + +- [ ] **Step 3: Commit** + +```bash +git add src/web/handlers/skills_handler.cpp +git commit -m "feat: expose skill usage stats via Web API" +``` + +--- + +## Verification + +完成所有 8 个 task 后: + +```bash +cmake --build build --target acecode_unit_tests --config Release +ctest --test-dir build --output-on-failure +``` + +预期:所有现有测试 + 新增 `acecode_skill_usage_store_test` 全部通过。 From 146905a3592369cc695a8e39947671a3634f1d94 Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Wed, 19 Aug 2026 13:28:16 +0800 Subject: [PATCH 06/16] docs: fix plan CMake registration steps (sources auto-collected by GLOB) --- .../plans/2026-08-18-skill-dormancy-plan.md | 33 +++++-------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/docs/superpowers/plans/2026-08-18-skill-dormancy-plan.md b/docs/superpowers/plans/2026-08-18-skill-dormancy-plan.md index a13f21ef..ff1285a2 100644 --- a/docs/superpowers/plans/2026-08-18-skill-dormancy-plan.md +++ b/docs/superpowers/plans/2026-08-18-skill-dormancy-plan.md @@ -34,8 +34,8 @@ | `src/tui/settings/management_center.cpp` | TUI 展示次数/状态/pin | 修改 | | `src/web/handlers/skills_handler.cpp` | Web API 返回状态 + pin | 修改 | | `tests/skills/skill_usage_store_test.cpp` | store 单元测试 | 新建 | -| `src/CMakeLists.txt` | 注册新源文件 | 修改 | -| `tests/CMakeLists.txt` | 注册新测试 | 修改 | +| (顶层 CMake GLOB_RECURSE 自动收集 src/*.cpp,无需注册) | - | - | +| (tests/ GLOB 自动收集 *_test.cpp,无需注册) | - | - | --- @@ -259,19 +259,15 @@ std::int64_t parse_iso8601_to_epoch_ms(const std::string& iso) { } // namespace acecode ``` -- [ ] **Step 2: 注册到 CMake** - -Modify `src/CMakeLists.txt`:在 `skills/` 源文件列表追加 `skills/skill_usage_store.cpp` - -- [ ] **Step 3: 编译验证** +- [ ] **Step 2: 编译验证(源文件由顶层 CMake GLOB_RECURSE 自动收集,无需注册)** Run: `cmake --build build --target acecode --config Release` Expected: 编译通过 -- [ ] **Step 4: Commit** +- [ ] **Step 3: Commit** ```bash -git add src/skills/skill_usage_store.cpp src/CMakeLists.txt +git add src/skills/skill_usage_store.cpp git commit -m "feat: implement SkillUsageStore with JSON read/write" ``` @@ -374,26 +370,15 @@ TEST(SkillUsageStoreTest, IncrementUseCount) { } ``` -- [ ] **Step 2: 注册到 tests/CMakeLists.txt** +- [ ] **Step 2: 运行测试(测试由 tests/ GLOB 自动收集进 acecode_unit_tests,无需注册)** -在 `tests/CMakeLists.txt` 中添加(仿现有 `default_skill_seeder_test.cpp` 模式): -```cmake -add_executable(acecode_skill_usage_store_test - skills/skill_usage_store_test.cpp -) -target_link_libraries(acecode_skill_usage_store_test ...) -add_test(NAME acecode_skill_usage_store_test ...) -``` - -- [ ] **Step 3: 运行测试,确认失败(如果 store 实现未完成则 link 失败)** - -Run: `cmake --build build --target acecode_skill_usage_store_test && ./build/tests/acecode_skill_usage_store_test` +Run: `cmake --build build --target acecode_unit_tests && ctest --test-dir build --output-on-failure -R skill_usage` Expected: 7/7 tests pass -- [ ] **Step 4: Commit** +- [ ] **Step 3: Commit** ```bash -git add tests/skills/skill_usage_store_test.cpp tests/CMakeLists.txt +git add tests/skills/skill_usage_store_test.cpp git commit -m "test: add SkillUsageStore unit tests" ``` From cd0a0cb7c431d9be6cf2cbfd89916627d68b1c40 Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Wed, 19 Aug 2026 13:31:13 +0800 Subject: [PATCH 07/16] feat: add SkillUsageStore header with interface declarations --- src/skills/skill_usage_store.hpp | 67 ++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/skills/skill_usage_store.hpp diff --git a/src/skills/skill_usage_store.hpp b/src/skills/skill_usage_store.hpp new file mode 100644 index 00000000..48e129ac --- /dev/null +++ b/src/skills/skill_usage_store.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include + +namespace acecode { + +struct SkillUsageRecord { + std::string last_used_at; // ISO8601, e.g. "2026-08-01T10:00:00Z" + std::uint64_t use_count = 0; + bool pinned = false; +}; + +struct SkillUsageSummary { + std::string name; + std::uint64_t use_count = 0; + std::string last_used_at; + bool pinned = false; + bool dormant = false; // 实时判定结果(仅展示用,不落盘) +}; + +// Manages the skill-usage state file (~/.acecode/.skill_usage_state.json). +// Thread-safe: every public method serializes on an internal mutex. +class SkillUsageStore { +public: + // state_path: absolute path to the JSON state file. + // Does not touch disk until the first record()/is_dormant() call. + explicit SkillUsageStore(std::string state_path); + + // Record one successful use of skill_name at now (ISO8601). + // Creates the record if absent (use_count = 1); otherwise increments + // use_count and refreshes last_used_at. Best-effort: returns false on + // persistence failure, never throws. + bool record(const std::string& skill_name, const std::string& now_iso); + + // Dormancy predicate: + // !pinned && (now_epoch_ms - last_used_epoch_ms) > idle_days_ms + // Returns false when idle_days_ms == 0 (feature disabled), when the + // record is absent, or when the timestamp cannot be parsed. + bool is_dormant(const std::string& skill_name, + std::int64_t now_epoch_ms, + std::int64_t idle_days_ms) const; + + // Set or clear the pinned flag. Creates a record if absent. + // Best-effort: returns false on persistence failure, never throws. + bool set_pinned(const std::string& skill_name, bool pinned); + + // Snapshot of all records for display (TUI/Web). The `dormant` field is + // computed live from now_epoch_ms / idle_days_ms and is not persisted. + std::vector get_summary( + std::int64_t now_epoch_ms, std::int64_t idle_days_ms) const; + + // Re-read the state file on the next access (call after config changes). + void reload(); + +private: + std::string state_path_; + mutable std::mutex mu_; +}; + +// Parse an ISO8601 timestamp ("2026-08-01T10:00:00Z", optional .ms fraction) +// to epoch milliseconds. Returns 0 on parse failure or empty input. +std::int64_t parse_iso8601_to_epoch_ms(const std::string& iso); + +} // namespace acecode From f53b4f934f2353e7c7a1702b6f5511b6f4b42869 Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Wed, 19 Aug 2026 13:34:19 +0800 Subject: [PATCH 08/16] feat: implement SkillUsageStore with JSON read/write --- src/skills/skill_usage_store.cpp | 157 +++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 src/skills/skill_usage_store.cpp diff --git a/src/skills/skill_usage_store.cpp b/src/skills/skill_usage_store.cpp new file mode 100644 index 00000000..fd33d5ee --- /dev/null +++ b/src/skills/skill_usage_store.cpp @@ -0,0 +1,157 @@ +#include "skills/skill_usage_store.hpp" + +#include "utils/atomic_file.hpp" +#include "utils/logger.hpp" + +#include + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace acecode { + +namespace { + +constexpr int kStateVersion = 1; +constexpr std::size_t kMaxStateFileBytes = 1024 * 1024; // 1 MB + +// Read the state file into a JSON object. Returns an empty object when the +// file is absent, oversized, corrupted, or has an unsupported version so the +// feature degrades gracefully instead of failing the caller. +nlohmann::json load_state_or_empty(const std::string& path) { + std::error_code ec; + if (!fs::exists(path, ec)) return nlohmann::json::object(); + if (fs::file_size(path, ec) > kMaxStateFileBytes) { + LOG_WARN("[skill_usage] state file too large, ignoring"); + return nlohmann::json::object(); + } + std::ifstream ifs(path); + if (!ifs) return nlohmann::json::object(); + try { + auto j = nlohmann::json::parse(ifs); + if (!j.is_object() || j.value("version", 0) != kStateVersion) { + LOG_WARN("[skill_usage] state file version mismatch, resetting"); + return nlohmann::json::object(); + } + return j; + } catch (const nlohmann::json::exception& e) { + LOG_WARN("[skill_usage] state file parse error: " + + std::string(e.what())); + return nlohmann::json::object(); + } +} + +} // namespace + +SkillUsageStore::SkillUsageStore(std::string state_path) + : state_path_(std::move(state_path)) {} + +bool SkillUsageStore::record(const std::string& skill_name, + const std::string& now_iso) { + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + auto& skills = state["skills"]; + if (!skills.is_object()) { + skills = nlohmann::json::object(); + } + if (!skills.contains(skill_name)) { + skills[skill_name] = {{"lastUsedAt", now_iso}, + {"useCount", 1}, + {"pinned", false}}; + } else { + auto& entry = skills[skill_name]; + entry["lastUsedAt"] = now_iso; + entry["useCount"] = entry.value("useCount", 0u) + 1u; + } + return atomic_write_file(state_path_, state.dump(2)); +} + +bool SkillUsageStore::is_dormant(const std::string& skill_name, + std::int64_t now_epoch_ms, + std::int64_t idle_days_ms) const { + if (idle_days_ms == 0) return false; + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + auto& skills = state["skills"]; + if (!skills.is_object() || !skills.contains(skill_name)) return false; + auto& entry = skills[skill_name]; + if (!entry.is_object() || entry.value("pinned", false)) return false; + const std::string last_used = entry.value("lastUsedAt", ""); + if (last_used.empty()) return false; + const std::int64_t last_ms = parse_iso8601_to_epoch_ms(last_used); + if (last_ms == 0) return false; + return (now_epoch_ms - last_ms) > idle_days_ms; +} + +bool SkillUsageStore::set_pinned(const std::string& skill_name, bool pinned) { + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + auto& skills = state["skills"]; + if (!skills.is_object()) { + skills = nlohmann::json::object(); + } + if (!skills.contains(skill_name)) { + skills[skill_name] = {{"lastUsedAt", ""}, + {"useCount", 0}, + {"pinned", pinned}}; + } else { + skills[skill_name]["pinned"] = pinned; + } + return atomic_write_file(state_path_, state.dump(2)); +} + +std::vector SkillUsageStore::get_summary( + std::int64_t now_epoch_ms, std::int64_t idle_days_ms) const { + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + std::vector out; + auto& skills = state["skills"]; + if (!skills.is_object()) return out; + for (auto& [name, entry] : skills.items()) { + if (!entry.is_object()) continue; + SkillUsageSummary s; + s.name = name; + s.use_count = entry.value("useCount", 0u); + s.last_used_at = entry.value("lastUsedAt", ""); + s.pinned = entry.value("pinned", false); + if (idle_days_ms > 0 && !s.pinned && !s.last_used_at.empty()) { + const std::int64_t last_ms = + parse_iso8601_to_epoch_ms(s.last_used_at); + s.dormant = (last_ms > 0) && + (now_epoch_ms - last_ms) > idle_days_ms; + } + out.push_back(std::move(s)); + } + return out; +} + +void SkillUsageStore::reload() { + // The next access re-reads the file via load_state_or_empty; nothing + // to invalidate here. +} + +std::int64_t parse_iso8601_to_epoch_ms(const std::string& iso) { + if (iso.empty()) return 0; + std::tm tm = {}; + std::istringstream ss(iso); + ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S"); + if (ss.fail()) return 0; + int ms = 0; + if (ss.peek() == '.') { + ss.ignore(); + ss >> ms; + } + // Treat the parsed time as UTC (the 'Z' suffix / gmtime convention). + auto tp = std::chrono::system_clock::from_time_t( + std::mktime(&tm)) + + std::chrono::milliseconds(ms); + return std::chrono::duration_cast( + tp.time_since_epoch()) + .count(); +} + +} // namespace acecode From 720ee7f2ebe83eff43904f3522d462be012c255f Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Wed, 19 Aug 2026 13:42:22 +0800 Subject: [PATCH 09/16] test: add SkillUsageStore unit tests and fix version field persistence --- src/skills/skill_usage_store.cpp | 2 + tests/skills/skill_usage_store_test.cpp | 116 ++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 tests/skills/skill_usage_store_test.cpp diff --git a/src/skills/skill_usage_store.cpp b/src/skills/skill_usage_store.cpp index fd33d5ee..fec4504a 100644 --- a/src/skills/skill_usage_store.cpp +++ b/src/skills/skill_usage_store.cpp @@ -54,6 +54,7 @@ bool SkillUsageStore::record(const std::string& skill_name, const std::string& now_iso) { std::lock_guard lock(mu_); auto state = load_state_or_empty(state_path_); + state["version"] = kStateVersion; auto& skills = state["skills"]; if (!skills.is_object()) { skills = nlohmann::json::object(); @@ -90,6 +91,7 @@ bool SkillUsageStore::is_dormant(const std::string& skill_name, bool SkillUsageStore::set_pinned(const std::string& skill_name, bool pinned) { std::lock_guard lock(mu_); auto state = load_state_or_empty(state_path_); + state["version"] = kStateVersion; auto& skills = state["skills"]; if (!skills.is_object()) { skills = nlohmann::json::object(); diff --git a/tests/skills/skill_usage_store_test.cpp b/tests/skills/skill_usage_store_test.cpp new file mode 100644 index 00000000..af506063 --- /dev/null +++ b/tests/skills/skill_usage_store_test.cpp @@ -0,0 +1,116 @@ +#include "skills/skill_usage_store.hpp" + +#include + +#include +#include + +namespace fs = std::filesystem; + +namespace { + +constexpr std::int64_t kDayMs = 24LL * 60 * 60 * 1000; + +fs::path temp_state_file(const std::string& name) { + fs::path tmp = fs::temp_directory_path() / + ("acecode_skill_usage_" + name + ".json"); + std::error_code ec; + fs::remove(tmp, ec); + return tmp; +} + +} // namespace + +TEST(SkillUsageStoreTest, ParseIso8601) { + const auto ms = acecode::parse_iso8601_to_epoch_ms("2026-08-01T10:00:00Z"); + EXPECT_GT(ms, 0); + EXPECT_EQ(ms, + acecode::parse_iso8601_to_epoch_ms("2026-08-01T10:00:00Z")); + // 无效输入返回 0 + EXPECT_EQ(0, acecode::parse_iso8601_to_epoch_ms("not-a-date")); + EXPECT_EQ(0, acecode::parse_iso8601_to_epoch_ms("")); +} + +TEST(SkillUsageStoreTest, RecordCreatesEntry) { + const fs::path tmp = temp_state_file("record"); + acecode::SkillUsageStore store(tmp.string()); + + EXPECT_TRUE(store.record("pdf", "2026-08-01T10:00:00Z")); + // 新记录应 active + EXPECT_FALSE(store.is_dormant("pdf", 1785578400000LL, 30 * kDayMs)); + auto s = store.get_summary(1785578400000LL, 30 * kDayMs); + ASSERT_EQ(s.size(), 1u); + EXPECT_EQ(s[0].name, "pdf"); + EXPECT_EQ(s[0].use_count, 1u); + EXPECT_FALSE(s[0].dormant); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, DormantAfterThreshold) { + const fs::path tmp = temp_state_file("dormant"); + acecode::SkillUsageStore store(tmp.string()); + + // 在 t=used_ms 使用,距今 40 天 > 30 天阈值 + const std::int64_t used_ms = 1785578400000LL; // 2026-08-01T10:00:00Z + const std::int64_t now_ms = used_ms + 40 * kDayMs; + EXPECT_TRUE(store.record("xlsx", "2026-08-01T10:00:00Z")); + EXPECT_TRUE(store.is_dormant("xlsx", now_ms, 30 * kDayMs)); + auto s = store.get_summary(now_ms, 30 * kDayMs); + ASSERT_EQ(s.size(), 1u); + EXPECT_TRUE(s[0].dormant); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, PinnedSkillNeverDormant) { + const fs::path tmp = temp_state_file("pinned"); + acecode::SkillUsageStore store(tmp.string()); + + EXPECT_TRUE(store.record("pinned_skill", "2026-06-01T10:00:00Z")); + EXPECT_TRUE(store.set_pinned("pinned_skill", true)); + const std::int64_t now_ms = 1780308000000LL + 60 * kDayMs; + EXPECT_FALSE(store.is_dormant("pinned_skill", now_ms, 30 * kDayMs)); + auto s = store.get_summary(now_ms, 30 * kDayMs); + ASSERT_EQ(s.size(), 1u); + EXPECT_FALSE(s[0].dormant); + EXPECT_TRUE(s[0].pinned); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, IdleDaysZeroDisablesFeature) { + const fs::path tmp = temp_state_file("zero"); + acecode::SkillUsageStore store(tmp.string()); + + EXPECT_TRUE(store.record("skill", "2020-01-01T00:00:00Z")); + EXPECT_FALSE(store.is_dormant("skill", 1785578400000LL, 0)); + auto s = store.get_summary(1785578400000LL, 0); + ASSERT_EQ(s.size(), 1u); + EXPECT_FALSE(s[0].dormant); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, IncrementUseCount) { + const fs::path tmp = temp_state_file("incr"); + acecode::SkillUsageStore store(tmp.string()); + + store.record("pdf", "2026-08-01T10:00:00Z"); + store.record("pdf", "2026-08-02T10:00:00Z"); + store.record("pdf", "2026-08-03T10:00:00Z"); + auto s = store.get_summary(1785578400000LL, 30 * kDayMs); + ASSERT_EQ(s.size(), 1u); + EXPECT_EQ(s[0].use_count, 3u); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, CorruptStateDegradesGracefully) { + const fs::path tmp = temp_state_file("corrupt"); + { + std::ofstream ofs(tmp); + ofs << "{ this is not valid json"; + } + acecode::SkillUsageStore store(tmp.string()); + + // 损坏文件:record 仍成功(内部重置),不抛异常 + EXPECT_TRUE(store.record("pdf", "2026-08-01T10:00:00Z")); + EXPECT_FALSE(store.is_dormant("pdf", 1785578400000LL, 30 * kDayMs)); + fs::remove(tmp); +} From 7c74f7646be0ef12241275127184b7d256b929aa Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Wed, 19 Aug 2026 13:48:40 +0800 Subject: [PATCH 10/16] feat: add idle_days config to SkillsConfig --- src/config/config.cpp | 8 +++++++- src/config/config.hpp | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/config/config.cpp b/src/config/config.cpp index 89039743..75763883 100644 --- a/src/config/config.cpp +++ b/src/config/config.cpp @@ -828,6 +828,9 @@ AppConfig load_config_from_path( if (sj.contains("reuse_opencode") && sj["reuse_opencode"].is_boolean()) { cfg.skills.reuse_opencode = sj["reuse_opencode"].get(); } + if (sj.contains("idle_days") && sj["idle_days"].is_number_integer()) { + cfg.skills.idle_days = sj["idle_days"].get(); + } } if (j.contains("memory") && j["memory"].is_object()) { const auto& mj = j["memory"]; @@ -1637,12 +1640,15 @@ nlohmann::json build_config_json(const AppConfig& cfg) { SkillsConfig skills_d; if (!cfg.skills.disabled.empty() || !cfg.skills.external_dirs.empty() || - cfg.skills.reuse_opencode != skills_d.reuse_opencode) { + cfg.skills.reuse_opencode != skills_d.reuse_opencode || + cfg.skills.idle_days != skills_d.idle_days) { nlohmann::json sj = nlohmann::json::object(); if (!cfg.skills.disabled.empty()) sj["disabled"] = cfg.skills.disabled; if (!cfg.skills.external_dirs.empty()) sj["external_dirs"] = cfg.skills.external_dirs; if (cfg.skills.reuse_opencode != skills_d.reuse_opencode) sj["reuse_opencode"] = cfg.skills.reuse_opencode; + if (cfg.skills.idle_days != skills_d.idle_days) + sj["idle_days"] = cfg.skills.idle_days; j["skills"] = sj; } diff --git a/src/config/config.hpp b/src/config/config.hpp index 7ef476c5..2a2b44bc 100644 --- a/src/config/config.hpp +++ b/src/config/config.hpp @@ -69,6 +69,9 @@ struct SkillsConfig { std::vector disabled; // skill names to hide even if present on disk std::vector external_dirs; // extra directories to scan (supports ~ and ${ENV}) bool reuse_opencode = true; // reuse opencode-compatible skill roots by default + // Days without use before a skill is treated as dormant (hidden from the + // automatic list). 0 disables dormancy entirely. + int idle_days = 30; // Runtime-only exact allowlist. nullopt keeps normal discovery behavior; // an engaged empty vector hides every skill. This field is intentionally // not loaded from or saved to config.json — headless mode uses it on its From 8db0d265bf95635de7af9eec89ec89b12ca1846c Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Wed, 19 Aug 2026 14:00:43 +0800 Subject: [PATCH 11/16] feat: record skill usage on injection and filter dormant skills from prompt --- src/agent_loop.cpp | 34 ++++++++++++++++++++++++++++++++-- src/agent_loop.hpp | 8 ++++++++ src/prompt/system_prompt.cpp | 12 +++++++++++- src/prompt/system_prompt.hpp | 6 +++++- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/agent_loop.cpp b/src/agent_loop.cpp index 7c0787bb..b175415b 100644 --- a/src/agent_loop.cpp +++ b/src/agent_loop.cpp @@ -22,6 +22,7 @@ #include "session/todo_state.hpp" #include "session/turn_timing.hpp" #include "skills/skill_activation.hpp" +#include "skills/skill_registry.hpp" #include "tool/ask_user_question_tool.hpp" #include "tool/mtime_tracker.hpp" #include "tool/tool_protocol_names.hpp" @@ -33,6 +34,7 @@ #include "headless/headless_mode.hpp" #include #include +#include #include #include #include @@ -425,6 +427,25 @@ void AgentLoop::set_cwd(const std::string& new_cwd) { git_snapshot_cache_.reset(); } +std::set AgentLoop::dormant_skill_names() const { + std::set out; + if (!skill_usage_store_ || skill_idle_days_ <= 0 || !skill_registry_) { + return out; + } + const std::int64_t now_ms = + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + const std::int64_t idle_ms = static_cast(skill_idle_days_) * + 24LL * 60 * 60 * 1000; + for (const auto& meta : skill_registry_->list()) { + if (skill_usage_store_->is_dormant(meta.name, now_ms, idle_ms)) { + out.insert(meta.name); + } + } + return out; +} + ResolvedQuestionPolicy AgentLoop::resolved_question_policy() const { const bool has_cli = !loop_cfg_.question_policy_cli.empty(); const std::string& configured = @@ -954,9 +975,10 @@ std::vector AgentLoop::build_compaction_initial_context() const { tools_.is_allowed("skills_list", &tool_capability_policy_); const bool spawn_subagent_available = tools_.is_allowed("spawn_subagent", &tool_capability_policy_); + const std::set dormant_skills = dormant_skill_names(); PromptContextBlock skill_context = build_skills_index_context_prompt( skill_registry_, context_window_.load(std::memory_order_relaxed), - skill_view_available, skills_list_available); + skill_view_available, skills_list_available, &dormant_skills); if (!skill_context.content.empty()) { ChatMessage skill_system; skill_system.role = "system"; @@ -1739,6 +1761,13 @@ AgentLoop::UserTurnInfo AgentLoop::prepare_user_turn(const UserInput& input, LOG_INFO("[skills] Injected " + std::to_string(skill_expansion.injected_skill_names.size()) + " explicitly selected Skill prompt(s) for this turn"); + if (skill_usage_store_) { + const std::string now = SessionStorage::now_iso8601(); + for (const auto& name : + skill_expansion.injected_skill_names) { + skill_usage_store_->record(name, now); + } + } } } const std::string& model_user_message = skill_expansion.prompt; @@ -1887,9 +1916,10 @@ AgentLoop::ApiRequestBundle AgentLoop::build_api_request_messages() { tools_.is_allowed("skills_list", &tool_capability_policy_); const bool spawn_subagent_available = tools_.is_allowed("spawn_subagent", &tool_capability_policy_); + const std::set dormant_skills = dormant_skill_names(); PromptContextBlock skill_context_block = build_skills_index_context_prompt( skill_registry_, context_window_.load(std::memory_order_relaxed), - skill_view_available, skills_list_available); + skill_view_available, skills_list_available, &dormant_skills); const bool skill_context_changed = skill_context_block.cache_key != skill_context_cache_key_; std::string skill_context = cached_context_for_api( diff --git a/src/agent_loop.hpp b/src/agent_loop.hpp index 8f12b4fa..2d17fa69 100644 --- a/src/agent_loop.hpp +++ b/src/agent_loop.hpp @@ -11,6 +11,7 @@ #include "session/ask_user_question_prompter.hpp" #include "config/config.hpp" #include "hooks/hook_runtime.hpp" +#include "skills/skill_usage_store.hpp" #include #include @@ -334,6 +335,11 @@ class AgentLoop { void notify_goal_objective_updated(); void set_skill_registry(const SkillRegistry* sr) { skill_registry_ = sr; } + void set_skill_usage_store(SkillUsageStore* store) { skill_usage_store_ = store; } + void set_skill_idle_days(int days) { skill_idle_days_ = days; } + // Names of skills that are dormant (idle past the threshold, not pinned). + // Returns an empty set when dormancy is disabled or the store is unset. + std::set dormant_skill_names() const; void set_memory_registry(const MemoryRegistry* mr) { memory_registry_ = mr; } void set_memory_config(const MemoryConfig* cfg) { memory_cfg_ = cfg; } void set_project_instructions_config(const ProjectInstructionsConfig* cfg) { @@ -587,6 +593,8 @@ class AgentLoop { std::vector hook_request_context_; bool stop_hook_active_ = false; const SkillRegistry* skill_registry_ = nullptr; + SkillUsageStore* skill_usage_store_ = nullptr; + int skill_idle_days_ = 30; const MemoryRegistry* memory_registry_ = nullptr; const MemoryConfig* memory_cfg_ = nullptr; const ProjectInstructionsConfig* project_instructions_cfg_ = nullptr; diff --git a/src/prompt/system_prompt.cpp b/src/prompt/system_prompt.cpp index 0540a9b9..4196d956 100644 --- a/src/prompt/system_prompt.cpp +++ b/src/prompt/system_prompt.cpp @@ -841,12 +841,22 @@ PromptContextBlock build_skills_index_context_prompt( const SkillRegistry* skills, int context_window_tokens, bool skill_view_available, - bool skills_list_available) { + bool skills_list_available, + const std::set* dormant_names) { PromptContextBlock block; if (!skills) return block; auto all = skills->list(); if (all.empty()) return block; + if (dormant_names && !dormant_names->empty()) { + all.erase( + std::remove_if(all.begin(), all.end(), + [&](const SkillMetadata& s) { + return dormant_names->count(s.name) != 0; + }), + all.end()); + if (all.empty()) return block; + } SkillIndexRenderResult rendered = format_skills_index_within_budget( all, skills_index_budget(context_window_tokens), skills_list_available); diff --git a/src/prompt/system_prompt.hpp b/src/prompt/system_prompt.hpp index 1872d5fa..aa85b416 100644 --- a/src/prompt/system_prompt.hpp +++ b/src/prompt/system_prompt.hpp @@ -2,6 +2,7 @@ #include "../tool/tool_executor.hpp" #include +#include #include #include @@ -111,11 +112,14 @@ SkillIndexRenderResult format_skills_index_within_budget( // Wrap the rendered index in a titled block with a content-hash cache key. // Null registry or empty skill list yields an empty block (not sent). +// `dormant_names` (optional): skill names to hide from the rendered index +// (dormant skills stay available via explicit mention but are not listed). PromptContextBlock build_skills_index_context_prompt( const SkillRegistry* skills, int context_window_tokens, bool skill_view_available = true, - bool skills_list_available = true); + bool skills_list_available = true, + const std::set* dormant_names = nullptr); // gitStatus 快照块(openspec add-git-context):把 collector 采集的快照文本 // 包成带缓存 key 的块。空文本(非仓库/采集失败/disabled)→ 空块不发送。 From 701f79dcbec901dbdbbde7ee198df194481b0304 Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Wed, 19 Aug 2026 14:06:06 +0800 Subject: [PATCH 12/16] feat: wire SkillUsageStore into tui_state and main --- src/main.cpp | 7 +++++++ src/tui_state.hpp | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/src/main.cpp b/src/main.cpp index 48cba35d..4ff545b7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4637,10 +4637,15 @@ static int run_interactive_app(const InteractiveCliOptions& cli, McpManager mcp_manager; MemoryConfig runtime_memory_cfg = initialize_tui_tools_and_registries( tools, skill_registry, memory_registry, mcp_manager, config, working_dir); + // Skill usage / dormancy state shared by the TUI session and any daemon + // surface. Best-effort: a read/write failure never blocks the session. + auto skill_usage_store = std::make_shared( + get_acecode_dir() + "/.skill_usage_state.json"); TuiState state; initialize_tui_state_before_screen(state, config, working_dir, dangerous_mode, mcp_manager, provider_accessor()); + state.skill_usage_store = skill_usage_store; state.slash_command_usage_counts = read_tui_slash_command_usage(); if (!startup_worktree_banner.empty()) { state.conversation.push_back({"system", startup_worktree_banner, false}); @@ -5107,6 +5112,8 @@ static int run_interactive_app(const InteractiveCliOptions& cli, agent_loop.set_agent_loop_config(config.agent_loop); agent_loop.set_hook_manager(&hook_manager); agent_loop.set_skill_registry(&skill_registry); + agent_loop.set_skill_usage_store(skill_usage_store.get()); + agent_loop.set_skill_idle_days(config.skills.idle_days); agent_loop.set_memory_registry(&memory_registry); agent_loop.set_memory_config(&runtime_memory_cfg); agent_loop.set_project_instructions_config(&config.project_instructions); diff --git a/src/tui_state.hpp b/src/tui_state.hpp index 2f087038..11c39534 100644 --- a/src/tui_state.hpp +++ b/src/tui_state.hpp @@ -3,6 +3,7 @@ #include "permissions.hpp" #include "provider/llm_provider.hpp" #include "path_reference/path_reference.hpp" +#include "skills/skill_usage_store.hpp" #include "tui/paste_handler.hpp" #include "tui/model_picker.hpp" #include "tui/mode_picker.hpp" @@ -18,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -346,6 +348,9 @@ struct TuiState { // Application-owned, cross-launch counters loaded once at TUI startup. // Dropdown refreshes read only this cache and never touch the filesystem. std::map slash_command_usage_counts; + // Skill usage/dormancy state shared with the AgentLoop. Non-owning from + // the TUI side; main() owns the shared_ptr. Null in headless modes. + std::shared_ptr skill_usage_store; // @ path-reference dropdown. The token offsets are UTF-8 byte offsets so // they can be applied directly to input_text without lossy conversion. From a15859aea718f25dda73441fb6bd7e4d00326944 Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Wed, 19 Aug 2026 14:12:03 +0800 Subject: [PATCH 13/16] feat: display skill usage stats and dormant status in TUI --- src/main.cpp | 1 + src/tui/settings/management_center.cpp | 36 ++++++++++++++++++++++++++ src/tui/settings/management_center.hpp | 2 ++ 3 files changed, 39 insertions(+) diff --git a/src/main.cpp b/src/main.cpp index 4ff545b7..58fd2f42 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8593,6 +8593,7 @@ static int run_interactive_app(const InteractiveCliOptions& cli, &mcp_manager, &tools, &hook_manager, + skill_usage_store.get(), working_dir, close_full_screen_surface, [&screen]() { screen.PostEvent(Event::Custom); }, diff --git a/src/tui/settings/management_center.cpp b/src/tui/settings/management_center.cpp index 8fa41c3a..e122d081 100644 --- a/src/tui/settings/management_center.cpp +++ b/src/tui/settings/management_center.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -1310,9 +1311,44 @@ struct ManagementCenter::Impl { .size() : 0) + " files"), + render_skill_usage(*skill), }) | color(theme().ui.text_muted) | border; } + Element render_skill_usage(const SkillMetadata& skill) const { + if (!deps.skill_usage || !deps.config) { + return text(""); + } + const std::int64_t now_ms = + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + const std::int64_t idle_ms = + static_cast(deps.config->skills.idle_days) * + 24LL * 60 * 60 * 1000; + const auto summaries = deps.skill_usage->get_summary( + now_ms, idle_ms); + std::string status; + std::uint64_t count = 0; + std::string last_used; + for (const auto& s : summaries) { + if (s.name == skill.name) { + status = s.pinned ? "[pinned]" + : s.dormant ? "[dormant]" + : "[active]"; + count = s.use_count; + last_used = s.last_used_at.empty() + ? "never" + : s.last_used_at.substr(0, 10); + break; + } + } + std::string line = "Usage " + status + " " + + std::to_string(count) + + " use(s), last " + last_used; + return text(line); + } + Element render_mcp_details() const { const McpServerInfo* row = selected_mcp(); if (!row) { diff --git a/src/tui/settings/management_center.hpp b/src/tui/settings/management_center.hpp index 007dc2ed..68c65598 100644 --- a/src/tui/settings/management_center.hpp +++ b/src/tui/settings/management_center.hpp @@ -3,6 +3,7 @@ #include "settings_state.hpp" #include "../../config/config.hpp" +#include "../../skills/skill_usage_store.hpp" #include @@ -27,6 +28,7 @@ struct ManagementCenterDependencies { McpManager* mcp = nullptr; ToolExecutor* tools = nullptr; HookManager* hooks = nullptr; + SkillUsageStore* skill_usage = nullptr; std::string cwd; std::function request_close; std::function post_event; From 3f63e04f4029807d352841e74fa4f7f418e61004 Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Wed, 19 Aug 2026 19:12:48 +0800 Subject: [PATCH 14/16] feat: expose skill usage stats via Web API --- src/web/handlers/skills_handler.cpp | 52 +++++++++++++++++++++++++++-- src/web/handlers/skills_handler.hpp | 9 +++-- src/web/routes/routes_experts.cpp | 3 +- src/web/routes/routes_files.cpp | 3 +- src/web/server.hpp | 4 +++ tests/web/skills_handler_test.cpp | 43 ++++++++++++++++++++++++ 6 files changed, 107 insertions(+), 7 deletions(-) diff --git a/src/web/handlers/skills_handler.cpp b/src/web/handlers/skills_handler.cpp index bcb289e8..b923074a 100644 --- a/src/web/handlers/skills_handler.cpp +++ b/src/web/handlers/skills_handler.cpp @@ -2,11 +2,15 @@ #include "../../skills/skill_init.hpp" #include "../../skills/skill_registry.hpp" +#include "../../skills/skill_usage_store.hpp" #include "../../utils/utf8_path.hpp" #include "../../utils/logger.hpp" #include +#include +#include #include +#include #include namespace fs = std::filesystem; @@ -138,7 +142,10 @@ std::optional get_skill_body(const std::string& name, nlohmann::json build_skills_payload_with_roots( const std::vector& project_roots, const std::vector& global_roots, - const std::vector& disabled_list) { + const std::vector& disabled_list, + const SkillUsageStore* skill_usage, + std::int64_t now_epoch_ms, + int idle_days) { // 扫描时 disabled 置空 —— 禁用中的 skill 也保留完整元数据(描述/来源), // 而不是像旧实现那样只剩一个名字。 std::unordered_set project_root_keys; @@ -157,6 +164,18 @@ nlohmann::json build_skills_payload_with_roots( const std::unordered_set disabled( disabled_list.begin(), disabled_list.end()); + // Usage/dormancy lookup for display. Empty when no store is wired up. + std::unordered_map usage_by_name; + if (skill_usage) { + const std::int64_t idle_ms = + idle_days > 0 ? static_cast(idle_days) * 24LL * 60 * + 60 * 1000 + : 0; + for (const auto& s : skill_usage->get_summary(now_epoch_ms, idle_ms)) { + usage_by_name.emplace(s.name, s); + } + } + nlohmann::json arr = nlohmann::json::array(); std::unordered_set listed; for (const auto& s : registry.list()) { @@ -169,6 +188,18 @@ nlohmann::json build_skills_payload_with_roots( o["source"] = project_root_keys.count( path_to_utf8(s.scan_root.lexically_normal())) ? "project" : "global"; + const auto usage_it = usage_by_name.find(s.name); + if (usage_it != usage_by_name.end()) { + o["useCount"] = usage_it->second.use_count; + o["lastUsedAt"] = usage_it->second.last_used_at; + o["pinned"] = usage_it->second.pinned; + o["dormant"] = usage_it->second.dormant; + } else { + o["useCount"] = 0; + o["lastUsedAt"] = ""; + o["pinned"] = false; + o["dormant"] = false; + } listed.insert(s.name); arr.push_back(std::move(o)); } @@ -184,17 +215,32 @@ nlohmann::json build_skills_payload_with_roots( o["category"] = ""; o["enabled"] = false; o["source"] = ""; + o["useCount"] = 0; + o["lastUsedAt"] = ""; + o["pinned"] = false; + o["dormant"] = false; arr.push_back(std::move(o)); } return arr; } nlohmann::json build_skills_payload(const AppConfig& cfg, - const std::string& workspace_cwd_utf8) { + const std::string& workspace_cwd_utf8, + const SkillUsageStore* skill_usage) { + std::int64_t now_epoch_ms = 0; + if (skill_usage) { + now_epoch_ms = + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + } return build_skills_payload_with_roots( project_skill_scan_roots(cfg, workspace_cwd_utf8), global_skill_scan_roots(cfg), - cfg.skills.disabled); + cfg.skills.disabled, + skill_usage, + now_epoch_ms, + cfg.skills.idle_days); } } // namespace acecode::web diff --git a/src/web/handlers/skills_handler.hpp b/src/web/handlers/skills_handler.hpp index 80ec69e3..28b92c07 100644 --- a/src/web/handlers/skills_handler.hpp +++ b/src/web/handlers/skills_handler.hpp @@ -21,6 +21,7 @@ namespace acecode { class SkillRegistry; +class SkillUsageStore; } namespace acecode::web { @@ -68,11 +69,15 @@ std::optional get_skill_body(const std::string& name, nlohmann::json build_skills_payload_with_roots( const std::vector& project_roots, const std::vector& global_roots, - const std::vector& disabled); + const std::vector& disabled, + const SkillUsageStore* skill_usage = nullptr, + std::int64_t now_epoch_ms = 0, + int idle_days = 0); // 便捷封装:扫描根取自 skill_init 的 project/global 根构成(与 // initialize_skill_registry 一致),disabled 取自 cfg.skills.disabled。 nlohmann::json build_skills_payload(const AppConfig& cfg, - const std::string& workspace_cwd_utf8); + const std::string& workspace_cwd_utf8, + const SkillUsageStore* skill_usage = nullptr); } // namespace acecode::web diff --git a/src/web/routes/routes_experts.cpp b/src/web/routes/routes_experts.cpp index bfd88e2c..023d0254 100644 --- a/src/web/routes/routes_experts.cpp +++ b/src/web/routes/routes_experts.cpp @@ -187,7 +187,8 @@ void WebServer::Impl::register_experts() { json skills = json::array(); if (config_snapshot) { const auto payload = - build_skills_payload(*config_snapshot, workspace->cwd); + build_skills_payload(*config_snapshot, workspace->cwd, + deps.skill_usage_store); for (const auto& item : payload) { const std::string id = item.value("name", std::string{}); diff --git a/src/web/routes/routes_files.cpp b/src/web/routes/routes_files.cpp index f09ec6f8..8ad98ec8 100644 --- a/src/web/routes/routes_files.cpp +++ b/src/web/routes/routes_files.cpp @@ -305,7 +305,8 @@ void WebServer::Impl::register_skills() { ws = compatibility_workspace(); } std::shared_lock config_lock(app_config_mu); - arr = build_skills_payload(*deps.app_config, ws->cwd); + arr = build_skills_payload(*deps.app_config, ws->cwd, + deps.skill_usage_store); } crow::response r(arr.dump()); r.add_header("Content-Type", "application/json"); diff --git a/src/web/server.hpp b/src/web/server.hpp index cc02a841..bb913724 100644 --- a/src/web/server.hpp +++ b/src/web/server.hpp @@ -35,6 +35,7 @@ class PtySessionRegistry; class SessionClient; class SessionRegistry; class SkillRegistry; +class SkillUsageStore; class ExpertRegistry; class ToolExecutor; } // namespace acecode @@ -81,6 +82,9 @@ struct WebServerDeps { acecode::desktop::WorkspaceRegistry* workspace_registry = nullptr; // 非 const:PUT /api/skills/:name 要写 cfg.skills.disabled 后调 set_disabled + reload。 SkillRegistry* skill_registry = nullptr; + // Skill usage/dormancy state shared with the TUI session. Null disables + // usage fields on /api/skills (headless or daemon-only processes). + SkillUsageStore* skill_usage_store = nullptr; // Daemon-global provider handle retained for routes/fixtures that inspect // process-level provider state. Current web session model switching is // session-scoped through SessionRegistry. diff --git a/tests/web/skills_handler_test.cpp b/tests/web/skills_handler_test.cpp index f4bd4c07..1ca84d18 100644 --- a/tests/web/skills_handler_test.cpp +++ b/tests/web/skills_handler_test.cpp @@ -10,6 +10,7 @@ #include "config/config.hpp" #include "skills/skill_registry.hpp" +#include "skills/skill_usage_store.hpp" #include #include @@ -338,3 +339,45 @@ TEST_F(SkillsHandlerTest, BuildSkillsPayloadDeduplicatesByNameProjectWins) { EXPECT_EQ((*entry)["source"], "project"); EXPECT_EQ((*entry)["description"], "project copy"); } + +// 场景: 传入 SkillUsageStore → payload 每条带 useCount/lastUsedAt/pinned/ +// dormant;未传入(默认)时这些字段以默认值(0/""/false/false)出现,保证 +// 前端字段形状稳定。 +TEST_F(SkillsHandlerTest, BuildSkillsPayloadCarriesUsageFields) { + const auto global_root = tmp_root / "home" / ".acecode" / "skills"; + write_skill(global_root, "used-skill", "used recently"); + write_skill(global_root, "idle-skill", "idle long enough"); + + const fs::path state = tmp_root / "skill_usage.json"; + acecode::SkillUsageStore store(state.string()); + // 最近使用(不休眠) + store.record("used-skill", "2026-08-10T10:00:00Z"); + store.record("used-skill", "2026-08-11T10:00:00Z"); + // 60 天前使用(超过 30 天阈值 → dormant) + store.record("idle-skill", "2026-06-01T10:00:00Z"); + + const std::int64_t now_ms = + acecode::parse_iso8601_to_epoch_ms("2026-08-15T10:00:00Z"); + + auto arr = acecode::web::build_skills_payload_with_roots( + {}, {global_root}, /*disabled=*/{}, &store, now_ms, /*idle_days=*/30); + + const auto* used = find_entry(arr, "used-skill"); + ASSERT_NE(used, nullptr); + EXPECT_EQ((*used)["useCount"].get(), 2u); + EXPECT_FALSE((*used)["dormant"].get()); + + const auto* idle = find_entry(arr, "idle-skill"); + ASSERT_NE(idle, nullptr); + EXPECT_TRUE((*idle)["dormant"].get()); + + // 无 store 时字段形状保持稳定 + auto arr_no_store = acecode::web::build_skills_payload_with_roots( + {}, {global_root}, /*disabled=*/{}); + const auto* no_store = find_entry(arr_no_store, "used-skill"); + ASSERT_NE(no_store, nullptr); + EXPECT_EQ((*no_store)["useCount"].get(), 0u); + EXPECT_FALSE((*no_store)["dormant"].get()); + + fs::remove(state); +} From 3ee2bd5d9d0384ec40673eeb9dfb65a0a03d25e6 Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Wed, 19 Aug 2026 19:16:14 +0800 Subject: [PATCH 15/16] feat: wire SkillUsageStore into daemon worker for Web UI --- src/daemon/worker.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/daemon/worker.cpp b/src/daemon/worker.cpp index e99fcb57..e4ba4603 100644 --- a/src/daemon/worker.cpp +++ b/src/daemon/worker.cpp @@ -31,6 +31,7 @@ #include "../session/session_user_message_search.hpp" #include "../skills/skill_registry.hpp" #include "../skills/skill_init.hpp" +#include "../skills/skill_usage_store.hpp" #include "../tool/ask_user_question_tool.hpp" #include "../tool/bash_tool.hpp" #include "../tool/builtin_tool_registry.hpp" @@ -579,6 +580,11 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { // 让 GET /api/skills 与 GET /api/commands 看到的 skill 集合与 TUI `/skills` 一致。 acecode::SkillRegistry skill_registry; acecode::initialize_skill_registry(skill_registry, cfg, cwd); + // Skill usage/dormancy state shared with the Web UI. Same state file the + // TUI process writes, so counts stay consistent across surfaces. Best- + // effort: read/write failures never break the daemon. + acecode::SkillUsageStore skill_usage_store( + acecode::get_acecode_dir() + "/.skill_usage_state.json"); acecode::ExpertRegistry expert_registry; acecode::ToolExecutor tools; @@ -708,6 +714,7 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { }; } web_deps.skill_registry = &skill_registry; + web_deps.skill_usage_store = &skill_usage_store; web_deps.provider = &provider; web_deps.provider_mu = &provider_mu; web_deps.dangerous = opts.dangerous; From 4e3928d00a9585d03ed83480587e9f4c61216d1c Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Thu, 20 Aug 2026 13:28:24 +0800 Subject: [PATCH 16/16] fix: fall back to qlmanage for DMG background rasterization on old macOS sips on macOS 12.x silently returns success without rasterizing the SVG background, leaving no PNG for the Finder layout step and aborting the DMG build. Detect the missing raster and fall back to qlmanage so the DMG background still resolves. Newer macOS (macos-15 CI) is unaffected. --- scripts/macos_create_dmg.sh | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/scripts/macos_create_dmg.sh b/scripts/macos_create_dmg.sh index b122a71d..1ea866db 100755 --- a/scripts/macos_create_dmg.sh +++ b/scripts/macos_create_dmg.sh @@ -132,8 +132,29 @@ mkdir -p "$staging_root" "$background_root" /usr/bin/ditto "$app_path" "$staging_root/ACECode.app" /bin/ln -s /Applications "$staging_root/Applications" +background_png="$background_root/ACECode-DMG.png" +# Older macOS sips (e.g. 12.x) silently fails to rasterize SVG and writes no +# output while still returning success. Fall back to qlmanage when the expected +# PNG is missing or not a valid image so the Finder background still resolves. /usr/bin/sips -s format png "$background_path" \ - --out "$background_root/ACECode-DMG.png" >/dev/null + --out "$background_png" >/dev/null 2>&1 || true +if [[ ! -s "$background_png" ]]; then + echo "sips could not rasterize the SVG background; using qlmanage fallback" >&2 + if ! /usr/bin/qlmanage -t -s 660 -o "$background_root" "$background_path" >/dev/null 2>&1; then + echo "Could not rasterize the DMG background image." >&2 + exit 1 + fi + ql_png="$background_root/$(basename "$background_path").png" + if [[ ! -f "$ql_png" ]]; then + echo "qlmanage produced no background image." >&2 + exit 1 + fi + /bin/mv -f "$ql_png" "$background_png" +fi +if [[ ! -s "$background_png" ]]; then + echo "Missing DMG background raster: $background_png" >&2 + exit 1 +fi if [[ ! -L "$staging_root/Applications" ]] || \ [[ "$(/usr/bin/readlink "$staging_root/Applications")" != "/Applications" ]]; then