pi-pretty-tui 0.2.7 → 0.2.9

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
@@ -37,6 +37,14 @@ Displayed custom messages without their own renderer use one generic accent-colo
37
37
 
38
38
  Pi's direct internal `showStatus()` feedback is transient: it appears only in the status line above the editor, a newer status replaces it, and a ten-second timer removes it. It never enters the transcript or activity timeline and is cleared on reload. In clean mode, `Ctrl+T` and `Ctrl+O` only show short hints to click the corresponding disclosure; neither changes visibility or Pi's Thinking setting. In full and compact modes, both shortcuts keep Pi's native behaviour.
39
39
 
40
+ Visible assistant text remains outside activity trees even when a later update to the same message introduces thinking. Repeated updates do not close a separate, subsequent tool group.
41
+
42
+ `read` range labels accept numeric strings and omitted/null fields without concatenating line numbers; malformed range values omit the label. Expanded `edit` diffs use a fixed, right-aligned line-number gutter followed by a separate `+`/`-` column. Wrapped code aligns with the content column, preserving code indentation and theme diff colors.
43
+
44
+ ## Error presentation
45
+
46
+ In clean mode, standalone errors keep their original title and complete text in a compact `✕ Error · reason` layout. Consecutive errors with exactly the same source type, title, and full text share one visible row with a dim `×2` / `×3` count. Other transcript components break the run; errors carrying thought/tool/visible-response content are not folded. This is UI-only: session records and original message components remain unchanged. Long errors wrap without an added preview truncation; full/compact modes keep native presentation.
47
+
40
48
  ## Codemode and nested tools
41
49
 
42
50
  In clean mode, tools that call other tools through Pi's nested execution API keep their own parent node. Expand the activity group to see each internal call, its status, compact arguments, duration, and result summary. Click an internal call to inspect its arguments and text result independently. Click its expanded body to collapse just that call. Script and script-output bodies also collapse their own sections when clicked. Tool calls use a `●` marker: dim while running, success-colored when successful, and error-colored when failed. Tool names use bold theme text. `[Script]` and `[Script output]` are separate disclosures; the nested parent title is informational and clicking it does not expand or collapse details. Use each section or child to expand and collapse its own details; `Ctrl+O` only shows a hint in clean mode. Extensions can still use Pi's `setToolsExpanded()` API for global control; local disclosures remain usable afterward, and another global API call resets the local overrides. Calls retain their start order even when they finish in parallel. A handled child failure remains marked as failed even if the script succeeds.
@@ -84,7 +92,7 @@ The suite covers live and restored activity timelines, retries and terminal stat
84
92
 
85
93
  ## Compatibility notice
86
94
 
87
- The current development and regression-test baseline is **Pi 1.1.0**. User chat bubbles support its direct Markdown layout, keeping their transparent background and content-sized width. Nested execution requires Pi 0.99.0 or later; this is a feature requirement, not a promise that every older Pi release is tested.
95
+ The current development and regression-test baseline is **Pi 1.3.0**. User chat bubbles support its direct Markdown layout, keeping their transparent background and content-sized width. Nested execution requires Pi 0.99.0 or later; this is a feature requirement, not a promise that every older Pi release is tested.
88
96
 
89
97
  Tool rendering uses Pi's documented extension APIs. Active-tool state follows Pi's built-in execution transitions through its exported interactive components. Clean-mode thought details use the activity hierarchy's own disclosure state; full and compact modes retain Pi's built-in Thinking visibility control. Rendering native user messages as adaptive chat bubbles, 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 enhancements are activated only while rendering user and assistant messages in the main transcript; Markdown created by extension overlays keeps Pi's native layout. Markdown parsing, syntax highlighting, and terminal semantic zones remain handled by Pi.
90
98
 
package/README.zh-CN.md CHANGED
@@ -37,6 +37,14 @@ Pi 可能会提示内置工具被覆盖。这是正常现象:pi-pretty-tui 会
37
37
 
