pi-background 0.3.3 → 0.4.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
@@ -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. With more than three jobs running, they share one line, `⏵ 5 jobs`, which adds `1 silent for 10m+` when one may be stuck; `/bg` has the details.
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,33 @@ 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
- 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}` : ""}`);
195
- return truncateToWidth(text, width);
196
- });
197
- if (live.length > MAX_SHOWN_JOBS) lines.push(theme.fg("dim", ` and ${live.length - MAX_SHOWN_JOBS} more`));
210
+ const silentFor = (job: Job) => Date.now() - (job.lastOutput ?? job.started);
211
+ let lines: string[];
212
+ if (live.length <= MAX_SHOWN_JOBS) {
213
+ lines = live.map((job) => {
214
+ const silent = silentFor(job);
215
+ const detail = silent >= SILENT_AFTER_MS ? ` · no output for ${duration(silent)}` : jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : "";
216
+ const text = theme.fg("accent", "⏵ ") + theme.fg("toolTitle", `${job.id} ${shownCommand(job.command)}`) + theme.fg("dim", ` · ${elapsed(job)}${detail}`);
217
+ return truncateToWidth(text, width);
218
+ });
219
+ } else {
220
+ // Past a few jobs, a line each crowds the input, so they share one with just the count, and a warning when
221
+ // some have gone quiet, since they may be stuck; /bg has the rest
222
+ const silent = live.filter((job) => silentFor(job) >= SILENT_AFTER_MS).length;
223
+ const warning = silent ? theme.fg("warning", ` · ${silent} silent for ${SILENT_AFTER_MS / 60_000}m+`) : "";
224
+ lines = [truncateToWidth(theme.fg("accent", "⏵ ") + theme.fg("toolTitle", `${live.length} jobs`) + warning, width)];
225
+ }
226
+ if (hinted.size > 0 && !(globalThis as any)[Symbol.for("pi-polish.showsMoveHint")]) lines.push(theme.fg("dim", " ctrl+b to run in background"));
198
227
  return lines;
199
228
  },
200
229
  });
@@ -239,6 +268,147 @@ export default function (pi: ExtensionAPI) {
239
268
  return branch(theme, expanded ? text : short);
240
269
  };
241
270
 
