pi-jar 0.1.2 → 0.1.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.
package/src/shells.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { Key, matchesKey, Text, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
5
  import { safeLine } from "./diff-view.ts";
6
6
  import { cleanText } from "./status.ts";
7
- import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
8
7
 
9
8
  export const SHELL_TOOL = "jar_shell";
10
9
  export const SHELL_MESSAGE = "pi-jar.shell";
@@ -188,13 +187,14 @@ export class ShellManager {
188
187
  }
189
188
 
190
189
  type ShellMeta = Omit<ShellJob, "lines">;
191
- const elapsed = (job: ShellMeta | ShellJob, now = Date.now()) => {
192
- const seconds = Math.max(0, Math.round(((job.endedAt ?? now) - job.startedAt) / 1000));
190
+ /** `12s` / `3m 4s` since `startedAt`, up to `endedAt` once finished. */
191
+ export const elapsed = (span: { startedAt: number; endedAt?: number }, now = Date.now()) => {
192
+ const seconds = Math.max(0, Math.round(((span.endedAt ?? now) - span.startedAt) / 1000));
193
193
  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
194
194
  };
195
195
  export const shellState = (job: ShellMeta | ShellJob) => job.status === "running" ? "running"
196
196
  : job.status === "exited" ? `exited ${job.exitCode ?? "?"}` : job.status === "failed" ? `failed: ${cleanText(job.error ?? "", 60)}` : "killed";
197
- const describe = (job: ShellMeta | ShellJob) => `${job.id} · ${job.name} · ${shellState(job)} · ${elapsed(job)}${job.watch ? ` · watch /${job.watch}/${job.matched ? " matched" : ""}` : ""}`;
197
+ export const describe = (job: ShellMeta | ShellJob) => `${job.id} · ${job.name} · ${shellState(job)} · ${elapsed(job)}${job.watch ? ` · watch /${job.watch}/${job.matched ? " matched" : ""}` : ""}`;
198
198
  const boundedTail = (lines: readonly string[], limit = MAX_TOOL_OUTPUT_CHARS): string => {
199
199
  let size = 0;
200
200
  const kept: string[] = [];
@@ -295,80 +295,3 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
295
295
  }
296
296
  });
297
297
  }