38
38
  Pi 内部直接调用 `showStatus()` 产生的反馈属于临时 UI 状态:它只显示在编辑器上方,新状态替换旧状态,并由十秒 timer 自动清除;它不会进入 transcript 或活动时间线,reload 时也会清除。在 clean 模式下,`Ctrl+T` 和 `Ctrl+O` 都只显示点击对应节点的简短提示,不改变内容可见性或 Pi 的 Thinking 设置。在 full 和 compact 模式下,两者保留 Pi 的原生行为。
39
39
 
40
+ 即使同一消息的后续更新才加入思考,可见 assistant 正文也保持在活动树之外;重复更新不会关闭后续独立的工具活动组。
41
+
42
+ `read` 行范围标签正确处理数字字符串及省略/null 参数,不再拼接出错误行号;无效范围值不显示范围标签。展开的 `edit` diff 使用固定宽度、右对齐的左侧行号栏,`+`/`-` 独占一列;长代码续行与正文列对齐,保留代码缩进及主题 diff 配色。
43
+
44
+ ## 错误展示
45
+
46
+ clean 模式下,独立错误保留原始标题和完整内容,以紧凑的 `✕ Error · 原因` 展示。来源类型、标题及完整内容完全一致的相邻错误合为一条可见行,并使用暗色 `×2` / `×3` 标识次数。其他 transcript 组件会打断计数;包含思考、工具或可见回答的错误不参与合并。合并仅影响 UI,不删除或修改会话记录及原始消息组件。长错误完整换行,不增加预览截断;full/compact 模式保留原生展示。
47
+
40
48
  ## Codemode 与嵌套工具
41
49
 
42
50
  clean 模式下,通过 Pi 嵌套执行 API 调用其他工具的工具保留自己的父节点。展开活动组后,会显示各内部调用的状态、紧凑参数、耗时和结果摘要;点击某个内部调用,可以独立展开其参数和文本结果;点击展开后的正文可收起该调用,脚本和脚本输出正文也可点击收起各自区块。工具统一使用 `●` 圆点:运行中为主题暗色,成功与失败分别为主题成功色与错误色;工具名使用正文色加粗。`[Script]` 和 `[Script output]` 分别控制脚本与汇总输出;嵌套父工具标题仅用于展示,点击不会展开或折叠详情。各区块或子调用通过自己的入口独立展开/收起,`Ctrl+O` 在 clean 模式下只显示提示。扩展仍可通过 Pi 的 `setToolsExpanded()` API 全局控制,之后也可局部展开/收起;再次调用全局 API 会重置局部状态。并行调用按照启动顺序排列,不会随完成顺序跳动。即使脚本捕获错误后成功,失败的子调用仍会明确标记。
@@ -84,7 +92,7 @@ npm test
84
92
 
85
93
  ## 兼容性说明
86
94
 
87
- 当前开发与回归测试基线为 **Pi 1.1.0**。用户气泡已适配该版本直接使用 Markdown 的布局,保留透明背景与按内容自适应的宽度。嵌套执行需要 Pi 0.99.0 及以后;这是功能要求,不代表每个旧版都经过测试。
95
+ 当前开发与回归测试基线为 **Pi 1.3.0**。用户气泡已适配该版本直接使用 Markdown 的布局,保留透明背景与按内容自适应的宽度。嵌套执行需要 Pi 0.99.0 及以后;这是功能要求,不代表每个旧版都经过测试。
88
96
 
89
97
  工具渲染使用 Pi 的公开扩展 API。活动工具状态通过 Pi 导出的交互组件跟随其内置执行状态变化;clean 模式的 thought 详情使用活动时间线自身的展开状态,full 和 compact 模式则保留 Pi 内置的 Thinking 可见性控制。由于 Pi 目前没有为这些展示细节提供公开渲染钩子,自适应的原生用户消息气泡、原生输入编辑器圆角、无序列表标记和围栏代码块增强需要使用可安全重载的运行时补丁。Markdown 增强仅在渲染主 transcript 中的用户消息和助手回复时启用;扩展 overlay 创建的 Markdown 保持 Pi 原生布局。Markdown 解析、语法高亮和终端语义区域仍由 Pi 处理。
