@hheei/pi-ext-ui 0.5.0 → 0.5.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.
package/README.md CHANGED
@@ -8,8 +8,8 @@ The shared view API owns fold levels, compact sections, status glyphs, copy feed
8
8
 
9
9
  Background Bash and subagent tools own their display formats through ext-core `ToolPresentation` providers. This resolver consumes those providers automatically, including nested codemode calls, and adds shared folding and copy controls. Without `pi-ext-ui`, the same tools retain native Pi renderers. No direct dependency on `pi-ext-ui` is required by the tool extensions.
10
10
 
11
- Opt into activity groups with `pi --ui-tool-groups` after installing this extension (Pi **>=1.0.1**). A heading such as `▸ codemode ×2 · grep ×3` keeps one live summary row per tool beneath a single outer rail. Each thinking round and notification also keeps a summary row; notifications are not counted. Groups form while tools run and can span consecutive tool/thinking rounds. Prose, user messages, unknown components, images, and unknown tool renderers break groups. Streaming arguments, partial results, completion, and errors update the existing row.
11
+ Activity groups are on by default for Pi **>=1.0.1** TUI sessions. Turn them off with the Tool UI setting `toolGroups.enabled` in `ext_settings.json` (the ext-core settings page). The change applies to the current session. A heading such as `▸ codemode ×2 · grep ×3` keeps one live summary row per tool beneath a single outer rail. Each thinking round and notification also keeps a summary row; notifications are not counted. Groups form while tools run and can span consecutive tool/thinking rounds. Prose, user messages, unknown components, images, and unknown tool renderers break groups. Any MessageView transcript notice stays inside the group, so bg-bash, subagent, memory, optimizer, and codemode-guard notices do not need their own allowlist. Notification rows show their text directly. Streaming arguments, partial results, completion, and errors update the existing row.
12
12
 
13
13
  Fullscreen: click the group heading to restore the original component layouts and interactions beneath the outer rail. Both terminal modes: Ctrl+O forces groups open; closing global expansion restores each group’s local choice. Appending members preserves that choice. Original per-tool fold choices remain intact. Groups are presentation only and are rebuilt on history navigation; session data, model context, and HTML export remain unchanged. Thinking summaries keep a label; expanding respects native thinking visibility. Each `ctx.ui.notify` call retains its full text for expansion. Notifications remain session-local, as in Pi.
14
14
 
15
- This optional feature uses two centralized, version-checked host prototype hooks: tool lookup and extension notifications. Thinking uses the existing assistant message, with no extra hook. Unsupported hosts or incompatible layouts report a warning and retain normal rendering. Cleanup restores the original methods; if another extension replaces the same methods, restart Pi to unload the conflicting patch. For workspace development use `pnpm run pi-dev --ui-tool-groups`: running the bundled CLI beside physical host devDependencies can resolve a second host class, unlike the published installation.
15
+ Grouping uses two centralized, version-checked host prototype hooks: tool lookup and extension notifications. Thinking uses the existing assistant message, with no extra hook. Unsupported hosts or incompatible layouts report a warning and retain normal rendering. Cleanup restores the original methods; if another extension replaces the same methods, restart Pi to unload the conflicting patch. For workspace development use `pnpm run pi-dev`: running the bundled CLI beside physical host devDependencies can resolve a second host class, unlike the published installation.
package/dist/extension.js CHANGED
@@ -1,33 +1,43 @@
1
- import { messageViewPoint, registerExtensionHook, registerExtensionLifecycle, toolRendererPoint, } from "@hheei/pi-ext-core";
1
+ import { getRuntimeSettingsRegistry, messageViewPoint, registerExtensionHook, registerExtensionLifecycle, registerSettings, toolRendererPoint, } from "@hheei/pi-ext-core";
2
2
  import { installHostToolGroups } from "./host-transcript-patch.js";
3
3
  import { createMessageView } from "./message-view.js";
4
4
  import { createOptionalToolRendererFactory, registerCollapsibleToolRenderer } from "./renderer.js";
5
+ import { activityGroupsEnabled, createToolGroupSettings } from "./settings.js";
5
6
  import { clearCapturedNestedCalls } from "./tracker.js";
