pi-better-btw-plus 1.0.2 → 1.1.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 +1 -1
- package/srcs/config.ts +18 -2
- package/srcs/fork-turn.ts +346 -0
- package/srcs/index.ts +7 -15
- package/srcs/overlay-layout.ts +302 -0
- package/srcs/pointer-gesture.ts +261 -0
- package/srcs/provider-retry.ts +73 -0
- package/srcs/retry.ts +9 -0
- package/srcs/side-chat-overlay.ts +292 -624
- package/srcs/status-channel.ts +170 -0
- package/srcs/tool-wrapper.ts +8 -184
- package/srcs/write-paths.ts +235 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-btw-plus",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "pi extension: /btw side-chat overlay — maintained fork of @yceachan/pi-better-btw (+ right-click copy/paste, fork model switch, turn-level retry)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
package/srcs/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { isAbsolute, join } from "node:path";
|
|
4
4
|
import type { PromptPackManifest } from "./prompt-pack.ts";
|
|
5
|
+
import { PROVIDER_RETRY_KEYS, type ProviderRetrySettings } from "./provider-retry.ts";
|
|
5
6
|
import type { RetryPolicy } from "./retry.ts";
|
|
6
7
|
/**
|
|
7
8
|
* Layered config resolution for pi-better-btw.
|
|
@@ -70,8 +71,11 @@ export const AGENT_CONFIG_DIR = join(homedir(), ".pi", "agent");
|
|
|
70
71
|
* with project <cwd>/.pi/settings.json (project wins per key, mirroring pi's
|
|
71
72
|
* deepMergeSettings), then the `retry` block is extracted with pi's defaults
|
|
72
73
|
* (settingsManager.getRetrySettings: enabled=true, maxRetries=3,
|
|
73
|
-
* baseDelayMs=2000).
|
|
74
|
-
*
|
|
74
|
+
* baseDelayMs=2000). The `retry.provider` block (spec #20 D4: timeoutMs /
|
|
75
|
+
* maxRetries / maxRetryDelayMs — HTTP-layer retry knobs) is extracted too,
|
|
76
|
+
* with only defined number keys kept; it is consumed solely by the overlay's
|
|
77
|
+
* stream assembly, never by the turn loop. Invalid/absent files contribute
|
|
78
|
+
* nothing; a present-but-unreadable file warns instead of failing the fork.
|
|
75
79
|
*/
|
|
76
80
|
export interface LoadRetryPolicyOptions {
|
|
77
81
|
/** Agent config dir holding pi's global settings.json (~/.pi/agent). */
|
|
@@ -125,6 +129,15 @@ export function loadRetryPolicy(options: LoadRetryPolicyOptions = {}): RetryPoli
|
|
|
125
129
|
: {},
|
|
126
130
|
);
|
|
127
131
|
const retry = isPlainRecord(merged.retry) ? merged.retry : {};
|
|
132
|
+
// D4 (spec #20): extract the provider block (HTTP-layer retry knobs)
|
|
133
|
+
// from the already-global+project-merged settings. Only defined number
|
|
134
|
+
// keys are kept; missing keys fall empty — no maxRetryDelayMs default
|
|
135
|
+
// here, because pi-ai's streamSimple defaults it to 60000 internally.
|
|
136
|
+
const provider = isPlainRecord(retry.provider) ? retry.provider : {};
|
|
137
|
+
const providerSettings: ProviderRetrySettings = {};
|
|
138
|
+
for (const key of PROVIDER_RETRY_KEYS) {
|
|
139
|
+
if (typeof provider[key] === "number") providerSettings[key] = provider[key];
|
|
140
|
+
}
|
|
128
141
|
return {
|
|
129
142
|
enabled:
|
|
130
143
|
typeof retry.enabled === "boolean" ? retry.enabled : true,
|
|
@@ -132,6 +145,9 @@ export function loadRetryPolicy(options: LoadRetryPolicyOptions = {}): RetryPoli
|
|
|
132
145
|
typeof retry.maxRetries === "number" ? retry.maxRetries : 3,
|
|
133
146
|
baseDelayMs:
|
|
134
147
|
typeof retry.baseDelayMs === "number" ? retry.baseDelayMs : 2000,
|
|
148
|
+
...(Object.keys(providerSettings).length > 0
|
|
149
|
+
? { provider: providerSettings }
|
|
150
|
+
: {}),
|
|
135
151
|
};
|
|
136
152
|
}
|
|
137
153
|
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fork-turn (issue #10, T1): the side chat's turn lifecycle as a deep module.
|
|
3
|
+
*
|
|
4
|
+
* `ForkTurnRunner` owns one fork `Agent`: it constructs the agent itself (via
|
|
5
|
+
* an injectable `agentFactory`), binds the lane-enforcement hooks
|
|
6
|
+
* (`transformContext` / `beforeToolCall` / `afterToolCall`) internally, and
|
|
7
|
+
* subscribes to agent events, translating them into semantic `TurnPhase`
|
|
8
|
+
* events for the overlay to render. The module is free of TUI / pi-runtime
|
|
9
|
+
* dependencies — it depends only on pi-agent-core types, the retry engine and
|
|
10
|
+
* the prompt-pack types — so tests drive it directly with a fake agent and a
|
|
11
|
+
* phase log.
|
|
12
|
+
*
|
|
13
|
+
* The turn semantics mirror the overlay's current wiring verbatim (ADR-0001):
|
|
14
|
+
* prompt → strip-failed-assistant-message → continue through `runWithRetry`,
|
|
15
|
+
* with `classifyRetryable` bound to the live model context window, per-turn
|
|
16
|
+
* lane state reset at the top of `run()`, and `cancel()` aborting both the
|
|
17
|
+
* backoff wait and the agent run. Budget exhaustion and Esc-cancel errors
|
|
18
|
+
* surface via the final `messages` + `turn-end` phases; genuine thrown attempt
|
|
19
|
+
* errors propagate out of `run()` for the caller to surface.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
Agent,
|
|
23
|
+
type AgentEvent,
|
|
24
|
+
type AgentMessage,
|
|
25
|
+
type AgentOptions,
|
|
26
|
+
type AfterToolCallContext,
|
|
27
|
+
type AfterToolCallResult,
|
|
28
|
+
type BeforeToolCallContext,
|
|
29
|
+
type BeforeToolCallResult,
|
|
30
|
+
} from "@earendil-works/pi-agent-core";
|
|
31
|
+
import { substituteTemplate, type PromptPack } from "./prompt-pack.ts";
|
|
32
|
+
import {
|
|
33
|
+
classifyRetryable,
|
|
34
|
+
runWithRetry,
|
|
35
|
+
type RetryableFailure,
|
|
36
|
+
type RetryableInput,
|
|
37
|
+
type RetryPolicy,
|
|
38
|
+
} from "./retry.ts";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Semantic turn lifecycle events emitted by the runner (frozen contract,
|
|
42
|
+
* issue #9). The overlay maps phases to rendering (spinner, countdown ticker,
|
|
43
|
+
* status line, message batches); tests read the union like a log.
|
|
44
|
+
*/
|
|
45
|
+
export type TurnPhase =
|
|
46
|
+
| { kind: "stream"; delta: string }
|
|
47
|
+
| { kind: "messages"; messages: AgentMessage[] }
|
|
48
|
+
| { kind: "tool"; name: string; state: "start" | "end" }
|
|
49
|
+
| {
|
|
50
|
+
kind: "retry-wait";
|
|
51
|
+
attempt: number;
|
|
52
|
+
maxAttempts: number;
|
|
53
|
+
delayMs: number;
|
|
54
|
+
errorMessage: string;
|
|
55
|
+
}
|
|
56
|
+
| { kind: "lane"; count: number; escalated: boolean; tool: string }
|
|
57
|
+
| { kind: "turn-end" };
|
|
58
|
+
|
|
59
|
+
export interface ForkTurnRunnerOptions {
|
|
60
|
+
/**
|
|
61
|
+
* Agent construction config without the three lane hooks — the runner
|
|
62
|
+
* overrides `transformContext` / `beforeToolCall` / `afterToolCall` with its
|
|
63
|
+
* own implementations. `initialState` is assembled by the overlay (fork
|
|
64
|
+
* surgery, framing message, peek tool, read-only tool list).
|
|
65
|
+
*/
|
|
66
|
+
agentOptions: AgentOptions;
|
|
67
|
+
/**
|
|
68
|
+
* Retry budget/backoff. Already ANDed with `features.retry` by the overlay
|
|
69
|
+
* (D11) — a disabled feature arrives as `enabled: false`.
|
|
70
|
+
*/
|
|
71
|
+
retryPolicy: RetryPolicy;
|
|
72
|
+
/** Resolved prompt texts (focus anchor + lane reminders). */
|
|
73
|
+
promptPack: PromptPack;
|
|
74
|
+
/** Live lane predicate (tool-mode closure; live after Ctrl+T). */
|
|
75
|
+
isReadOnlyLane: () => boolean;
|
|
76
|
+
/** Read-only tool-set membership (tool-set closure). */
|
|
77
|
+
isReadOnlyTool: (name: string) => boolean;
|
|
78
|
+
/** Receives every semantic phase; the overlay maps phases to rendering. */
|
|
79
|
+
onPhase: (phase: TurnPhase) => void;
|
|
80
|
+
/** Test seam; defaults to constructing the real Agent. */
|
|
81
|
+
agentFactory?: (options: AgentOptions) => Agent;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class ForkTurnRunner {
|
|
85
|
+
/** The owned fork agent; the overlay reaches it for model picker / export / Ctrl+T. */
|
|
86
|
+
readonly agent: Agent;
|
|
87
|
+
|
|
88
|
+
/** Out-of-lane attempts in the current turn (reset at the top of run()). */
|
|
89
|
+
private laneViolations = 0;
|
|
90
|
+
/** Reminder queued for injection by transformContext before the next LLM call. */
|
|
91
|
+
private pendingReminder: string | null = null;
|
|
92
|
+
/** When true, the turn is aborted right after the escalated reminder is injected. */
|
|
93
|
+
private abortAfterInject = false;
|
|
94
|
+
/** Per-turn retry cancellation (D9): cancel() aborts this so the backoff wait stops. */
|
|
95
|
+
private retryAbortController: AbortController | null = null;
|
|
96
|
+
/** True while a turn is in flight — streaming and backoff wait included. */
|
|
97
|
+
private running = false;
|
|
98
|
+
|
|
99
|
+
constructor(private readonly options: ForkTurnRunnerOptions) {
|
|
100
|
+
const agentFactory =
|
|
101
|
+
options.agentFactory ?? ((agentOptions: AgentOptions) => new Agent(agentOptions));
|
|
102
|
+
this.agent = agentFactory({
|
|
103
|
+
...options.agentOptions,
|
|
104
|
+
transformContext: (messages) => this.transformContext(messages),
|
|
105
|
+
beforeToolCall: (context) => this.beforeToolCall(context),
|
|
106
|
+
afterToolCall: (context) => this.afterToolCall(context),
|
|
107
|
+
});
|
|
108
|
+
this.agent.subscribe((event) => this.handleAgentEvent(event));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True during both streaming and the retry backoff wait. */
|
|
112
|
+
get isRunning(): boolean {
|
|
113
|
+
return this.running;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Run one turn (user submit): prompt → strip-failed-assistant-message →
|
|
118
|
+
* continue through `runWithRetry` (ADR-0001 mirror). A thrown attempt error
|
|
119
|
+
* propagates out of `run()` for the caller to surface; budget exhaustion and
|
|
120
|
+
* Esc-cancel errors surface via the final `messages` + `turn-end` phases.
|
|
121
|
+
* A submit while a turn is in flight is ignored.
|
|
122
|
+
*/
|
|
123
|
+
async run(text: string): Promise<void> {
|
|
124
|
+
const trimmed = text.trim();
|
|
125
|
+
if (!trimmed || this.running) return;
|
|
126
|
+
|
|
127
|
+
// Per-turn lane state resets at the top of run().
|
|
128
|
+
this.laneViolations = 0;
|
|
129
|
+
this.pendingReminder = null;
|
|
130
|
+
this.abortAfterInject = false;
|
|
131
|
+
|
|
132
|
+
this.running = true;
|
|
133
|
+
this.retryAbortController = new AbortController();
|
|
134
|
+
const signal = this.retryAbortController.signal;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
let firstAttempt = true;
|
|
138
|
+
const attempt = async (): Promise<RetryableFailure | undefined> => {
|
|
139
|
+
if (!firstAttempt) this.removeTrailingAssistantError();
|
|
140
|
+
if (firstAttempt) {
|
|
141
|
+
firstAttempt = false;
|
|
142
|
+
await this.agent.prompt(trimmed);
|
|
143
|
+
} else {
|
|
144
|
+
await this.agent.continue();
|
|
145
|
+
}
|
|
146
|
+
return this.lastAssistantMessage();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
await runWithRetry({
|
|
150
|
+
attempt,
|
|
151
|
+
signal,
|
|
152
|
+
// ADR-0001 mirror: classify against the live model context window so
|
|
153
|
+
// silent overflow (pi isContextOverflow cases 2/3) is also excluded.
|
|
154
|
+
classify: (result) =>
|
|
155
|
+
classifyRetryable(
|
|
156
|
+
result as RetryableInput,
|
|
157
|
+
this.agent.state.model?.contextWindow ?? 0,
|
|
158
|
+
),
|
|
159
|
+
onAttempt: (info) => {
|
|
160
|
+
this.emit({ kind: "retry-wait", ...info });
|
|
161
|
+
},
|
|
162
|
+
policy: this.options.retryPolicy,
|
|
163
|
+
});
|
|
164
|
+
} finally {
|
|
165
|
+
this.running = false;
|
|
166
|
+
this.retryAbortController = null;
|
|
167
|
+
// Final transcript snapshot + turn-end: the overlay clears its statuses
|
|
168
|
+
// and re-renders here (budget-exhaustion / Esc-cancel errors included).
|
|
169
|
+
this.emit({ kind: "messages", messages: [...this.agent.state.messages] });
|
|
170
|
+
this.emit({ kind: "turn-end" });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Esc / overlay dispose: abort the backoff wait and the agent run. */
|
|
175
|
+
cancel(): void {
|
|
176
|
+
this.retryAbortController?.abort();
|
|
177
|
+
this.agent.abort();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- Agent event → phase translation --------------------------------------
|
|
181
|
+
|
|
182
|
+
private handleAgentEvent(event: AgentEvent): void {
|
|
183
|
+
if (
|
|
184
|
+
event.type === "message_update" &&
|
|
185
|
+
event.assistantMessageEvent?.type === "text_delta"
|
|
186
|
+
) {
|
|
187
|
+
this.emit({ kind: "stream", delta: event.assistantMessageEvent.delta });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (event.type === "message_end") {
|
|
191
|
+
this.emit({ kind: "messages", messages: [...this.agent.state.messages] });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (event.type === "tool_execution_start") {
|
|
195
|
+
this.emit({ kind: "tool", name: event.toolName, state: "start" });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (event.type === "tool_execution_end") {
|
|
199
|
+
this.emit({ kind: "tool", name: event.toolName, state: "end" });
|
|
200
|
+
// Detection signal: an error result for a tool that is not in the
|
|
201
|
+
// read-only lane (absent tools produce "Tool X not found" errors).
|
|
202
|
+
if (
|
|
203
|
+
this.options.isReadOnlyLane() &&
|
|
204
|
+
event.isError &&
|
|
205
|
+
!this.options.isReadOnlyTool(event.toolName)
|
|
206
|
+
) {
|
|
207
|
+
this.registerLaneViolation(event.toolName);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private emit(phase: TurnPhase): void {
|
|
213
|
+
this.options.onPhase(phase);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- Lane enforcement ------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 1st violation → base reminder; 2nd → escalated wording + abort-after-inject
|
|
220
|
+
* (the reminder is injected by transformContext before the next LLM call).
|
|
221
|
+
* Texts come from the prompt pack (#13); UI copy stays in the overlay.
|
|
222
|
+
*/
|
|
223
|
+
private registerLaneViolation(toolName: string): void {
|
|
224
|
+
this.laneViolations += 1;
|
|
225
|
+
const escalated = this.laneViolations >= 2;
|
|
226
|
+
if (escalated) {
|
|
227
|
+
this.pendingReminder = substituteTemplate(
|
|
228
|
+
this.options.promptPack.laneReminders.escalated,
|
|
229
|
+
{ tool: toolName, count: this.laneViolations },
|
|
230
|
+
);
|
|
231
|
+
this.abortAfterInject = true;
|
|
232
|
+
} else {
|
|
233
|
+
this.pendingReminder = substituteTemplate(
|
|
234
|
+
this.options.promptPack.laneReminders.base,
|
|
235
|
+
{ tool: toolName },
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
this.emit({ kind: "lane", count: this.laneViolations, escalated, tool: toolName });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// --- Lane hooks (bound by the runner, never by the overlay) ----------------
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Transient tail injections (present in the LLM request only, never stored in
|
|
245
|
+
* the transcript), texts from the prompt pack: focus anchor (every turn, both
|
|
246
|
+
* modes), lane preamble (read-only lane only), pending lane reminder (after
|
|
247
|
+
* an out-of-lane attempt; escalated violations abort the turn right after the
|
|
248
|
+
* reminder is queued).
|
|
249
|
+
*/
|
|
250
|
+
private async transformContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
|
|
251
|
+
const additions: AgentMessage[] = [
|
|
252
|
+
{
|
|
253
|
+
role: "user",
|
|
254
|
+
content: this.options.promptPack.focusAnchor,
|
|
255
|
+
timestamp: Date.now(),
|
|
256
|
+
},
|
|
257
|
+
];
|
|
258
|
+
if (this.options.isReadOnlyLane()) {
|
|
259
|
+
additions.push({
|
|
260
|
+
role: "user",
|
|
261
|
+
content: this.options.promptPack.laneReminders.preamble,
|
|
262
|
+
timestamp: Date.now(),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (this.pendingReminder) {
|
|
266
|
+
const reminder = this.pendingReminder;
|
|
267
|
+
this.pendingReminder = null;
|
|
268
|
+
if (this.abortAfterInject) {
|
|
269
|
+
this.abortAfterInject = false;
|
|
270
|
+
setTimeout(() => this.agent.abort(), 0);
|
|
271
|
+
}
|
|
272
|
+
additions.push({
|
|
273
|
+
role: "user",
|
|
274
|
+
content: reminder,
|
|
275
|
+
timestamp: Date.now(),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return [...messages, ...additions];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Belt-and-braces: block any residual present-but-disallowed tool with the
|
|
283
|
+
* base reminder as the reason (blocked calls never reach afterToolCall).
|
|
284
|
+
*/
|
|
285
|
+
private async beforeToolCall(
|
|
286
|
+
context: BeforeToolCallContext,
|
|
287
|
+
): Promise<BeforeToolCallResult | undefined> {
|
|
288
|
+
if (!this.options.isReadOnlyLane()) return undefined;
|
|
289
|
+
if (this.options.isReadOnlyTool(context.toolCall.name)) return undefined;
|
|
290
|
+
return {
|
|
291
|
+
block: true,
|
|
292
|
+
reason: substituteTemplate(this.options.promptPack.laneReminders.base, {
|
|
293
|
+
tool: context.toolCall.name,
|
|
294
|
+
}),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Layer 2: re-ground executed-but-failed read-only calls (never fires for
|
|
300
|
+
* blocked/absent tools). Not a violation — no escalation count.
|
|
301
|
+
*/
|
|
302
|
+
private async afterToolCall(
|
|
303
|
+
context: AfterToolCallContext,
|
|
304
|
+
): Promise<AfterToolCallResult | undefined> {
|
|
305
|
+
if (!this.options.isReadOnlyLane() || !context.isError) return undefined;
|
|
306
|
+
const content = [...context.result.content];
|
|
307
|
+
if (!content.some((c) => c.type === "text" && c.text.includes("🚧"))) {
|
|
308
|
+
content.push({
|
|
309
|
+
type: "text",
|
|
310
|
+
text: this.options.promptPack.laneReminders.failedNote,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return { content };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// --- Transcript helpers (pi _findLastAssistantMessage / _prepareRetry mirrors) --
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Last assistant message in the fork transcript (pi's
|
|
320
|
+
* `_findLastAssistantMessage` semantics: includes aborted/error ones). The
|
|
321
|
+
* retry loop classifies this result after each attempt.
|
|
322
|
+
*/
|
|
323
|
+
private lastAssistantMessage(): RetryableFailure | undefined {
|
|
324
|
+
const messages = this.agent.state.messages;
|
|
325
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
326
|
+
const msg = messages[i];
|
|
327
|
+
if (msg.role === "assistant") return msg as RetryableFailure;
|
|
328
|
+
}
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* pi's `_prepareRetry` cleanup, mirrored: before a retry the failed assistant
|
|
334
|
+
* message is stripped from the transcript so the error never re-enters the
|
|
335
|
+
* next request (and `agent.continue()` can run — it requires a trailing
|
|
336
|
+
* user/toolResult message). Only an error-stop trailing message is removed;
|
|
337
|
+
* a successful prior turn's assistant message is left untouched.
|
|
338
|
+
*/
|
|
339
|
+
private removeTrailingAssistantError(): void {
|
|
340
|
+
const messages = this.agent.state.messages;
|
|
341
|
+
const last = messages[messages.length - 1];
|
|
342
|
+
if (last?.role === "assistant" && last.stopReason === "error") {
|
|
343
|
+
this.agent.state.messages = messages.slice(0, -1);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
package/srcs/index.ts
CHANGED
|
@@ -14,17 +14,17 @@ import { loadConfig, loadRetryPolicy } from "./config.ts";
|
|
|
14
14
|
import { getExtensionDir, loadPromptPack } from "./prompt-pack.ts";
|
|
15
15
|
import {
|
|
16
16
|
SideChatOverlay,
|
|
17
|
-
SIDE_CHAT_OVERLAY_MARGIN_TOP,
|
|
18
|
-
SIDE_CHAT_OVERLAY_MAX_HEIGHT,
|
|
19
17
|
type ForkContext,
|
|
20
18
|
} from "./side-chat-overlay.ts";
|
|
19
|
+
import { LAYOUT } from "./overlay-layout.ts";
|
|
21
20
|
import { SIDE_CHAT_SHORTCUT } from "./shortcuts.ts";
|
|
22
21
|
import {
|
|
23
22
|
disableMouseReporting,
|
|
24
23
|
enableMouseReporting,
|
|
24
|
+
isLeftPress,
|
|
25
25
|
parseSgrMouseEvent,
|
|
26
26
|
} from "./side-chat-mouse.ts";
|
|
27
|
-
import { extractWritePaths } from "./
|
|
27
|
+
import { extractWritePaths } from "./write-paths.ts";
|
|
28
28
|
// Patch to capture the runner instance for extension tool access in side chat.
|
|
29
29
|
let capturedRunner: ExtensionRunner | null = null;
|
|
30
30
|
// Patch once (module reloads re-execute this file; re-patching would nest the
|
|
@@ -116,11 +116,9 @@ export default function sideChatExtension(pi: ExtensionAPI) {
|
|
|
116
116
|
// A press on the chat focuses the overlay, so the subsequent
|
|
117
117
|
// Ctrl+C / Ctrl+Shift+C lands in the overlay (not the main editor)
|
|
118
118
|
// and re-copies the selection.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
(event.button & 32) === 0
|
|
123
|
-
) {
|
|
119
|
+
// A press on the chat focuses the overlay (single classification
|
|
120
|
+
// source: isLeftPress, shared with the gesture module).
|
|
121
|
+
if (isLeftPress(event)) {
|
|
124
122
|
overlayHandle?.focus();
|
|
125
123
|
}
|
|
126
124
|
overlay.handleMouseEvent(event);
|
|
@@ -328,13 +326,7 @@ export default function sideChatExtension(pi: ExtensionAPI) {
|
|
|
328
326
|
},
|
|
329
327
|
{
|
|
330
328
|
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
|
-
},
|
|
329
|
+
overlayOptions: { ...LAYOUT },
|
|
338
330
|
onHandle: (handle) => {
|
|
339
331
|
overlayHandle = handle;
|
|
340
332
|
handle.focus();
|