90
98
 
@@ -100,6 +100,32 @@ export default function prettyTui(pi: ExtensionAPI) {
100
100
  const mouseClickHitTests = new WeakMap<object, (event: any) => boolean>();
101
101
  let currentTui: any;
102
102
  let currentPromptEditor: any;
103
+ let errorChatContainer: any;
104
+ const runtimeErrorDescriptors = new WeakMap<object, { source: string; title: string; content: string }>();
105
+ const errorDescriptor = (component: any) => {
106
+ const runtime = runtimeErrorDescriptors.get(component);
107
+ if (runtime) return runtime;
108
+ if (!(component instanceof AssistantMessageComponent)) return undefined;
109
+ const message = (component as any).lastMessage;
110
+ if (message?.stopReason !== "error" || (message.content ?? []).some((item: any) =>
111
+ item.type === "toolCall" || item.type === "thinking" || item.type === "text" && item.text?.length)) return undefined;
112
+ return { source: "assistant:error", title: "Error", content: String(message.errorMessage ?? "") };
113
+ };
114
+ const errorRun = (component: any) => {
115
+ const children: any[] = errorChatContainer?.children ?? [];
116
+ const index = children.indexOf(component);
117
+ const descriptor = errorDescriptor(component);
118
+ const same = (other: any) => {
119
+ const value = errorDescriptor(other);
120
+ return descriptor && value && value.source === descriptor.source &&
121
+ value.title === descriptor.title && value.content === descriptor.content;
122
+ };
123
+ if (index < 0 || !descriptor) return { hidden: false, count: 1, compact: false };
124
+ let count = 1;
125
+ while (index + count < children.length && same(children[index + count])) count++;
126
+ return { hidden: index > 0 && Boolean(same(children[index - 1])), count,
127
+ compact: index > 0 && Boolean(errorDescriptor(children[index - 1])) };
128
+ };
103
129
  const runningColor = (theme: any, text: string): string =>
104
130
  typeof currentPromptEditor?.borderColor === "function"
105
131
  ? currentPromptEditor.borderColor(text)
@@ -553,7 +579,31 @@ export default function prettyTui(pi: ExtensionAPI) {
553
579
  );
554
580
  };
555
581
 
