shariq-pi-extensions 0.4.0 → 0.5.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/docs/EXTENSIONS.md +1 -1
- package/extensions/ask-user/index.ts +60 -33
- package/extensions/background-terminals/README.md +5 -5
- package/extensions/background-terminals/index.ts +1 -1
- package/extensions/background-terminals/src/manager.ts +32 -7
- package/extensions/background-terminals/src/pty.ts +25 -8
- package/extensions/background-terminals/src/ui.ts +6 -15
- package/extensions/context-usage/index.ts +40 -17
- package/extensions/firecrawl-web/auth.ts +1 -1
- package/extensions/firecrawl-web/client.ts +15 -5
- package/extensions/firecrawl-web/output.ts +4 -1
- package/extensions/goal/extension.ts +57 -33
- package/extensions/input-mode/index.ts +10 -2
- package/extensions/performance-status/README.md +1 -1
- package/extensions/performance-status/index.ts +29 -20
- package/extensions/shared/README.md +4 -2
- package/extensions/shared/activity-dock.ts +33 -22
- package/extensions/shared/child-session.ts +4 -0
- package/extensions/shared/model-picker.ts +2 -2
- package/extensions/shared/settlement-delivery.ts +7 -1
- package/extensions/shared/tool-call-timeout.ts +8 -1
- package/extensions/shared/tool-card.ts +5 -3
- package/extensions/smart-compaction/engine.ts +39 -21
- package/extensions/smart-compaction/index.ts +17 -3
- package/extensions/smart-compaction/prompt.ts +1 -1
- package/extensions/subagents/README.md +4 -2
- package/extensions/subagents/index.ts +73 -26
- package/extensions/subagents/src/backends/pi.ts +42 -10
- package/extensions/subagents/src/catalog.ts +74 -4
- package/extensions/subagents/src/config.ts +13 -8
- package/extensions/subagents/src/manager.ts +4 -29
- package/extensions/task-list/README.md +1 -1
- package/extensions/task-list/index.ts +22 -11
- package/extensions/task-list/state.ts +19 -3
- package/extensions/web-fetch/README.md +3 -1
- package/extensions/web-fetch/index.ts +252 -263
- package/package.json +4 -2
- package/skills/subagents/SKILL.md +1 -1
- package/extensions/subagents/src/storage.ts +0 -65
package/docs/EXTENSIONS.md
CHANGED
|
@@ -36,7 +36,7 @@ Managed PTYs for servers, watchers, long builds, and interactive programs: up to
|
|
|
36
36
|
|
|
37
37
|
### [Web fetch](../extensions/web-fetch/README.md)
|
|
38
38
|
|
|
39
|
-
`web_fetch` retrieves one known HTTP or HTTPS URL as Markdown, text, or HTML, with time and size limits and no Firecrawl credits.
|
|
39
|
+
`web_fetch` retrieves one known HTTP or HTTPS URL as Markdown, text, or HTML, with time and size limits and no Firecrawl credits. Local and private network addresses are blocked unless `PI_WEB_FETCH_ALLOW_PRIVATE=1` is set.
|
|
40
40
|
|
|
41
41
|
## Interface
|
|
42
42
|
|
|
@@ -77,8 +77,10 @@ function cleanMultiline(text: string): string {
|
|
|
77
77
|
return sanitizeTerminalText(text).trim();
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// Compare labels the way they are displayed (whitespace collapsed), so two
|
|
81
|
+
// labels that look identical on screen are rejected as duplicates.
|
|
80
82
|
function normalizedChoiceKey(label: string): string {
|
|
81
|
-
return label.normalize("NFKC").toLocaleLowerCase("en-US");
|
|
83
|
+
return oneLine(label).normalize("NFKC").toLocaleLowerCase("en-US");
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
function normalizeQuestionInput(
|
|
@@ -124,6 +126,7 @@ function createEditorTheme(theme: Theme): EditorTheme {
|
|
|
124
126
|
export class AskUserView implements Focusable {
|
|
125
127
|
private selected = 0;
|
|
126
128
|
private editing = false;
|
|
129
|
+
private questionOffset = 0;
|
|
127
130
|
private cachedWidth?: number;
|
|
128
131
|
private cachedLines?: string[];
|
|
129
132
|
private _focused = false;
|
|
@@ -133,6 +136,7 @@ export class AskUserView implements Focusable {
|
|
|
133
136
|
private readonly requestRender: () => void;
|
|
134
137
|
private readonly finish: (selection: Selection | null) => void;
|
|
135
138
|
private readonly editor: Editor;
|
|
139
|
+
private readonly maxRows: () => number;
|
|
136
140
|
|
|
137
141
|
constructor(
|
|
138
142
|
question: string,
|
|
@@ -141,6 +145,7 @@ export class AskUserView implements Focusable {
|
|
|
141
145
|
requestRender: () => void,
|
|
142
146
|
finish: (selection: Selection | null) => void,
|
|
143
147
|
editor: Editor,
|
|
148
|
+
maxRows: () => number = () => Number.POSITIVE_INFINITY,
|
|
144
149
|
) {
|
|
145
150
|
this.question = question;
|
|
146
151
|
this.options = options;
|
|
@@ -148,6 +153,7 @@ export class AskUserView implements Focusable {
|
|
|
148
153
|
this.requestRender = requestRender;
|
|
149
154
|
this.finish = finish;
|
|
150
155
|
this.editor = editor;
|
|
156
|
+
this.maxRows = maxRows;
|
|
151
157
|
this.editor.onSubmit = (value) => {
|
|
152
158
|
const answer = cleanMultiline(value).slice(0, MAX_ANSWER_LENGTH);
|
|
153
159
|
if (!answer) {
|
|
@@ -189,6 +195,15 @@ export class AskUserView implements Focusable {
|
|
|
189
195
|
}
|
|
190
196
|
|
|
191
197
|
handleInput(data: string): void {
|
|
198
|
+
if (matchesKey(data, Key.ctrl("c"))) {
|
|
199
|
+
this.finish(null);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.pageDown)) {
|
|
203
|
+
this.questionOffset = Math.max(0, this.questionOffset + (matchesKey(data, Key.pageUp) ? -5 : 5));
|
|
204
|
+
this.refresh();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
192
207
|
if (this.editing) {
|
|
193
208
|
if (matchesKey(data, Key.escape)) {
|
|
194
209
|
this.editing = false;
|
|
@@ -220,7 +235,7 @@ export class AskUserView implements Focusable {
|
|
|
220
235
|
this.choose(this.selected);
|
|
221
236
|
return;
|
|
222
237
|
}
|
|
223
|
-
if (matchesKey(data, Key.escape)
|
|
238
|
+
if (matchesKey(data, Key.escape)) {
|
|
224
239
|
this.finish(null);
|
|
225
240
|
}
|
|
226
241
|
}
|
|
@@ -229,54 +244,61 @@ export class AskUserView implements Focusable {
|
|
|
229
244
|
if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
|
|
230
245
|
const safeWidth = Math.max(1, width);
|
|
231
246
|
const inner = Math.max(1, safeWidth - 4);
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
for (const line of wrapTextWithAnsi(this.theme.bold(cleanMultiline(this.question)), inner)) {
|
|
235
|
-
lines.push(padLine(` ${this.theme.fg("text", line)}`, safeWidth));
|
|
236
|
-
}
|
|
237
|
-
lines.push(padLine("", safeWidth));
|
|
247
|
+
const questionLines = wrapTextWithAnsi(this.theme.bold(cleanMultiline(this.question)), inner)
|
|
248
|
+
.map((line) => padLine(` ${this.theme.fg("text", line)}`, safeWidth));
|
|
238
249
|
|
|
250
|
+
const rest: string[] = [padLine("", safeWidth)];
|
|
239
251
|
this.options.forEach((option, index) => {
|
|
240
252
|
const active = index === this.selected;
|
|
241
253
|
const marker = option.custom ? "✎" : `${index + 1}.`;
|
|
242
254
|
const prefix = active ? this.theme.fg("accent", "❯") : " ";
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
255
|
+
// Wrap long labels so options that differ only near the end stay distinguishable.
|
|
256
|
+
const labelLines = wrapTextWithAnsi(oneLine(option.label), Math.max(8, inner - 4));
|
|
257
|
+
labelLines.forEach((text, lineIndex) => {
|
|
258
|
+
const label = lineIndex === 0 ? `${prefix} ${marker} ${text}` : `${" ".repeat(marker.length + 3)}${text}`;
|
|
259
|
+
rest.push(padLine(
|
|
246
260
|
active
|
|
247
261
|
? this.theme.bg("selectedBg", this.theme.fg("accent", label))
|
|
248
262
|
: this.theme.fg(option.custom ? "muted" : "text", label),
|
|
249
263
|
safeWidth,
|
|
250
|
-
)
|
|
251
|
-
);
|
|
264
|
+
));
|
|
265
|
+
});
|
|
252
266
|
if (option.description) {
|
|
253
267
|
for (const description of wrapTextWithAnsi(oneLine(option.description), Math.max(8, inner - 4))) {
|
|
254
|
-
|
|
268
|
+
rest.push(padLine(` ${this.theme.fg("muted", description)}`, safeWidth));
|
|
255
269
|
}
|
|
256
270
|
}
|
|
257
271
|
});
|
|
258
|
-
|
|
259
272
|
if (this.editing) {
|
|
260
|
-
|
|
261
|
-
|
|
273
|
+
rest.push(padLine("", safeWidth));
|
|
274
|
+
rest.push(padLine(` ${this.theme.fg("muted", "Your answer")}`, safeWidth));
|
|
262
275
|
for (const line of this.editor.render(Math.max(10, safeWidth - 4))) {
|
|
263
|
-
|
|
276
|
+
rest.push(padLine(` ${line}`, safeWidth));
|
|
264
277
|
}
|
|
265
278
|
}
|
|
279
|
+
rest.push(padLine("", safeWidth));
|
|
280
|
+
|
|
281
|
+
// Keep the choices, editor, and controls on screen; a long question scrolls.
|
|
282
|
+
const questionRoom = this.maxRows() - rest.length - 3;
|
|
283
|
+
let visibleQuestion = questionLines;
|
|
284
|
+
let scrollHint = "";
|
|
285
|
+
if (questionLines.length > questionRoom) {
|
|
286
|
+
const room = Math.max(1, questionRoom);
|
|
287
|
+
this.questionOffset = Math.min(this.questionOffset, Math.max(0, questionLines.length - room));
|
|
288
|
+
visibleQuestion = questionLines.slice(this.questionOffset, this.questionOffset + room);
|
|
289
|
+
scrollHint = `pgup/pgdn question ${this.questionOffset + 1}-${this.questionOffset + visibleQuestion.length}/${questionLines.length} · `;
|
|
290
|
+
}
|
|
266
291
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
),
|
|
278
|
-
);
|
|
279
|
-
lines.push(frameBottom(this.theme, safeWidth));
|
|
292
|
+
const controls = this.editing
|
|
293
|
+
? "enter submit · escape choices · ctrl+c dismiss"
|
|
294
|
+
: `up/down or 1-${this.options.length} choose · enter confirm · escape dismiss`;
|
|
295
|
+
const lines = [
|
|
296
|
+
frameTop(this.theme, safeWidth, "Decision needed"),
|
|
297
|
+
...visibleQuestion,
|
|
298
|
+
...rest,
|
|
299
|
+
padLine(` ${this.theme.fg("dim", `${scrollHint}${controls}`)}`, safeWidth),
|
|
300
|
+
frameBottom(this.theme, safeWidth),
|
|
301
|
+
];
|
|
280
302
|
this.cachedWidth = width;
|
|
281
303
|
this.cachedLines = lines.map((line) => truncateToWidth(line, safeWidth, ""));
|
|
282
304
|
return this.cachedLines;
|
|
@@ -315,6 +337,8 @@ async function openQuestion(
|
|
|
315
337
|
() => tui.requestRender(),
|
|
316
338
|
complete,
|
|
317
339
|
new Editor(tui, createEditorTheme(theme)),
|
|
340
|
+
// Matches the overlay's maxHeight (86%) minus its margin.
|
|
341
|
+
() => Math.max(8, Math.floor((tui.terminal.rows || 24) * 0.86) - 2),
|
|
318
342
|
);
|
|
319
343
|
return {
|
|
320
344
|
get focused() {
|
|
@@ -407,9 +431,12 @@ export default function askUserExtension(pi: ExtensionAPI) {
|
|
|
407
431
|
invalidate() {},
|
|
408
432
|
};
|
|
409
433
|
},
|
|
410
|
-
renderResult(result, _options, theme) {
|
|
434
|
+
renderResult(result, _options, theme, context) {
|
|
411
435
|
const details = result.details as AskUserDetails | undefined;
|
|
412
|
-
const
|
|
436
|
+
const errorText = result.content.find((item) => item.type === "text")?.text ?? "invalid question";
|
|
437
|
+
const text = context.isError
|
|
438
|
+
? `${theme.fg("error", "● not asked")} ${theme.fg("muted", oneLine(errorText))}`
|
|
439
|
+
: !details || details.cancelled
|
|
413
440
|
? theme.fg("warning", "● dismissed")
|
|
414
441
|
: `${theme.fg("success", "● answered")} ${theme.fg("accent", oneLine(details.answer ?? ""))}`;
|
|
415
442
|
return { render: (width: number) => [truncateToWidth(text, width)], invalidate() {} };
|
|
@@ -21,19 +21,19 @@ When a model-started terminal exits, its status and a bounded output tail are de
|
|
|
21
21
|
- live counts and status;
|
|
22
22
|
- terminal list with a selected-session inspector;
|
|
23
23
|
- live output, elapsed time, dimensions, command, cwd, and full-log size;
|
|
24
|
-
- real PTY input;
|
|
24
|
+
- real PTY input: every printable key goes to the process;
|
|
25
25
|
- Ctrl+C delivery to the PTY;
|
|
26
|
-
-
|
|
27
|
-
- two-press process-stop guard.
|
|
26
|
+
- PgUp/PgDn scrolling;
|
|
27
|
+
- a two-press Ctrl+X process-stop guard in the detail view.
|
|
28
28
|
|
|
29
29
|
## Lifecycle and safety
|
|
30
30
|
|
|
31
31
|
- Maximum eight concurrent terminals and 32 tracked entries.
|
|
32
32
|
- Newest 2 MiB retained in memory per terminal.
|
|
33
33
|
- Raw output spills to a mode-`0600` file under a mode-`0700` temporary session directory, capped at 64 MiB per terminal.
|
|
34
|
-
-
|
|
34
|
+
- Under Node, spill backpressure pauses and resumes the PTY. Bun's terminal API has no pause, so the 64 MiB cap is the only bound there. Reaching the cap stops log growth while retaining the newest live-output tail.
|
|
35
35
|
- Pruning an old settled terminal deletes its spill file immediately; session shutdown removes the complete temporary directory.
|
|
36
36
|
- Output is sanitized before TUI or model rendering.
|
|
37
37
|
- Processes run in their own PTY process group and are stopped on session shutdown, replacement, or reload.
|
|
38
|
-
- Shutdown and stop operations are bounded and escalate from SIGTERM to SIGKILL.
|
|
38
|
+
- Shutdown and stop operations are bounded and escalate from SIGTERM to SIGKILL. A stop finishes only when the whole process group is gone, including descendants that outlive the shell and ignore hangup.
|
|
39
39
|
- Completion delivery is keyed by terminal id, so each terminal produces at most one result message.
|
|
@@ -432,7 +432,7 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
|
|
|
432
432
|
return;
|
|
433
433
|
}
|
|
434
434
|
if (trimmed.startsWith("stop ")) {
|
|
435
|
-
const ids = [...new Set(trimmed.slice("stop ".length).split(/[
|
|
435
|
+
const ids = [...new Set(trimmed.slice("stop ".length).split(/[\s,]+/).filter(Boolean))];
|
|
436
436
|
if (ids.length === 0) {
|
|
437
437
|
ctx.ui.notify("Usage: /term stop <id> [id…]", "error");
|
|
438
438
|
return;
|
|
@@ -365,6 +365,7 @@ export class TerminalManager {
|
|
|
365
365
|
// node-pty still knows how to terminate its direct child.
|
|
366
366
|
}
|
|
367
367
|
}
|
|
368
|
+
if (entry.snapshot.status !== "running") return;
|
|
368
369
|
try {
|
|
369
370
|
entry.pty.kill(signal);
|
|
370
371
|
} catch {
|
|
@@ -372,6 +373,26 @@ export class TerminalManager {
|
|
|
372
373
|
}
|
|
373
374
|
}
|
|
374
375
|
|
|
376
|
+
/** The PTY's process group can outlive its leader when descendants ignore hangup. */
|
|
377
|
+
private groupAlive(entry: Entry): boolean {
|
|
378
|
+
if (process.platform === "win32") return false;
|
|
379
|
+
try {
|
|
380
|
+
process.kill(-entry.snapshot.pid, 0);
|
|
381
|
+
return true;
|
|
382
|
+
} catch (error) {
|
|
383
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private async waitForStop(entry: Entry, timeoutMs: number): Promise<void> {
|
|
388
|
+
const deadline = Date.now() + timeoutMs;
|
|
389
|
+
while (Date.now() < deadline) {
|
|
390
|
+
if (entry.snapshot.status !== "running" && !this.groupAlive(entry)) return;
|
|
391
|
+
await Promise.race([entry.settled, wait(25)]);
|
|
392
|
+
if (entry.snapshot.status !== "running") await wait(25);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
375
396
|
async kill(ids: ReadonlyArray<string>): Promise<ReadonlyArray<TerminalSnapshot>> {
|
|
376
397
|
const unique = [...new Set(ids)];
|
|
377
398
|
const entries = unique.map((id) => {
|
|
@@ -380,13 +401,15 @@ export class TerminalManager {
|
|
|
380
401
|
return entry;
|
|
381
402
|
});
|
|
382
403
|
await Promise.all(entries.map(async (entry) => {
|
|
383
|
-
|
|
384
|
-
entry.
|
|
404
|
+
// Stop is complete only when the whole group is gone, not just the leader.
|
|
405
|
+
const running = entry.snapshot.status === "running";
|
|
406
|
+
if (!running && !this.groupAlive(entry)) return;
|
|
407
|
+
if (running) entry.killRequested = true;
|
|
385
408
|
this.signal(entry, "SIGTERM");
|
|
386
|
-
await
|
|
387
|
-
if (entry.snapshot.status !== "running") return;
|
|
409
|
+
await this.waitForStop(entry, TERM_GRACE_MS);
|
|
410
|
+
if (entry.snapshot.status !== "running" && !this.groupAlive(entry)) return;
|
|
388
411
|
this.signal(entry, "SIGKILL");
|
|
389
|
-
await
|
|
412
|
+
await this.waitForStop(entry, KILL_GRACE_MS);
|
|
390
413
|
if (entry.snapshot.status === "running") {
|
|
391
414
|
entry.snapshot.errorText ??= "Process exit was not observed after SIGKILL; output may be incomplete.";
|
|
392
415
|
await this.finalize(entry, 137, 9);
|
|
@@ -471,8 +494,10 @@ export class TerminalManager {
|
|
|
471
494
|
if (this.disposed) return;
|
|
472
495
|
this.disposed = true;
|
|
473
496
|
this.settledListener = undefined;
|
|
474
|
-
const
|
|
475
|
-
|
|
497
|
+
const live = [...this.entries.values()]
|
|
498
|
+
.filter((entry) => entry.snapshot.status === "running" || this.groupAlive(entry))
|
|
499
|
+
.map((entry) => entry.snapshot.id);
|
|
500
|
+
if (live.length > 0) await this.kill(live);
|
|
476
501
|
this.listeners.clear();
|
|
477
502
|
this.idListeners.clear();
|
|
478
503
|
this.entries.clear();
|
|
@@ -30,6 +30,9 @@ class BunPtyAdapter implements UniversalPty {
|
|
|
30
30
|
private readonly dataListeners = new Set<(chunk: string) => void>();
|
|
31
31
|
private readonly exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>();
|
|
32
32
|
private exited = false;
|
|
33
|
+
// Chunks can split a multibyte character; a streaming decoder carries the
|
|
34
|
+
// partial bytes into the next chunk instead of emitting replacement characters.
|
|
35
|
+
private readonly decoder = new TextDecoder("utf-8");
|
|
33
36
|
|
|
34
37
|
constructor(options: SpawnUniversalPtyOptions) {
|
|
35
38
|
const cmd = [options.file, ...options.args];
|
|
@@ -40,14 +43,7 @@ class BunPtyAdapter implements UniversalPty {
|
|
|
40
43
|
cols: options.cols,
|
|
41
44
|
rows: options.rows,
|
|
42
45
|
data: (_term: unknown, chunk: Uint8Array | string) => {
|
|
43
|
-
|
|
44
|
-
for (const listener of this.dataListeners) {
|
|
45
|
-
try {
|
|
46
|
-
listener(text);
|
|
47
|
-
} catch {
|
|
48
|
-
// Ignore listener errors
|
|
49
|
-
}
|
|
50
|
-
}
|
|
46
|
+
this.emitData(typeof chunk === "string" ? chunk : this.decoder.decode(chunk, { stream: true }));
|
|
51
47
|
},
|
|
52
48
|
},
|
|
53
49
|
});
|
|
@@ -57,6 +53,7 @@ class BunPtyAdapter implements UniversalPty {
|
|
|
57
53
|
void this.proc.exited.then((exitCode: number) => {
|
|
58
54
|
if (this.exited) return;
|
|
59
55
|
this.exited = true;
|
|
56
|
+
this.emitData(this.decoder.decode());
|
|
60
57
|
const signal = this.proc.signalCode
|
|
61
58
|
? typeof this.proc.signalCode === "number"
|
|
62
59
|
? this.proc.signalCode
|
|
@@ -72,6 +69,17 @@ class BunPtyAdapter implements UniversalPty {
|
|
|
72
69
|
});
|
|
73
70
|
}
|
|
74
71
|
|
|
72
|
+
private emitData(text: string) {
|
|
73
|
+
if (!text) return;
|
|
74
|
+
for (const listener of this.dataListeners) {
|
|
75
|
+
try {
|
|
76
|
+
listener(text);
|
|
77
|
+
} catch {
|
|
78
|
+
// Ignore listener errors
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
75
83
|
onData(listener: (chunk: string) => void) {
|
|
76
84
|
this.dataListeners.add(listener);
|
|
77
85
|
return {
|
|
@@ -140,6 +148,15 @@ class NodePtyAdapter implements UniversalPty {
|
|
|
140
148
|
this.pty.resize(cols, rows);
|
|
141
149
|
}
|
|
142
150
|
|
|
151
|
+
// Flow control for spill backpressure: stop reading the PTY until the log drains.
|
|
152
|
+
pause(): void {
|
|
153
|
+
this.pty.pause();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
resume(): void {
|
|
157
|
+
this.pty.resume();
|
|
158
|
+
}
|
|
159
|
+
|
|
143
160
|
kill(signal?: string): void {
|
|
144
161
|
this.pty.kill(signal);
|
|
145
162
|
}
|
|
@@ -393,7 +393,8 @@ export class TerminalDetail implements Component, Focusable {
|
|
|
393
393
|
this.close();
|
|
394
394
|
return;
|
|
395
395
|
}
|
|
396
|
-
|
|
396
|
+
// Printable keys belong to the input field, so detail controls use modifiers.
|
|
397
|
+
if (matchesKey(data, Key.ctrl("x"))) {
|
|
397
398
|
if (terminal?.status !== "running") return;
|
|
398
399
|
if (this.killArmed) {
|
|
399
400
|
this.killArmed = false;
|
|
@@ -404,26 +405,16 @@ export class TerminalDetail implements Component, Focusable {
|
|
|
404
405
|
this.tui.requestRender();
|
|
405
406
|
return;
|
|
406
407
|
}
|
|
407
|
-
if (this.keybindings.matches(data, "tui.editor.pageUp")
|
|
408
|
+
if (this.keybindings.matches(data, "tui.editor.pageUp")) {
|
|
408
409
|
this.scrollOffset += Math.max(4, this.viewportHeight() - 2);
|
|
409
410
|
this.tui.requestRender();
|
|
410
411
|
return;
|
|
411
412
|
}
|
|
412
|
-
if (this.keybindings.matches(data, "tui.editor.pageDown")
|
|
413
|
+
if (this.keybindings.matches(data, "tui.editor.pageDown")) {
|
|
413
414
|
this.scrollOffset = Math.max(0, this.scrollOffset - Math.max(4, this.viewportHeight() - 2));
|
|
414
415
|
this.tui.requestRender();
|
|
415
416
|
return;
|
|
416
417
|
}
|
|
417
|
-
if (data === "g") {
|
|
418
|
-
this.scrollOffset = Number.MAX_SAFE_INTEGER;
|
|
419
|
-
this.tui.requestRender();
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
if (data === "G") {
|
|
423
|
-
this.scrollOffset = 0;
|
|
424
|
-
this.tui.requestRender();
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
418
|
this.killArmed = false;
|
|
428
419
|
this.input.handleInput(data);
|
|
429
420
|
this.tui.requestRender();
|
|
@@ -462,10 +453,10 @@ export class TerminalDetail implements Component, Focusable {
|
|
|
462
453
|
lines.push(frameBottom(theme, width));
|
|
463
454
|
if (terminal.status === "running") lines.push(...this.input.render(width));
|
|
464
455
|
else lines.push(truncateToWidth(` ${theme.fg("muted", "Process settled; input is closed.")}`, width));
|
|
465
|
-
const stop = this.killArmed ? theme.fg("warning", "x again to stop") : "x stop (confirm)";
|
|
456
|
+
const stop = this.killArmed ? theme.fg("warning", "ctrl+x again to stop") : "ctrl+x stop (confirm)";
|
|
466
457
|
lines.push(
|
|
467
458
|
truncateToWidth(
|
|
468
|
-
theme.fg("dim", ` enter send · ctrl+c interrupt PTY · ${stop} · pgup/pgdn
|
|
459
|
+
theme.fg("dim", ` enter send · ctrl+c interrupt PTY · ${stop} · pgup/pgdn scroll · ${configuredKeys(this.keybindings, "tui.select.cancel")} back`),
|
|
469
460
|
width,
|
|
470
461
|
),
|
|
471
462
|
);
|
|
@@ -2,6 +2,7 @@ import type { Component } from "@earendil-works/pi-tui";
|
|
|
2
2
|
import type { ContextUsage, ExtensionAPI, ExtensionContext, SessionEntry, Theme, ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { buildSessionContext, convertToLlm, getAgentDir, keyText } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { delimiter, join } from "node:path";
|
|
7
8
|
import { stripAnsi } from "./lib/ansi.ts";
|
|
@@ -528,8 +529,10 @@ function splitConfigPaths(value: string | undefined, separator = delimiter): str
|
|
|
528
529
|
return (value ?? "").split(separator).map((entry) => expandHomePath(entry.trim())).filter(Boolean);
|
|
529
530
|
}
|
|
530
531
|
|
|
531
|
-
function loadContextUsageConfig(cwd: string): ContextUsageConfig {
|
|
532
|
-
|
|
532
|
+
function loadContextUsageConfig(cwd: string, projectTrusted: boolean): ContextUsageConfig {
|
|
533
|
+
// The project file applies only to trusted projects; global and explicit env paths always apply.
|
|
534
|
+
const [globalPath, projectPath] = configPaths("pi-context-usage", cwd);
|
|
535
|
+
const paths = [globalPath!, ...(projectTrusted ? [projectPath!] : []), ...splitConfigPaths(process.env.PI_CONTEXT_USAGE_CONFIG)];
|
|
533
536
|
return paths.reduce<ContextUsageConfig>(
|
|
534
537
|
(config, filePath) => mergeContextUsageConfig(config, readJsonConfig(filePath, parseContextUsageConfig)),
|
|
535
538
|
{},
|
|
@@ -1184,13 +1187,18 @@ function buildSnapshot(
|
|
|
1184
1187
|
const session = buildSessionBreakdown(sessionManager);
|
|
1185
1188
|
const contextUsage = getContextUsage?.();
|
|
1186
1189
|
|
|
1190
|
+
// Hash the full rendered inputs: same-length prompt or schema edits must
|
|
1191
|
+
// still invalidate cached counts.
|
|
1192
|
+
const contentHash = createHash("sha1")
|
|
1193
|
+
.update(systemPrompt)
|
|
1194
|
+
.update(JSON.stringify(pi.getAllTools().map((tool) => [tool.name, tool.description, tool.parameters])))
|
|
1195
|
+
.digest("hex");
|
|
1187
1196
|
const signature = [
|
|
1188
|
-
|
|
1197
|
+
contentHash,
|
|
1189
1198
|
model ? `${model.provider}:${model.id}:${model.api}` : "no-model",
|
|
1190
1199
|
`${heuristic.label}:${heuristic.textDenominator}:${heuristic.sessionDenominator}:${heuristic.toolDenominator}:${heuristic.toolNumerator}`,
|
|
1191
1200
|
JSON.stringify(config),
|
|
1192
1201
|
pi.getActiveTools().join(","),
|
|
1193
|
-
pi.getAllTools().map((tool) => `${tool.name}:${tool.description.length}`).join(","),
|
|
1194
1202
|
session ? `${session.thinkingChars}:${session.toolOutputChars}:${session.messageChars}:${session.messageCount}` : "no-session",
|
|
1195
1203
|
contextUsage ? `${contextUsage.tokens}:${contextUsage.contextWindow}:${contextUsage.percent}` : "no-usage",
|
|
1196
1204
|
].join("|");
|
|
@@ -1481,9 +1489,10 @@ function renderSessionRows(snapshot: PrefixSnapshot, theme: Theme, width: number
|
|
|
1481
1489
|
rows.push(renderMetricRow({
|
|
1482
1490
|
label: "Total request",
|
|
1483
1491
|
tokens: usage.tokens,
|
|
1484
|
-
|
|
1492
|
+
// Pi reports this as an estimate (last usage plus trailing messages).
|
|
1493
|
+
exact: false,
|
|
1485
1494
|
emphasis: true,
|
|
1486
|
-
detail: percent && window ? `(${percent} / ${window} ctx)` : "(Pi
|
|
1495
|
+
detail: percent && window ? `(${percent} / ${window} ctx)` : "(Pi estimate)",
|
|
1487
1496
|
}, theme, layout));
|
|
1488
1497
|
rows.push(...renderContextBar(snapshot, estimate, theme, width));
|
|
1489
1498
|
}
|
|
@@ -1536,7 +1545,7 @@ function renderSummary(snapshot: PrefixSnapshot, theme: Theme, width = 80): stri
|
|
|
1536
1545
|
const percent = formatPercent(usage?.percent ?? null);
|
|
1537
1546
|
const budgetParts = [
|
|
1538
1547
|
`Harness ~${compactCount(harness)}`,
|
|
1539
|
-
typeof request === "number" ? `Request
|
|
1548
|
+
typeof request === "number" ? `Request ~${compactCount(request)}` : undefined,
|
|
1540
1549
|
window && window > 0 ? `/ ${contextWindowLabel(window)}` : undefined,
|
|
1541
1550
|
percent ? `· ${percent}` : undefined,
|
|
1542
1551
|
].filter(Boolean).join(" ");
|
|
@@ -1656,8 +1665,9 @@ function renderExpanded(snapshot: PrefixSnapshot, theme: Theme, width: number):
|
|
|
1656
1665
|
return lines;
|
|
1657
1666
|
}
|
|
1658
1667
|
|
|
1668
|
+
// Wrap to the exact width Pi supplies; a minimum here would overflow narrow terminals.
|
|
1659
1669
|
function wrapLines(lines: string[], width: number): string[] {
|
|
1660
|
-
const maxWidth = Math.max(
|
|
1670
|
+
const maxWidth = Math.max(1, width);
|
|
1661
1671
|
const out: string[] = [];
|
|
1662
1672
|
for (const rawLine of lines) {
|
|
1663
1673
|
if (rawLine.length === 0) {
|
|
@@ -1737,7 +1747,7 @@ class StartupContextComponent implements Component {
|
|
|
1737
1747
|
this.cachedSignature = snapshot.signature;
|
|
1738
1748
|
this.cachedMode = this.mode;
|
|
1739
1749
|
this.cachedWidth = width;
|
|
1740
|
-
this.cachedLines = wrapLines(body,
|
|
1750
|
+
this.cachedLines = wrapLines(body, width);
|
|
1741
1751
|
return this.cachedLines;
|
|
1742
1752
|
} catch {
|
|
1743
1753
|
this.cachedSignature = "context-usage-unavailable";
|
|
@@ -1746,7 +1756,7 @@ class StartupContextComponent implements Component {
|
|
|
1746
1756
|
this.cachedLines = wrapLines([
|
|
1747
1757
|
"",
|
|
1748
1758
|
`${accent(undefined, "[Context Usage]")} unavailable while Pi finishes resuming this session`,
|
|
1749
|
-
],
|
|
1759
|
+
], width);
|
|
1750
1760
|
return this.cachedLines;
|
|
1751
1761
|
}
|
|
1752
1762
|
}
|
|
@@ -1807,9 +1817,24 @@ function isContextBlockInstalled(block: StartupContextComponent): boolean {
|
|
|
1807
1817
|
return Array.isArray(chat?.children) && chat.children.includes(block);
|
|
1808
1818
|
}
|
|
1809
1819
|
|
|
1810
|
-
|
|
1820
|
+
/**
|
|
1821
|
+
* With quietStartup Pi renders no resource rows to anchor on. Pi's document
|
|
1822
|
+
* container holds exactly [header, loaded resources, chat]; mount into the
|
|
1823
|
+
* (empty) loaded-resources slot so the card sits in the same place.
|
|
1824
|
+
*/
|
|
1825
|
+
function findQuietStartupContainer(tui: unknown): ContainerLike | undefined {
|
|
1826
|
+
const isContainer = (value: unknown) => !!value && typeof value === "object" && Array.isArray((value as { children?: unknown }).children);
|
|
1827
|
+
const document = findContainerBy(tui, (children) => children.length === 3 && children.every(isContainer));
|
|
1828
|
+
const resources = document?.children[1] as ContainerLike | undefined;
|
|
1829
|
+
return resources && Array.isArray(resources.children) && resources.children.every((child) => isPrefixBlock(child) || isBlankComponent(child as Component))
|
|
1830
|
+
? resources
|
|
1831
|
+
: undefined;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function installContextBlock(block: StartupContextComponent, allowQuietFallback = false): boolean {
|
|
1811
1835
|
const tui = g.__piContextUsageTui;
|
|
1812
|
-
const chat = findResourceChatContainer(tui) ?? g.__piContextUsageChat
|
|
1836
|
+
const chat = findResourceChatContainer(tui) ?? g.__piContextUsageChat ??
|
|
1837
|
+
(allowQuietFallback ? findQuietStartupContainer(tui) : undefined);
|
|
1813
1838
|
if (!chat || !Array.isArray(chat.children)) return false;
|
|
1814
1839
|
|
|
1815
1840
|
g.__piContextUsageChat = chat;
|
|
@@ -1828,7 +1853,8 @@ function scheduleInstall(block: StartupContextComponent): void {
|
|
|
1828
1853
|
let attempts = 0;
|
|
1829
1854
|
const attempt = () => {
|
|
1830
1855
|
attempts++;
|
|
1831
|
-
|
|
1856
|
+
// Prefer the resource-list anchor; fall back only after it has had time to render.
|
|
1857
|
+
if (safely(() => installContextBlock(block, attempts > 10), false)) return;
|
|
1832
1858
|
if (attempts < 30) {
|
|
1833
1859
|
g.__piContextUsageInstallTimer = setTimeout(attempt, 50);
|
|
1834
1860
|
}
|
|
@@ -1918,11 +1944,8 @@ export default function piContextUsage(pi: ExtensionAPI) {
|
|
|
1918
1944
|
pi.on("session_start", async (_event, ctx) => {
|
|
1919
1945
|
if (!ctx.hasUI) return;
|
|
1920
1946
|
|
|
1921
|
-
// Restore Pi's normal header; this extension now renders below Pi's loaded-resource list.
|
|
1922
|
-
ctx.ui.setHeader(undefined);
|
|
1923
|
-
|
|
1924
1947
|
const currentMode = g.__piContextUsageMode ?? DEFAULT_MODE;
|
|
1925
|
-
const config = loadContextUsageConfig(ctx.cwd);
|
|
1948
|
+
const config = loadContextUsageConfig(ctx.cwd, ctx.isProjectTrusted());
|
|
1926
1949
|
g.__piContextUsageModel = toModelSummary(ctx.model);
|
|
1927
1950
|
markDirty();
|
|
1928
1951
|
const block = new StartupContextComponent(
|
|
@@ -64,7 +64,7 @@ function readCliCredentials(home: string, platform: NodeJS.Platform): { apiKey?:
|
|
|
64
64
|
function normalizeApiUrl(value: string | undefined): string {
|
|
65
65
|
const input = value?.trim() || DEFAULT_API_URL;
|
|
66
66
|
const url = new URL(input);
|
|
67
|
-
const localHttp = url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
|
|
67
|
+
const localHttp = url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
68
68
|
if (url.protocol !== "https:" && !localHttp) throw new Error("Firecrawl API URL must use HTTPS (or localhost HTTP).");
|
|
69
69
|
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
70
70
|
return url.toString().replace(/\/$/, "");
|
|
@@ -191,11 +191,16 @@ function retryAfterMs(value: string | null): number | undefined {
|
|
|
191
191
|
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
192
192
|
return new Promise((resolve, reject) => {
|
|
193
193
|
if (signal?.aborted) return reject(signal.reason ?? new Error("Request aborted"));
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
// Remove the listener on both paths so retries sharing a signal do not pile up.
|
|
195
|
+
const onAbort = () => {
|
|
196
196
|
clearTimeout(timer);
|
|
197
|
-
reject(signal
|
|
198
|
-
}
|
|
197
|
+
reject(signal?.reason ?? new Error("Request aborted"));
|
|
198
|
+
};
|
|
199
|
+
const timer = setTimeout(() => {
|
|
200
|
+
signal?.removeEventListener("abort", onAbort);
|
|
201
|
+
resolve();
|
|
202
|
+
}, ms);
|
|
203
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
199
204
|
});
|
|
200
205
|
}
|
|
201
206
|
|
|
@@ -260,7 +265,12 @@ export async function firecrawlRequest(
|
|
|
260
265
|
});
|
|
261
266
|
|
|
262
267
|
const contentLength = Number(response.headers.get("content-length") || 0);
|
|
263
|
-
if (contentLength > MAX_RESPONSE_BYTES)
|
|
268
|
+
if (contentLength > MAX_RESPONSE_BYTES) {
|
|
269
|
+
// Stop the transfer instead of leaving the body streaming in the background.
|
|
270
|
+
await response.body?.cancel("Firecrawl response exceeded 25 MiB.").catch(() => undefined);
|
|
271
|
+
timeout.abort(new Error("Firecrawl response exceeded 25 MiB."));
|
|
272
|
+
throw new Error("Firecrawl response exceeded 25 MiB.");
|
|
273
|
+
}
|
|
264
274
|
const bytes = await readResponseBounded(response);
|
|
265
275
|
const text = new TextDecoder().decode(bytes);
|
|
266
276
|
let payload: unknown;
|
|
@@ -26,8 +26,11 @@ async function writeArtifact(operation: string, value: unknown): Promise<string>
|
|
|
26
26
|
return path;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Room for the artifact footer, so the returned text stays within Pi's limits.
|
|
30
|
+
const FOOTER_RESERVE_BYTES = 512;
|
|
31
|
+
|
|
29
32
|
async function boundOutput(text: string, operation: string, raw: unknown, forceArtifact = false): Promise<string> {
|
|
30
|
-
const bounded = truncateHead(text, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
|
|
33
|
+
const bounded = truncateHead(text, { maxBytes: DEFAULT_MAX_BYTES - FOOTER_RESERVE_BYTES, maxLines: DEFAULT_MAX_LINES - 2 });
|
|
31
34
|
if (!bounded.truncated && !forceArtifact) return text;
|
|
32
35
|
const path = await writeArtifact(operation, raw);
|
|
33
36
|
if (!bounded.truncated) return `${text}\n\n[Complete Firecrawl response saved to: ${path}]`;
|