Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

用 MoonBit 重新实现的 [pi](https://github.com/earendil-works/pi) coding agent 子集。

![pim 全屏 TUI:示例对话、思考过程、bash 工具输出与输入框](docs/assets/tui-session.png)

## Install

```bash
Expand All @@ -13,6 +15,14 @@ npm install -g . # symlink 到全局

## Usage

在终端中直接运行 `pim` 进入全屏 TUI:

```bash
pim
```

`Ctrl+O` 展开工具输出,`Ctrl+T` 切换思考显示,`Esc` 中断当前回合。

`-p/--print` 单轮、非交互模式:

```bash
Expand Down
Binary file added docs/assets/tui-session.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 13 additions & 8 deletions tui_app/README.mbt.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ assistant text markdown: fenced code + inline `

**状态与视觉反馈:**

- **空闲** — 上边框是纯横线;footer 保留上次用量
- **空闲** — 上边框是纯横线,按 thinking 档位着色;footer 保留会话累计用量
- **工作中** — 上边框换成 `-- * thinking (Esc to interrupt) --`;终端进度指示(OSC 9;4)开启
- **工具执行** — 工具块背景 pending -> success / error
- **完成** — 回落空闲;footer 用量刷新
Expand All @@ -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 <path>"; 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 <path>:<range>"; success hidden while collapsed, errors visible
\-- other bold name + formatted JSON arguments; 10-line preview, ctrl+o expands
```

## 会话与命令
Expand All @@ -165,4 +166,8 @@ TUI 内:

`TuiApp` 统一维护提交队列与排队展示,消费一条提交时撤掉对应展示行;
`run` 只负责顺序执行提示词和会话切换。工具结果在渲染时按内容列宽换行后截取
预览,bash 显示最后 20 个可见行,其余工具显示前 10 个;展开后保留全部输出。
预览,bash 显示最后 5 个可见行,其余工具显示前 10 个;展开后保留全部输出。
点击工具结果或思考段可单独展开/折叠;全局快捷键会清除单块显示设置。
工具输出使用 `toolOutput` 色,截断路径/警告来自结果 details,实时 bash 显示耗时。
恢复会话时重算累计用量与最近一次模型缓存命中率,并读取 session_info 会话名。
上下文窗口尚无模型元数据源,因此不显示估算占用或自动压缩标记。
93 changes: 77 additions & 16 deletions tui_app/app.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
})
Expand All @@ -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 => ""
}
Expand All @@ -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()
}
Expand Down Expand Up @@ -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(
Comment thread
kkkiio marked this conversation as resolved.
"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
}
Expand Down Expand Up @@ -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()
}

Expand All @@ -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()
}

Expand Down Expand Up @@ -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()
}
Expand All @@ -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()
}

Expand All @@ -442,9 +466,46 @@ 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 label = name.map(sanitize_label).unwrap_or("")
self.terminal.set_title(
if label.is_empty() {
"pim - " + base
} else {
"pim - " + label + " - " + base
Comment thread
kkkiio marked this conversation as resolved.
},
)
}

///|
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 {
Expand Down
12 changes: 11 additions & 1 deletion tui_app/chat_viewport.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading