pi-my-compact-ui 0.2.0 → 0.2.1

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.
Files changed (2) hide show
  1. package/index.ts +135 -35
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -632,23 +632,38 @@ class ToolGroupComponent extends Container {
632
632
  if ((tool as any)?.[PARENT_KEY] === this) delete (tool as any)[PARENT_KEY];
633
633
  }
634
634
 
635
+ /** Return only entries that should be displayed (tools, or thinking with non-empty text). */
636
+ getVisibleEntries(): GroupEntry[] {
637
+ return this.entries.filter((entry) => {
638
+ if (entry.kind === "tool") return true;
639
+ if (entry.kind === "thinking") {
640
+ return entry.text.trim().length > 0;
641
+ }
642
+ return false;
643
+ });
644
+ }
645
+
646
+ hasVisibleEntries(): boolean {
647
+ return this.getVisibleEntries().length > 0;
648
+ }
649
+
635
650
  hasPending(): boolean {
636
- return this.children.some((tool) => toolStatus(tool) === "pending");
651
+ return this.getVisibleEntries().some((entry) => entry.kind === "tool" && toolStatus(entry.tool) === "pending");
637
652
  }
638
653
 
639
654
  /** True while a thinking run in this group is still streaming. */
640
655
  hasActiveThinking(): boolean {
641
- return this.entries.some((entry) => entry.kind === "thinking" && entry.active);
656
+ return this.getVisibleEntries().some((entry) => entry.kind === "thinking" && entry.active);
642
657
  }
643
658
 
644
659
  /** True once any thinking run has been added to this group. */
645
660
  hasThinking(): boolean {
646
- return this.entries.some((entry) => entry.kind === "thinking");
661
+ return this.getVisibleEntries().some((entry) => entry.kind === "thinking");
647
662
  }
648
663
 
649
664
  /** True while this group should keep its spinner animating. */
650
665
  needsAnimation(): boolean {
651
- return this.hasPending() || this.hasActiveThinking();
666
+ return this.hasVisibleEntries() && (this.hasPending() || this.hasActiveThinking());
652
667
  }
653
668
 
