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 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.
@@ -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
- const state = job.status === "exited" ? `exit ${job.exitCode}` : job.status;
51
- return `${job.id} [${state}, ${elapsed(job)}] ${job.command}\n log: ${job.log}`;
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 and with a script path shortened to its name. */
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
- return command
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} ${command.length > 50 ? `${command.slice(0, 49)}…` : command}`) +
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
- // Progress bars redraw with \r, so the last segment of either kind of line break is the current state
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-background",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "author": "Thomas van Dongen",
6
6
  "type": "module",