pi-pretty-tui 0.1.6 → 0.1.8
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 +557 -24
- 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, a clear Markdown heading hierarchy, cleaner 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,420 @@ 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 === "heading") {
|
|
608
|
+
const level = Math.max(1, Math.min(6, Number(token.depth) || 1));
|
|
609
|
+
const maxWidth = Math.max(1, width);
|
|
610
|
+
const addHeadingSpacing = (lines: string[]) => {
|
|
611
|
+
if (nextTokenType && nextTokenType !== "space") lines.push("");
|
|
612
|
+
return lines;
|
|
613
|
+
};
|
|
614
|
+
const headingStyle = (text: string) => this.theme.heading(text);
|
|
615
|
+
const foregroundLuminance = (styled: string): number | undefined => {
|
|
616
|
+
const trueColor = /\x1b\[38;2;(\d+);(\d+);(\d+)m/.exec(styled);
|
|
617
|
+
if (!trueColor) return undefined;
|
|
618
|
+
const [, red, green, blue] = trueColor.map(Number);
|
|
619
|
+
return red * 0.2126 + green * 0.7152 + blue * 0.0722;
|
|
620
|
+
};
|
|
621
|
+
const defaultLuminance = foregroundLuminance(this.applyDefaultStyle("M"));
|
|
622
|
+
const headingLuminance = foregroundLuminance(headingStyle("M"));
|
|
623
|
+
const darkTheme =
|
|
624
|
+
defaultLuminance !== undefined
|
|
625
|
+
? defaultLuminance >= 128
|
|
626
|
+
: (headingLuminance ?? 255) >= 160;
|
|
627
|
+
const whiteStyle = (text: string) => `\x1b[97m${text}\x1b[39m`;
|
|
628
|
+
const titleStyle =
|
|
629
|
+
level === 1 || level === 3
|
|
630
|
+
? (text: string) => headingStyle(this.theme.bold(this.theme.underline(text)))
|
|
631
|
+
: level === 2
|
|
632
|
+
? (text: string) =>
|
|
633
|
+
(darkTheme ? whiteStyle : headingStyle)(
|
|
634
|
+
this.theme.bold(this.theme.underline(text)),
|
|
635
|
+
)
|
|
636
|
+
: level === 4
|
|
637
|
+
? (text: string) => headingStyle(this.theme.bold(text))
|
|
638
|
+
: level === 5
|
|
639
|
+
? headingStyle
|
|
640
|
+
: (text: string) => this.theme.quote(this.theme.italic(text));
|
|
641
|
+
const titleContext = {
|
|
642
|
+
applyText: titleStyle,
|
|
643
|
+
stylePrefix: this.getStylePrefix(titleStyle),
|
|
644
|
+
};
|
|
645
|
+
const title = this.renderInlineTokens(token.tokens || [], titleContext);
|
|
646
|
+
|
|
647
|
+
if (level === 1 && maxWidth >= 6) {
|
|
648
|
+
const maximumInnerWidth = maxWidth - 4;
|
|
649
|
+
const titleLines = wrapTextWithAnsi(title, Math.max(1, maximumInnerWidth));
|
|
650
|
+
const innerWidth = Math.max(
|
|
651
|
+
1,
|
|
652
|
+
...titleLines.map((line: string) => visibleWidth(line)),
|
|
653
|
+
);
|
|
654
|
+
const frameWidth = Math.min(maxWidth, innerWidth + 4);
|
|
655
|
+
const frameStyle = (text: string) => headingStyle(this.theme.bold(text));
|
|
656
|
+
const lines = [
|
|
657
|
+
frameStyle(`╔${"═".repeat(frameWidth - 2)}╗`),
|
|
658
|
+
...titleLines.map((line: string) => {
|
|
659
|
+
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(line)));
|
|
660
|
+
return frameStyle("║ ") + line + frameStyle(`${padding} ║`);
|
|
661
|
+
}),
|
|
662
|
+
frameStyle(`╚${"═".repeat(frameWidth - 2)}╝`),
|
|
663
|
+
];
|
|
664
|
+
return addHeadingSpacing(lines);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (level === 2) {
|
|
668
|
+
// Reverse only the rendered title cells: the theme's heading color
|
|
669
|
+
// becomes a compact, content-width background label with no frame.
|
|
670
|
+
const lines = wrapTextWithAnsi(title, maxWidth).map((line: string) =>
|
|
671
|
+
darkTheme
|
|
672
|
+
? `\x1b[48;5;94m${line}\x1b[49m`
|
|
673
|
+
: `\x1b[107m\x1b[7m${line}\x1b[27m\x1b[49m`
|
|
674
|
+
);
|
|
675
|
+
return addHeadingSpacing(lines);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (level === 5 && maxWidth >= 8) {
|
|
679
|
+
const prefix = "┄┄ ";
|
|
680
|
+
const suffix = " ┄┄";
|
|
681
|
+
const contentWidth = Math.max(
|
|
682
|
+
1,
|
|
683
|
+
maxWidth - visibleWidth(prefix) - visibleWidth(suffix),
|
|
684
|
+
);
|
|
685
|
+
const wrapped = wrapTextWithAnsi(title, contentWidth);
|
|
686
|
+
const lines = wrapped.map((line: string, index: number) => {
|
|
687
|
+
const left = index === 0 ? this.theme.heading(prefix) : " ".repeat(visibleWidth(prefix));
|
|
688
|
+
const right = index === wrapped.length - 1 ? this.theme.heading(suffix) : "";
|
|
689
|
+
return left + line + right;
|
|
690
|
+
});
|
|
691
|
+
return addHeadingSpacing(lines);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return addHeadingSpacing(wrapTextWithAnsi(title, maxWidth));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (token?.type !== "code") {
|
|
698
|
+
return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const maxWidth = Math.max(1, width);
|
|
702
|
+
const code = String(token.text ?? "");
|
|
703
|
+
const useRoundedFrame = maxWidth >= 8;
|
|
704
|
+
const maximumCodeWidth = useRoundedFrame ? maxWidth - 4 : maxWidth;
|
|
705
|
+
const highlighted = this.theme.highlightCode
|
|
706
|
+
? this.theme.highlightCode(code, token.lang)
|
|
707
|
+
: code.split("\n").map((line: string) => this.theme.codeBlock(line));
|
|
708
|
+
const codeLines: string[] = [];
|
|
709
|
+
for (const line of highlighted.length > 0 ? highlighted : [""]) {
|
|
710
|
+
const wrapped = wrapTextWithAnsi(line, maximumCodeWidth);
|
|
711
|
+
codeLines.push(...(wrapped.length > 0 ? wrapped : [""]));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (!useRoundedFrame) {
|
|
715
|
+
if (nextTokenType && nextTokenType !== "space") codeLines.push("");
|
|
716
|
+
return codeLines;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const copyLabel = "[Copy]";
|
|
720
|
+
const showCopyButton = fullscreenTui && maxWidth >= 18;
|
|
721
|
+
const topSuffix = showCopyButton ? ` ${copyLabel} ╮` : "╮";
|
|
722
|
+
const rawLanguage = typeof token.lang === "string" ? token.lang.trim() : "";
|
|
723
|
+
const language = rawLanguage.split(/\s+/, 1)[0] || "code";
|
|
724
|
+
const label = truncateToWidth(
|
|
725
|
+
language,
|
|
726
|
+
Math.max(1, maxWidth - visibleWidth(topSuffix) - 7),
|
|
727
|
+
"…",
|
|
728
|
+
);
|
|
729
|
+
const topPrefix = `╭─ ${label} `;
|
|
730
|
+
const contentWidth = codeLines.reduce(
|
|
731
|
+
(widest, line) => Math.max(widest, visibleWidth(line)),
|
|
732
|
+
0,
|
|
733
|
+
);
|
|
734
|
+
const minimumFrameWidth = visibleWidth(topPrefix) + 3 + visibleWidth(topSuffix);
|
|
735
|
+
const frameWidth = Math.min(maxWidth, Math.max(contentWidth + 4, minimumFrameWidth));
|
|
736
|
+
const fillWidth = Math.max(
|
|
737
|
+
0,
|
|
738
|
+
frameWidth - visibleWidth(topPrefix) - visibleWidth(topSuffix),
|
|
739
|
+
);
|
|
740
|
+
const buttonStart = visibleWidth(topPrefix) + fillWidth + (showCopyButton ? 1 : 0);
|
|
741
|
+
const topRule = topPrefix + "─".repeat(fillWidth) + topSuffix;
|
|
742
|
+
const styledTopRule = this.theme.codeBlockBorder(topRule);
|
|
743
|
+
const framedCodeWidth = Math.max(1, frameWidth - 4);
|
|
744
|
+
const lines = [
|
|
745
|
+
styledTopRule,
|
|
746
|
+
...codeLines.map((line) =>
|
|
747
|
+
this.theme.codeBlockBorder("│ ") +
|
|
748
|
+
line +
|
|
749
|
+
" ".repeat(Math.max(0, framedCodeWidth - visibleWidth(line))) +
|
|
750
|
+
this.theme.codeBlockBorder(" │")
|
|
751
|
+
),
|
|
752
|
+
this.theme.codeBlockBorder(`╰${"─".repeat(Math.max(0, frameWidth - 2))}╯`),
|
|
753
|
+
];
|
|
754
|
+
|
|
755
|
+
if (showCopyButton && Array.isArray(this[codeBlockCollectionKey])) {
|
|
756
|
+
this[codeBlockCollectionKey].push({
|
|
757
|
+
buttonStart,
|
|
758
|
+
buttonWidth: visibleWidth(copyLabel),
|
|
759
|
+
code,
|
|
760
|
+
styledTopRule,
|
|
761
|
+
topRule,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
if (nextTokenType && nextTokenType !== "space") lines.push("");
|
|
765
|
+
return lines;
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
const patchedHandleMouse = function (this: any, event: TuiMouseEvent) {
|
|
769
|
+
if (event.button === "left" && (event.type === "press" || event.type === "click")) {
|
|
770
|
+
const region = (this[codeBlockRegionsKey] ?? []).find(
|
|
771
|
+
(candidate: any) =>
|
|
772
|
+
event.y === candidate.y && event.x >= candidate.xStart && event.x < candidate.xEnd,
|
|
773
|
+
);
|
|
774
|
+
if (region) {
|
|
775
|
+
if (event.type === "click") {
|
|
776
|
+
if (typeof currentTui?.copyTextToClipboard === "function") {
|
|
777
|
+
// Match fullscreen selection exactly: use Pi TUI's clipboard path
|
|
778
|
+
// and its transient "Copied!" / "Copy failed" flash feedback.
|
|
779
|
+
void Promise.resolve(currentTui.copyTextToClipboard(region.code)).catch(() => {
|
|
780
|
+
currentTui?.flash?.("Copy failed");
|
|
781
|
+
});
|
|
782
|
+
} else {
|
|
783
|
+
// Defensive fallback for older Pi versions without the fullscreen helper.
|
|
784
|
+
void copyToClipboard(region.code)
|
|
785
|
+
.then(() => currentExtensionUi?.notify("Copied!", "info"))
|
|
786
|
+
.catch(() => currentExtensionUi?.notify("Copy failed", "error"));
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return { handled: true, render: false };
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return originalHandleMouse?.call(this, event);
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
markdownPrototype[codeBlockPatchKey] = {
|
|
796
|
+
originalHandleMouse,
|
|
797
|
+
originalInvalidate,
|
|
798
|
+
originalRender,
|
|
799
|
+
originalRenderToken,
|
|
800
|
+
patchedHandleMouse,
|
|
801
|
+
patchedInvalidate,
|
|
802
|
+
patchedRender,
|
|
803
|
+
patchedRenderToken,
|
|
804
|
+
};
|
|
805
|
+
markdownPrototype.invalidate = patchedInvalidate;
|
|
806
|
+
markdownPrototype.render = patchedRender;
|
|
807
|
+
markdownPrototype.renderToken = patchedRenderToken;
|
|
808
|
+
markdownPrototype.handleMouse = patchedHandleMouse;
|
|
809
|
+
|
|
810
|
+
pi.on("session_shutdown", () => {
|
|
811
|
+
const patch = markdownPrototype[codeBlockPatchKey];
|
|
812
|
+
if (patch?.patchedInvalidate === markdownPrototype.invalidate) {
|
|
813
|
+
markdownPrototype.invalidate = patch.originalInvalidate;
|
|
814
|
+
}
|
|
815
|
+
if (patch?.patchedRender === markdownPrototype.render) {
|
|
816
|
+
markdownPrototype.render = patch.originalRender;
|
|
817
|
+
}
|
|
818
|
+
if (patch?.patchedRenderToken === markdownPrototype.renderToken) {
|
|
819
|
+
markdownPrototype.renderToken = patch.originalRenderToken;
|
|
820
|
+
}
|
|
821
|
+
if (patch?.patchedHandleMouse === markdownPrototype.handleMouse) {
|
|
822
|
+
if (patch.originalHandleMouse) markdownPrototype.handleMouse = patch.originalHandleMouse;
|
|
823
|
+
else delete markdownPrototype.handleMouse;
|
|
824
|
+
}
|
|
825
|
+
if (
|
|
826
|
+
patch &&
|
|
827
|
+
markdownPrototype.invalidate === patch.originalInvalidate &&
|
|
828
|
+
markdownPrototype.render === patch.originalRender &&
|
|
829
|
+
markdownPrototype.renderToken === patch.originalRenderToken &&
|
|
830
|
+
markdownPrototype.handleMouse === patch.originalHandleMouse
|
|
831
|
+
) {
|
|
832
|
+
delete markdownPrototype[codeBlockPatchKey];
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
410
837
|
const listPatchKey = Symbol.for("pretty-tui.list-bullets");
|
|
411
838
|
if (!markdownPrototype[listPatchKey]) {
|
|
412
839
|
const originalRenderList = markdownPrototype.renderList;
|
|
@@ -1032,20 +1459,26 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1032
1459
|
count: number,
|
|
1033
1460
|
failed: number,
|
|
1034
1461
|
activity: DisplayValue = "done",
|
|
1462
|
+
collapsedDone = false,
|
|
1035
1463
|
): DisplayRow => ({
|
|
1036
1464
|
prefix: () => {
|
|
1037
1465
|
const currentActivity = typeof activity === "function" ? activity() : activity;
|
|
1038
|
-
const color = currentActivity === "done"
|
|
1466
|
+
const color = currentActivity === "done"
|
|
1467
|
+
? collapsedDone ? "thinkingText" : "success"
|
|
1468
|
+
: "accent";
|
|
1039
1469
|
return theme.fg(color, "● ");
|
|
1040
1470
|
},
|
|
1041
1471
|
continuation: " ",
|
|
1042
1472
|
content: () => {
|
|
1043
1473
|
const currentActivity = typeof activity === "function" ? activity() : activity;
|
|
1044
1474
|
const label = currentActivity === "done" ? "Done" : "Running";
|
|
1045
|
-
const color = label === "Done"
|
|
1475
|
+
const color = label === "Done"
|
|
1476
|
+
? collapsedDone ? "thinkingText" : "success"
|
|
1477
|
+
: "accent";
|
|
1478
|
+
const detailColor = label === "Done" && collapsedDone ? "thinkingText" : "text";
|
|
1046
1479
|
return theme.fg(color, theme.bold(label)) +
|
|
1047
1480
|
theme.fg("dim", "(") +
|
|
1048
|
-
theme.fg(
|
|
1481
|
+
theme.fg(detailColor, summaryText(count, failed, currentActivity)) + theme.fg("dim", ")");
|
|
1049
1482
|
},
|
|
1050
1483
|
});
|
|
1051
1484
|
|
|
@@ -1084,7 +1517,13 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1084
1517
|
|
|
1085
1518
|
const settledSummary = settledSummaries.get(toolCallId);
|
|
1086
1519
|
if (settledSummary) {
|
|
1087
|
-
return block([summaryRow(
|
|
1520
|
+
return block([summaryRow(
|
|
1521
|
+
theme,
|
|
1522
|
+
settledSummary.count,
|
|
1523
|
+
settledSummary.failed,
|
|
1524
|
+
settledSummary.activity,
|
|
1525
|
+
true,
|
|
1526
|
+
)]).render(width);
|
|
1088
1527
|
}
|
|
1089
1528
|
if (cleanRun.settled) return [];
|
|
1090
1529
|
|
|
@@ -1139,7 +1578,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1139
1578
|
|
|
1140
1579
|
pi.registerEntryRenderer<ToolSummaryData>("pretty-tui-tool-summary", (entry, { expanded }, theme) => ({
|
|
1141
1580
|
render(width: number): string[] {
|
|
1142
|
-
if (renderMode !== "clean" || expanded) return [];
|
|
1581
|
+
if (renderMode !== "clean" || expanded || cleanContextCompacted) return [];
|
|
1143
1582
|
const data = entry.data;
|
|
1144
1583
|
const groups = data?.groups?.length
|
|
1145
1584
|
? data.groups
|
|
@@ -1160,15 +1599,15 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1160
1599
|
: [];
|
|
1161
1600
|
})();
|
|
1162
1601
|
// The live tool components own the visual positions. Keep this durable
|
|
1163
|
-
// entry as a fallback only for groups whose
|
|
1164
|
-
// from
|
|
1602
|
+
// entry as a fallback only for groups whose components are unexpectedly
|
|
1603
|
+
// absent from an uncompacted branch.
|
|
1165
1604
|
const missingGroups = groups.filter((group) => !knownToolCallIds.has(group.lastToolCallId));
|
|
1166
1605
|
if (missingGroups.length === 0) return [];
|
|
1167
1606
|
// Persisted entries represent settled groups. Older versions could
|
|
1168
1607
|
// accidentally store the transient responding... activity, so always
|
|
1169
1608
|
// normalize their display to Done.
|
|
1170
1609
|
return block(missingGroups.map((group) =>
|
|
1171
|
-
summaryRow(theme, group.count, group.failed, "done")
|
|
1610
|
+
summaryRow(theme, group.count, group.failed, "done", true)
|
|
1172
1611
|
)).render(width);
|
|
1173
1612
|
},
|
|
1174
1613
|
invalidate() {},
|
|
@@ -1185,6 +1624,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1185
1624
|
);
|
|
1186
1625
|
|
|
1187
1626
|
const restoreCleanSession = (ctx: any) => {
|
|
1627
|
+
currentExtensionUi = ctx.ui;
|
|
1188
1628
|
cleanToolsExpanded = ctx.ui.getToolsExpanded();
|
|
1189
1629
|
settledSummaries.clear();
|
|
1190
1630
|
legacySummaryLastToolCallIds.clear();
|
|
@@ -1213,7 +1653,9 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1213
1653
|
let failed = 0;
|
|
1214
1654
|
let lastToolCallId: string | undefined;
|
|
1215
1655
|
let lastFinishedGroup: ToolSummaryGroup | undefined;
|
|
1656
|
+
const inferredGroups: ToolSummaryGroup[] = [];
|
|
1216
1657
|
const toolCalls = new Set<string>();
|
|
1658
|
+
const completedToolCalls = new Set<string>();
|
|
1217
1659
|
const explicitSummaryIds = new Set<string>();
|
|
1218
1660
|
|
|
1219
1661
|
const rememberGroup = (group: ToolSummaryGroup, entryId?: string) => {
|
|
@@ -1232,6 +1674,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1232
1674
|
const finishGroup = () => {
|
|
1233
1675
|
if (lastToolCallId && count > 0) {
|
|
1234
1676
|
lastFinishedGroup = { count, failed, lastToolCallId, toolCallIds: [...toolCalls] };
|
|
1677
|
+
inferredGroups.push(lastFinishedGroup);
|
|
1235
1678
|
setCleanGroupMembers(lastToolCallId, [...toolCalls]);
|
|
1236
1679
|
if (!explicitSummaryIds.has(lastToolCallId)) {
|
|
1237
1680
|
settledSummaries.set(lastToolCallId, { count, failed, activity: "done" });
|
|
@@ -1241,23 +1684,96 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1241
1684
|
failed = 0;
|
|
1242
1685
|
lastToolCallId = undefined;
|
|
1243
1686
|
toolCalls.clear();
|
|
1687
|
+
completedToolCalls.clear();
|
|
1688
|
+
};
|
|
1689
|
+
|
|
1690
|
+
const inferredGroupsOverlapping = (group: ToolSummaryGroup): ToolSummaryGroup[] => {
|
|
1691
|
+
if (!group.toolCallIds?.length) return [];
|
|
1692
|
+
const persistedIds = new Set(group.toolCallIds);
|
|
1693
|
+
const currentGroup = lastToolCallId && count > 0
|
|
1694
|
+
? [{ count, failed, lastToolCallId, toolCallIds: [...toolCalls] }]
|
|
1695
|
+
: [];
|
|
1696
|
+
return [...inferredGroups, ...currentGroup].filter((inferred) =>
|
|
1697
|
+
inferred.toolCallIds?.some((toolCallId) => persistedIds.has(toolCallId)),
|
|
1698
|
+
);
|
|
1699
|
+
};
|
|
1700
|
+
|
|
1701
|
+
const splitSummaryAtTranscriptBoundaries = (group: ToolSummaryGroup): ToolSummaryGroup[] => {
|
|
1702
|
+
if (!group.toolCallIds?.length) return [group];
|
|
1703
|
+
const persistedIds = new Set(group.toolCallIds);
|
|
1704
|
+
const overlappingGroups = inferredGroupsOverlapping(group);
|
|
1705
|
+
if (overlappingGroups.length <= 1) return [group];
|
|
1706
|
+
|
|
1707
|
+
// Older clean-mode summaries could span steering or compaction because
|
|
1708
|
+
// only visible assistant text ended a live group. Prefer the transcript's
|
|
1709
|
+
// hard boundaries so parent rows and expanded children stay chronological.
|
|
1710
|
+
return overlappingGroups.map((inferred) => ({
|
|
1711
|
+
...inferred,
|
|
1712
|
+
toolCallIds: inferred.toolCallIds?.filter((toolCallId) => persistedIds.has(toolCallId)),
|
|
1713
|
+
}));
|
|
1714
|
+
};
|
|
1715
|
+
|
|
1716
|
+
const replaceInferredGroupsWithSummary = (
|
|
1717
|
+
persistedGroup: ToolSummaryGroup,
|
|
1718
|
+
restoredGroups: ToolSummaryGroup[],
|
|
1719
|
+
) => {
|
|
1720
|
+
const restoredOwners = new Set(restoredGroups.map((group) => group.lastToolCallId));
|
|
1721
|
+
for (const inferred of inferredGroupsOverlapping(persistedGroup)) {
|
|
1722
|
+
if (!restoredOwners.has(inferred.lastToolCallId)) {
|
|
1723
|
+
settledSummaries.delete(inferred.lastToolCallId);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
for (const group of restoredGroups) rememberGroup(group);
|
|
1727
|
+
};
|
|
1728
|
+
|
|
1729
|
+
const consumeGroupCoveredBySummary = (groups: ToolSummaryGroup[]) => {
|
|
1730
|
+
if (toolCalls.size === 0) return;
|
|
1731
|
+
const coveredToolCallIds = new Set(
|
|
1732
|
+
groups.flatMap((group) => [group.lastToolCallId, ...(group.toolCallIds ?? [])]),
|
|
1733
|
+
);
|
|
1734
|
+
if (![...toolCalls].some((toolCallId) => coveredToolCallIds.has(toolCallId))) return;
|
|
1735
|
+
|
|
1736
|
+
// A durable summary closes the current run even if the session contains
|
|
1737
|
+
// an orphaned tool call without a toolResult. Do not infer a second,
|
|
1738
|
+
// larger Done row when the following user message closes the transcript group.
|
|
1739
|
+
count = 0;
|
|
1740
|
+
failed = 0;
|
|
1741
|
+
lastToolCallId = undefined;
|
|
1742
|
+
lastFinishedGroup = undefined;
|
|
1743
|
+
toolCalls.clear();
|
|
1744
|
+
completedToolCalls.clear();
|
|
1244
1745
|
};
|
|
1245
1746
|
|
|
1246
1747
|
// Use the same compaction-aware branch that Pi renders in the transcript;
|
|
1247
1748
|
// getEntries() can also contain entries from other branches.
|
|
1248
|
-
|
|
1749
|
+
const modelContextEntries = ctx.sessionManager.buildContextEntries();
|
|
1750
|
+
cleanContextCompacted = modelContextEntries[0]?.type === "compaction";
|
|
1751
|
+
const contextEntries = orderContextEntriesForTranscript(modelContextEntries);
|
|
1752
|
+
for (const entry of contextEntries) {
|
|
1753
|
+
if (entry.type === "compaction") {
|
|
1754
|
+
finishGroup();
|
|
1755
|
+
lastFinishedGroup = undefined;
|
|
1756
|
+
continue;
|
|
1757
|
+
}
|
|
1249
1758
|
if (entry.type === "custom" && entry.customType === "pretty-tui-tool-summary") {
|
|
1250
1759
|
const data = entry.data as ToolSummaryData | undefined;
|
|
1251
1760
|
if (data?.groups?.length) {
|
|
1252
|
-
|
|
1253
|
-
|
|
1761
|
+
const validGroups = data.groups.filter(
|
|
1762
|
+
(group): group is ToolSummaryGroup => Boolean(group?.lastToolCallId && group.count > 0),
|
|
1763
|
+
);
|
|
1764
|
+
for (const group of validGroups) {
|
|
1765
|
+
const restoredGroups = splitSummaryAtTranscriptBoundaries(group);
|
|
1766
|
+
replaceInferredGroupsWithSummary(group, restoredGroups);
|
|
1254
1767
|
}
|
|
1768
|
+
consumeGroupCoveredBySummary(validGroups);
|
|
1255
1769
|
} else if (data?.lastToolCallId) {
|
|
1256
|
-
|
|
1770
|
+
const group = {
|
|
1257
1771
|
count: data.count ?? 0,
|
|
1258
1772
|
failed: data.failed ?? 0,
|
|
1259
1773
|
lastToolCallId: data.lastToolCallId,
|
|
1260
|
-
}
|
|
1774
|
+
};
|
|
1775
|
+
rememberGroup(group);
|
|
1776
|
+
consumeGroupCoveredBySummary([group]);
|
|
1261
1777
|
} else if (lastFinishedGroup) {
|
|
1262
1778
|
// Migrate summaries written by the earlier clean-mode versions,
|
|
1263
1779
|
// which did not persist the final tool call id. The text message
|
|
@@ -1291,15 +1807,20 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1291
1807
|
if (messageHasVisibleText(message)) finishGroup();
|
|
1292
1808
|
for (const item of messageContentItems(message)) {
|
|
1293
1809
|
if (item.type !== "toolCall" || !supportedTools.has(item.name)) continue;
|
|
1294
|
-
count++;
|
|
1295
|
-
lastToolCallId = item.id;
|
|
1296
1810
|
toolCalls.add(item.id);
|
|
1297
1811
|
}
|
|
1298
1812
|
continue;
|
|
1299
1813
|
}
|
|
1300
1814
|
|
|
1301
|
-
if (
|
|
1302
|
-
|
|
1815
|
+
if (
|
|
1816
|
+
message.role === "toolResult" &&
|
|
1817
|
+
toolCalls.has(message.toolCallId) &&
|
|
1818
|
+
!completedToolCalls.has(message.toolCallId)
|
|
1819
|
+
) {
|
|
1820
|
+
completedToolCalls.add(message.toolCallId);
|
|
1821
|
+
count++;
|
|
1822
|
+
lastToolCallId = message.toolCallId;
|
|
1823
|
+
if (message.isError) failed++;
|
|
1303
1824
|
}
|
|
1304
1825
|
}
|
|
1305
1826
|
|
|
@@ -1309,10 +1830,13 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1309
1830
|
pi.on("session_start", (_event, ctx) => restoreCleanSession(ctx));
|
|
1310
1831
|
pi.on("session_tree", (_event, ctx) => restoreCleanSession(ctx));
|
|
1311
1832
|
pi.on("session_compact", () => {
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
|
|
1315
|
-
|
|
1833
|
+
// Compaction is a hard chronological boundary. Keep tools completed before
|
|
1834
|
+
// it in their own group so expanded hierarchy lines never cross the summary.
|
|
1835
|
+
finishCleanGroup("done");
|
|
1836
|
+
settleLastCleanGroup();
|
|
1837
|
+
// Pre-compaction tools have been summarized intentionally. Their durable
|
|
1838
|
+
// fallback rows should not be replayed beside the compacted transcript.
|
|
1839
|
+
cleanContextCompacted = true;
|
|
1316
1840
|
});
|
|
1317
1841
|
|
|
1318
1842
|
const hasVisibleAssistantText = (message: any): boolean =>
|
|
@@ -1353,6 +1877,15 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1353
1877
|
}
|
|
1354
1878
|
};
|
|
1355
1879
|
|
|
1880
|
+
// A delivered steering message is also a chronological group boundary.
|
|
1881
|
+
// message_start runs before Pi adds the user component to the transcript,
|
|
1882
|
+
// so settling here keeps the previous parent row above that message.
|
|
1883
|
+
pi.on("message_start", (event) => {
|
|
1884
|
+
if (event.message.role !== "user") return;
|
|
1885
|
+
finishCleanGroup("done");
|
|
1886
|
+
settleLastCleanGroup();
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1356
1889
|
// A visible assistant response is the boundary between tool groups. Do
|
|
1357
1890
|
// this during streaming so a following tool call cannot inherit the prior
|
|
1358
1891
|
// 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.8",
|
|
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.8/assets/screenshot.png"
|
|
44
56
|
}
|
|
45
57
|
}
|