654
669
  override invalidate(): void {
@@ -711,10 +726,8 @@ class ToolGroupComponent extends Container {
711
726
  const bold = theme?.bold ? theme.bold : (t: string) => t;
712
727
  const st = toolStatus(tool);
713
728
  const s = toolSummary(tool.toolName, tool.args);
714
- return `${fg("dim", rail)}${fg(this.colorFor(st), this.iconFor(tool, frame))} ${fg("toolTitle", bold(s.name))} ${fg("dim", s.content)} ${fg("muted", `(${toolElapsed(tool)}s)`)}`;
715
- }
716
- private thinkingActiveInGroup(): boolean {
717
- return !this.sealed && this.hasActiveThinking();
729
+ const prefix = rail ? fg("dim", rail) : "";
730
+ return `${prefix}${fg(this.colorFor(st), this.iconFor(tool, frame))} ${fg("toolTitle", bold(s.name))} ${fg("dim", s.content)} ${fg("muted", `(${toolElapsed(tool)}s)`)}`;
718
731
  }
719
732
 
720
733
  // One row per thinking run. The spinner marks the run that is still streaming;
@@ -724,28 +737,37 @@ class ToolGroupComponent extends Container {
724
737
  const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
725
738
  const icon = entry.active ? frame : "✓";
726
739
  const preview = entry.text.trim().replace(/[*_#`>]+/g, "");
740
+ const prefix = rail ? fg("dim", rail) : "";
727
741
  if (!preview) {
728
- return `${fg("dim", rail)}${fg("thinkingText", icon)} ${fg("thinkingText", "thinking...")}`;
742
+ const label = entry.active ? "thinking..." : "thinking";
743
+ return `${prefix}${fg("thinkingText", icon)} ${fg("thinkingText", label)}`;
729
744
  }
730
745
  const tokenLabel = thinkingTokenLabel(entry);
731
- const previewLimit = Math.max(1, Math.min(60, width - GROUP_PADDING_X - 18 - tokenLabel.length));
732
- return `${fg("dim", rail)}${fg("thinkingText", icon)} ${fg("thinkingText", `thinking: ${oneLine(preview, previewLimit)}`)} ${fg("muted", `· ${tokenLabel}`)}`;
746
+ const previewLimit = Math.max(1, Math.min(60, width - GROUP_PADDING_X - rail.length - 18 - tokenLabel.length));
747
+ return `${prefix}${fg("thinkingText", icon)} ${fg("thinkingText", `thinking: ${oneLine(preview, previewLimit)}`)} ${fg("muted", `· ${tokenLabel}`)}`;
733
748
  }
734
749
 
735
750
  // Folded: header + one line per entry, in stream order (oldest first).
736
- private renderCollapsed(width: number): string[] {
751
+ // When there is only 1 visible entry, do not show hierarchy (no header, no tree rail).
752
+ private renderCollapsed(width: number, visible: GroupEntry[]): string[] {
737
753
  const theme = currentTheme;
738
754
  const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
739
755
  const frame = SPINNER[Math.floor((Date.now() - spinnerStart) / SPINNER_MS) % SPINNER.length]!;
756
+
757
+ if (visible.length === 1) {
758
+ const entry = visible[0]!;
759
+ if (entry.kind === "tool") {
760
+ return [this.toolRow("", entry.tool, frame)];
761
+ }
762
+ return [this.thinkingRow("", entry, frame, width)];
763
+ }
764
+
740
765
  const lines: string[] = [];
741
766
 
742
- const hasPendingTool = this.hasPending();
743
- const isThinking = this.thinkingActiveInGroup();
744
- // An open block with no tools yet is still "thinking" (waiting for tools or
745
- // a text seal); a sealed block without tools is a finished thinking-only
746
- // block and must not claim "tools done".
747
- const hasTools = this.children.length > 0;
748
- const hasThinking = this.hasThinking();
767
+ const hasPendingTool = visible.some((e) => e.kind === "tool" && toolStatus(e.tool) === "pending");
768
+ const isThinking = !this.sealed && visible.some((e) => e.kind === "thinking" && e.active);
769
+ const hasTools = visible.some((e) => e.kind === "tool");
770
+ const hasThinking = visible.some((e) => e.kind === "thinking");
749
771
  const openNoTools = !this.sealed && !hasTools;
750
772
  const working = hasPendingTool || isThinking || openNoTools;
751
773
  const state = hasPendingTool
@@ -765,9 +787,9 @@ class ToolGroupComponent extends Container {
765
787
  lines.push(`${fg(stateColor, leftIcon)} ${fg(stateColor, state)}`);
766
788
 
767
789
  // Every entry is shown, one line each, in the order the model produced it.
768
- const total = this.entries.length;
790
+ const total = visible.length;
769
791
  for (let index = 0; index < total; index++) {
770
- const entry = this.entries[index]!;
792
+ const entry = visible[index]!;
771
793
  const rail = index === total - 1 ? "└ " : "│ ";
772
794
  lines.push(
773
795
  entry.kind === "thinking"
@@ -776,23 +798,73 @@ class ToolGroupComponent extends Container {
776
798
  );
777
799
  }
778
800
 
779
- if (this.needsAnimation()) scheduleAnimation();
780
801
  return lines;
781
802
  }
782
803
 
783
804
  // Expanded: per-tool detail + every thinking run, in stream order.
784
- private renderExpanded(width: number): string[] {
805
+ // When there is only 1 visible entry, do not show hierarchy (no header, no tree rail).
806
+ private renderExpanded(width: number, visible: GroupEntry[]): string[] {
785
807
  const theme = currentTheme;
786
808
  const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
787
809
  const frame = SPINNER[Math.floor((Date.now() - spinnerStart) / SPINNER_MS) % SPINNER.length]!;
788
810
  const lines: string[] = [];
789
811
 
790
- const hasPendingTool = this.hasPending();
791
- const isThinking = this.thinkingActiveInGroup();
812
+ if (visible.length === 1) {
813
+ const entry = visible[0]!;
814
+ const sub = " ";
815
+ if (entry.kind === "tool") {
816
+ const tool = entry.tool;
817
+ lines.push(this.toolRow("", tool, frame));
818
+ const result = toolResultText(tool);
819
+ if (result) {
820
+ const markdownWidth = Math.max(1, width - GROUP_PADDING_X - sub.length);
821
+ const preview = this.renderMarkdownPreview(
822
+ `tool:${tool.toolCallId ?? 0}`,
823
+ result,
824
+ markdownWidth,
825
+ config.expandedToolLines,
826
+ { color: (text) => currentTheme?.fg?.("toolOutput", text) ?? text },
827
+ );
828
+ for (const row of preview.lines) {
829
+ lines.push(`${fg("dim", sub)}${row}`);
830
+ }
831
+ if (preview.truncated) {
832
+ lines.push(`${fg("dim", sub)}${fg("muted", "…")}`);
833
+ }
834
+ }
835
+ return lines;
836
+ }
837
+
838
+ const icon = entry.active ? frame : "✓";
839
+ lines.push(
840
+ `${fg("thinkingText", icon)} ${fg("thinkingText", "thinking")} ${fg("muted", `· ${thinkingTokenLabel(entry)}`)}`,
841
+ );
842
+ const tText = entry.text.trim();
843
+ if (tText) {
844
+ const markdownWidth = Math.max(1, width - GROUP_PADDING_X - sub.length);
845
+ const preview = this.renderMarkdownPreview(
846
+ `thinking:${entry.id}`,
847
+ tText,
848
+ markdownWidth,
849
+ config.expandedThinkingLines,
850
+ { color: (text) => currentTheme?.fg?.("thinkingText", text) ?? text, italic: true },
851
+ );
852
+ for (const row of preview.lines) {
853
+ lines.push(`${fg("dim", sub)}${row}`);
854
+ }
855
+ if (preview.truncated) {
856
+ lines.push(`${fg("dim", sub)}${fg("muted", "…")}`);
857
+ }
858
+ }
859
+ return lines;
860
+ }
861
+
862
+ const hasPendingTool = visible.some((e) => e.kind === "tool" && toolStatus(e.tool) === "pending");
863
+ const isThinking = !this.sealed && visible.some((e) => e.kind === "thinking" && e.active);
792
864
  // See renderCollapsed: a block without tools keeps the thinking label, and a
793
865
  // block with reasoning must not claim "tools done" alone.
794
- const hasTools = this.children.length > 0;
795
- const hasThinking = this.hasThinking();
866
+ const hasTools = visible.some((e) => e.kind === "tool");
867
+ const hasThinking = visible.some((e) => e.kind === "thinking");
796
868
  const openNoTools = !this.sealed && !hasTools;
797
869
  const working = hasPendingTool || isThinking || openNoTools;
798
870
  const state = hasPendingTool
@@ -811,9 +883,9 @@ class ToolGroupComponent extends Container {
811
883
  const leftIcon = working ? frame : "✓";
812
884
  lines.push(`${fg(stateColor, leftIcon)} ${fg(stateColor, state)}`);
813
885
 
814
- const total = this.entries.length;
886
+ const total = visible.length;
815
887
  for (let index = 0; index < total; index++) {
816
- const entry = this.entries[index]!;
888
+ const entry = visible[index]!;
817
889
  const isLast = index === total - 1;
818
890
  const rail = isLast ? "└─ " : "├─ ";
819
891
  const sub = isLast ? " " : "│ ";
@@ -862,12 +934,17 @@ class ToolGroupComponent extends Container {
862
934
  }
863
935
  }
864
936
 
865
- if (this.needsAnimation()) scheduleAnimation();
866
937
  return lines;
867
938
  }
868
939
 
869
940
  override render(width: number): string[] {
870
- const lines = this._expanded ? this.renderExpanded(width) : this.renderCollapsed(width);
941
+ const visible = this.getVisibleEntries();
942
+ if (visible.length === 0) return [];
943
+
944
+ if (this.needsAnimation()) scheduleAnimation();
945
+
946
+ const lines = this._expanded ? this.renderExpanded(width, visible) : this.renderCollapsed(width, visible);
947
+ if (lines.length === 0) return [];
871
948
  // Indent compact blocks from the transcript edge while keeping every line
872
949
  // within the terminal width (including mobile / narrow terminals).
873
950
  const padding = " ".repeat(Math.min(GROUP_PADDING_X, Math.max(0, width - 1)));
@@ -966,8 +1043,13 @@ function flushPendingTextSeal(): void {
966
1043
  // visible text. Anchor it immediately before this text block so the visual
967
1044
  // component order matches the stream order:
968
1045
  // group -> visible text -> next group -> next visible text
969
- if (pendingTextOrdinal !== null) {
970
- anchorGroupBeforeCurrentText(lastActiveGroup, pendingTextOrdinal);
1046
+ if (lastActiveGroup.hasVisibleEntries()) {
1047
+ if (pendingTextOrdinal !== null) {
1048
+ anchorGroupBeforeCurrentText(lastActiveGroup, pendingTextOrdinal);
1049
+ }
1050
+ } else {
1051
+ if (lastChatContainer) removeGroupFromContainer(lastChatContainer, lastActiveGroup);
1052
+ groups.delete(lastActiveGroup);
971
1053
  }
972
1054
  lastActiveGroup.sealed = true;
973
1055
  finalizeActiveThinking();
@@ -1139,7 +1221,7 @@ function insertTurnDivider(elapsedMs: number): void {
1139
1221
  // group). A plain text-only answer gets no divider.
1140
1222
  const hasPriorContent = contentContainer.children
1141
1223
  .slice(0, finalIndex)
1142
- .some((child) => child instanceof ToolGroupComponent);
1224
+ .some((child) => child instanceof ToolGroupComponent && child.hasVisibleEntries());
1143
1225
  if (!hasPriorContent) return;
1144
1226
 
1145
1227
  const state = getAssistantContentState(contentContainer);
@@ -1183,6 +1265,7 @@ function placeAnchoredGroupBeforeText(container: Container, target: Markdown, gr
1183
1265
  }
1184
1266
 
1185
1267
  function insertAnchoredGroup(container: Container, ordinal: number, group: ToolGroupComponent): void {
1268
+ if (!group.hasVisibleEntries()) return;
1186
1269
  const markdowns = container.children.filter(isVisibleTextMarkdown);
1187
1270
  const target = markdowns[ordinal];
1188
1271
  if (!target) return;
@@ -1220,6 +1303,11 @@ function anchorGroupBeforeCurrentText(group: ToolGroupComponent, ordinal: number
1220
1303
  removeGroupFromContainer(previousAnchor.container, group);
1221
1304
  }
1222
1305
 
1306
+ if (!group.hasVisibleEntries()) {
1307
+ groups.delete(group);
1308
+ return;
1309
+ }
1310
+
1223
1311
  const state = getAssistantContentState(contentContainer);
1224
1312
  const replaced = state.anchors.get(ordinal);
1225
1313
  if (replaced && replaced !== group) {
@@ -1240,7 +1328,7 @@ function restoreAssistantAnchor(parent: any, component: any): void {
1240
1328
  const state = getAssistantContentState(parent);
1241
1329
  const ordinal = state.nextTextOrdinal++;
1242
1330
  const group = state.anchors.get(ordinal);
1243
- if (group) {
1331
+ if (group && group.hasVisibleEntries()) {
1244
1332
  removeGroupFromContainer(parent, group);
1245
1333
  placeAnchoredGroupBeforeText(parent, component, group);
1246
1334
  }
@@ -1464,6 +1552,12 @@ export default function (pi: ExtensionAPI) {
1464
1552
  lastActiveGroup.invalidate();
1465
1553
  }
1466
1554
  if (role === "user") {
1555
+ for (const g of [...groups]) {
1556
+ if (!g.hasVisibleEntries()) {
1557
+ if (lastChatContainer) removeGroupFromContainer(lastChatContainer, g);
1558
+ groups.delete(g);
1559
+ }
1560
+ }
1467
1561
  turnStartMs = Date.now();
1468
1562
  activeThinking = null;
1469
1563
  activeThinkingIndex = null;
@@ -1567,6 +1661,12 @@ export default function (pi: ExtensionAPI) {
1567
1661
  finalizeActiveThinking();
1568
1662
  lastActiveGroup.invalidate();
1569
1663
  }
1664
+ for (const g of [...groups]) {
1665
+ if (!g.hasVisibleEntries()) {
1666
+ if (lastChatContainer) removeGroupFromContainer(lastChatContainer, g);
1667
+ groups.delete(g);
1668
+ }
1669
+ }
1570
1670
  refreshSealedThinkingTokens();
1571
1671
  // Separate the final visible text from the preceding work with a divider
1572
1672
  // that reports how long this turn ran.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-my-compact-ui",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Compact tree-shaped reasoning and tool-call UI for the Pi Coding Agent",
5
5
  "author": "cybershape",
6
6
  "license": "MIT",