@nmzpy/pi-ember-stack 0.2.1 → 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 (30) hide show
  1. package/README.md +83 -83
  2. package/package.json +63 -59
  3. package/plugins/devin-auth/extensions/index.ts +23 -0
  4. package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -246
  5. package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -246
  6. package/plugins/devin-auth/src/cloud-direct/chat.ts +1096 -1091
  7. package/plugins/devin-auth/src/cloud-direct/index.ts +41 -41
  8. package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -78
  9. package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -202
  10. package/plugins/devin-auth/src/oauth/register-user.ts +174 -174
  11. package/plugins/devin-auth/src/oauth/types.ts +71 -71
  12. package/plugins/devin-auth/src/stream.ts +1 -1
  13. package/plugins/pi-compact-tools/index.ts +19 -1
  14. package/plugins/pi-compact-tools/renderer.ts +231 -61
  15. package/plugins/pi-custom-agents/index.ts +310 -102
  16. package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
  17. package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
  18. package/plugins/pi-custom-agents/subagent/extensions/index.ts +116 -224
  19. package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
  20. package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
  21. package/plugins/pi-custom-agents/subagent/extensions/runner.ts +241 -177
  22. package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
  23. package/plugins/pi-ember-fff/index.ts +275 -17
  24. package/plugins/pi-ember-fff/query.ts +170 -10
  25. package/plugins/pi-ember-fff/test/query.test.ts +157 -1
  26. package/plugins/pi-ember-fff/test/renderer.test.ts +367 -16
  27. package/plugins/pi-ember-tps/index.ts +27 -5
  28. package/plugins/pi-ember-ui/ember.json +3 -0
  29. package/plugins/pi-ember-ui/index.ts +223 -36
  30. package/plugins/pi-ember-ui/mode-colors.ts +36 -8
