pi-background 0.3.3 → 0.4.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/README.md CHANGED
@@ -14,11 +14,11 @@ pi install npm:pi-background
14
14
  ## Features
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
- - 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.
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`. A job that has printed nothing for 10 minutes shows `no output for 12m30s` instead, so a stuck one stands out.
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. Its list shows running jobs first, then the 10 latest finished ones.
19
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.
20
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.
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.
21
+ - A regular shell command still running after 2 minutes moves to the background on its own, so a slow command Pi didn't expect never holds up the turn. It shows in the jobs line, and Pi hears back when it ends. Press `ctrl+b` to move a running command to the background sooner. If another extension already replaces Pi's bash, such as a sandbox, pi-background leaves it alone.
22
22
 
23
23
  You don't need to do anything differently: Pi decides when a job belongs in the background. You can also ask for it, for example "run the tests in the background and tell me when they're done".
24
24
 
@@ -1,18 +1,24 @@
1
1
  import { createWriteStream, mkdirSync, readFileSync } from "node:fs";
2
2
  import { homedir, tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { createLocalBashOperations, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { type BashOperations, createBashToolDefinition, createLocalBashOperations, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { Key, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
6
6
  import { Type } from "typebox";
7
7
  import { shortCommand } from "./command.ts";
8
8
 
9
9
  const TAIL_LINES = 20;
10
10
  const TAIL_BYTES = 64 * 1024;
11
- const BASH_DEFAULT_TIMEOUT = 120;
12
- const BASH_MAX_TIMEOUT = 600;
11
+ // A bash command still running after this long moves to the background, so it no longer blocks the turn
12
+ const AUTO_BACKGROUND_SECS = 120;
13
13
  const WAKE_DEBOUNCE_MS = 1000;
14
14
  const MAX_WAKES = 10;
15
15
  const MAX_SHOWN_JOBS = 3;
16
+ // A running job silent this long shows how long it has been quiet, since it may be stuck
17
+ const SILENT_AFTER_MS = 10 * 60_000;
18
+ // Finished jobs listed by /bg; older ones stay reachable as /bg <id>
19
+ const MAX_LISTED_FINISHED = 10;
20
+ // How long a bash command runs before the jobs line offers ctrl+b, so quick commands don't flash it
21
+ const HINT_AFTER_MS = 3000;
16
22
  // How long finished results wait for jobs started alongside them before Pi is woken anyway
17
23
  const GROUP_HOLD_MS = 2 * 60_000;
18
24
 
@@ -27,6 +33,8 @@ interface Job {
27
33
  ended?: number;
28
34
  // The latest output line, shown while the job runs
29
35
  lastLine?: string;
36
+ // When the job last printed anything
37
+ lastOutput?: number;
30
38
  // The turn that started the job; jobs from the same turn are reported together
31
39
  group: number;
32
40
  // Started with wake_on, so it may run indefinitely and doesn't hold back the others
@@ -42,9 +50,16 @@ function tail(path: string, lines: number): string {
42
50
  }
43
51
  }
44
52
 
53
+ /** 45s, 12m30s, or 1h20m once seconds no longer matter. */
54
+ function duration(ms: number): string {
55
+ const s = Math.round(ms / 1000);
56
+ if (s < 60) return `${s}s`;
57
+ if (s < 3600) return `${Math.floor(s / 60)}m${s % 60}s`;
58
+ return `${Math.floor(s / 3600)}h${Math.floor(s / 60) % 60}m`;
59
+ }
60
+
45
61
  function elapsed(job: Job): string {
46
- const s = Math.round(((job.ended ?? Date.now()) - job.started) / 1000);
47
- return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60}s`;
62
+ return duration((job.ended ?? Date.now()) - job.started);
48
63
  }
49
64
 
