pi-background 0.3.1 → 0.3.3

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.
@@ -1,9 +1,10 @@
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, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import { Key, matchesKey, stripTerminalSequences, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { createLocalBashOperations, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { Key, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
6
6
  import { Type } from "typebox";
7
+ import { shortCommand } from "./command.ts";
7
8
 
8
9
  const TAIL_LINES = 20;
9
10
  const TAIL_BYTES = 64 * 1024;
@@ -65,6 +66,8 @@ function shownCommand(command: string): string {
65
66
  const shown = command
66
67
  .split("\n")[0]
67
68
  .replace(/^\s*(cd\s+("[^"]*"|'[^']*'|\S+)\s*(&&|;)\s*)+/, "")
69
+ // Leading settings such as PYTHONPATH=/some/src, which the path shortening below would cut at their last /
70
+ .replace(/^(\w+=("[^"]*"|'[^']*'|\S*)\s+)+/, "")
68
71
  .replace(/^\S*\/(?=[^\s/]+(\s|$))/, "");
69
72
  return shown.length > 50 ? `${shown.slice(0, 49)}…` : shown;
70
73
  }
@@ -161,35 +164,40 @@ export default function (pi: ExtensionAPI) {
161
164
  };
162
165
 
163
166
  // One line per running job above the input, with its elapsed time and latest output
164
- let ui: ExtensionContext["ui"] | undefined;
165
167
  const running = () => [...jobs.values()].filter((job) => job.status === "running");
166
168
  // The job open in /bg, left out of the jobs line meanwhile so it isn't shown twice
167
169
  let viewing: Job | undefined;
168
170
  // "full" (the default), "compact" without the output, or "off", set with "background": { "jobs": ... } in pi's settings.json
169
171
  const jobsLine = () => (pi.getSettings() as { background?: { jobs?: string } }).background?.jobs ?? "full";
172
+ // The widget is set once per session and redrawn in place: setting it again would move it below the widgets other
173
+ // extensions added since, while running jobs belong at the top, next to what Pi is doing now
174
+ let redrawJobs = () => {};
175
+ let ticker: NodeJS.Timeout | undefined;
170
176
  const showJobs = () => {
171
- if (!ui) return;
172
- if (jobsLine() === "off" || running().length === 0) return ui.setWidget("background-jobs", undefined);
173
- ui.setWidget("background-jobs", (tui, theme) => {
174
- const timer = setInterval(() => tui.requestRender(), 1000);
175
- return {
176
- dispose: () => clearInterval(timer),
177
- invalidate() {},
178
- render(width: number) {
179
- const live = running().filter((job) => job !== viewing);
180
- const lines = live.slice(0, MAX_SHOWN_JOBS).map((job) => {
181
- const text =
182
- theme.fg("accent", " ⏵ ") +
183
- theme.fg("toolTitle", `${job.id} ${shownCommand(job.command)}`) +
184
- theme.fg("dim", ` · ${elapsed(job)}${jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : ""}`);
185
- return truncateToWidth(text, width);
186
- });
187
- if (live.length > MAX_SHOWN_JOBS) lines.push(theme.fg("dim", ` and ${live.length - MAX_SHOWN_JOBS} more`));
188
- return lines;
189
- },
190
- };
191
- });
177
+ // A running job's elapsed time and latest output change every second
178
+ if (running().length > 0) ticker ??= setInterval(() => redrawJobs(), 1000);
179
+ else {
180
+ clearInterval(ticker);
181
+ ticker = undefined;
182
+ }
183
+ redrawJobs();
192
184
  };
185
+ const jobsWidget = (theme: any) => ({
186
+ invalidate() {},
187
+ render(width: number) {
188
+ if (jobsLine() === "off") return [];
189
+ 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`));
198
+ return lines;
199
+ },
200
+ });
193
201
 
194
202
  const find = (id: string): Job => {
195
203
  const job = jobs.get(id);
@@ -197,22 +205,38 @@ export default function (pi: ExtensionAPI) {
197
205
  return job;
198
206
  };
199
207
 
200
- // Compact, Claude-Code-style rows instead of pi's default boxed tool and message rendering
208
+ // Compact, Claude-Code-style rows instead of pi's default boxed tool and message rendering: quiet grey once done,
209
+ // with a dot only while running or when something failed
210
+ // " └ text" in dim, with long and extra lines wrapped under the text instead of at the left edge
211
+ const branch = (theme: any, text: string, head?: string) => ({
212
+ invalidate() {},
213
+ render: (width: number) => [
214
+ ...(head ? [truncateToWidth(head, width)] : []),
215
+ ...wrapTextWithAnsi(text, Math.max(10, width - 4)).map((line, i) => theme.fg("dim", `${i === 0 ? " └ " : " "}${line}`)),
216
+ ],
217
+ });
201
218
  pi.registerMessageRenderer("background-job", (message, { expanded }, theme) => {
202
219
  const d = (message.details ?? {}) as { headline?: string; last?: string; error?: boolean };
203
- const head = theme.fg(d.error ? "error" : "success", "● ") + theme.fg("toolTitle", `Background ${d.headline ?? ""}`);
204
- const body = expanded ? String(message.content) : (d.last ?? "");
205
- return new Text(`${head}\n${theme.fg("dim", ` └ ${body.replace(/\n/g, "\n ")}`)}`, 0, 0);
220
+ const title = `Background ${d.headline ?? ""}`;
221
+ const head = d.error ? theme.fg("error", "● ") + theme.fg("toolTitle", title) : ` ${theme.fg("dim", title)}`;
222
+ return branch(theme, expanded ? String(message.content) : (d.last ?? ""), head);
223
+ });
224
+ // One line, like pi-polish's shell rows; expanded (ctrl+o) shows the whole text, wrapped under the row
225
+ const row = (theme: any, text: string, context: any, full = text) => ({
226
+ invalidate() {},
227
+ render(width: number) {
228
+ const color = context.isError ? "error" : context.executionStarted && context.isPartial ? "accent" : undefined;
229
+ const mark = color ? theme.fg(color, "● ") : " ";
230
+ const paint = (line: string) => theme.fg(color ? "toolTitle" : "dim", line);
231
+ if (!context.expanded) return [mark + paint(truncateToWidth(text, Math.max(10, width - 2)))];
232
+ return wrapTextWithAnsi(full, Math.max(10, width - 2)).map((line, i) => (i === 0 ? mark : " ") + paint(line));
233
+ },
206
234
  });
207
- const row = (theme: any, text: string, context: any) => {
208
- const color = context.isError ? "error" : context.executionStarted && context.isPartial ? "accent" : "success";
209
- return new Text(theme.fg(color, "● ") + theme.fg("toolTitle", text), 0, 0);
210
- };
211
235
  const note = (theme: any, result: any, expanded: boolean) => {
212
236
  const text = result.content?.map((c: any) => c.text ?? "").join("\n") ?? "";
213
237
  // Collapsed: first sentence only ("Started bg1"), without the log path
214
238
  const short = text.split("\n")[0].split(";")[0];
215
- return new Text(theme.fg("dim", ` └ ${(expanded ? text : short).replace(/\n/g, "\n ")}`), 0, 0);
239
+ return branch(theme, expanded ? text : short);
216
240
  };
217
241
 
218
242
  pi.registerTool({
@@ -229,25 +253,30 @@ export default function (pi: ExtensionAPI) {
229
253
  "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.",
230
254
  "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.",
231
255
  "Keep using bash for quick commands whose output you need right away, such as git, ls, cat, grep, find, file inspection and short checks.",
232
- "To react when a bg_run job prints something specific while it keeps running (a server is ready, an epoch finished, an error appeared), pass wake_on with a regex instead of polling.",
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.",
233
257
  "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.",
234
258
  "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.",
235
259
  "Use bg_status to check progress of a bg_run job and bg_kill to stop one.",
236
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.`,
237
261
  "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.",
238
- "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; pass wake_on matching the lines you want to see, such as results or Traceback.",
262
+ "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.",
239
263
  ],
240
264
  // Runs after the edits in the same reply, so the job starts from the code pi just wrote
241
265
  executionMode: "sequential",
242
266
  renderShell: "self",
243
- renderCall: (args: any, theme, context) => row(theme, `Background $ ${String(args?.command ?? "").split("\n")[0]}`, context),
267
+ renderCall: (args: any, theme, context) => {
268
+ const command = String(args?.command ?? "");
269
+ // Collapsed, without leading settings such as PYTHONPATH=/some/src, like the jobs line; ctrl+o shows them
270
+ const short = shortCommand(command, args?.cwd ?? process.cwd()).replace(/^(\w+=("[^"]*"|'[^']*'|\S*)\s+)+/, "");
271
+ return row(theme, `Background $ ${short}`, context, `Background $ ${command.trim()}`);
272
+ },
244
273
  renderResult: (result, { expanded }, theme) => note(theme, result, expanded),
245
274
  parameters: Type.Object({
246
275
  command: Type.String({ description: "Shell command to run" }),
247
276
  cwd: Type.Optional(Type.String({ description: "Working directory (defaults to the session cwd)" })),
248
277
  wake_on: Type.Optional(
249
278
  Type.String({
250
- description: "Regex; also wake you while the job runs whenever an output line matches, e.g. 'listening on|ready' or 'epoch \\d+'. Matches within a second are batched; at most 10 wake-ups per job.",
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.",
251
280
  }),
252
281
  ),
253
282
  }),
@@ -293,7 +322,6 @@ export default function (pi: ExtensionAPI) {
293
322
  });
294
323
  });
295
324
 
296
- if (ctx.mode === "tui") ui = ctx.ui;
297
325
  showJobs();
298
326
  return { content: [{ type: "text", text: `Started ${id}; log: ${log}. You will be notified when it exits.` }], details: { id, log } };
299
327
  },
@@ -395,6 +423,12 @@ export default function (pi: ExtensionAPI) {
395
423
 
396
424
  // After /reload or a resume, carry on after the ids already in the conversation so "bg3" keeps meaning one job
397
425
  pi.on("session_start", async (_event, ctx) => {
426
+ if (ctx.mode === "tui") {
427
+ ctx.ui.setWidget("background-jobs", (tui, theme) => {
428
+ redrawJobs = () => tui.requestRender();
429
+ return jobsWidget(theme);
430
+ });
431
+ }
398
432
  for (const entry of ctx.sessionManager.getBranch()) {
399
433
  const msg = entry.type === "message" ? (entry.message as any) : undefined;
400
434
  const n = Number(/^bg(\d+)$/.exec(msg?.role === "toolResult" && msg.toolName === "bg_run" ? String(msg.details?.id) : "")?.[1]);
@@ -403,6 +437,7 @@ export default function (pi: ExtensionAPI) {
403
437
  });
404
438
 
405
439
  pi.on("session_shutdown", async () => {
440
+ clearInterval(ticker);
406
441
  for (const timer of holds.values()) clearTimeout(timer);
407
442
  holds.clear();
408
443
  for (const job of jobs.values()) {
@@ -0,0 +1,22 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { basename, isAbsolute, resolve } from "node:path";
3
+
4
+ // Real paths, so a cd through a symlink such as /tmp still counts as cwd
5
+ const real = (path: string) => {
6
+ try {
7
+ return realpathSync(path);
8
+ } catch {
9
+ return path;
10
+ }
11
+ };
12
+
13
+ /** A shell command's first line, without leading cds into cwd; another folder shows as its last part when the path is absolute. */
14
+ export function shortCommand(command: string, cwd: string): string {
15
+ const rest = command.trim().replace(/^(?:cd\s+(?:"[^"]*"|'[^']*'|\S+)[ \t]*(?:&&|;|\n)\s*)+/, (prefix) => {
16
+ const [, quoted, separator] = [...prefix.matchAll(/cd\s+("[^"]*"|'[^']*'|\S+)[ \t]*(&&|;|\n)/g)].at(-1)!;
17
+ const dir = quoted.replace(/^["']|["']$/g, "");
18
+ if (real(resolve(cwd, dir)) === real(resolve(cwd))) return "";
19
+ return `cd ${isAbsolute(dir) ? `…/${basename(dir)}` : quoted}${separator === ";" ? ";" : " &&"} `;
20
+ });
21
+ return rest.split("\n")[0];
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-background",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "license": "MIT",
5
5
  "author": "Thomas van Dongen",
6
6
  "type": "module",