271
+ // Starts tracking a job: its output goes to a log and the jobs line, and Pi is told when run's promise settles.
272
+ // A bash command that moves to the background brings its start time and the output it printed before.
273
+ const startJob = (
274
+ command: string,
275
+ pattern: RegExp | undefined,
276
+ controller: AbortController,
277
+ run: (onData: (d: Buffer) => void) => Promise<{ exitCode: number | null }>,
278
+ earlier?: { started: number; output: Buffer; lastOutput?: number },
279
+ ) => {
280
+ const id = `bg${next++}`;
281
+ const dir = join(tmpdir(), "pi-bg");
282
+ mkdirSync(dir, { recursive: true });
283
+ const log = join(dir, `${process.pid}-${id}.log`);
284
+ const out = createWriteStream(log);
285
+ const job: Job = { id, command, log, started: earlier?.started ?? Date.now(), controller, status: "running", group: turn, watched: Boolean(pattern) };
286
+ jobs.set(id, job);
287
+
288
+ const watch = pattern ? watcher(job, pattern) : undefined;
289
+ const onData = (d: Buffer) => {
290
+ out.write(d);
291
+ watch?.(d);
292
+ job.lastOutput = Date.now();
293
+ const last = d.toString("utf8").split("\n").map(cleanLine).filter((line) => line.trim()).at(-1)?.trim();
294
+ if (last) job.lastLine = last;
295
+ };
296
+ if (earlier?.output.length) {
297
+ onData(earlier.output);
298
+ job.lastOutput = earlier.lastOutput;
299
+ }
300
+
301
+ run(onData)
302
+ .then(({ exitCode }) => {
303
+ job.status = "exited";
304
+ job.exitCode = exitCode;
305
+ })
306
+ .catch((err: Error) => {
307
+ job.status = controller.signal.aborted ? "killed" : "failed";
308
+ out.write(`\n[bg] ${err.message}\n`);
309
+ })
310
+ .finally(() => {
311
+ job.ended = Date.now();
312
+ showJobs();
313
+ out.end(() => {
314
+ // A kill from session shutdown has no session left to notify
315
+ if (job.status === "killed" && !jobs.has(id)) return;
316
+ report(job, `Background job finished: ${summary(job)}\nLast lines:\n${tail(log, TAIL_LINES)}`, {
317
+ headline: `${id} ${job.status === "exited" ? `exited ${job.exitCode}` : job.status} · ${elapsed(job)}`,
318
+ last: tail(log, 1),
319
+ error: job.status !== "exited" || job.exitCode !== 0,
320
+ });
321
+ });
322
+ });
323
+
324
+ showJobs();
325
+ return job;
326
+ };
327
+
328
+ // Pi's bash, except that a command still running after AUTO_BACKGROUND_SECS becomes a background job
329
+ // instead of blocking the turn until it ends or times out. The command keeps running in the same
330
+ // process; its output so far goes to the job's log, and Pi gets the usual message when it exits.
331
+ const handoff = (local: BashOperations, toolCallId: string): BashOperations => ({
332
+ exec(command, cwd, options) {
333
+ // Pi's own check, which its exec would otherwise have made
334
+ if (options.timeout !== undefined && !(Number.isFinite(options.timeout) && options.timeout > 0)) {
335
+ return Promise.reject(new Error("Invalid timeout: must be a finite number of seconds"));
336
+ }
337
+ const started = Date.now();
338
+ // Its own signal, so a job outlives the turn; until then the turn's abort is passed on
339
+ const controller = new AbortController();
340
+ const forward = () => controller.abort();
341
+ if (options.signal?.aborted) controller.abort();
342
+ options.signal?.addEventListener("abort", forward, { once: true });
343
+ // The output so far goes to the job's log; only its last TAIL_BYTES are kept, like the log tails
344
+ let earlier = Buffer.alloc(0);
345
+ let lastOutput: number | undefined;
346
+ let sink = (d: Buffer) => {
347
+ earlier = Buffer.concat([earlier, d]).subarray(-TAIL_BYTES);
348
+ lastOutput = Date.now();
349
+ options.onData(d);
350
+ };
351
+ const env = { ...(options.env ?? process.env), PYTHONUNBUFFERED: "1" };
352
+ // A timeout moves the command to the background sooner rather than stopping it, so its work isn't lost
353
+ const after = Math.min(options.timeout ?? AUTO_BACKGROUND_SECS, AUTO_BACKGROUND_SECS);
354
+ const running = local.exec(command, cwd, { onData: (d) => sink(d), signal: controller.signal, env });
355
+ return new Promise((resolve, reject) => {
356
+ let timer: NodeJS.Timeout | undefined;
357
+ let hint: NodeJS.Timeout | undefined;
358
+ const settle = () => {
359
+ clearTimeout(timer);
360
+ clearTimeout(hint);
361
+ options.signal?.removeEventListener("abort", forward);
362
+ if (hinted.delete(toolCallId)) redrawJobs();
363
+ };
364
+ const move = () => {
365
+ settle();
366
+ const output = earlier;
367
+ const job = startJob(command, undefined, controller, (onData) => {
368
+ sink = onData;
369
+ return running;
370
+ }, { started, output, lastOutput });
371
+ reject(new MovedToBackground(job, output.toString("utf8")));
372
+ };
373
+ timer = setTimeout(move, after * 1000);
374
+ hint = setTimeout(() => {
375
+ hinted.set(toolCallId, move);
376
+ redrawJobs();
377
+ }, HINT_AFTER_MS);
378
+ running.then(resolve, reject).finally(settle);
379
+ });
380
+ },
381
+ });
382
+ // Only Pi's own bash is wrapped; one that another extension replaced, such as a sandbox's, is left alone. By
383
+ // session_start every extension has registered its tools, whichever order they loaded in.
384
+ let wrapped = false;
385
+ pi.on("session_start", async () => {
386
+ if (wrapped || !pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo.path.startsWith("builtin:")) return;
387
+ wrapped = true;
388
+ const pisBash = createBashToolDefinition(process.cwd());
389
+ pi.registerTool({
390
+ ...pisBash,
391
+ promptGuidelines: [
392
+ ...(pisBash.promptGuidelines ?? []),
393
+ `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.`,
394
+ ],
395
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
396
+ // Built per call from the same settings Pi builds its own bash from
397
+ const { shellPath, shellCommandPrefix } = pi.getSettings();
398
+ const bash = createBashToolDefinition(ctx.cwd, { commandPrefix: shellCommandPrefix, operations: handoff(createLocalBashOperations({ shellPath }), toolCallId) });
399
+ try {
400
+ return await bash.execute(toolCallId, params, signal, onUpdate, ctx);
401
+ } catch (err) {
402
+ if (!(err instanceof MovedToBackground)) throw err;
403
+ const { id, log, started } = err.job;
404
+ const soFar = err.output.trimEnd().split("\n").slice(-TAIL_LINES).join("\n") || "(no output)";
405
+ 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}`;
406
+ return { content: [{ type: "text", text }], details: undefined };
407
+ }
408
+ },
409
+ });
410
+ });
411
+
242
412
  pi.registerTool({
243
413
  name: "bg_run",
244
414
  label: "Background",
@@ -253,11 +423,10 @@ export default function (pi: ExtensionAPI) {
253
423
  "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
424
  "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
425
  "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.",
426
+ "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
427
  "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
428
  "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
429
  "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
430
  "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
431
  "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
432
  ],
@@ -276,53 +445,17 @@ export default function (pi: ExtensionAPI) {
276
445
  cwd: Type.Optional(Type.String({ description: "Working directory (defaults to the session cwd)" })),
277
446
  wake_on: Type.Optional(
278
447
  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.",
448
+ 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
449
  }),
281
450
  ),
282
451
  }),
283
452
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
284
453
  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
-
454
+ const controller = new AbortController();
301
455
  // 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();
456
+ const { id, log } = startJob(params.command, pattern, controller, (onData) =>
457
+ ops.exec(`export PYTHONUNBUFFERED=1; ${params.command}`, params.cwd ?? ctx.cwd, { onData, signal: controller.signal }),
458
+ );
326
459
  return { content: [{ type: "text", text: `Started ${id}; log: ${log}. You will be notified when it exits.` }], details: { id, log } };
327
460
  },
328
461
  });
@@ -365,60 +498,73 @@ export default function (pi: ExtensionAPI) {
365
498
  });
366
499
 
367
500
  // Shows a job's latest output in place of the input until esc, refreshed every second while it runs
501
+ const showJob = async (args: string, ctx: ExtensionContext) => {
502
+ if (!ctx.hasUI) return;
503
+ const all = [...jobs.values()].reverse();
504
+ if (all.length === 0) return ctx.ui.notify("No background jobs yet", "info");
505
+ // "/bg 2" works as well as "/bg bg2"
506
+ const wanted = args.trim().toLowerCase().replace(/^(\d+)$/, "bg$1");
507
+ let job = wanted ? jobs.get(wanted) : all.length === 1 ? all[0] : undefined;
508
+ if (wanted && !job) return ctx.ui.notify(`No background job ${args.trim()}; there's ${[...jobs.keys()].join(", ")}`, "warning");
509
+ if (!job) {
510
+ // Running jobs first, then the latest finished ones
511
+ const listed = [...all.filter((other) => other.status === "running"), ...all.filter((other) => other.status !== "running").slice(0, MAX_LISTED_FINISHED)];
512
+ // Matched by position, since the elapsed time in a label changes while the list is open
513
+ // Ids and commands padded to one width, so the states and times line up
514
+ const idWidth = Math.max(...listed.map((other) => other.id.length));
515
+ const commandWidth = Math.max(...listed.map((other) => shownCommand(other.command).length));
516
+ const options = listed.map((other) => `${other.id.padEnd(idWidth)} ${shownCommand(other.command).padEnd(commandWidth)} · ${state(other)} · ${elapsed(other)}`);
517
+ job = listed[options.indexOf((await ctx.ui.select("Background jobs", options)) ?? "")];
518
+ if (!job) return;
519
+ }
520
+ const shown = job;
521
+ viewing = shown;
522
+ const log = shown.log.replace(homedir(), "~");
523
+ try {
524
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
525
+ const timer = setInterval(() => tui.requestRender(), 1000);
526
+ return {
527
+ dispose: () => clearInterval(timer),
528
+ invalidate() {},
529
+ handleInput: (data: string) => {
530
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data === "q") done();
531
+ },
532
+ render(width: number) {
533
+ const color = shown.status === "running" ? "accent" : shown.status === "exited" && shown.exitCode === 0 ? "success" : "error";
534
+ const title = theme.fg(color, " ● ") + theme.fg("toolTitle", `${shown.id} ${shownCommand(shown.command)}`);
535
+ const status = theme.fg("muted", `${state(shown)} · ${elapsed(shown)} `);
536
+ const gap = Math.max(1, width - visibleWidth(title) - visibleWidth(status));
537
+ // At most half the terminal, so the conversation above stays in view
538
+ const text = tail(shown.log, Math.max(3, Math.min(TAIL_LINES, Math.floor(tui.terminal.rows / 2))));
539
+ const output = text === "(no output)" ? [theme.fg("dim", theme.italic(" no output yet"))] : text.split("\n").map((line) => ` ${cleanLine(line)}`);
540
+ return [
541
+ theme.fg("borderMuted", "─".repeat(width)),
542
+ title + " ".repeat(gap) + status,
543
+ ...output,
544
+ theme.fg("dim", ` esc to close · ${log}`),
545
+ theme.fg("borderMuted", "─".repeat(width)),
546
+ ].map((line) => truncateToWidth(line, width));
547
+ },
548
+ };
549
+ });
550
+ } finally {
551
+ viewing = undefined;
552
+ }
553
+ };
368
554
  pi.registerCommand("bg", {
369
555
  description: "Show a background job's latest output",
370
556
  getArgumentCompletions: (prefix) => {
371
557
  const matching = [...jobs.values()].filter((job) => job.id.startsWith(prefix.trim().toLowerCase()));
372
558
  return matching.length ? matching.map((job) => ({ value: job.id, label: job.id, description: `${shownCommand(job.command)} · ${state(job)}` })) : null;
373
559
  },
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
- },
560
+ handler: showJob,
561
+ });
562
+ // "/bg3" without the space isn't a command, so pi would send it to the model as a message; open the job instead
563
+ pi.on("input", async (event, ctx) => {
564
+ const id = event.text.trim().match(/^\/(bg\d+)$/i)?.[1];
565
+ if (!id || !jobs.has(id.toLowerCase())) return { action: "continue" };
566
+ await showJob(id, ctx);
567
+ return { action: "handled" };
422
568
  });
423
569
 
424
570
  // After /reload or a resume, carry on after the ids already in the conversation so "bg3" keeps meaning one job
@@ -428,6 +574,13 @@ export default function (pi: ExtensionAPI) {
428
574
  redrawJobs = () => tui.requestRender();
429
575
  return jobsWidget(theme);
430
576
  });
577
+ // ctrl+b moves running bash commands to the background; otherwise it stays the editor's cursor-left
578
+ stopCtrlB();
579
+ stopCtrlB = ctx.ui.onTerminalInput((data) => {
580
+ if (hinted.size === 0 || !matchesKey(data, Key.ctrl("b"))) return undefined;
581
+ for (const move of [...hinted.values()]) move();
582
+ return { consume: true };
583
+ });
431
584
  }
432
585
  for (const entry of ctx.sessionManager.getBranch()) {
433
586
  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.1",
4
4
  "license": "MIT",
5
5
  "author": "Thomas van Dongen",
6
6
  "type": "module",