pi-better-btw-plus 1.0.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/LICENSE +23 -0
- package/README.md +251 -0
- package/README.zh-CN.md +252 -0
- package/banner.png +0 -0
- package/config.json +23 -0
- package/package.json +69 -0
- package/prompts/btw-focus-anchor.md +1 -0
- package/prompts/btw-framing.md +8 -0
- package/prompts/lane-failed-note.md +1 -0
- package/prompts/lane-preamble.md +1 -0
- package/prompts/lane-reminder-base.md +1 -0
- package/prompts/lane-reminder-escalated.md +1 -0
- package/srcs/clipboard-read.ts +339 -0
- package/srcs/config.ts +341 -0
- package/srcs/file-activity-tracker.ts +21 -0
- package/srcs/fork-surgery.ts +106 -0
- package/srcs/index.ts +381 -0
- package/srcs/model-switch.ts +60 -0
- package/srcs/prompt-pack.ts +145 -0
- package/srcs/retry.ts +360 -0
- package/srcs/shortcuts.ts +4 -0
- package/srcs/side-chat-export.ts +302 -0
- package/srcs/side-chat-messages.ts +479 -0
- package/srcs/side-chat-mouse.ts +108 -0
- package/srcs/side-chat-overlay.ts +1513 -0
- package/srcs/tool-wrapper.ts +225 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
export class FileActivityTracker {
|
|
4
|
+
private written = new Set<string>();
|
|
5
|
+
|
|
6
|
+
trackWrite(path: string, cwd: string) {
|
|
7
|
+
this.written.add(this.normalize(path, cwd));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
hasWritten(path: string, cwd: string): boolean {
|
|
11
|
+
return this.written.has(this.normalize(path, cwd));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get writeCount(): number {
|
|
15
|
+
return this.written.size;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private normalize(path: string, cwd: string): string {
|
|
19
|
+
return resolve(cwd, path);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
3
|
+
|
|
4
|
+
/** Approved wording (#12): synthesized result for a tool call that was still
|
|
5
|
+
* running on the main lane when the btw side chat forked. */
|
|
6
|
+
export const FORKED_MID_EXECUTION_TEXT =
|
|
7
|
+
"[forked mid-execution — the main lane was still running this tool call when the btw side chat opened]";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fork surgery (#12/#16): make the trailing tool exchange gateway-legal on
|
|
11
|
+
* the fork snapshot, tail-only, never rewriting existing messages.
|
|
12
|
+
*
|
|
13
|
+
* The gateway rejects any request with an unanswered `assistant.tool_calls`
|
|
14
|
+
* (HTTP 400), and the fork snapshot can genuinely end mid-execution (the
|
|
15
|
+
* main lane was still running the tool when the btw side chat opened). Rules:
|
|
16
|
+
*
|
|
17
|
+
* - S1/S2 dangling tool_calls (no matching toolResult in the kept list) get
|
|
18
|
+
* one synthesized toolResult each, appended after the trailing exchange.
|
|
19
|
+
* Real landed results are never touched.
|
|
20
|
+
* - Orphan toolResults (no matching tool_call anywhere in the kept list) are
|
|
21
|
+
* defensively dropped.
|
|
22
|
+
* - S5 carry-cut (#16, reverses S4): a trailing run of user messages is cut
|
|
23
|
+
* entirely, and the user message that triggered the trailing tool exchange
|
|
24
|
+
* is cut with it — the cite never ends on a user message. "The
|
|
25
|
+
* newest-looking question in the cite" is the lane-confusion source
|
|
26
|
+
* (2026-08-13 export: the model answered the main lane's trailing user
|
|
27
|
+
* message instead of the btw message). Tail cuts keep the btw request a
|
|
28
|
+
* token prefix of the main request, so shared-prefix caching is unaffected.
|
|
29
|
+
* - branchSummary/compactionSummary, images and `excludeFromContext` messages
|
|
30
|
+
* pass through untouched (shared prefix).
|
|
31
|
+
*
|
|
32
|
+
* Matching against the whole kept list (not just the region) keeps valid
|
|
33
|
+
* answered exchanges intact in pathological orderings; only true orphans and
|
|
34
|
+
* true dangling calls are touched.
|
|
35
|
+
*/
|
|
36
|
+
export function forkSurgery(messages: AgentMessage[], forkTimestamp = Date.now()): AgentMessage[] {
|
|
37
|
+
// (1) Locate the trailing exchange region with S5 carry-cut (#16):
|
|
38
|
+
// - a trailing run of user messages is cut entirely (S4 reversal),
|
|
39
|
+
// - the user message that triggered the trailing tool exchange is cut with
|
|
40
|
+
// it (carryStart), so the cite never ends on a user message.
|
|
41
|
+
let end = messages.length;
|
|
42
|
+
while (end > 0 && messages[end - 1].role === "user") end--;
|
|
43
|
+
let start = end;
|
|
44
|
+
while (start > 0) {
|
|
45
|
+
const message = messages[start - 1];
|
|
46
|
+
if (message.role === "toolResult") {
|
|
47
|
+
start--;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (message.role === "assistant" && hasToolCalls(message)) {
|
|
51
|
+
start--;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
const carryStart = start > 0 && messages[start - 1].role === "user" ? start - 1 : start;
|
|
57
|
+
const region = messages.slice(start, end);
|
|
58
|
+
const cut = end < messages.length || carryStart < start;
|
|
59
|
+
if (!cut && region.length === 0) return messages;
|
|
60
|
+
|
|
61
|
+
// (2) Index every tool_call in the whole list (id → tool name, for
|
|
62
|
+
// synthesis) and every toolResult id.
|
|
63
|
+
const callNames = new Map<string, string>();
|
|
64
|
+
const resultIds = new Set<string>();
|
|
65
|
+
for (const message of messages) {
|
|
66
|
+
if (message.role === "assistant") {
|
|
67
|
+
for (const block of message.content) {
|
|
68
|
+
if (block.type === "toolCall") callNames.set(block.id, block.name);
|
|
69
|
+
}
|
|
70
|
+
} else if (message.role === "toolResult") {
|
|
71
|
+
resultIds.add(message.toolCallId);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// (3) Orphan toolResults in the region are dropped; everything else in the
|
|
76
|
+
// region passes through byte-identical.
|
|
77
|
+
const kept = region.filter((message) => message.role !== "toolResult" || callNames.has(message.toolCallId));
|
|
78
|
+
|
|
79
|
+
// (4) Dangling calls in the region get one synthesized toolResult each.
|
|
80
|
+
const regionCallIds = new Set<string>();
|
|
81
|
+
for (const message of region) {
|
|
82
|
+
if (message.role !== "assistant") continue;
|
|
83
|
+
for (const block of message.content) {
|
|
84
|
+
if (block.type === "toolCall") regionCallIds.add(block.id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const synthesized: ToolResultMessage[] = [];
|
|
88
|
+
for (const id of regionCallIds) {
|
|
89
|
+
if (resultIds.has(id)) continue;
|
|
90
|
+
synthesized.push({
|
|
91
|
+
role: "toolResult",
|
|
92
|
+
toolCallId: id,
|
|
93
|
+
toolName: callNames.get(id) ?? "unknown",
|
|
94
|
+
content: [{ type: "text", text: FORKED_MID_EXECUTION_TEXT }],
|
|
95
|
+
isError: false,
|
|
96
|
+
timestamp: forkTimestamp,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!cut && kept.length === region.length && synthesized.length === 0) return messages;
|
|
101
|
+
return [...messages.slice(0, carryStart), ...kept, ...synthesized];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hasToolCalls(message: AgentMessage): boolean {
|
|
105
|
+
return message.role === "assistant" && message.content.some((block) => block.type === "toolCall");
|
|
106
|
+
}
|
package/srcs/index.ts
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
ExtensionUIContext,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { OverlayHandle, Terminal, TUI } from "@earendil-works/pi-tui";
|
|
8
|
+
import {
|
|
9
|
+
buildSessionContext,
|
|
10
|
+
ExtensionRunner,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { FileActivityTracker } from "./file-activity-tracker.ts";
|
|
13
|
+
import { loadConfig, loadRetryPolicy } from "./config.ts";
|
|
14
|
+
import { getExtensionDir, loadPromptPack } from "./prompt-pack.ts";
|
|
15
|
+
import {
|
|
16
|
+
SideChatOverlay,
|
|
17
|
+
SIDE_CHAT_OVERLAY_MARGIN_TOP,
|
|
18
|
+
SIDE_CHAT_OVERLAY_MAX_HEIGHT,
|
|
19
|
+
type ForkContext,
|
|
20
|
+
} from "./side-chat-overlay.ts";
|
|
21
|
+
import { SIDE_CHAT_SHORTCUT } from "./shortcuts.ts";
|
|
22
|
+
import {
|
|
23
|
+
disableMouseReporting,
|
|
24
|
+
enableMouseReporting,
|
|
25
|
+
parseSgrMouseEvent,
|
|
26
|
+
} from "./side-chat-mouse.ts";
|
|
27
|
+
import { extractWritePaths } from "./tool-wrapper.ts";
|
|
28
|
+
// Patch to capture the runner instance for extension tool access in side chat.
|
|
29
|
+
let capturedRunner: ExtensionRunner | null = null;
|
|
30
|
+
// Patch once (module reloads re-execute this file; re-patching would nest the
|
|
31
|
+
// wrapper one level per /reload and eventually blow the stack). The marker
|
|
32
|
+
// lives in the Symbol.for registry (global across module reloads and other
|
|
33
|
+
// extensions) so re-entry is a no-op without a string-keyed prototype prop
|
|
34
|
+
// another extension could collide with.
|
|
35
|
+
const RUNNER_CAPTURED = Symbol.for("__btwRunnerCaptured");
|
|
36
|
+
if (!(ExtensionRunner.prototype as unknown as Record<symbol, unknown>)[RUNNER_CAPTURED]) {
|
|
37
|
+
const origGetAllRegisteredTools =
|
|
38
|
+
ExtensionRunner.prototype.getAllRegisteredTools;
|
|
39
|
+
(ExtensionRunner.prototype as unknown as Record<symbol, unknown>)[RUNNER_CAPTURED] = true;
|
|
40
|
+
ExtensionRunner.prototype.getAllRegisteredTools = function () {
|
|
41
|
+
capturedRunner = this;
|
|
42
|
+
return origGetAllRegisteredTools.call(this);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getExtensionAgentTools(): AgentTool[] {
|
|
47
|
+
if (!capturedRunner) return [];
|
|
48
|
+
return capturedRunner.getAllRegisteredTools().map((rt): AgentTool => {
|
|
49
|
+
const { definition } = rt;
|
|
50
|
+
return {
|
|
51
|
+
name: definition.name,
|
|
52
|
+
label: definition.label,
|
|
53
|
+
description: definition.description,
|
|
54
|
+
parameters: definition.parameters,
|
|
55
|
+
execute: (toolCallId, params, signal, onUpdate) =>
|
|
56
|
+
definition.execute(
|
|
57
|
+
toolCallId,
|
|
58
|
+
params,
|
|
59
|
+
signal,
|
|
60
|
+
onUpdate,
|
|
61
|
+
capturedRunner!.createContext(),
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const OVERLAY_BLOCKED_ERROR = "PI_SIDE_CHAT_OVERLAY_BLOCKED";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Log an open failure that escapes openSideChat's own state cleanup
|
|
71
|
+
* (fire-and-forget /btw & Alt+W paths). Error-only channel: never called on
|
|
72
|
+
* the happy path, so it can't pollute the TUI's normal rendering.
|
|
73
|
+
*/
|
|
74
|
+
function logOpenFailure(error: unknown): void {
|
|
75
|
+
console.error("[btw] failed to open side chat:", error);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Extension directory: base for the bundle config.json and prompt-pack paths. */
|
|
79
|
+
const extensionDir = getExtensionDir();
|
|
80
|
+
|
|
81
|
+
export default function sideChatExtension(pi: ExtensionAPI) {
|
|
82
|
+
const tracker = new FileActivityTracker();
|
|
83
|
+
let activeOverlay: SideChatOverlay | null = null;
|
|
84
|
+
let overlayHandle: OverlayHandle | null = null;
|
|
85
|
+
let lastMessages: AgentMessage[] | null = null;
|
|
86
|
+
let mouseTerminal: Terminal | null = null;
|
|
87
|
+
let removeMouseListener: (() => void) | null = null;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Enable xterm mouse reporting + SGR while the side chat is open and route
|
|
91
|
+
* overlay events (wheel scroll, drag-select, copy) to the chat. Mouse
|
|
92
|
+
* sequences are always consumed so they never leak into the editor as
|
|
93
|
+
* garbage input.
|
|
94
|
+
*/
|
|
95
|
+
const installMouseHandler = (tui: TUI) => {
|
|
96
|
+
if (removeMouseListener) return;
|
|
97
|
+
enableMouseReporting(tui.terminal);
|
|
98
|
+
mouseTerminal = tui.terminal;
|
|
99
|
+
removeMouseListener = tui.addInputListener((data) => {
|
|
100
|
+
const event = parseSgrMouseEvent(data);
|
|
101
|
+
if (!event) return undefined;
|
|
102
|
+
const overlay = activeOverlay;
|
|
103
|
+
if (overlay && !overlayHandle?.isHidden()) {
|
|
104
|
+
// An in-flight drag keeps consuming events even when the pointer
|
|
105
|
+
// leaves the overlay, so the selection clamps to the edges.
|
|
106
|
+
if (overlay.isMouseDragging()) {
|
|
107
|
+
overlay.handleMouseEvent(event);
|
|
108
|
+
return { consume: true };
|
|
109
|
+
}
|
|
110
|
+
const viewport = overlay.getViewport();
|
|
111
|
+
const overOverlay =
|
|
112
|
+
viewport !== null &&
|
|
113
|
+
event.row >= viewport.topRow &&
|
|
114
|
+
event.row < viewport.topRow + viewport.height;
|
|
115
|
+
if (overOverlay) {
|
|
116
|
+
// A press on the chat focuses the overlay, so the subsequent
|
|
117
|
+
// Ctrl+C / Ctrl+Shift+C lands in the overlay (not the main editor)
|
|
118
|
+
// and re-copies the selection.
|
|
119
|
+
if (
|
|
120
|
+
!event.isRelease &&
|
|
121
|
+
(event.button & 3) === 0 &&
|
|
122
|
+
(event.button & 32) === 0
|
|
123
|
+
) {
|
|
124
|
+
overlayHandle?.focus();
|
|
125
|
+
}
|
|
126
|
+
overlay.handleMouseEvent(event);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Always consume: mouse sequences must never leak into the editor as
|
|
130
|
+
// garbage input. (In fullscreen mode the alt-screen handler already
|
|
131
|
+
// consumed every SGR sequence before us, so this branch only fires in
|
|
132
|
+
// regular mode.)
|
|
133
|
+
return { consume: true };
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const uninstallMouseHandler = () => {
|
|
138
|
+
if (!removeMouseListener) return;
|
|
139
|
+
removeMouseListener();
|
|
140
|
+
removeMouseListener = null;
|
|
141
|
+
if (mouseTerminal) {
|
|
142
|
+
disableMouseReporting(mouseTerminal);
|
|
143
|
+
mouseTerminal = null;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Keep terminal mouse reporting bound to overlay *visibility* (issue #17
|
|
149
|
+
* Q8): backgrounding the chat (hide) must release the terminal's native
|
|
150
|
+
* selection, restoring it on show. Focus is irrelevant — wheel scroll
|
|
151
|
+
* keeps working while visible-but-unfocused.
|
|
152
|
+
*/
|
|
153
|
+
const syncMouseReporting = () => {
|
|
154
|
+
if (!removeMouseListener || !mouseTerminal) return;
|
|
155
|
+
if (overlayHandle?.isHidden()) {
|
|
156
|
+
disableMouseReporting(mouseTerminal);
|
|
157
|
+
// Reporting is off, so no release will arrive: abort any in-flight
|
|
158
|
+
// drag so a stale capture cannot swallow later events.
|
|
159
|
+
activeOverlay?.cancelMouseDrag();
|
|
160
|
+
} else {
|
|
161
|
+
enableMouseReporting(mouseTerminal);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Restore a hidden overlay to the foreground and re-enable mouse reporting. */
|
|
166
|
+
const restoreOverlay = (handle: OverlayHandle) => {
|
|
167
|
+
handle.setHidden(false);
|
|
168
|
+
handle.focus();
|
|
169
|
+
syncMouseReporting();
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** Background a visible overlay: release focus, hide, and disable mouse reporting. */
|
|
173
|
+
const hideOverlay = (handle: OverlayHandle) => {
|
|
174
|
+
handle.unfocus();
|
|
175
|
+
handle.setHidden(true);
|
|
176
|
+
syncMouseReporting();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** Toggle the side chat between hidden (backgrounded) and visible. Opens it if needed. */
|
|
180
|
+
const backgroundSideChat = async (ctx: ExtensionContext) => {
|
|
181
|
+
if (!activeOverlay) {
|
|
182
|
+
void openSideChat(ctx).catch(logOpenFailure); // fire-and-forget, see toggleSideChat
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const handle = overlayHandle;
|
|
186
|
+
if (!handle) return;
|
|
187
|
+
if (handle.isHidden()) {
|
|
188
|
+
restoreOverlay(handle);
|
|
189
|
+
} else {
|
|
190
|
+
hideOverlay(handle);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
pi.on("tool_execution_start", (event, ctx) => {
|
|
195
|
+
if (["write", "edit", "bash"].includes(event.toolName)) {
|
|
196
|
+
const paths = extractWritePaths(event.toolName, event.args);
|
|
197
|
+
paths.forEach((p) => tracker.trackWrite(p, ctx.cwd));
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const toggleSideChat = async (ctx: ExtensionContext) => {
|
|
202
|
+
if (activeOverlay) {
|
|
203
|
+
const handle = overlayHandle;
|
|
204
|
+
if (!handle) return;
|
|
205
|
+
if (handle.isHidden()) {
|
|
206
|
+
// Hidden in the background: restore and focus.
|
|
207
|
+
restoreOverlay(handle);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// The Alt+/ focus toggle was dropped (Alt+W owns background/restore):
|
|
211
|
+
// the open key only ever brings the chat to the front — a visible but
|
|
212
|
+
// unfocused overlay (e.g. after a mouse refocus) comes back this way,
|
|
213
|
+
// and pressing it while focused is a no-op.
|
|
214
|
+
if (!handle.isFocused()) {
|
|
215
|
+
handle.focus();
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Fire-and-forget: the custom overlay lifecycle outlives this command
|
|
220
|
+
// handler. Awaiting it blocks the main agent's input loop (the prompt()
|
|
221
|
+
// call chain suspends on ctx.ui.custom until the overlay closes), so every
|
|
222
|
+
// later slash command — including a second /btw meant to re-open the
|
|
223
|
+
// hidden overlay — queues in pendingUserInputs and never runs. The
|
|
224
|
+
// overlay's own close/refork/clear handling happens inside openSideChat.
|
|
225
|
+
void openSideChat(ctx).catch(logOpenFailure);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const openSideChat = async (ctx: ExtensionContext, clear = false) => {
|
|
229
|
+
if (!ctx.model) {
|
|
230
|
+
ctx.ui.notify("Cannot open side chat: no model configured", "error");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const sessionContext = buildSessionContext(
|
|
235
|
+
ctx.sessionManager.getEntries(),
|
|
236
|
+
ctx.sessionManager.getLeafId(),
|
|
237
|
+
);
|
|
238
|
+
// Layered config (#18): bundle <ExtensionDir>/config.json defaults, then
|
|
239
|
+
// ~/.pi/agent/pi-better-btw/config.json (user), then
|
|
240
|
+
// <cwd>/.pi/pi-better-btw/config.json (project) — allowlists union,
|
|
241
|
+
// promptPack merges per key.
|
|
242
|
+
// Read fresh at every open (no cache), consistent with the prompt pack.
|
|
243
|
+
const config = loadConfig({
|
|
244
|
+
extensionDir,
|
|
245
|
+
cwd: ctx.cwd,
|
|
246
|
+
onWarning: (message) => ctx.ui.notify(message, "warning"),
|
|
247
|
+
});
|
|
248
|
+
// Retry budget (#8, D8): pi's own settings.retry (global settings.json
|
|
249
|
+
// merged with <cwd>/.pi/settings.json), read fresh at every fork.
|
|
250
|
+
const retryPolicy = loadRetryPolicy({
|
|
251
|
+
cwd: ctx.cwd,
|
|
252
|
+
onWarning: (message) => ctx.ui.notify(message, "warning"),
|
|
253
|
+
});
|
|
254
|
+
// Prompt pack (#13): read fresh at every fork (no cache) so edits to the
|
|
255
|
+
// manifest files apply on the next fork; per-key fallback + notify.
|
|
256
|
+
const promptPack = loadPromptPack(config.promptPack, {
|
|
257
|
+
extensionDir,
|
|
258
|
+
notify: (message) => ctx.ui.notify(message, "warning"),
|
|
259
|
+
});
|
|
260
|
+
const forkContext: ForkContext = {
|
|
261
|
+
messages: clear ? [] : (lastMessages ?? sessionContext.messages),
|
|
262
|
+
model: ctx.model,
|
|
263
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
264
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
265
|
+
cwd: ctx.cwd,
|
|
266
|
+
extensionTools: getExtensionAgentTools(),
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const action = await ctx.ui.custom<"close" | "refork" | "clear">(
|
|
271
|
+
(tui, theme, _keybindings, done) => {
|
|
272
|
+
if (tui.hasOverlay()) {
|
|
273
|
+
setTimeout(() => {
|
|
274
|
+
ctx.ui.notify(
|
|
275
|
+
"Close or background the current overlay first",
|
|
276
|
+
"warning",
|
|
277
|
+
);
|
|
278
|
+
}, 0);
|
|
279
|
+
throw new Error(OVERLAY_BLOCKED_ERROR);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
activeOverlay = new SideChatOverlay({
|
|
283
|
+
tui,
|
|
284
|
+
theme,
|
|
285
|
+
forkContext,
|
|
286
|
+
tracker,
|
|
287
|
+
modelRegistry: ctx.modelRegistry,
|
|
288
|
+
scopedModels: ctx.scopedModels,
|
|
289
|
+
sessionManager: ctx.sessionManager,
|
|
290
|
+
promptPack,
|
|
291
|
+
readOnlyExtensionAllowlist: config.readOnlyExtensionAllowlist,
|
|
292
|
+
features: config.features,
|
|
293
|
+
retryPolicy,
|
|
294
|
+
onOverlapWarning: (path) => showOverlapWarning(ctx.ui, path),
|
|
295
|
+
onBackground: () => {
|
|
296
|
+
// Defer past the current input dispatch: hiding synchronously
|
|
297
|
+
// while the overlay is inside handleInput races the TUI's focus
|
|
298
|
+
// bookkeeping (the overlay stays focused-but-hidden, and the
|
|
299
|
+
// next keystroke gets eaten by the focus-redirect path — the
|
|
300
|
+
// reported "/btw won't re-open after Alt+W" bug). The Alt+W
|
|
301
|
+
// shortcut handler runs asynchronously from the editor and is
|
|
302
|
+
// unaffected; this mirrors it.
|
|
303
|
+
const scheduledHandle = overlayHandle;
|
|
304
|
+
setTimeout(() => {
|
|
305
|
+
// Identity check: if the overlay closed and a new one opened
|
|
306
|
+
// within the 0ms window, only act on the handle we scheduled
|
|
307
|
+
// for — never toggle a brand-new overlay.
|
|
308
|
+
if (!scheduledHandle || overlayHandle !== scheduledHandle) return;
|
|
309
|
+
if (scheduledHandle.isHidden()) {
|
|
310
|
+
restoreOverlay(scheduledHandle);
|
|
311
|
+
} else {
|
|
312
|
+
hideOverlay(scheduledHandle);
|
|
313
|
+
}
|
|
314
|
+
}, 0);
|
|
315
|
+
},
|
|
316
|
+
onExport: (path) =>
|
|
317
|
+
ctx.ui.notify(`btw chat exported → ${path}`, "info"),
|
|
318
|
+
onClose: (action, messages) => {
|
|
319
|
+
lastMessages = action === "close" ? messages : null;
|
|
320
|
+
activeOverlay = null;
|
|
321
|
+
overlayHandle = null;
|
|
322
|
+
uninstallMouseHandler();
|
|
323
|
+
done(action);
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
installMouseHandler(tui);
|
|
327
|
+
return activeOverlay;
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
overlay: true,
|
|
331
|
+
overlayOptions: {
|
|
332
|
+
width: "85%",
|
|
333
|
+
maxHeight: SIDE_CHAT_OVERLAY_MAX_HEIGHT,
|
|
334
|
+
anchor: "top-center",
|
|
335
|
+
margin: { top: SIDE_CHAT_OVERLAY_MARGIN_TOP, left: 2, right: 2 },
|
|
336
|
+
nonCapturing: true,
|
|
337
|
+
},
|
|
338
|
+
onHandle: (handle) => {
|
|
339
|
+
overlayHandle = handle;
|
|
340
|
+
handle.focus();
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
if (action === "refork") return openSideChat(ctx);
|
|
345
|
+
if (action === "clear") return openSideChat(ctx, true);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (error instanceof Error && error.message === OVERLAY_BLOCKED_ERROR) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
activeOverlay = null;
|
|
351
|
+
overlayHandle = null;
|
|
352
|
+
uninstallMouseHandler();
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
pi.registerShortcut(SIDE_CHAT_SHORTCUT, {
|
|
358
|
+
description: "Open / background / restore the side chat (keeps it running)",
|
|
359
|
+
handler: backgroundSideChat,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
pi.registerCommand("side", {
|
|
363
|
+
description: "Open side chat (fork conversation)",
|
|
364
|
+
handler: (_, ctx) => toggleSideChat(ctx),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
pi.registerCommand("btw", {
|
|
368
|
+
description: "Open side chat (fork conversation) — alias for /side",
|
|
369
|
+
handler: (_, ctx) => toggleSideChat(ctx),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function showOverlapWarning(
|
|
374
|
+
ui: ExtensionUIContext,
|
|
375
|
+
path: string,
|
|
376
|
+
): Promise<boolean> {
|
|
377
|
+
return ui.confirm(
|
|
378
|
+
"File Overlap",
|
|
379
|
+
`Main agent has modified:\n ${path}\n\nEditing may cause conflicts. Proceed?`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import { clampThinkingLevel } from "@earendil-works/pi-ai/compat";
|
|
4
|
+
import type { ScopedModel } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fork model switching (issue #5): pure list-building and thinking-level
|
|
8
|
+
* clamping. The overlay applies the chosen model to its own agent only
|
|
9
|
+
* (fork-local, ADR 0002) — the main session's model is never touched.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A selectable fork model: the model plus an optional scoped thinking level. */
|
|
13
|
+
export interface ModelChoice {
|
|
14
|
+
model: Model<any>;
|
|
15
|
+
/**
|
|
16
|
+
* Thinking level pinned by the scoped pattern (e.g. "model:high"), when the
|
|
17
|
+
* session scoped one explicitly. Undefined otherwise — the current level is
|
|
18
|
+
* kept and clamped to the new model's capabilities.
|
|
19
|
+
*/
|
|
20
|
+
thinkingLevel?: ThinkingLevel;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build the pickable model list (D6):
|
|
25
|
+
* - scoped models win when the session configured any (`--models` /
|
|
26
|
+
* `enabledModels`); an empty scoped set falls back to the available
|
|
27
|
+
* catalogue;
|
|
28
|
+
* - models without configured auth are dropped outright ("只显示"而非置灰 —
|
|
29
|
+
* choices that would inevitably fail are absent, not disabled).
|
|
30
|
+
*/
|
|
31
|
+
export function buildModelChoices(
|
|
32
|
+
scoped: readonly ScopedModel[],
|
|
33
|
+
available: readonly Model<any>[],
|
|
34
|
+
hasAuth: (model: Model<any>) => boolean,
|
|
35
|
+
): ModelChoice[] {
|
|
36
|
+
const source: ModelChoice[] =
|
|
37
|
+
scoped.length > 0
|
|
38
|
+
? scoped.map((s) => ({ model: s.model, thinkingLevel: s.thinkingLevel }))
|
|
39
|
+
: available.map((model) => ({ model }));
|
|
40
|
+
return source.filter((choice) => hasAuth(choice.model));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Clamp a thinking level to what the new model supports, mirroring the main
|
|
45
|
+
* session's `setThinkingLevel` semantics (it delegates to the same
|
|
46
|
+
* `clampThinkingLevel` from pi-ai): a model without reasoning clamps any
|
|
47
|
+
* level to `"off"`, and pi's request builders map `"off"` to no reasoning
|
|
48
|
+
* request. Levels above a model's `thinkingLevelMap` ceiling clamp down.
|
|
49
|
+
*/
|
|
50
|
+
export function clampThinkingLevelForModel(
|
|
51
|
+
model: Model<any>,
|
|
52
|
+
level: ThinkingLevel,
|
|
53
|
+
): ThinkingLevel {
|
|
54
|
+
return clampThinkingLevel(model, level);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Canonical identity key for a model (provider + id). */
|
|
58
|
+
export function modelKey(model: { provider: string; id: string }): string {
|
|
59
|
+
return `${model.provider}\0${model.id}`;
|
|
60
|
+
}
|