298
-
299
- const SHELL_ACTIONS = [
300
- { key: "x", label: "Kill the selected shell" },
301
- { key: "f", label: "Follow the latest output" }
302
- ] as const;
303
-
304
- /** Overlay: shells on the left, the selected shell's live output on the right; k kills. */
305
- export async function openShellsView(ctx: ExtensionContext, manager: ShellManager): Promise<void> {
306
- if (!ctx.hasUI || ctx.mode !== "tui") return;
307
- if (!manager.summaries().length) { ctx.ui.notify("pi-jar: no background shells (the agent starts them with jar_shell)", "info"); return; }
308
- await ctx.ui.custom<void>((tui, theme, _keys, done) => {
309
- let selected = 0;
310
- let follow = true;
311
- let scroll = 0;
312
- let rows = 0;
313
- let width = 80;
314
- let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
315
- const fg = (color: string, text: string) => theme.fg(color as never, text);
316
- const previous = manager.onChange;
317
- const timer = setInterval(() => tui.requestRender(), 1000);
318
- timer.unref?.();
319
- manager.onChange = () => { previous?.(); tui.requestRender(); };
320
- const close = () => { clearInterval(timer); manager.onChange = previous; done(); };
321
- const component = {
322
- invalidate() {},
323
- handleInput(data: string) {
324
- const jobs = manager.summaries();
325
- if (matchesKey(data, Key.escape) || data === "q") return close();
326
- if (matchesKey(data, Key.up) || data === "k") selected = Math.max(0, selected - 1);
327
- else if (matchesKey(data, Key.down) || data === "j") selected = Math.min(jobs.length - 1, selected + 1);
328
- else if (data === "x" || data === "K") { const job = jobs[selected]; if (job) { try { manager.kill(job.id); } catch (error) { ctx.ui.notify("pi-jar: " + String(error), "error"); } } }
329
- else if (matchesKey(data, Key.pageUp)) { follow = false; scroll = Math.max(0, scroll - Math.max(1, rows - 2)); }
330
- else if (matchesKey(data, Key.pageDown)) scroll += Math.max(1, rows - 2);
331
- else if (data === "f" || data === "G") follow = true;
332
- tui.requestRender();
333
- },
334
- handleMouse(event: TuiMouseEvent) {
335
- const row = event.y - layout.top;
336
- if (event.type === "wheel" && event.wheelDelta) { follow = false; scroll = Math.max(0, scroll + Math.sign(event.wheelDelta) * 3); tui.requestRender(); return { handled: true }; }
337
- if (event.type !== "click" || event.button !== "left") return;
338
- if (event.y === 0 && event.x >= width - 3) { close(); return { handled: true }; }
339
- if (row >= 0 && row < layout.rows && layout.leftWidth && event.x < layout.leftWidth + 3 && row < manager.summaries().length) { selected = row; follow = true; tui.requestRender(); return { handled: true, focus: true }; }
340
- const action = SHELL_ACTIONS[event.y - layout.footerTop];
341
- if (action) { component.handleInput(action.key); return { handled: true }; }
342
- },
343
- render(available: number): string[] {
344
- width = Math.max(24, available);
345
- rows = contentRows(6 + SHELL_ACTIONS.length, 6);
346
- const jobs = manager.summaries();
347
- selected = Math.min(selected, Math.max(0, jobs.length - 1));
348
- const job = jobs[selected];
349
- const listWidth = sidebarWidth(width, 20, 34);
350
- const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
351
- const output = job ? manager.output(job.id, 400) : [];
352
- const maxScroll = Math.max(0, output.length - rows);
353
- scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
354
- if (scroll >= maxScroll) follow = true;
355
- const list = jobs.slice(0, rows).map((item, index) => {
356
- const color = item.status === "running" ? "accent" : item.status === "exited" && item.exitCode === 0 ? "success" : item.status === "killed" ? "dim" : "error";
357
- const glyph = item.status === "running" ? "●" : item.status === "exited" && item.exitCode === 0 ? "✔" : item.status === "killed" ? "■" : "✖";
358
- return fg(index === selected ? "accent" : "muted", (index === selected ? "▌" : " ")) + fg(color, glyph + " ") + fg(index === selected ? "accent" : "muted", truncateToWidth(`${item.id} ${item.name}`, Math.max(4, listWidth - 4)));
359
- });
360
- const body = output.slice(scroll, scroll + rows).map((line) => fg("muted", truncateToWidth(safeLine(line), bodyWidth)));
361
- if (job && !output.length) body.push(fg("dim", "(no output yet)"));
362
- const title = `⚙ SHELLS · ${manager.running()} running` + (job ? ` · ${describe(job)}` : "");
363
- const footer = [
364
- ...optionList(theme, SHELL_ACTIONS.map((item) => item.label), -1, SHELL_ACTIONS.map((item) => item.key)),
365
- fg("dim", (job ? "$ " + truncateToWidth(cleanText(job.command, 400), Math.max(8, width - 60)) + " " : "") + "↑↓ shell · PgUp/PgDn scroll · Esc close")
366
- ];
367
- const split = splitFrame(theme, width, title, list, body, footer, rows, listWidth);
368
- layout = split.layout;
369
- return split.lines;
370
- }
371
- };
372
- return component;
373
- }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
374
- }
package/src/status.ts CHANGED
@@ -62,6 +62,8 @@ export function collectStatuses(statuses: ReadonlyMap<string, string>, now: numb
62
62
  const key = cleanText(rawKey, 48);
63
63
  if (!key || typeof rawValue !== "string") continue;
64
64
  if (rawKey.startsWith("pi-jar.quota.")) continue;
65
+ // SoL-Pi already announces each saving in its own notification; reprinting it here duplicates it.
66
+ if (rawKey.startsWith("sol-pi")) continue;
65
67
  if (!rawKey.startsWith(ROLE_PREFIX)) {
66
68
  const value = cleanText(rawValue);
67
69
  // pi-jar's own indicators (such as `role:default`) are already self-describing.
package/src/welcome.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { FLAME_RAMP, FLAME_WIDTH, flameFrame, rgb, supportsTruecolor } from "./flame.ts";
3
3
  import { cleanText } from "./status.ts";
4
+ import { iconSet, withIcon } from "./icons.ts";
4
5
 
5
6
  export type WelcomeAction = "settings" | "refresh" | "roles" | "plan" | "goal" | `resume:${number}`;
6
7
 
@@ -89,8 +90,11 @@ const center = (line: string, width: number) => {
89
90
  const left = Math.floor(room / 2);
90
91
  return " ".repeat(left) + line + " ".repeat(room - left);
91
92
  };
93
+ // truncateToWidth re-segments every grapheme even when a line already fits; visibleWidth is
94
+ // memoized by Pi, so measure first and only truncate real overflow (same output either way).
95
+ const fitTo = (line: string, width: number) => visibleWidth(line) <= width ? line : truncateToWidth(line, width);
92
96
  const fixedCell = (line: string, width: number) => {
93
- const value = truncateToWidth(line, width);
97
+ const value = fitTo(line, width);
94
98
  return value + " ".repeat(Math.max(0, width - visibleWidth(value)));
95
99
  };
96
100
 
@@ -157,12 +161,12 @@ function card(width: number, fg: Paint, info: WelcomeInfo): string[] {
157
161
  const label = (name: string, value: string, color: WelcomeColor = "muted") =>
158
162
  fg("dim", name.padEnd(labelWidth)) + fg(color, cleanText(value, 120));
159
163
 
160
- const header = [fg("accent", "pi-jar" + (info.version ? " " + info.version : "")),
161
- info.model ? fg("muted", cleanText(info.model, 40)) : fg("dim", "no model"),
164
+ const header = [fg("accent", withIcon("pi", "pi-jar" + (info.version ? " " + info.version : ""))),
165
+ info.model ? fg("muted", withIcon("model", cleanText(info.model, 40))) : fg("dim", "no model"),
162
166
  info.effort && info.effort !== "off" ? fg("muted", info.effort) : "",
163
167
  info.activeRole ? fg("warning", "role:" + cleanText(info.activeRole, 16)) : ""].filter(Boolean).join(fg("dim", " · "));
164
168
 
165
- const git = info.branch ? "git " + cleanText(info.branch, 24) + (info.dirty ? " · dirty" : "") : "git unavailable";
169
+ const git = info.branch ? withIcon("branch", cleanText(info.branch, 24)) + (info.dirty ? " · dirty" : "") : withIcon("branch", "unavailable");
166
170
  const quota = info.quota != null ? `quota ${Math.round(info.quota)}%` : `quota ${info.quotaEnabled ? "unavailable" : "off"}`;
167
171
  const plan = info.plan?.enabled ? "◆ planning" + (info.plan.title ? " · " + info.plan.title : "")
168
172
  : info.plan?.title ? "last · " + info.plan.title + ` (${info.plan.steps} steps)` : "none · /plan <idea>";
@@ -185,7 +189,7 @@ function card(width: number, fg: Paint, info: WelcomeInfo): string[] {
185
189
  cell(center(fg("dim", "— welcome to ") + fg("accent", "pi-jar") + fg("dim", " —"), inner)),
186
190
  cell(""),
187
191
  divider,
188
- cell(label("PROJECT", (info.project || "unavailable") + " · " + git, info.dirty ? "warning" : "muted")),
192
+ cell(label("PROJECT", withIcon("folder", info.project || "unavailable") + " · " + git, info.dirty ? "warning" : "muted")),
189
193
  cell(label("SESSION", [info.context ?? "ctx ?", quota, info.cost ?? ""].filter(Boolean).join(" · "))),
190
194
  divider,
191
195
  cell(label("PLAN", plan, info.plan?.enabled ? "warning" : "muted")),
@@ -203,6 +207,16 @@ function card(width: number, fg: Paint, info: WelcomeInfo): string[] {
203
207
  ];
204
208
  }
205
209
 
210
+ const CARD_COLORS: readonly WelcomeColor[] = ["accent", "warning", "error", "muted", "dim"];
211
+ let cardCache: { key: string; lines: string[] } | undefined;
212
+
213
+ /** The card only changes with its inputs and theme; the flame beside it repaints every tick. */
214
+ function cachedCard(width: number, fg: Paint, info: WelcomeInfo): string[] {
215
+ const key = `${width}\0${supportsTruecolor()}\0${iconSet()}\0${CARD_COLORS.map((color) => fg(color, "x")).join("")}\0${JSON.stringify(info)}`;
216
+ if (cardCache?.key !== key) cardCache = { key, lines: card(width, fg, info) };
217
+ return cardCache.lines;
218
+ }
219
+
206
220
  /** Which welcome action (if any) sits under a zero-based cell in the rendered lines. */
207
221
  export function welcomeHit(lines: readonly string[], x: number, y: number): WelcomeAction | undefined {
208
222
  const line = lines[y];
@@ -227,7 +241,7 @@ export function welcomeSettingsHit(lines: readonly string[], x: number, y: numbe
227
241
 
228
242
  export function welcomeLines(width: number, frame: number, fg: Paint, info: WelcomeInfo = {}): string[] {
229
243
  if (width <= 0) return [];
230
- const fit = (line: string) => truncateToWidth(line, width);
244
+ const fit = (line: string) => fitTo(line, width);
231
245
  const seed = info.flameSeed ?? 1;
232
246
  const flame = flameFrame(frame, seed, (color, text) => fg(color, text));
233
247
  if (width < 32) {
@@ -242,19 +256,25 @@ export function welcomeLines(width: number, frame: number, fg: Paint, info: Welc
242
256
  ];
243
257
  }
244
258
  const wide = width >= 72;
245
- const flameArt = flame.map((line) => center(fixedCell(line, FLAME_WIDTH), ART_WIDTH));
259
+ // FlameSim rows are exactly FLAME_WIDTH single-cell glyphs, so center them without measuring:
260
+ // re-measuring freshly colored rows every 150 ms tick was most of the welcome's render time.
261
+ const flameLeft = " ".repeat(Math.floor((ART_WIDTH - FLAME_WIDTH) / 2));
262
+ const flameRight = " ".repeat(ART_WIDTH - FLAME_WIDTH - flameLeft.length);
263
+ const flameArt = flame.map((line) => flameLeft + line + flameRight);
246
264
  if (!wide) {
247
- const art = [...flame.slice(2).map((line) => center(fixedCell(line, FLAME_WIDTH), Math.min(width, ART_WIDTH))),
248
- ...PI_COMPACT.map((line) => fg("accent", center(line, Math.min(width, ART_WIDTH))))];
249
- return [...art.map(fit), ...card(width, fg, info).map(fit), ""];
265
+ // width >= 32 here, so ART_WIDTH-wide art rows always fit.
266
+ const art = [...flameArt.slice(2), ...PI_COMPACT.map((line) => fg("accent", center(line, ART_WIDTH)))];
267
+ return [...art, ...cachedCard(width, fg, info).map(fit), ""];
250
268
  }
251
269
  const art = [...flameArt, ...PI_LARGE.map((line) => fg("accent", center(fixedCell(line, 21), ART_WIDTH)))];
252
- const details = card(width - ART_WIDTH - 2, fg, info);
270
+ const details = cachedCard(width - ART_WIDTH - 2, fg, info);
253
271
  const topPad = Math.max(0, Math.floor((details.length - art.length) / 2));
254
272
  const artRows = [...Array.from({ length: topPad }, () => " ".repeat(ART_WIDTH)), ...art];
255
273
  const merged = Array.from({ length: Math.max(artRows.length, details.length) }, (_, index) => {
256
274
  const left = artRows[index] ?? " ".repeat(ART_WIDTH);
257
- return fit(left + " " + (details[index] ?? ""));
275
+ const right = details[index] ?? "";
276
+ // Every art row is ART_WIDTH cells; the cached card rows hit Pi's width cache.
277
+ return ART_WIDTH + 2 + visibleWidth(right) <= width ? left + " " + right : truncateToWidth(left + " " + right, width);
258
278
  });
259
279
  return [...merged, "", ""];
260
280
  }
package/src/working.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { icon } from "./icons.ts";
1
2
  import { cleanText } from "./status.ts";
2
3
 
3
4
  export type WorkingPhase = "idle" | "generating" | "tool" | "waiting";
@@ -54,7 +55,8 @@ export class WorkingState {
54
55
  const symbols = phase === "tool" ? ["◐", "◓", "◑", "◒"] : phase === "generating" ? ["✢", "✣", "✤", "✣"] : ["✢"];
55
56
  const frames = animations ? symbols.map((symbol) => fg(color, symbol)) : [fg(color, symbols[0] ?? "✢")];
56
57
  const details = phase === "idle" || this.startedAt === undefined ? "" :
57
- ` (${duration(elapsed)}${this.outputTokens ? ` · ↓ ${tokenCount(this.outputTokens)} tokens` : ""}${effort && effort !== "off" ? ` · ${effort} effort` : ""})`;
58
+ ` (${duration(elapsed)}${this.outputTokens ? ` · ↓ ${tokenCount(this.outputTokens)} tokens` : ""}${effort && effort !== "off" ? ` · ${effort} effort` : ""}`
59
+ + `${phase === "waiting" ? "" : ` · ${icon("esc") === "esc" ? "esc" : icon("esc") + " esc"} to interrupt`})`;
58
60
  return { message: word ? fg(color, word) + fg("muted", details) : undefined, frames, color };
59
61
  }
60
62
  }