556
- const renderPendingActivityUpdate = (member: ActivityMember, width: number): string[] => {
582
+ const renderStandaloneError = (member: ActivityMember, width: number, component?: any): string[] => {
583
+ const run = component ? errorRun(component) : { hidden: false, count: 1, compact: false };
584
+ if (run.hidden) return [];
585
+ const theme = defaultActivityTheme();
586
+ const style = activityUpdateStyle(member, theme);
587
+ const original = component ? errorDescriptor(component) : undefined;
588
+ const title = stripTerminalSequences(original?.title ?? member.updateTitle ?? "Error");
589
+ const content = stripTerminalSequences(original?.content || member.updateContent || "");
590
+ const heading = style.icon + style.title(title);
591
+ const prefix = heading + (content ? theme.fg("dim", " · ") : "");
592
+ const text = prefix + theme.fg("muted", content) + (run.count > 1 ? theme.fg("dim", ` ×${run.count}`) : "");
593
+ // Wrap rather than truncate; all original detail lines remain accessible.
594
+ const continuation = " ".repeat(Math.min(visibleWidth(prefix), Math.max(0, Math.floor(width / 3))));
595
+ const rows: string[] = [];
596
+ for (const line of text.split("\n")) {
597
+ const wrapped = wrapTextWithAnsi(line, Math.max(1, width - visibleWidth(continuation)));
598
+ rows.push(...wrapped.map((row, index) => (rows.length || index ? continuation : "") + row));
599
+ }
600
+ return [...(run.compact ? [] : [""]), ...rows.map((line) => truncateToWidth(line, Math.max(1, width), ""))];
601
+ };
602
+
603
+ const renderPendingActivityUpdate = (member: ActivityMember, width: number, component?: any): string[] => {
604
+ if (member.updateSeverity === "error" && member.updateTitle !== "Response truncated") {
605
+ return renderStandaloneError(member, width, component);
606
+ }
557
607
  const theme = defaultActivityTheme();
558
608
  const title = truncateToWidth(member.updateTitle ?? "Update", Math.max(1, width - 2), "…");
559
609
  const style = activityUpdateStyle(member, theme);
@@ -572,8 +622,9 @@ export default function prettyTui(pi: ExtensionAPI) {
572
622
  group: ActivityGroup,
573
623
  member: ActivityMember,
574
624
  width: number,
625
+ component?: any,
575
626
  ): string[] => {
576
- if (!activityGroupCollapsible(group)) return renderPendingActivityUpdate(member, width);
627
+ if (!activityGroupCollapsible(group)) return renderPendingActivityUpdate(member, width, component);
577
628
  const position = activityMemberPosition(group, member);
578
629
  if (!activityGroupRevealed(group)) {
579
630
  return position.first
@@ -1242,7 +1293,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1242
1293
  const terminalGroup = activityTimeline.groupForMember(terminalMember.id);
1243
1294
  if (!terminalGroup) return originalRender.call(this, width);
1244
1295
  activityUpdateComponents.set(terminalMember.id, this);
1245
- return renderActivityUpdateProjection(terminalGroup, terminalMember, width);
1296
+ return renderActivityUpdateProjection(terminalGroup, terminalMember, width, this);
1246
1297
  }
1247
1298
 
1248
1299
  const key = assistantMessageKey(message);
@@ -1507,10 +1558,14 @@ export default function prettyTui(pi: ExtensionAPI) {
1507
1558
  const group = activityTimeline.groupForMember(member.id);
1508
1559
  if (!group || !mode.chatContainer?.addChild) return false;
1509
1560
  const component: Component = {
1510
- render: (width: number) => renderActivityUpdateProjection(group, member, width),
1561
+ render: (width: number) => renderActivityUpdateProjection(group, member, width, component),
1511
1562
  invalidate() {},
1512
1563
  handleMouse: (event: any) => handleActivityUpdateMouse(group, member, event),
1513
1564
  } as Component;
1565
+ if (severity === "error") runtimeErrorDescriptors.set(component, {
1566
+ source: "runtime:error", title, content,
1567
+ });
1568
+ errorChatContainer = mode.chatContainer;
1514
1569
  activityUpdateComponents.set(member.id, component);
1515
1570
  mode.chatContainer.addChild(component);
1516
1571
  mode.ui?.requestRender?.();
@@ -1569,6 +1624,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1569
1624
 
1570
1625
  const patchedRenderSessionEntries = function (this: any, entries: any[], options?: any) {
1571
1626
  currentTui = this.ui;
1627
+ errorChatContainer = this.chatContainer;
1572
1628
  fullscreenTui = currentTui?.mode === "fullscreen";
1573
1629
  // buildContextEntries() prepends the latest compaction for model context,
1574
1630
  // while Pi's live compaction UI appends it chronologically. Keep reloads
@@ -1674,6 +1730,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1674
1730
  // calling sites, rather than depend on private chat-container children or
1675
1731
  // instanceof checks (these can be wrapped or come from another module).
1676
1732
  const patchedAddMessageToChat = function (this: any, message: any, ...args: any[]) {
1733
+ errorChatContainer = this.chatContainer;
1677
1734
  if (message?.role === "assistant") cacheMessageContexts.set(this, message);
1678
1735
  else cacheMessageContexts.delete(this);
1679
1736
  return originalAddMessageToChat.call(this, message, ...args);
@@ -1874,6 +1931,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1874
1931
 
1875
1932
  pi.on("session_shutdown", () => {
1876
1933
  fullscreenTui = false;
1934
+ errorChatContainer = undefined;
1877
1935
  currentTui = undefined;
1878
1936
  clearLatestActivityStatus();
1879
1937
  currentExtensionUi = undefined;
@@ -2404,6 +2462,22 @@ export default function prettyTui(pi: ExtensionAPI) {
2404
2462
  const nonEmptyLines = (value: string): number =>
2405
2463
  value ? value.split("\n").filter((line) => line.length > 0).length : 0;
2406
2464
 
2465
+ // Normalize display values only; null means omitted, as in native Pi labels.
2466
+ // Malformed values hide the range rather than inventing line numbers.
2467
+ const readLineRange = (offset: unknown, limit: unknown): string => {
2468
+ if (offset == null && limit == null) return "";
2469
+ const parse = (value: unknown): number | undefined => {
2470
+ if (typeof value === "string" && value.trim() !== "") value = Number(value);
2471
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
2472
+ };
2473
+ const start = offset == null ? 1 : parse(offset);
2474
+ if (start === undefined) return "";
2475
+ if (limit == null) return ` · lines ${start}+`;
2476
+ const count = parse(limit);
2477
+ if (count === undefined) return "";
2478
+ return ` · lines ${start}–${start + count - 1}`;
2479
+ };
2480
+
2407
2481
  type DisplayValue = string | (() => string);
2408
2482
  type DisplayRow = {
2409
2483
  prefix: DisplayValue;
@@ -2490,6 +2564,28 @@ export default function prettyTui(pi: ExtensionAPI) {
2490
2564
  return block(rows);
2491
2565
  };
2492
2566
 
2567
+ // Pi diffs encode the marker before the padded line number. Move it into
2568
+ // its own column and keep the whole gutter out of content word wrapping.
2569
+ const editDiffLayout = (theme: any, diff: string): ((line: string) => DisplayRow) => {
2570
+ const parse = (line: string) => /^([ +\-]) *(\d+) (.*)$/.exec(line);
2571
+ const numberWidth = diff.split("\n").reduce((width, line) => Math.max(width, parse(line)?.[2].length ?? 0), 1);
2572
+ const gutterWidth = numberWidth + 3;
2573
+ return (line) => {
2574
+ const match = parse(line);
2575
+ const marker = match?.[1] ?? (
2576
+ line.startsWith("+") && !line.startsWith("+++") ? "+" :
2577
+ line.startsWith("-") && !line.startsWith("---") ? "-" : " "
2578
+ );
2579
+ const color = marker === "+" ? "toolDiffAdded" : marker === "-" ? "toolDiffRemoved" : "toolDiffContext";
2580
+ const gutter = match ? `${match[2].padStart(numberWidth)} ${marker} ` : " ".repeat(gutterWidth);
2581
+ return {
2582
+ prefix: theme.fg(color, gutter),
2583
+ continuation: " ".repeat(gutterWidth),
2584
+ content: theme.fg(color, match ? match[3] : /^\s+\.\.\.$/.test(line) ? "..." : line),
2585
+ };
2586
+ };
2587
+ };
2588
+
2493
2589
  const result = (
2494
2590
  theme: any,
2495
2591
  summary: string,
@@ -2498,6 +2594,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2498
2594
  error = false,
2499
2595
  summaryIsStyled = false,
2500
2596
  outputStyle: (line: string) => string = (line) => theme.fg("dim", line),
2597
+ outputLayout?: (line: string) => DisplayRow,
2501
2598
  ) => {
2502
2599
  const rows: DisplayRow[] = [{
2503
2600
  prefix: theme.fg("dim", "└ "),
@@ -2512,10 +2609,11 @@ export default function prettyTui(pi: ExtensionAPI) {
2512
2609
  const omitted = lines.length - shown.length;
2513
2610
  for (let index = 0; index < shown.length; index++) {
2514
2611
  const isLast = index === shown.length - 1 && omitted === 0;
2612
+ const layout = outputLayout?.(shown[index]);
2515
2613
  rows.push({
2516
- prefix: theme.fg("dim", isLast ? " └ " : " │ "),
2517
- continuation: isLast ? " " : theme.fg("dim", " │ "),
2518
- content: outputStyle(shown[index] || " "),
2614
+ prefix: theme.fg("dim", isLast ? " └ " : " │ ") + (layout?.prefix ?? ""),
2615
+ continuation: (isLast ? " " : theme.fg("dim", " │ ")) + (layout?.continuation ?? ""),
2616
+ content: layout?.content ?? outputStyle(shown[index] || " "),
2519
2617
  });
2520
2618
  }
2521
2619
  if (omitted > 0) {
@@ -3840,7 +3938,15 @@ export default function prettyTui(pi: ExtensionAPI) {
3840
3938
 
3841
3939
  const closeAtAssistantBoundary = (message: any, activity: string): boolean => {
3842
3940
  const key = assistantMessageKey(message);
3843
- if (assistantBoundaryKeys.has(key)) return false;
3941
+ if (assistantBoundaryKeys.has(key)) {
3942
+ // A later snapshot can introduce thinking after text already sealed this
3943
+ // message. Seal that newly-created thought group too, but do not let
3944
+ // repeated text updates close a different, subsequent tool group.
3945
+ const thought = activityTimeline.memberForThinking(key);
3946
+ const thoughtGroup = thought ? activityTimeline.groupForMember(thought.id) : undefined;
3947
+ if (!thoughtGroup || activityTimeline.currentGroup()?.id !== thoughtGroup.id ||
3948
+ activityTimeline.isSettled(thoughtGroup.id)) return false;
3949
+ }
3844
3950
  assistantBoundaryKeys.add(key);
3845
3951
  cleanRun.activity = activity;
3846
3952
  finishCleanGroup(activity);
@@ -4123,9 +4229,7 @@ export default function prettyTui(pi: ExtensionAPI) {
4123
4229
  renderShell: "self",
4124
4230
  renderCall(args: any, theme: any, context: any) {
4125
4231
  if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, readLabel, context.toolCallId);
4126
- const range = args.offset || args.limit
4127
- ? ` · lines ${args.offset ?? 1}${args.limit ? `–${(args.offset ?? 1) + args.limit - 1}` : "+"}`
4128
- : "";
4232
+ const range = readLineRange(args.offset, args.limit);
4129
4233
  return call(theme, readLabel, `${args.path}${range}`, context.state);
4130
4234
  },
4131
4235
  renderResult(toolResult: any, options: any, theme: any, context: any) {
@@ -4236,6 +4340,7 @@ export default function prettyTui(pi: ExtensionAPI) {
4236
4340
  if (line.startsWith("-") && !line.startsWith("---")) return theme.fg("toolDiffRemoved", line);
4237
4341
  return theme.fg("toolDiffContext", line);
4238
4342
  },
4343
+ !failed && diff ? editDiffLayout(theme, diff) : undefined,
4239
4344
  );
4240
4345
  },
4241
4346
  } as any);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pretty-tui",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "A beautiful, polished TUI for the Pi coding agent",
5
5
  "license": "MIT",
6
6
  "author": "Kainan Yang <ykn0309@whu.edu.cn>",
@@ -39,8 +39,8 @@
39
39
  "@earendil-works/pi-tui": "*"
40
40
  },
41
41
  "devDependencies": {
42
- "@earendil-works/pi-coding-agent": "1.1.0",
43
- "@earendil-works/pi-tui": "1.1.0",
42
+ "@earendil-works/pi-coding-agent": "1.3.0",
43
+ "@earendil-works/pi-tui": "1.3.0",
44
44
  "@types/node": "^24.0.0",
45
45
  "jiti": "^2.7.0",
46
46
  "typescript": "^5.9.0"