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 CHANGED
@@ -1,22 +1,16 @@
1
1
  # pi-pretty-tui
2
2
 
3
- A visual refinement extension for the [Pi coding agent](https://pi.dev/). It provides framed user messages, compact tool-call rendering, and cleaner list presentation while preserving Pi's built-in behavior.
3
+ English | [简体中文](./README.zh-CN.md)
4
4
 
5
- ![pi-pretty-tui preview](./assets/screenshot.png)
5
+ A beautiful, polished TUI for the [Pi coding agent](https://pi.dev/).
6
6
 
7
7
  ## Features
8
8
 
9
- - Rounded, titled `User` message frames with theme-aware borders, filled backgrounds, and Markdown support
10
- - A rounded prompt editor that preserves native cursor, IME, autocomplete, and mouse behavior
11
- - Compact `Read`, `Bash`, `Edit`, `Write`, `Grep`, `Find`, and `List` calls
12
- - Concise per-tool call and result summaries with expandable details
13
- - Native-style `Write` previews in `full` mode: first 10 lines when collapsed, full content when expanded
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 a mode interactively, or set one directly:
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
- ```text
36
- /pretty-tui full
37
- /pretty-tui compact
38
- /pretty-tui clean
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
- - `full`: preserve each tool's detailed call renderer and normal result preview. Press `Ctrl+O` to expand available output, diffs, and content.
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
- Clean mode covers the built-in tools managed by this package: `read`, `bash`, `edit`, `write`, `grep`, `find`, and `ls`. Third-party tools keep their own rendering.
43
+ ## Development
47
44
 
48
- The selected mode applies immediately and persists in `~/.pi/agent/pretty-tui.json` (or the directory selected by `PI_CODING_AGENT_DIR`).
45
+ Run the committed regression suite before changing renderer state or transcript grouping:
49
46
 
50
- ## Expand tool output
47
+ ```sh
48
+ npm install
49
+ npm test
50
+ ```
51
51
 
52
- Press `Ctrl+O` (Pi's default `app.tools.expand` keybinding) to show or hide detailed output, edit diffs, complete `Write` content, full tool details, and thinking content in `compact` or `clean` mode. In clean mode, mouse interaction also supports progressive disclosure: click a group status to retain it as a parent and reveal compact child tools, click one child for its full view, or click the parent again to collapse the group.
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. Clean thinking 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, and changing unordered-list markers require reload-safe runtime patches because Pi does not currently expose public renderer hooks for those presentation details. User Markdown and terminal semantic zones remain handled by Pi, and code blocks use Pi's original renderer without modification.
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
 
@@ -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
@@ -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 (interactiveModePrototype.setToolsExpanded === patch.originalSetToolsExpanded) {
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
- // Pi normalizes unordered-list markers to "-". Replace only that marker;
408
- // leave code-block rendering entirely to Pi's built-in Markdown renderer.
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" ? "success" : "accent";
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" ? "success" : "accent";
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("text", summaryText(count, failed, currentActivity)) + theme.fg("dim", ")");
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(theme, settledSummary.count, settledSummary.failed, settledSummary.activity)]).render(width);
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 tool components are absent
1164
- // from the current branch (for example, after compaction).
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
- for (const entry of ctx.sessionManager.buildContextEntries()) {
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
- for (const group of data.groups) {
1243
- if (group?.lastToolCallId && group.count > 0) rememberGroup(group);
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
- rememberGroup({
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
- const hasVisibleText = (message.content ?? []).some(
1282
- (item: any) => item.type === "text" && typeof item.text === "string" && item.text.trim().length > 0,
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 (message.role === "toolResult" && toolCalls.has(message.toolCallId) && message.isError) {
1295
- failed++;
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
- // Tool components removed by compaction must no longer suppress the
1306
- // durable summary fallback. Remaining components register again as Pi
1307
- // renders the rebuilt transcript.
1308
- knownToolCallIds.clear();
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.content ?? []).some(
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?.content ?? []).filter(
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.5",
4
- "description": "Polished user messages, tool calls, and list rendering for the Pi coding agent TUI",
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.5/assets/screenshot.png"
55
+ "image": "https://unpkg.com/pi-pretty-tui@0.1.7/assets/screenshot.png"
44
56
  }
45
57
  }