pi-background 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/extensions/background.ts +87 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ pi install npm:pi-background
|
|
|
15
15
|
|
|
16
16
|
- Long commands like downloads, builds, test suites and training runs go to the background, and Pi hears back with the exit code and the end of the output when they finish.
|
|
17
17
|
- While a job runs, a line above the input shows it with its elapsed time and latest output, such as `⏵ bg1 python3 train.py · 4m12s · epoch 3/10 loss 0.41`.
|
|
18
|
+
- `/bg` shows a job's last 20 lines of output, updating while it runs (`/bg bg2` for a specific job). Press esc to close it.
|
|
18
19
|
- Pi can wait for a specific line, such as a dev server's `listening on` or a training run's `epoch 3`, without checking again and again.
|
|
19
20
|
- Jobs started together are reported together: when Pi starts several jobs at once, such as benchmarks to compare, it's woken once when the last one finishes instead of after each. You can keep chatting meanwhile, and a job that runs much longer doesn't hold the others back for more than 2 minutes.
|
|
20
21
|
- Regular commands time out after 2 minutes, unless Pi asks for longer (up to 10), so Pi never hangs on a stuck command. Commands that start their own background process with `&` are left alone.
|
package/extensions/background.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createWriteStream, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { createLocalBashOperations, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { Key, matchesKey, stripTerminalSequences, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
6
6
|
import { Type } from "typebox";
|
|
7
7
|
|
|
8
8
|
const TAIL_LINES = 20;
|
|
@@ -46,17 +46,27 @@ function elapsed(job: Job): string {
|
|
|
46
46
|
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60}s`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
function state(job: Job): string {
|
|
50
|
+
return job.status === "exited" ? `exit ${job.exitCode}` : job.status;
|
|
51
|
+
}
|
|
52
|
+
|
|
49
53
|
function summary(job: Job): string {
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
return `${job.id} [${state(job)}, ${elapsed(job)}] ${job.command}\n log: ${job.log}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** An output line as a terminal would last have shown it: without escape codes or control characters, and a progress bar redrawn with \r in its latest state only. */
|
|
58
|
+
function cleanLine(line: string): string {
|
|
59
|
+
const shown = stripTerminalSequences(line).split("\r").filter((part) => part.trim()).at(-1) ?? "";
|
|
60
|
+
return shown.replace(/\t/g, " ").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "").trimEnd();
|
|
52
61
|
}
|
|
53
62
|
|
|
54
|
-
/** The part of a command worth showing: its first line, without a leading cd
|
|
63
|
+
/** The part of a command worth showing: its first line, without a leading cd, with a script path shortened to its name and at most 50 characters. */
|
|
55
64
|
function shownCommand(command: string): string {
|
|
56
|
-
|
|
65
|
+
const shown = command
|
|
57
66
|
.split("\n")[0]
|
|
58
67
|
.replace(/^\s*(cd\s+("[^"]*"|'[^']*'|\S+)\s*(&&|;)\s*)+/, "")
|
|
59
68
|
.replace(/^\S*\/(?=[^\s/]+(\s|$))/, "");
|
|
69
|
+
return shown.length > 50 ? `${shown.slice(0, 49)}…` : shown;
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/** True when a command starts a job with a bare & (not &&, 2>&1, &> or |&). */
|
|
@@ -153,6 +163,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
153
163
|
// One line per running job above the input, with its elapsed time and latest output
|
|
154
164
|
let ui: ExtensionContext["ui"] | undefined;
|
|
155
165
|
const running = () => [...jobs.values()].filter((job) => job.status === "running");
|
|
166
|
+
// The job open in /bg, left out of the jobs line meanwhile so it isn't shown twice
|
|
167
|
+
let viewing: Job | undefined;
|
|
156
168
|
// "full" (the default), "compact" without the output, or "off", set with "background": { "jobs": ... } in pi's settings.json
|
|
157
169
|
const jobsLine = () => (pi.getSettings() as { background?: { jobs?: string } }).background?.jobs ?? "full";
|
|
158
170
|
const showJobs = () => {
|
|
@@ -164,12 +176,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
164
176
|
dispose: () => clearInterval(timer),
|
|
165
177
|
invalidate() {},
|
|
166
178
|
render(width: number) {
|
|
167
|
-
const live = running();
|
|
179
|
+
const live = running().filter((job) => job !== viewing);
|
|
168
180
|
const lines = live.slice(0, MAX_SHOWN_JOBS).map((job) => {
|
|
169
|
-
const command = shownCommand(job.command);
|
|
170
181
|
const text =
|
|
171
182
|
theme.fg("accent", " ⏵ ") +
|
|
172
|
-
theme.fg("toolTitle", `${job.id} ${
|
|
183
|
+
theme.fg("toolTitle", `${job.id} ${shownCommand(job.command)}`) +
|
|
173
184
|
theme.fg("dim", ` · ${elapsed(job)}${jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : ""}`);
|
|
174
185
|
return truncateToWidth(text, width);
|
|
175
186
|
});
|
|
@@ -254,8 +265,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
254
265
|
const onData = (d: Buffer) => {
|
|
255
266
|
out.write(d);
|
|
256
267
|
watch?.(d);
|
|
257
|
-
|
|
258
|
-
const last = d.toString("utf8").replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").split(/\r\n|\r|\n/).map((line) => line.trim()).filter(Boolean).at(-1);
|
|
268
|
+
const last = d.toString("utf8").split("\n").map(cleanLine).filter((line) => line.trim()).at(-1)?.trim();
|
|
259
269
|
if (last) job.lastLine = last;
|
|
260
270
|
};
|
|
261
271
|
|
|
@@ -326,6 +336,72 @@ export default function (pi: ExtensionAPI) {
|
|
|
326
336
|
},
|
|
327
337
|
});
|
|
328
338
|
|
|
339
|
+
// Shows a job's latest output in place of the input until esc, refreshed every second while it runs
|
|
340
|
+
pi.registerCommand("bg", {
|
|
341
|
+
description: "Show a background job's latest output",
|
|
342
|
+
getArgumentCompletions: (prefix) => {
|
|
343
|
+
const matching = [...jobs.values()].filter((job) => job.id.startsWith(prefix.trim().toLowerCase()));
|
|
344
|
+
return matching.length ? matching.map((job) => ({ value: job.id, label: job.id, description: `${shownCommand(job.command)} · ${state(job)}` })) : null;
|
|
345
|
+
},
|
|
346
|
+
handler: async (args, ctx) => {
|
|
347
|
+
if (!ctx.hasUI) return;
|
|
348
|
+
const all = [...jobs.values()].reverse();
|
|
349
|
+
if (all.length === 0) return ctx.ui.notify("No background jobs yet", "info");
|
|
350
|
+
// "/bg 2" works as well as "/bg bg2"
|
|
351
|
+
const wanted = args.trim().toLowerCase().replace(/^(\d+)$/, "bg$1");
|
|
352
|
+
let job = wanted ? jobs.get(wanted) : all.length === 1 ? all[0] : undefined;
|
|
353
|
+
if (wanted && !job) return ctx.ui.notify(`No background job ${args.trim()}; there's ${[...jobs.keys()].join(", ")}`, "warning");
|
|
354
|
+
if (!job) {
|
|
355
|
+
// Matched by position, since the elapsed time in a label changes while the list is open
|
|
356
|
+
const options = all.map((other) => `${other.id} ${shownCommand(other.command)} · ${state(other)} · ${elapsed(other)}`);
|
|
357
|
+
job = all[options.indexOf((await ctx.ui.select("Background jobs", options)) ?? "")];
|
|
358
|
+
if (!job) return;
|
|
359
|
+
}
|
|
360
|
+
const shown = job;
|
|
361
|
+
viewing = shown;
|
|
362
|
+
const log = shown.log.replace(homedir(), "~");
|
|
363
|
+
try {
|
|
364
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
365
|
+
const timer = setInterval(() => tui.requestRender(), 1000);
|
|
366
|
+
return {
|
|
367
|
+
dispose: () => clearInterval(timer),
|
|
368
|
+
invalidate() {},
|
|
369
|
+
handleInput: (data: string) => {
|
|
370
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data === "q") done();
|
|
371
|
+
},
|
|
372
|
+
render(width: number) {
|
|
373
|
+
const color = shown.status === "running" ? "accent" : shown.status === "exited" && shown.exitCode === 0 ? "success" : "error";
|
|
374
|
+
const title = theme.fg(color, " ● ") + theme.fg("toolTitle", `${shown.id} ${shownCommand(shown.command)}`);
|
|
375
|
+
const status = theme.fg("muted", `${state(shown)} · ${elapsed(shown)} `);
|
|
376
|
+
const gap = Math.max(1, width - visibleWidth(title) - visibleWidth(status));
|
|
377
|
+
// At most half the terminal, so the conversation above stays in view
|
|
378
|
+
const text = tail(shown.log, Math.max(3, Math.min(TAIL_LINES, Math.floor(tui.terminal.rows / 2))));
|
|
379
|
+
const output = text === "(no output)" ? [theme.fg("dim", theme.italic(" no output yet"))] : text.split("\n").map((line) => ` ${cleanLine(line)}`);
|
|
380
|
+
return [
|
|
381
|
+
theme.fg("borderMuted", "─".repeat(width)),
|
|
382
|
+
title + " ".repeat(gap) + status,
|
|
383
|
+
...output,
|
|
384
|
+
theme.fg("dim", ` esc to close · ${log}`),
|
|
385
|
+
theme.fg("borderMuted", "─".repeat(width)),
|
|
386
|
+
].map((line) => truncateToWidth(line, width));
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
});
|
|
390
|
+
} finally {
|
|
391
|
+
viewing = undefined;
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// After /reload or a resume, carry on after the ids already in the conversation so "bg3" keeps meaning one job
|
|
397
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
398
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
399
|
+
const msg = entry.type === "message" ? (entry.message as any) : undefined;
|
|
400
|
+
const n = Number(/^bg(\d+)$/.exec(msg?.role === "toolResult" && msg.toolName === "bg_run" ? String(msg.details?.id) : "")?.[1]);
|
|
401
|
+
if (n >= next) next = n + 1;
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
329
405
|
pi.on("session_shutdown", async () => {
|
|
330
406
|
for (const timer of holds.values()) clearTimeout(timer);
|
|
331
407
|
holds.clear();
|