6
7
  export default function (pi) {
7
- pi.registerFlag("ui-tool-groups", {
8
- type: "boolean",
9
- default: false,
10
- description: "Group tool summaries, thinking and notifications (Pi >=1.0.1 runtime patch)",
11
- });
12
8
  let redrawGroups;
13
9
  pi.on("agent_settled", () => redrawGroups?.());
14
10
  registerCollapsibleToolRenderer(pi);
15
11
  registerExtensionLifecycle(pi, {
16
12
  key: "@hheei/pi-ext-ui",
17
- start(context) {
18
- if (context.extension.mode === "tui" && pi.getFlag("ui-tool-groups") === true) {
13
+ async start(context) {
14
+ let patch;
15
+ const apply = (enabled) => {
16
+ if (context.extension.mode !== "tui")
17
+ return;
18
+ if (!enabled) {
19
+ redrawGroups = undefined;
20
+ patch?.dispose();
21
+ patch = undefined;
22
+ return;
23
+ }
24
+ if (patch !== undefined)
25
+ return;
19
26
  try {
20
- const patch = installHostToolGroups(context.extension);
27
+ patch = installHostToolGroups(context.extension);
21
28
  redrawGroups = patch.requestRender;
22
- context.resources.add("tool-groups", () => {
23
- redrawGroups = undefined;
24
- patch.dispose();
25
- });
26
29
  }
27
30
  catch (error) {
28
31
  context.extension.ui.notify(error instanceof Error ? error.message : String(error), "warning");
29
32
  }
30
- }
33
+ };
34
+ const provider = createToolGroupSettings({ onEnabled: apply });
35
+ context.resources.add("tool-group-settings", registerSettings(provider, getRuntimeSettingsRegistry(pi)));
36
+ context.resources.add("tool-groups", () => {
37
+ redrawGroups = undefined;
38
+ patch?.dispose();
39
+ patch = undefined;
40
+ });
31
41
  context.resources.add("tracker-cleanup", () => {
32
42
  clearCapturedNestedCalls();
33
43
  });
@@ -35,6 +45,21 @@ export default function (pi) {
35
45
  context.resources.add("tool-renderer", () => toolRenderers.dispose());
36
46
  const handle = registerExtensionHook(context.pi, messageViewPoint, createMessageView);
37
47
  context.resources.add("message-view", () => handle.dispose());
48
+ try {
49
+ const state = await provider.storage.load({
50
+ sessionId: context.extension.sessionManager.getSessionId(),
51
+ signal: context.signal,
52
+ });
53
+ if (!context.signal.aborted)
54
+ apply(activityGroupsEnabled(state));
55
+ }
56
+ catch (error) {
57
+ if (!context.signal.aborted) {
58
+ const detail = error instanceof Error ? error.message : String(error);
59
+ context.extension.ui.notify(`Tool activity groups settings unreadable: ${detail}`, "warning");
60
+ apply(true);
61
+ }
62
+ }
38
63
  },
39
64
  });
40
65
  }
@@ -1,5 +1,7 @@
1
1
  import { AssistantMessageComponent, InteractiveMode, ToolExecutionComponent, VERSION, } from "@earendil-works/pi-coding-agent";
2
- import { Container, MouseRegion, Spacer, stripTerminalSequences, Text, truncateToWidth, } from "@earendil-works/pi-tui";
2
+ import { Container, MouseRegion, Spacer, stripTerminalSequences, Text, } from "@earendil-works/pi-tui";
3
+ import { fitRow } from "@hheei/pi-ext-core";
4
+ import { MessageView } from "./message-view.js";
3
5
  import { renderWithoutToolRails, ToolView, toSingleRow } from "./tool-view.js";
4
6
  const SEAMS = ["getRegisteredToolDefinition", "showExtensionNotify"];
5
7
  function record(value) {
@@ -71,6 +73,21 @@ function toolMember(component, calls) {
71
73
  return undefined;
72
74
  return { component, name: toSingleRow(stripTerminalSequences(name)), summary };
73
75
  }
76
+ function messageView(component) {
77
+ if (component instanceof MessageView)
78
+ return component;
79
+ if (!(component instanceof Container))
80
+ return undefined;
81
+ for (const child of component.children)
82
+ if (child instanceof MessageView)
83
+ return child;
84
+ return undefined;
85
+ }
86
+ /** Shared transcript notices stay in the group. The frame is the contract, not each custom type. */
87
+ function noticeContinuation(component) {
88
+ const view = messageView(component);
89
+ return view === undefined ? undefined : { component, summary: view.activitySummary };
90
+ }
74
91
  class NotifyView extends Container {
75
92
  #text = new Text("", 1, 0);
76
93
  #context;
@@ -85,7 +102,7 @@ class NotifyView extends Container {
85
102
  this.addChild(this.#text);
86
103
  }
87
104
  get summary() {
88
- return this.#context.ui.theme.fg(this.#type === "info" ? "dim" : this.#type, toSingleRow(`notify · ${this.#content}`));
105
+ return this.#context.ui.theme.fg(this.#type === "info" ? "dim" : this.#type, toSingleRow(this.#content));
89
106
  }
90
107
  get #content() {
91
108
  const prefix = this.#type === "error" ? "Error: " : this.#type === "warning" ? "Warning: " : "";
@@ -100,45 +117,93 @@ class ToolGroup extends Container {
100
117
  #header = new Text("", 0, 0);
101
118
  #originals = new Container();
102
119
  #context;
120
+ #redraw;
103
121
  #members = [];
104
122
  #expanded = false;
105
123
  #renderedExpanded = false;
124
+ #open = new Set();
125
+ #hits = [];
106
126
  constructor(context, redraw) {
107
127
  super();
108
128
  this.#context = context;
129
+ this.#redraw = redraw;
109
130
  const heading = new MouseRegion(this.#header, (event) => {
110
131
  if (event.type !== "click" || event.button !== "left")
111
132
  return undefined;
112
133
  this.#expanded = this.#context.ui.getToolsExpanded() ? false : !this.#expanded;
134
+ if (!this.#expanded)
135
+ this.#open.clear();
113
136
  redraw();
114
137
  return { handled: true, render: true };
115
138
  });
116
139
  const body = {
117
- render: (width) => {
118
- const rows = this.#renderedExpanded
119
- ? renderWithoutToolRails(this.#originals, Math.max(1, width - 2))
120
- : this.#members.flatMap((member) => member.summary === undefined ? [] : [member.summary]);
121
- const rail = this.#context.ui.theme.fg("dim", "▎");
122
- return rows.map((row) => truncateToWidth(`${rail} ${row}`, Math.max(0, width), "…"));
123
- },
124
- handleMouse: (event) => {
125
- if (!this.#renderedExpanded || event.x < 2)
126
- return undefined;
127
- return this.#originals.handleMouse({
128
- ...event,
129
- x: event.x - 2,
130
- width: Math.max(1, event.width - 2),
131
- });
132
- },
140
+ render: (width) => this.#renderBody(width),
141
+ handleMouse: (event) => this.#hit(event),
133
142
  invalidate() { },
134
143
  };
135
144
  this.children = [new Spacer(1), heading, body];
136
145
  }
146
+ #renderBody(width) {
147
+ const inner = Math.max(1, width - 2);
148
+ const rail = this.#context.ui.theme.fg("dim", "▎");
149
+ const lines = [];
150
+ const hits = [];
151
+ this.#members.forEach((member, index) => {
152
+ const wrapper = this.#originals.children[index];
153
+ if (wrapper === undefined)
154
+ return;
155
+ const native = this.#renderedExpanded || this.#open.has(member.component);
156
+ const rows = native
157
+ ? renderWithoutToolRails(wrapper, inner)
158
+ : member.summary === undefined
159
+ ? []
160
+ : [member.summary];
161
+ if (rows.length === 0)
162
+ return;
163
+ hits.push({ component: member.component, wrapper, native, height: rows.length });
164
+ for (const row of rows) {
165
+ lines.push(fitRow(`${rail} ${row}`, Math.max(0, width), "…"));
166
+ }
167
+ });
168
+ this.#hits = hits;
169
+ return lines;
170
+ }
171
+ #hit(event) {
172
+ let y = event.y;
173
+ for (const hit of this.#hits) {
174
+ if (y >= hit.height) {
175
+ y -= hit.height;
176
+ continue;
177
+ }
178
+ if (!hit.native) {
179
+ if (event.type !== "click" || event.button !== "left")
180
+ return undefined;
181
+ this.#open.add(hit.component);
182
+ this.#redraw();
183
+ return { handled: true, render: true };
184
+ }
185
+ // The rail belongs to the group. Content, including header fold and copy, stays with the member.
186
+ if (event.x < 2) {
187
+ if (!this.#renderedExpanded && event.type === "click" && event.button === "left") {
188
+ this.#open.delete(hit.component);
189
+ this.#redraw();
190
+ return { handled: true, render: true };
191
+ }
192
+ return undefined;
193
+ }
194
+ return hit.wrapper.handleMouse?.({
195
+ ...event,
196
+ y,
197
+ height: hit.height,
198
+ x: event.x - 2,
199
+ width: Math.max(1, event.width - 2),
200
+ });
201
+ }
202
+ return undefined;
203
+ }
137
204
  update(members) {
138
205
  this.#members = members;
139
- this.#originals.children = members.map(({ component, name }) => {
140
- if (name === undefined)
141
- return component;
206
+ this.#originals.children = members.map(({ component }) => {
142
207
  let offset = 0;
143
208
  return {
144
209
  render(width) {
@@ -173,7 +238,7 @@ class ToolGroup extends Container {
173
238
  const heading = [...counts]
174
239
  .map(([name, count]) => `${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("dim", `×${count}`)}`)
175
240
  .join(theme.fg("dim", " · "));
176
- this.#header.setText(truncateToWidth(`${theme.fg("dim", this.#renderedExpanded ? "▾" : "▸")} ${heading}`, Math.max(0, width), "…"));
241
+ this.#header.setText(fitRow(`${theme.fg("dim", this.#renderedExpanded ? "▾" : "▸")} ${heading}`, Math.max(0, width), "…"));
177
242
  return super.render(width);
178
243
  }
179
244
  }
@@ -236,6 +301,13 @@ function attach(host, context) {
236
301
  members.push({ component: child, summary: child.summary });
237
302
  continue;
238
303
  }
304
+ else {
305
+ const continuation = noticeContinuation(child);
306
+ if (continuation !== undefined) {
307
+ members.push(continuation);
308
+ continue;
309
+ }
310
+ }
239
311
  flush();
240
312
  children.push(child);
241
313
  }
@@ -12,4 +12,6 @@ export declare class MessageView implements Component {
12
12
  render(width: number): string[];
13
13
  invalidate(): void;
14
14
  handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined;
15
+ /** Single-row label used when this notice sits inside an activity group. */
16
+ get activitySummary(): string;
15
17
  }
@@ -125,13 +125,16 @@ export class MessageView {
125
125
  this.toggle();
126
126
  return { handled: true, render: true };
127
127
  }
128
- #header(width, expanded) {
128
+ /** Single-row label used when this notice sits inside an activity group. */
129
+ get activitySummary() {
129
130
  const { theme, header } = this.#spec;
130
131
  const kind = theme.fg("dim", toSingleRow(header.kind));
131
132
  const summaryRaw = header.summary === undefined ? "" : toSingleRow(header.summary);
132
133
  const summary = summaryRaw === "" ? "" : theme.fg(header.tone ?? "dim", summaryRaw);
133
- const label = summary === "" ? kind : `${kind}${theme.fg("dim", " · ")}${summary}`;
134
- const pin = this.expandable ? `${theme.fg("dim", expanded ? "▾" : "▸")} ` : "";
135
- return truncate(`${pin}${label}`, width);
134
+ return summary === "" ? kind : `${kind}${theme.fg("dim", " · ")}${summary}`;
135
+ }
136
+ #header(width, expanded) {
137
+ const pin = this.expandable ? `${this.#spec.theme.fg("dim", expanded ? "▾" : "▸")} ` : "";
138
+ return truncate(`${pin}${this.activitySummary}`, width);
136
139
  }
137
140
  }
@@ -0,0 +1,6 @@
1
+ import { type SettingsProvider, type SettingsState } from "@hheei/pi-ext-core";
2
+ /** Missing or non-boolean values stay on; only an explicit false disables groups. */
3
+ export declare function activityGroupsEnabled(state: SettingsState | undefined): boolean;
4
+ export declare function createToolGroupSettings(options: {
5
+ readonly onEnabled: (enabled: boolean) => void;
6
+ }): SettingsProvider;
@@ -0,0 +1,36 @@
1
+ import { createJsonSettingsStorage, } from "@hheei/pi-ext-core";
2
+ const GROUP = "toolGroups";
3
+ const FIELD = "enabled";
4
+ /** Missing or non-boolean values stay on; only an explicit false disables groups. */
5
+ export function activityGroupsEnabled(state) {
6
+ const value = state?.[GROUP]?.[FIELD];
7
+ return value !== false;
8
+ }
9
+ export function createToolGroupSettings(options) {
10
+ return {
11
+ id: "pi-ext-ui",
12
+ title: "Tool UI",
13
+ origin: "@hheei/pi-ext-ui",
14
+ groups: [
15
+ {
16
+ id: GROUP,
17
+ title: "Tool activity",
18
+ fields: [
19
+ {
20
+ id: FIELD,
21
+ label: "Activity groups",
22
+ type: "boolean",
23
+ defaultValue: true,
24
+ description: "Group consecutive tool, thinking, and notification rows in the TUI transcript.",
25
+ parse: (draft) => draft === "true",
26
+ },
27
+ ],
28
+ },
29
+ ],
30
+ storage: createJsonSettingsStorage({ group: GROUP }),
31
+ onChange(change) {
32
+ if (change.groupId === GROUP && change.fieldId === FIELD)
33
+ options.onEnabled(change.value === true);
34
+ },
35
+ };
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hheei/pi-ext-ui",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Collapsible tool rendering frame and tool renderer pilot for Pi.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",