@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.
- package/README.md +4 -4
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
- package/node_modules/@herbertgao/pi-subagents/package.json +1 -1
- package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +5 -4
- package/node_modules/@herbertgao/pi-subagents/src/mention-clone.ts +39 -16
- package/node_modules/@narumitw/pi-btw/README.md +21 -4
- package/node_modules/@narumitw/pi-btw/dist/index.ts +1668 -958
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
- package/node_modules/@narumitw/pi-btw/docs/workflows.md +10 -2
- package/node_modules/@narumitw/pi-btw/package.json +1 -1
- package/node_modules/@narumitw/pi-btw/src/btw.ts +17 -79
- package/node_modules/@narumitw/pi-btw/src/conversation-context.ts +74 -0
- package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +196 -7
- package/node_modules/@narumitw/pi-btw/src/main-thread-updates.ts +40 -0
- package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -2
- package/node_modules/@narumitw/pi-btw/src/settings.ts +49 -0
- package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +9 -1
- package/node_modules/@narumitw/pi-btw/src/workspace-layout.ts +559 -0
- package/node_modules/pi-multi-account/CHANGELOG.md +23 -0
- package/node_modules/pi-multi-account/README.md +38 -11
- package/node_modules/pi-multi-account/index.ts +371 -96
- package/node_modules/pi-multi-account/package.json +5 -4
- package/node_modules/pi-multi-account/provider-payload-stream.ts +36 -28
- package/node_modules/pi-multi-account/usage.ts +31 -2
- package/node_modules/pi-typesafe/README.md +3 -1
- package/node_modules/pi-typesafe/dist/auth.d.ts +10 -3
- package/node_modules/pi-typesafe/dist/auth.js +13 -7
- package/node_modules/pi-typesafe/dist/backends.d.ts +31 -0
- package/node_modules/pi-typesafe/dist/backends.js +33 -0
- package/node_modules/pi-typesafe/dist/client.d.ts +3 -9
- package/node_modules/pi-typesafe/dist/client.js +63 -39
- package/node_modules/pi-typesafe/dist/credentials.d.ts +11 -5
- package/node_modules/pi-typesafe/dist/credentials.js +15 -8
- package/node_modules/pi-typesafe/dist/extension.js +4 -1
- package/node_modules/pi-typesafe/dist/index.d.ts +1 -1
- package/node_modules/pi-typesafe/dist/index.js +1 -1
- package/node_modules/pi-typesafe/dist/login.d.ts +13 -7
- package/node_modules/pi-typesafe/dist/login.js +17 -8
- package/node_modules/pi-typesafe/dist/schema.js +19 -13
- package/node_modules/pi-typesafe/package.json +1 -1
- 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
|
|
21
|
-
The
|
|
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.
|
|
@@ -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
|
|
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
|
|
184
|
-
//
|
|
185
|
-
//
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
+
}
|