@herbertgao/pi-extensions 2026.9.12 → 2026.9.13

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 (42) hide show
  1. package/README.md +4 -4
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
  4. package/node_modules/@herbertgao/pi-subagents/package.json +1 -1
  5. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +5 -4
  6. package/node_modules/@herbertgao/pi-subagents/src/mention-clone.ts +39 -16
  7. package/node_modules/@narumitw/pi-btw/README.md +21 -4
  8. package/node_modules/@narumitw/pi-btw/dist/index.ts +1668 -958
  9. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
  10. package/node_modules/@narumitw/pi-btw/docs/workflows.md +10 -2
  11. package/node_modules/@narumitw/pi-btw/package.json +1 -1
  12. package/node_modules/@narumitw/pi-btw/src/btw.ts +17 -79
  13. package/node_modules/@narumitw/pi-btw/src/conversation-context.ts +74 -0
  14. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +196 -7
  15. package/node_modules/@narumitw/pi-btw/src/main-thread-updates.ts +40 -0
  16. package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -2
  17. package/node_modules/@narumitw/pi-btw/src/settings.ts +49 -0
  18. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +9 -1
  19. package/node_modules/@narumitw/pi-btw/src/workspace-layout.ts +559 -0
  20. package/node_modules/pi-multi-account/CHANGELOG.md +23 -0
  21. package/node_modules/pi-multi-account/README.md +38 -11
  22. package/node_modules/pi-multi-account/index.ts +371 -96
  23. package/node_modules/pi-multi-account/package.json +5 -4
  24. package/node_modules/pi-multi-account/provider-payload-stream.ts +36 -28
  25. package/node_modules/pi-multi-account/usage.ts +31 -2
  26. package/node_modules/pi-typesafe/README.md +3 -1
  27. package/node_modules/pi-typesafe/dist/auth.d.ts +10 -3
  28. package/node_modules/pi-typesafe/dist/auth.js +13 -7
  29. package/node_modules/pi-typesafe/dist/backends.d.ts +31 -0
  30. package/node_modules/pi-typesafe/dist/backends.js +33 -0
  31. package/node_modules/pi-typesafe/dist/client.d.ts +3 -9
  32. package/node_modules/pi-typesafe/dist/client.js +63 -39
  33. package/node_modules/pi-typesafe/dist/credentials.d.ts +11 -5
  34. package/node_modules/pi-typesafe/dist/credentials.js +15 -8
  35. package/node_modules/pi-typesafe/dist/extension.js +4 -1
  36. package/node_modules/pi-typesafe/dist/index.d.ts +1 -1
  37. package/node_modules/pi-typesafe/dist/index.js +1 -1
  38. package/node_modules/pi-typesafe/dist/login.d.ts +13 -7
  39. package/node_modules/pi-typesafe/dist/login.js +17 -8
  40. package/node_modules/pi-typesafe/dist/schema.js +19 -13
  41. package/node_modules/pi-typesafe/package.json +1 -1
  42. package/package.json +5 -5
@@ -17,8 +17,16 @@ Opening and closing without a new result does not reorder them.
17
17
 
18
18
  ## Read, select, and search
19
19
 
20
- The fullscreen workspace keeps answers above the editor and identifies itself with a fixed `btw · side thread` header.
21
- The main agent may keep running, but its rendering is suspended so new output cannot move a mouse selection; returning to Pi redraws that output.
20
+ The dedicated workspace keeps answers above the editor and identifies itself with a fixed `btw · side thread` header.
21
+ The default **Fullscreen** layout gives the side thread the complete workspace.
22
+ **Side thread left** and **Side thread right** place it beside Pi's native, live main-thread rendering; terminals narrower than 80 columns show only the side thread.
23
+ A single muted divider separates the panes; click either pane to move keyboard focus to it.
24
+ Drag the divider with the primary mouse button to resize the side thread between 20% and 80% of the workspace.
25
+ Releasing the button saves that side-thread ratio for either pane placement; a failed save restores the last saved width and reports the error.
26
+ Pi's search and keyboard viewport controls apply to the active pane.
27
+ The mouse wheel scrolls the pane under the pointer without moving keyboard focus, and a narrow terminal returns focus to the visible side thread.
28
+ A branch selected through **Start from main thread tree…** supplies side-model context but does not replace the active main thread shown in the other pane.
29
+ The main agent may keep running, and the main pane redraws its progress while either pane is active.
22
30
  Submit each question with Enter; successful prior questions and answers remain available to the side model.
