pi-pretty-tui 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -27
- package/README.zh-CN.md +70 -0
- package/extensions/index.ts +481 -33
- package/package.json +15 -3
package/README.md
CHANGED
|
@@ -1,22 +1,16 @@
|
|
|
1
1
|
# pi-pretty-tui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
English | [简体中文](./README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
A beautiful, polished TUI for the [Pi coding agent](https://pi.dev/).
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
- Live `Bash` output in `full` mode: latest 5 lines when collapsed, all available output when expanded
|
|
15
|
-
- Switchable `full`, `compact`, and `clean` rendering modes with persistent settings
|
|
16
|
-
- Clean mode hides thinking blocks (including Pi's standalone `Thinking...` placeholder) while collapsed and reveals the original thinking content when expanded
|
|
17
|
-
- Colored `+added` and `-removed` edit statistics and diffs
|
|
18
|
-
- Correct hanging indentation for long paths and wrapped tool output
|
|
19
|
-
- Bullet (`•`) markers for unordered Markdown lists
|
|
9
|
+
- **Collapsible tool activity:** instead of filling the conversation with individual tool calls, supported tools are collected into a single `Running(...)` row while work is in progress and a quiet `Done(...)` row when it finishes. Click the group to reveal its compact child calls, click any child for full details, and click the parent again to collapse everything.
|
|
10
|
+
- **Useful details on demand:** tool-aware summaries keep paths, commands, results, diffs, file previews, and live shell output concise without removing access to the original content.
|
|
11
|
+
- **A cohesive visual finish:** rounded user messages and prompt input, theme-aware colors, cleaner Markdown lists, and rounded syntax-highlighted code blocks make the entire TUI feel intentional. In fullscreen, each fenced code block also includes a native-feeling `[Copy]` control.
|
|
12
|
+
|
|
13
|
+
Collapsible grouping covers Pi's built-in `read`, `bash`, `edit`, `write`, `grep`, `find`, and `ls` tools. Third-party tools keep their own rendering, and Pi's cursor, IME, autocomplete, mouse interaction, and tool execution behavior remain intact.
|
|
20
14
|
|
|
21
15
|
## Install
|
|
22
16
|
|
|
@@ -28,32 +22,38 @@ Restart Pi or run `/reload` after installation.
|
|
|
28
22
|
|
|
29
23
|
Pi may warn that built-in tools are being overridden. This is expected: pi-pretty-tui re-registers Pi's built-in tools and delegates execution to their original implementations, changing only their TUI renderers.
|
|
30
24
|
|
|
25
|
+
## Working with tool calls
|
|
26
|
+
|
|
27
|
+
Click a `Running(...)` or `Done(...)` row to keep it as a parent and reveal the tools beneath it. Click an individual child to toggle its complete view, or click the parent again to collapse the whole group. Press `Ctrl+O` at any time to use Pi's global expansion control for tool output and thinking content.
|
|
28
|
+
|
|
31
29
|
## Rendering modes
|
|
32
30
|
|
|
33
|
-
Run `/pretty-tui` to choose
|
|
31
|
+
The extension supports three persistent rendering modes: `clean` (default), `compact`, and `full`. Run `/pretty-tui` to choose one interactively, or use `/pretty-tui clean`, `/pretty-tui compact`, `/pretty-tui full`, and `/pretty-tui status` directly. The selection is stored in `~/.pi/agent/pretty-tui.json` or under the directory selected by `PI_CODING_AGENT_DIR`.
|
|
32
|
+
|
|
33
|
+
## Copy code blocks
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
/pretty-tui status
|
|
35
|
+
In fullscreen TUI mode, fenced Markdown code blocks include a clickable `[Copy]` control in the top rule:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pi --tui-mode fullscreen
|
|
40
39
|
```
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
- `compact`: when collapsed, all supported built-in tools use concise one-line call summaries and final result summaries. Long commands, written content, edit diffs, and tool output stay hidden until expanded; press `Ctrl+O` to reveal the full details.
|
|
44
|
-
- `clean` (default): collapse supported tool calls into a single `Running(...)` status; the activity slot shows a tool name as soon as its streamed tool call appears, such as `Running(3 tool calls · Read)`, and remains visible for at least 1 second before changing to `thinking...`. Once settled, the label becomes `Done(...)`. Click a group status to reveal all tools in that group as indented compact children while retaining the parent `Running`/`Done` row, then click an individual tool to toggle its full details. Visible assistant text starts a new tool group, so later calls get a separate status. Collapsed thinking blocks stay hidden without Pi's `Thinking...` placeholder; press `Ctrl+O` to reveal thinking content and all expanded tool calls/output in transcript order.
|
|
41
|
+
Clicking it copies only the code content—not the rounded border—through Pi's native fullscreen clipboard path and shows the same transient `Copied!` or `Copy failed` flash used by direct text selection. The control is hidden in regular TUI mode because the terminal keeps mouse input for text selection and scrolling there.
|
|
45
42
|
|
|
46
|
-
|
|
43
|
+
## Development
|
|
47
44
|
|
|
48
|
-
|
|
45
|
+
Run the committed regression suite before changing renderer state or transcript grouping:
|
|
49
46
|
|
|
50
|
-
|
|
47
|
+
```sh
|
|
48
|
+
npm install
|
|
49
|
+
npm test
|
|
50
|
+
```
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
The suite covers live and restored steering/compaction boundaries, parallel completion ownership, orphaned tool calls, duplicate-summary prevention, and fullscreen code-block copy hit regions. GitHub Actions runs the same checks on every push and pull request.
|
|
53
53
|
|
|
54
54
|
## Compatibility notice
|
|
55
55
|
|
|
56
|
-
Tool rendering uses Pi's documented extension APIs.
|
|
56
|
+
Tool rendering uses Pi's documented extension APIs. Thinking visibility and active-tool state follow Pi's built-in expansion and execution transitions through its exported interactive components. Framing native user messages, rounding the native prompt editor, changing unordered-list markers, and enhancing fenced code blocks require reload-safe runtime patches because Pi does not currently expose public renderer hooks for those presentation details. Markdown parsing, syntax highlighting, and terminal semantic zones remain handled by Pi.
|
|
57
57
|
|
|
58
58
|
No Pi source files are modified. Runtime patches are removed during session shutdown, but a future Pi release may require this extension to be updated.
|
|
59
59
|
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# pi-pretty-tui
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | 简体中文
|
|
4
|
+
|
|
5
|
+
为 [Pi coding agent](https://pi.dev/) 打造美观、精致的 TUI 体验。
|
|
6
|
+
|
|
7
|
+
## 功能特性
|
|
8
|
+
|
|
9
|
+
- **可折叠的工具活动:** 不再让大量独立工具调用填满对话。工作进行时,受支持的工具会集中显示为一行 `Running(...)`;工作结束后,则变为低调的 `Done(...)`。点击分组可展开紧凑的子工具列表,点击任一子工具可查看完整详情,再次点击父级即可全部折叠。
|
|
10
|
+
- **按需查看有效信息:** 针对不同工具设计的摘要,会简洁呈现路径、命令、结果、diff、文件预览和实时 shell 输出,同时保留查看原始完整内容的能力。
|
|
11
|
+
- **统一、精致的视觉体验:** 圆角用户消息与输入框、主题感知配色、更清爽的 Markdown 列表,以及带语法高亮的圆角代码块,让整个 TUI 更协调。在 fullscreen 模式下,每个围栏代码块还带有符合原生体验的 `[Copy]` 控件。
|
|
12
|
+
|
|
13
|
+
可折叠分组覆盖 Pi 内置的 `read`、`bash`、`edit`、`write`、`grep`、`find` 和 `ls` 工具。第三方工具保留自己的渲染方式,同时不会影响 Pi 原有的光标、输入法、自动补全、鼠标交互和工具执行行为。
|
|
14
|
+
|
|
15
|
+
## 安装
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pi install npm:pi-pretty-tui
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
安装后重启 Pi,或运行 `/reload`。
|
|
22
|
+
|
|
23
|
+
Pi 可能会提示内置工具被覆盖。这是正常现象:pi-pretty-tui 会重新注册 Pi 的内置工具,并将实际执行委托给原始实现,只修改它们的 TUI 渲染方式。
|
|
24
|
+
|
|
25
|
+
## 使用工具调用
|
|
26
|
+
|
|
27
|
+
点击 `Running(...)` 或 `Done(...)` 行,可以保留父级并展开其下方的工具。点击单个子工具可切换完整视图,再次点击父级可折叠整个分组。任何时候都可以按 `Ctrl+O`,使用 Pi 的全局展开控制来显示或隐藏工具输出和思考内容。
|
|
28
|
+
|
|
29
|
+
## 渲染模式
|
|
30
|
+
|
|
31
|
+
扩展支持三种可持久化的渲染模式:`clean`(默认)、`compact` 和 `full`。运行 `/pretty-tui` 可交互选择,也可以直接使用 `/pretty-tui clean`、`/pretty-tui compact`、`/pretty-tui full` 和 `/pretty-tui status`。所选设置会保存到 `~/.pi/agent/pretty-tui.json`,或 `PI_CODING_AGENT_DIR` 指定目录中的对应位置。
|
|
32
|
+
|
|
33
|
+
## 复制代码块
|
|
34
|
+
|
|
35
|
+
在 fullscreen TUI 模式下,Markdown 围栏代码块的顶部边框会显示可点击的 `[Copy]` 控件:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pi --tui-mode fullscreen
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
点击后只复制代码内容,不包含圆角边框;复制会通过 Pi 原生的 fullscreen 剪贴板路径完成,并显示与直接框选文本相同的临时 `Copied!` 或 `Copy failed` 提示。regular TUI 模式下,终端会保留鼠标输入用于文本选择和滚动,因此该控件会被隐藏。
|
|
42
|
+
|
|
43
|
+
## 开发
|
|
44
|
+
|
|
45
|
+
修改渲染状态或 transcript 分组前,请运行仓库内的回归测试:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
npm install
|
|
49
|
+
npm test
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
测试覆盖实时与历史恢复时的 steer/compaction 边界、并行工具完成归属、孤立 tool call、重复摘要防护,以及 fullscreen 代码块复制的点击区域。GitHub Actions 会在每次 push 和 pull request 时运行相同检查。
|
|
53
|
+
|
|
54
|
+
## 兼容性说明
|
|
55
|
+
|
|
56
|
+
工具渲染使用 Pi 的公开扩展 API。思考内容的可见性与活动工具状态,通过 Pi 导出的交互组件跟随其内置展开和执行状态变化。由于 Pi 目前没有为这些展示细节提供公开渲染钩子,原生用户消息框、原生输入编辑器圆角、无序列表标记和围栏代码块增强需要使用可安全重载的运行时补丁。Markdown 解析、语法高亮和终端语义区域仍由 Pi 处理。
|
|
57
|
+
|
|
58
|
+
本扩展不会修改 Pi 源文件。运行时补丁会在会话关闭时移除,但未来的 Pi 版本可能需要本扩展同步适配。
|
|
59
|
+
|
|
60
|
+
## 卸载
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
pi remove npm:pi-pretty-tui
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
然后重启 Pi。
|
|
67
|
+
|
|
68
|
+
## 许可证
|
|
69
|
+
|
|
70
|
+
MIT
|
package/extensions/index.ts
CHANGED
|
@@ -11,12 +11,17 @@ import {
|
|
|
11
11
|
createLsTool,
|
|
12
12
|
createReadTool,
|
|
13
13
|
createWriteTool,
|
|
14
|
+
copyToClipboard,
|
|
14
15
|
getAgentDir,
|
|
15
16
|
type ExtensionAPI,
|
|
16
17
|
} from "@earendil-works/pi-coding-agent";
|
|
17
18
|
import {
|
|
18
19
|
type Component,
|
|
19
20
|
Markdown,
|
|
21
|
+
stripTerminalSequences,
|
|
22
|
+
truncateToWidth,
|
|
23
|
+
TuiAltScreen,
|
|
24
|
+
type TuiMouseEvent,
|
|
20
25
|
visibleWidth,
|
|
21
26
|
wrapTextWithAnsi,
|
|
22
27
|
} from "@earendil-works/pi-tui";
|
|
@@ -52,6 +57,10 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
52
57
|
? configuredMode
|
|
53
58
|
: "clean";
|
|
54
59
|
let cleanToolsExpanded = false;
|
|
60
|
+
let cleanContextCompacted = false;
|
|
61
|
+
let fullscreenTui = false;
|
|
62
|
+
let currentTui: any;
|
|
63
|
+
let currentExtensionUi: any;
|
|
55
64
|
let changingAllToolsExpansion = false;
|
|
56
65
|
const cleanCompactToolCallIds = new Set<string>();
|
|
57
66
|
const cleanGroupToolCallIds = new Map<string, string[]>();
|
|
@@ -371,10 +380,35 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
371
380
|
// Extension shortcuts cannot replace Pi's built-in Ctrl+O binding. Wrap
|
|
372
381
|
// the exported state transition instead, so both Ctrl+O and UI callers keep
|
|
373
382
|
// their normal behavior while clean thinking follows the same state.
|
|
383
|
+
const orderContextEntriesForTranscript = (entries: any[]): any[] => {
|
|
384
|
+
if (!Array.isArray(entries) || entries[0]?.type !== "compaction") return entries;
|
|
385
|
+
const [compaction, ...contextEntries] = entries;
|
|
386
|
+
const parentIndex = contextEntries.findIndex((entry: any) => entry?.id === compaction.parentId);
|
|
387
|
+
const compactionTime = Date.parse(compaction.timestamp ?? "");
|
|
388
|
+
const firstNewerIndex = Number.isFinite(compactionTime)
|
|
389
|
+
? contextEntries.findIndex((entry: any) => {
|
|
390
|
+
const entryTime = Date.parse(entry?.timestamp ?? "");
|
|
391
|
+
return Number.isFinite(entryTime) && entryTime >= compactionTime;
|
|
392
|
+
})
|
|
393
|
+
: -1;
|
|
394
|
+
const insertionIndex = parentIndex >= 0
|
|
395
|
+
? parentIndex + 1
|
|
396
|
+
: firstNewerIndex >= 0
|
|
397
|
+
? firstNewerIndex
|
|
398
|
+
: contextEntries.length;
|
|
399
|
+
return [
|
|
400
|
+
...contextEntries.slice(0, insertionIndex),
|
|
401
|
+
compaction,
|
|
402
|
+
...contextEntries.slice(insertionIndex),
|
|
403
|
+
];
|
|
404
|
+
};
|
|
405
|
+
|
|
374
406
|
const interactiveModePrototype = InteractiveMode.prototype as any;
|
|
375
407
|
const toolsExpansionPatchKey = Symbol.for("pretty-tui.clean-tool-expansion");
|
|
376
408
|
if (!interactiveModePrototype[toolsExpansionPatchKey]) {
|
|
377
409
|
const originalSetToolsExpanded = interactiveModePrototype.setToolsExpanded;
|
|
410
|
+
const originalRenderSessionEntries = interactiveModePrototype.renderSessionEntries;
|
|
411
|
+
const originalSwitchTuiMode = interactiveModePrototype.switchTuiMode;
|
|
378
412
|
const patchedSetToolsExpanded = function (this: any, expanded: boolean) {
|
|
379
413
|
cleanToolsExpanded = expanded;
|
|
380
414
|
changingAllToolsExpansion = true;
|
|
@@ -386,27 +420,330 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
386
420
|
}
|
|
387
421
|
};
|
|
388
422
|
|
|
423
|
+
const patchedRenderSessionEntries = function (this: any, entries: any[], options?: any) {
|
|
424
|
+
currentTui = this.ui;
|
|
425
|
+
fullscreenTui = currentTui?.mode === "fullscreen";
|
|
426
|
+
// buildContextEntries() prepends the latest compaction for model context,
|
|
427
|
+
// while Pi's live compaction UI appends it chronologically. Keep reloads
|
|
428
|
+
// and transcript rebuilds consistent with that live presentation.
|
|
429
|
+
return originalRenderSessionEntries.call(
|
|
430
|
+
this,
|
|
431
|
+
orderContextEntriesForTranscript(entries),
|
|
432
|
+
options,
|
|
433
|
+
);
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const patchedSwitchTuiMode = function (this: any, ...args: any[]) {
|
|
437
|
+
const result = originalSwitchTuiMode.apply(this, args);
|
|
438
|
+
currentTui = this.ui;
|
|
439
|
+
fullscreenTui = currentTui?.mode === "fullscreen";
|
|
440
|
+
return result;
|
|
441
|
+
};
|
|
442
|
+
|
|
389
443
|
interactiveModePrototype[toolsExpansionPatchKey] = {
|
|
390
444
|
originalSetToolsExpanded,
|
|
391
445
|
patchedSetToolsExpanded,
|
|
446
|
+
originalRenderSessionEntries,
|
|
447
|
+
patchedRenderSessionEntries,
|
|
448
|
+
originalSwitchTuiMode,
|
|
449
|
+
patchedSwitchTuiMode,
|
|
392
450
|
};
|
|
393
451
|
interactiveModePrototype.setToolsExpanded = patchedSetToolsExpanded;
|
|
452
|
+
interactiveModePrototype.renderSessionEntries = patchedRenderSessionEntries;
|
|
453
|
+
interactiveModePrototype.switchTuiMode = patchedSwitchTuiMode;
|
|
394
454
|
|
|
395
455
|
pi.on("session_shutdown", () => {
|
|
456
|
+
fullscreenTui = false;
|
|
457
|
+
currentTui = undefined;
|
|
458
|
+
currentExtensionUi = undefined;
|
|
396
459
|
const patch = interactiveModePrototype[toolsExpansionPatchKey];
|
|
397
460
|
if (!patch) return;
|
|
398
461
|
if (patch.patchedSetToolsExpanded === interactiveModePrototype.setToolsExpanded) {
|
|
399
462
|
interactiveModePrototype.setToolsExpanded = patch.originalSetToolsExpanded;
|
|
400
463
|
}
|
|
401
|
-
if (
|
|
464
|
+
if (patch.patchedRenderSessionEntries === interactiveModePrototype.renderSessionEntries) {
|
|
465
|
+
interactiveModePrototype.renderSessionEntries = patch.originalRenderSessionEntries;
|
|
466
|
+
}
|
|
467
|
+
if (patch.patchedSwitchTuiMode === interactiveModePrototype.switchTuiMode) {
|
|
468
|
+
interactiveModePrototype.switchTuiMode = patch.originalSwitchTuiMode;
|
|
469
|
+
}
|
|
470
|
+
if (
|
|
471
|
+
interactiveModePrototype.setToolsExpanded === patch.originalSetToolsExpanded &&
|
|
472
|
+
interactiveModePrototype.renderSessionEntries === patch.originalRenderSessionEntries &&
|
|
473
|
+
interactiveModePrototype.switchTuiMode === patch.originalSwitchTuiMode
|
|
474
|
+
) {
|
|
402
475
|
delete interactiveModePrototype[toolsExpansionPatchKey];
|
|
403
476
|
}
|
|
404
477
|
});
|
|
405
478
|
}
|
|
406
479
|
|
|
407
|
-
//
|
|
408
|
-
//
|
|
480
|
+
// Fullscreen selection only emits a component click when press and release
|
|
481
|
+
// land on the exact same cell. Give clean group rows a tiny horizontal
|
|
482
|
+
// tolerance so an ordinary click does not turn into an accidental selection.
|
|
483
|
+
const altScreenPrototype = TuiAltScreen.prototype as any;
|
|
484
|
+
const cleanSummaryClickPatchKey = Symbol.for("pretty-tui.clean-summary-click");
|
|
485
|
+
const cleanSummaryPressKey = Symbol("pretty-tui.clean-summary-press");
|
|
486
|
+
if (!altScreenPrototype[cleanSummaryClickPatchKey]) {
|
|
487
|
+
const originalHandleSelectionMouseEvent = altScreenPrototype.handleSelectionMouseEvent;
|
|
488
|
+
const summaryAtPoint = (screen: any, x: number, y: number): boolean => {
|
|
489
|
+
const line = stripTerminalSequences(screen.previousScreen?.[y] ?? "");
|
|
490
|
+
const match = /●\s+(?:Done|Running)\([^)]*\)/.exec(line);
|
|
491
|
+
return Boolean(match && x >= match.index && x < match.index + match[0].length);
|
|
492
|
+
};
|
|
493
|
+
const patchedHandleSelectionMouseEvent = function (this: any, event: any) {
|
|
494
|
+
const isMotion = (event.button & 32) !== 0;
|
|
495
|
+
const isLeftPress = !event.release && !isMotion && (event.button & 3) === 0;
|
|
496
|
+
if (isLeftPress) {
|
|
497
|
+
if (summaryAtPoint(this, event.x, event.y)) {
|
|
498
|
+
this[cleanSummaryPressKey] = { x: event.x, y: event.y };
|
|
499
|
+
} else {
|
|
500
|
+
delete this[cleanSummaryPressKey];
|
|
501
|
+
}
|
|
502
|
+
return originalHandleSelectionMouseEvent.call(this, event);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const press = this[cleanSummaryPressKey];
|
|
506
|
+
if (press) {
|
|
507
|
+
const withinClickTolerance =
|
|
508
|
+
event.y === press.y && Math.abs(event.x - press.x) <= 2;
|
|
509
|
+
if (isMotion && withinClickTolerance) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (event.release) {
|
|
513
|
+
delete this[cleanSummaryPressKey];
|
|
514
|
+
if (withinClickTolerance) {
|
|
515
|
+
return originalHandleSelectionMouseEvent.call(this, { ...event, x: press.x });
|
|
516
|
+
}
|
|
517
|
+
} else if (isMotion) {
|
|
518
|
+
delete this[cleanSummaryPressKey];
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return originalHandleSelectionMouseEvent.call(this, event);
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
altScreenPrototype[cleanSummaryClickPatchKey] = {
|
|
525
|
+
originalHandleSelectionMouseEvent,
|
|
526
|
+
patchedHandleSelectionMouseEvent,
|
|
527
|
+
};
|
|
528
|
+
altScreenPrototype.handleSelectionMouseEvent = patchedHandleSelectionMouseEvent;
|
|
529
|
+
|
|
530
|
+
pi.on("session_shutdown", () => {
|
|
531
|
+
const patch = altScreenPrototype[cleanSummaryClickPatchKey];
|
|
532
|
+
if (patch?.patchedHandleSelectionMouseEvent === altScreenPrototype.handleSelectionMouseEvent) {
|
|
533
|
+
altScreenPrototype.handleSelectionMouseEvent = patch.originalHandleSelectionMouseEvent;
|
|
534
|
+
}
|
|
535
|
+
if (
|
|
536
|
+
patch &&
|
|
537
|
+
altScreenPrototype.handleSelectionMouseEvent === patch.originalHandleSelectionMouseEvent
|
|
538
|
+
) {
|
|
539
|
+
delete altScreenPrototype[cleanSummaryClickPatchKey];
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Refine Pi's Markdown presentation while preserving its parser and themes.
|
|
409
545
|
const markdownPrototype = Markdown.prototype as any;
|
|
546
|
+
const codeBlockPatchKey = Symbol.for("pretty-tui.code-blocks");
|
|
547
|
+
const codeBlockCollectionKey = Symbol("pretty-tui.code-block-collection");
|
|
548
|
+
const codeBlockRegionsKey = Symbol("pretty-tui.code-block-regions");
|
|
549
|
+
if (!markdownPrototype[codeBlockPatchKey]) {
|
|
550
|
+
const originalInvalidate = markdownPrototype.invalidate;
|
|
551
|
+
const originalRender = markdownPrototype.render;
|
|
552
|
+
const originalRenderToken = markdownPrototype.renderToken;
|
|
553
|
+
const originalHandleMouse = markdownPrototype.handleMouse;
|
|
554
|
+
const stripTerminalStyles = (value: string): string =>
|
|
555
|
+
value.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
|
|
556
|
+
|
|
557
|
+
const patchedInvalidate = function (this: any) {
|
|
558
|
+
delete this[codeBlockCollectionKey];
|
|
559
|
+
delete this[codeBlockRegionsKey];
|
|
560
|
+
return originalInvalidate.call(this);
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const patchedRender = function (this: any, width: number): string[] {
|
|
564
|
+
const needsRender =
|
|
565
|
+
!this.cachedLines || this.cachedText !== this.text || this.cachedWidth !== width;
|
|
566
|
+
if (needsRender) this[codeBlockCollectionKey] = [];
|
|
567
|
+
|
|
568
|
+
const lines = originalRender.call(this, width);
|
|
569
|
+
if (!needsRender) return lines;
|
|
570
|
+
|
|
571
|
+
const regions: Array<{ xStart: number; xEnd: number; y: number; code: string }> = [];
|
|
572
|
+
let searchFrom = 0;
|
|
573
|
+
for (const block of this[codeBlockCollectionKey] ?? []) {
|
|
574
|
+
let headerLine = -1;
|
|
575
|
+
for (let lineIndex = searchFrom; lineIndex < lines.length; lineIndex += 1) {
|
|
576
|
+
const renderedLine = lines[lineIndex] ?? "";
|
|
577
|
+
if (
|
|
578
|
+
renderedLine.includes(block.styledTopRule) ||
|
|
579
|
+
stripTerminalStyles(renderedLine).includes(block.topRule)
|
|
580
|
+
) {
|
|
581
|
+
headerLine = lineIndex;
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (headerLine < 0) continue;
|
|
586
|
+
|
|
587
|
+
regions.push({
|
|
588
|
+
xStart: Number(this.paddingX ?? 0) + block.buttonStart,
|
|
589
|
+
xEnd: Number(this.paddingX ?? 0) + block.buttonStart + block.buttonWidth,
|
|
590
|
+
y: headerLine,
|
|
591
|
+
code: block.code,
|
|
592
|
+
});
|
|
593
|
+
searchFrom = headerLine + 1;
|
|
594
|
+
}
|
|
595
|
+
this[codeBlockRegionsKey] = regions;
|
|
596
|
+
delete this[codeBlockCollectionKey];
|
|
597
|
+
return lines;
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const patchedRenderToken = function (
|
|
601
|
+
this: any,
|
|
602
|
+
token: any,
|
|
603
|
+
width: number,
|
|
604
|
+
nextTokenType?: string,
|
|
605
|
+
styleContext?: any,
|
|
606
|
+
): string[] {
|
|
607
|
+
if (token?.type !== "code") {
|
|
608
|
+
return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const maxWidth = Math.max(1, width);
|
|
612
|
+
const code = String(token.text ?? "");
|
|
613
|
+
const useRoundedFrame = maxWidth >= 8;
|
|
614
|
+
const maximumCodeWidth = useRoundedFrame ? maxWidth - 4 : maxWidth;
|
|
615
|
+
const highlighted = this.theme.highlightCode
|
|
616
|
+
? this.theme.highlightCode(code, token.lang)
|
|
617
|
+
: code.split("\n").map((line: string) => this.theme.codeBlock(line));
|
|
618
|
+
const codeLines: string[] = [];
|
|
619
|
+
for (const line of highlighted.length > 0 ? highlighted : [""]) {
|
|
620
|
+
const wrapped = wrapTextWithAnsi(line, maximumCodeWidth);
|
|
621
|
+
codeLines.push(...(wrapped.length > 0 ? wrapped : [""]));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (!useRoundedFrame) {
|
|
625
|
+
if (nextTokenType && nextTokenType !== "space") codeLines.push("");
|
|
626
|
+
return codeLines;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const copyLabel = "[Copy]";
|
|
630
|
+
const showCopyButton = fullscreenTui && maxWidth >= 18;
|
|
631
|
+
const topSuffix = showCopyButton ? ` ${copyLabel} ╮` : "╮";
|
|
632
|
+
const rawLanguage = typeof token.lang === "string" ? token.lang.trim() : "";
|
|
633
|
+
const language = rawLanguage.split(/\s+/, 1)[0] || "code";
|
|
634
|
+
const label = truncateToWidth(
|
|
635
|
+
language,
|
|
636
|
+
Math.max(1, maxWidth - visibleWidth(topSuffix) - 7),
|
|
637
|
+
"…",
|
|
638
|
+
);
|
|
639
|
+
const topPrefix = `╭─ ${label} `;
|
|
640
|
+
const contentWidth = codeLines.reduce(
|
|
641
|
+
(widest, line) => Math.max(widest, visibleWidth(line)),
|
|
642
|
+
0,
|
|
643
|
+
);
|
|
644
|
+
const minimumFrameWidth = visibleWidth(topPrefix) + 3 + visibleWidth(topSuffix);
|
|
645
|
+
const frameWidth = Math.min(maxWidth, Math.max(contentWidth + 4, minimumFrameWidth));
|
|
646
|
+
const fillWidth = Math.max(
|
|
647
|
+
0,
|
|
648
|
+
frameWidth - visibleWidth(topPrefix) - visibleWidth(topSuffix),
|
|
649
|
+
);
|
|
650
|
+
const buttonStart = visibleWidth(topPrefix) + fillWidth + (showCopyButton ? 1 : 0);
|
|
651
|
+
const topRule = topPrefix + "─".repeat(fillWidth) + topSuffix;
|
|
652
|
+
const styledTopRule = this.theme.codeBlockBorder(topRule);
|
|
653
|
+
const framedCodeWidth = Math.max(1, frameWidth - 4);
|
|
654
|
+
const lines = [
|
|
655
|
+
styledTopRule,
|
|
656
|
+
...codeLines.map((line) =>
|
|
657
|
+
this.theme.codeBlockBorder("│ ") +
|
|
658
|
+
line +
|
|
659
|
+
" ".repeat(Math.max(0, framedCodeWidth - visibleWidth(line))) +
|
|
660
|
+
this.theme.codeBlockBorder(" │")
|
|
661
|
+
),
|
|
662
|
+
this.theme.codeBlockBorder(`╰${"─".repeat(Math.max(0, frameWidth - 2))}╯`),
|
|
663
|
+
];
|
|
664
|
+
|
|
665
|
+
if (showCopyButton && Array.isArray(this[codeBlockCollectionKey])) {
|
|
666
|
+
this[codeBlockCollectionKey].push({
|
|
667
|
+
buttonStart,
|
|
668
|
+
buttonWidth: visibleWidth(copyLabel),
|
|
669
|
+
code,
|
|
670
|
+
styledTopRule,
|
|
671
|
+
topRule,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
if (nextTokenType && nextTokenType !== "space") lines.push("");
|
|
675
|
+
return lines;
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
const patchedHandleMouse = function (this: any, event: TuiMouseEvent) {
|
|
679
|
+
if (event.button === "left" && (event.type === "press" || event.type === "click")) {
|
|
680
|
+
const region = (this[codeBlockRegionsKey] ?? []).find(
|
|
681
|
+
(candidate: any) =>
|
|
682
|
+
event.y === candidate.y && event.x >= candidate.xStart && event.x < candidate.xEnd,
|
|
683
|
+
);
|
|
684
|
+
if (region) {
|
|
685
|
+
if (event.type === "click") {
|
|
686
|
+
if (typeof currentTui?.copyTextToClipboard === "function") {
|
|
687
|
+
// Match fullscreen selection exactly: use Pi TUI's clipboard path
|
|
688
|
+
// and its transient "Copied!" / "Copy failed" flash feedback.
|
|
689
|
+
void Promise.resolve(currentTui.copyTextToClipboard(region.code)).catch(() => {
|
|
690
|
+
currentTui?.flash?.("Copy failed");
|
|
691
|
+
});
|
|
692
|
+
} else {
|
|
693
|
+
// Defensive fallback for older Pi versions without the fullscreen helper.
|
|
694
|
+
void copyToClipboard(region.code)
|
|
695
|
+
.then(() => currentExtensionUi?.notify("Copied!", "info"))
|
|
696
|
+
.catch(() => currentExtensionUi?.notify("Copy failed", "error"));
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return { handled: true, render: false };
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return originalHandleMouse?.call(this, event);
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
markdownPrototype[codeBlockPatchKey] = {
|
|
706
|
+
originalHandleMouse,
|
|
707
|
+
originalInvalidate,
|
|
708
|
+
originalRender,
|
|
709
|
+
originalRenderToken,
|
|
710
|
+
patchedHandleMouse,
|
|
711
|
+
patchedInvalidate,
|
|
712
|
+
patchedRender,
|
|
713
|
+
patchedRenderToken,
|
|
714
|
+
};
|
|
715
|
+
markdownPrototype.invalidate = patchedInvalidate;
|
|
716
|
+
markdownPrototype.render = patchedRender;
|
|
717
|
+
markdownPrototype.renderToken = patchedRenderToken;
|
|
718
|
+
markdownPrototype.handleMouse = patchedHandleMouse;
|
|
719
|
+
|
|
720
|
+
pi.on("session_shutdown", () => {
|
|
721
|
+
const patch = markdownPrototype[codeBlockPatchKey];
|
|
722
|
+
if (patch?.patchedInvalidate === markdownPrototype.invalidate) {
|
|
723
|
+
markdownPrototype.invalidate = patch.originalInvalidate;
|
|
724
|
+
}
|
|
725
|
+
if (patch?.patchedRender === markdownPrototype.render) {
|
|
726
|
+
markdownPrototype.render = patch.originalRender;
|
|
727
|
+
}
|
|
728
|
+
if (patch?.patchedRenderToken === markdownPrototype.renderToken) {
|
|
729
|
+
markdownPrototype.renderToken = patch.originalRenderToken;
|
|
730
|
+
}
|
|
731
|
+
if (patch?.patchedHandleMouse === markdownPrototype.handleMouse) {
|
|
732
|
+
if (patch.originalHandleMouse) markdownPrototype.handleMouse = patch.originalHandleMouse;
|
|
733
|
+
else delete markdownPrototype.handleMouse;
|
|
734
|
+
}
|
|
735
|
+
if (
|
|
736
|
+
patch &&
|
|
737
|
+
markdownPrototype.invalidate === patch.originalInvalidate &&
|
|
738
|
+
markdownPrototype.render === patch.originalRender &&
|
|
739
|
+
markdownPrototype.renderToken === patch.originalRenderToken &&
|
|
740
|
+
markdownPrototype.handleMouse === patch.originalHandleMouse
|
|
741
|
+
) {
|
|
742
|
+
delete markdownPrototype[codeBlockPatchKey];
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
410
747
|
const listPatchKey = Symbol.for("pretty-tui.list-bullets");
|
|
411
748
|
if (!markdownPrototype[listPatchKey]) {
|
|
412
749
|
const originalRenderList = markdownPrototype.renderList;
|
|
@@ -1032,20 +1369,26 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1032
1369
|
count: number,
|
|
1033
1370
|
failed: number,
|
|
1034
1371
|
activity: DisplayValue = "done",
|
|
1372
|
+
collapsedDone = false,
|
|
1035
1373
|
): DisplayRow => ({
|
|
1036
1374
|
prefix: () => {
|
|
1037
1375
|
const currentActivity = typeof activity === "function" ? activity() : activity;
|
|
1038
|
-
const color = currentActivity === "done"
|
|
1376
|
+
const color = currentActivity === "done"
|
|
1377
|
+
? collapsedDone ? "thinkingText" : "success"
|
|
1378
|
+
: "accent";
|
|
1039
1379
|
return theme.fg(color, "● ");
|
|
1040
1380
|
},
|
|
1041
1381
|
continuation: " ",
|
|
1042
1382
|
content: () => {
|
|
1043
1383
|
const currentActivity = typeof activity === "function" ? activity() : activity;
|
|
1044
1384
|
const label = currentActivity === "done" ? "Done" : "Running";
|
|
1045
|
-
const color = label === "Done"
|
|
1385
|
+
const color = label === "Done"
|
|
1386
|
+
? collapsedDone ? "thinkingText" : "success"
|
|
1387
|
+
: "accent";
|
|
1388
|
+
const detailColor = label === "Done" && collapsedDone ? "thinkingText" : "text";
|
|
1046
1389
|
return theme.fg(color, theme.bold(label)) +
|
|
1047
1390
|
theme.fg("dim", "(") +
|
|
1048
|
-
theme.fg(
|
|
1391
|
+
theme.fg(detailColor, summaryText(count, failed, currentActivity)) + theme.fg("dim", ")");
|
|
1049
1392
|
},
|
|
1050
1393
|
});
|
|
1051
1394
|
|
|
@@ -1084,7 +1427,13 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1084
1427
|
|
|
1085
1428
|
const settledSummary = settledSummaries.get(toolCallId);
|
|
1086
1429
|
if (settledSummary) {
|
|
1087
|
-
return block([summaryRow(
|
|
1430
|
+
return block([summaryRow(
|
|
1431
|
+
theme,
|
|
1432
|
+
settledSummary.count,
|
|
1433
|
+
settledSummary.failed,
|
|
1434
|
+
settledSummary.activity,
|
|
1435
|
+
true,
|
|
1436
|
+
)]).render(width);
|
|
1088
1437
|
}
|
|
1089
1438
|
if (cleanRun.settled) return [];
|
|
1090
1439
|
|
|
@@ -1139,7 +1488,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1139
1488
|
|
|
1140
1489
|
pi.registerEntryRenderer<ToolSummaryData>("pretty-tui-tool-summary", (entry, { expanded }, theme) => ({
|
|
1141
1490
|
render(width: number): string[] {
|
|
1142
|
-
if (renderMode !== "clean" || expanded) return [];
|
|
1491
|
+
if (renderMode !== "clean" || expanded || cleanContextCompacted) return [];
|
|
1143
1492
|
const data = entry.data;
|
|
1144
1493
|
const groups = data?.groups?.length
|
|
1145
1494
|
? data.groups
|
|
@@ -1160,21 +1509,32 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1160
1509
|
: [];
|
|
1161
1510
|
})();
|
|
1162
1511
|
// The live tool components own the visual positions. Keep this durable
|
|
1163
|
-
// entry as a fallback only for groups whose
|
|
1164
|
-
// from
|
|
1512
|
+
// entry as a fallback only for groups whose components are unexpectedly
|
|
1513
|
+
// absent from an uncompacted branch.
|
|
1165
1514
|
const missingGroups = groups.filter((group) => !knownToolCallIds.has(group.lastToolCallId));
|
|
1166
1515
|
if (missingGroups.length === 0) return [];
|
|
1167
1516
|
// Persisted entries represent settled groups. Older versions could
|
|
1168
1517
|
// accidentally store the transient responding... activity, so always
|
|
1169
1518
|
// normalize their display to Done.
|
|
1170
1519
|
return block(missingGroups.map((group) =>
|
|
1171
|
-
summaryRow(theme, group.count, group.failed, "done")
|
|
1520
|
+
summaryRow(theme, group.count, group.failed, "done", true)
|
|
1172
1521
|
)).render(width);
|
|
1173
1522
|
},
|
|
1174
1523
|
invalidate() {},
|
|
1175
1524
|
}));
|
|
1176
1525
|
|
|
1526
|
+
const messageContentItems = (message: any): any[] =>
|
|
1527
|
+
Array.isArray(message?.content) ? message.content : [];
|
|
1528
|
+
|
|
1529
|
+
const messageHasVisibleText = (message: any): boolean =>
|
|
1530
|
+
typeof message?.content === "string"
|
|
1531
|
+
? message.content.trim().length > 0
|
|
1532
|
+
: messageContentItems(message).some(
|
|
1533
|
+
(item: any) => item.type === "text" && typeof item.text === "string" && item.text.trim().length > 0,
|
|
1534
|
+
);
|
|
1535
|
+
|
|
1177
1536
|
const restoreCleanSession = (ctx: any) => {
|
|
1537
|
+
currentExtensionUi = ctx.ui;
|
|
1178
1538
|
cleanToolsExpanded = ctx.ui.getToolsExpanded();
|
|
1179
1539
|
settledSummaries.clear();
|
|
1180
1540
|
legacySummaryLastToolCallIds.clear();
|
|
@@ -1203,7 +1563,9 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1203
1563
|
let failed = 0;
|
|
1204
1564
|
let lastToolCallId: string | undefined;
|
|
1205
1565
|
let lastFinishedGroup: ToolSummaryGroup | undefined;
|
|
1566
|
+
const inferredGroups: ToolSummaryGroup[] = [];
|
|
1206
1567
|
const toolCalls = new Set<string>();
|
|
1568
|
+
const completedToolCalls = new Set<string>();
|
|
1207
1569
|
const explicitSummaryIds = new Set<string>();
|
|
1208
1570
|
|
|
1209
1571
|
const rememberGroup = (group: ToolSummaryGroup, entryId?: string) => {
|
|
@@ -1222,6 +1584,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1222
1584
|
const finishGroup = () => {
|
|
1223
1585
|
if (lastToolCallId && count > 0) {
|
|
1224
1586
|
lastFinishedGroup = { count, failed, lastToolCallId, toolCallIds: [...toolCalls] };
|
|
1587
|
+
inferredGroups.push(lastFinishedGroup);
|
|
1225
1588
|
setCleanGroupMembers(lastToolCallId, [...toolCalls]);
|
|
1226
1589
|
if (!explicitSummaryIds.has(lastToolCallId)) {
|
|
1227
1590
|
settledSummaries.set(lastToolCallId, { count, failed, activity: "done" });
|
|
@@ -1231,23 +1594,96 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1231
1594
|
failed = 0;
|
|
1232
1595
|
lastToolCallId = undefined;
|
|
1233
1596
|
toolCalls.clear();
|
|
1597
|
+
completedToolCalls.clear();
|
|
1598
|
+
};
|
|
1599
|
+
|
|
1600
|
+
const inferredGroupsOverlapping = (group: ToolSummaryGroup): ToolSummaryGroup[] => {
|
|
1601
|
+
if (!group.toolCallIds?.length) return [];
|
|
1602
|
+
const persistedIds = new Set(group.toolCallIds);
|
|
1603
|
+
const currentGroup = lastToolCallId && count > 0
|
|
1604
|
+
? [{ count, failed, lastToolCallId, toolCallIds: [...toolCalls] }]
|
|
1605
|
+
: [];
|
|
1606
|
+
return [...inferredGroups, ...currentGroup].filter((inferred) =>
|
|
1607
|
+
inferred.toolCallIds?.some((toolCallId) => persistedIds.has(toolCallId)),
|
|
1608
|
+
);
|
|
1609
|
+
};
|
|
1610
|
+
|
|
1611
|
+
const splitSummaryAtTranscriptBoundaries = (group: ToolSummaryGroup): ToolSummaryGroup[] => {
|
|
1612
|
+
if (!group.toolCallIds?.length) return [group];
|
|
1613
|
+
const persistedIds = new Set(group.toolCallIds);
|
|
1614
|
+
const overlappingGroups = inferredGroupsOverlapping(group);
|
|
1615
|
+
if (overlappingGroups.length <= 1) return [group];
|
|
1616
|
+
|
|
1617
|
+
// Older clean-mode summaries could span steering or compaction because
|
|
1618
|
+
// only visible assistant text ended a live group. Prefer the transcript's
|
|
1619
|
+
// hard boundaries so parent rows and expanded children stay chronological.
|
|
1620
|
+
return overlappingGroups.map((inferred) => ({
|
|
1621
|
+
...inferred,
|
|
1622
|
+
toolCallIds: inferred.toolCallIds?.filter((toolCallId) => persistedIds.has(toolCallId)),
|
|
1623
|
+
}));
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1626
|
+
const replaceInferredGroupsWithSummary = (
|
|
1627
|
+
persistedGroup: ToolSummaryGroup,
|
|
1628
|
+
restoredGroups: ToolSummaryGroup[],
|
|
1629
|
+
) => {
|
|
1630
|
+
const restoredOwners = new Set(restoredGroups.map((group) => group.lastToolCallId));
|
|
1631
|
+
for (const inferred of inferredGroupsOverlapping(persistedGroup)) {
|
|
1632
|
+
if (!restoredOwners.has(inferred.lastToolCallId)) {
|
|
1633
|
+
settledSummaries.delete(inferred.lastToolCallId);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
for (const group of restoredGroups) rememberGroup(group);
|
|
1637
|
+
};
|
|
1638
|
+
|
|
1639
|
+
const consumeGroupCoveredBySummary = (groups: ToolSummaryGroup[]) => {
|
|
1640
|
+
if (toolCalls.size === 0) return;
|
|
1641
|
+
const coveredToolCallIds = new Set(
|
|
1642
|
+
groups.flatMap((group) => [group.lastToolCallId, ...(group.toolCallIds ?? [])]),
|
|
1643
|
+
);
|
|
1644
|
+
if (![...toolCalls].some((toolCallId) => coveredToolCallIds.has(toolCallId))) return;
|
|
1645
|
+
|
|
1646
|
+
// A durable summary closes the current run even if the session contains
|
|
1647
|
+
// an orphaned tool call without a toolResult. Do not infer a second,
|
|
1648
|
+
// larger Done row when the following user message closes the transcript group.
|
|
1649
|
+
count = 0;
|
|
1650
|
+
failed = 0;
|
|
1651
|
+
lastToolCallId = undefined;
|
|
1652
|
+
lastFinishedGroup = undefined;
|
|
1653
|
+
toolCalls.clear();
|
|
1654
|
+
completedToolCalls.clear();
|
|
1234
1655
|
};
|
|
1235
1656
|
|
|
1236
1657
|
// Use the same compaction-aware branch that Pi renders in the transcript;
|
|
1237
1658
|
// getEntries() can also contain entries from other branches.
|
|
1238
|
-
|
|
1659
|
+
const modelContextEntries = ctx.sessionManager.buildContextEntries();
|
|
1660
|
+
cleanContextCompacted = modelContextEntries[0]?.type === "compaction";
|
|
1661
|
+
const contextEntries = orderContextEntriesForTranscript(modelContextEntries);
|
|
1662
|
+
for (const entry of contextEntries) {
|
|
1663
|
+
if (entry.type === "compaction") {
|
|
1664
|
+
finishGroup();
|
|
1665
|
+
lastFinishedGroup = undefined;
|
|
1666
|
+
continue;
|
|
1667
|
+
}
|
|
1239
1668
|
if (entry.type === "custom" && entry.customType === "pretty-tui-tool-summary") {
|
|
1240
1669
|
const data = entry.data as ToolSummaryData | undefined;
|
|
1241
1670
|
if (data?.groups?.length) {
|
|
1242
|
-
|
|
1243
|
-
|
|
1671
|
+
const validGroups = data.groups.filter(
|
|
1672
|
+
(group): group is ToolSummaryGroup => Boolean(group?.lastToolCallId && group.count > 0),
|
|
1673
|
+
);
|
|
1674
|
+
for (const group of validGroups) {
|
|
1675
|
+
const restoredGroups = splitSummaryAtTranscriptBoundaries(group);
|
|
1676
|
+
replaceInferredGroupsWithSummary(group, restoredGroups);
|
|
1244
1677
|
}
|
|
1678
|
+
consumeGroupCoveredBySummary(validGroups);
|
|
1245
1679
|
} else if (data?.lastToolCallId) {
|
|
1246
|
-
|
|
1680
|
+
const group = {
|
|
1247
1681
|
count: data.count ?? 0,
|
|
1248
1682
|
failed: data.failed ?? 0,
|
|
1249
1683
|
lastToolCallId: data.lastToolCallId,
|
|
1250
|
-
}
|
|
1684
|
+
};
|
|
1685
|
+
rememberGroup(group);
|
|
1686
|
+
consumeGroupCoveredBySummary([group]);
|
|
1251
1687
|
} else if (lastFinishedGroup) {
|
|
1252
1688
|
// Migrate summaries written by the earlier clean-mode versions,
|
|
1253
1689
|
// which did not persist the final tool call id. The text message
|
|
@@ -1278,21 +1714,23 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1278
1714
|
}
|
|
1279
1715
|
|
|
1280
1716
|
if (message.role === "assistant") {
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
);
|
|
1284
|
-
if (hasVisibleText) finishGroup();
|
|
1285
|
-
for (const item of message.content ?? []) {
|
|
1717
|
+
if (messageHasVisibleText(message)) finishGroup();
|
|
1718
|
+
for (const item of messageContentItems(message)) {
|
|
1286
1719
|
if (item.type !== "toolCall" || !supportedTools.has(item.name)) continue;
|
|
1287
|
-
count++;
|
|
1288
|
-
lastToolCallId = item.id;
|
|
1289
1720
|
toolCalls.add(item.id);
|
|
1290
1721
|
}
|
|
1291
1722
|
continue;
|
|
1292
1723
|
}
|
|
1293
1724
|
|
|
1294
|
-
if (
|
|
1295
|
-
|
|
1725
|
+
if (
|
|
1726
|
+
message.role === "toolResult" &&
|
|
1727
|
+
toolCalls.has(message.toolCallId) &&
|
|
1728
|
+
!completedToolCalls.has(message.toolCallId)
|
|
1729
|
+
) {
|
|
1730
|
+
completedToolCalls.add(message.toolCallId);
|
|
1731
|
+
count++;
|
|
1732
|
+
lastToolCallId = message.toolCallId;
|
|
1733
|
+
if (message.isError) failed++;
|
|
1296
1734
|
}
|
|
1297
1735
|
}
|
|
1298
1736
|
|
|
@@ -1302,19 +1740,20 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1302
1740
|
pi.on("session_start", (_event, ctx) => restoreCleanSession(ctx));
|
|
1303
1741
|
pi.on("session_tree", (_event, ctx) => restoreCleanSession(ctx));
|
|
1304
1742
|
pi.on("session_compact", () => {
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
|
|
1308
|
-
|
|
1743
|
+
// Compaction is a hard chronological boundary. Keep tools completed before
|
|
1744
|
+
// it in their own group so expanded hierarchy lines never cross the summary.
|
|
1745
|
+
finishCleanGroup("done");
|
|
1746
|
+
settleLastCleanGroup();
|
|
1747
|
+
// Pre-compaction tools have been summarized intentionally. Their durable
|
|
1748
|
+
// fallback rows should not be replayed beside the compacted transcript.
|
|
1749
|
+
cleanContextCompacted = true;
|
|
1309
1750
|
});
|
|
1310
1751
|
|
|
1311
1752
|
const hasVisibleAssistantText = (message: any): boolean =>
|
|
1312
|
-
message?.role === "assistant" && (message
|
|
1313
|
-
(item: any) => item.type === "text" && typeof item.text === "string" && item.text.trim().length > 0,
|
|
1314
|
-
);
|
|
1753
|
+
message?.role === "assistant" && messageHasVisibleText(message);
|
|
1315
1754
|
|
|
1316
1755
|
const pendingToolCalls = (message: any): any[] =>
|
|
1317
|
-
(message
|
|
1756
|
+
messageContentItems(message).filter(
|
|
1318
1757
|
(item: any) => item.type === "toolCall" && supportedTools.has(item.name) && item.id,
|
|
1319
1758
|
);
|
|
1320
1759
|
|
|
@@ -1348,6 +1787,15 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1348
1787
|
}
|
|
1349
1788
|
};
|
|
1350
1789
|
|
|
1790
|
+
// A delivered steering message is also a chronological group boundary.
|
|
1791
|
+
// message_start runs before Pi adds the user component to the transcript,
|
|
1792
|
+
// so settling here keeps the previous parent row above that message.
|
|
1793
|
+
pi.on("message_start", (event) => {
|
|
1794
|
+
if (event.message.role !== "user") return;
|
|
1795
|
+
finishCleanGroup("done");
|
|
1796
|
+
settleLastCleanGroup();
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1351
1799
|
// A visible assistant response is the boundary between tool groups. Do
|
|
1352
1800
|
// this during streaming so a following tool call cannot inherit the prior
|
|
1353
1801
|
// summary, while message_end keeps the rule correct for non-streaming paths.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pretty-tui",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"description": "A beautiful, polished TUI for the Pi coding agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Kainan Yang <ykn0309@whu.edu.cn>",
|
|
7
7
|
"type": "module",
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/ykn0309/pi-pretty-tui/issues"
|
|
15
15
|
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
18
|
+
"test": "npm run typecheck && node tests/regression.mjs"
|
|
19
|
+
},
|
|
16
20
|
"keywords": [
|
|
17
21
|
"pi-package",
|
|
18
22
|
"pi-extension",
|
|
@@ -24,6 +28,7 @@
|
|
|
24
28
|
"extensions",
|
|
25
29
|
"assets",
|
|
26
30
|
"README.md",
|
|
31
|
+
"README.zh-CN.md",
|
|
27
32
|
"LICENSE"
|
|
28
33
|
],
|
|
29
34
|
"engines": {
|
|
@@ -33,6 +38,13 @@
|
|
|
33
38
|
"@earendil-works/pi-coding-agent": "*",
|
|
34
39
|
"@earendil-works/pi-tui": "*"
|
|
35
40
|
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
43
|
+
"@earendil-works/pi-tui": "0.85.1",
|
|
44
|
+
"@types/node": "^24.0.0",
|
|
45
|
+
"jiti": "^2.7.0",
|
|
46
|
+
"typescript": "^5.9.0"
|
|
47
|
+
},
|
|
36
48
|
"publishConfig": {
|
|
37
49
|
"access": "public"
|
|
38
50
|
},
|
|
@@ -40,6 +52,6 @@
|
|
|
40
52
|
"extensions": [
|
|
41
53
|
"./extensions/index.ts"
|
|
42
54
|
],
|
|
43
|
-
"image": "https://unpkg.com/pi-pretty-tui@0.1.
|
|
55
|
+
"image": "https://unpkg.com/pi-pretty-tui@0.1.7/assets/screenshot.png"
|
|
44
56
|
}
|
|
45
57
|
}
|