@pi-archimedes/ui 2.11.1 → 2.12.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/ui",
3
- "version": "2.11.1",
3
+ "version": "2.12.0",
4
4
  "description": "Terminal UI enhancements, styled bash tool rendering, and visual presentation components for Pi",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -28,12 +28,12 @@
28
28
  "./config": "./src/config.ts"
29
29
  },
30
30
  "dependencies": {
31
- "@pi-archimedes/core": "2.11.1"
31
+ "@pi-archimedes/core": "2.12.0"
32
32
  },
33
33
  "devDependencies": {
34
- "@earendil-works/pi-ai": "1.2.0",
35
- "@earendil-works/pi-coding-agent": "1.2.0",
36
- "@earendil-works/pi-tui": "1.2.0",
34
+ "@earendil-works/pi-ai": "1.4.0",
35
+ "@earendil-works/pi-coding-agent": "1.4.0",
36
+ "@earendil-works/pi-tui": "1.4.0",
37
37
  "typescript": "^6.0.0"
38
38
  },
39
39
  "peerDependencies": {
package/src/index.test.ts CHANGED
@@ -365,9 +365,10 @@ describe("packages/ui lifecycle and registration", () => {
365
365
  // plus its own animated indicator) and our border spinner say the same
366
366
  // thing — `editorSpinLabel` defaults to "Working" too — so with the spinner
367
367
  // on the row is hidden through pi's own API rather than left showing an
368
- // indicator-less label.
368
+ // indicator-less label. The indicator itself is never written: pi's setter
369
+ // is an unskeyed last-write-wins and belongs to whoever configures it (#147).
369
370
  expect(ui.setWorkingVisible).toHaveBeenCalledWith(false);
370
- expect(ui.setWorkingIndicator).toHaveBeenCalledWith(undefined);
371
+ expect(ui.setWorkingIndicator).not.toHaveBeenCalled();
371
372
  expect(ui.setEditorComponent).toHaveBeenCalledTimes(1);
372
373
  const editorFactory = ui.setEditorComponent.mock.calls[0]![0];
373
374
  const editorInstance = editorFactory(fakeTui, { borderColor: (s: string) => s } as EditorTheme, {} as KeybindingsManager);
@@ -428,11 +429,13 @@ describe("packages/ui lifecycle and registration", () => {
428
429
 
429
430
  triggerStart(ctx);
430
431
 
431
- // No border spinner, so pi's own working row comes back, looking exactly
432
- // like pi's default: visible, with its own animated indicator (omitting the
433
- // argument is how pi restores the default frames).
434
- expect(ui.setWorkingVisible).toHaveBeenCalledWith(true);
435
- expect(ui.setWorkingIndicator).toHaveBeenCalledWith(undefined);
432
+ // No border spinner, so nothing here touches pi's working row at all: pi's
433
+ // own row and its own animated indicator stand exactly as pi (and any other
434
+ // extension) left them. Writing `setWorkingVisible(true)` would un-hide the
435
+ // row for a user who never asked for it, and for an extension that hid it on
436
+ // purpose to draw its own spinner (#147).
437
+ expect(ui.setWorkingVisible).not.toHaveBeenCalled();
438
+ expect(ui.setWorkingIndicator).not.toHaveBeenCalled();
436
439
  expect(ui.setEditorComponent).not.toHaveBeenCalled();
437
440
  });
438
441
 
@@ -473,7 +476,11 @@ describe("packages/ui lifecycle and registration", () => {
473
476
  triggerStart(ctx);
474
477
  triggerShutdown(ctx);
475
478
 
476
- expect(ui.setWorkingIndicator).toHaveBeenLastCalledWith(undefined);
479
+ // The row was hidden by this session (`editorSpinBorder` defaults to on), so
480
+ // hiding it is the one thing handed back. The indicator is not: pi resets it
481
+ // itself before the next session, and our writing it is what overwrote a
482
+ // sibling extension's spinner in 2.11.1 (#147).
483
+ expect(ui.setWorkingIndicator).not.toHaveBeenCalled();
477
484
  expect(ui.setHiddenThinkingLabel).toHaveBeenLastCalledWith(undefined);
478
485
  expect(ui.setWorkingVisible).toHaveBeenLastCalledWith(true);
479
486
  });
@@ -482,8 +489,11 @@ describe("packages/ui lifecycle and registration", () => {
482
489
  // lives in the editor's own top border and its default label is "Working" as
483
490
  // well. With both on screen the word appeared twice, which is what the
484
491
  // maintainer's screenshot shows. The setting that decides is
485
- // `editorSpinBorder`, and the row is handed back and taken through pi's own
486
- // `setWorkingVisible()` — `types.d.ts:88`.
492
+ // `editorSpinBorder`, and hiding the row goes through pi's own
493
+ // `setWorkingVisible()` — `types.d.ts:88`. Only that one direction is ours to
494
+ // write: pi's working-row setters carry no key or scope (`types.d.ts`
495
+ // `setWorkingIndicator` is a plain assignment, `interactive-mode.js:1831-1837`),
496
+ // so anything more we assert overwrites whichever extension wrote last (#147).
487
497
  describe("pi's working row and the border spinner", () => {
488
498
  it("hides pi's working row while the border spinner carries the announcement", () => {
489
499
  vi.mocked(loadUIConfig).mockReturnValue({ ...DEFAULT_UI_CONFIG, editorSpinBorder: true });
@@ -493,29 +503,31 @@ describe("packages/ui lifecycle and registration", () => {
493
503
  triggerStart(ctx);
494
504
 
495
505
  expect(ui.setWorkingVisible).toHaveBeenLastCalledWith(false);
496
- // No indicator configuration is needed once the row is hidden — and
497
- // nothing here asks pi for `frames: []` any more, so the row that *is*
498
- // shown (spinner off) is pi's default down to its spinner.
499
- expect(ui.setWorkingIndicator).toHaveBeenCalledWith(undefined);
500
- expect(ui.setWorkingIndicator).not.toHaveBeenCalledWith({ frames: [] });
506
+ // Nothing about the indicator is ours to write — neither `frames: []` nor
507
+ // the `undefined` that means "pi's default spinner". Whoever configures a
508
+ // spinner owns it; pi restores its own default per session.
509
+ expect(ui.setWorkingIndicator).not.toHaveBeenCalled();
501
510
  });
502
511
 
503
- it("shows pi's working row, with pi's own indicator, when the spinner is off", () => {
512
+ it("leaves pi's working row and indicator entirely alone when the spinner is off", () => {
504
513
  vi.mocked(loadUIConfig).mockReturnValue({ ...DEFAULT_UI_CONFIG, editorSpinBorder: false });
505
514
  const { triggerStart } = setupHarness();
506
515
  const { ctx, ui } = makeCtx(true);
507
516
 
508
517
  triggerStart(ctx);
509
518
 
510
- expect(ui.setWorkingVisible).toHaveBeenLastCalledWith(true);
511
- expect(ui.setWorkingIndicator).toHaveBeenCalledWith(undefined);
519
+ expect(ui.setWorkingVisible).not.toHaveBeenCalled();
520
+ expect(ui.setWorkingIndicator).not.toHaveBeenCalled();
512
521
  });
513
522
 
514
- it("re-reads the setting for every session, so a replaced session gets the row back", () => {
515
- // pi resets the extension UI on session replacement (`resetExtensionUI()`,
516
- // interactive-mode.js:1921 sets `workingVisible = true` again), and the
517
- // setting can change between sessions, so the answer has to be read from
518
- // the config each time rather than remembered from the first install.
523
+ it("reads the setting per session and asserts only the direction it asks for", () => {
524
+ // The setting can change between sessions, so the answer is read from the
525
+ // config every time rather than remembered from the first install — and
526
+ // only the `false` direction is ever written. The row coming back is pi's
527
+ // job, not ours: `resetExtensionUI()` sets `workingVisible = true`
528
+ // (`interactive-mode.js:1921`) on startup defaults and on every session
529
+ // replacement and `/reload`, so a spinner-off session needs no handback
530
+ // from us and cannot be asserted from here.
519
531
  vi.mocked(loadUIConfig).mockReturnValue({ ...DEFAULT_UI_CONFIG, editorSpinBorder: true });
520
532
  const { triggerStart } = setupHarness();
521
533
  const spinnerOn = makeCtx(true);
@@ -525,10 +537,11 @@ describe("packages/ui lifecycle and registration", () => {
525
537
  const spinnerOff = makeCtx(true);
526
538
  triggerStart(spinnerOff.ctx);
527
539
 
528
- // Two sessions, two answers: the row went away and came back.
540
+ // Two sessions, two answers: the row was hidden for the first and left
541
+ // completely untouched for the second.
529
542
  const calls = spinnerOn.ui.setWorkingVisible.mock.calls.map((c) => c[0]);
530
543
  expect(calls).toEqual([false]);
531
- expect(spinnerOff.ui.setWorkingVisible.mock.calls.map((c) => c[0])).toEqual([true]);
544
+ expect(spinnerOff.ui.setWorkingVisible.mock.calls.map((c) => c[0])).toEqual([]);
532
545
  });
533
546
 
534
547
  it("hides the row for a session installed on a second pi handle", () => {
@@ -548,6 +561,43 @@ describe("packages/ui lifecycle and registration", () => {
548
561
  expect(firstCtx.ui.setWorkingVisible).toHaveBeenLastCalledWith(false);
549
562
  expect(secondCtx.ui.setWorkingVisible).toHaveBeenLastCalledWith(false);
550
563
  });
564
+
565
+ it("leaves a sibling extension's custom working indicator alone (#147)", () => {
566
+ vi.mocked(loadUIConfig).mockReturnValue({ ...DEFAULT_UI_CONFIG, editorSpinBorder: false });
567
+ const { pi, listeners } = makePi();
568
+ const { ctx, ui } = makeCtx(true);
569
+
570
+ // Stands in for another extension in `~/.pi/agent/extensions/`: it configures
571
+ // pi's spinner on session start, and pi gives no key or scoping to that call.
572
+ // Registered before ours because `setupHarness()` cannot be used here — it
573
+ // registers ours first — and pi does not sort extension files, so whichever
574
+ // order `readdirSync` yields is the order the handlers run in.
575
+ pi.on("session_start", (_event, siblingCtx) => {
576
+ siblingCtx.ui.setWorkingIndicator({ frames: ["◐"], intervalMs: 80 });
577
+ });
578
+ registerUI(pi);
579
+
580
+ for (const handler of listeners["session_start"] ?? []) handler(null, ctx);
581
+
582
+ // Exactly one call — the sibling's — and nothing of ours after it. Asserting
583
+ // the count (not just "never called with undefined") is what catches a
584
+ // handback written as `setWorkingIndicator()` with no argument at all.
585
+ expect(ui.setWorkingIndicator).toHaveBeenCalledTimes(1);
586
+ expect(ui.setWorkingIndicator).toHaveBeenCalledWith({ frames: ["◐"], intervalMs: 80 });
587
+ });
588
+
589
+ it("does not assert the working row visible on shutdown when the spinner was never on", () => {
590
+ vi.mocked(loadUIConfig).mockReturnValue({ ...DEFAULT_UI_CONFIG, editorSpinBorder: false });
591
+ const { triggerStart, triggerShutdown } = setupHarness();
592
+ const { ctx, ui } = makeCtx(true);
593
+
594
+ triggerStart(ctx);
595
+ triggerShutdown(ctx);
596
+
597
+ // Nothing was taken, so nothing is returned — an unconditional
598
+ // `setWorkingVisible(true)` here is the second half of the same overwrite bug.
599
+ expect(ui.setWorkingVisible).not.toHaveBeenCalled();
600
+ });
551
601
  });
552
602
 
553
603
  it("registers no bash tool and yields pi's native row when bashToolStyling is false", () => {
package/src/index.ts CHANGED
@@ -139,13 +139,15 @@ export function registerUI(pi: ExtensionAPI): void {
139
139
  pi.on("session_shutdown", (_event, ctx) => {
140
140
  const targetCtx = ctx?.hasUI ? ctx : uiCtx;
141
141
  if (targetCtx?.hasUI) {
142
- // Give the working row back whichever way we left it: a session that
143
- // started with the border spinner on had hidden it (`setWorkingVisible(false)`).
144
- targetCtx.ui.setWorkingVisible(true);
145
- // Give back everything the session borrowed: pi's animated indicator and
146
- // its own hidden-thinking wording, so a session that starts after this
147
- // one (or after `/reload` with the plugin off) sees pi's defaults.
148
- targetCtx.ui.setWorkingIndicator(undefined);
142
+ // Give back what this session took: the row we hid for the border spinner,
143
+ // and only if we hid it. pi resets the indicator and the row's visibility
144
+ // itself before the next session starts (`resetExtensionUI()`), and asserting
145
+ // a default here would overwrite another extension's choice — so the
146
+ // indicator is not touched on the way out either (#147, ADR 0031).
147
+ if (spinFlag) targetCtx.ui.setWorkingVisible(true);
148
+ // These two ARE ours to hand back: `labelText` is an archimedes setting and
149
+ // the editor component is ours outright, so both calls undo state this
150
+ // session demonstrably took rather than asserting a default.
149
151
  targetCtx.ui.setHiddenThinkingLabel(undefined);
150
152
  targetCtx.ui.setEditorComponent(undefined);
151
153
  }
@@ -304,27 +306,23 @@ export function registerUI(pi: ExtensionAPI): void {
304
306
  // One announcement, in one place. pi's working row writes "Working" above
305
307
  // the editor and our border spinner writes its label — `editorSpinLabel`
306
308
  // defaults to "Working" as well — into the editor's own top border, so with
307
- // both on screen the word appeared twice. The border is ours, so the row is
308
- // handed pi's own switch: hidden while the spinner runs, given back when it
309
- // does not.
309
+ // both on screen the word appeared twice. The border is ours, so while it
310
+ // spins the row is hidden through pi's own switch (`setWorkingVisible()`,
311
+ // `types.d.ts:88`).
310
312
  //
311
- // `setWorkingVisible()` is official API (`types.d.ts:88`), and hiding the
312
- // row is what makes the indicator configuration `#106` needed redundant:
313
- // pi's `setWorkingVisible(false)` clears the working status indicator
314
- // outright and its `turn_start` handler honours the flag
315
- // (`interactive-mode.js:1819-1828`, `:2762-2769`), so there is no row left
316
- // for a `frames: []` to silence. The indicator is still handed back to pi
317
- // unconditionally, so the row a user *does* see is pi's default down to its
318
- // spinner.
313
+ // Only the direction this setting asks for is written. `setWorkingIndicator`
314
+ // is never called, and the row is never asserted visible: pi restores both
315
+ // itself wherever they could have been altered (`resetExtensionUI()`:
316
+ // `workingVisible = true` at `interactive-mode.js:1921`, `setWorkingIndicator()`
317
+ // at `:1922`, reached by startup defaults and by `/new`, `/resume`, `/fork`
318
+ // and `/reload`), and asserting either would overwrite another extension's
319
+ // choice — pi's working-row setters are an unskeyed last-write-wins
320
+ // (`:1831-1837`). Writing `setWorkingIndicator(undefined)` unconditionally is
321
+ // how 2.11.1 deleted a user's customised spinner (#147, ADR 0031).
319
322
  //
320
323
  // Read per session rather than remembered: `editorSpinBorder` can change
321
- // between sessions, and pi puts the row back on whenever it replaces the
322
- // session (`resetExtensionUI()` → `workingVisible = true`, `:1921`). The
323
- // toggle needs no keying on the `pi` handle the way a registration does —
324
- // it is one idempotent setter call inside the `session_start` handler, and
325
- // every handle gets its own handler.
326
- ctx.ui.setWorkingVisible(!spinFlag);
327
- ctx.ui.setWorkingIndicator(undefined);
324
+ // between sessions, and every `pi` handle gets its own handler.
325
+ if (spinFlag) ctx.ui.setWorkingVisible(false);
328
326
 
329
327
  // The word a collapsed thinking block shows is pi's own row, so the
330
328
  // setting is handed to pi rather than rebuilt here. An empty setting