23
31
 
24
32
  Drag the primary mouse button across the transcript to select text.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.60.3",
3
+ "version": "0.61.0",
4
4
  "description": "Pi extension that adds a /btw side-question command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,7 +12,9 @@ import {
12
12
  getAnsweredTurns,
13
13
  summarizeBringToMain,
14
14
  } from "./bring-to-main.js";
15
+ import { buildConversationContext } from "./conversation-context.js";
15
16
  import { type RunBtwFullscreen, runBtwFullscreen } from "./fullscreen-ui.js";
17
+ import { registerBtwMainThreadUpdates } from "./main-thread-updates.js";
16
18
  import { pickMainEntry } from "./main-tree-picker.js";
17
19
  import {
18
20
  type BtwCommandMenuResult,
@@ -23,6 +25,8 @@ import {
23
25
  } from "./menu.js";
24
26
  import {
25
27
  type BtwSettings,
28
+ effectiveBtwLayout,
29
+ effectiveBtwSidePaneRatio,
26
30
  effectiveFullscreenCopyOnSelect,
27
31
  effectiveRememberThinkingLevelChanges,
28
32
  parseBtwModelReference,
@@ -47,8 +51,11 @@ import {
47
51
  type TranscriptPagerAction,
48
52
  } from "./transcript-pager.js";
49
53
 
54
+ export { buildConversationContext } from "./conversation-context.js";
50
55
  export {
56
+ BTW_LAYOUTS,
51
57
  BTW_SETTINGS_FILE,
58
+ type BtwLayout,
52
59
  type BtwSettings,
53
60
  type BtwSettingsLoadResult,
54
61
  normalizeBtwSettings,
@@ -63,8 +70,6 @@ export {
63
70
  } from "./side-thread.js";
64
71
  export { sanitizeSingleLine } from "./text.js";
65
72
 
66
- const MAX_CONTEXT_CHARS = 40_000;
67
-
68
73
  interface LoadBtwThinkingLevelOptions {
69
74
  settingsPath?: string;
70
75
  warn?: (message: string) => void;
@@ -151,7 +156,7 @@ export async function loadBtwThinkingLevel(
151
156
 
152
157
  options.warn?.(
153
158
  sanitizeSingleLine(
154
- `pi-btw settings ignored: ${settings.reason}; expected optional model "provider/model-id", omitted thinkingLevel for Same as main thread or thinkingLevel "${BTW_THINKING_LEVELS.join('" | "')}", boolean rememberThinkingLevelChanges, and boolean fullscreenCopyOnSelect. Using current Pi thinking level.`,
159
+ `pi-btw settings ignored: ${settings.reason}; expected optional model "provider/model-id", omitted thinkingLevel for Same as main thread or thinkingLevel "${BTW_THINKING_LEVELS.join('" | "')}", boolean rememberThinkingLevelChanges, boolean fullscreenCopyOnSelect, layout "fullscreen" | "left-pane" | "right-pane", and sidePaneRatio from 0.2 to 0.8. Using current Pi thinking level.`,
155
160
  ),
156
161
  );
157
162
  return currentThinkingLevel;
@@ -180,9 +185,9 @@ function notifySafely(
180
185
  }
181
186
  }
182
187
 
183
- // Keep this slightly-over-1,000-line command coordinator intact: its injectable menu,
184
- // request, resume, and delivery flows share the same thread-state and test seams;
185
- // settings, keybinding policy, terminal ownership, and rendering live in separate modules.
188
+ // Keep the command coordinator intact: its injectable menu, request, resume, and
189
+ // delivery flows share the same thread-state and test seams; settings, keybinding
190
+ // policy, terminal ownership, and rendering live in separate modules.
186
191
  export interface BtwExtensionDependencies {
187
192
  showCommandMenu?: (
188
193
  pi: ExtensionAPI,
@@ -203,6 +208,7 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
203
208
  const resolveModel = dependencies.resolveModel ?? resolveBtwModelForCommand;
204
209
  const runThread = dependencies.runThread ?? runBtwThread;
205
210
  const runFullscreen = dependencies.runFullscreen ?? runBtwFullscreen;
211
+ const subscribeMainThreadUpdates = registerBtwMainThreadUpdates(pi);
206
212
  // Pi creates a fresh extension instance after session replacement or reload.
207
213
  const resumableThreads = new Map<string, BtwThreadState>();
208
214
  let nextThreadNumber = 1;
@@ -302,6 +308,11 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
302
308
  },
303
309
  {
304
310
  copyOnSelect: effectiveFullscreenCopyOnSelect(settings),
311
+ layout: effectiveBtwLayout(settings),
312
+ sidePaneRatio: effectiveBtwSidePaneRatio(settings),
313
+ persistSidePaneRatio: (ratio, signal) =>
314
+ updateBtwSettings({ sidePaneRatio: ratio }, { signal }).then(() => undefined),
315
+ subscribeMainThreadUpdates: (listener) => subscribeMainThreadUpdates(ctx.sessionManager, listener),
305
316
  ...(settings.keybindings ? { keybindings: settings.keybindings } : {}),
306
317
  },
307
318
  );
@@ -837,76 +848,3 @@ async function showThreadComposer(
837
848
  }),
838
849
  );
839
850
  }
840
-
841
- type MessageContentBlock = {
842
- type?: string;
843
- text?: string;
844
- name?: string;
845
- arguments?: unknown;
846
- result?: unknown;
847
- };
848
-
849
- type SessionMessage = {
850
- role?: string;
851
- content?: unknown;
852
- stopReason?: string;
853
- };
854
-
855
- type SessionEntry = {
856
- type: string;
857
- message?: SessionMessage;
858
- };
859
-
860
- export function buildConversationContext(entries: readonly SessionEntry[]) {
861
- const sections: string[] = [];
862
-
863
- for (const entry of entries) {
864
- if (entry.type !== "message" || !entry.message?.role) continue;
865
-
866
- const role = entry.message.role;
867
- if (role !== "user" && role !== "assistant") continue;
868
-
869
- const contentLines = extractContentLines(entry.message.content);
870
- if (contentLines.length === 0) continue;
871
-
872
- const label = role === "user" ? "User" : "Assistant";
873
- const status =
874
- entry.message.stopReason && entry.message.stopReason !== "stop" ? ` (${entry.message.stopReason})` : "";
875
- sections.push(`${label}${status}: ${contentLines.join("\n")}`);
876
- }
877
-
878
- return truncateFromStart(sections.join("\n\n"), MAX_CONTEXT_CHARS);
879
- }
880
-
881
- function extractContentLines(content: unknown): string[] {
882
- if (typeof content === "string") return [content.trim()].filter(Boolean);
883
- if (!Array.isArray(content)) return [];
884
-
885
- const lines: string[] = [];
886
- for (const part of content) {
887
- if (!part || typeof part !== "object") continue;
888
- const block = part as MessageContentBlock;
889
- if (block.type === "text" && typeof block.text === "string") {
890
- lines.push(block.text.trim());
891
- } else if (block.type === "toolCall" && typeof block.name === "string") {
892
- lines.push(`Tool call: ${block.name}(${formatJson(block.arguments)})`);
893
- } else if (block.type === "toolResult" && typeof block.name === "string") {
894
- lines.push(`Tool result from ${block.name}: ${formatJson(block.result)}`);
895
- }
896
- }
897
- return lines.filter(Boolean);
898
- }
899
-
900
- function formatJson(value: unknown) {
901
- if (value === undefined) return "";
902
- try {
903
- return JSON.stringify(value);
904
- } catch {
905
- return String(value);
906
- }
907
- }
908
-
909
- function truncateFromStart(text: string, maxChars: number) {
910
- if (text.length <= maxChars) return text;
911
- return `[Earlier context omitted; showing the last ${maxChars} characters.]\n${text.slice(-maxChars)}`;
912
- }
@@ -0,0 +1,74 @@
1
+ const MAX_CONTEXT_CHARS = 40_000;
2
+
3
+ type MessageContentBlock = {
4
+ type?: string;
5
+ text?: string;
6
+ name?: string;
7
+ arguments?: unknown;
8
+ result?: unknown;
9
+ };
10
+
11
+ type SessionMessage = {
12
+ role?: string;
13
+ content?: unknown;
14
+ stopReason?: string;
15
+ };
16
+
17
+ export type BtwSessionEntry = {
18
+ type: string;
19
+ message?: SessionMessage;
20
+ };
21
+
22
+ export function buildConversationContext(entries: readonly BtwSessionEntry[]): string {
23
+ const sections: string[] = [];
24
+
25
+ for (const entry of entries) {
26
+ if (entry.type !== "message" || !entry.message?.role) continue;
27
+
28
+ const role = entry.message.role;
29
+ if (role !== "user" && role !== "assistant") continue;
30
+
31
+ const contentLines = extractContentLines(entry.message.content);
32
+ if (contentLines.length === 0) continue;
33
+
34
+ const label = role === "user" ? "User" : "Assistant";
35
+ const status =
36
+ entry.message.stopReason && entry.message.stopReason !== "stop" ? ` (${entry.message.stopReason})` : "";
37
+ sections.push(`${label}${status}: ${contentLines.join("\n")}`);
38
+ }
39
+
40
+ return truncateFromStart(sections.join("\n\n"), MAX_CONTEXT_CHARS);
41
+ }
42
+
43
+ function extractContentLines(content: unknown): string[] {
44
+ if (typeof content === "string") return [content.trim()].filter(Boolean);
45
+ if (!Array.isArray(content)) return [];
46
+
47
+ const lines: string[] = [];
48
+ for (const part of content) {
49
+ if (!part || typeof part !== "object") continue;
50
+ const block = part as MessageContentBlock;
51
+ if (block.type === "text" && typeof block.text === "string") {
52
+ lines.push(block.text.trim());
53
+ } else if (block.type === "toolCall" && typeof block.name === "string") {
54
+ lines.push(`Tool call: ${block.name}(${formatJson(block.arguments)})`);
55
+ } else if (block.type === "toolResult" && typeof block.name === "string") {
56
+ lines.push(`Tool result from ${block.name}: ${formatJson(block.result)}`);
57
+ }
58
+ }
59
+ return lines.filter(Boolean);
60
+ }
61
+
62
+ function formatJson(value: unknown): string {
63
+ if (value === undefined) return "";
64
+ try {
65
+ return JSON.stringify(value);
66
+ } catch {
67
+ return String(value);
68
+ }
69
+ }
70
+
71
+ function truncateFromStart(text: string, maxChars: number): string {
72
+ if (text.length <= maxChars) return text;
73
+ return `[Earlier context omitted; showing the last ${maxChars} characters.]\n${text.slice(-maxChars)}`;
74
+ }
@@ -12,6 +12,7 @@ import {
12
12
  Key,
13
13
  type OverlayHandle,
14
14
  parseKey,
15
+ type ScrollView,
15
16
  type TUI,
16
17
  TuiAltScreen,
17
18
  type TuiInputListener,
@@ -19,7 +20,10 @@ import {
19
20
  truncateToWidth,
20
21
  } from "@earendil-works/pi-tui";
21
22
  import { type BtwKeybindingOverrides, BtwPasteGuard, resolveBtwShortcuts, setBtwShortcuts } from "./keybindings.js";
23
+ import type { BtwMainThreadUpdateSubscription } from "./main-thread-updates.js";
24
+ import type { BtwLayout } from "./settings.js";
22
25
  import { formatKeyLabel, sanitizeSingleLine } from "./text.js";
26
+ import { type BtwFullscreenLayoutComponent, BtwMainThreadInput, BtwSplitPane } from "./workspace-layout.js";
23
27
 
24
28
  type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
25
29
  type BtwCustomFactory<T> = (
@@ -32,17 +36,23 @@ type BtwCustomFactory<T> = (
32
36
  type BtwFullscreenTui = TUI & {
33
37
  flash?: (message: string, durationMs?: number) => void;
34
38
  setLayoutRoot(component: Component | undefined): void;
39
+ hasFocusedOverlay?(): boolean;
35
40
  addInputListenerBeforeAll?(listener: TuiInputListener): () => void;
36
41
  addInputListenerBeforeViewport?(listener: TuiInputListener): () => void;
42
+ setViewportTarget?(scrollView: ScrollView | undefined): void;
37
43
  };
38
44
 
39
- export interface BtwFullscreenLayoutComponent extends Component {
40
- getFullscreenLayout(): Component;
41
- }
45
+ type FocusInspectableTui = TUI & {
46
+ getFocusedComponent?(): Component | null;
47
+ };
42
48
 
43
49
  export interface BtwFullscreenOptions {
44
50
  keybindings?: BtwKeybindingOverrides;
45
51
  copyOnSelect?: boolean;
52
+ layout?: BtwLayout;
53
+ sidePaneRatio?: number;
54
+ persistSidePaneRatio?(ratio: number, signal: AbortSignal): Promise<void>;
55
+ subscribeMainThreadUpdates?: BtwMainThreadUpdateSubscription;
46
56
  }
47
57
 
48
58
  export type BtwFullscreenTuiFactory = (
@@ -158,6 +168,12 @@ class BtwTuiAltScreen extends TuiAltScreen {
158
168
  return this.isOverlayFocused();
159
169
  }
160
170
 
171
+ setViewportTarget(scrollView: ScrollView | undefined): void {
172
+ if (!scrollView) return;
173
+ const layout = Reflect.get(this, "currentLayout") as { primaryScrollView?: ScrollView } | undefined;
174
+ if (layout) layout.primaryScrollView = scrollView;
175
+ }
176
+
161
177
  override addInputListener(listener: TuiInputListener): () => void {
162
178
  let listeners = btwInputListeners.get(this);
163
179
  if (!listeners) {
@@ -419,7 +435,14 @@ class BtwFullscreenHost<T> implements Component {
419
435
  private parentRestoreQueued = false;
420
436
  private parentRestorePromise: Promise<void> | undefined;
421
437
  private cleanupError: unknown;
438
+ private removeMainThreadUpdateListener: (() => void) | undefined;
439
+ private mainThreadRefreshTimer: ReturnType<typeof setTimeout> | undefined;
440
+ private readonly mainThreadInput: BtwMainThreadInput;
422
441
  private readonly lifetimeController = new AbortController();
442
+ private readonly pendingSidePaneWrites = new Set<Promise<void>>();
443
+ private sidePaneRatio: number | undefined;
444
+ private sidePaneWriteGeneration = 0;
445
+ private confirmedSidePaneWriteGeneration = 0;
423
446
 
424
447
  constructor(
425
448
  private readonly parent: TUI,
@@ -431,6 +454,16 @@ class BtwFullscreenHost<T> implements Component {
431
454
  private readonly createTui: BtwFullscreenTuiFactory,
432
455
  private readonly options: BtwFullscreenOptions,
433
456
  ) {
457
+ const initialMainInput = getFocusedComponent(parent);
458
+ this.mainThreadInput = new BtwMainThreadInput(
459
+ initialMainInput,
460
+ () => {
461
+ const target = getFocusedComponent(parent);
462
+ return target === this ? initialMainInput : target;
463
+ },
464
+ () => this.fullscreen?.requestRender(),
465
+ );
466
+ this.sidePaneRatio = options.sidePaneRatio;
434
467
  queueMicrotask(() => void this.start());
435
468
  }
436
469
 
@@ -464,6 +497,7 @@ class BtwFullscreenHost<T> implements Component {
464
497
  this.fullscreen = this.createTui(this.parent, this.theme, this.keybindings, this.options);
465
498
  this.fullscreenCreated = true;
466
499
  this.fullscreen.start();
500
+ this.watchMainThreadUpdates();
467
501
  const shortcuts = resolveBtwShortcuts(
468
502
  this.options.keybindings,
469
503
  this.keybindings,
@@ -507,8 +541,11 @@ class BtwFullscreenHost<T> implements Component {
507
541
  }
508
542
  return { consume: true };
509
543
  });
510
- outcome = { kind: "completed", value: await this.run(this.createContext()) };
544
+ const value = await this.run(this.createContext());
545
+ await this.waitForSidePaneWrites();
546
+ outcome = { kind: "completed", value };
511
547
  } catch (error) {
548
+ await this.waitForSidePaneWrites();
512
549
  outcome = { kind: "failed", error };
513
550
  }
514
551
 
@@ -547,7 +584,32 @@ class BtwFullscreenHost<T> implements Component {
547
584
  });
548
585
  }
549
586
 
587
+ private watchMainThreadUpdates(): void {
588
+ if ((this.options.layout ?? "fullscreen") === "fullscreen") return;
589
+ this.removeMainThreadUpdateListener = this.options.subscribeMainThreadUpdates?.(() => {
590
+ this.mainThreadInput.refreshTarget();
591
+ if (this.mainThreadRefreshTimer || this.disposed || this.finished) return;
592
+ this.mainThreadRefreshTimer = setTimeout(() => {
593
+ this.mainThreadRefreshTimer = undefined;
594
+ if (!this.disposed && !this.finished) this.fullscreen?.requestRender();
595
+ }, 0);
596
+ this.mainThreadRefreshTimer.unref();
597
+ });
598
+ }
599
+
550
600
  private restoreParent(): void {
601
+ if (this.mainThreadRefreshTimer) {
602
+ clearTimeout(this.mainThreadRefreshTimer);
603
+ this.mainThreadRefreshTimer = undefined;
604
+ }
605
+ const removeMainThreadUpdateListener = this.removeMainThreadUpdateListener;
606
+ this.removeMainThreadUpdateListener = undefined;
607
+ try {
608
+ removeMainThreadUpdateListener?.();
609
+ } catch (error) {
610
+ this.cleanupError ??= error;
611
+ }
612
+ this.mainThreadInput.dispose();
551
613
  const removeUpstreamAbortListener = this.removeUpstreamAbortListener;
552
614
  this.removeUpstreamAbortListener = undefined;
553
615
  try {
@@ -629,6 +691,8 @@ class BtwFullscreenHost<T> implements Component {
629
691
  return new Promise<Value>((resolve, reject) => {
630
692
  let component: (Component & { dispose?(): void }) | undefined;
631
693
  let overlay: OverlayHandle | undefined;
694
+ let splitPane: BtwSplitPane | undefined;
695
+ let removePaneFocusListener: (() => void) | undefined;
632
696
  let mounted = false;
633
697
  let layoutMounted = false;
634
698
  let factorySettled = false;
@@ -649,12 +713,21 @@ class BtwFullscreenHost<T> implements Component {
649
713
  };
650
714
  const unmount = () => {
651
715
  let cleanupError: unknown;
716
+ const removeFocusListener = removePaneFocusListener;
717
+ removePaneFocusListener = undefined;
718
+ try {
719
+ removeFocusListener?.();
720
+ } catch (error) {
721
+ cleanupError = error;
722
+ }
723
+ splitPane?.dispose();
724
+ splitPane = undefined;
652
725
  try {
653
726
  if (overlay) overlay.hide();
654
727
  else if (mounted && layoutMounted) fullscreen.setLayoutRoot(undefined);
655
728
  else if (mounted && component) fullscreen.removeChild(component);
656
729
  } catch (error) {
657
- cleanupError = error;
730
+ cleanupError ??= error;
658
731
  }
659
732
  if (overlay || mounted) {
660
733
  try {
@@ -728,7 +801,7 @@ class BtwFullscreenHost<T> implements Component {
728
801
  return;
729
802
  }
730
803
  Promise.resolve(created)
731
- .then((value) => {
804
+ .then(async (value) => {
732
805
  component = value;
733
806
  factorySettled = true;
734
807
  if (promiseSettled) {
@@ -739,6 +812,22 @@ class BtwFullscreenHost<T> implements Component {
739
812
  complete();
740
813
  return;
741
814
  }
815
+ const workspaceLayout = this.options.layout ?? "fullscreen";
816
+ if (!options?.overlay && workspaceLayout !== "fullscreen") {
817
+ await this.waitForSidePaneWrites();
818
+ if (promiseSettled) {
819
+ disposeComponent();
820
+ return;
821
+ }
822
+ if (closed) {
823
+ complete();
824
+ return;
825
+ }
826
+ if (this.disposed || this.finished || this.fullscreen !== fullscreen) {
827
+ fail(new FullscreenUiDisposedError());
828
+ return;
829
+ }
830
+ }
742
831
  if (options?.overlay) {
743
832
  const overlayOptions =
744
833
  typeof options.overlayOptions === "function" ? options.overlayOptions() : options.overlayOptions;
@@ -747,7 +836,38 @@ class BtwFullscreenHost<T> implements Component {
747
836
  } else {
748
837
  fullscreen.clear();
749
838
  mounted = true;
750
- if (isFullscreenLayoutComponent(component)) {
839
+ if (workspaceLayout !== "fullscreen") {
840
+ layoutMounted = true;
841
+ const sideLayout = isFullscreenLayoutComponent(component) ? component.getFullscreenLayout() : component;
842
+ splitPane = new BtwSplitPane({
843
+ sideComponent: component,
844
+ sideLayout,
845
+ mainThread: this.parent,
846
+ mainLayout: getParentFullscreenLayout(this.parent),
847
+ mainInput: this.mainThreadInput,
848
+ sideScrollView: isFullscreenLayoutComponent(component) ? component.getPrimaryScrollView?.() : undefined,
849
+ layout: workspaceLayout,
850
+ theme: this.theme,
851
+ terminalColumns: () => fullscreen.terminal.columns,
852
+ terminalRows: () => fullscreen.terminal.rows,
853
+ hasFocusedOverlay: () => fullscreen.hasFocusedOverlay?.() ?? false,
854
+ setFocus: (target) => fullscreen.setFocus(target),
855
+ setViewportTarget: (target) => fullscreen.setViewportTarget?.(target),
856
+ requestRender: () => fullscreen.requestRender(),
857
+ sidePaneRatio: this.sidePaneRatio,
858
+ ...(this.options.persistSidePaneRatio
859
+ ? { persistSidePaneRatio: (ratio: number) => this.persistSidePaneRatio(ratio) }
860
+ : {}),
861
+ });
862
+ const addPaneFocusListener =
863
+ fullscreen.addInputListenerBeforeViewport?.bind(fullscreen) ??
864
+ fullscreen.addInputListener.bind(fullscreen);
865
+ removePaneFocusListener = addPaneFocusListener((data) => {
866
+ const consumed = splitPane?.handleTerminalInput(data);
867
+ return consumed ? { consume: true } : undefined;
868
+ });
869
+ fullscreen.setLayoutRoot(splitPane.getFullscreenLayout());
870
+ } else if (isFullscreenLayoutComponent(component)) {
751
871
  layoutMounted = true;
752
872
  fullscreen.setLayoutRoot(component.getFullscreenLayout());
753
873
  } else {
@@ -760,6 +880,75 @@ class BtwFullscreenHost<T> implements Component {
760
880
  .catch(fail);
761
881
  });
762
882
  }
883
+
884
+ private persistSidePaneRatio(ratio: number): Promise<void> {
885
+ const persist = this.options.persistSidePaneRatio;
886
+ if (!persist) {
887
+ this.sidePaneRatio = ratio;
888
+ return Promise.resolve();
889
+ }
890
+ const writeGeneration = ++this.sidePaneWriteGeneration;
891
+ let task!: Promise<void>;
892
+ task = Promise.resolve()
893
+ .then(() => persist(ratio, this.lifetimeController.signal))
894
+ .then(() => {
895
+ if (this.lifetimeController.signal.aborted || writeGeneration <= this.confirmedSidePaneWriteGeneration) {
896
+ return;
897
+ }
898
+ this.confirmedSidePaneWriteGeneration = writeGeneration;
899
+ this.sidePaneRatio = ratio;
900
+ })
901
+ .catch((error: unknown) => {
902
+ if (!this.lifetimeController.signal.aborted) {
903
+ const message = sanitizeSingleLine(
904
+ `Pi BTW pane width was not saved; the previous value remains active: ${formatError(error)}`,
905
+ );
906
+ try {
907
+ this.ctx.ui.notify(message, "error");
908
+ } catch {
909
+ // A replaced command context must not prevent rollback or cleanup.
910
+ }
911
+ this.fullscreen?.flash?.(message);
912
+ }
913
+ throw error;
914
+ })
915
+ .finally(() => this.pendingSidePaneWrites.delete(task));
916
+ this.pendingSidePaneWrites.add(task);
917
+ return task;
918
+ }
919
+
920
+ private async waitForSidePaneWrites(): Promise<void> {
921
+ while (this.pendingSidePaneWrites.size > 0) {
922
+ await Promise.allSettled([...this.pendingSidePaneWrites]);
923
+ }
924
+ }
925
+ }
926
+
927
+ function formatError(error: unknown): string {
928
+ return error instanceof Error ? error.message : String(error);
929
+ }
930
+
931
+ function getFocusedComponent(tui: TUI): Component | null {
932
+ return (tui as FocusInspectableTui).getFocusedComponent?.() ?? null;
933
+ }
934
+
935
+ // Pi does not expose the mounted fullscreen layout root. Reusing this runtime
936
+ // field lets Pi's own layout engine constrain its transcript and dock to the pane.
937
+ function getParentFullscreenLayout(tui: TUI): Component | undefined {
938
+ if (tui.mode !== "fullscreen") return undefined;
939
+ const layoutRoot = Reflect.get(tui, "layoutRoot") as unknown;
940
+ return isComponent(layoutRoot) ? layoutRoot : undefined;
941
+ }
942
+
943
+ function isComponent(value: unknown): value is Component {
944
+ return (
945
+ typeof value === "object" &&
946
+ value !== null &&
947
+ "render" in value &&
948
+ typeof value.render === "function" &&
949
+ "invalidate" in value &&
950
+ typeof value.invalidate === "function"
951
+ );
763
952
  }
764
953
 
765
954
  function isFullscreenLayoutComponent(component: Component): component is BtwFullscreenLayoutComponent {
@@ -0,0 +1,40 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type BtwMainThreadUpdateSubscription = (listener: () => void) => () => void;
4
+ type SessionManager = ExtensionContext["sessionManager"];
5
+
6
+ export function registerBtwMainThreadUpdates(
7
+ pi: ExtensionAPI,
8
+ ): (sessionManager: SessionManager, listener: () => void) => () => void {
9
+ const listeners = new WeakMap<SessionManager, Set<() => void>>();
10
+ const notify = (ctx: ExtensionContext) => {
11
+ for (const listener of listeners.get(ctx.sessionManager) ?? []) listener();
12
+ };
13
+
14
+ pi.on("session_info_changed", (_event, ctx) => notify(ctx));
15
+ pi.on("session_compact", (_event, ctx) => notify(ctx));
16
+ pi.on("session_tree", (_event, ctx) => notify(ctx));
17
+ pi.on("agent_start", (_event, ctx) => notify(ctx));
18
+ pi.on("agent_end", (_event, ctx) => notify(ctx));
19
+ pi.on("agent_settled", (_event, ctx) => notify(ctx));
20
+ pi.on("turn_start", (_event, ctx) => notify(ctx));
21
+ pi.on("turn_end", (_event, ctx) => notify(ctx));
22
+ pi.on("message_start", (_event, ctx) => notify(ctx));
23
+ pi.on("message_update", (_event, ctx) => notify(ctx));
24
+ pi.on("message_end", (_event, ctx) => notify(ctx));
25
+ pi.on("tool_execution_start", (_event, ctx) => notify(ctx));
26
+ pi.on("tool_execution_update", (_event, ctx) => notify(ctx));
27
+ pi.on("tool_execution_end", (_event, ctx) => notify(ctx));
28
+ pi.on("model_select", (_event, ctx) => notify(ctx));
29
+ pi.on("thinking_level_select", (_event, ctx) => notify(ctx));
30
+
31
+ return (sessionManager, listener) => {
32
+ const active = listeners.get(sessionManager) ?? new Set<() => void>();
33
+ active.add(listener);
34
+ listeners.set(sessionManager, active);
35
+ return () => {
36
+ active.delete(listener);
37
+ if (active.size === 0) listeners.delete(sessionManager);
38
+ };
39
+ };
40
+ }