@@ -31,11 +31,19 @@ export type CompactCall = {
31
31
  export type DiscoveryGroup = {
32
32
  records: CompactCall[];
33
33
  /**
34
- * The record whose component currently renders the group header.
35
- * The latest call to join the group becomes the owner so the
36
- * header always renders in the current turn's visible component.
34
+ * The record whose component renders the group header. Set once at
35
+ * group creation to the first member and never changed.
37
36
  */
38
37
  renderOwner?: CompactCall;
38
+ hasNonDiscovery?: boolean;
39
+ /**
40
+ * Shared visual handle for the group block. The owner re-binds this
41
+ * to its live `Text` on every `renderCall`; members write into it
42
+ * directly via `setText` in `renderResultInner` so the group stays
43
+ * visible across Pi rebuilds (thinking-toggle, compaction, settings)
44
+ * without relying on owner invalidation.
45
+ */
46
+ callText?: Text;
39
47
  };
40
48
 
41
49
  function textValue(value: unknown, fallback = ""): string {
@@ -52,10 +60,37 @@ function bashCdDir(command: string): string | undefined {
52
60
  return match?.[1];
53
61
  }
54
62
 
63
+ /**
64
+ * Detect bash commands that are grep invocations (optionally preceded by
65
+ * `cd <dir> &&`). Returns the extracted pattern and path so the call
66
+ * can render as "Search" and join the discovery group.
67
+ */
68
+ function bashGrepInfo(command: string): { pattern: string; path: string } | undefined {
69
+ const stripped = command.replace(/^\s*cd\s+([^\s&]+)\s*&&\s*/, "");
70
+ if (!/^\s*grep\b/.test(stripped)) return undefined;
71
+ const cdDir = bashCdDir(command);
72
+ const path = cdDir ?? ".";
73
+ const afterGrep = stripped.replace(/^\s*grep\s+/, "");
74
+ const cmdBeforePipe = afterGrep.split(/\s+[|>]/)[0];
75
+ const parts = cmdBeforePipe.trim().split(/\s+/);
76
+ let pattern: string | undefined;
77
+ for (const part of parts) {
78
+ if (!part.startsWith("-")) {
79
+ pattern = part;
80
+ break;
81
+ }
82
+ }
83
+ if (!pattern) return undefined;
84
+ pattern = pattern.replace(/^["']|["']$/g, "");
85
+ return { pattern, path };
86
+ }
87
+
55
88
  function groupKey(name: string, args: any): string | undefined {
56
89
  if (DISCOVERY_TOOLS.has(name)) return "__discovery__";
57
90
  if (name === "bash") {
58
- const dir = bashCdDir(textValue(args?.command));
91
+ const command = textValue(args?.command);
92
+ if (bashGrepInfo(command)) return "__discovery__";
93
+ const dir = bashCdDir(command);
59
94
  return dir ? `bash:${dir}` : undefined;
60
95
  }
61
96
  return undefined;
@@ -123,15 +158,69 @@ function matchLabel(result: any, theme: any): string {
123
158
  return theme.fg("dim", " ") + theme.fg(color, label);
124
159
  }
125
160
 
126
- const PULSE_INTERVAL_MS = 600;
161
+ export const PULSE_INTERVAL_MS = 600;
127
162
 
128
- function bulletColor(record: CompactCall, theme: any): string {
129
- if (record.isError) return theme.fg("error", BULLET);
130
- if (record._completed) return theme.fg("success", BULLET);
163
+ /**
164
+ * Canonical status-bullet color: error→red, completed→green, else a
165
+ * flashing muted/dim bullet driven by PULSE_INTERVAL_MS. Shared by the
166
+ * compact renderer and the subagent renderer so the pulse stays in sync.
167
+ */
168
+ export function statusBulletColor(isError: boolean, isCompleted: boolean, theme: any): string {
169
+ if (isError) return theme.fg("error", BULLET);
170
+ if (isCompleted) return theme.fg("success", BULLET);
131
171
  const pulse = Math.floor(Date.now() / PULSE_INTERVAL_MS) % 2 === 0;
132
172
  return pulse ? theme.fg("muted", BULLET) : theme.fg("dim", BULLET);
133
173
  }
134
174
 
175
+ /**
176
+ * Canonical group-bullet color: any error→red, all completed→green,
177
+ * else flashing. Derived from statusBulletColor's pulse logic.
178
+ */
179
+ export function groupBulletColorFromFlags(hasError: boolean, allCompleted: boolean, theme: any): string {
180
+ return statusBulletColor(hasError, allCompleted, theme);
181
+ }
182
+
183
+ /**
184
+ * Single shared pulse timer. Holds a set of invalidate callbacks and
185
+ * fires them all on one PULSE_INTERVAL_MS interval, starting on first
186
+ * add and stopping when the last callback is removed. One timer drives
187
+ * every flashing bullet in the session.
188
+ */
189
+ export class PulseManager {
190
+ private readonly callbacks = new Set<() => void>();
191
+ private timer: ReturnType<typeof setInterval> | undefined;
192
+
193
+ add(cb: () => void): void {
194
+ this.callbacks.add(cb);
195
+ if (this.timer) return;
196
+ this.timer = setInterval(() => {
197
+ for (const cb of this.callbacks) {
198
+ try { cb(); } catch { /* best effort */ }
199
+ }
200
+ }, PULSE_INTERVAL_MS);
201
+ }
202
+
203
+ remove(cb: () => void): void {
204
+ this.callbacks.delete(cb);
205
+ if (this.callbacks.size === 0 && this.timer) {
206
+ clearInterval(this.timer);
207
+ this.timer = undefined;
208
+ }
209
+ }
210
+
211
+ clear(): void {
212
+ this.callbacks.clear();
213
+ if (this.timer) {
214
+ clearInterval(this.timer);
215
+ this.timer = undefined;
216
+ }
217
+ }
218
+ }
219
+
220
+ function bulletColor(record: CompactCall, theme: any): string {
221
+ return statusBulletColor(record.isError, record._completed === true, theme);
222
+ }
223
+
135
224
  function formatEditStats(result: any, theme: any): string {
136
225
  const { additions, removals } = diffStats(result);
137
226
  return theme.fg("success", `+${additions}`) +
@@ -158,6 +247,12 @@ export function formatCallBody(name: string, args: any, theme: any, inGroup = fa
158
247
  theme.fg("dim", ` ${pathName}`);
159
248
  case "bash": {
160
249
  const cmd = textValue(args?.command);
250
+ const grepInfo = bashGrepInfo(cmd);
251
+ if (grepInfo) {
252
+ return theme.fg("dim", theme.bold("Search")) +
253
+ theme.fg("dim", ` ${grepInfo.pattern}`) +
254
+ theme.fg("dim", ` in ${grepInfo.path}`);
255
+ }
161
256
  const stripped = inGroup ? (cmd.replace(/^\s*cd\s+[^\s&]+\s*&&\s*/, "") || cmd) : cmd;
162
257
  return theme.fg("dim", theme.bold("Run")) +
163
258
  theme.fg("dim", ` $ ${stripped}`);
@@ -174,15 +269,14 @@ export function formatCallBody(name: string, args: any, theme: any, inGroup = fa
174
269
  }
175
270
 
176
271
  function groupBulletColor(group: DiscoveryGroup, theme: any): string {
177
- if (group.records.some((r) => r.isError)) return theme.fg("error", BULLET);
178
- if (group.records.every((r) => r._completed)) return theme.fg("success", BULLET);
179
- const pulse = Math.floor(Date.now() / PULSE_INTERVAL_MS) % 2 === 0;
180
- return pulse ? theme.fg("muted", BULLET) : theme.fg("dim", BULLET);
272
+ const hasError = group.records.some((r) => r.isError);
273
+ const allCompleted = group.records.every((r) => r._completed);
274
+ return groupBulletColorFromFlags(hasError, allCompleted, theme);
181
275
  }
182
276
 
183
277
  function groupHeaderLabel(group: DiscoveryGroup): string {
184
278
  const allDone = group.records.every((r) => r._completed);
185
- const verb = allDone ? "Explored" : "Exploring";
279
+ const verb = allDone && group.hasNonDiscovery ? "Explored" : "Exploring";
186
280
  const first = group.records[0];
187
281
  if (!first) return verb;
188
282
  const key = groupKey(first.name, first.args);
@@ -204,7 +298,6 @@ function formatGroup(group: DiscoveryGroup, theme: any): string {
204
298
  : "";
205
299
  lines.push(
206
300
  theme.fg("dim", prefix) +
207
- bulletColor(record, theme) +
208
301
  formatCallBody(record.name, record.args, theme, true) +
209
302
  suffix,
210
303
  );
@@ -216,40 +309,66 @@ export class CompactRenderer {
216
309
  private readonly calls = new Map<string, CompactCall>();
217
310
  private lastCall: CompactCall | undefined;
218
311
  private lastGroupKey: string | undefined;
219
- private pulseTimer: ReturnType<typeof setInterval> | null = null;
220
- private readonly pendingPulses = new Set<CompactCall>();
221
-
222
- private startPulse(record: CompactCall): void {
223
- this.pendingPulses.add(record);
224
- if (this.pulseTimer) return;
225
- this.pulseTimer = setInterval(() => {
226
- if (this.pendingPulses.size === 0) {
227
- this.stopPulse();
228
- return;
229
- }
230
- for (const r of this.pendingPulses) r.invalidate?.();
231
- }, PULSE_INTERVAL_MS);
312
+ private currentGroup: DiscoveryGroup | undefined;
313
+ /** The single discovery group persists until session replacement. */
314
+ private discoveryGroup: DiscoveryGroup | undefined;
315
+ private readonly pulses = new PulseManager();
316
+
317
+ /** Whether the current turn has produced visible text output. When
318
+ * true, grouping state is reset so the next discovery call starts a
319
+ * fresh group. When false (thinking-only turns), discovery calls
320
+ * continue appending to the previous turn's group so exploration
321
+ * stays coherent with nothing visible between turns. */
322
+ private turnHasText = false;
323
+
324
+ beginTurn(): void {
325
+ // Do NOT reset grouping state here. A turn that only streams thinking
326
+ // tokens (no visible text) and then does discovery calls should
327
+ // append to the previous turn's group — there is nothing visible
328
+ // between them. Grouping is reset lazily in registerCall() once we
329
+ // know the current turn produced visible text (see noteVisibleText()).
330
+ this.turnHasText = false;
331
+ this.pulses.clear();
232
332
  }
233
333
 
234
- private stopPulse(): void {
235
- if (this.pulseTimer) {
236
- clearInterval(this.pulseTimer);
237
- this.pulseTimer = null;
238
- }
334
+ /** Called by the plugin when the current turn produces visible text
335
+ * output (text_start/text_delta). Marks the turn as having visible
336
+ * content so the next discovery call starts a fresh group instead
337
+ * of appending to the previous turn's group. */
338
+ noteVisibleText(): void {
339
+ this.turnHasText = true;
239
340
  }
240
341
 
241
- beginTurn(): void {
242
- // Do NOT reset lastCall / lastGroupKey here. Resetting breaks
243
- // grouping when turn_start fires between renderCall registrations
244
- // (e.g. parallel bash calls with the same cd dir). Consecutive
245
- // calls with the same group key should always group, even across
246
- // turn boundaries. Different keys naturally don't group.
247
- this.pendingPulses.clear();
248
- this.stopPulse();
342
+ /** Clear all accumulated call state. Called on session replacement
343
+ * (/resume, /new, /fork) so stale rows from the previous session do
344
+ * not leak into the new one. */
345
+ resetForSession(): void {
346
+ this.calls.clear();
347
+ this.lastCall = undefined;
348
+ this.lastGroupKey = undefined;
349
+ this.currentGroup = undefined;
350
+ this.discoveryGroup = undefined;
351
+ this.pulses.clear();
352
+ }
353
+
354
+ private markNonDiscoveryGroups(): void {
355
+ const groups = new Set<DiscoveryGroup>();
356
+ if (this.currentGroup) groups.add(this.currentGroup);
357
+ if (this.discoveryGroup) groups.add(this.discoveryGroup);
358
+ for (const group of groups) {
359
+ group.hasNonDiscovery = true;
360
+ group.renderOwner?.invalidate?.();
361
+ }
249
362
  }
250
363
 
251
- observeCall(name: string, id: string, args: any): CompactCall {
252
- return this.registerCall(name, id, args);
364
+ private appendToGroup(group: DiscoveryGroup, record: CompactCall): void {
365
+ // Attach every member only once the group has a second member. This
366
+ // keeps a lone discovery call rendered as a normal standalone row.
367
+ for (const member of group.records) member.group = group;
368
+ group.records.push(record);
369
+ record.group = group;
370
+ this.currentGroup = group;
371
+ group.renderOwner?.invalidate?.();
253
372
  }
254
373
 
255
374
  registerCall(
@@ -261,29 +380,62 @@ export class CompactRenderer {
261
380
  const existing = this.calls.get(id);
262
381
  if (existing) {
263
382
  existing.args = args;
264
- existing.invalidate = invalidate ?? existing.invalidate;
383
+ // On Pi rebuilds (thinking-toggle, compaction, settings) the
384
+ // ToolExecutionComponent is destroyed and recreated with a fresh
385
+ // invalidate callback. Swap the old callback out of the
386
+ // PulseManager and insert the live one so the pulse timer only
387
+ // fires live components. Completed records are not re-pulsed.
388
+ if (existing.invalidate && invalidate && existing.invalidate !== invalidate) {
389
+ this.pulses.remove(existing.invalidate);
390
+ }
391
+ existing.invalidate = invalidate;
392
+ if (invalidate && !existing._completed) this.pulses.add(invalidate);
265
393
  return existing;
266
394
  }
267
395
 
268
396
  const record: CompactCall = { id, name, args, isError: false };
269
397
  this.calls.set(id, record);
270
398
  const key = groupKey(name, args);
271
- if (key && this.lastCall && key === this.lastGroupKey) {
272
- const group = this.lastCall.group ?? { records: [this.lastCall], renderOwner: this.lastCall };
399
+
400
+ // If the current turn produced visible text, reset grouping so this
401
+ // call starts a fresh group. Thinking-only turns do NOT reset —
402
+ // discovery calls append to the previous turn's group so exploration
403
+ // stays coherent when nothing visible separates the turns.
404
+ if (this.turnHasText && key !== undefined) {
405
+ this.lastCall = undefined;
406
+ this.lastGroupKey = undefined;
407
+ this.currentGroup = undefined;
408
+ this.discoveryGroup = undefined;
409
+ this.turnHasText = false;
410
+ }
411
+
412
+ if (key === undefined) {
413
+ this.markNonDiscoveryGroups();
414
+ }
415
+
416
+ if (key === "__discovery__") {
417
+ // Discovery is one persistent group. It intentionally survives
418
+ // turn boundaries and intervening non-discovery calls; the latter
419
+ // only makes the group label monotonic via hasNonDiscovery.
420
+ if (!this.discoveryGroup) {
421
+ this.discoveryGroup = {
422
+ records: [record],
423
+ renderOwner: record,
424
+ };
425
+ this.currentGroup = this.discoveryGroup;
426
+ } else {
427
+ this.appendToGroup(this.discoveryGroup, record);
428
+ }
429
+ } else if (key && this.lastCall && key === this.lastGroupKey) {
430
+ const group = this.lastCall.group ?? { records: [this.lastCall] };
273
431
  this.lastCall.group = group;
274
- // New record joins the group — become renderOwner so the group
275
- // header renders in this call's (current-turn) component.
276
- const prevOwner = group.renderOwner;
277
- group.renderOwner = record;
278
- if (prevOwner && prevOwner !== record) prevOwner.invalidate?.();
279
- group.records.push(record);
280
- record.group = group;
281
- for (const groupedCall of group.records) groupedCall.invalidate?.();
432
+ if (!group.renderOwner) group.renderOwner = this.lastCall;
433
+ this.appendToGroup(group, record);
282
434
  }
283
435
  this.lastCall = record;
284
436
  this.lastGroupKey = key;
285
437
  record.invalidate = invalidate;
286
- this.startPulse(record);
438
+ if (invalidate) this.pulses.add(invalidate);
287
439
  return record;
288
440
  }
289
441
 
@@ -292,11 +444,13 @@ export class CompactRenderer {
292
444
  record.isError = isError;
293
445
  record._completed = true;
294
446
  record.result = result;
295
- this.pendingPulses.delete(record);
296
- if (this.pendingPulses.size === 0) this.stopPulse();
297
- if (record.group) {
298
- record.group.renderOwner?.invalidate?.();
299
- }
447
+ if (record.invalidate) this.pulses.remove(record.invalidate);
448
+ // Do NOT invalidate the owner here. The group visual is updated
449
+ // directly via group.callText.setText() in renderResultInner so the
450
+ // owner's next render picks up the change. Invalidating the owner
451
+ // synchronously triggers updateDisplay -> renderResult -> setResult,
452
+ // which races during Pi rebuilds (thinking-toggle, compaction) when
453
+ // the owner component has been destroyed and recreated.
300
454
  }
301
455
 
302
456
  renderCall(
@@ -326,7 +480,17 @@ export class CompactRenderer {
326
480
  const record = this.registerCall(name, context.toolCallId, args, context.invalidate);
327
481
  if (record.group && record.group.records.length > 1) {
328
482
  if (record.group.renderOwner !== record) return new Text("", 0, 0);
329
- return new Text(formatGroup(record.group, theme), 0, 0);
483
+ const callText = context.state.callText instanceof Text
484
+ ? context.state.callText
485
+ : new Text("", 0, 0);
486
+ context.state.callText = callText;
487
+ // Re-bind the group's shared visual handle to the owner's live
488
+ // Text on every render. On Pi rebuilds (thinking-toggle,
489
+ // compaction, settings) context.state is fresh, so a new Text is
490
+ // created and the group handle is repointed to the live owner.
491
+ record.group.callText = callText;
492
+ callText.setText(formatGroup(record.group, theme));
493
+ return callText;
330
494
  }
331
495
  const callText = context.state.callText instanceof Text
332
496
  ? context.state.callText
@@ -369,6 +533,12 @@ export class CompactRenderer {
369
533
  const expanded = options.expanded === true;
370
534
 
371
535
  if (record.group && record.group.records.length > 1) {
536
+ // Update the shared group visual directly so the owner's row
537
+ // reflects this member's completion (bullet color, match count,
538
+ // Explored label) without invalidating the owner. setText clears
539
+ // the Text cache; Pi's next requestRender re-renders the owner's
540
+ // selfRenderContainer with the updated Text.
541
+ record.group.callText?.setText(formatGroup(record.group, theme));
372
542
  if (record.group.renderOwner !== record) return new Text("", 0, 0);
373
543
  if (expanded && !options.isPartial) {
374
544
  const output = formatExpandedOutput(result, theme);
@@ -416,4 +586,4 @@ export class CompactRenderer {
416
586
  }
417
587
  }
418
588
 
419
- export { BULLET, DISCOVERY_TOOLS, GROUPABLE_TOOLS };
589
+ export { BULLET, DISCOVERY_TOOLS, GROUPABLE_TOOLS, bashGrepInfo };