diff --git a/README.md b/README.md index 7c71c4c..2c873f4 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ 用 MoonBit 重新实现的 [pi](https://github.com/earendil-works/pi) coding agent 子集。 +![pim 全屏 TUI:示例对话、思考过程、bash 工具输出与输入框](docs/assets/tui-session.png) + ## Install ```bash @@ -13,6 +15,14 @@ npm install -g . # symlink 到全局 ## Usage +在终端中直接运行 `pim` 进入全屏 TUI: + +```bash +pim +``` + +`Ctrl+O` 展开工具输出,`Ctrl+T` 切换思考显示,`Esc` 中断当前回合。 + `-p/--print` 单轮、非交互模式: ```bash diff --git a/docs/assets/tui-session.png b/docs/assets/tui-session.png new file mode 100644 index 0000000..25d25eb Binary files /dev/null and b/docs/assets/tui-session.png differ diff --git a/justfile b/justfile index f686614..d0cbb48 100644 --- a/justfile +++ b/justfile @@ -20,6 +20,10 @@ test: package: bash scripts/pack-npm.sh +# Replay the offline fixture and regenerate the README TUI image. +screenshot: + python3 scripts/screenshot/capture.py + cram: moon build moon cram test --work-directory . tests/cram diff --git a/scripts/screenshot/README.md b/scripts/screenshot/README.md new file mode 100644 index 0000000..433aeb0 --- /dev/null +++ b/scripts/screenshot/README.md @@ -0,0 +1,19 @@ +# README TUI screenshot + +在仓库根目录运行: + +```bash +npm ci +just screenshot +# 没有 just 时:python3 scripts/screenshot/capture.py +``` + +依赖:MoonBit、Node.js(版本见根目录 package.json)、Python 3(仅标准库)、tmux 和 [Freeze 0.2.2](https://github.com/charmbracelet/freeze/releases/tag/v0.2.2)。例如 macOS 可以用 `brew install tmux charmbracelet/tap/freeze`,Linux 可安装 tmux 并下载对应架构的 Freeze release。Freeze 使用自带的 JetBrains Mono 字体;无需安装 Python 图像库。 + +脚本先构建 npm 产物,再在独立 tmux server 的 **100×32** 终端运行 `dist/pim.js`,恢复本目录的 `session.jsonl`。配置、工作目录和可写 journal 副本均在临时目录中;退出时清理。CLI 使用占位凭证满足启动检查,只回放日志并输入未提交的提示,不调用 provider,也不执行 fixture 中的 bash 命令。fixture 中的回复、测试数量和费用是固定的演示数据,不代表当前测试运行结果。 + +脚本等待恢复状态和编辑器输入实际出现,捕获带 ANSI 颜色的画面,把临时目录显示替换为 `~/projects/pi.mbt`,使用固定的 `freeze.json` 生成 `docs/assets/tui-session.png`。重生成后检查图片和 Git diff,再提交。更换示例内容时修改 `session.jsonl`;渲染外观由 `freeze.json` 控制。截图流程参考 [pi-workmap](https://github.com/kkkiio/pi-workmap/tree/main/test/visual)。 + +tmux 的 ANSI 样式状态会跨行保留,而 Freeze 0.2.2 会在换行时结束背景、忽略部分样式复位。`freeze_ansi` 将捕获中的持续状态转换为每段文字显式指定的完整样式,防止用户消息和工具输出脱离背景块。Freeze 0.2.2 本身不显示 ANSI 粗体,截图仍有这一限制。 + +`TMUX_BIN` 和 `FREEZE_BIN` 可指定工具路径。不同 tmux/Freeze 版本可能影响像素;需稳定输出时保持上述版本和终端尺寸。 diff --git a/scripts/screenshot/capture.py b/scripts/screenshot/capture.py new file mode 100644 index 0000000..ce0bacc --- /dev/null +++ b/scripts/screenshot/capture.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Capture the packaged pim CLI replaying an offline README fixture.""" + +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +TMUX = os.environ.get("TMUX_BIN", "tmux") +FREEZE = os.environ.get("FREEZE_BIN", "freeze") + + +def run(*args, **kwargs): + return subprocess.run(args, check=True, text=True, **kwargs) + + +def freeze_ansi(capture): + """Freeze drops styles at newlines and ignores selective resets like SGR 49. + + Resolve tmux's persistent SGR state into complete styles for each text run. + """ + foreground, background = [38, 2, 31, 35, 40], None + attributes = set() + rows = [] + for line in capture.splitlines(): + rendered = [] + for token in re.split(r"(\x1b\[[0-9;]*m)", line): + if token.startswith("\x1b["): + codes = [int(value or "0") for value in token[2:-1].split(";")] + index = 0 + while index < len(codes): + code = codes[index] + if code == 0: + foreground, background = [38, 2, 31, 35, 40], None + attributes.clear() + elif code in (1, 3, 4, 7, 9): + attributes.add(code) + elif code in (22, 23, 24, 27, 29): + attributes.discard({22: 1, 23: 3, 24: 4, 27: 7, 29: 9}[code]) + elif code == 39: + foreground = [38, 2, 31, 35, 40] + elif code == 49: + background = None + elif code in (38, 48): + count = 5 if codes[index + 1] == 2 else 3 + color = codes[index:index + count] + if code == 38: + foreground = color + else: + background = color + index += count - 1 + else: + raise ValueError(f"Unsupported captured SGR: {token!r}") + index += 1 + elif token: + fg, bg = foreground, background + if 7 in attributes: + fg = [38, *(bg or [48, 2, 255, 255, 255])[1:]] + bg = [48, *foreground[1:]] + style = sorted(attributes - {7}) + fg + (bg or []) + rendered.append("\x1b[0m\x1b[" + ";".join(map(str, style)) + "m" + token) + rows.append("".join(rendered) + "\x1b[0m") + return "\n".join(rows) + "\n" + + +def main(): + for executable in (TMUX, FREEZE, "node", "moon"): + if not shutil.which(executable): + raise SystemExit(f"Missing {executable}; see scripts/screenshot/README.md") + run("bash", str(ROOT / "scripts/pack-npm.sh"), cwd=ROOT) + with tempfile.TemporaryDirectory(prefix="pim-screenshot-") as temporary: + base = Path(temporary) + cwd = base / "demo" + config = base / "config" + sessions = base / "sessions" + for directory in (cwd, config, sessions): + directory.mkdir() + (sessions / "readme.jsonl").write_text( + (HERE / "session.jsonl").read_text().replace("/tmp/pim-demo", str(cwd)) + ) + # A private tmux server avoids inherited user config and terminal sizing. + tmux = [TMUX, "-f", "/dev/null", "-S", str(base / "tmux.sock")] + command = shlex.join([ + "env", f"PIM_CONFIG_DIR={config}", "DEEPSEEK_API_KEY=screenshot-only", + "COLORTERM=truecolor", "TERM=xterm-256color", + "node", str(ROOT / "dist/pim.js"), + "--session-dir", str(sessions), "--resume", "readme", + ]) + try: + run(*tmux, "new-session", "-d", "-s", "capture", "-x", "100", "-y", "32", + "-c", str(cwd), command) + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + pane = run(*tmux, "capture-pane", "-t", "capture", "-p", + capture_output=True).stdout + if "Resumed session" in pane and "Project tour" in pane: + break + time.sleep(0.1) + else: + raise RuntimeError(f"TUI replay did not become ready:\n{pane}") + # Type into the real editor without Enter: no model request is sent. + prompt = "Show me how session resume works" + run(*tmux, "send-keys", "-t", "capture", "-l", prompt) + while time.monotonic() < deadline: + pane = run(*tmux, "capture-pane", "-t", "capture", "-p", + capture_output=True).stdout + if prompt in pane: + break + time.sleep(0.1) + else: + raise RuntimeError("Editor input did not render") + ansi = run(*tmux, "capture-pane", "-t", "capture", "-p", "-e", "-N", + capture_output=True).stdout + ansi = ansi.replace(str(cwd), "~/projects/pi.mbt") + output = ROOT / "docs/assets/tui-session.png" + run(FREEZE, "--config", str(HERE / "freeze.json"), "--language", "ansi", + "--output", str(output), "-", input=freeze_ansi(ansi)) + print(f"Generated {output.relative_to(ROOT)}") + finally: + subprocess.run([*tmux, "kill-server"], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + +if __name__ == "__main__": + main() diff --git a/scripts/screenshot/freeze.json b/scripts/screenshot/freeze.json new file mode 100644 index 0000000..18cbfee --- /dev/null +++ b/scripts/screenshot/freeze.json @@ -0,0 +1,11 @@ +{ + "window": true, + "theme": "github", + "border": { "radius": 8, "width": 1, "color": "#d0d7de" }, + "shadow": { "blur": 20, "x": 0, "y": 10 }, + "padding": [20, 32, 20, 32], + "margin": [48, 56, 64, 56], + "background": "#eef2f5", + "font": { "family": "JetBrains Mono", "size": 14, "ligatures": false }, + "line_height": 1.25 +} diff --git a/scripts/screenshot/session.jsonl b/scripts/screenshot/session.jsonl new file mode 100644 index 0000000..7f9e6c1 --- /dev/null +++ b/scripts/screenshot/session.jsonl @@ -0,0 +1,6 @@ +{"type":"session","version":3,"id":"readme-demo","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/tmp/pim-demo"} +{"type":"message","id":"user","parentId":null,"timestamp":"2026-01-01T00:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"Run the tests and explain how this coding agent is organized."}],"timestamp":1767225601000}} +{"type":"message","id":"call","parentId":"user","timestamp":"2026-01-01T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"I'll check the local test suite, then summarize the CLI, session, and TUI layers."},{"type":"toolCall","id":"tests","name":"bash","arguments":{"command":"moon test"}}],"api":"deepseek","provider":"deepseek","model":"deepseek-v4-flash","stopReason":"toolUse","timestamp":1767225602000,"usage":{"input":1000,"output":90,"cacheRead":1500,"cacheWrite":0,"totalTokens":2590,"cost":{"input":0.0004,"output":0.0001,"cacheRead":0.0001,"cacheWrite":0,"total":0.0006}}}} +{"type":"message","id":"result","parentId":"call","timestamp":"2026-01-01T00:00:03.000Z","message":{"role":"toolResult","toolCallId":"tests","toolName":"bash","content":[{"type":"text","text":"Finished. moon: ran 8 tasks, now up to date\nTotal tests: 83, passed: 83, failed: 0."}],"isError":false,"timestamp":1767225603000}} +{"type":"message","id":"answer","parentId":"result","timestamp":"2026-01-01T00:00:04.000Z","message":{"role":"assistant","content":[{"type":"text","text":"All **83 tests passed**. The project has three main layers:\n\n- `cmd/pim`: CLI entry point, configuration, and print mode.\n- `sdk`: agent loop, session journals, providers, and tools.\n- `tui_app`: transcript, editor, status, and footer components.\n\nThe TUI uses pi's **light theme** and supports scrolling, search, and tool expansion."}],"api":"deepseek","provider":"deepseek","model":"deepseek-v4-flash","stopReason":"stop","timestamp":1767225604000,"usage":{"input":800,"output":180,"cacheRead":1600,"cacheWrite":0,"totalTokens":2580,"cost":{"input":0.0003,"output":0.0009,"cacheRead":0.0002,"cacheWrite":0,"total":0.0014}}}} +{"type":"session_info","id":"name","parentId":"answer","timestamp":"2026-01-01T00:00:05.000Z","name":"Project tour"} diff --git a/sdk/agent_session/journal.mbt b/sdk/agent_session/journal.mbt index 2c35c20..cbf6a61 100644 --- a/sdk/agent_session/journal.mbt +++ b/sdk/agent_session/journal.mbt @@ -177,6 +177,29 @@ pub impl FromJson for SessionEntry with fn from_json(json, path) { ///| pub extend SessionEntry with @json.FromJson::{from_json} +///| +priv struct JournalUsage(@agent_loop.Usage) + +///| +impl @json.FromJson for JournalUsage with fn from_json(json, path) { + JournalUsage(parse_usage(json, path)) +} + +///| +/// Billable usage persisted outside messages, including Pi cache-warming entries. +pub fn SessionEntry::auxiliary_usage(self : SessionEntry) -> @agent_loop.Usage? { + match self.content { + Compaction(usage~, ..) => usage + Metadata({ "type": "usage", "usage": usage, .. }) => { + let parsed : JournalUsage? = Some(@json.from_json(usage)) catch { + _ => None + } + parsed.map(value => value.0) + } + _ => None + } +} + ///| /// Shared by the session header and entries, matching Date.toISOString(). pub fn format_journal_timestamp(timestamp : UInt64) -> String { diff --git a/tui_app/README.mbt.md b/tui_app/README.mbt.md index 3a7f48d..2009a72 100644 --- a/tui_app/README.mbt.md +++ b/tui_app/README.mbt.md @@ -132,7 +132,7 @@ assistant text markdown: fenced code + inline ` **状态与视觉反馈:** -- **空闲** — 上边框是纯横线;footer 保留上次用量 +- **空闲** — 上边框是纯横线,按 thinking 档位着色;footer 保留会话累计用量 - **工作中** — 上边框换成 `-- * thinking (Esc to interrupt) --`;终端进度指示(OSC 9;4)开启 - **工具执行** — 工具块背景 pending -> success / error - **完成** — 回落空闲;footer 用量刷新 @@ -143,15 +143,16 @@ assistant text markdown: fenced code + inline ` ``` TranscriptItem +-- User(text) whole block gets userMessageBg, one padding row above/below, no ">" prefix -+-- Note(text) dim: startup help, retry notices, "... earlier messages omitted" ++-- Startup accent title + dim keys / muted descriptions; ctrl+o expands help ++-- Note(text) dim: status notices, "... earlier messages omitted" +-- Error(text) error color -+-- Assistant(text, thinking, stop_reason, error_message) ++-- Assistant(blocks, stop_reason, error_message) | +-- thinking expanded by default: italic gray markdown; ctrl+t collapses to the label -| \-- text simplified markdown: fenced code + inline `code` / bold +| \-- text pi-tui Markdown, preserving text/thinking order; syntax highlight TODO \-- Tool(name, args, result, is_error) - +-- bash green frame + "$ command" + last 20 lines + "... (N more lines, ctrl+o to expand)" - +-- read "read "; result hidden while collapsed (same as pi) - \-- other bold name + arg summary; 10-line preview, ctrl+o expands + +-- bash status background + "$ command" + last 5 lines; earlier-lines hint above output + +-- read "read :"; success hidden while collapsed, errors visible + \-- other bold name + formatted JSON arguments; 10-line preview, ctrl+o expands ``` ## 会话与命令 @@ -165,4 +166,8 @@ TUI 内: `TuiApp` 统一维护提交队列与排队展示,消费一条提交时撤掉对应展示行; `run` 只负责顺序执行提示词和会话切换。工具结果在渲染时按内容列宽换行后截取 -预览,bash 显示最后 20 个可见行,其余工具显示前 10 个;展开后保留全部输出。 +预览,bash 显示最后 5 个可见行,其余工具显示前 10 个;展开后保留全部输出。 +点击工具结果或思考段可单独展开/折叠;全局快捷键会清除单块显示设置。 +工具输出使用 `toolOutput` 色,截断路径/警告来自结果 details,实时 bash 显示耗时。 +恢复会话时重算累计用量与最近一次模型缓存命中率,并读取 session_info 会话名。 +上下文窗口尚无模型元数据源,因此不显示估算占用或自动压缩标记。 diff --git a/tui_app/app.mbt b/tui_app/app.mbt index 1b9805d..2142a22 100644 --- a/tui_app/app.mbt +++ b/tui_app/app.mbt @@ -80,14 +80,14 @@ pub async fn TuiApp::load( // terminal 与 Tui 绑定:pi-tui 的 `TuiAltScreen` 构造时只是拿引用, // 真正的 raw mode/副屏切换在 `Tui::start`。 let terminal = terminal_new() - let tui = tui_new(terminal) - let editor = prompt_editor_new(tui, theme) + let tui = tui_new(terminal, theme=Some(theme)) + let editor = prompt_editor_new(tui, theme, thinking~) let app = TuiApp::{ tui, terminal, theme, transcript: Transcript::new(theme), - status: StatusLine::new(theme), + status: StatusLine::new(theme, thinking~), footer: Footer::new(theme, model, cwd, thinking), editor, pending: Ref(PendingList::new(theme)), @@ -98,14 +98,12 @@ pub async fn TuiApp::load( interrupt_handler: fn() { () }, last_ctrl_c_ms: 0.0, } - app.transcript.push_note(STARTUP_HELP) + let branch = current_branch(cwd) + app.footer.set_branch(if branch.is_empty() { None } else { Some(branch) }) + app.transcript.push_startup() app } -///| -/// 启动帮助行(只列 pim 实际支持的键位)。 -const STARTUP_HELP : String = "Esc interrupt · Ctrl+C clear · Ctrl+D exit · Ctrl+O tools · Ctrl+T thinking · / commands" - ///| /// 把提交回调接到外部处理器(避免构造顺序上的循环依赖)。 pub fn TuiApp::on_submit(self : TuiApp, handler : (String) -> Unit) -> Unit { @@ -134,6 +132,9 @@ pub fn TuiApp::start(self : TuiApp) -> Unit { let transcript_component = component_of(fn(width) { Widget::render(self.transcript, width) }) + transcript_component.on_click(fn(row, width) { + self.transcript.click(row, width) + }) let pending_component = component_of(fn(width) { Widget::render(self.pending.val, width) }) @@ -142,8 +143,8 @@ pub fn TuiApp::start(self : TuiApp) -> Unit { }) let editor_component = self.editor.component() // 运行状态嵌在输入框上边框:每帧按当前状态渲染整行。 - self.editor.set_border_provider(fn(width) { - match self.status.render_border(width) { + self.editor.set_border_provider(fn(width, hidden) { + match self.status.render_border(width, hidden_lines=hidden) { Some(line) => line None => "" } @@ -154,10 +155,11 @@ pub fn TuiApp::start(self : TuiApp) -> Unit { pending=pending_component, editor=editor_component, footer=footer_component, + theme=Some(self.theme), ) self.tui.set_layout_root(viewport.root()) self.tui.set_focus(editor_component) - self.terminal.set_title("pim") + self.set_session_name(None) let _unlisten = self.tui.add_input_listener(fn(data) { self.on_input(data) }) self.tui.start() } @@ -213,12 +215,24 @@ fn TuiApp::on_input(self : TuiApp, data : String) -> Bool { // app.tools.expand == ctrl+o:展开/折叠工具输出。 if matches_key(data, "ctrl+o") { self.transcript.toggle_expanded_tool_output() + self.push_note( + "Tool output: " + + (if self.transcript.expanded_tool_output { + "expanded" + } else { + "collapsed" + }), + ) self.tui.request_render() return true } // app.thinking.expand == ctrl+t:展开/折叠思考内容。 if matches_key(data, "ctrl+t") { self.transcript.toggle_expanded_thinking() + self.push_note( + "Thinking blocks: " + + (if self.transcript.expanded_thinking { "visible" } else { "hidden" }), + ) self.tui.request_render() return true } @@ -357,8 +371,9 @@ pub fn TuiApp::tool_call( name : String, arguments : Json, id~ : String, + timed? : Bool = false, ) -> Unit { - self.transcript.push_tool_call(name, arguments, id~) + self.transcript.push_tool_call(name, arguments, id~, timed~) self.tui.request_render() } @@ -369,14 +384,19 @@ pub fn TuiApp::tool_result( text : String, is_error : Bool, id~ : String, + details? : Json? = None, ) -> Unit { - self.transcript.push_tool_result(name, text, is_error, id~) + self.transcript.push_tool_result(name, text, is_error, id~, details~) self.tui.request_render() } ///| -pub fn TuiApp::set_usage(self : TuiApp, usage : @agent_loop.Usage) -> Unit { - self.footer.set_usage(usage) +pub fn TuiApp::set_usage( + self : TuiApp, + usage : @agent_loop.Usage, + update_cache_hit? : Bool = true, +) -> Unit { + self.footer.set_usage(usage, update_cache_hit~) self.tui.request_render() } @@ -405,6 +425,8 @@ fn TuiApp::remove_pending_head(self : TuiApp) -> Unit { pub fn TuiApp::set_idle(self : TuiApp) -> Unit { self.running = false self.status.set_idle() + let branch = current_branch(self.footer.cwd()) + self.footer.set_branch(if branch.is_empty() { None } else { Some(branch) }) self.terminal.set_progress(false) self.tui.request_render() } @@ -418,10 +440,12 @@ pub fn TuiApp::tick(self : TuiApp) -> Unit { } ///| -/// 清空消息流与排队区(`/resume` 回放前调用),保留 footer 用量。 +/// 清空消息流、排队区与用量(`/resume` 回放前调用)。 pub fn TuiApp::reset_transcript(self : TuiApp) -> Unit { self.transcript = Transcript::new(self.theme) self.pending.val.lines = [] + self.footer.reset_usage() + self.set_session_name(None) self.tui.request_render() } @@ -442,9 +466,47 @@ pub fn TuiApp::reset(self : TuiApp) -> Unit { self.footer.cwd(), self.footer.thinking(), ) + let branch = current_branch(self.footer.cwd()) + self.footer.set_branch(if branch.is_empty() { None } else { Some(branch) }) self.set_idle() } +///| +pub fn TuiApp::set_session_name(self : TuiApp, name : String?) -> Unit { + self.footer.set_session_name(name) + let cwd = self.footer.cwd() + let base = match cwd.rev_split_once("/") { + Some((_, base)) => base.to_owned() + None => cwd + } + let base = sanitize_label(base) + let label = name.map(sanitize_label).unwrap_or("") + self.terminal.set_title( + if label.is_empty() { + "pim - " + base + } else { + "pim - " + label + " - " + base + }, + ) +} + +///| +pub fn TuiApp::begin_retry( + self : TuiApp, + attempt : Int, + max_attempts : Int, + delay_ms : Int, +) -> Unit { + self.running = true + self.status.set_retry(attempt, max_attempts, delay_ms) + self.terminal.set_progress(true) + self.tui.request_render() +} + +///| +extern "js" fn current_branch(cwd : String) -> String = + #| cwd => { try { const cp = process.getBuiltinModule('child_process'); return cp.execFileSync('git', ['branch','--show-current'], {cwd, encoding:'utf8', stdio:['ignore','pipe','ignore']}).trim(); } catch { return ''; } } + ///| /// 短暂的 toast 提示。 pub fn TuiApp::flash(self : TuiApp, message : String) -> Unit { diff --git a/tui_app/chat_viewport.mbt b/tui_app/chat_viewport.mbt index 85eefe4..198b694 100644 --- a/tui_app/chat_viewport.mbt +++ b/tui_app/chat_viewport.mbt @@ -23,8 +23,18 @@ pub fn chat_viewport_new( editor~ : Component, footer~ : Component, scrollbar? : String = "auto", + theme? : Theme? = None, ) -> ChatViewport { - let transcript = scroll_view(document, scrollbar~) + let transcript = match theme { + Some(theme) => + scroll_view( + document, + scrollbar~, + scrollbar_track_style=Some(fn(text) { theme.fg("scrollbarTrack", text) }), + scrollbar_thumb_style=Some(fn(text) { theme.fg("scrollbarThumb", text) }), + ) + None => scroll_view(document, scrollbar~) + } let dock = vstack([ stack_child(pending, min_size=0), stack_child(editor, min_size=3), diff --git a/tui_app/component.mbt b/tui_app/component.mbt index a990705..fb35d21 100644 --- a/tui_app/component.mbt +++ b/tui_app/component.mbt @@ -44,6 +44,22 @@ pub fn component_of(render : (Int) -> Array[String]) -> Component { } } +///| +/// Attach a left-click handler; pi-tui supplies document-local coordinates. +pub fn Component::on_click( + self : Component, + click : (Int, Int) -> Bool, +) -> Unit { + js_component_click(self.handle, click) +} + +///| +extern "js" fn js_component_click( + handle : Value, + click : (Int, Int) -> Bool, +) -> Unit = + #| (component, click) => { component.handleMouse = event => event.type === 'click' && event.button === 'left' && click(event.y, event.width) ? {handled:true} : undefined; } + ///| /// 把 MoonBit 组件注册成 pi-tui 的 `Component`。 pub fn[W : Widget] mount(widget : W) -> Component { @@ -82,20 +98,86 @@ pub fn text( } ///| -/// Markdown 块(pi-tui `Markdown`,由 marked 解析)。 -pub fn markdown( +/// Render markdown synchronously with pi-tui's renderer and pim's semantic theme. +/// The preserve flags are useful for user-authored messages where source markers matter. +pub fn render_markdown_themed( + theme : Theme, content : String, + width : Int, padding_x? : Int = 1, padding_y? : Int = 0, -) -> Component { - let args : Array[Value] = [ - Value::cast_from(content), - Value::cast_from(padding_x), - Value::cast_from(padding_y), - ] - Component::{ handle: ns_get("Markdown").new(args), } + body_role? : String = "text", + body_italic? : Bool = false, + preserve_ordered_markers? : Bool = false, + preserve_backslash_escapes? : Bool = false, +) -> Array[String] { + let lines : FixedArray[String] = js_render_markdown( + ns_get("Markdown"), + content, + width, + padding_x, + padding_y, + js_callback_text(fn(text) { theme.fg(body_role, text) }), + body_italic, + preserve_ordered_markers, + preserve_backslash_escapes, + js_callback_text(fn(text) { theme.fg("mdHeading", text) }), + js_callback_text(fn(text) { theme.fg("mdLink", text) }), + js_callback_text(fn(text) { theme.fg("mdLinkUrl", text) }), + js_callback_text(fn(text) { theme.fg("mdCode", text) }), + js_callback_text(fn(text) { theme.fg("mdCodeBlock", text) }), + js_callback_text(fn(text) { theme.fg("mdCodeBlockBorder", text) }), + js_callback_text(fn(text) { theme.fg("mdQuote", text) }), + js_callback_text(fn(text) { theme.fg("mdQuoteBorder", text) }), + js_callback_text(fn(text) { theme.fg("mdHr", text) }), + js_callback_text(fn(text) { theme.fg("mdListBullet", text) }), + js_callback_highlight(fn(code, _language) { + // TODO: 对齐 pi 的代码块语法高亮;目前统一使用 mdCodeBlock 颜色。 + FixedArray::from_array( + physical_lines(code).map(fn(line) { theme.fg("mdCodeBlock", line) }), + ) + }), + ) + Array::makei(lines.length(), index => lines[index]) } +///| +extern "js" fn js_render_markdown( + markdown_class : Value, + content : String, + width : Int, + padding_x : Int, + padding_y : Int, + body : Value, + body_italic : Bool, + preserve_ordered : Bool, + preserve_escapes : Bool, + heading : Value, + link : Value, + link_url : Value, + code : Value, + code_block : Value, + code_border : Value, + quote : Value, + quote_border : Value, + hr : Value, + bullet : Value, + highlight : Value, +) -> FixedArray[String] = + #| (Markdown, content, width, px, py, body, bodyItalic, preserveOrdered, preserveEscapes, heading, link, linkUrl, code, codeBlock, codeBlockBorder, quote, quoteBorder, hr, listBullet, highlightCode) => { + #| const close = (open, end) => text => `\u001b[${open}m${text}\u001b[${end}m`; + #| const mdTheme = { heading, link, linkUrl, code, codeBlock, codeBlockBorder, + #| quote, quoteBorder, hr, listBullet, bold: close(1,22), italic: close(3,23), + #| underline: close(4,24), strikethrough: close(9,29), highlightCode }; + #| return new Markdown(content, px, py, mdTheme, { color: body, italic: bodyItalic }, + #| { preserveOrderedListMarkers: preserveOrdered, preserveBackslashEscapes: preserveEscapes }).render(width); + #| } + +///| +extern "js" fn js_callback_highlight( + callback : (String, String) -> FixedArray[String], +) -> Value = "(callback) => (code, language) => callback(code, language || '')" + ///| /// 空行。 pub fn spacer(lines? : Int = 1) -> Component { @@ -179,12 +261,22 @@ pub fn scroll_view( primary? : Bool = true, overscroll? : String = "chain", scrollbar? : String = "auto", + scrollbar_track_style? : ((String) -> String)? = None, + scrollbar_thumb_style? : ((String) -> String)? = None, ) -> ScrollView { let options = Object::new() options["follow"] = if follow_end { "end" } else { "none" } options["primary"] = primary options["overscroll"] = overscroll options["scrollbar"] = scrollbar + match scrollbar_track_style { + Some(style) => options["scrollbarTrackStyle"] = js_callback_text(style) + None => () + } + match scrollbar_thumb_style { + Some(style) => options["scrollbarThumbStyle"] = js_callback_text(style) + None => () + } ScrollView::{ handle: ns_get("ScrollView").new([child.handle, options.to_value()]), } @@ -235,24 +327,38 @@ pub fn editor_new( tui : Tui, on_submit : (String) -> Unit, theme : Theme, + thinking? : String = "off", ) -> Editor { Editor::{ handle: js_editor_new( ns_get("Editor"), tui.handle, on_submit, - js_callback_text(fn(text) { theme.f_border_muted(text) }), + js_callback_text(fn(text) { theme.fg(thinking_role(thinking), text) }), js_callback_text(fn(text) { theme.f_accent(text) }), js_callback_text(fn(text) { theme.f_muted(text) }), ), } } +///| +fn thinking_role(level : String) -> String { + match level { + "minimal" => "thinkingMinimal" + "low" => "thinkingLow" + "medium" => "thinkingMedium" + "high" => "thinkingHigh" + "xhigh" => "thinkingXhigh" + "max" => "thinkingMax" + _ => "thinkingOff" + } +} + ///| /// 设置顶部边框渲染:`render(width)` 返回整行;返回空串则用默认边框。 pub fn Editor::set_border_provider( self : Editor, - render : (Int) -> String, + render : (Int, Int) -> String, ) -> Unit { js_editor_border_provider(self.handle, render) } @@ -288,7 +394,7 @@ extern "js" fn js_callback_text(callback : (String) -> String) -> Value = "(call /// 绑定顶部边框渲染函数。 extern "js" fn js_editor_border_provider( handle : Value, - render : (Int) -> String, + render : (Int, Int) -> String, ) -> Unit = "(editor, render) => { editor.borderProvider = render; }" ///| @@ -309,7 +415,7 @@ extern "js" fn js_editor_new( #| } #| renderTopBorder(width, hiddenLineCount) { #| if (this.borderProvider && width > 0) { - #| const line = this.borderProvider(width); + #| const line = this.borderProvider(width, hiddenLineCount); #| if (line) return line; #| } #| return super.renderTopBorder(width, hiddenLineCount); diff --git a/tui_app/footer.mbt b/tui_app/footer.mbt index 8c6d990..cecccb4 100644 --- a/tui_app/footer.mbt +++ b/tui_app/footer.mbt @@ -3,8 +3,8 @@ // 行1:`~/cwd (branch) • session`(全 dim) // 行2:`↑in ↓out Rcache Wcache CH% $cost` + 右对齐 `model • thinking` // -// 目前 pim 只从 Usage 拿得到 token/费用;git 分支、会话名、上下文窗口还没有数据源, -// 字段先留空,待接线后自动出现。 +// 用量累计整个会话,git 分支与会话名由 app/session_host 接线。 +// 上下文窗口还没有模型元数据源,未知时不显示。 ///| /// HOME 目录(把 footer 里的绝对路径缩成 `~`)。 @@ -18,6 +18,16 @@ extern "js" fn format_cost(value : Float) -> String = "(value) => value.toFixed( /// 固定格式化为一位小数。 extern "js" fn format_percent(value : Float) -> String = "(value) => value.toFixed(1)" +///| +/// Footer labels must not be able to inject terminal rows or control sequences. +extern "js" fn sanitize_label(value : String) -> String = + #|(value) => value.replace(/[\x00-\x1f\x7f]/g, ' ').replace(/ +/g, ' ').trim() + +///| +/// Compact count formatting uses pi's rounding (not integer truncation). +extern "js" fn format_footer_count(value : Int) -> String = + #|(value) => value < 1000 ? String(value) : value < 10000 ? `${(value / 1000).toFixed(1)}k` : value < 1000000 ? `${Math.round(value / 1000)}k` : value < 10000000 ? `${(value / 1000000).toFixed(1)}M` : `${Math.round(value / 1000000)}M` + ///| /// 底部模型、目录与用量信息栏。 pub struct Footer { @@ -26,9 +36,9 @@ pub struct Footer { cwd : String thinking : String /// git 分支(pi 显示 ` (branch)`);None 时不显示。 - branch : String? + mut branch : String? /// 会话名(pi 显示 ` • session`);None 时不显示。 - session_name : String? + mut session_name : String? mut usage_set : Bool mut input_tokens : Int mut output_tokens : Int @@ -67,7 +77,7 @@ pub fn Footer::new( cache_hit_rate: None, context_percent: None, context_window: None, - auto_compact: true, + auto_compact: false, } } @@ -90,22 +100,50 @@ pub fn Footer::thinking(self : Footer) -> String { } ///| -/// 更新最近一次模型调用的用量。 -pub fn Footer::set_usage(self : Footer, usage : @agent_loop.Usage) -> Unit { +/// 累计用量;最近一次模型调用同时更新缓存命中率。 +pub fn Footer::set_usage( + self : Footer, + usage : @agent_loop.Usage, + update_cache_hit? : Bool = true, +) -> Unit { self.usage_set = true - self.input_tokens = usage.input - self.output_tokens = usage.output - self.cache_read = usage.cache_read - self.cache_write = usage.cache_write - self.total_cost = usage.cost.total - let prompt = usage.input + usage.cache_read + usage.cache_write - self.cache_hit_rate = if prompt > 0 { - Some(Float::from_int(usage.cache_read) / Float::from_int(prompt) * 100.0) - } else { - None + self.input_tokens += usage.input + self.output_tokens += usage.output + self.cache_read += usage.cache_read + self.cache_write += usage.cache_write + self.total_cost += usage.cost.total + if update_cache_hit { + let prompt = usage.input + usage.cache_read + usage.cache_write + self.cache_hit_rate = if prompt > 0 { + Some(Float::from_int(usage.cache_read) / Float::from_int(prompt) * 100.0) + } else { + None + } } } +///| +/// Clear accumulated usage before replaying a different session. +pub fn Footer::reset_usage(self : Footer) -> Unit { + self.usage_set = false + self.input_tokens = 0 + self.output_tokens = 0 + self.cache_read = 0 + self.cache_write = 0 + self.total_cost = 0.0 + self.cache_hit_rate = None +} + +///| +pub fn Footer::set_branch(self : Footer, branch : String?) -> Unit { + self.branch = branch.map(sanitize_label) +} + +///| +pub fn Footer::set_session_name(self : Footer, name : String?) -> Unit { + self.session_name = name.map(sanitize_label) +} + ///| /// 设置上下文占用比例与窗口大小(数据源接线前不会调用)。 pub fn Footer::set_context( @@ -117,6 +155,13 @@ pub fn Footer::set_context( self.context_window = Some(window) } +///| +/// Set a known context window while its current occupancy is unknown. +pub fn Footer::set_context_unknown(self : Footer, window : Int) -> Unit { + self.context_percent = None + self.context_window = Some(window) +} + ///| /// 设置是否启用自动压缩(仅影响上下文段的后缀)。 pub fn Footer::set_auto_compact(self : Footer, enabled : Bool) -> Unit { @@ -158,19 +203,22 @@ fn Footer::path_line(self : Footer) -> String { fn Footer::stats_prefix(self : Footer) -> String { let parts : Array[String] = [] if self.input_tokens > 0 { - parts.push("↑\{format_count(self.input_tokens)}") + parts.push("↑\{format_footer_count(self.input_tokens)}") } if self.output_tokens > 0 { - parts.push("↓\{format_count(self.output_tokens)}") + parts.push("↓\{format_footer_count(self.output_tokens)}") } if self.cache_read > 0 { - parts.push("R\{format_count(self.cache_read)}") + parts.push("R\{format_footer_count(self.cache_read)}") } if self.cache_write > 0 { - parts.push("W\{format_count(self.cache_write)}") + parts.push("W\{format_footer_count(self.cache_write)}") } match self.cache_hit_rate { - Some(rate) => parts.push("CH\{format_percent(rate)}%") + Some(rate) => + if self.cache_read > 0 || self.cache_write > 0 { + parts.push("CH\{format_percent(rate)}%") + } None => () } if self.total_cost > 0.0 { @@ -185,13 +233,17 @@ fn Footer::context_text(self : Footer) -> String? { match self.context_percent { Some(percent) => { let window = match self.context_window { - Some(value) => format_count(value) + Some(value) => format_footer_count(value) None => "?" } let suffix = if self.auto_compact { " (auto)" } else { "" } Some("\{format_percent(percent)}%/\{window}\{suffix}") } - None => None + None => + match self.context_window { + Some(window) => Some("?/\{format_footer_count(window)}") + None => None + } } } @@ -250,7 +302,11 @@ fn align_line(left : String, right : String, width : Int) -> String { } let available = width - left_width - 2 if available > 0 { - let truncated = truncate_to_width(right, available) + let truncated : String = ns_get("truncateToWidth").apply([ + Value::cast_from(right), + Value::cast_from(available), + Value::cast_from(""), + ]) let pad = width - left_width - visible_width(truncated) left + " ".repeat(if pad > 0 { pad } else { 0 }) + truncated } else { diff --git a/tui_app/footer_test.mbt b/tui_app/footer_test.mbt new file mode 100644 index 0000000..04668f6 --- /dev/null +++ b/tui_app/footer_test.mbt @@ -0,0 +1,211 @@ +///| +extern "js" fn plain(value : String) -> String = + #|(value) => value.replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, '') + +///| +/// Capture the public TuiApp terminal output, without exposing Footer's renderer. +extern "js" fn capture_screen(width : Int, draw : () -> Unit) -> String = + #| (width, draw) => { + #| const stdout = process.stdout, write = stdout.write; + #| const columns = Object.getOwnPropertyDescriptor(stdout, 'columns'); + #| const rows = Object.getOwnPropertyDescriptor(stdout, 'rows'); + #| let output = ''; + #| try { + #| Object.defineProperty(stdout, 'columns', {value:width, configurable:true}); + #| Object.defineProperty(stdout, 'rows', {value:24, configurable:true}); + #| stdout.write = chunk => { output += String(chunk); return true; }; + #| draw(); + #| return output; + #| } finally { + #| stdout.write = write; + #| if (columns) Object.defineProperty(stdout, 'columns', columns); else delete stdout.columns; + #| if (rows) Object.defineProperty(stdout, 'rows', rows); else delete stdout.rows; + #| } + #| } + +///| +fn screen(app : @tui_app.TuiApp, width : Int) -> String { + plain(capture_screen(width, fn() { app.tui.render_now(force=true) })) +} + +///| +fn usage( + input : Int, + output : Int, + read : Int, + write : Int, +) -> @agent_loop.Usage { + @agent_loop.Usage::{ + input, + output, + cache_read: read, + cache_write: write, + cache_write_1h: None, + reasoning: None, + total_tokens: input + output + read + write, + cost: @agent_loop.UsageCost::{ + input: 0.0, + output: 0.0, + cache_read: 0.0, + cache_write: 0.0, + total: 0.0, + }, + } +} + +///| +async test "footer accumulates usage and preserves assistant cache rate across tool usage" { + let app = @tui_app.TuiApp::load("model", "/tmp", "high") + defer { + let _ = capture_screen(80, fn() { app.stop() }) + } + let _ = capture_screen(80, fn() { app.start() }) + app.set_usage(usage(1000, 2, 1000, 0)) + app.set_usage(usage(999, 3, 0, 1), update_cache_hit=false) + assert_true(screen(app, 80).contains("↑2.0k ↓5 R1.0k W1 CH50.0%")) +} + +///| +async test "footer hides cache hit without cache totals and reset is instance-local" { + let first = @tui_app.TuiApp::load("m", "/tmp", "high") + defer { + let _ = capture_screen(40, fn() { first.stop() }) + } + let _ = capture_screen(40, fn() { first.start() }) + first.set_usage(usage(10, 0, 0, 0)) + assert_false(screen(first, 40).contains("CH")) + first.reset_transcript() + assert_false(screen(first, 40).contains("↑")) + let second = @tui_app.TuiApp::load("m", "/tmp", "high") + defer { + let _ = capture_screen(40, fn() { second.stop() }) + } + let _ = capture_screen(40, fn() { second.start() }) + assert_false(screen(second, 40).contains("↑")) +} + +///| +async test "footer rounds boundaries, truncates right without ellipsis, and sanitizes labels" { + let app = @tui_app.TuiApp::load("long-model-name", "/tmp", "high") + defer { + let _ = capture_screen(80, fn() { app.stop() }) + } + let _ = capture_screen(80, fn() { app.start() }) + app.set_session_name(Some(" session\tname ")) + app.set_usage(usage(1999, 0, 0, 0)) + let wide = screen(app, 80) + assert_true(wide.contains("/tmp • session name")) + assert_true(wide.contains("↑2.0k")) + app.reset_transcript() + app.set_usage(usage(1999, 0, 0, 0)) + let narrow = screen(app, 12) + assert_false(narrow.contains("...")) + assert_true(narrow.contains("long-")) +} + +///| +async test "session replay includes compaction and standalone usage without replacing assistant cache rate" { + let dir = @coding_agent.create_temp_dir("pim-footer-replay-") + defer @coding_agent.remove_dir(dir) + let assistant = @faux.faux_message([@faux.faux_text("done")]) + let message : @agent_loop.AssistantMessage = { + ..assistant, + usage: Some(usage(1000, 2, 1000, 0)), + } + let entries : Array[@agent_session.SessionEntry] = [ + { + id: "a", + parent_id: None, + timestamp: 0, + content: Message(Assistant(message)), + }, + { + id: "c", + parent_id: Some("a"), + timestamp: 1, + content: Compaction( + summary="summary", + first_kept_entry_id="a", + tokens_before=2000, + details=None, + usage=Some(usage(111, 3, 7, 0)), + ), + }, + { + id: "u", + parent_id: Some("c"), + timestamp: 2, + content: Metadata({ + "type": "usage", + "kind": "cache_warm", + "provider": "test", + "model": "model", + "usage": { + "input": 200, + "output": 4, + "cacheRead": 0, + "cacheWrite": 9, + "totalTokens": 213, + "cost": { + "input": 0.02, + "output": 0.01, + "cacheRead": 0, + "cacheWrite": 0.01, + "total": 0.04, + }, + }, + }), + }, + { + id: "failed", + parent_id: Some("u"), + timestamp: 3, + content: Message( + Assistant({ + ..assistant, + usage: None, + stop_reason: Error, + error_message: Some("provider failed"), + }), + ), + }, + ] + let header : Json = { + "type": "session", + "version": 3, + "id": "replay", + "timestamp": "1970-01-01T00:00:00.000Z", + "cwd": dir, + } + let journal = header.stringify() + + "\n" + + entries.map(entry => entry.to_json().stringify()).join("\n") + + "\n" + let path = "\{dir}/session.jsonl" + @coding_agent.write_bytes_file(path, @utf8.encode(journal)) + let host = @tui_app.SessionHost::new(@faux.FauxLLM(), dir, Some(dir)) + defer host.close() + let app = @tui_app.TuiApp::load("model", dir, "high") + defer { + let _ = capture_screen(80, fn() { app.stop() }) + } + let _ = capture_screen(80, fn() { app.start() }) + host.load_session(path, app) + assert_true(screen(app, 80).contains("↑1.3k ↓9 R1.0k W9 CH50.0% $0.040")) + host.load_session(path, app) + assert_true(screen(app, 80).contains("↑1.3k ↓9 R1.0k W9 CH50.0% $0.040")) +} + +///| +async test "terminal title sanitizes directory and session labels" { + let app = @tui_app.TuiApp::load("model", "/tmp/dir\u{07}\u{1b}tail", "high") + defer { + let _ = capture_screen(80, fn() { app.stop() }) + } + let output = capture_screen(80, fn() { + app.set_session_name(Some("name\nnext")) + }) + assert_true(output.contains("pim - name next - dir tail")) + assert_false(output.contains("dir\u{07}")) + assert_false(output.contains("\u{1b}tail")) +} diff --git a/tui_app/moon.pkg b/tui_app/moon.pkg index 72e6912..1eb7598 100644 --- a/tui_app/moon.pkg +++ b/tui_app/moon.pkg @@ -13,6 +13,7 @@ import { import { "KKKIIO/pi/sdk/providers/faux", + "moonbitlang/core/encoding/utf8", } for "test" supported_targets = "+js" diff --git a/tui_app/prompt_editor.mbt b/tui_app/prompt_editor.mbt index b6734e6..8df5047 100644 --- a/tui_app/prompt_editor.mbt +++ b/tui_app/prompt_editor.mbt @@ -12,9 +12,18 @@ pub struct PromptEditor { } ///| -pub fn prompt_editor_new(tui : Tui, theme : Theme) -> PromptEditor { +pub fn prompt_editor_new( + tui : Tui, + theme : Theme, + thinking? : String = "off", +) -> PromptEditor { let handler : Ref[(String) -> Unit] = Ref(fn(_text) { () }) - let editor = editor_new(tui, fn(text) { (handler.val)(text) }, theme) + let editor = editor_new( + tui, + fn(text) { (handler.val)(text) }, + theme, + thinking~, + ) PromptEditor::{ editor, handler, } } @@ -56,7 +65,7 @@ pub fn PromptEditor::set_text(self : PromptEditor, text : String) -> Unit { /// 设置输入框上边框渲染(用于嵌入运行状态)。 pub fn PromptEditor::set_border_provider( self : PromptEditor, - render : (Int) -> String, + render : (Int, Int) -> String, ) -> Unit { self.editor.set_border_provider(render) } diff --git a/tui_app/run.mbt b/tui_app/run.mbt index 93f3bf5..c05aa85 100644 --- a/tui_app/run.mbt +++ b/tui_app/run.mbt @@ -54,7 +54,7 @@ pub async fn run( host.new_session() host.attach(app) app.reset() - app.flash("new session") + app.push_note("Started new session") } "/resume" => host.resume_session("", app) command if command.has_prefix("/resume ") => { diff --git a/tui_app/session_host.mbt b/tui_app/session_host.mbt index 8cc86c2..86c0177 100644 --- a/tui_app/session_host.mbt +++ b/tui_app/session_host.mbt @@ -14,9 +14,6 @@ pub struct SessionHost { mut close_journal : () -> Unit /// 当前会话的 journal 文件路径;内存 journal 时为 `None`。 mut session_path : String? - /// 当前 assistant 消息已消费的文本/思考长度(满量内容 → 增量)。 - mut text_len : Int - mut thinking_len : Int } ///| @@ -34,8 +31,6 @@ pub fn SessionHost::new( session: @coding_agent.new_coding_session(llm, cwd~, journal~), close_journal, session_path, - text_len: 0, - thinking_len: 0, } } @@ -87,8 +82,6 @@ pub fn SessionHost::new_session( journal~, active_tools~, ) - self.text_len = 0 - self.thinking_len = 0 } ///| @@ -106,8 +99,6 @@ pub fn SessionHost::interrupt(self : SessionHost) -> Unit { ///| /// 订阅当前会话的事件并投影到视图。 pub fn SessionHost::attach(self : SessionHost, app : TuiApp) -> Unit { - self.text_len = 0 - self.thinking_len = 0 self.session.subscribe(event => self.on_event(event, app)) } @@ -199,12 +190,10 @@ pub fn SessionHost::load_session( journal~, active_tools~, ) - self.text_len = 0 - self.thinking_len = 0 app.reset_transcript() replay(entries, app) self.attach(app) - app.flash("resumed \{session_stem(path)}") + app.push_note("Resumed session") } ///| @@ -227,6 +216,9 @@ fn session_stem(path : String) -> String { /// 本期不渲染。 fn replay(entries : Array[@agent_session.SessionEntry], app : TuiApp) -> Unit { for entry in entries { + if entry.auxiliary_usage() is Some(usage) { + app.set_usage(usage, update_cache_hit=false) + } match entry.content { Message(User(message)) => for block in message.content { @@ -236,13 +228,24 @@ fn replay(entries : Array[@agent_session.SessionEntry], app : TuiApp) -> Unit { } } Message(Assistant(message)) => replay_assistant(message, app) - Message(ToolResult(result)) => + Message(ToolResult(result)) => { app.tool_result( result.tool_name, tool_result_text(result), result.is_error, id=result.tool_call_id, + details=result.details, ) + if result.usage is Some(usage) { + app.set_usage(usage, update_cache_hit=false) + } + } + Metadata(Object(fields)) => + if fields.get("type") is Some(String("session_info")) { + if fields.get("name") is Some(String(name)) { + app.set_session_name(Some(name)) + } + } _ => () } } @@ -255,57 +258,52 @@ fn replay_assistant( message : @agent_loop.AssistantMessage, app : TuiApp, ) -> Unit { - let mut text = "" - let mut thinking = "" let calls : Array[@agent_loop.ToolCall] = [] + let content : Array[(String, Bool)] = [] + app.assistant_start() for block in message.content { match block { - Text(content, ..) => text = text + content - Thinking(content, ..) => thinking = thinking + content + Text(text, ..) => content.push((text, false)) + Thinking(text, ..) => content.push((text, true)) ToolCall(call, ..) => calls.push(call) _ => () } } - app.assistant_start() - if !thinking.is_empty() { - app.assistant_delta(thinking, true) - } - if !text.is_empty() { - app.assistant_delta(text, false) - } + app.transcript.set_assistant_content(content) app.assistant_end( stop_reason_name(message.stop_reason), message.error_message, ) + if message.usage is Some(usage) { + // Pi serialization fills absent usage with zeroes; it has no measured prompt rate. + app.set_usage( + usage, + update_cache_hit=usage.input + usage.cache_read + usage.cache_write > 0, + ) + } for call in calls { app.tool_call(call.name, call.arguments, id=call.id) } } ///| -/// 结束会话的当前 assistant 增量(换会话/换消息时重置游标)。 +/// Replace the current assistant snapshot, retaining text/thinking order. fn SessionHost::sync_assistant( self : SessionHost, message : @agent_loop.AssistantMessage, app : TuiApp, ) -> Unit { - let mut text = "" - let mut thinking = "" + let content : Array[(String, Bool)] = [] for block in message.content { match block { - Text(content, ..) => text = text + content - Thinking(content, ..) => thinking = thinking + content + Text(text, ..) => content.push((text, false)) + Thinking(text, ..) => content.push((text, true)) _ => () } } - if text.length() > self.text_len { - app.assistant_delta(text[self.text_len:].to_owned(), false) - self.text_len = text.length() - } - if thinking.length() > self.thinking_len { - app.assistant_delta(thinking[self.thinking_len:].to_owned(), true) - self.thinking_len = thinking.length() - } + let _ = self + app.transcript.set_assistant_content(content) + app.render() } ///| @@ -344,7 +342,7 @@ fn SessionHost::on_event( app.push_note( "retrying (\{attempt}/\{max_attempts}) in \{delay_ms}ms: \{error_message}", ) - app.begin_work("retrying") + app.begin_retry(attempt, max_attempts, delay_ms) } AutoRetryEnd(attempt~, success~, final_error~) => if success { @@ -361,11 +359,7 @@ fn SessionHost::on_event( match loop_event { AgentStart => app.begin_work("thinking") TurnStart => () - MessageStart(Assistant(_)) => { - self.text_len = 0 - self.thinking_len = 0 - app.assistant_start() - } + MessageStart(Assistant(_)) => app.assistant_start() MessageStart(_) => () MessageUpdate(Assistant(message)) => self.sync_assistant(message, app) MessageUpdate(_) => () @@ -386,15 +380,20 @@ fn SessionHost::on_event( MessageEnd(_) => () ToolExecutionStart(call) => { app.begin_work(call.name) - app.tool_call(call.name, call.arguments, id=call.id) + app.tool_call(call.name, call.arguments, id=call.id, timed=true) } - ToolExecutionEnd(result) => + ToolExecutionEnd(result) => { app.tool_result( result.tool_name, tool_result_text(result), result.is_error, id=result.tool_call_id, + details=result.details, ) + if result.usage is Some(usage) { + app.set_usage(usage, update_cache_hit=false) + } + } TurnEnd(_, _) => () AgentEnd(_) => app.set_idle() } @@ -408,8 +407,6 @@ pub async fn SessionHost::prompt( text : String, app : TuiApp, ) -> Unit { - self.text_len = 0 - self.thinking_len = 0 app.begin_work("thinking") let input : Array[@agent_loop.UserMessage] = [ @agent_loop.UserMessage::{ diff --git a/tui_app/status.mbt b/tui_app/status.mbt index c84ada6..3ed3270 100644 --- a/tui_app/status.mbt +++ b/tui_app/status.mbt @@ -9,20 +9,22 @@ pub enum Phase { Idle /// 工作态:标签(如 "thinking" / 工具名)。 Working(String) + Retrying(Int, Int, Double) } ///| /// 状态行状态机(spinner 帧 + 标签)。 pub struct StatusLine { theme : Theme + thinking : String mut current : Phase mut spinner_index : Int } ///| /// 创建一个空闲的状态行。 -pub fn StatusLine::new(theme : Theme) -> StatusLine { - StatusLine::{ theme, current: Idle, spinner_index: 0, } +pub fn StatusLine::new(theme : Theme, thinking? : String = "off") -> StatusLine { + StatusLine::{ theme, thinking, current: Idle, spinner_index: 0, } } ///| @@ -37,6 +39,20 @@ pub fn StatusLine::set_working(self : StatusLine, label : String) -> Unit { self.current = Working(label) } +///| +pub fn StatusLine::set_retry( + self : StatusLine, + attempt : Int, + max_attempts : Int, + delay_ms : Int, +) -> Unit { + self.current = Retrying( + attempt, + max_attempts, + now_ms() + delay_ms.to_double(), + ) +} + ///| /// 前进到下一个 braille 旋转帧。 pub fn StatusLine::tick(self : StatusLine) -> Unit { @@ -54,7 +70,7 @@ pub fn StatusLine::phase(self : StatusLine) -> Phase { pub fn StatusLine::is_working(self : StatusLine) -> Bool { match self.current { Idle => false - Working(_) => true + Working(_) | Retrying(_, _, _) => true } } @@ -78,40 +94,99 @@ fn spinner_frame(index : Int) -> String { ///| /// 状态文本:`⠋ Working (Esc to interrupt)`。 fn StatusLine::activity_text(self : StatusLine, label : String) -> String { - self.theme.f_accent(bold("\{spinner_frame(self.spinner_index)} \{label}")) + + self.theme.f_accent(spinner_frame(self.spinner_index)) + " " + - self.theme.f_dim("(Esc to interrupt)") + self.theme.f_muted("\{label} (Esc to interrupt)") } ///| /// 渲染输入框上边框。空闲时返回 `None`(调用方回退到默认 `─` 边框)。 -pub fn StatusLine::render_border(self : StatusLine, width : Int) -> String? { - match self.current { - Idle => None - Working(label) => { - if width <= 0 { - return Some("") - } - let activity = self.activity_text(label) - let activity_width = visible_width(activity) - let used = 3 + activity_width + 1 - if used <= width { - Some( - self.theme.f_border_muted("── ") + - activity + - " " + - self.theme.f_border_muted("─".repeat(width - used)), - ) - } else if activity_width + 2 <= width { - let left = (width - activity_width) / 2 - Some( - self.theme.f_border_muted("─".repeat(left)) + - activity + - self.theme.f_border_muted("─".repeat(width - left - activity_width)), - ) - } else { - Some(self.theme.f_border_muted("─".repeat(width))) - } +pub fn StatusLine::render_border( + self : StatusLine, + width : Int, + hidden_lines? : Int = 0, +) -> String? { + if self.current is Idle { + return None + } + if width <= 0 { + return Some("") + } + let retry = self.current is Retrying(_, _, _) + let spinner = if retry { + self.theme.f_warning(spinner_frame(self.spinner_index)) + } else { + self.theme.f_accent(spinner_frame(self.spinner_index)) + } + let activity = match self.current { + Working(label) => self.activity_text(label) + Retrying(attempt, max_attempts, deadline) => { + let seconds = retry_seconds(deadline - now_ms()) + spinner + + " " + + self.theme.f_muted( + "Retrying (\{attempt}/\{max_attempts}) in \{seconds}s... (Esc to cancel)", + ) } + Idle => "" + } + let border = fn(text : String) { + self.theme.fg(thinking_role(self.thinking), text) + } + let overflow = if hidden_lines > 0 { + " ↑ \{hidden_lines} more " + } else { + "" } + let overflow_width = visible_width(overflow) + let overflow_start = (width - overflow_width) / 2 + let mut status = truncate_without_ellipsis( + activity, + if width > 5 { + width - 5 + } else { + 1 + }, + ) + if hidden_lines > 0 && overflow_start - (4 + visible_width(status)) < 1 { + status = spinner + } + let used = visible_width(status) + if hidden_lines > 0 && + overflow_width + 2 <= width && + overflow_start - (4 + used) >= 1 { + return Some( + border("── ") + + status + + border( + " " + + "─".repeat(overflow_start - 4 - used) + + overflow + + "─".repeat(width - overflow_start - overflow_width), + ), + ) + } + if width >= used + 5 { + return Some( + border("── ") + status + border(" " + "─".repeat(width - used - 4)), + ) + } + let prefix = if width - 1 < 3 { width - 1 } else { 3 } + Some( + border("─".repeat(prefix)) + + spinner + + border("─".repeat(width - prefix - 1)), + ) +} + +///| +extern "js" fn retry_seconds(ms : Double) -> Int = "ms => Math.max(0, Math.ceil(ms / 1000))" + +///| +fn truncate_without_ellipsis(text : String, width : Int) -> String { + ns_get("truncateToWidth").apply([ + Value::cast_from(text), + Value::cast_from(width), + Value::cast_from(""), + ]) } diff --git a/tui_app/style.mbt b/tui_app/style.mbt index 0d4fd09..abfd7e3 100644 --- a/tui_app/style.mbt +++ b/tui_app/style.mbt @@ -11,125 +11,168 @@ const ESC : String = "\u{1B}" ///| /// 包裹前景色(24-bit),结束时只重置前景。 -extern "js" fn fg_hex(hex : String, text : String) -> String = - #| (hex, text) => { +extern "js" fn fg_hex(hex : String, text : String, true_color : Bool) -> String = + #| (hex, text, trueColor) => { #| const r = parseInt(hex.slice(1, 3), 16); #| const g = parseInt(hex.slice(3, 5), 16); #| const b = parseInt(hex.slice(5, 7), 16); - #| return "\u001b[38;2;" + r + ";" + g + ";" + b + "m" + text + "\u001b[39m"; + #| const cube = [0,95,135,175,215,255]; + #| const q = v => cube.reduce((a,x,i) => Math.abs(x-v) .299*(r-x)**2+.587*(g-y)**2+.114*(b-z)**2; + #| const gray = Math.round(.299*r+.587*g+.114*b); + #| const gi = Math.max(0,Math.min(23,Math.ceil((gray-8)/10-.5))), gv = 8+10*gi; + #| const index = Math.max(r,g,b)-Math.min(r,g,b)<10 && distance(gv,gv,gv) String = - #| (hex, text) => { +extern "js" fn bg_hex(hex : String, text : String, true_color : Bool) -> String = + #| (hex, text, trueColor) => { #| const r = parseInt(hex.slice(1, 3), 16); #| const g = parseInt(hex.slice(3, 5), 16); #| const b = parseInt(hex.slice(5, 7), 16); - #| return "\u001b[48;2;" + r + ";" + g + ";" + b + "m" + text + "\u001b[49m"; + #| const cube = [0,95,135,175,215,255]; + #| const q = v => cube.reduce((a,x,i) => Math.abs(x-v) .299*(r-x)**2+.587*(g-y)**2+.114*(b-z)**2; + #| const gray = Math.round(.299*r+.587*g+.114*b); + #| const gi = Math.max(0,Math.min(23,Math.ceil((gray-8)/10-.5))), gv = 8+10*gi; + #| const index = Math.max(r,g,b)-Math.min(r,g,b)<10 && distance(gv,gv,gv) { + #| const codes = parameters.split(';'); + #| for (let i = 0; i < codes.length; i++) { + #| const code = Number(codes[i].split(':')[0]); + #| if (code === 0 || code === 49) return sequence + open; + #| // Extended color payloads may contain zeroes, which are not SGR resets. + #| if ((code === 38 || code === 48 || code === 58) && !codes[i].includes(':')) { + #| i += codes[i+1] === '2' ? 4 : codes[i+1] === '5' ? 2 : 0; + #| } + #| } + #| return sequence; + #| }) + "\u001b[49m"; #| } +///| +fn terminal_true_color() -> Bool { + let capabilities : Value = ns_get("getCapabilities").apply(no_args()) + get(capabilities, "trueColor") +} + +///| +/// Apply any foreground semantic role. +pub fn Theme::fg(self : Theme, role : String, text : String) -> String { + fg_hex(self.color(role), text, terminal_true_color()) +} + +///| +/// Apply any background semantic role. +pub fn Theme::bg(self : Theme, role : String, text : String) -> String { + bg_hex(self.color(role), text, terminal_true_color()) +} + // ---- 语义色(方法挂在 `Theme` 上,对齐 pi 的 `theme.fg(...)`/`theme.bg(...)`) ---- ///| pub fn Theme::f_text(self : Theme, text : String) -> String { - fg_hex(self.colors.text, text) + self.fg("text", text) } ///| pub fn Theme::f_muted(self : Theme, text : String) -> String { - fg_hex(self.colors.muted, text) + self.fg("muted", text) } ///| pub fn Theme::f_dim(self : Theme, text : String) -> String { - fg_hex(self.colors.dim, text) + self.fg("dim", text) } ///| pub fn Theme::f_accent(self : Theme, text : String) -> String { - fg_hex(self.colors.accent, text) + self.fg("accent", text) } ///| pub fn Theme::f_border(self : Theme, text : String) -> String { - fg_hex(self.colors.border, text) + self.fg("border", text) } ///| pub fn Theme::f_border_muted(self : Theme, text : String) -> String { - fg_hex(self.colors.border_muted, text) + self.fg("borderMuted", text) } ///| pub fn Theme::f_success(self : Theme, text : String) -> String { - fg_hex(self.colors.success, text) + self.fg("success", text) } ///| pub fn Theme::f_error(self : Theme, text : String) -> String { - fg_hex(self.colors.error, text) + self.fg("error", text) } ///| pub fn Theme::f_warning(self : Theme, text : String) -> String { - fg_hex(self.colors.warning, text) + self.fg("warning", text) } ///| pub fn Theme::f_thinking(self : Theme, text : String) -> String { - fg_hex(self.colors.thinking_text, text) + self.fg("thinkingText", text) } ///| pub fn Theme::f_custom_label(self : Theme, text : String) -> String { - fg_hex(self.colors.custom_message_label, text) + self.fg("customMessageLabel", text) } ///| pub fn Theme::f_md_link(self : Theme, text : String) -> String { - fg_hex(self.colors.md_link, text) + self.fg("mdLink", text) } ///| pub fn Theme::f_md_code(self : Theme, text : String) -> String { - fg_hex(self.colors.md_code, text) + self.fg("mdCode", text) } ///| pub fn Theme::f_md_code_block(self : Theme, text : String) -> String { - fg_hex(self.colors.md_code_block, text) + self.fg("mdCodeBlock", text) } ///| pub fn Theme::f_md_border(self : Theme, text : String) -> String { - fg_hex(self.colors.md_code_block_border, text) + self.fg("mdCodeBlockBorder", text) } ///| /// bash 模式(命令头 + 边框)。 pub fn Theme::f_bash(self : Theme, text : String) -> String { - fg_hex(self.colors.bash_mode, text) + self.fg("bashMode", text) } ///| pub fn Theme::bg_user(self : Theme, text : String) -> String { - bg_hex(self.colors.user_message_bg, text) + self.bg("userMessageBg", text) } ///| pub fn Theme::bg_tool_pending(self : Theme, text : String) -> String { - bg_hex(self.colors.tool_pending_bg, text) + self.bg("toolPendingBg", text) } ///| pub fn Theme::bg_tool_success(self : Theme, text : String) -> String { - bg_hex(self.colors.tool_success_bg, text) + self.bg("toolSuccessBg", text) } ///| pub fn Theme::bg_tool_error(self : Theme, text : String) -> String { - bg_hex(self.colors.tool_error_bg, text) + self.bg("toolErrorBg", text) } ///| @@ -154,28 +197,33 @@ pub fn Theme::tool_bg( // ---- 文本属性 ---- ///| -fn wrap_sgr(code : String, text : String) -> String { - "\{ESC}[\{code}m\{text}\{ESC}[0m" +fn wrap_sgr(code : String, close : String, text : String) -> String { + "\{ESC}[\{code}m\{text}\{ESC}[\{close}m" } ///| pub fn bold(text : String) -> String { - wrap_sgr("1", text) + wrap_sgr("1", "22", text) } ///| pub fn italic(text : String) -> String { - wrap_sgr("3", text) + wrap_sgr("3", "23", text) } ///| pub fn underline(text : String) -> String { - wrap_sgr("4", text) + wrap_sgr("4", "24", text) } ///| pub fn inverse(text : String) -> String { - wrap_sgr("7", text) + wrap_sgr("7", "27", text) +} + +///| +pub fn strikethrough(text : String) -> String { + wrap_sgr("9", "29", text) } // ---- 对齐小工具 ---- diff --git a/tui_app/theme.mbt b/tui_app/theme.mbt index 708f3ac..65006ac 100644 --- a/tui_app/theme.mbt +++ b/tui_app/theme.mbt @@ -14,8 +14,7 @@ ///| /// 解析后的语义色:字段名即角色名,值都是 `#rrggbb`。 /// -/// 字段齐全由构造处的 struct literal 在**编译期**保证(等价于 TS 的 -/// `Record`),所以取色是 total 的,没有 `Option`/`abort`。 +/// 必需角色用字段表示,构造器逐个校验 JSON;缺字段在载入期报错。 pub struct ThemeColors { text : String muted : String @@ -37,6 +36,27 @@ pub struct ThemeColors { tool_success_bg : String tool_error_bg : String bash_mode : String + selected_bg : String + scrollbar_track : String + scrollbar_thumb : String + search_match_bg : String + search_match_text : String + user_message_text : String + tool_title : String + tool_output : String + md_heading : String + md_link_url : String + md_quote : String + md_quote_border : String + md_hr : String + md_list_bullet : String + thinking_off : String + thinking_minimal : String + thinking_low : String + thinking_medium : String + thinking_high : String + thinking_xhigh : String + thinking_max : String } ///| @@ -106,7 +126,6 @@ pub fn Theme::load(name : String) -> Theme raise Failure { resolved[role] = resolve_color(vars, text) } } - // 这个 literal 就是「必需角色齐全」的编译期检查:漏一个字段编译不过。 let colors = ThemeColors::{ text: require_color(resolved, name, "text"), muted: require_color(resolved, name, "muted"), @@ -128,10 +147,80 @@ pub fn Theme::load(name : String) -> Theme raise Failure { tool_success_bg: require_color(resolved, name, "toolSuccessBg"), tool_error_bg: require_color(resolved, name, "toolErrorBg"), bash_mode: require_color(resolved, name, "bashMode"), + selected_bg: require_color(resolved, name, "selectedBg"), + scrollbar_track: require_color(resolved, name, "scrollbarTrack"), + scrollbar_thumb: require_color(resolved, name, "scrollbarThumb"), + search_match_bg: require_color(resolved, name, "searchMatchBg"), + search_match_text: require_color(resolved, name, "searchMatchText"), + user_message_text: require_color(resolved, name, "userMessageText"), + tool_title: require_color(resolved, name, "toolTitle"), + tool_output: require_color(resolved, name, "toolOutput"), + md_heading: require_color(resolved, name, "mdHeading"), + md_link_url: require_color(resolved, name, "mdLinkUrl"), + md_quote: require_color(resolved, name, "mdQuote"), + md_quote_border: require_color(resolved, name, "mdQuoteBorder"), + md_hr: require_color(resolved, name, "mdHr"), + md_list_bullet: require_color(resolved, name, "mdListBullet"), + thinking_off: require_color(resolved, name, "thinkingOff"), + thinking_minimal: require_color(resolved, name, "thinkingMinimal"), + thinking_low: require_color(resolved, name, "thinkingLow"), + thinking_medium: require_color(resolved, name, "thinkingMedium"), + thinking_high: require_color(resolved, name, "thinkingHigh"), + thinking_xhigh: require_color(resolved, name, "thinkingXhigh"), + thinking_max: require_color(resolved, name, "thinkingMax"), } Theme::{ name, colors, } } +///| +/// Return a resolved semantic color. Unknown roles are programmer errors. +pub fn Theme::color(self : Theme, role : String) -> String { + match role { + "text" => self.colors.text + "muted" => self.colors.muted + "dim" => self.colors.dim + "accent" => self.colors.accent + "border" => self.colors.border + "borderMuted" => self.colors.border_muted + "success" => self.colors.success + "error" => self.colors.error + "warning" => self.colors.warning + "thinkingText" => self.colors.thinking_text + "customMessageLabel" => self.colors.custom_message_label + "mdLink" => self.colors.md_link + "mdCode" => self.colors.md_code + "mdCodeBlock" => self.colors.md_code_block + "mdCodeBlockBorder" => self.colors.md_code_block_border + "userMessageBg" => self.colors.user_message_bg + "toolPendingBg" => self.colors.tool_pending_bg + "toolSuccessBg" => self.colors.tool_success_bg + "toolErrorBg" => self.colors.tool_error_bg + "bashMode" => self.colors.bash_mode + "selectedBg" => self.colors.selected_bg + "scrollbarTrack" => self.colors.scrollbar_track + "scrollbarThumb" => self.colors.scrollbar_thumb + "searchMatchBg" => self.colors.search_match_bg + "searchMatchText" => self.colors.search_match_text + "userMessageText" => self.colors.user_message_text + "toolTitle" => self.colors.tool_title + "toolOutput" => self.colors.tool_output + "mdHeading" => self.colors.md_heading + "mdLinkUrl" => self.colors.md_link_url + "mdQuote" => self.colors.md_quote + "mdQuoteBorder" => self.colors.md_quote_border + "mdHr" => self.colors.md_hr + "mdListBullet" => self.colors.md_list_bullet + "thinkingOff" => self.colors.thinking_off + "thinkingMinimal" => self.colors.thinking_minimal + "thinkingLow" => self.colors.thinking_low + "thinkingMedium" => self.colors.thinking_medium + "thinkingHigh" => self.colors.thinking_high + "thinkingXhigh" => self.colors.thinking_xhigh + "thinkingMax" => self.colors.thinking_max + _ => abort("unknown theme color role: \{role}") + } +} + ///| /// 主题名。 pub fn Theme::theme_name(self : Theme) -> String { diff --git a/tui_app/transcript.mbt b/tui_app/transcript.mbt index d5f01d0..f746553 100644 --- a/tui_app/transcript.mbt +++ b/tui_app/transcript.mbt @@ -1,23 +1,23 @@ // 聊天记录内容组件:把消息模型同步渲染成终端行。 // -// 渲染对齐 pi(见各 render_* 的注释),但**本期不做** diff / 压缩摘要 / -// 分支摘要 / 自定义消息 / mermaid / OSC 8 链接。布局、滚动、增量绘制由 pi-tui 负责。 +// Markdown、换行与布局复用 pi-tui;消息和工具状态由本包维护。 // // `render(width)` 是同步纯函数:只读当前状态,不访问外部、不 await。 ///| /// 聊天记录中的一项。 enum TranscriptItem { + Startup /// 用户消息(整块背景,pi `userMessageBg`)。 User(String) /// 系统提示 / 普通信息(dim)。 Note(String) /// 错误信息(error 色)。 Error(String) - /// assistant 消息:text, thinking, stop_reason, error_message。 - Assistant(String, String, String?, String?) + /// assistant 内容按到达顺序保存;Bool 为 true 时是 thinking。 + Assistant(Array[(String, Bool)], String?, String?) /// 工具调用 + 可选结果:name, args, result, is_error;按 call id 配对。 - Tool(String, Json, String?, Bool, id~ : String) + Tool(String, Json, String?, Bool, Json?, Double?, Double?, id~ : String) } ///| @@ -29,6 +29,11 @@ pub struct Transcript { mut expanded_tool_output : Bool /// 思考内容是否展开(默认展开,`Ctrl+T` 折叠)。 mut expanded_thinking : Bool + tool_overrides : Map[Int, Bool] + thinking_overrides : Map[String, Bool] + /// One latest render per message block; streaming replaces entries instead of growing the cache. + markdown_cache : Map[String, (String, Int, Bool, String, Array[String])] + generic_tool_cache : Map[Int, (Int, Bool, Bool, Array[String])] } ///| @@ -43,7 +48,7 @@ const TOOL_PREVIEW_LINES : Int = 10 ///| /// 折叠(未展开)时 bash 输出预览的行数。 -const BASH_PREVIEW_LINES : Int = 20 +const BASH_PREVIEW_LINES : Int = 5 ///| /// 内容左内边距(pi 的 paddingX=1)。 @@ -54,7 +59,16 @@ const PAD : Int = 1 /// /// 主题在构造时注入:渲染是同步纯函数,不读取任何全局状态。 pub fn Transcript::new(theme : Theme) -> Transcript { - { theme, items: [], expanded_tool_output: false, expanded_thinking: true, } + { + theme, + items: [], + expanded_tool_output: false, + expanded_thinking: true, + tool_overrides: Map([]), + thinking_overrides: Map([]), + markdown_cache: Map([]), + generic_tool_cache: Map([]), + } } ///| @@ -63,6 +77,11 @@ pub fn Transcript::push_user(self : Transcript, text : String) -> Unit { self.push_item(TranscriptItem::User(keep_text(text))) } +///| +pub fn Transcript::push_startup(self : Transcript) -> Unit { + self.push_item(TranscriptItem::Startup) +} + ///| /// 向记录追加一条系统提示或普通信息。 pub fn Transcript::push_note(self : Transcript, text : String) -> Unit { @@ -78,37 +97,60 @@ pub fn Transcript::push_error(self : Transcript, text : String) -> Unit { ///| /// 开始一条新的 assistant 消息。 pub fn Transcript::start_assistant(self : Transcript) -> Unit { - self.push_item(TranscriptItem::Assistant("", "", None, None)) + self.push_item(TranscriptItem::Assistant([], None, None)) } ///| /// 把流式 assistant 文本追加到最后一条 assistant 消息。 pub fn Transcript::append_text(self : Transcript, delta : String) -> Unit { + self.append_content(delta, false) +} + +///| +/// 把流式 assistant 思考内容追加到最后一条 assistant 消息。 +pub fn Transcript::append_thinking(self : Transcript, delta : String) -> Unit { + self.append_content(delta, true) +} + +///| +fn Transcript::append_content( + self : Transcript, + delta : String, + thinking : Bool, +) -> Unit { let index = self.ensure_assistant() match self.items[index] { - TranscriptItem::Assistant(text, thinking, stop_reason, error_message) => - self.items[index] = TranscriptItem::Assistant( - append_text_bounded(text, delta), - thinking, - stop_reason, - error_message, - ) + TranscriptItem::Assistant(blocks, _, _) => { + let last = blocks.length() - 1 + if last >= 0 && blocks[last].1 == thinking { + blocks[last] = (append_text_bounded(blocks[last].0, delta), thinking) + } else { + blocks.push((keep_text(delta), thinking)) + } + } _ => () } } ///| -/// 把流式 assistant 思考内容追加到最后一条 assistant 消息。 -pub fn Transcript::append_thinking(self : Transcript, delta : String) -> Unit { +/// Replace an in-flight snapshot without regrouping text and thinking runs. +pub fn Transcript::set_assistant_content( + self : Transcript, + content : Array[(String, Bool)], +) -> Unit { let index = self.ensure_assistant() + let blocks : Array[(String, Bool)] = [] + for (text, thinking) in content { + let last = blocks.length() - 1 + if thinking && last >= 0 && blocks[last].1 { + blocks[last] = (keep_text(blocks[last].0 + "\n\n" + text), true) + } else { + blocks.push((keep_text(text), thinking)) + } + } match self.items[index] { - TranscriptItem::Assistant(text, thinking, stop_reason, error_message) => - self.items[index] = TranscriptItem::Assistant( - text, - append_text_bounded(thinking, delta), - stop_reason, - error_message, - ) + TranscriptItem::Assistant(_, reason, error) => + self.items[index] = TranscriptItem::Assistant(blocks, reason, error) _ => () } } @@ -122,10 +164,9 @@ pub fn Transcript::finish_assistant( ) -> Unit { let index = self.ensure_assistant() match self.items[index] { - TranscriptItem::Assistant(text, thinking, _, _) => + TranscriptItem::Assistant(blocks, _, _) => self.items[index] = TranscriptItem::Assistant( - text, - thinking, + blocks, Some(keep_text(stop_reason)), error_message.map(fn(message) { keep_text(message) }), ) @@ -140,9 +181,23 @@ pub fn Transcript::push_tool_call( name : String, arguments : Json, id~ : String, + timed? : Bool = false, ) -> Unit { self.push_item( - TranscriptItem::Tool(keep_text(name), arguments, None, false, id~), + TranscriptItem::Tool( + keep_text(name), + arguments, + None, + false, + None, + if timed { + Some(now_ms()) + } else { + None + }, + None, + id~, + ), ) } @@ -154,20 +209,33 @@ pub fn Transcript::push_tool_result( text : String, is_error : Bool, id~ : String, + details? : Json? = None, ) -> Unit { let mut index = self.items.length() while index > 0 { index -= 1 match self.items[index] { - TranscriptItem::Tool(tool_name, arguments, None, _, id=call_id) if call_id == - id => { + TranscriptItem::Tool( + tool_name, + arguments, + None, + _, + _, + started, + _, + id=call_id + ) if call_id == id => { self.items[index] = TranscriptItem::Tool( tool_name, arguments, Some(keep_text(text)), is_error, + details, + started, + started.map(_ => now_ms()), id~, ) + self.generic_tool_cache.remove(index) |> ignore return } _ => () @@ -179,6 +247,9 @@ pub fn Transcript::push_tool_result( Json::empty_object(), Some(keep_text(text)), is_error, + details, + None, + None, id~, ), ) @@ -191,6 +262,7 @@ pub fn Transcript::set_expanded_tool_output( expanded : Bool, ) -> Unit { self.expanded_tool_output = expanded + self.tool_overrides.clear() } ///| @@ -200,18 +272,19 @@ pub fn Transcript::set_expanded_thinking( expanded : Bool, ) -> Unit { self.expanded_thinking = expanded + self.thinking_overrides.clear() } ///| /// 切换思考内容的展开状态(`Ctrl+T`)。 pub fn Transcript::toggle_expanded_thinking(self : Transcript) -> Unit { - self.expanded_thinking = !self.expanded_thinking + self.set_expanded_thinking(!self.expanded_thinking) } ///| /// 切换工具输出的展开状态(`Ctrl+O`)。 pub fn Transcript::toggle_expanded_tool_output(self : Transcript) -> Unit { - self.expanded_tool_output = !self.expanded_tool_output + self.set_expanded_tool_output(!self.expanded_tool_output) } ///| @@ -234,10 +307,14 @@ pub impl Widget for Transcript with fn render(self : Transcript, width : Int) -> let safe_width = if width < 0 { 0 } else { width } let lines : Array[String] = [] for index, item in self.items { - if index > 0 { + let rendered = render_item(self, item, safe_width, index) + if rendered.is_empty() { + continue + } + if !lines.is_empty() { lines.push("") } - for line in render_item(self, item, safe_width) { + for line in rendered { for row in physical_lines(line) { lines.push(truncate_to_width(row, safe_width)) } @@ -267,11 +344,85 @@ fn physical_lines(text : String) -> Array[String] { ///| pub extend Transcript with Widget::{render, invalidate} +///| +/// Left-click coordinates are logical document rows, retargeted by ScrollView. +/// Recompute spans at the current width so resizing cannot leave stale hit areas. +pub fn Transcript::click(self : Transcript, row : Int, width : Int) -> Bool { + let mut top = 0 + for index, item in self.items { + let rendered = render_item(self, item, width, index) + if rendered.is_empty() { + continue + } + if top > 0 { + top += 1 + } + if row >= top && row < top + rendered.length() { + match item { + TranscriptItem::Tool(name, args, Some(_), error, _, _, _, ..) => { + let expanded = self.tool_overrides + .get(index) + .unwrap_or(self.expanded_tool_output) + let header = render_tool( + self, + name, + args, + None, + error, + None, + None, + None, + width, + expanded~, + ) + if row - top >= header.length() - 1 { + self.tool_overrides[index] = !expanded + return true + } + } + TranscriptItem::Assistant(blocks, _, _) => { + let mut block_top = top + let mut has_content = false + for block_index, (text, thinking) in blocks { + if text.trim().is_empty() { + continue + } + if has_content { + block_top += 1 + } + let key = "\{index}:\{block_index}" + let expanded = self.thinking_overrides + .get(key) + .unwrap_or(self.expanded_thinking) + let rows = render_assistant_block( + self, text, thinking, expanded, width, key, + ) + if thinking && row >= block_top && row < block_top + rows.length() { + self.thinking_overrides[key] = !expanded + return true + } + has_content = true + block_top += rows.length() + } + } + _ => () + } + return false + } + top += rendered.length() + } + false +} + ///| /// 追加项并限制模型大小;保留一条省略提示作为历史边界。 fn Transcript::push_item(self : Transcript, item : TranscriptItem) -> Unit { if self.items.length() >= MAX_ITEMS { self.items = self.items[2:].to_owned() + self.tool_overrides.clear() + self.thinking_overrides.clear() + self.markdown_cache.clear() + self.generic_tool_cache.clear() self.items.push(TranscriptItem::Note("… earlier messages omitted")) } self.items.push(item) @@ -281,12 +432,12 @@ fn Transcript::push_item(self : Transcript, item : TranscriptItem) -> Unit { /// 找到最后一条 assistant,没有时就创建一条。 fn Transcript::ensure_assistant(self : Transcript) -> Int { if self.items.is_empty() { - self.push_item(TranscriptItem::Assistant("", "", None, None)) + self.start_assistant() } else { let last = self.items.length() - 1 match self.items[last] { - TranscriptItem::Assistant(_, _, _, _) => () - _ => self.push_item(TranscriptItem::Assistant("", "", None, None)) + TranscriptItem::Assistant(_, _, _) => () + _ => self.start_assistant() } } self.items.length() - 1 @@ -301,31 +452,118 @@ fn render_item( transcript : Transcript, item : TranscriptItem, width : Int, + index : Int, ) -> Array[String] { let theme = transcript.theme match item { - TranscriptItem::User(text) => render_user(theme, text, width) + TranscriptItem::Startup => { + let hints = [ + ("Esc", "interrupt"), + ("Ctrl+C/Ctrl+D", "clear/exit"), + ("/", "commands"), + ("Ctrl+O", "more"), + ] + let compact = hints + .map(fn(pair) { theme.f_dim(pair.0) + " " + theme.f_muted(pair.1) }) + .join(theme.f_muted(" · ")) + let content = if transcript.expanded_tool_output { + [ + ("Esc", "to interrupt"), + ("Ctrl+C", "to clear"), + ("Ctrl+C twice", "to exit"), + ("Ctrl+D", "to exit (empty)"), + ("Ctrl+O", "to expand tools"), + ("Ctrl+T", "to expand thinking"), + ("/", "for commands"), + ] + .map(fn(pair) { theme.f_dim(pair.0) + " " + theme.f_muted(pair.1) }) + .join("\n") + } else { + compact + "\n" + theme.f_dim("Press Ctrl+O to show full startup help.") + } + let rows : Array[String] = [] + for row in physical_lines(bold(theme.f_accent("pim")) + "\n" + content) { + for line in wrap_text(row, available_width(width, PAD * 2)) { + rows.push(pad_left(line, PAD)) + } + } + rows + } + TranscriptItem::User(text) => + render_user(transcript, text, width, "user:\{index}") TranscriptItem::Note(text) => render_plain(text, fn(text) { theme.f_dim(text) }, width) TranscriptItem::Error(text) => render_plain(text, fn(text) { theme.f_error(text) }, width) - TranscriptItem::Assistant(text, thinking, stop_reason, error_message) => + TranscriptItem::Assistant(blocks, stop_reason, error_message) => render_assistant( - transcript, text, thinking, stop_reason, error_message, width, + transcript, blocks, stop_reason, error_message, width, index, + ) + TranscriptItem::Tool( + name, + arguments, + result, + is_error, + details, + started, + ended, + .. + ) => { + let expanded = transcript.tool_overrides + .get(index) + .unwrap_or(transcript.expanded_tool_output) + let true_color = terminal_true_color() + if name != "bash" && + transcript.generic_tool_cache.get(index) + is Some((cached_width, cached_expanded, cached_color, rows)) && + cached_width == width && + cached_expanded == expanded && + cached_color == true_color { + return rows + } + let rows = render_tool( + transcript, + name, + arguments, + result, + is_error, + details, + started, + ended, + width, + expanded~, ) - TranscriptItem::Tool(name, arguments, result, is_error, ..) => - render_tool(transcript, name, arguments, result, is_error, width) + if name != "bash" { + transcript.generic_tool_cache[index] = ( + width, expanded, true_color, rows, + ) + } + rows + } } } ///| /// 用户消息:整块 `userMessageBg` 背景,上下各留一行 padding(pi `Box(padX=1,padY=1)`)。 -fn render_user(theme : Theme, text : String, width : Int) -> Array[String] { +fn render_user( + transcript : Transcript, + text : String, + width : Int, + key : String, +) -> Array[String] { + let theme = transcript.theme let bg = fn(text : String) { theme.bg_user(text) } let lines : Array[String] = [] lines.push(bg_blank(bg, width)) let content_width = available_width(width, PAD * 2) - for wrapped in wrap_text(text, content_width) { + for + wrapped in transcript.cached_markdown( + key, + physical_lines(text).join("\n"), + content_width, + body_role="userMessageText", + user=true, + ) { lines.push(bg_line(bg, " ".repeat(PAD) + wrapped, width)) } lines.push(bg_blank(bg, width)) @@ -340,7 +578,7 @@ fn render_plain( width : Int, ) -> Array[String] { let lines : Array[String] = [] - let content_width = available_width(width, PAD) + let content_width = available_width(width, PAD * 2) for line in physical_lines(text) { let rendered = color(line) if visible_width(rendered) == 0 { @@ -361,74 +599,151 @@ fn render_plain( /// assistant 消息:思考段 + 正文(Markdown)+ 停止原因 / 错误。 fn render_assistant( transcript : Transcript, - text : String, - thinking : String, + blocks : Array[(String, Bool)], stop_reason : String?, error_message : String?, width : Int, + item_index : Int, ) -> Array[String] { let theme = transcript.theme let lines : Array[String] = [] - if !thinking.is_empty() { - if transcript.expanded_thinking { - for - line in render_markdown( - theme, - thinking, - width, - PAD, - fn(text) { theme.f_thinking(text) }, - true, - ) { - lines.push(line) - } - } else { - lines.push(pad_left(italic(theme.f_thinking("Thinking...")), PAD)) + for block_index, (text, thinking) in blocks { + if text.trim().is_empty() { + continue } - lines.push("") - } - if !text.is_empty() { + if !lines.is_empty() { + lines.push("") + } + let expanded = transcript.thinking_overrides + .get("\{item_index}:\{block_index}") + .unwrap_or(transcript.expanded_thinking) for - line in render_markdown( - theme, + line in render_assistant_block( + transcript, text, + thinking, + expanded, width, - PAD, - fn(text) { theme.f_text(text) }, - false, + "\{item_index}:\{block_index}", ) { lines.push(line) } } - match stop_reason { - Some(reason) if is_abnormal_stop(reason) => - lines.push(pad_left(theme.f_dim("(stopped: \{reason})"), PAD)) - _ => () + let notice = match stop_reason { + Some("length") => Some("Response was truncated before completion.") + Some("aborted") => + Some( + match error_message { + Some(message) if message != "Request was aborted" && + !message.is_empty() => message + _ => "Operation aborted" + }, + ) + Some("error") => Some("Error: " + error_message.unwrap_or("Unknown error")) + _ => None } - match error_message { - Some(message) if !message.is_empty() => - for line in render_plain(message, fn(text) { theme.f_error(text) }, width) { - lines.push(line) - } - _ => () + if notice is Some(message) { + if !lines.is_empty() { + lines.push("") + } + for line in render_plain(message, fn(text) { theme.f_error(text) }, width) { + lines.push(line) + } } lines } ///| -/// 工具调用 + 结果。bash 走 pi `bash-execution.ts` 的绿框;其余走通用块。 +fn render_assistant_block( + transcript : Transcript, + text : String, + thinking : Bool, + expanded : Bool, + width : Int, + key : String, +) -> Array[String] { + if thinking && !expanded { + [pad_left(italic(transcript.theme.f_thinking("Thinking...")), PAD)] + } else { + transcript.cached_markdown( + key, + text, + width, + body_role=if thinking { "thinkingText" } else { "text" }, + body_italic=thinking, + ) + } +} + +///| +fn Transcript::cached_markdown( + self : Transcript, + key : String, + text : String, + width : Int, + body_role~ : String, + body_italic? : Bool = false, + user? : Bool = false, +) -> Array[String] { + let true_color = terminal_true_color() + if self.markdown_cache.get(key) + is Some((cached_text, cached_width, cached_color, cached_role, lines)) && + cached_text == text && + cached_width == width && + cached_color == true_color && + cached_role == body_role { + return lines + } + let lines = render_markdown_themed( + self.theme, + text, + width, + padding_x=if user { 0 } else { 1 }, + body_role~, + body_italic~, + preserve_ordered_markers=user, + preserve_backslash_escapes=user, + ) + self.markdown_cache[key] = (text, width, true_color, body_role, lines) + lines +} + +///| +/// Agent tool calls use the status-colored shell; `!` shell mode is separate in pi. fn render_tool( transcript : Transcript, name : String, arguments : Json, result : String?, is_error : Bool, + details : Json?, + started : Double?, + ended : Double?, width : Int, + expanded~ : Bool, ) -> Array[String] { if name == "bash" { - render_bash(transcript, arguments, result, is_error, width) + render_bash( + transcript, + arguments, + result, + is_error, + details, + started, + ended, + width, + expanded~, + ) } else { - render_tool_generic(transcript, name, arguments, result, is_error, width) + render_tool_generic( + transcript, + name, + arguments, + result, + is_error, + width, + expanded~, + ) } } @@ -446,7 +761,7 @@ fn arg_string(arguments : Json, keys : Array[String]) -> String? { } ///| -/// 通用工具块:粗体工具名 + 参数摘要,结果按预览/展开显示。 +/// 通用工具块与 read 回放使用状态背景。 /// /// pi 的 read 渲染是 `read :`,且折叠时不显示结果;这里同样处理。 fn render_tool_generic( @@ -456,6 +771,7 @@ fn render_tool_generic( result : String?, is_error : Bool, width : Int, + expanded~ : Bool, ) -> Array[String] { let theme = transcript.theme let bg = theme.tool_bg(result, is_error) @@ -464,54 +780,70 @@ fn render_tool_generic( let is_read = name == "read" || name == "read_file" let header = if is_read { match arg_string(arguments, ["path", "file_path"]) { - Some(path) => bold(theme.f_text("read")) + " " + theme.f_accent(path) + Some(path) => + theme.fg("toolTitle", bold("read")) + + " " + + theme.f_accent(path) + + read_range(theme, arguments) None => bold(theme.f_text(name)) } } else { - let summary = match - arg_string(arguments, ["command", "path", "file_path", "pattern", "query"]) { - Some(value) => value - None => arguments.stringify() - } - // 摘要按设计只占一行(下面会按可用宽度截断):多行参数只取首行并标注省略, - // 不能把带换行的整串塞进一个元素(会被终端写到视窗下方的物理行)。 - let summary_rows = physical_lines(summary) - let summary_text = if summary_rows.length() > 1 { - summary_rows[0] + "..." - } else { - summary + theme.fg("toolTitle", bold(name)) + } + let content_width = available_width(width, PAD * 2) + for row in wrap_text(header, content_width) { + lines.push(bg_line(bg, " " + row, width)) + } + if !is_read { + lines.push(bg_blank(bg, width)) + let limit = if expanded { 50 } else { 10 } + let mut shown = 0 + let mut omitted = false + for row in physical_lines(pretty_arguments(arguments)) { + for wrapped in wrap_text(row, content_width) { + if shown >= limit { + omitted = true + break + } + lines.push(bg_line(bg, " " + wrapped, width)) + shown += 1 + } + if omitted { + break + } } - bold(theme.f_text(name)) + - (if summary_text.is_empty() { - "" - } else { - " " + - theme.f_dim( - truncate_to_width( - summary_text, - available_width(width, PAD + visible_width(name) + 1), + if omitted { + lines.push( + bg_line( + bg, + " " + + truncate_to_width( + theme.f_muted("... (arguments truncated)"), + content_width, + ), + width, ), ) - }) - } - lines.push(bg_line(bg, " ".repeat(PAD) + header, width)) - let show_result = match (is_read, transcript.expanded_tool_output) { - (true, false) => false - _ => true + } } + let show_result = !is_read || expanded || is_error match result { - Some(text) if show_result && !text.is_empty() => + Some(text) if show_result && !text.is_empty() => { + lines.push(bg_blank(bg, width)) + // TODO: read 的代码输出语法高亮随 Markdown 代码块一起对齐 pi。 + let rendered = text.replace_all(old="\t", new=" ") for line in result_preview( theme, - text, + rendered, TOOL_PREVIEW_LINES, - transcript.expanded_tool_output, - available_width(width, PAD), + expanded, + content_width, tail=false, ) { - lines.push(bg_line(bg, " ".repeat(PAD) + theme.f_muted(line), width)) + lines.push(bg_line(bg, " " + line, width)) } + } _ => () } lines.push(bg_blank(bg, width)) @@ -519,57 +851,119 @@ fn render_tool_generic( } ///| -/// bash 执行块:绿框 + `$ command` + 折叠预览(pi `bash-execution.ts`)。 +/// Agent bash uses a status background and a five-visual-line tail preview. fn render_bash( transcript : Transcript, arguments : Json, result : String?, is_error : Bool, + details : Json?, + started : Double?, + ended : Double?, width : Int, + expanded~ : Bool, ) -> Array[String] { let theme = transcript.theme + let bg = theme.tool_bg(result, is_error) let lines : Array[String] = [] - let rule = theme.f_bash("─".repeat(if width > 0 { width } else { 0 })) - lines.push(rule) + let content_width = available_width(width, PAD * 2) + lines.push(bg_blank(bg, width)) let command = match arg_string(arguments, ["command"]) { Some(value) => value None => "" } - // 与 pi 一致(pi 用 `Text(theme.fg("bashMode", theme.bold("$ " + command)), 1, 0)`): - // 命令按物理行展开,`$ ` 只加在第一行,超宽按内容宽度换行。 - // 整串命令塞进一个元素会让 `\n` 后面的行落到视窗下方的物理行(输入框)。 for index, command_line in physical_lines(command) { let prefix = if index == 0 { "$ " } else { "" } + let timeout = if index == 0 { + match arguments { + Object(fields) => + match fields.get("timeout") { + Some(Number(seconds, ..)) => theme.f_muted(" (timeout \{seconds}s)") + _ => "" + } + _ => "" + } + } else { + "" + } for wrapped in wrap_text( - theme.f_bash(bold(prefix + command_line)), - available_width(width, PAD), + theme.fg("toolTitle", bold(prefix + command_line)) + timeout, + content_width, ) { - lines.push(" ".repeat(PAD) + wrapped) + lines.push(bg_line(bg, " " + wrapped, width)) } } - match result { - Some(text) if !text.is_empty() => + let output = result.map(fn(text) { bash_output(text, details) }) + match output { + Some(text) if !text.is_empty() => { + lines.push(bg_blank(bg, width)) for line in result_preview( theme, text, BASH_PREVIEW_LINES, - transcript.expanded_tool_output, - available_width(width, PAD), + expanded, + content_width, tail=true, ) { - lines.push(" ".repeat(PAD) + theme.f_muted(line)) + lines.push(bg_line(bg, " " + line, width)) } + } _ => () } - // pi 只在拿得到真实退出码时才写 `(exit N)`(`bash-execution.ts`:状态未知就不写), - // 这里没有退出码,所以只标一个不谎报的失败记号;具体原因由结果文本给出 - // ("Command exited with code 2" / "Command timed out..." / "Command aborted")。 - if is_error { - lines.push(" ".repeat(PAD) + theme.f_error("(failed)")) + if details is Some(Object(fields)) { + let warnings : Array[String] = [] + if fields.get("fullOutputPath") is Some(String(path)) { + warnings.push("Full output: " + path) + } + if fields.get("truncation") is Some(Object(truncation)) && + truncation.get("truncated") is Some(True) { + if truncation.get("outputLines") is Some(Number(shown, ..)) && + truncation.get("totalLines") is Some(Number(total, ..)) { + if truncation.get("lastLinePartial") is Some(True) && + truncation.get("outputBytes") is Some(Number(output_bytes, ..)) && + truncation.get("totalBytes") is Some(Number(total_bytes, ..)) { + warnings.push( + "Truncated: showing last " + + format_output_size(output_bytes) + + " of " + + format_output_size(total_bytes) + + " (partial line)", + ) + } else if truncation.get("truncatedBy") is Some(String("lines")) { + warnings.push("Truncated: showing \{shown} of \{total} lines") + } else { + let max_bytes = match truncation.get("maxBytes") { + Some(Number(bytes, ..)) => bytes + _ => 51200.0 + } + warnings.push( + "Truncated: \{shown} lines shown (" + + format_output_size(max_bytes) + + " limit)", + ) + } + } + } + if !warnings.is_empty() { + lines.push(bg_blank(bg, width)) + for + row in wrap_text( + theme.f_warning("[" + warnings.join(". ") + "]"), + content_width, + ) { + lines.push(bg_line(bg, " " + row, width)) + } + } } - lines.push(rule) + if started is Some(started) { + let label = if ended is Some(_) { "Took" } else { "Elapsed" } + let duration = format_tool_duration(ended.unwrap_or(now_ms()) - started) + lines.push(bg_blank(bg, width)) + lines.push(bg_line(bg, " " + theme.f_muted("\{label} \{duration}"), width)) + } + lines.push(bg_blank(bg, width)) lines } @@ -585,21 +979,36 @@ fn result_preview( ) -> Array[String] { let all : Array[String] = [] for row in physical_lines(text) { - for wrapped in wrap_text(row, width) { + for wrapped in wrap_text(theme.fg("toolOutput", row), width) { all.push(wrapped) } } let total = all.length() let shown = if expanded || total <= max_lines { total } else { max_lines } let start = if tail { total - shown } else { 0 } - let lines = all[start:start + shown].to_owned() + let lines : Array[String] = [] if shown < total { let remaining = total - shown - lines.push( - theme.f_dim("... (\{remaining} more lines, ") + + let hint = theme.f_muted( + "... (\{remaining} " + + (if tail { "earlier" } else { "more" }) + + " lines, ", + ) + key_hint(theme, "ctrl+o") + - theme.f_dim(" to expand)"), - ) + theme.f_muted(" to expand)") + if tail { + lines.push(truncate_to_width(hint, width)) + } + for row in all[start:start + shown] { + lines.push(row) + } + if !tail { + lines.push(truncate_to_width(hint, width)) + } + } else { + for row in all { + lines.push(row) + } } lines } @@ -610,104 +1019,60 @@ fn key_hint(theme : Theme, key : String) -> String { theme.f_dim(key) } -// --------------------------------------------------------------------------- -// Markdown(简化版:围栏代码块 + 行内 code/bold) -// --------------------------------------------------------------------------- - ///| -/// 渲染 Markdown 文本:围栏代码块按语言高亮,其余行做行内变换后按宽度换行。 -fn render_markdown( - theme : Theme, - text : String, - width : Int, - indent : Int, - body_color : (String) -> String, - italic_body : Bool, -) -> Array[String] { - let lines : Array[String] = [] - let content_width = available_width(width, indent) - let mut in_fence = false - let mut lang = "" - for line in physical_lines(text) { - let trimmed = line.trim().to_owned() - if !in_fence && trimmed.has_prefix("```") { - in_fence = true - lang = trimmed[3:].to_owned() - lines.push(pad_left(theme.f_md_border("```" + lang), indent)) - } else if in_fence && trimmed.has_prefix("```") { - in_fence = false - lang = "" - lines.push(pad_left(theme.f_md_border("```"), indent)) - } else if in_fence { - for wrapped in wrap_text(theme.f_md_code_block(line), content_width) { - lines.push(pad_left(wrapped, indent)) - } - } else if trimmed.is_empty() { - lines.push("") - } else { - let rendered = if italic_body { - italic(body_color(render_inline(theme, line))) - } else { - body_color(render_inline(theme, line)) - } - for wrapped in wrap_text(rendered, content_width) { - lines.push(pad_left(wrapped, indent)) - } - } - } - lines +fn pretty_arguments(arguments : Json) -> String { + js_pretty_arguments(arguments.stringify()) } ///| -/// 行内 Markdown:`` `code` `` → mdCode,`**bold**` → 粗体。 -fn render_inline(theme : Theme, text : String) -> String { - let chars = text.to_array() - let out = StringBuilder() - let mut buffer = "" - let mut in_code = false - let mut in_bold = false - let mut index = 0 - let length = chars.length() - while index < length { - let char = chars[index] - if char == '`' { - out.write_string(styled_run(theme, buffer, in_code, in_bold)) - buffer = "" - in_code = !in_code - index += 1 - } else if char == '*' && index + 1 < length && chars[index + 1] == '*' { - out.write_string(styled_run(theme, buffer, in_code, in_bold)) - buffer = "" - in_bold = !in_bold - index += 2 - } else { - buffer = buffer + char.to_string() - index += 1 - } +extern "js" fn js_pretty_arguments(arguments : String) -> String = + #| (value) => { const formatted = JSON.stringify(JSON.parse(value), null, 2); return formatted.length > 65536 ? formatted.slice(0,65536) + '\n... (arguments truncated)' : formatted; } + +///| +fn read_range(theme : Theme, arguments : Json) -> String { + guard arguments is Object(fields) else { return "" } + let offset = fields.get("offset") + let limit = fields.get("limit") + if offset is None && limit is None { + return "" + } + let start = match offset { + Some(Number(n, ..)) => n + _ => 1.0 + } + let end = match limit { + Some(Number(n, ..)) => "-\{start + n - 1.0}" + _ => "" } - out.write_string(styled_run(theme, buffer, in_code, in_bold)) - out.to_string() + theme.f_warning(":\{start}\{end}") } ///| -/// 渲染一段行内文本(按当前行内状态着色)。 -fn styled_run( - theme : Theme, - text : String, - in_code : Bool, - in_bold : Bool, -) -> String { - if text.is_empty() { - "" - } else if in_code { - theme.f_md_code(text) - } else if in_bold { - bold(text) - } else { - text +fn bash_output(text : String, details : Json?) -> String { + let path = match details { + Some(Object(fields)) if fields.get("truncation") is Some(Object(truncation)) && + truncation.get("truncated") is Some(True) => + match fields.get("fullOutputPath") { + Some(String(path)) => path + _ => "" + } + _ => "" } + strip_bash_footer(text, path) } +///| +extern "js" fn strip_bash_footer(text : String, path : String) -> String = + #| (text, path) => { const index = text.lastIndexOf('\n\n['); return path && text.endsWith(']') && index >= 0 && text.slice(index).includes(path) ? text.slice(0,index) : text; } + +///| +extern "js" fn format_output_size(bytes : Double) -> String = + #| bytes => bytes < 1024 ? `${bytes}B` : bytes < 1048576 ? `${(bytes/1024).toFixed(1)}KB` : `${(bytes/1048576).toFixed(1)}MB` + +///| +extern "js" fn format_tool_duration(ms : Double) -> String = + #| ms => { const seconds = ms/1000; if (seconds < 60) return `${seconds.toFixed(1)}s`; const s = Math.floor(seconds), m = Math.floor(s/60); return m < 60 ? `${m}m ${s%60}s` : `${Math.floor(m/60)}h ${m%60}m ${s%60}s`; } + // --------------------------------------------------------------------------- // 小工具 // --------------------------------------------------------------------------- @@ -723,17 +1088,6 @@ fn available_width(width : Int, indent : Int) -> Int { } } -///| -/// 判断停止原因是否代表错误或中止。 -fn is_abnormal_stop(reason : String) -> Bool { - reason == "error" || - reason == "failed" || - reason == "abort" || - reason == "aborted" || - reason == "cancelled" || - reason == "canceled" -} - ///| /// 限制单条文本的保留大小,避免流式输出无限增长。 fn keep_text(text : String) -> String { diff --git a/tui_app/transcript_test.mbt b/tui_app/transcript_test.mbt index a2f217a..6d3f8ea 100644 --- a/tui_app/transcript_test.mbt +++ b/tui_app/transcript_test.mbt @@ -61,11 +61,11 @@ async test "multi-line bash command renders one row per element" { #| run the snippet #| #| - #|──────────────────────────────────────── - #| $ python3 - <<'PY' - #| print("greeting") - #| PY - #|──────────────────────────────────────── + #| + #| $ python3 - <<'PY' + #| print("greeting") + #| PY + #| ), ) } @@ -75,9 +75,9 @@ async test "multi-line bash command renders one row per element" { /// keeps the tail (the final diagnostic); generic tools keep the beginning. async test "tool previews count visual rows and preserve expanded output" { @tui_app.load_module() - for (name, limit) in [("bash", 20), ("grep", 10)] { + for (name, limit) in [("bash", 5), ("grep", 10)] { let transcript = @tui_app.Transcript::new(@tui_app.Theme::load("light")) - let output = "0123456789".repeat(limit + 2) + let output = "012345678".repeat(limit + 2) transcript.push_tool_call( name, { "command": "run", "pattern": "match" }, @@ -90,29 +90,39 @@ async test "tool previews count visual rows and preserve expanded output" { id=name, ) let collapsed = render_rows(transcript, 11) + let rows = collapsed.map(row => row.trim().to_owned()) assert_eq( - collapsed[2], + rows.filter(row => row == "012345678").length(), if name == "bash" { - " 0123456789" + 4 } else { - " HEAD " + 9 }, ) - if name == "bash" { - assert_eq(collapsed[limit + 1], " TAIL") + assert_eq(rows.contains("HEAD"), name != "bash") + assert_eq(rows.contains("TAIL"), name == "bash") + let theme = @tui_app.Theme::load("light") + let foreground = theme + .fg("toolOutput", "") + .strip_suffix("\u{1b}[39m") + .unwrap() + .to_owned() + for line in @tui_app.Widget::render(transcript, 11) { + if strip_ansi(line).contains("012345678") { + assert_true(line.contains(foreground)) + } } transcript.set_expanded_tool_output(true) let expanded = render_rows(transcript, 11) - // Framing/header = 2 rows, output = limit + 4 rows, final frame = 1 row. - assert_eq(expanded.length(), limit + 7) - for row in 3..<(limit + 5) { - assert_eq(expanded[row].trim().to_owned(), "0123456789") - } - assert_eq(expanded[2].trim().to_owned(), "HEAD") - assert_eq(expanded[limit + 5].trim().to_owned(), "TAIL") + let rows = expanded.map(row => row.trim().to_owned()) + assert_eq(rows.filter(row => row == "012345678").length(), limit + 2) + assert_true(rows.contains("HEAD")) + assert_true(rows.contains("TAIL")) // A resize must rewrap the raw text, not a cached preview. - let wider = render_rows(transcript, 21) - assert_eq(wider[3].trim().to_owned(), "01234567890123456789") + let wider = render_rows(transcript, 20) + assert_true( + wider.map(row => row.trim().to_owned()).contains("012345678012345678"), + ) } } @@ -155,7 +165,11 @@ async test "multi-line tool summary renders one row per element" { render_rows(transcript, 40).join("\n"), content=( #| - #| grep first... + #| grep + #| + #| { + #| "pattern": "first\nsecond" + #| } #| ), ) @@ -178,19 +192,18 @@ async test "multi-line user message and CRLF tool result render one row per elem #| line two #| #| - #|──────────────────────────────────────── - #| $ printf a - #| a - #| b - #|──────────────────────────────────────── + #| + #| $ printf a + #| + #| a + #| b + #| ), ) } ///| -/// bash 的结果文本已经写了真实原因(`exit 2` / 超时 / 中断),所以块尾只标一个不 -/// 谎报的失败记号;pi 的 `bash-execution.ts` 同样只在拿得到真实退出码时才写 -/// `(exit N)`。 +/// 错误背景保留工具提供的真实退出原因。 async test "a failed bash result marks failure without inventing an exit code" { @tui_app.load_module() let transcript = @tui_app.Transcript::new(@tui_app.Theme::load("light")) @@ -204,11 +217,11 @@ async test "a failed bash result marks failure without inventing an exit code" { inspect( render_rows(transcript, 40).join("\n"), content=( - #|──────────────────────────────────────── - #| $ exit 2 - #| Command exited with code 2 - #| (failed) - #|──────────────────────────────────────── + #| + #| $ exit 2 + #| + #| Command exited with code 2 + #| ), ) } @@ -238,13 +251,20 @@ async test "resumed same-name tool calls retain their live output pairing" { host.attach(app) app.submit("go") host.prompt(app.take_submission(), app) - let live = render_rows(app.transcript, 60).join("\n") - assert_true(live.contains("$ printf ALPHA\n ALPHA")) - assert_true(live.contains("$ printf OMEGA\n OMEGA")) + let live = render_rows(app.transcript, 60) + .map(row => row.trim().to_owned()) + .filter(row => !row.is_empty() && !row.has_prefix("Took ")) + assert_eq( + live.join("\n"), + "go\n$ printf ALPHA\nALPHA\n$ printf OMEGA\nOMEGA\nDONE", + ) let sessions = @coding_agent.list_sessions(dir) assert_eq(sessions.length(), 1) host.load_session("\{dir}/\{sessions[0]}.jsonl", app) - assert_eq(render_rows(app.transcript, 60).join("\n"), live) + let replayed = render_rows(app.transcript, 60) + .map(row => row.trim().to_owned()) + .filter(row => !row.is_empty() && row != "Resumed session") + assert_eq(replayed, live) } ///| @@ -271,9 +291,391 @@ async test "same-name tool results match call IDs in either result order" { for (id, text) in results { transcript.push_tool_result("bash", text, false, id~) } - let rendered = render_rows(transcript, 60).join("\n") + let rendered = render_rows(transcript, 60) + .map(row => row.trim().to_owned()) + .filter(row => !row.is_empty()) + .join("\n") assert_eq(transcript.item_count(), 2) - assert_true(rendered.contains("$ printf ALPHA\n ALPHA")) - assert_true(rendered.contains("$ printf OMEGA\n OMEGA")) + assert_eq(rendered, "$ printf ALPHA\nALPHA\n$ printf OMEGA\nOMEGA") + } +} + +///| +async test "tool status backgrounds survive bold and embedded ANSI reset" { + @tui_app.load_module() + let theme = @tui_app.Theme::load("light") + let transcript = @tui_app.Transcript::new(theme) + transcript.push_tool_call("bash", { "command": "echo OK" }, id="call") + let pending = @tui_app.Widget::render(transcript, 40) + assert_eq(pending[0], theme.bg("toolPendingBg", " ".repeat(40))) + assert_false(pending[1].contains("\u{1b}[0m")) + transcript.push_tool_result( + "bash", + "\u{1b}[31mOK\u{1b}[0m END", + false, + id="call", + ) + let success = @tui_app.Widget::render(transcript, 40) + assert_eq(success[0], theme.bg("toolSuccessBg", " ".repeat(40))) + let open = theme + .bg("toolSuccessBg", "") + .strip_suffix("\u{1b}[49m") + .unwrap() + .to_owned() + assert_true(success[3].contains("\u{1b}[0m" + open)) + let failed = @tui_app.Transcript::new(theme) + failed.push_tool_call("bash", { "command": "exit 7" }, id="failure") + failed.push_tool_result( + "bash", + "Command exited with code 7", + true, + id="failure", + ) + assert_eq( + @tui_app.Widget::render(failed, 40)[0], + theme.bg("toolErrorBg", " ".repeat(40)), + ) +} + +///| +async test "assistant preserves alternating content and termination notices" { + @tui_app.load_module() + let transcript = @tui_app.Transcript::new(@tui_app.Theme::load("light")) + transcript.start_assistant() + transcript.set_assistant_content([ + ("First", false), + ("Reason", true), + ("Last", false), + ]) + transcript.finish_assistant("length", None) + inspect( + render_rows(transcript, 60).join("\n"), + content=( + #| First + #| + #| Reason + #| + #| Last + #| + #| Response was truncated before completion. + ), + ) + transcript.set_expanded_thinking(false) + transcript.finish_assistant("aborted", Some("Request was aborted")) + inspect( + render_rows(transcript, 60).join("\n"), + content=( + #| First + #| + #| Thinking... + #| + #| Last + #| + #| Operation aborted + ), + ) +} + +///| +async test "read errors remain visible when collapsed and ranges retain offset" { + @tui_app.load_module() + let transcript = @tui_app.Transcript::new(@tui_app.Theme::load("light")) + transcript.push_tool_call( + "read", + { "path": "src/main.ts", "offset": 7, "limit": 3 }, + id="read", + ) + transcript.push_tool_result("read", "ENOENT: src/main.ts", true, id="read") + inspect( + render_rows(transcript, 50).join("\n"), + content=( + #| + #| read src/main.ts:7-9 + #| + #| ENOENT: src/main.ts + #| + ), + ) +} + +///| +async test "status retains spinner and editor overflow at narrow widths" { + @tui_app.load_module() + let status = @tui_app.StatusLine::new( + @tui_app.Theme::load("light"), + thinking="high", + ) + status.set_working("thinking") + let line = status.render_border(40, hidden_lines=7).unwrap() + assert_true(strip_ansi(line).contains("⠋")) + assert_true(strip_ansi(line).contains("↑ 7 more")) + for width in [0, 1, 2, 8, 40] { + assert_eq( + @tui_app.visible_width(status.render_border(width).unwrap()), + width, + ) + } + status.set_retry(2, 3, 3000) + assert_true( + strip_ansi(status.render_border(80).unwrap()).contains( + "Retrying (2/3) in 3s", + ), + ) +} + +///| +async test "individual clicks preserve other blocks and global toggles reset overrides" { + @tui_app.load_module() + let transcript = @tui_app.Transcript::new(@tui_app.Theme::load("light")) + transcript.start_assistant() + transcript.set_assistant_content([ + ("Reason A", true), + ("Reason B", true), + ("Text", false), + ("Reason C", true), + ]) + assert_true(transcript.click(0, 60)) + let collapsed = render_rows(transcript, 60).join("\n") + assert_false(collapsed.contains("Reason A")) + assert_false(collapsed.contains("Reason B")) + assert_true(collapsed.contains("Reason C")) + transcript.set_expanded_thinking(true) + assert_true(render_rows(transcript, 60).join("\n").contains("Reason B")) + let tools = @tui_app.Transcript::new(@tui_app.Theme::load("light")) + for id in ["first", "second"] { + tools.push_tool_call("bash", { "command": id }, id~) + tools.push_tool_result( + "bash", + "HEAD \{id}\n2\n3\n4\n5\nTAIL \{id}", + false, + id~, + ) + } + assert_false(tools.click(1, 60)) // command is not the result click target + assert_true(tools.click(3, 60)) + let expanded = render_rows(tools, 60).join("\n") + assert_true(expanded.contains("HEAD first")) + assert_false(expanded.contains("HEAD second")) + tools.set_expanded_tool_output(false) + assert_false(render_rows(tools, 60).join("\n").contains("HEAD first")) + // Reflow before clicking the second result; do not use the old wide-row position. + let narrow = render_rows(tools, 16) + let mut tail_row = -1 + for index, row in narrow { + if row.contains("TAIL second") { + tail_row = index + } + } + assert_true(tools.click(tail_row, 16)) + assert_true(render_rows(tools, 60).join("\n").contains("HEAD second")) +} + +///| +async test "bash truncation distinguishes byte limits and preserves untruncated footers" { + @tui_app.load_module() + for truncated in [true, false] { + let transcript = @tui_app.Transcript::new(@tui_app.Theme::load("light")) + transcript.push_tool_call("bash", { "command": "run" }, id="call") + transcript.push_tool_result( + "bash", + "output\n\n[Original /tmp/output]", + false, + id="call", + details=Some({ + "fullOutputPath": "/tmp/output", + "truncation": { + "truncated": truncated, + "truncatedBy": "bytes", + "outputLines": 3, + "totalLines": 7, + "maxBytes": 2048, + }, + }), + ) + let rows = render_rows(transcript, 80).join("\n") + assert_eq(rows.contains("Original"), !truncated) + assert_eq(rows.contains("3 lines shown (2.0KB limit)"), truncated) + assert_false(rows.contains("showing 3 of 7 lines")) + } +} + +///| +async test "bash preview and expansion retain whitespace-sensitive output" { + @tui_app.load_module() + for truncated in [false, true] { + let transcript = @tui_app.Transcript::new(@tui_app.Theme::load("light")) + transcript.push_tool_call( + "bash", + { "command": "printf indented" }, + id="call", + ) + let text = " value\n\n" + + (if truncated { + "\n\n[Showing lines 4-6 of 6. Full output: /tmp/full]" + } else { + "" + }) + transcript.push_tool_result( + "bash", + text, + false, + id="call", + details=if truncated { + Some({ + "fullOutputPath": "/tmp/full", + "truncation": { + "truncated": true, + "truncatedBy": "lines", + "outputLines": 3, + "totalLines": 6, + }, + }) + } else { + None + }, + ) + for expanded in [false, true] { + transcript.set_expanded_tool_output(expanded) + let rows = render_rows(transcript, 80) + assert_eq(rows.length(), if truncated { 9 } else { 7 }) + assert_eq(rows[3], " value" + " ".repeat(72)) + assert_eq(rows[4], " ".repeat(80)) + assert_eq(rows[5], " ".repeat(80)) + assert_false(rows.join("\n").contains("Showing lines")) + } + } +} + +///| +async test "card backgrounds restore shorthand and combined resets without mistaking RGB zeroes" { + @tui_app.load_module() + let theme = @tui_app.Theme::load("light") + let open = theme + .bg("toolSuccessBg", "") + .strip_suffix("\u{1b}[49m") + .unwrap() + .to_owned() + for reset in ["\u{1b}[m", "\u{1b}[0;32m", "\u{1b}[1;49m", "\u{1b}[;32m"] { + assert_true( + theme + .bg("toolSuccessBg", "before" + reset + "after") + .contains(reset + open), + ) + } + for + color in [ + "\u{1b}[38;2;0;49;0m", "\u{1b}[48;2;0;49;0m", "\u{1b}[38:2::0:49:0m", + ] { + assert_false( + theme.bg("toolSuccessBg", color + "text").contains(color + open), + ) + } +} + +///| +async test "cached markdown follows content, width, and thinking changes" { + @tui_app.load_module() + let transcript = @tui_app.Transcript::new(@tui_app.Theme::load("light")) + transcript.push_user("A user **message**") + transcript.start_assistant() + transcript.set_assistant_content([("Alpha **bold** omega", false)]) + let first = @tui_app.Widget::render(transcript, 50) + assert_eq(@tui_app.Widget::render(transcript, 50), first) + transcript.set_assistant_content([("Replacement", false)]) + let replacement = render_rows(transcript, 50).join("\n") + assert_true(replacement.contains("Replacement")) + assert_false(replacement.contains("Alpha")) + let text = @tui_app.Widget::render(transcript, 50) + transcript.set_assistant_content([("Replacement", true)]) + assert_true(@tui_app.Widget::render(transcript, 50) != text) + transcript.set_expanded_thinking(false) + assert_true(render_rows(transcript, 50).join("\n").contains("Thinking...")) + transcript.set_expanded_thinking(true) + let narrow = render_rows(transcript, 12) + assert_true(narrow.length() > render_rows(transcript, 50).length()) + assert_eq(render_rows(transcript, 12), narrow) +} + +///| +async test "large generic arguments are bounded and cached calls refresh after results" { + @tui_app.load_module() + let transcript = @tui_app.Transcript::new(@tui_app.Theme::load("light")) + transcript.push_tool_call( + "write", + { "content": "large payload ".repeat(20000) }, + id="write", + ) + let pending = render_rows(transcript, 40) + assert_true(pending.length() <= 15) + assert_true(pending.join("\n").contains("arguments truncated")) + assert_eq(render_rows(transcript, 40), pending) + transcript.push_tool_result("write", "saved", false, id="write") + assert_true(render_rows(transcript, 40).join("\n").contains("saved")) + transcript.set_expanded_tool_output(true) + let expanded = render_rows(transcript, 40) + assert_true(expanded.length() > pending.length()) + assert_true(expanded.length() <= 60) + assert_true(expanded.join("\n").contains("saved")) + assert_false(transcript.click(20, 40)) + let mut result_row = -1 + for index, row in expanded { + if row.contains("saved") { + result_row = index + } + } + assert_true(transcript.click(result_row, 40)) + assert_true(render_rows(transcript, 40).length() < expanded.length()) + transcript.set_expanded_tool_output(true) + let narrow = render_rows(transcript, 12) + assert_true(narrow.length() <= 60) + assert_eq(render_rows(transcript, 12), narrow) +} + +///| +async test "assistant and thinking retain indented Markdown code blocks" { + @tui_app.load_module() + for thinking in [false, true] { + let transcript = @tui_app.Transcript::new(@tui_app.Theme::load("light")) + transcript.start_assistant() + transcript.set_assistant_content([(" **literal**\n next", thinking)]) + transcript.set_expanded_thinking(true) + let rows = render_rows(transcript, 50).join("\n") + assert_true(rows.contains("**literal**")) + assert_true(rows.contains("next")) + transcript.set_assistant_content([("**prose**", thinking)]) + let prose = render_rows(transcript, 50).join("\n") + assert_true(prose.contains("prose")) + assert_false(prose.contains("**prose**")) + } +} + +///| +async test "bash partial line warning reports retained and total bytes" { + @tui_app.load_module() + let transcript = @tui_app.Transcript::new(@tui_app.Theme::load("light")) + transcript.push_tool_call("bash", { "command": "run" }, id="call") + transcript.push_tool_result( + "bash", + "tail", + false, + id="call", + details=Some({ + "truncation": { + "truncated": true, + "truncatedBy": "bytes", + "lastLinePartial": true, + "outputLines": 1, + "totalLines": 1, + "outputBytes": 2048, + "totalBytes": 8192, + "maxBytes": 2048, + }, + }), + ) + for expanded in [false, true] { + transcript.set_expanded_tool_output(expanded) + let rows = render_rows(transcript, 80).join("\n") + assert_true(rows.contains("showing last 2.0KB of 8.0KB (partial line)")) + assert_false(rows.contains("1 lines shown")) } } diff --git a/tui_app/tui.mbt b/tui_app/tui.mbt index 45c13dc..e79079a 100644 --- a/tui_app/tui.mbt +++ b/tui_app/tui.mbt @@ -57,6 +57,7 @@ pub fn Unlisten::unlisten(self : Unlisten) -> Unit { ///| pub async fn tui_new( terminal : Terminal, + theme? : Theme? = None, show_hardware_cursor? : Bool = true, mouse? : Bool = true, wheel_scroll_lines? : Int = 1, @@ -68,10 +69,37 @@ pub async fn tui_new( options["mouse"] = mouse options["wheelScrollLines"] = wheel_scroll_lines options["copyOnSelect"] = copy_on_select + match theme { + Some(theme) => { + options["searchMatchStyle"] = js_callback_text(fn(text) { + underline(theme.bg("searchMatchBg", theme.fg("searchMatchText", text))) + }) + options["searchCurrentMatchStyle"] = js_callback_text(fn(text) { + bold( + inverse(theme.bg("searchMatchBg", theme.fg("searchMatchText", text))), + ) + }) + options["searchNavigationButtonStyle"] = js_hover_style( + js_callback_text(fn(text) { text }), + js_callback_text(fn(text) { underline(text) }), + ) + } + None => () + } match scroll_to_end_indicator { Some(render) => options["scrollToEndIndicator"] = js_callback_string_fn(render) - None => () + None => + match theme { + Some(theme) => + options["scrollToEndIndicator"] = js_callback_string_fn(fn() { + theme.bg( + "selectedBg", + theme.fg("text", " ↓ Jump to latest message · End "), + ) + }) + None => () + } } let args : Array[Value] = [ terminal.handle, @@ -82,6 +110,9 @@ pub async fn tui_new( Tui::{ handle: ns_get("TuiAltScreen").new(args), } } +///| +extern "js" fn js_hover_style(normal : Value, hover : Value) -> Value = "(normal, hover) => (text, hovered) => (hovered ? hover : normal)(text)" + ///| pub fn Tui::start(self : Tui) -> Unit { let _ : Value = call(self.handle, "start", no_args())