pi-better-background-tasks 0.1.15 → 0.1.17
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/src/runtime.ts +31 -17
- package/src/shared-callback-batcher.ts +320 -0
- package/src/shared-navigator.ts +240 -27
- package/src/tools.ts +3 -0
package/package.json
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { appendLine, appendWatchResult, retainLogTail, resolveMaxLogBytes } from
|
|
|
4
4
|
import { evaluateCondition } from "./conditions.js";
|
|
5
5
|
import { processExists, runCommandOnce, spawnCommand, stopProcessGroup } from "./process.js";
|
|
6
6
|
import { ensureTaskDir, logPathFor, nextTaskId, readMeta, writeMeta } from "./registry.js";
|
|
7
|
+
import { getCallbackBatcher } from "./shared-callback-batcher.js";
|
|
7
8
|
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, CommandSpec, Condition, TerminalResult } from "./types.js";
|
|
8
9
|
import { isTerminalStatus } from "./types.js";
|
|
9
10
|
|
|
@@ -304,24 +305,37 @@ async function notifyTerminal(
|
|
|
304
305
|
if (meta.callback === false || meta.callbackSentAt || meta.callbackSuppressedAt) return;
|
|
305
306
|
const latest = readMeta(meta.id) ?? meta;
|
|
306
307
|
if (latest.callback === false || latest.callbackSentAt || latest.callbackSuppressedAt) return;
|
|
307
|
-
const suppressionReason = getCallbackSuppressionReason(latest, getActiveSession?.());
|
|
308
|
-
if (suppressionReason) {
|
|
309
|
-
latest.callbackSuppressedAt = Date.now();
|
|
310
|
-
latest.callbackSuppressedReason = suppressionReason;
|
|
311
|
-
writeMeta(latest);
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
308
|
const label = latest.name ? `${latest.name} (${latest.id})` : latest.id;
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
309
|
+
getCallbackBatcher(pi).enqueue({
|
|
310
|
+
source: "background-task",
|
|
311
|
+
id: latest.id,
|
|
312
|
+
label,
|
|
313
|
+
status: latest.status,
|
|
314
|
+
detailTool: "bg_task_status",
|
|
315
|
+
callback: true,
|
|
316
|
+
isDelivered: () => {
|
|
317
|
+
const current = readMeta(latest.id);
|
|
318
|
+
return current?.callbackSentAt !== undefined || current?.callbackSuppressedAt !== undefined;
|
|
319
|
+
},
|
|
320
|
+
getSuppressionReason: () => {
|
|
321
|
+
const current = readMeta(latest.id);
|
|
322
|
+
if (!current) return "background task metadata is unavailable";
|
|
323
|
+
return getCallbackSuppressionReason(current, getActiveSession?.());
|
|
324
|
+
},
|
|
325
|
+
onDelivered: (at) => {
|
|
326
|
+
const current = readMeta(latest.id);
|
|
327
|
+
if (!current || current.callbackSentAt !== undefined || current.callbackSuppressedAt !== undefined) return;
|
|
328
|
+
current.callbackSentAt = at;
|
|
329
|
+
writeMeta(current);
|
|
330
|
+
},
|
|
331
|
+
onSuppressed: (reason, at) => {
|
|
332
|
+
const current = readMeta(latest.id);
|
|
333
|
+
if (!current || current.callbackSentAt !== undefined || current.callbackSuppressedAt !== undefined) return;
|
|
334
|
+
current.callbackSuppressedAt = at;
|
|
335
|
+
current.callbackSuppressedReason = reason;
|
|
336
|
+
writeMeta(current);
|
|
337
|
+
},
|
|
338
|
+
});
|
|
325
339
|
}
|
|
326
340
|
|
|
327
341
|
function getCallbackSuppressionReason(
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// Generated from packages/callback-batcher/index.ts. Do not edit directly.
|
|
2
|
+
export type CallbackSource = "subagent" | "background-task";
|
|
3
|
+
export type CallbackDetailTool = "subagent_result" | "bg_task_status";
|
|
4
|
+
|
|
5
|
+
export interface CallbackBatchHost {
|
|
6
|
+
sendMessage(
|
|
7
|
+
message: { customType: string; content: string; display: boolean },
|
|
8
|
+
options: Record<string, unknown>,
|
|
9
|
+
): unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CallbackBatchEvent {
|
|
13
|
+
source: CallbackSource;
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
status: string;
|
|
17
|
+
detailTool: CallbackDetailTool;
|
|
18
|
+
callback?: boolean;
|
|
19
|
+
isDelivered?: () => boolean;
|
|
20
|
+
getSuppressionReason?: () => string | undefined;
|
|
21
|
+
onDelivered?: (at: number) => void;
|
|
22
|
+
onSuppressed?: (reason: string, at: number) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UrgentCallbackEvent {
|
|
26
|
+
source: CallbackSource;
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
status: "orphaned" | "lost" | string;
|
|
30
|
+
customType: string;
|
|
31
|
+
content: string;
|
|
32
|
+
isDelivered?: () => boolean;
|
|
33
|
+
getSuppressionReason?: () => string | undefined;
|
|
34
|
+
onDelivered?: (at: number) => void;
|
|
35
|
+
onSuppressed?: (reason: string, at: number) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CallbackBatcherOptions {
|
|
39
|
+
windowMs?: number;
|
|
40
|
+
retryMs?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CallbackBatcher {
|
|
44
|
+
enqueue(event: CallbackBatchEvent): boolean;
|
|
45
|
+
flush(): Promise<boolean>;
|
|
46
|
+
deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
|
|
47
|
+
cancel(): void;
|
|
48
|
+
pendingCount(): number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface PendingEvent {
|
|
52
|
+
event: CallbackBatchEvent;
|
|
53
|
+
sequence: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface SharedCallbackBatcherState {
|
|
57
|
+
byHost: WeakMap<object, CallbackBatcher>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
|
|
61
|
+
const DEFAULT_WINDOW_MS = 100;
|
|
62
|
+
const DEFAULT_RETRY_MS = 1_000;
|
|
63
|
+
const MAX_LABEL_CHARS = 160;
|
|
64
|
+
const MAX_ID_CHARS = 200;
|
|
65
|
+
const MAX_STATUS_CHARS = 80;
|
|
66
|
+
|
|
67
|
+
export const CALLBACK_BATCH_WINDOW_ENV = "PI_BETTER_CALLBACK_BATCH_MS";
|
|
68
|
+
export const DEFAULT_CALLBACK_BATCH_WINDOW_MS = DEFAULT_WINDOW_MS;
|
|
69
|
+
|
|
70
|
+
export function resolveCallbackBatchWindowMs(
|
|
71
|
+
value: unknown = process.env[CALLBACK_BATCH_WINDOW_ENV],
|
|
72
|
+
): number {
|
|
73
|
+
if (value === undefined || value === null || value === "") return DEFAULT_WINDOW_MS;
|
|
74
|
+
const parsed = Number(value);
|
|
75
|
+
if (!Number.isFinite(parsed)) return DEFAULT_WINDOW_MS;
|
|
76
|
+
return Math.max(0, Math.min(5_000, Math.floor(parsed)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatCallbackBatch(events: readonly CallbackBatchEvent[]): string {
|
|
80
|
+
const count = events.length;
|
|
81
|
+
const heading = `${count} background completion${count === 1 ? " is" : "s are"} ready:`;
|
|
82
|
+
const rows = events.map((event) => {
|
|
83
|
+
const source = boundedField(event.source, 40);
|
|
84
|
+
const id = boundedField(event.id, MAX_ID_CHARS);
|
|
85
|
+
const label = boundedField(event.label, MAX_LABEL_CHARS);
|
|
86
|
+
const status = boundedField(event.status, MAX_STATUS_CHARS);
|
|
87
|
+
const detail = event.detailTool === "bg_task_status"
|
|
88
|
+
? `bg_task_status id=${id}`
|
|
89
|
+
: `subagent_result id=${JSON.stringify(id)}`;
|
|
90
|
+
return `- source=${source} | id=${id} | label=${JSON.stringify(label)} | status=${status} | inspect: ${detail}`;
|
|
91
|
+
});
|
|
92
|
+
return [
|
|
93
|
+
heading,
|
|
94
|
+
...rows,
|
|
95
|
+
"Retrieve durable results/status with the listed tools. Full results and logs are intentionally omitted.",
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createCallbackBatcher(
|
|
100
|
+
host: CallbackBatchHost,
|
|
101
|
+
options: CallbackBatcherOptions = {},
|
|
102
|
+
): CallbackBatcher {
|
|
103
|
+
const windowMs = options.windowMs ?? resolveCallbackBatchWindowMs();
|
|
104
|
+
const retryMs = Math.max(0, options.retryMs ?? DEFAULT_RETRY_MS);
|
|
105
|
+
const pending = new Map<string, PendingEvent>();
|
|
106
|
+
const inFlight = new Set<string>();
|
|
107
|
+
const urgentInFlight = new Set<string>();
|
|
108
|
+
let sequence = 0;
|
|
109
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
110
|
+
let flushPromise: Promise<boolean> | undefined;
|
|
111
|
+
|
|
112
|
+
const cancelTimer = (): void => {
|
|
113
|
+
if (timer) clearTimeout(timer);
|
|
114
|
+
timer = undefined;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const schedule = (delayMs: number): void => {
|
|
118
|
+
if (timer || pending.size === 0) return;
|
|
119
|
+
timer = setTimeout(() => {
|
|
120
|
+
timer = undefined;
|
|
121
|
+
void api.flush();
|
|
122
|
+
}, Math.max(0, delayMs));
|
|
123
|
+
timer.unref?.();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const enqueue = (event: CallbackBatchEvent): boolean => {
|
|
127
|
+
if (event.callback === false) return false;
|
|
128
|
+
const key = eventKey(event);
|
|
129
|
+
if (pending.has(key) || inFlight.has(key)) return false;
|
|
130
|
+
pending.set(key, { event, sequence: sequence++ });
|
|
131
|
+
schedule(windowMs);
|
|
132
|
+
return true;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const performFlush = async (): Promise<boolean> => {
|
|
136
|
+
cancelTimer();
|
|
137
|
+
const snapshot = [...pending.entries()]
|
|
138
|
+
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
139
|
+
pending.clear();
|
|
140
|
+
for (const [key] of snapshot) inFlight.add(key);
|
|
141
|
+
|
|
142
|
+
const deliverable: Array<[string, PendingEvent]> = [];
|
|
143
|
+
for (const item of snapshot) {
|
|
144
|
+
const [key, pendingEvent] = item;
|
|
145
|
+
const disposition = eventDisposition(pendingEvent.event);
|
|
146
|
+
if (disposition.kind === "delivered") {
|
|
147
|
+
inFlight.delete(key);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (disposition.kind === "suppressed") {
|
|
151
|
+
invokeSuppressed(pendingEvent.event, disposition.reason, Date.now());
|
|
152
|
+
inFlight.delete(key);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
deliverable.push(item);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (deliverable.length === 0) {
|
|
159
|
+
if (pending.size > 0) schedule(windowMs);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await host.sendMessage(
|
|
165
|
+
{
|
|
166
|
+
customType: "background-completion-batch",
|
|
167
|
+
content: formatCallbackBatch(deliverable.map(([, item]) => item.event)),
|
|
168
|
+
display: true,
|
|
169
|
+
},
|
|
170
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
171
|
+
);
|
|
172
|
+
} catch {
|
|
173
|
+
for (const [key] of deliverable) inFlight.delete(key);
|
|
174
|
+
const retryItems = [...deliverable, ...pending.entries()]
|
|
175
|
+
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
176
|
+
pending.clear();
|
|
177
|
+
for (const [key, item] of retryItems) {
|
|
178
|
+
if (!pending.has(key)) pending.set(key, item);
|
|
179
|
+
}
|
|
180
|
+
schedule(retryMs);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const deliveredAt = Date.now();
|
|
185
|
+
for (const [key, item] of deliverable) {
|
|
186
|
+
invokeDelivered(item.event, deliveredAt);
|
|
187
|
+
inFlight.delete(key);
|
|
188
|
+
}
|
|
189
|
+
if (pending.size > 0) schedule(windowMs);
|
|
190
|
+
return true;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const flush = (): Promise<boolean> => {
|
|
194
|
+
if (flushPromise) return flushPromise;
|
|
195
|
+
flushPromise = performFlush().finally(() => {
|
|
196
|
+
flushPromise = undefined;
|
|
197
|
+
});
|
|
198
|
+
return flushPromise;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const deliverUrgent = (event: UrgentCallbackEvent): boolean | Promise<boolean> => {
|
|
202
|
+
const key = eventKey(event);
|
|
203
|
+
if (urgentInFlight.has(key)) return false;
|
|
204
|
+
const disposition = eventDisposition(event);
|
|
205
|
+
if (disposition.kind === "delivered") return true;
|
|
206
|
+
if (disposition.kind === "suppressed") {
|
|
207
|
+
invokeSuppressed(event, disposition.reason, Date.now());
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
urgentInFlight.add(key);
|
|
212
|
+
try {
|
|
213
|
+
const handoff = host.sendMessage(
|
|
214
|
+
{ customType: event.customType, content: event.content, display: true },
|
|
215
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
216
|
+
);
|
|
217
|
+
if (isPromiseLike(handoff)) {
|
|
218
|
+
return Promise.resolve(handoff).then(
|
|
219
|
+
() => {
|
|
220
|
+
invokeDelivered(event, Date.now());
|
|
221
|
+
return true;
|
|
222
|
+
},
|
|
223
|
+
() => false,
|
|
224
|
+
).finally(() => urgentInFlight.delete(key));
|
|
225
|
+
}
|
|
226
|
+
invokeDelivered(event, Date.now());
|
|
227
|
+
urgentInFlight.delete(key);
|
|
228
|
+
return true;
|
|
229
|
+
} catch {
|
|
230
|
+
urgentInFlight.delete(key);
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const api: CallbackBatcher = {
|
|
236
|
+
enqueue,
|
|
237
|
+
flush,
|
|
238
|
+
deliverUrgent,
|
|
239
|
+
cancel() {
|
|
240
|
+
cancelTimer();
|
|
241
|
+
pending.clear();
|
|
242
|
+
},
|
|
243
|
+
pendingCount() {
|
|
244
|
+
return pending.size;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
return api;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function getCallbackBatcher(
|
|
251
|
+
host: CallbackBatchHost,
|
|
252
|
+
options: CallbackBatcherOptions = {},
|
|
253
|
+
): CallbackBatcher {
|
|
254
|
+
const state = globalState();
|
|
255
|
+
const key = host as object;
|
|
256
|
+
const existing = state.byHost.get(key);
|
|
257
|
+
if (existing) return existing;
|
|
258
|
+
const created = createCallbackBatcher(host, options);
|
|
259
|
+
state.byHost.set(key, created);
|
|
260
|
+
return created;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function cancelCallbackBatch(host: CallbackBatchHost): void {
|
|
264
|
+
globalState().byHost.get(host as object)?.cancel();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function globalState(): SharedCallbackBatcherState {
|
|
268
|
+
const root = globalThis as typeof globalThis & {
|
|
269
|
+
[GLOBAL_STATE_KEY]?: SharedCallbackBatcherState;
|
|
270
|
+
};
|
|
271
|
+
root[GLOBAL_STATE_KEY] ??= { byHost: new WeakMap<object, CallbackBatcher>() };
|
|
272
|
+
return root[GLOBAL_STATE_KEY];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function eventKey(event: Pick<CallbackBatchEvent, "source" | "id" | "status">): string {
|
|
276
|
+
return `${event.source}\u0000${event.id}\u0000${event.status}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function eventDisposition(
|
|
280
|
+
event: Pick<CallbackBatchEvent, "isDelivered" | "getSuppressionReason">,
|
|
281
|
+
): { kind: "deliver" } | { kind: "delivered" } | { kind: "suppressed"; reason: string } {
|
|
282
|
+
try {
|
|
283
|
+
if (event.isDelivered?.()) return { kind: "delivered" };
|
|
284
|
+
} catch {
|
|
285
|
+
return { kind: "suppressed", reason: "durable delivery state could not be verified" };
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const reason = event.getSuppressionReason?.();
|
|
289
|
+
return reason ? { kind: "suppressed", reason } : { kind: "deliver" };
|
|
290
|
+
} catch {
|
|
291
|
+
return { kind: "suppressed", reason: "callback ownership could not be verified" };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function invokeDelivered(
|
|
296
|
+
event: Pick<CallbackBatchEvent, "onDelivered">,
|
|
297
|
+
at: number,
|
|
298
|
+
): void {
|
|
299
|
+
try { event.onDelivered?.(at); } catch { /* handoff already succeeded */ }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function invokeSuppressed(
|
|
303
|
+
event: Pick<CallbackBatchEvent, "onSuppressed">,
|
|
304
|
+
reason: string,
|
|
305
|
+
at: number,
|
|
306
|
+
): void {
|
|
307
|
+
try { event.onSuppressed?.(reason, at); } catch { /* best effort durable suppression */ }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function boundedField(value: unknown, maxChars: number): string {
|
|
311
|
+
const oneLine = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
312
|
+
if (oneLine.length <= maxChars) return oneLine;
|
|
313
|
+
return `${oneLine.slice(0, Math.max(0, maxChars - 3))}...`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
317
|
+
return (typeof value === "object" || typeof value === "function")
|
|
318
|
+
&& value !== null
|
|
319
|
+
&& typeof (value as PromiseLike<unknown>).then === "function";
|
|
320
|
+
}
|
package/src/shared-navigator.ts
CHANGED
|
@@ -39,6 +39,11 @@ export type BackgroundWorkDetail = {
|
|
|
39
39
|
metadata: Array<{ label: string; value: string }>;
|
|
40
40
|
foldedSections?: Array<{ id: string; label: string; text: string; collapsedText?: string; expandedByDefault?: boolean }>;
|
|
41
41
|
evidence: { label: string; text: string };
|
|
42
|
+
transcript?: Array<
|
|
43
|
+
| { type: "assistant"; content: Array<{ type: string; text?: string; thinking?: string }>; streaming: boolean }
|
|
44
|
+
| { type: "tool"; id?: string; name: string; args?: unknown; result?: unknown; isError: boolean; state: "running" | "completed" }
|
|
45
|
+
>;
|
|
46
|
+
transcriptDiagnostic?: string;
|
|
42
47
|
footerActions?: string[];
|
|
43
48
|
};
|
|
44
49
|
|
|
@@ -54,6 +59,8 @@ export type BackgroundWorkProvider = {
|
|
|
54
59
|
label: string;
|
|
55
60
|
priority: number;
|
|
56
61
|
visibleCount(): number;
|
|
62
|
+
showSection?(rows: BackgroundWorkRow[], now: number): boolean;
|
|
63
|
+
parentRow?(now: number): BackgroundWorkRow | null;
|
|
57
64
|
listRows(now: number): BackgroundWorkRow[];
|
|
58
65
|
detail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null;
|
|
59
66
|
armCloseLabel(row: BackgroundWorkRow): string;
|
|
@@ -66,6 +73,7 @@ type HostDeps = {
|
|
|
66
73
|
isOpenTrigger: (data: string) => boolean;
|
|
67
74
|
matchKey: (data: string, keyId: string) => boolean;
|
|
68
75
|
truncate: (s: string, width: number) => string;
|
|
76
|
+
createTranscriptComponent?: (detail: BackgroundWorkDetail, theme: unknown) => Component;
|
|
69
77
|
};
|
|
70
78
|
|
|
71
79
|
type NavigatorState = {
|
|
@@ -82,6 +90,7 @@ type NavigatorState = {
|
|
|
82
90
|
mainListCloseArm?: { id: string; armedAt: number };
|
|
83
91
|
mainListCloseArmTimer?: ReturnType<typeof setTimeout>;
|
|
84
92
|
mainListDeadlineScheduler?: RenderScheduler;
|
|
93
|
+
editorComponent?: Component;
|
|
85
94
|
detailOverlayRows?: number;
|
|
86
95
|
dispose?: () => void;
|
|
87
96
|
};
|
|
@@ -98,8 +107,7 @@ export const CLOSE_ARM_MS = 3000;
|
|
|
98
107
|
export const DEFAULT_LOG_TAIL_ROWS = 10;
|
|
99
108
|
export const LOG_TAIL_ROW_CHOICES = [10, 25] as const;
|
|
100
109
|
const MAIN_LIST_FALLBACK_WIDTH = 100;
|
|
101
|
-
const
|
|
102
|
-
const DETAIL_OVERLAY_FOOTER_MARGIN_ROWS = 3;
|
|
110
|
+
const DETAIL_OVERLAY_FOOTER_ROWS = 3;
|
|
103
111
|
const EVIDENCE_SECTION_ID = "__evidence__";
|
|
104
112
|
const RUNNING_DOT_GLYPH = "●";
|
|
105
113
|
const RUNNING_DOT_FRAMES = ["dim", "accent", "accent", "dim"] as const;
|
|
@@ -314,7 +322,7 @@ function renderWidth(width: number): number {
|
|
|
314
322
|
return Number.isFinite(width) && width > 0 ? Math.floor(width) : MAIN_LIST_FALLBACK_WIDTH;
|
|
315
323
|
}
|
|
316
324
|
|
|
317
|
-
type InternalRow = BackgroundWorkRow & { navigatorId: string; providerLabel: string };
|
|
325
|
+
type InternalRow = BackgroundWorkRow & { navigatorId: string; providerLabel: string; parentRow?: boolean };
|
|
318
326
|
|
|
319
327
|
function rowKey(providerId: string, id: string): string {
|
|
320
328
|
return `${providerId}:${id}`;
|
|
@@ -332,6 +340,19 @@ function listRows(now = Date.now()): InternalRow[] {
|
|
|
332
340
|
let providerRows: BackgroundWorkRow[] = [];
|
|
333
341
|
try { providerRows = provider.listRows(now) ?? []; } catch { providerRows = []; }
|
|
334
342
|
const orderedProviderRows = [...providerRows].sort((a, b) => b.sortStartedAt - a.sortStartedAt || rowDisplayName(a).localeCompare(rowDisplayName(b)));
|
|
343
|
+
let showSection = orderedProviderRows.length > 0 || provider.parentRow !== undefined;
|
|
344
|
+
try { showSection = provider.showSection?.(orderedProviderRows, now) ?? showSection; } catch { showSection = false; }
|
|
345
|
+
if (!showSection) continue;
|
|
346
|
+
let parentRow: BackgroundWorkRow | null = null;
|
|
347
|
+
try { parentRow = provider.parentRow?.(now) ?? null; } catch { parentRow = null; }
|
|
348
|
+
if (parentRow) {
|
|
349
|
+
rows.push({
|
|
350
|
+
...parentRow,
|
|
351
|
+
navigatorId: rowKey(parentRow.providerId, parentRow.id),
|
|
352
|
+
providerLabel: provider.label,
|
|
353
|
+
parentRow: true,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
335
356
|
for (const row of orderedProviderRows) {
|
|
336
357
|
rows.push({ ...row, navigatorId: rowKey(provider.id, row.id), providerLabel: provider.label });
|
|
337
358
|
}
|
|
@@ -377,7 +398,7 @@ function focusMainList(): void {
|
|
|
377
398
|
const rows = listRows();
|
|
378
399
|
if (rows.length === 0) return;
|
|
379
400
|
const s = state();
|
|
380
|
-
|
|
401
|
+
s.mainListSelectedId = rows.find((row) => row.parentRow && row.id === "main")?.navigatorId ?? rows[0]!.navigatorId;
|
|
381
402
|
s.mainListFocused = true;
|
|
382
403
|
refreshMainListWidget();
|
|
383
404
|
}
|
|
@@ -422,13 +443,13 @@ function buildMainListLines(
|
|
|
422
443
|
}
|
|
423
444
|
|
|
424
445
|
function shortcutsLine(focused: boolean, fg: (color: string, value: string) => string): string {
|
|
425
|
-
const keys = focused ? "↑↓
|
|
446
|
+
const keys = focused ? "↑↓ switch · Enter detail · x stop · Esc unfocus" : "← to navigate";
|
|
426
447
|
return dim(keys, fg);
|
|
427
448
|
}
|
|
428
449
|
|
|
429
450
|
function providerGroupLabel(label: string, fg: (color: string, value: string) => string): string {
|
|
430
451
|
const normalized = singleLine(label).toLowerCase();
|
|
431
|
-
return
|
|
452
|
+
return fg("warning", normalized);
|
|
432
453
|
}
|
|
433
454
|
|
|
434
455
|
function formatMainListRow(row: InternalRow, selected: boolean, fg: (color: string, value: string) => string, width: number): string {
|
|
@@ -516,13 +537,14 @@ function detailFor(navigatorId: string, now = Date.now(), options?: { logTailLin
|
|
|
516
537
|
}
|
|
517
538
|
|
|
518
539
|
function closeFor(row: InternalRow): BackgroundWorkCloseOutcome {
|
|
540
|
+
if (row.parentRow) return { action: "not-closable", providerId: row.providerId, id: row.id };
|
|
519
541
|
const provider = state().providers.get(row.providerId);
|
|
520
542
|
if (!provider) return { action: "missing", providerId: row.providerId, id: row.id };
|
|
521
543
|
try { return provider.close(row.id); } catch { return { action: "missing", providerId: row.providerId, id: row.id }; }
|
|
522
544
|
}
|
|
523
545
|
|
|
524
546
|
function closeHintFor(row: InternalRow | undefined): string | null {
|
|
525
|
-
if (!row) return null;
|
|
547
|
+
if (!row || row.parentRow) return null;
|
|
526
548
|
const provider = state().providers.get(row.providerId);
|
|
527
549
|
if (!provider) return null;
|
|
528
550
|
try { return `${provider.armCloseLabel(row)} ${row.name || row.id}`; } catch { return null; }
|
|
@@ -547,6 +569,7 @@ function installNavigatorEditor(ui: any, deps: HostDeps): unknown {
|
|
|
547
569
|
}
|
|
548
570
|
|
|
549
571
|
function wrapEditor(inner: any, deps: HostDeps): unknown {
|
|
572
|
+
if (inner && typeof inner.render === "function") state().editorComponent = inner as Component;
|
|
550
573
|
return new Proxy(inner, {
|
|
551
574
|
get(target, prop) {
|
|
552
575
|
if (prop === "handleInput") {
|
|
@@ -577,16 +600,24 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
|
577
600
|
}
|
|
578
601
|
if (deps.matchKey(data, "up")) {
|
|
579
602
|
clearMainListCloseArm();
|
|
580
|
-
if (moveMainListSelection(-1))
|
|
603
|
+
if (moveMainListSelection(-1)) {
|
|
604
|
+
refreshMainListWidget();
|
|
605
|
+
if (!selectedMainListRow()?.parentRow) openNavigator();
|
|
606
|
+
}
|
|
581
607
|
return true;
|
|
582
608
|
}
|
|
583
609
|
if (deps.matchKey(data, "down")) {
|
|
584
610
|
clearMainListCloseArm();
|
|
585
|
-
if (moveMainListSelection(1))
|
|
611
|
+
if (moveMainListSelection(1)) {
|
|
612
|
+
refreshMainListWidget();
|
|
613
|
+
if (!selectedMainListRow()?.parentRow) openNavigator();
|
|
614
|
+
}
|
|
586
615
|
return true;
|
|
587
616
|
}
|
|
588
617
|
if (deps.matchKey(data, "enter")) {
|
|
589
|
-
|
|
618
|
+
const selected = selectedMainListRow();
|
|
619
|
+
if (selected?.parentRow) unfocusMainList();
|
|
620
|
+
else openNavigator();
|
|
590
621
|
return true;
|
|
591
622
|
}
|
|
592
623
|
if (data === "x" || data === "X" || deps.matchKey(data, "x") || deps.matchKey(data, "X")) {
|
|
@@ -602,7 +633,7 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
|
602
633
|
|
|
603
634
|
function handleMainListCloseKey(): void {
|
|
604
635
|
const row = selectedMainListRow();
|
|
605
|
-
if (!row) return;
|
|
636
|
+
if (!row || row.parentRow) return;
|
|
606
637
|
const s = state();
|
|
607
638
|
const now = Date.now();
|
|
608
639
|
const arm = s.mainListCloseArm;
|
|
@@ -678,6 +709,8 @@ function createOverlayComponent(
|
|
|
678
709
|
let closeArm: { id: string; armedAt: number } | undefined;
|
|
679
710
|
let closeArmTimer: ReturnType<typeof setTimeout> | undefined;
|
|
680
711
|
let closed = false;
|
|
712
|
+
let transcriptDetail: BackgroundWorkDetail | null = null;
|
|
713
|
+
let transcriptComponent: Component | null = null;
|
|
681
714
|
|
|
682
715
|
const fg = (color: string, value: string) => theme?.fg ? theme.fg(color, value) : value;
|
|
683
716
|
|
|
@@ -693,12 +726,24 @@ function createOverlayComponent(
|
|
|
693
726
|
});
|
|
694
727
|
|
|
695
728
|
function refreshRows(): void {
|
|
696
|
-
const selectedId = overlayState.rows[overlayState.selected]?.navigatorId;
|
|
729
|
+
const selectedId = state().mainListSelectedId ?? overlayState.rows[overlayState.selected]?.navigatorId;
|
|
697
730
|
overlayState.rows = listRows();
|
|
698
731
|
const nextIdx = selectedId ? overlayState.rows.findIndex((row) => row.navigatorId === selectedId) : -1;
|
|
699
732
|
overlayState.selected = nextIdx >= 0 ? nextIdx : Math.min(overlayState.selected, Math.max(0, overlayState.rows.length - 1));
|
|
700
733
|
}
|
|
701
734
|
|
|
735
|
+
function selectOverlayRow(next: number): void {
|
|
736
|
+
overlayState.selected = Math.min(Math.max(0, overlayState.rows.length - 1), Math.max(0, next));
|
|
737
|
+
state().mainListSelectedId = overlayState.rows[overlayState.selected]?.navigatorId;
|
|
738
|
+
clearCloseArm();
|
|
739
|
+
refreshMainListWidget();
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function activateSelectedRow(): void {
|
|
743
|
+
if (selectedRow()?.parentRow) close();
|
|
744
|
+
else openDetail();
|
|
745
|
+
}
|
|
746
|
+
|
|
702
747
|
function clearCloseArm(): void {
|
|
703
748
|
if (closeArmTimer) clearTimeout(closeArmTimer);
|
|
704
749
|
closeArmTimer = undefined;
|
|
@@ -720,13 +765,16 @@ function createOverlayComponent(
|
|
|
720
765
|
}
|
|
721
766
|
|
|
722
767
|
function selectedRow(): InternalRow | undefined {
|
|
723
|
-
if (mode === "detail" && detailId) return overlayState.rows.find((row) => row.navigatorId === detailId);
|
|
724
768
|
return overlayState.rows[overlayState.selected];
|
|
725
769
|
}
|
|
726
770
|
|
|
727
771
|
function openDetail(): void {
|
|
728
772
|
const row = selectedRow();
|
|
729
773
|
if (!row) return;
|
|
774
|
+
if (row.parentRow) {
|
|
775
|
+
close();
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
730
778
|
clearCloseArm();
|
|
731
779
|
detailId = row.navigatorId;
|
|
732
780
|
expandedSections.clear();
|
|
@@ -797,10 +845,43 @@ function createOverlayComponent(
|
|
|
797
845
|
|
|
798
846
|
return {
|
|
799
847
|
render(width: number) {
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
848
|
+
refreshRows();
|
|
849
|
+
const railLines = mode === "detail"
|
|
850
|
+
? buildMainListLines(overlayState.rows, width, deps.truncate, fg, {
|
|
851
|
+
selectedId: selectedRow()?.navigatorId,
|
|
852
|
+
focused: true,
|
|
853
|
+
})
|
|
854
|
+
: [];
|
|
855
|
+
const editorLines = mode === "detail" ? renderEditorLines(width) : [];
|
|
856
|
+
const bottomLines = [...railLines, ...editorLines];
|
|
857
|
+
const overlayRows = state().detailOverlayRows;
|
|
858
|
+
const detailRows = overlayRows === undefined ? undefined : Math.max(1, overlayRows - bottomLines.length);
|
|
859
|
+
let contentLines: string[];
|
|
860
|
+
if (mode === "detail" && detail?.transcript && deps.createTranscriptComponent) {
|
|
861
|
+
if (transcriptDetail !== detail || !transcriptComponent) {
|
|
862
|
+
transcriptDetail = detail;
|
|
863
|
+
transcriptComponent = deps.createTranscriptComponent(detail, theme);
|
|
864
|
+
}
|
|
865
|
+
contentLines = buildTranscriptDetailLines(
|
|
866
|
+
detail,
|
|
867
|
+
transcriptComponent.render(width),
|
|
868
|
+
width,
|
|
869
|
+
deps.truncate,
|
|
870
|
+
fg,
|
|
871
|
+
{ minRows: detailRows },
|
|
872
|
+
);
|
|
873
|
+
} else {
|
|
874
|
+
transcriptDetail = null;
|
|
875
|
+
transcriptComponent = null;
|
|
876
|
+
contentLines = mode === "detail"
|
|
877
|
+
? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows })
|
|
878
|
+
: buildListLines(overlayState, width, deps.truncate, fg);
|
|
879
|
+
}
|
|
880
|
+
if (mode !== "detail") return contentLines;
|
|
881
|
+
if (detailRows === undefined) return [...contentLines, ...bottomLines];
|
|
882
|
+
const fittedContent = contentLines.slice(0, detailRows);
|
|
883
|
+
while (fittedContent.length < detailRows) fittedContent.push("");
|
|
884
|
+
return [...fittedContent, ...bottomLines];
|
|
804
885
|
},
|
|
805
886
|
handleInput(data: string) {
|
|
806
887
|
if (closed) return;
|
|
@@ -809,10 +890,25 @@ function createOverlayComponent(
|
|
|
809
890
|
return;
|
|
810
891
|
}
|
|
811
892
|
if (mode === "detail") {
|
|
812
|
-
if (deps.matchKey(data, "
|
|
893
|
+
if (deps.matchKey(data, "up")) {
|
|
894
|
+
selectOverlayRow(overlayState.selected - 1);
|
|
895
|
+
activateSelectedRow();
|
|
896
|
+
}
|
|
897
|
+
else if (deps.matchKey(data, "down")) {
|
|
898
|
+
selectOverlayRow(overlayState.selected + 1);
|
|
899
|
+
activateSelectedRow();
|
|
900
|
+
}
|
|
901
|
+
else if (deps.matchKey(data, "left")) {
|
|
902
|
+
const mainIdx = overlayState.rows.findIndex((row) => row.parentRow && row.id === "main");
|
|
903
|
+
if (mainIdx >= 0) selectOverlayRow(mainIdx);
|
|
904
|
+
close();
|
|
905
|
+
}
|
|
813
906
|
else if (deps.matchKey(data, "enter")) {
|
|
814
|
-
const
|
|
815
|
-
if (
|
|
907
|
+
const row = selectedRow();
|
|
908
|
+
if (row?.parentRow || row?.navigatorId !== detailId) openDetail();
|
|
909
|
+
else {
|
|
910
|
+
const sectionId = firstToggleableSectionId(detail);
|
|
911
|
+
if (!sectionId) return;
|
|
816
912
|
if (expandedSections.has(sectionId)) expandedSections.delete(sectionId);
|
|
817
913
|
else expandedSections.add(sectionId);
|
|
818
914
|
requestRender();
|
|
@@ -823,7 +919,10 @@ function createOverlayComponent(
|
|
|
823
919
|
if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
824
920
|
requestRender();
|
|
825
921
|
}
|
|
826
|
-
else if (deps.matchKey(data, "escape"))
|
|
922
|
+
else if (deps.matchKey(data, "escape")) {
|
|
923
|
+
unfocusMainList();
|
|
924
|
+
close();
|
|
925
|
+
}
|
|
827
926
|
return;
|
|
828
927
|
}
|
|
829
928
|
if (deps.matchKey(data, "up")) {
|
|
@@ -840,7 +939,7 @@ function createOverlayComponent(
|
|
|
840
939
|
close();
|
|
841
940
|
}
|
|
842
941
|
},
|
|
843
|
-
invalidate() {},
|
|
942
|
+
invalidate() { transcriptComponent?.invalidate(); },
|
|
844
943
|
dispose() {
|
|
845
944
|
clearCloseArm();
|
|
846
945
|
detailScheduler.dispose();
|
|
@@ -848,26 +947,61 @@ function createOverlayComponent(
|
|
|
848
947
|
};
|
|
849
948
|
}
|
|
850
949
|
|
|
950
|
+
function buildTranscriptDetailLines(
|
|
951
|
+
detail: BackgroundWorkDetail,
|
|
952
|
+
transcriptLines: string[],
|
|
953
|
+
width: number,
|
|
954
|
+
truncate: (s: string, width: number) => string,
|
|
955
|
+
fg: (color: string, value: string) => string,
|
|
956
|
+
options: { minRows?: number } = {},
|
|
957
|
+
): string[] {
|
|
958
|
+
const actions = [...(detail.footerActions ?? ["x close"]), "Esc close"].join(" · ");
|
|
959
|
+
const lines: string[] = [
|
|
960
|
+
fg("accent", rule(detail.title, width)),
|
|
961
|
+
dim(` ← main · ${actions}`, fg),
|
|
962
|
+
"",
|
|
963
|
+
` status ${fg(toneColor(detail.statusTone, detail.status), detail.status)}`,
|
|
964
|
+
];
|
|
965
|
+
if (detail.subtitle) lines.push(` summary ${detail.subtitle}`);
|
|
966
|
+
for (const item of detail.metadata) lines.push(` ${item.label.padEnd(8, " ").slice(0, 8)} ${item.value}`);
|
|
967
|
+
lines.push("", dim(section("transcript", width), fg));
|
|
968
|
+
if (detail.transcriptDiagnostic) lines.push(` ${dim(detail.transcriptDiagnostic, fg)}`);
|
|
969
|
+
lines.push(...(transcriptLines.length ? transcriptLines : [" (no transcript yet)"]));
|
|
970
|
+
lines.push("");
|
|
971
|
+
const footerLines = [dim(` ← main · ${actions}`, fg), dim(rule("", width), fg)];
|
|
972
|
+
padBeforeFooter(lines, footerLines.length, options.minRows);
|
|
973
|
+
lines.push(...footerLines);
|
|
974
|
+
return lines.map((line) => safeTruncate(line, width, truncate));
|
|
975
|
+
}
|
|
976
|
+
|
|
851
977
|
function detailOverlayOptions() {
|
|
852
|
-
const
|
|
853
|
-
const marginBottom = DETAIL_OVERLAY_FOOTER_MARGIN_ROWS + navigatorRows;
|
|
978
|
+
const marginBottom = DETAIL_OVERLAY_FOOTER_ROWS;
|
|
854
979
|
return {
|
|
855
980
|
anchor: "top-left" as const,
|
|
856
981
|
width: "100%" as const,
|
|
857
982
|
maxHeight: "100%" as const,
|
|
858
983
|
margin: {
|
|
859
|
-
top:
|
|
984
|
+
top: 0,
|
|
860
985
|
right: 0,
|
|
861
986
|
bottom: marginBottom,
|
|
862
987
|
left: 0,
|
|
863
988
|
},
|
|
864
989
|
visible: (_termWidth: number, termHeight: number) => {
|
|
865
|
-
state().detailOverlayRows = Math.max(1, termHeight -
|
|
990
|
+
state().detailOverlayRows = Math.max(1, termHeight - marginBottom);
|
|
866
991
|
return true;
|
|
867
992
|
},
|
|
868
993
|
};
|
|
869
994
|
}
|
|
870
995
|
|
|
996
|
+
function renderEditorLines(width: number): string[] {
|
|
997
|
+
try {
|
|
998
|
+
const lines = state().editorComponent?.render(width);
|
|
999
|
+
if (lines?.length) return lines;
|
|
1000
|
+
} catch { /* use an empty editor-shaped fallback */ }
|
|
1001
|
+
const border = "─".repeat(Math.max(1, width));
|
|
1002
|
+
return [border, "", border];
|
|
1003
|
+
}
|
|
1004
|
+
|
|
871
1005
|
function fallbackDetail(row: InternalRow): BackgroundWorkDetail {
|
|
872
1006
|
return {
|
|
873
1007
|
providerId: row.providerId,
|
|
@@ -976,7 +1110,10 @@ function buildDetailLines(
|
|
|
976
1110
|
} else {
|
|
977
1111
|
const evidenceLabel = /log/i.test(detail.evidence.label) ? `${detail.evidence.label} · latest ${tailRows} rows` : detail.evidence.label;
|
|
978
1112
|
lines.push(dim(section(evidenceLabel, width), fg));
|
|
979
|
-
|
|
1113
|
+
const wrapped = /log/i.test(detail.evidence.label)
|
|
1114
|
+
? wrapLogText(body, width - 6)
|
|
1115
|
+
: body.split(/\r?\n/);
|
|
1116
|
+
for (const raw of wrapped) lines.push(raw ? ` ${raw}` : " ");
|
|
980
1117
|
}
|
|
981
1118
|
lines.push("");
|
|
982
1119
|
const footerLines = [dim(` ← back · ${actions}`, fg), dim(rule("", width), fg)];
|
|
@@ -1042,6 +1179,82 @@ function wrapEvidenceText(text: string, width: number): string[] {
|
|
|
1042
1179
|
return rows.length ? rows : ["(no output yet)"];
|
|
1043
1180
|
}
|
|
1044
1181
|
|
|
1182
|
+
/**
|
|
1183
|
+
* Wrap terminal log rows without flattening entries together. Continuations
|
|
1184
|
+
* retain the source indentation, and long paths/JSON tokens are hard-wrapped
|
|
1185
|
+
* so the final width guard never has to discard their suffix.
|
|
1186
|
+
*/
|
|
1187
|
+
export function wrapLogText(text: string, width: number): string[] {
|
|
1188
|
+
const max = Math.max(8, Math.floor(width));
|
|
1189
|
+
const rows: string[] = [];
|
|
1190
|
+
for (const source of String(text ?? "").split(/\r?\n/)) {
|
|
1191
|
+
if (!source) {
|
|
1192
|
+
rows.push("");
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
const sourceIndent = source.match(/^[ \t]*/)?.[0] ?? "";
|
|
1196
|
+
const indent = sourceIndent.replace(/\t/g, " ");
|
|
1197
|
+
let remaining = source.slice(sourceIndent.length);
|
|
1198
|
+
const contentWidth = Math.max(1, max - visibleWidth(indent));
|
|
1199
|
+
if (!remaining) {
|
|
1200
|
+
rows.push(indent);
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
while (visibleWidth(remaining) > contentWidth) {
|
|
1204
|
+
let [head, tail] = splitVisiblePrefix(remaining, contentWidth);
|
|
1205
|
+
const minSoftBreak = Math.floor(contentWidth * 0.45);
|
|
1206
|
+
const delimiterBreak = lastDelimiterBreak(head);
|
|
1207
|
+
const whitespace = head.match(/\s+\S*$/)?.index;
|
|
1208
|
+
if (delimiterBreak > minSoftBreak) {
|
|
1209
|
+
tail = head.slice(delimiterBreak) + tail;
|
|
1210
|
+
head = head.slice(0, delimiterBreak);
|
|
1211
|
+
} else if (whitespace !== undefined && whitespace > minSoftBreak) {
|
|
1212
|
+
tail = head.slice(whitespace).trimStart() + tail;
|
|
1213
|
+
head = head.slice(0, whitespace).trimEnd();
|
|
1214
|
+
}
|
|
1215
|
+
rows.push(indent + head);
|
|
1216
|
+
remaining = tail.trimStart();
|
|
1217
|
+
}
|
|
1218
|
+
rows.push(indent + remaining);
|
|
1219
|
+
}
|
|
1220
|
+
return rows.length ? rows : [""];
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function lastDelimiterBreak(value: string): number {
|
|
1224
|
+
let last = -1;
|
|
1225
|
+
for (const match of value.matchAll(/[\/,}:]/g)) last = match.index + 1;
|
|
1226
|
+
return last;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function splitVisiblePrefix(value: string, width: number): [string, string] {
|
|
1230
|
+
const max = Math.max(0, Math.floor(width));
|
|
1231
|
+
let visible = 0;
|
|
1232
|
+
let index = 0;
|
|
1233
|
+
while (index < value.length && visible < max) {
|
|
1234
|
+
if (value[index] === "\u001b" || value[index] === "\u009b") {
|
|
1235
|
+
const match = value.slice(index).match(ANSI_RE);
|
|
1236
|
+
if (match?.index === 0) {
|
|
1237
|
+
index += match[0].length;
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
if (value[index] === "<") {
|
|
1242
|
+
const close = value.indexOf(">", index);
|
|
1243
|
+
if (close !== -1) {
|
|
1244
|
+
const tag = value.slice(index, close + 1);
|
|
1245
|
+
if (/^<\/?[a-zA-Z][\w-]*>$/.test(tag) || tag === "</>") {
|
|
1246
|
+
index = close + 1;
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
const codePoint = value.codePointAt(index)!;
|
|
1252
|
+
index += codePoint > 0xFFFF ? 2 : 1;
|
|
1253
|
+
visible += 1;
|
|
1254
|
+
}
|
|
1255
|
+
return [value.slice(0, index), value.slice(index)];
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1045
1258
|
function cycleLogTailRows(current: number): number {
|
|
1046
1259
|
const idx = LOG_TAIL_ROW_CHOICES.findIndex((value) => value === current);
|
|
1047
1260
|
return LOG_TAIL_ROW_CHOICES[(idx + 1) % LOG_TAIL_ROW_CHOICES.length];
|
package/src/tools.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Type } from "typebox";
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { readLog } from "./logs.js";
|
|
4
4
|
import { refreshBackgroundTasksNavigator } from "./navigator-provider.js";
|
|
5
|
+
import { cancelCallbackBatch } from "./shared-callback-batcher.js";
|
|
5
6
|
import { listMetas, readMeta, writeMeta } from "./registry.js";
|
|
6
7
|
import { resumeRunningTask, spawnTask, startWatchTask, stopTask } from "./runtime.js";
|
|
7
8
|
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta } from "./types.js";
|
|
@@ -89,9 +90,11 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
89
90
|
});
|
|
90
91
|
pi.on("session_before_switch", () => {
|
|
91
92
|
activeSession = undefined;
|
|
93
|
+
cancelCallbackBatch(pi);
|
|
92
94
|
});
|
|
93
95
|
pi.on("session_shutdown", () => {
|
|
94
96
|
activeSession = undefined;
|
|
97
|
+
cancelCallbackBatch(pi);
|
|
95
98
|
});
|
|
96
99
|
|
|
97
100
|
pi.registerTool({
|