pi-pretty-tui 0.2.2 → 0.2.4

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
@@ -7,6 +7,7 @@ A beautiful, polished TUI for the [Pi coding agent](https://pi.dev/).
7
7
  ## Features
8
8
 
9
9
  - **Collapsible activity timeline:** instead of filling the conversation with individual calls, thoughts, tools, and ordinary `info` notifications share one chronological activity stream. Work appears beneath a single `Running(...)` row and settles into a quiet `Done(...)` row. Parent rows count only tool calls and thoughts; expanding one reveals every ordered member with summaries retained above nested details. Thoughts and tools are peers: a turn that calls no tool still gets its own `Running(1 thought)` / `Done(1 thought)` parent row with the Thought as its child, so thinking is never dropped.
10
+ - **Live working duration:** in clean mode, elapsed time appears beside `Working` in the prompt editor's top border, using the border's own color. It shares the agent-run clock and duration format with the completion footer, includes automatic retries, and disappears when the run settles. Narrow editors omit the timer when it cannot fit. Expanded `Done` titles and markers use the theme's `success` color; collapsed summaries remain quiet gray.
10
11
  - **Transient UI status:** one line above the editor shows direct Pi UI feedback such as Thinking/tool-output visibility changes. A newer UI status replaces it, and it disappears automatically after ten seconds without entering the transcript or session.
11
12
  - **Durable response footers:** once the complete agent run settles—including tools, retries, and compaction—the final answer ends with one quiet gray divider containing the elapsed-work time and, in fullscreen, a `[Copy]` control. It copies only the final visible answer, excluding thoughts, tools, statuses, and the footer itself; the footer survives reloads and session-tree navigation.
12
13
  - **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.
@@ -26,7 +27,7 @@ Pi may warn that built-in tools are being overridden. This is expected: pi-prett
26
27
 
27
28
  ## Working with tool calls
28
29
 
29
- Click a `Running(...)` or `Done(...)` row to keep it as a parent and reveal tools, completed `● thought` entries, and extension updates beneath it. Click an individual tool or thought to reveal nested details without replacing its summary, or click the parent again to collapse the whole group. Press `Ctrl+O` at any time to use Pi's global expansion control for all tool output and thinking content. Tool names use each definition's `label` verbatim, falling back to its raw `name`; pi-pretty-tui does not rename or title-case them.
30
+ Click a `Running(...)` or `Done(...)` row to keep it as a parent and reveal tools, completed `● thought` entries, and extension updates beneath it. Click an individual tool or thought to reveal nested details without replacing its summary. The last item in each expanded tree, `└─ [↑ Collapse]`, collapses just that group without scrolling back to its parent; clicking the parent also works. Press `Ctrl+O` at any time to use Pi's global expansion control for all tool output and thinking content. The end-of-tree action still collapses only its own group after global expansion. Third-party replacements for `grep` and `find` (such as FFF) use compact tree rows too, with their native details available when expanded. Tool names use each definition's `label` verbatim, falling back to its raw `name`; pi-pretty-tui does not rename or title-case them.
30
31
 
31
32
  Extension calls to `ctx.ui.notify(message, "info")` (or with the type omitted) join an existing thought/tool group in chronological order; without existing work they remain independent and never attach to a future group. Warnings and errors stay standalone, use yellow `⚠` and red `✕`, and are hard boundaries. Tool failures remain owned by their tool details. None of these extension notifications or errors appear in the transient UI-status line.
32
33
 
package/README.zh-CN.md CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  - **可折叠的活动时间线:** 不再让大量独立调用填满对话。思考、工具和普通 `info` 通知会进入同一条按真实时间排序的活动流;工作进行时显示为 `Running(...)`,结束后变为低调的 `Done(...)`。父级只统计工具调用和思考数量;展开后可查看所有有序成员,成员摘要在展开嵌套详情后仍然保留。思考与工具地位相同:没有调用任何工具的那一轮同样会有自己的 `Running(1 thought)` / `Done(1 thought)` 父行,思考作为其子级,内容不会被丢弃。
10
10
  - **可持久化的回答 footer:** 整轮 Agent 工作真正 settled 后(包括工具、自动重试和 compaction),最终回答底部会显示一条低调的灰色分隔线,工作时长与 fullscreen 模式下的 `[Copy]` 控件都嵌在线中。`[Copy]` 只复制最终可见回答,不包含 thought、工具、状态或 footer;reload 和 session tree 切换后仍会保留。
11
+ - **实时工作计时:** clean 模式下,耗时显示在输入框顶部边框的 `Working` 右侧,颜色与边框一致。它与完成 footer 共用 Agent 整轮工作的计时起点和时长格式,包含自动重试,工作真正结束后消失;窄输入框放不下时会隐藏计时。展开后的 `Done` 标题和圆点使用主题的 `success` 色,折叠摘要仍保持低调的灰色。
11
12
  - **临时 UI 状态:** 编辑器上方的一行只显示 Pi 直接产生的 UI 操作反馈,例如 Thinking 或工具输出可见性变化。新状态会替换旧状态,并在十秒后自动消失,不进入 transcript 或 session。
12
13
  - **按需查看有效信息:** 针对不同工具设计的摘要,会简洁呈现路径、命令、结果、diff、文件预览和实时 shell 输出,同时保留查看原始完整内容的能力。
13
14
  - **统一、精致的视觉体验:** 透明、右对齐的用户聊天气泡会根据短消息自适应宽度,长消息则在终端宽度的 75% 处换行;气泡使用 accent 浅蓝色圆角边框,并移除了多余的 `User` 标注。配合圆角输入框、主题感知配色、层级清晰的 Markdown 标题、更清爽的列表,以及带语法高亮的圆角代码块,让整个 TUI 更协调。在 fullscreen 模式下,每个围栏代码块还带有符合原生体验的 `[Copy]` 控件。
@@ -26,7 +27,7 @@ Pi 可能会提示内置工具被覆盖。这是正常现象:pi-pretty-tui 会
26
27
 
27
28
  ## 使用工具调用
28
29
 
29
- 点击 `Running(...)` 或 `Done(...)` 行,可以保留父级并展开其下方的工具、已完成的 `● thought` 和扩展更新。点击单个工具或思考子项可在保留摘要的同时展开嵌套详情,再次点击父级可折叠整个分组。任何时候都可以按 `Ctrl+O`,使用 Pi 的全局展开控制来显示或隐藏全部工具输出和思考内容。工具名称会原样使用定义中的 `label`,没有 `label` 时回退到原始 `name`;pi-pretty-tui 不再重命名或转换大小写。
30
+ 点击 `Running(...)` 或 `Done(...)` 行,可以保留父级并展开其下方的工具、已完成的 `● thought` 和扩展更新。点击单个工具或思考子项可在保留摘要的同时展开嵌套详情。每棵展开的树末尾都有 `└─ [↑ Collapse]`,无需滚回父级即可只折叠当前分组;再次点击父级也可以折叠。任何时候都可以按 `Ctrl+O`,使用 Pi 的全局展开控制来显示或隐藏全部工具输出和思考内容;全局展开后,树尾操作仍只折叠本组。第三方替换的 `grep`、`find`(如 FFF)也使用紧凑的树节点,展开子项时仍可查看原生详情。工具名称会原样使用定义中的 `label`,没有 `label` 时回退到原始 `name`;pi-pretty-tui 不再重命名或转换大小写。
30
31
 
31
32
  扩展调用 `ctx.ui.notify(message, "info")`(或省略 type)时,如果当前已有 thought/tool 活动组,通知会按真实时间顺序加入该组;如果没有现有工作,则独立显示,并且不会附着到未来的工具组。warning 和 error 保持独立,分别使用黄色 `⚠` 和红色 `✕`,并形成硬边界。工具失败继续归属于对应工具详情。上述扩展通知和错误都不会进入临时 UI 状态栏。
32
33
 
Binary file
@@ -192,6 +192,16 @@ export default function prettyTui(pi: ExtensionAPI) {
192
192
  };
193
193
  const responseAnswerTexts = new Map<string, string>();
194
194
  let responseRunStartedAt: number | undefined;
195
+ let workingElapsedTimer: ReturnType<typeof setInterval> | undefined;
196
+ const stopWorkingElapsedTimer = () => {
197
+ if (workingElapsedTimer) clearInterval(workingElapsedTimer);
198
+ workingElapsedTimer = undefined;
199
+ };
200
+ const startWorkingElapsedTimer = () => {
201
+ if (workingElapsedTimer) return;
202
+ workingElapsedTimer = setInterval(() => currentTui?.requestRender?.(), 1000);
203
+ workingElapsedTimer.unref?.();
204
+ };
195
205
  let responseHasAssistantMessage = false;
196
206
  const assistantAnswerText = (message: any): string => {
197
207
  if (typeof message?.content === "string") return message.content.trim();
@@ -203,7 +213,7 @@ export default function prettyTui(pi: ExtensionAPI) {
203
213
  .trim();
204
214
  };
205
215
  const formatResponseDuration = (durationMs: number): string => {
206
- const totalSeconds = Math.max(1, Math.round(Math.max(0, durationMs) / 1000));
216
+ const totalSeconds = Math.floor(Math.max(0, durationMs) / 1000);
207
217
  if (totalSeconds < 60) return `${totalSeconds}s`;
208
218
  const totalMinutes = Math.floor(totalSeconds / 60);
209
219
  const seconds = totalSeconds % 60;
@@ -214,6 +224,8 @@ export default function prettyTui(pi: ExtensionAPI) {
214
224
  };
215
225
  const activityTimeline = new ActivityTimeline();
216
226
  const revealedActivityGroups = new Set<string>();
227
+ // A local collapse must still work when Ctrl+O has expanded every group.
228
+ const collapsedActivityGroups = new Set<string>();
217
229
  const expandedThinkingMembers = new Set<string>();
218
230
  const thinkingComponents = new Map<string, any>();
219
231
  const activityFallbackThemes = new Map<string, any>();
@@ -342,7 +354,8 @@ export default function prettyTui(pi: ExtensionAPI) {
342
354
  };
343
355
 
344
356
  const activityGroupRevealed = (group: ActivityGroup): boolean =>
345
- cleanToolsExpanded || revealedActivityGroups.has(group.id);
357
+ !collapsedActivityGroups.has(group.id) &&
358
+ (cleanToolsExpanded || revealedActivityGroups.has(group.id));
346
359
 
347
360
  // A group is revealable once it owns anything renderable beneath its parent
348
361
  // row. Thinking counts on its own: a tool-free turn still gets a parent row
@@ -355,6 +368,67 @@ export default function prettyTui(pi: ExtensionAPI) {
355
368
  last: group.members[group.members.length - 1]?.id === member.id,
356
369
  });
357
370
 
371
+ const collapseLabel = "[↑ Collapse]";
372
+ const collapsePrefix = " └─ ";
373
+ const renderActivityCollapse = (group: ActivityGroup, width: number): string => {
374
+ const theme = activityGroupTheme(group);
375
+ return truncateToWidth(
376
+ theme.fg("dim", collapsePrefix) + theme.fg("muted", collapseLabel),
377
+ Math.max(1, width),
378
+ "",
379
+ );
380
+ };
381
+ const withActivityCollapse = (
382
+ group: ActivityGroup,
383
+ member: ActivityMember,
384
+ width: number,
385
+ lines: string[],
386
+ ): string[] => activityMemberPosition(group, member).last && activityGroupRevealed(group)
387
+ ? [...lines, renderActivityCollapse(group, width)]
388
+ : lines;
389
+ const activityCollapseClicked = (
390
+ group: ActivityGroup,
391
+ member: ActivityMember,
392
+ event: any,
393
+ row: number,
394
+ ): boolean => activityGroupRevealed(group) &&
395
+ activityMemberPosition(group, member).last &&
396
+ event.type === "click" && event.button === "left" && event.y === row &&
397
+ event.x >= visibleWidth(collapsePrefix) &&
398
+ event.x < Math.min(event.width, visibleWidth(collapsePrefix + collapseLabel));
399
+
400
+ const refreshActivityGroup = (group: ActivityGroup): void => {
401
+ for (const child of group.members) {
402
+ if (child.kind === "tool" && child.toolCallId) {
403
+ cleanToolComponents.get(child.toolCallId)?.updateDisplay?.();
404
+ } else if (child.kind === "thinking" && child.messageKey) {
405
+ thinkingComponents.get(child.messageKey)?.invalidate?.();
406
+ } else if (child.kind === "update") {
407
+ activityUpdateComponents.get(child.id)?.invalidate?.();
408
+ }
409
+ }
410
+ };
411
+ const revealActivityGroup = (group: ActivityGroup, ui?: any): void => {
412
+ collapsedActivityGroups.delete(group.id);
413
+ revealedActivityGroups.add(group.id);
414
+ for (const toolCallId of group.toolCallIds) cleanCompactToolCallIds.add(toolCallId);
415
+ refreshActivityGroup(group);
416
+ (ui ?? currentTui)?.requestRender?.();
417
+ };
418
+ const collapseActivityGroup = (group: ActivityGroup, ui?: any): void => {
419
+ revealedActivityGroups.delete(group.id);
420
+ if (cleanToolsExpanded) collapsedActivityGroups.add(group.id);
421
+ else collapsedActivityGroups.delete(group.id);
422
+ for (const toolCallId of group.toolCallIds) {
423
+ cleanCompactToolCallIds.delete(toolCallId);
424
+ const component = cleanToolComponents.get(toolCallId);
425
+ if (component?.expanded) component.setExpanded(false);
426
+ }
427
+ for (const child of group.members) expandedThinkingMembers.delete(child.id);
428
+ refreshActivityGroup(group);
429
+ (ui ?? currentTui)?.requestRender?.();
430
+ };
431
+
358
432
  const activityTreeStyle = (
359
433
  group: ActivityGroup,
360
434
  member: ActivityMember,
@@ -362,8 +436,10 @@ export default function prettyTui(pi: ExtensionAPI) {
362
436
  memberTheme?: any,
363
437
  ) => {
364
438
  const { last } = activityMemberPosition(group, member);
365
- const rawPrefix = last ? " └─ " : " ├─ ";
366
- const rawContinuation = last ? " " : " │ ";
439
+ // While expanded, the collapse action is the final sibling in the tree.
440
+ const hasFollowingCollapse = last && activityGroupRevealed(group);
441
+ const rawPrefix = last && !hasFollowingCollapse ? " └─ " : " ├─ ";
442
+ const rawContinuation = last && !hasFollowingCollapse ? " " : " │ ";
367
443
  const prefixWidth = visibleWidth(rawPrefix);
368
444
  if (width <= prefixWidth) {
369
445
  return { prefix: "", continuation: "", childWidth: Math.max(1, width), prefixWidth: 0 };
@@ -450,9 +526,10 @@ export default function prettyTui(pi: ExtensionAPI) {
450
526
  truncateToWidth((index === 0 ? prefix : continuation) + line, Math.max(1, width), "")
451
527
  );
452
528
  const position = activityMemberPosition(group, member);
453
- return position.first
454
- ? ["", ...renderActivityGroupSummary(group, width), ...decorated]
455
- : decorated;
529
+ return withActivityCollapse(
530
+ group, member, width,
531
+ position.first ? ["", ...renderActivityGroupSummary(group, width), ...decorated] : decorated,
532
+ );
456
533
  };
457
534
 
458
535
  const renderPendingActivityUpdate = (member: ActivityMember, width: number): string[] => {
@@ -495,29 +572,16 @@ export default function prettyTui(pi: ExtensionAPI) {
495
572
  const position = activityMemberPosition(group, member);
496
573
  if (!activityGroupRevealed(group)) {
497
574
  if (!position.first) return undefined;
498
- revealedActivityGroups.add(group.id);
499
- for (const toolCallId of group.toolCallIds) cleanCompactToolCallIds.add(toolCallId);
500
- } else {
501
- const summaryHeight = position.first ? renderActivityGroupSummary(group, event.width).length : 0;
502
- if (!position.first || event.y <= 0 || event.y > summaryHeight) return undefined;
503
- revealedActivityGroups.delete(group.id);
504
- for (const toolCallId of group.toolCallIds) {
505
- cleanCompactToolCallIds.delete(toolCallId);
506
- const component = cleanToolComponents.get(toolCallId);
507
- if (component?.expanded) component.setExpanded(false);
508
- }
509
- for (const child of group.members) expandedThinkingMembers.delete(child.id);
510
- }
511
- for (const child of group.members) {
512
- if (child.kind === "tool" && child.toolCallId) {
513
- cleanToolComponents.get(child.toolCallId)?.updateDisplay?.();
514
- } else if (child.kind === "thinking" && child.messageKey) {
515
- thinkingComponents.get(child.messageKey)?.invalidate?.();
516
- } else if (child.kind === "update") {
517
- activityUpdateComponents.get(child.id)?.invalidate?.();
518
- }
575
+ revealActivityGroup(group);
576
+ return { handled: true };
519
577
  }
520
- currentTui?.requestRender?.();
578
+ const summaryHeight = position.first ? renderActivityGroupSummary(group, event.width).length : 0;
579
+ const onSummary = position.first && event.y > 0 && event.y <= summaryHeight;
580
+ const onCollapse = position.last && activityCollapseClicked(
581
+ group, member, event, renderActivityUpdate(group, member, event.width).length - 1,
582
+ );
583
+ if (!onSummary && !onCollapse) return undefined;
584
+ collapseActivityGroup(group);
521
585
  return { handled: true };
522
586
  };
523
587
 
@@ -606,10 +670,16 @@ export default function prettyTui(pi: ExtensionAPI) {
606
670
 
607
671
  cleanCompactToolCallIds.clear();
608
672
  revealedActivityGroups.clear();
673
+ collapsedActivityGroups.clear();
609
674
  expandedThinkingMembers.clear();
610
675
  renderMode = requested;
611
- if (renderMode === "clean") refreshLatestActivityWidget();
612
- else clearLatestActivityStatus();
676
+ if (renderMode === "clean") {
677
+ if (cleanRun.active && !cleanRun.settled) startWorkingElapsedTimer();
678
+ refreshLatestActivityWidget();
679
+ } else {
680
+ stopWorkingElapsedTimer();
681
+ clearLatestActivityStatus();
682
+ }
613
683
  for (const component of cleanToolComponents.values()) component.updateDisplay?.();
614
684
  for (const component of thinkingComponents.values()) component.invalidate?.();
615
685
  try {
@@ -625,6 +695,7 @@ export default function prettyTui(pi: ExtensionAPI) {
625
695
  // Keep the settings command available while disabled, but do not install
626
696
  // render patches or override built-in tools until the next enabled reload.
627
697
  if (!activeForSession) return;
698
+ pi.on("session_shutdown", stopWorkingElapsedTimer);
628
699
 
629
700
  // Give Pi's main prompt editor a complete rounded frame. Render the native
630
701
  // editor at a two-column narrower width so cursor layout, wrapping, IME, and
@@ -635,7 +706,30 @@ export default function prettyTui(pi: ExtensionAPI) {
635
706
  const hadOwnRender = Object.prototype.hasOwnProperty.call(customEditorPrototype, "render");
636
707
  const hadOwnHandleMouse = Object.prototype.hasOwnProperty.call(customEditorPrototype, "handleMouse");
637
708
  const originalEditorRender = customEditorPrototype.render;
709
+ const originalEditorTopBorder = customEditorPrototype.renderTopBorder;
638
710
  const originalEditorHandleMouse = customEditorPrototype.handleMouse;
711
+ const patchedEditorTopBorder = function (this: any, width: number, hiddenLineCount: number): string {
712
+ if (this.tui) currentTui = this.tui;
713
+ const indicator = this.workingStatusIndicator;
714
+ if (renderMode !== "clean" || indicator?.kind !== "working" || responseRunStartedAt === undefined) {
715
+ return originalEditorTopBorder.call(this, width, hiddenLineCount);
716
+ }
717
+ const elapsed = formatResponseDuration(Date.now() - responseRunStartedAt);
718
+ const indicatorColor = (text: string) => this.borderColor(text);
719
+ const originalRenderInBorder = indicator.renderInBorder;
720
+ indicator.renderInBorder = function (availableWidth: number): string {
721
+ const status = originalRenderInBorder.call(this, availableWidth);
722
+ const suffix = ` ${elapsed}`;
723
+ return visibleWidth(status) + visibleWidth(suffix) <= availableWidth
724
+ ? status + indicatorColor(suffix)
725
+ : status;
726
+ };
727
+ try {
728
+ return originalEditorTopBorder.call(this, width, hiddenLineCount);
729
+ } finally {
730
+ indicator.renderInBorder = originalRenderInBorder;
731
+ }
732
+ };
639
733
  const patchedEditorRender = function (this: any, width: number): string[] {
640
734
  if (width < 3) return originalEditorRender.call(this, width);
641
735
 
@@ -669,11 +763,14 @@ export default function prettyTui(pi: ExtensionAPI) {
669
763
  hadOwnRender,
670
764
  hadOwnHandleMouse,
671
765
  originalRender: originalEditorRender,
766
+ originalTopBorder: originalEditorTopBorder,
672
767
  originalHandleMouse: originalEditorHandleMouse,
673
768
  patchedRender: patchedEditorRender,
769
+ patchedTopBorder: patchedEditorTopBorder,
674
770
  patchedHandleMouse: patchedEditorHandleMouse,
675
771
  };
676
772
  customEditorPrototype.render = patchedEditorRender;
773
+ customEditorPrototype.renderTopBorder = patchedEditorTopBorder;
677
774
  customEditorPrototype.handleMouse = patchedEditorHandleMouse;
678
775
 
679
776
  pi.on("session_shutdown", () => {
@@ -683,12 +780,16 @@ export default function prettyTui(pi: ExtensionAPI) {
683
780
  if (patch.hadOwnRender) customEditorPrototype.render = patch.originalRender;
684
781
  else delete customEditorPrototype.render;
685
782
  }
783
+ if (patch.patchedTopBorder === customEditorPrototype.renderTopBorder) {
784
+ customEditorPrototype.renderTopBorder = patch.originalTopBorder;
785
+ }
686
786
  if (patch.patchedHandleMouse === customEditorPrototype.handleMouse) {
687
787
  if (patch.hadOwnHandleMouse) customEditorPrototype.handleMouse = patch.originalHandleMouse;
688
788
  else delete customEditorPrototype.handleMouse;
689
789
  }
690
790
  if (
691
791
  customEditorPrototype.render === patch.originalRender &&
792
+ customEditorPrototype.renderTopBorder === patch.originalTopBorder &&
692
793
  customEditorPrototype.handleMouse === patch.originalHandleMouse
693
794
  ) {
694
795
  delete customEditorPrototype[editorFramePatchKey];
@@ -1015,15 +1116,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1015
1116
  );
1016
1117
 
1017
1118
  const refreshGroup = (group: ActivityGroup) => {
1018
- for (const member of group.members) {
1019
- if (member.kind === "tool" && member.toolCallId) {
1020
- cleanToolComponents.get(member.toolCallId)?.updateDisplay?.();
1021
- } else if (member.kind === "thinking" && member.messageKey) {
1022
- thinkingComponents.get(member.messageKey)?.invalidate?.();
1023
- } else if (member.kind === "update") {
1024
- activityUpdateComponents.get(member.id)?.invalidate?.();
1025
- }
1026
- }
1119
+ refreshActivityGroup(group);
1027
1120
  currentTui?.requestRender?.();
1028
1121
  };
1029
1122
 
@@ -1189,9 +1282,10 @@ export default function prettyTui(pi: ExtensionAPI) {
1189
1282
  const decorated = contentLines.map((line, index) =>
1190
1283
  truncateToWidth((index === 0 ? prefix : continuation) + line, Math.max(1, width), "")
1191
1284
  );
1192
- const output = position.first
1193
- ? ["", ...renderActivityGroupSummary(group, width), ...decorated]
1194
- : decorated;
1285
+ const output = withActivityCollapse(
1286
+ group, member, width,
1287
+ position.first ? ["", ...renderActivityGroupSummary(group, width), ...decorated] : decorated,
1288
+ );
1195
1289
  if (cacheable) {
1196
1290
  this[cleanThinkingRenderCacheKey] = {
1197
1291
  width,
@@ -1232,10 +1326,11 @@ export default function prettyTui(pi: ExtensionAPI) {
1232
1326
  // visible Markdown. The Markdown is rendered after the activity rows;
1233
1327
  // route clicks in that suffix back to Pi's native component so links,
1234
1328
  // text selection, and code-block Copy controls keep working.
1329
+ let activityHeight: number | undefined;
1235
1330
  if (visibleAssistantText(message)) {
1236
1331
  const visibleLines = originalRender.call(this, event.width);
1237
1332
  const renderedLines = patchedRender.call(this, event.width);
1238
- const activityHeight = Math.max(0, renderedLines.length - visibleLines.length);
1333
+ activityHeight = Math.max(0, renderedLines.length - visibleLines.length);
1239
1334
  if (event.y >= activityHeight) {
1240
1335
  return originalHandleMouse.call(this, {
1241
1336
  ...event,
@@ -1247,25 +1342,21 @@ export default function prettyTui(pi: ExtensionAPI) {
1247
1342
 
1248
1343
  if (!activityGroupRevealed(group)) {
1249
1344
  if (!isLeftClick) return undefined;
1250
- if (position.first) {
1251
- revealedActivityGroups.add(group.id);
1252
- for (const toolCallId of group.toolCallIds) cleanCompactToolCallIds.add(toolCallId);
1253
- refreshGroup(group);
1254
- }
1345
+ if (position.first) revealActivityGroup(group);
1255
1346
  return { handled: true };
1256
1347
  }
1257
1348
  const summaryHeight = position.first ? renderActivityGroupSummary(group, event.width).length : 0;
1258
- if (position.first && event.y > 0 && event.y <= summaryHeight && isLeftClick) {
1259
- revealedActivityGroups.delete(group.id);
1260
- for (const toolCallId of group.toolCallIds) {
1261
- cleanCompactToolCallIds.delete(toolCallId);
1262
- const component = cleanToolComponents.get(toolCallId);
1263
- if (component?.expanded) component.setExpanded(false);
1264
- }
1265
- for (const child of group.members) expandedThinkingMembers.delete(child.id);
1266
- refreshGroup(group);
1349
+ const collapseRow = position.last
1350
+ ? (activityHeight ?? patchedRender.call(this, event.width).length) - 1
1351
+ : -1;
1352
+ if (
1353
+ (position.first && event.y > 0 && event.y <= summaryHeight && isLeftClick) ||
1354
+ activityCollapseClicked(group, member, event, collapseRow)
1355
+ ) {
1356
+ collapseActivityGroup(group);
1267
1357
  return { handled: true };
1268
1358
  }
1359
+ if (position.last && event.y === collapseRow) return undefined;
1269
1360
  if (isLeftClick) {
1270
1361
  if (expandedThinkingMembers.has(member.id)) expandedThinkingMembers.delete(member.id);
1271
1362
  else expandedThinkingMembers.add(member.id);
@@ -1390,6 +1481,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1390
1481
  const originalShowError = interactiveModePrototype.showError;
1391
1482
  const patchedSetToolsExpanded = function (this: any, expanded: boolean) {
1392
1483
  cleanToolsExpanded = expanded;
1484
+ collapsedActivityGroups.clear();
1393
1485
  changingAllToolsExpansion = true;
1394
1486
  if (!expanded) {
1395
1487
  cleanCompactToolCallIds.clear();
@@ -2426,32 +2518,8 @@ export default function prettyTui(pi: ExtensionAPI) {
2426
2518
  const originalToolHandleMouse = toolExecutionPrototype.handleMouse;
2427
2519
  const renderedModeKey = Symbol("pretty-tui.tool-rendered-mode");
2428
2520
  const cleanChildRenderCacheKey = Symbol("pretty-tui.clean-child-render-cache");
2429
- const refreshActivityGroup = (group: ActivityGroup) => {
2430
- for (const member of group.members) {
2431
- if (member.kind === "tool" && member.toolCallId) {
2432
- cleanToolComponents.get(member.toolCallId)?.updateDisplay();
2433
- } else if (member.kind === "thinking" && member.messageKey) {
2434
- thinkingComponents.get(member.messageKey)?.invalidate?.();
2435
- }
2436
- }
2437
- };
2438
- const revealCleanGroup = (group: ActivityGroup, ui?: any) => {
2439
- revealedActivityGroups.add(group.id);
2440
- for (const toolCallId of group.toolCallIds) cleanCompactToolCallIds.add(toolCallId);
2441
- refreshActivityGroup(group);
2442
- ui?.requestRender?.();
2443
- };
2444
- const collapseCleanGroup = (group: ActivityGroup, ui?: any) => {
2445
- revealedActivityGroups.delete(group.id);
2446
- for (const toolCallId of group.toolCallIds) {
2447
- const component = cleanToolComponents.get(toolCallId);
2448
- if (component?.expanded) originalSetExpanded.call(component, false);
2449
- cleanCompactToolCallIds.delete(toolCallId);
2450
- }
2451
- for (const member of group.members) expandedThinkingMembers.delete(member.id);
2452
- refreshActivityGroup(group);
2453
- ui?.requestRender?.();
2454
- };
2521
+ const revealCleanGroup = revealActivityGroup;
2522
+ const collapseCleanGroup = collapseActivityGroup;
2455
2523
  const patchedMarkExecutionStarted = function (this: any) {
2456
2524
  activityTimeline.addTool(this.toolCallId, this.toolName);
2457
2525
  if (!cleanRun.currentToolCallIds.includes(this.toolCallId)) {
@@ -2512,7 +2580,11 @@ export default function prettyTui(pi: ExtensionAPI) {
2512
2580
  continuation,
2513
2581
  childWidth,
2514
2582
  } = activityTreeStyle(group, member, width, childTheme);
2515
- const thirdParty = !SPECIALIZED_TOOL_NAMES.has(this.toolName);
2583
+ // Other extensions (notably FFF in override mode) can register their own
2584
+ // grep/find renderers under built-in names. Their default boxed shell is
2585
+ // not one of our self-rendering specialized tools.
2586
+ const thirdParty = !SPECIALIZED_TOOL_NAMES.has(this.toolName) ||
2587
+ this.toolDefinition?.renderShell !== "self";
2516
2588
  const cacheable = !this.expanded && Boolean(this.result) && !this.isPartial;
2517
2589
  const cached = cacheable ? this[cleanChildRenderCacheKey] : undefined;
2518
2590
  let decoratedContent: string[];
@@ -2578,8 +2650,10 @@ export default function prettyTui(pi: ExtensionAPI) {
2578
2650
  };
2579
2651
  }
2580
2652
  }
2581
- if (!position.first) return decoratedContent;
2582
- return ["", ...renderActivityGroupSummary(group, width), ...decoratedContent];
2653
+ return withActivityCollapse(
2654
+ group, member, width,
2655
+ position.first ? ["", ...renderActivityGroupSummary(group, width), ...decoratedContent] : decoratedContent,
2656
+ );
2583
2657
  };
2584
2658
  const patchedToolHandleMouse = function (this: any, event: any) {
2585
2659
  const member = activityTimeline.memberForTool(this.toolCallId);
@@ -2598,7 +2672,11 @@ export default function prettyTui(pi: ExtensionAPI) {
2598
2672
  if (!revealed) return isLeftClick ? { handled: true } : undefined;
2599
2673
 
2600
2674
  const summaryHeight = position.first ? renderActivityGroupSummary(group, event.width).length : 0;
2601
- if (position.first && event.y > 0 && event.y <= summaryHeight && isLeftClick) {
2675
+ const onSummary = position.first && event.y > 0 && event.y <= summaryHeight && isLeftClick;
2676
+ const onCollapse = position.last && activityCollapseClicked(
2677
+ group, member, event, patchedToolRender.call(this, event.width).length - 1,
2678
+ );
2679
+ if (onSummary || onCollapse) {
2602
2680
  changingAllToolsExpansion = true;
2603
2681
  try {
2604
2682
  collapseCleanGroup(group, this.ui);
@@ -2612,7 +2690,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2612
2690
  // replaces that spacer with the compact header for non-first members.
2613
2691
  const thirdPartyHeaderY = position.first ? summaryHeight + 1 : 0;
2614
2692
  if (
2615
- !SPECIALIZED_TOOL_NAMES.has(this.toolName) &&
2693
+ (!SPECIALIZED_TOOL_NAMES.has(this.toolName) || this.toolDefinition?.renderShell !== "self") &&
2616
2694
  isLeftClick &&
2617
2695
  event.y === thirdPartyHeaderY
2618
2696
  ) {
@@ -2766,7 +2844,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2766
2844
  prefix: () => {
2767
2845
  const currentActivity = typeof activity === "function" ? activity() : activity;
2768
2846
  const color = currentActivity === "done"
2769
- ? collapsedDone ? "thinkingText" : successMarkerColor(theme)
2847
+ ? collapsedDone ? "thinkingText" : "success"
2770
2848
  : "accent";
2771
2849
  return theme.fg(color, "● ");
2772
2850
  },
@@ -2775,7 +2853,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2775
2853
  const currentActivity = typeof activity === "function" ? activity() : activity;
2776
2854
  const label = currentActivity === "done" ? "Done" : "Running";
2777
2855
  const color = label === "Done"
2778
- ? collapsedDone ? "thinkingText" : successMarkerColor(theme)
2856
+ ? collapsedDone ? "thinkingText" : "success"
2779
2857
  : "accent";
2780
2858
  const detailColor = label === "Done" && collapsedDone ? "thinkingText" : "text";
2781
2859
  return theme.fg(color, theme.bold(label)) +
@@ -3063,6 +3141,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3063
3141
  );
3064
3142
 
3065
3143
  const restoreCleanSession = (ctx: any) => {
3144
+ stopWorkingElapsedTimer();
3066
3145
  currentExtensionUi = ctx.ui;
3067
3146
  clearLatestActivityStatus();
3068
3147
  cleanToolsExpanded = ctx.ui.getToolsExpanded();
@@ -3074,6 +3153,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3074
3153
  responseHasAssistantMessage = false;
3075
3154
  nativeCustomBoundaryKeys.clear();
3076
3155
  revealedActivityGroups.clear();
3156
+ collapsedActivityGroups.clear();
3077
3157
  expandedThinkingMembers.clear();
3078
3158
  thinkingComponents.clear();
3079
3159
  activityFallbackThemes.clear();
@@ -3486,6 +3566,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3486
3566
  cleanRun.activeToolCallId = undefined;
3487
3567
  cleanRun.activeToolName = undefined;
3488
3568
  cleanRun.activity = "thinking";
3569
+ if (renderMode === "clean") startWorkingElapsedTimer();
3489
3570
  });
3490
3571
  pi.on("tool_execution_start", (event) => {
3491
3572
  activityTimeline.addTool(event.toolCallId, event.toolName);
@@ -3535,6 +3616,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3535
3616
  cleanRun.activity = "thinking";
3536
3617
  });
3537
3618
  pi.on("agent_settled", (_event, ctx) => {
3619
+ stopWorkingElapsedTimer();
3538
3620
  if (cleanRun.settled) return;
3539
3621
 
3540
3622
  clearPendingToolActivities();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pretty-tui",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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>",
@@ -52,6 +52,6 @@
52
52
  "extensions": [
53
53
  "./extensions/index.ts"
54
54
  ],
55
- "image": "https://unpkg.com/pi-pretty-tui@0.2.0/assets/screenshot.png"
55
+ "image": "https://raw.githubusercontent.com/ykn0309/pi-pretty-tui/main/assets/screenshot.png"
56
56
  }
57
57
  }