pi-my-compact-ui 0.2.0 → 0.2.2

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 (3) hide show
  1. package/README.md +2 -2
  2. package/index.ts +140 -37
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -53,8 +53,8 @@ reasoning content:
53
53
  - Renders fenced code blocks as subtle theme-aware background panels with
54
54
  syntax highlighting and one character of horizontal padding instead of
55
55
  decorative top and bottom border rows.
56
- - Preserves Pi's native execution semantics for `read`, `bash`, `edit`, `write`,
57
- `find`, `grep`, and `ls`.
56
+ - Preserves Pi's native execution semantics for `read`, `bash`, `powershell`, `edit`,
57
+ `write`, `find`, `grep`, and `ls`.
58
58
  - Gives compaction summaries a distinct, compact presentation.
59
59
 
60
60
  ## Installation
package/index.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  createFindTool,
31
31
  createGrepTool,
32
32
  createLsTool,
33
+ createPowerShellTool,
33
34
  createReadTool,
34
35
  createWriteTool,
35
36
  getMarkdownTheme,
@@ -313,7 +314,8 @@ function refreshSealedThinkingTokens(reportedReasoning?: unknown): void {
313
314
  function toolSummary(name: string, args: any): { name: string; content: string } {
314
315
  switch (name) {
315
316
  case "bash":
316
- return { name: "bash", content: oneLine(args?.command || "…") };
317
+ case "powershell":
318
+ return { name, content: oneLine(args?.command || "…") };
317
319
  case "read":
318
320
  return { name: "read", content: shortenPath(args?.path || "…") };
319
321
  case "write":
@@ -632,23 +634,38 @@ class ToolGroupComponent extends Container {
632
634
  if ((tool as any)?.[PARENT_KEY] === this) delete (tool as any)[PARENT_KEY];
633
635
  }
634
636
 
637
+ /** Return only entries that should be displayed (tools, or thinking with non-empty text). */
638
+ getVisibleEntries(): GroupEntry[] {
639
+ return this.entries.filter((entry) => {
640
+ if (entry.kind === "tool") return true;
641
+ if (entry.kind === "thinking") {
642
+ return entry.text.trim().length > 0;
643
+ }
644
+ return false;
645
+ });
646
+ }
647
+
648
+ hasVisibleEntries(): boolean {
649
+ return this.getVisibleEntries().length > 0;
650
+ }
651
+
635
652
  hasPending(): boolean {
636
- return this.children.some((tool) => toolStatus(tool) === "pending");
653
+ return this.getVisibleEntries().some((entry) => entry.kind === "tool" && toolStatus(entry.tool) === "pending");
637
654
  }
638
655
 
639
656
  /** True while a thinking run in this group is still streaming. */
640
657
  hasActiveThinking(): boolean {
641
- return this.entries.some((entry) => entry.kind === "thinking" && entry.active);
658
+ return this.getVisibleEntries().some((entry) => entry.kind === "thinking" && entry.active);
642
659
  }
643
660
 
644
661
  /** True once any thinking run has been added to this group. */
645
662
  hasThinking(): boolean {
646
- return this.entries.some((entry) => entry.kind === "thinking");
663
+ return this.getVisibleEntries().some((entry) => entry.kind === "thinking");
647
664
  }
648
665
 
649
666
  /** True while this group should keep its spinner animating. */
650
667
  needsAnimation(): boolean {
651
- return this.hasPending() || this.hasActiveThinking();
668
+ return this.hasVisibleEntries() && (this.hasPending() || this.hasActiveThinking());
652
669
  }
653
670
 
654
671
  override invalidate(): void {
@@ -711,10 +728,8 @@ class ToolGroupComponent extends Container {
711
728
  const bold = theme?.bold ? theme.bold : (t: string) => t;
712
729
  const st = toolStatus(tool);
713
730
  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();
731
+ const prefix = rail ? fg("dim", rail) : "";
732
+ 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
733
  }
719
734
 
720
735
  // One row per thinking run. The spinner marks the run that is still streaming;
@@ -724,28 +739,37 @@ class ToolGroupComponent extends Container {
724
739
  const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
725
740
  const icon = entry.active ? frame : "✓";
726
741
  const preview = entry.text.trim().replace(/[*_#`>]+/g, "");
742
+ const prefix = rail ? fg("dim", rail) : "";
727
743
  if (!preview) {
728
- return `${fg("dim", rail)}${fg("thinkingText", icon)} ${fg("thinkingText", "thinking...")}`;
744
+ const label = entry.active ? "thinking..." : "thinking";
745
+ return `${prefix}${fg("thinkingText", icon)} ${fg("thinkingText", label)}`;
729
746
  }
730
747
  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}`)}`;
748
+ const previewLimit = Math.max(1, Math.min(60, width - GROUP_PADDING_X - rail.length - 18 - tokenLabel.length));
749
+ return `${prefix}${fg("thinkingText", icon)} ${fg("thinkingText", `thinking: ${oneLine(preview, previewLimit)}`)} ${fg("muted", `· ${tokenLabel}`)}`;
733
750
  }
734
751
 
735
752
  // Folded: header + one line per entry, in stream order (oldest first).
736
- private renderCollapsed(width: number): string[] {
753
+ // When there is only 1 visible entry, do not show hierarchy (no header, no tree rail).
754
+ private renderCollapsed(width: number, visible: GroupEntry[]): string[] {
737
755
  const theme = currentTheme;
738
756
  const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
739
757
  const frame = SPINNER[Math.floor((Date.now() - spinnerStart) / SPINNER_MS) % SPINNER.length]!;
758
+
759
+ if (visible.length === 1) {
760
+ const entry = visible[0]!;
761
+ if (entry.kind === "tool") {
762
+ return [this.toolRow("", entry.tool, frame)];
763
+ }
764
+ return [this.thinkingRow("", entry, frame, width)];
765
+ }
766
+
740
767
  const lines: string[] = [];
741
768
 
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();
769
+ const hasPendingTool = visible.some((e) => e.kind === "tool" && toolStatus(e.tool) === "pending");
770
+ const isThinking = !this.sealed && visible.some((e) => e.kind === "thinking" && e.active);
771
+ const hasTools = visible.some((e) => e.kind === "tool");
772
+ const hasThinking = visible.some((e) => e.kind === "thinking");
749
773
  const openNoTools = !this.sealed && !hasTools;
750
774
  const working = hasPendingTool || isThinking || openNoTools;
751
775
  const state = hasPendingTool
@@ -765,9 +789,9 @@ class ToolGroupComponent extends Container {
765
789
  lines.push(`${fg(stateColor, leftIcon)} ${fg(stateColor, state)}`);
766
790
 
767
791
  // Every entry is shown, one line each, in the order the model produced it.
768
- const total = this.entries.length;
792
+ const total = visible.length;
769
793
  for (let index = 0; index < total; index++) {
770
- const entry = this.entries[index]!;
794
+ const entry = visible[index]!;
771
795
  const rail = index === total - 1 ? "└ " : "│ ";
772
796
  lines.push(
773
797
  entry.kind === "thinking"
@@ -776,23 +800,73 @@ class ToolGroupComponent extends Container {
776
800
  );
777
801
  }
778
802
 
779
- if (this.needsAnimation()) scheduleAnimation();
780
803
  return lines;
781
804
  }
782
805
 
783
806
  // Expanded: per-tool detail + every thinking run, in stream order.
784
- private renderExpanded(width: number): string[] {
807
+ // When there is only 1 visible entry, do not show hierarchy (no header, no tree rail).
808
+ private renderExpanded(width: number, visible: GroupEntry[]): string[] {
785
809
  const theme = currentTheme;
786
810
  const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
787
811
  const frame = SPINNER[Math.floor((Date.now() - spinnerStart) / SPINNER_MS) % SPINNER.length]!;
788
812
  const lines: string[] = [];
789
813
 
790
- const hasPendingTool = this.hasPending();
791
- const isThinking = this.thinkingActiveInGroup();
814
+ if (visible.length === 1) {
815
+ const entry = visible[0]!;
816
+ const sub = " ";
817
+ if (entry.kind === "tool") {
818
+ const tool = entry.tool;
819
+ lines.push(this.toolRow("", tool, frame));
820
+ const result = toolResultText(tool);
821
+ if (result) {
822
+ const markdownWidth = Math.max(1, width - GROUP_PADDING_X - sub.length);
823
+ const preview = this.renderMarkdownPreview(
824
+ `tool:${tool.toolCallId ?? 0}`,
825
+ result,
826
+ markdownWidth,
827
+ config.expandedToolLines,
828
+ { color: (text) => currentTheme?.fg?.("toolOutput", text) ?? text },
829
+ );
830
+ for (const row of preview.lines) {
831
+ lines.push(`${fg("dim", sub)}${row}`);
832
+ }
833
+ if (preview.truncated) {
834
+ lines.push(`${fg("dim", sub)}${fg("muted", "…")}`);
835
+ }
836
+ }
837
+ return lines;
838
+ }
839
+
840
+ const icon = entry.active ? frame : "✓";
841
+ lines.push(
842
+ `${fg("thinkingText", icon)} ${fg("thinkingText", "thinking")} ${fg("muted", `· ${thinkingTokenLabel(entry)}`)}`,
843
+ );
844
+ const tText = entry.text.trim();
845
+ if (tText) {
846
+ const markdownWidth = Math.max(1, width - GROUP_PADDING_X - sub.length);
847
+ const preview = this.renderMarkdownPreview(
848
+ `thinking:${entry.id}`,
849
+ tText,
850
+ markdownWidth,
851
+ config.expandedThinkingLines,
852
+ { color: (text) => currentTheme?.fg?.("thinkingText", text) ?? text, italic: true },
853
+ );
854
+ for (const row of preview.lines) {
855
+ lines.push(`${fg("dim", sub)}${row}`);
856
+ }
857
+ if (preview.truncated) {
858
+ lines.push(`${fg("dim", sub)}${fg("muted", "…")}`);
859
+ }
860
+ }
861
+ return lines;
862
+ }
863
+
864
+ const hasPendingTool = visible.some((e) => e.kind === "tool" && toolStatus(e.tool) === "pending");
865
+ const isThinking = !this.sealed && visible.some((e) => e.kind === "thinking" && e.active);
792
866
  // See renderCollapsed: a block without tools keeps the thinking label, and a
793
867
  // block with reasoning must not claim "tools done" alone.
794
- const hasTools = this.children.length > 0;
795
- const hasThinking = this.hasThinking();
868
+ const hasTools = visible.some((e) => e.kind === "tool");
869
+ const hasThinking = visible.some((e) => e.kind === "thinking");
796
870
  const openNoTools = !this.sealed && !hasTools;
797
871
  const working = hasPendingTool || isThinking || openNoTools;
798
872
  const state = hasPendingTool
@@ -811,9 +885,9 @@ class ToolGroupComponent extends Container {
811
885
  const leftIcon = working ? frame : "✓";
812
886
  lines.push(`${fg(stateColor, leftIcon)} ${fg(stateColor, state)}`);
813
887
 
814
- const total = this.entries.length;
888
+ const total = visible.length;
815
889
  for (let index = 0; index < total; index++) {
816
- const entry = this.entries[index]!;
890
+ const entry = visible[index]!;
817
891
  const isLast = index === total - 1;
818
892
  const rail = isLast ? "└─ " : "├─ ";
819
893
  const sub = isLast ? " " : "│ ";
@@ -862,12 +936,17 @@ class ToolGroupComponent extends Container {
862
936
  }
863
937
  }
864
938
 
865
- if (this.needsAnimation()) scheduleAnimation();
866
939
  return lines;
867
940
  }
868
941
 
869
942
  override render(width: number): string[] {
870
- const lines = this._expanded ? this.renderExpanded(width) : this.renderCollapsed(width);
943
+ const visible = this.getVisibleEntries();
944
+ if (visible.length === 0) return [];
945
+
946
+ if (this.needsAnimation()) scheduleAnimation();
947
+
948
+ const lines = this._expanded ? this.renderExpanded(width, visible) : this.renderCollapsed(width, visible);
949
+ if (lines.length === 0) return [];
871
950
  // Indent compact blocks from the transcript edge while keeping every line
872
951
  // within the terminal width (including mobile / narrow terminals).
873
952
  const padding = " ".repeat(Math.min(GROUP_PADDING_X, Math.max(0, width - 1)));
@@ -966,8 +1045,13 @@ function flushPendingTextSeal(): void {
966
1045
  // visible text. Anchor it immediately before this text block so the visual
967
1046
  // component order matches the stream order:
968
1047
  // group -> visible text -> next group -> next visible text
969
- if (pendingTextOrdinal !== null) {
970
- anchorGroupBeforeCurrentText(lastActiveGroup, pendingTextOrdinal);
1048
+ if (lastActiveGroup.hasVisibleEntries()) {
1049
+ if (pendingTextOrdinal !== null) {
1050
+ anchorGroupBeforeCurrentText(lastActiveGroup, pendingTextOrdinal);
1051
+ }
1052
+ } else {
1053
+ if (lastChatContainer) removeGroupFromContainer(lastChatContainer, lastActiveGroup);
1054
+ groups.delete(lastActiveGroup);
971
1055
  }
972
1056
  lastActiveGroup.sealed = true;
973
1057
  finalizeActiveThinking();
@@ -1139,7 +1223,7 @@ function insertTurnDivider(elapsedMs: number): void {
1139
1223
  // group). A plain text-only answer gets no divider.
1140
1224
  const hasPriorContent = contentContainer.children
1141
1225
  .slice(0, finalIndex)
1142
- .some((child) => child instanceof ToolGroupComponent);
1226
+ .some((child) => child instanceof ToolGroupComponent && child.hasVisibleEntries());
1143
1227
  if (!hasPriorContent) return;
1144
1228
 
1145
1229
  const state = getAssistantContentState(contentContainer);
@@ -1183,6 +1267,7 @@ function placeAnchoredGroupBeforeText(container: Container, target: Markdown, gr
1183
1267
  }
1184
1268
 
1185
1269
  function insertAnchoredGroup(container: Container, ordinal: number, group: ToolGroupComponent): void {
1270
+ if (!group.hasVisibleEntries()) return;
1186
1271
  const markdowns = container.children.filter(isVisibleTextMarkdown);
1187
1272
  const target = markdowns[ordinal];
1188
1273
  if (!target) return;
@@ -1220,6 +1305,11 @@ function anchorGroupBeforeCurrentText(group: ToolGroupComponent, ordinal: number
1220
1305
  removeGroupFromContainer(previousAnchor.container, group);
1221
1306
  }
1222
1307
 
1308
+ if (!group.hasVisibleEntries()) {
1309
+ groups.delete(group);
1310
+ return;
1311
+ }
1312
+
1223
1313
  const state = getAssistantContentState(contentContainer);
1224
1314
  const replaced = state.anchors.get(ordinal);
1225
1315
  if (replaced && replaced !== group) {
@@ -1240,7 +1330,7 @@ function restoreAssistantAnchor(parent: any, component: any): void {
1240
1330
  const state = getAssistantContentState(parent);
1241
1331
  const ordinal = state.nextTextOrdinal++;
1242
1332
  const group = state.anchors.get(ordinal);
1243
- if (group) {
1333
+ if (group && group.hasVisibleEntries()) {
1244
1334
  removeGroupFromContainer(parent, group);
1245
1335
  placeAnchoredGroupBeforeText(parent, component, group);
1246
1336
  }
@@ -1386,6 +1476,7 @@ function getTools(cwd: string): Record<string, AnyTool> {
1386
1476
  tools = {
1387
1477
  read: createReadTool(cwd),
1388
1478
  bash: createBashTool(cwd),
1479
+ powershell: createPowerShellTool(cwd),
1389
1480
  edit: createEditTool(cwd),
1390
1481
  write: createWriteTool(cwd),
1391
1482
  find: createFindTool(cwd),
@@ -1413,7 +1504,7 @@ export default function (pi: ExtensionAPI) {
1413
1504
  return getTools((ctx as { cwd: string }).cwd)[name].execute(toolCallId, params, signal, onUpdate);
1414
1505
  };
1415
1506
 
1416
- for (const name of ["read", "bash", "edit", "write", "find", "grep", "ls"] as const) {
1507
+ for (const name of ["read", "bash", "powershell", "edit", "write", "find", "grep", "ls"] as const) {
1417
1508
  pi.registerTool({
1418
1509
  name,
1419
1510
  label: name,
@@ -1464,6 +1555,12 @@ export default function (pi: ExtensionAPI) {
1464
1555
  lastActiveGroup.invalidate();
1465
1556
  }
1466
1557
  if (role === "user") {
1558
+ for (const g of [...groups]) {
1559
+ if (!g.hasVisibleEntries()) {
1560
+ if (lastChatContainer) removeGroupFromContainer(lastChatContainer, g);
1561
+ groups.delete(g);
1562
+ }
1563
+ }
1467
1564
  turnStartMs = Date.now();
1468
1565
  activeThinking = null;
1469
1566
  activeThinkingIndex = null;
@@ -1567,6 +1664,12 @@ export default function (pi: ExtensionAPI) {
1567
1664
  finalizeActiveThinking();
1568
1665
  lastActiveGroup.invalidate();
1569
1666
  }
1667
+ for (const g of [...groups]) {
1668
+ if (!g.hasVisibleEntries()) {
1669
+ if (lastChatContainer) removeGroupFromContainer(lastChatContainer, g);
1670
+ groups.delete(g);
1671
+ }
1672
+ }
1570
1673
  refreshSealedThinkingTokens();
1571
1674
  // Separate the final visible text from the preceding work with a divider
1572
1675
  // 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.2",
4
4
  "description": "Compact tree-shaped reasoning and tool-call UI for the Pi Coding Agent",
5
5
  "author": "cybershape",
6
6
  "license": "MIT",