50
65
  function state(job: Job): string {
@@ -72,28 +87,20 @@ function shownCommand(command: string): string {
72
87
  return shown.length > 50 ? `${shown.slice(0, 49)}…` : shown;
73
88
  }
74
89
 
75
- /** True when a command starts a job with a bare & (not &&, 2>&1, &> or |&). */
76
- function startsBackgroundJob(command: string): boolean {
77
- return /&/.test(command.replace(/&&|[0-9]*>&[0-9-]*|&>>?|\|&/g, ""));
78
- }
79
-
80
90
  /** Compiles wake_on, accepting Python's (?i) prefix that models often write but JavaScript only takes as a flag. */
81
91
  function wakePattern(source: string): RegExp {
82
92
  const insensitive = source.startsWith("(?i)");
83
93
  return new RegExp(insensitive ? source.slice(4) : source, insensitive ? "i" : "");
84
94
  }
85
95
 
86
- export default function (pi: ExtensionAPI) {
87
- // Pi's bash has no default timeout, so a stuck or polling command blocks until interrupted; cap it like Claude Code
88
- // does. A timeout SIGKILLs the command's whole process group, which would include jobs it started with &, so a
89
- // command that backgrounds something is never timed out, even if the model asked for a timeout.
90
- pi.on("tool_call", async (event) => {
91
- if (event.toolName !== "bash") return;
92
- const input = event.input as { command?: string; timeout?: number };
93
- if (startsBackgroundJob(String(input.command ?? ""))) delete input.timeout;
94
- else input.timeout = Math.min(input.timeout ?? BASH_DEFAULT_TIMEOUT, BASH_MAX_TIMEOUT);
95
- });
96
+ /** Thrown by the bash operations below when a command moves to the background, carrying the job and its output so far. */
97
+ class MovedToBackground extends Error {
98
+ constructor(readonly job: { id: string; log: string; started: number }, readonly output: string) {
99
+ super(`moved to background as ${job.id}`);
100
+ }
101
+ }
96
102
 
103
+ export default function (pi: ExtensionAPI) {
97
104
  const jobs = new Map<string, Job>();
98
105
  const ops = createLocalBashOperations();
99
106
  let next = 1;
@@ -113,6 +120,14 @@ export default function (pi: ExtensionAPI) {
113
120
  [...jobs.values()].filter((other) => other !== job && other.group === job.group && other.status === "running" && !other.watched);
114
121
  const report = (job: Job, content: string, details: { headline: string; last: string; error: boolean }) => {
115
122
  const others = job.watched ? [] : runningInGroup(job);
123
+ // Only Pi stops jobs, with bg_kill, so it already knows; the message is added without waking it again
124
+ if (job.status === "killed") {
125
+ if (others.length === 0) {
126
+ clearTimeout(holds.get(job.group));
127
+ holds.delete(job.group);
128
+ }
129
+ return wake(content, details, false);
130
+ }
116
131
  // A failure wakes Pi straight away, so it can react before the rest of the group finishes
117
132
  if (others.length === 0 || details.error) {
118
133
  clearTimeout(holds.get(job.group));
@@ -182,19 +197,24 @@ export default function (pi: ExtensionAPI) {
182
197
  }
183
198
  redrawJobs();
184
199
  };
200
+ // Running bash commands that ctrl+b can move to the background now, by tool call id, once they have run for
201
+ // HINT_AFTER_MS. Shared so pi-polish can show the hint under the command's row instead of in the jobs line.
202
+ const hinted = new Map<string, () => void>();
203
+ (globalThis as any)[Symbol.for("pi-background.movable")] = hinted;
204
+ let stopCtrlB = () => {};
185
205
  const jobsWidget = (theme: any) => ({
186
206
  invalidate() {},
187
207
  render(width: number) {
188
208
  if (jobsLine() === "off") return [];
189
209
  const live = running().filter((job) => job !== viewing);
190
210
  const lines = live.slice(0, MAX_SHOWN_JOBS).map((job) => {
191
- const text =
192
- theme.fg("accent", "⏵ ") +
193
- theme.fg("toolTitle", `${job.id} ${shownCommand(job.command)}`) +
194
- theme.fg("dim", ` · ${elapsed(job)}${jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : ""}`);
211
+ const silent = Date.now() - (job.lastOutput ?? job.started);
212
+ const detail = silent >= SILENT_AFTER_MS ? ` · no output for ${duration(silent)}` : jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : "";
213
+ const text = theme.fg("accent", "⏵ ") + theme.fg("toolTitle", `${job.id} ${shownCommand(job.command)}`) + theme.fg("dim", ` · ${elapsed(job)}${detail}`);
195
214
  return truncateToWidth(text, width);
196
215
  });
197
216
  if (live.length > MAX_SHOWN_JOBS) lines.push(theme.fg("dim", ` and ${live.length - MAX_SHOWN_JOBS} more`));
217
+ if (hinted.size > 0 && !(globalThis as any)[Symbol.for("pi-polish.showsMoveHint")]) lines.push(theme.fg("dim", " ctrl+b to run in background"));
198
218
  return lines;
199
219
  },
200
220
  });
@@ -239,6 +259,147 @@ export default function (pi: ExtensionAPI) {
239
259
  return branch(theme, expanded ? text : short);
240
260
  };
241
261
 
262
+ // Starts tracking a job: its output goes to a log and the jobs line, and Pi is told when run's promise settles.
263
+ // A bash command that moves to the background brings its start time and the output it printed before.
264
+ const startJob = (
265
+ command: string,
266
+ pattern: RegExp | undefined,
267
+ controller: AbortController,
268
+ run: (onData: (d: Buffer) => void) => Promise<{ exitCode: number | null }>,
269
+ earlier?: { started: number; output: Buffer; lastOutput?: number },
270
+ ) => {
271
+ const id = `bg${next++}`;
272
+ const dir = join(tmpdir(), "pi-bg");
273
+ mkdirSync(dir, { recursive: true });
274
+ const log = join(dir, `${process.pid}-${id}.log`);
275
+ const out = createWriteStream(log);
276
+ const job: Job = { id, command, log, started: earlier?.started ?? Date.now(), controller, status: "running", group: turn, watched: Boolean(pattern) };
277
+ jobs.set(id, job);
278
+
279
+ const watch = pattern ? watcher(job, pattern) : undefined;
280
+ const onData = (d: Buffer) => {
281
+ out.write(d);
282
+ watch?.(d);
283
+ job.lastOutput = Date.now();
284
+ const last = d.toString("utf8").split("\n").map(cleanLine).filter((line) => line.trim()).at(-1)?.trim();
285
+ if (last) job.lastLine = last;
286
+ };
287
+ if (earlier?.output.length) {
288
+ onData(earlier.output);
289
+ job.lastOutput = earlier.lastOutput;
290
+ }
291
+
292
+ run(onData)
293
+ .then(({ exitCode }) => {
294
+ job.status = "exited";
295
+ job.exitCode = exitCode;
296
+ })
297
+ .catch((err: Error) => {
298
+ job.status = controller.signal.aborted ? "killed" : "failed";
299
+ out.write(`\n[bg] ${err.message}\n`);
300
+ })
301
+ .finally(() => {
302
+ job.ended = Date.now();
303
+ showJobs();
304
+ out.end(() => {
305
+ // A kill from session shutdown has no session left to notify
306
+ if (job.status === "killed" && !jobs.has(id)) return;
307
+ report(job, `Background job finished: ${summary(job)}\nLast lines:\n${tail(log, TAIL_LINES)}`, {
308
+ headline: `${id} ${job.status === "exited" ? `exited ${job.exitCode}` : job.status} · ${elapsed(job)}`,
309
+ last: tail(log, 1),
310
+ error: job.status !== "exited" || job.exitCode !== 0,
311
+ });
312
+ });
313
+ });
314
+
315
+ showJobs();
316
+ return job;
317
+ };
318
+
319
+ // Pi's bash, except that a command still running after AUTO_BACKGROUND_SECS becomes a background job
320
+ // instead of blocking the turn until it ends or times out. The command keeps running in the same
321
+ // process; its output so far goes to the job's log, and Pi gets the usual message when it exits.
322
+ const handoff = (local: BashOperations, toolCallId: string): BashOperations => ({
323
+ exec(command, cwd, options) {
324
+ // Pi's own check, which its exec would otherwise have made
325
+ if (options.timeout !== undefined && !(Number.isFinite(options.timeout) && options.timeout > 0)) {
326
+ return Promise.reject(new Error("Invalid timeout: must be a finite number of seconds"));
327
+ }
328
+ const started = Date.now();
329
+ // Its own signal, so a job outlives the turn; until then the turn's abort is passed on
330
+ const controller = new AbortController();
331
+ const forward = () => controller.abort();
332
+ if (options.signal?.aborted) controller.abort();
333
+ options.signal?.addEventListener("abort", forward, { once: true });
334
+ // The output so far goes to the job's log; only its last TAIL_BYTES are kept, like the log tails
335
+ let earlier = Buffer.alloc(0);
336
+ let lastOutput: number | undefined;
337
+ let sink = (d: Buffer) => {
338
+ earlier = Buffer.concat([earlier, d]).subarray(-TAIL_BYTES);
339
+ lastOutput = Date.now();
340
+ options.onData(d);
341
+ };
342
+ const env = { ...(options.env ?? process.env), PYTHONUNBUFFERED: "1" };
343
+ // A timeout moves the command to the background sooner rather than stopping it, so its work isn't lost
344
+ const after = Math.min(options.timeout ?? AUTO_BACKGROUND_SECS, AUTO_BACKGROUND_SECS);
345
+ const running = local.exec(command, cwd, { onData: (d) => sink(d), signal: controller.signal, env });
346
+ return new Promise((resolve, reject) => {
347
+ let timer: NodeJS.Timeout | undefined;
348
+ let hint: NodeJS.Timeout | undefined;
349
+ const settle = () => {
350
+ clearTimeout(timer);
351
+ clearTimeout(hint);
352
+ options.signal?.removeEventListener("abort", forward);
353
+ if (hinted.delete(toolCallId)) redrawJobs();
354
+ };
355
+ const move = () => {
356
+ settle();
357
+ const output = earlier;
358
+ const job = startJob(command, undefined, controller, (onData) => {
359
+ sink = onData;
360
+ return running;
361
+ }, { started, output, lastOutput });
362
+ reject(new MovedToBackground(job, output.toString("utf8")));
363
+ };
364
+ timer = setTimeout(move, after * 1000);
365
+ hint = setTimeout(() => {
366
+ hinted.set(toolCallId, move);
367
+ redrawJobs();
368
+ }, HINT_AFTER_MS);
369
+ running.then(resolve, reject).finally(settle);
370
+ });
371
+ },
372
+ });
373
+ // Only Pi's own bash is wrapped; one that another extension replaced, such as a sandbox's, is left alone. By
374
+ // session_start every extension has registered its tools, whichever order they loaded in.
375
+ let wrapped = false;
376
+ pi.on("session_start", async () => {
377
+ if (wrapped || !pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo.path.startsWith("builtin:")) return;
378
+ wrapped = true;
379
+ const pisBash = createBashToolDefinition(process.cwd());
380
+ pi.registerTool({
381
+ ...pisBash,
382
+ promptGuidelines: [
383
+ ...(pisBash.promptGuidelines ?? []),
384
+ `A bash command still running after ${AUTO_BACKGROUND_SECS} seconds, or after its timeout if that is shorter, moves to the background on its own, as if started with bg_run, and you get its job id instead of its output; it is not stopped. Still start a command you expect to take that long with bg_run, so you choose wake_on and the turn isn't held up first.`,
385
+ ],
386
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
387
+ // Built per call from the same settings Pi builds its own bash from
388
+ const { shellPath, shellCommandPrefix } = pi.getSettings();
389
+ const bash = createBashToolDefinition(ctx.cwd, { commandPrefix: shellCommandPrefix, operations: handoff(createLocalBashOperations({ shellPath }), toolCallId) });
390
+ try {
391
+ return await bash.execute(toolCallId, params, signal, onUpdate, ctx);
392
+ } catch (err) {
393
+ if (!(err instanceof MovedToBackground)) throw err;
394
+ const { id, log, started } = err.job;
395
+ const soFar = err.output.trimEnd().split("\n").slice(-TAIL_LINES).join("\n") || "(no output)";
396
+ const text = `Still running after ${duration(Date.now() - started)}, so it moved to the background as ${id}. You will be notified when it exits; check it with bg_status, or stop it with bg_kill if it shouldn't take this long.\nOutput so far:\n${soFar}\nlog: ${log}`;
397
+ return { content: [{ type: "text", text }], details: undefined };
398
+ }
399
+ },
400
+ });
401
+ });
402
+
242
403
  pi.registerTool({
243
404
  name: "bg_run",
244
405
  label: "Background",
@@ -253,11 +414,10 @@ export default function (pi: ExtensionAPI) {
253
414
  "When unsure whether a command is quick, prefer bg_run: a quick job just reports back sooner, while a long bash command blocks the user until it finishes.",
254
415
  "After bg_run, continue with independent work or end your turn; a message with the exit code and log tail arrives automatically when the job exits, so never sleep, poll or tail the log to wait for it.",
255
416
  "Keep using bash for quick commands whose output you need right away, such as git, ls, cat, grep, find, file inspection and short checks.",
256
- "Pass wake_on only when you must act before a bg_run job ends, such as a server becoming ready to test against, or when the user asked to follow its progress. To report a job's result, leave wake_on out: the exit message already brings the exit code and last lines, so waking on every epoch or step only adds turns.",
417
+ "Pass wake_on only when you must act before a bg_run job ends, such as a server becoming ready to test against, or when the user asked to follow its progress. Make it a pattern for that one event, which matches once, not a line the job prints for every item, file, epoch or step. To report a job's result, leave wake_on out: the exit message already brings the exit code and last lines.",
257
418
  "Don't redirect a bg_run command's output to a file (> file, 2>&1, nohup): bg_run already saves all of it to the job's log, and redirecting hides the job's progress from the user and from wake_on. If the output should be kept, tell the user where the job's log is.",
258
419
  "bg_run starts only after the other tool calls in the same reply have finished, so editing a file and starting a job that uses it in one reply is safe.",
259
420
  "Use bg_status to check progress of a bg_run job and bg_kill to stop one.",
260
- `bash commands time out after ${BASH_DEFAULT_TIMEOUT / 60} minutes unless you pass timeout (at most ${BASH_MAX_TIMEOUT / 60} minutes); run anything that may take longer with bg_run instead.`,
261
421
  "Unless the user explicitly asks for that bash command, never wait, poll or sleep in bash (while/until loops with sleep, watch, repeated status checks), not even as part of a larger command; start the wait with bg_run and pass wake_on to be woken when the awaited output appears.",
262
422
  "Unless the user explicitly asks otherwise, start long-running processes (training or evaluation runs, servers, watchers) with bg_run, not with & or nohup in bash followed by sleep.",
263
423
  ],
@@ -276,53 +436,17 @@ export default function (pi: ExtensionAPI) {
276
436
  cwd: Type.Optional(Type.String({ description: "Working directory (defaults to the session cwd)" })),
277
437
  wake_on: Type.Optional(
278
438
  Type.String({
279
- description: "Regex; also wake you while the job runs whenever an output line matches, e.g. 'listening on|ready'. Matches within a second are batched; at most 10 wake-ups per job.",
439
+ description: `Regex; also wake you while the job runs whenever an output line matches, e.g. 'listening on|ready'. Matches within a second are batched; at most ${MAX_WAKES} wake-ups per job.`,
280
440
  }),
281
441
  ),
282
442
  }),
283
443
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
284
444
  const pattern = params.wake_on ? wakePattern(params.wake_on) : undefined;
285
- const id = `bg${next++}`;
286
- const dir = join(tmpdir(), "pi-bg");
287
- mkdirSync(dir, { recursive: true });
288
- const log = join(dir, `${process.pid}-${id}.log`);
289
- const out = createWriteStream(log);
290
- const job: Job = { id, command: params.command, log, started: Date.now(), controller: new AbortController(), status: "running", group: turn, watched: Boolean(pattern) };
291
- jobs.set(id, job);
292
-
293
- const watch = pattern ? watcher(job, pattern) : undefined;
294
- const onData = (d: Buffer) => {
295
- out.write(d);
296
- watch?.(d);
297
- const last = d.toString("utf8").split("\n").map(cleanLine).filter((line) => line.trim()).at(-1)?.trim();
298
- if (last) job.lastLine = last;
299
- };
300
-
445
+ const controller = new AbortController();
301
446
  // Output goes to a pipe, where Python would buffer prints until it exits; unbuffered, the latest line and wake_on stay live
302
- ops.exec(`export PYTHONUNBUFFERED=1; ${params.command}`, params.cwd ?? ctx.cwd, { onData, signal: job.controller.signal })
303
- .then(({ exitCode }) => {
304
- job.status = "exited";
305
- job.exitCode = exitCode;
306
- })
307
- .catch((err: Error) => {
308
- job.status = job.controller.signal.aborted ? "killed" : "failed";
309
- out.write(`\n[bg] ${err.message}\n`);
310
- })
311
- .finally(() => {
312
- job.ended = Date.now();
313
- showJobs();
314
- out.end(() => {
315
- // A kill from session shutdown has no session left to notify
316
- if (job.status === "killed" && !jobs.has(id)) return;
317
- report(job, `Background job finished: ${summary(job)}\nLast lines:\n${tail(log, TAIL_LINES)}`, {
318
- headline: `${id} ${job.status === "exited" ? `exited ${job.exitCode}` : job.status} · ${elapsed(job)}`,
319
- last: tail(log, 1),
320
- error: job.status !== "exited" || job.exitCode !== 0,
321
- });
322
- });
323
- });
324
-
325
- showJobs();
447
+ const { id, log } = startJob(params.command, pattern, controller, (onData) =>
448
+ ops.exec(`export PYTHONUNBUFFERED=1; ${params.command}`, params.cwd ?? ctx.cwd, { onData, signal: controller.signal }),
449
+ );
326
450
  return { content: [{ type: "text", text: `Started ${id}; log: ${log}. You will be notified when it exits.` }], details: { id, log } };
327
451
  },
328
452
  });
@@ -365,60 +489,73 @@ export default function (pi: ExtensionAPI) {
365
489
  });
366
490
 
367
491
  // Shows a job's latest output in place of the input until esc, refreshed every second while it runs
492
+ const showJob = async (args: string, ctx: ExtensionContext) => {
493
+ if (!ctx.hasUI) return;
494
+ const all = [...jobs.values()].reverse();
495
+ if (all.length === 0) return ctx.ui.notify("No background jobs yet", "info");
496
+ // "/bg 2" works as well as "/bg bg2"
497
+ const wanted = args.trim().toLowerCase().replace(/^(\d+)$/, "bg$1");
498
+ let job = wanted ? jobs.get(wanted) : all.length === 1 ? all[0] : undefined;
499
+ if (wanted && !job) return ctx.ui.notify(`No background job ${args.trim()}; there's ${[...jobs.keys()].join(", ")}`, "warning");
500
+ if (!job) {
501
+ // Running jobs first, then the latest finished ones
502
+ const listed = [...all.filter((other) => other.status === "running"), ...all.filter((other) => other.status !== "running").slice(0, MAX_LISTED_FINISHED)];
503
+ // Matched by position, since the elapsed time in a label changes while the list is open
504
+ // Ids and commands padded to one width, so the states and times line up
505
+ const idWidth = Math.max(...listed.map((other) => other.id.length));
506
+ const commandWidth = Math.max(...listed.map((other) => shownCommand(other.command).length));
507
+ const options = listed.map((other) => `${other.id.padEnd(idWidth)} ${shownCommand(other.command).padEnd(commandWidth)} · ${state(other)} · ${elapsed(other)}`);
508
+ job = listed[options.indexOf((await ctx.ui.select("Background jobs", options)) ?? "")];
509
+ if (!job) return;
510
+ }
511
+ const shown = job;
512
+ viewing = shown;
513
+ const log = shown.log.replace(homedir(), "~");
514
+ try {
515
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
516
+ const timer = setInterval(() => tui.requestRender(), 1000);
517
+ return {
518
+ dispose: () => clearInterval(timer),
519
+ invalidate() {},
520
+ handleInput: (data: string) => {
521
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data === "q") done();
522
+ },
523
+ render(width: number) {
524
+ const color = shown.status === "running" ? "accent" : shown.status === "exited" && shown.exitCode === 0 ? "success" : "error";
525
+ const title = theme.fg(color, " ● ") + theme.fg("toolTitle", `${shown.id} ${shownCommand(shown.command)}`);
526
+ const status = theme.fg("muted", `${state(shown)} · ${elapsed(shown)} `);
527
+ const gap = Math.max(1, width - visibleWidth(title) - visibleWidth(status));
528
+ // At most half the terminal, so the conversation above stays in view
529
+ const text = tail(shown.log, Math.max(3, Math.min(TAIL_LINES, Math.floor(tui.terminal.rows / 2))));
530
+ const output = text === "(no output)" ? [theme.fg("dim", theme.italic(" no output yet"))] : text.split("\n").map((line) => ` ${cleanLine(line)}`);
531
+ return [
532
+ theme.fg("borderMuted", "─".repeat(width)),
533
+ title + " ".repeat(gap) + status,
534
+ ...output,
535
+ theme.fg("dim", ` esc to close · ${log}`),
536
+ theme.fg("borderMuted", "─".repeat(width)),
537
+ ].map((line) => truncateToWidth(line, width));
538
+ },
539
+ };
540
+ });
541
+ } finally {
542
+ viewing = undefined;
543
+ }
544
+ };
368
545
  pi.registerCommand("bg", {
369
546
  description: "Show a background job's latest output",
370
547
  getArgumentCompletions: (prefix) => {
371
548
  const matching = [...jobs.values()].filter((job) => job.id.startsWith(prefix.trim().toLowerCase()));
372
549
  return matching.length ? matching.map((job) => ({ value: job.id, label: job.id, description: `${shownCommand(job.command)} · ${state(job)}` })) : null;
373
550
  },
374
- handler: async (args, ctx) => {
375
- if (!ctx.hasUI) return;
376
- const all = [...jobs.values()].reverse();
377
- if (all.length === 0) return ctx.ui.notify("No background jobs yet", "info");
378
- // "/bg 2" works as well as "/bg bg2"
379
- const wanted = args.trim().toLowerCase().replace(/^(\d+)$/, "bg$1");
380
- let job = wanted ? jobs.get(wanted) : all.length === 1 ? all[0] : undefined;
381
- if (wanted && !job) return ctx.ui.notify(`No background job ${args.trim()}; there's ${[...jobs.keys()].join(", ")}`, "warning");
382
- if (!job) {
383
- // Matched by position, since the elapsed time in a label changes while the list is open
384
- const options = all.map((other) => `${other.id} ${shownCommand(other.command)} · ${state(other)} · ${elapsed(other)}`);
385
- job = all[options.indexOf((await ctx.ui.select("Background jobs", options)) ?? "")];
386
- if (!job) return;
387
- }
388
- const shown = job;
389
- viewing = shown;
390
- const log = shown.log.replace(homedir(), "~");
391
- try {
392
- await ctx.ui.custom<void>((tui, theme, _kb, done) => {
393
- const timer = setInterval(() => tui.requestRender(), 1000);
394
- return {
395
- dispose: () => clearInterval(timer),
396
- invalidate() {},
397
- handleInput: (data: string) => {
398
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data === "q") done();
399
- },
400
- render(width: number) {
401
- const color = shown.status === "running" ? "accent" : shown.status === "exited" && shown.exitCode === 0 ? "success" : "error";
402
- const title = theme.fg(color, " ● ") + theme.fg("toolTitle", `${shown.id} ${shownCommand(shown.command)}`);
403
- const status = theme.fg("muted", `${state(shown)} · ${elapsed(shown)} `);
404
- const gap = Math.max(1, width - visibleWidth(title) - visibleWidth(status));
405
- // At most half the terminal, so the conversation above stays in view
406
- const text = tail(shown.log, Math.max(3, Math.min(TAIL_LINES, Math.floor(tui.terminal.rows / 2))));
407
- const output = text === "(no output)" ? [theme.fg("dim", theme.italic(" no output yet"))] : text.split("\n").map((line) => ` ${cleanLine(line)}`);
408
- return [
409
- theme.fg("borderMuted", "─".repeat(width)),
410
- title + " ".repeat(gap) + status,
411
- ...output,
412
- theme.fg("dim", ` esc to close · ${log}`),
413
- theme.fg("borderMuted", "─".repeat(width)),
414
- ].map((line) => truncateToWidth(line, width));
415
- },
416
- };
417
- });
418
- } finally {
419
- viewing = undefined;
420
- }
421
- },
551
+ handler: showJob,
552
+ });
553
+ // "/bg3" without the space isn't a command, so pi would send it to the model as a message; open the job instead
554
+ pi.on("input", async (event, ctx) => {
555
+ const id = event.text.trim().match(/^\/(bg\d+)$/i)?.[1];
556
+ if (!id || !jobs.has(id.toLowerCase())) return { action: "continue" };
557
+ await showJob(id, ctx);
558
+ return { action: "handled" };
422
559
  });
423
560
 
424
561
  // After /reload or a resume, carry on after the ids already in the conversation so "bg3" keeps meaning one job
@@ -428,6 +565,13 @@ export default function (pi: ExtensionAPI) {
428
565
  redrawJobs = () => tui.requestRender();
429
566
  return jobsWidget(theme);
430
567
  });
568
+ // ctrl+b moves running bash commands to the background; otherwise it stays the editor's cursor-left
569
+ stopCtrlB();
570
+ stopCtrlB = ctx.ui.onTerminalInput((data) => {
571
+ if (hinted.size === 0 || !matchesKey(data, Key.ctrl("b"))) return undefined;
572
+ for (const move of [...hinted.values()]) move();
573
+ return { consume: true };
574
+ });
431
575
  }
432
576
  for (const entry of ctx.sessionManager.getBranch()) {
433
577
  const msg = entry.type === "message" ? (entry.message as any) : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-background",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "author": "Thomas van Dongen",
6
6
  "type": "module",