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/README.md +16 -12
- package/docs/DEVELOPMENT.md +4 -2
- package/docs/WORKFLOWS.md +2 -1
- package/extensions/index.ts +141 -36
- package/package.json +1 -1
- package/src/activity-view.ts +252 -0
- package/src/composer.ts +30 -9
- package/src/delegate.ts +150 -15
- package/src/footer.ts +105 -18
- package/src/icons.ts +57 -0
- package/src/plan.ts +1 -0
- package/src/prompt-search.ts +4 -2
- package/src/session-gallery.ts +8 -3
- package/src/session-ui.ts +106 -41
- package/src/settings-ui.ts +5 -2
- package/src/settings.ts +5 -1
- package/src/shells.ts +6 -83
- package/src/status.ts +2 -0
- package/src/welcome.ts +32 -12
- package/src/working.ts +3 -1
package/src/shells.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import type { ExtensionAPI
|
|
3
|
-
import {
|
|
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
|
-
|
|
192
|
-
|
|
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 =
|
|
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 ? "
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
return [...art
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|