pi-jar 0.1.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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +288 -0
  3. package/docs/DESIGN.md +85 -0
  4. package/docs/DEVELOPMENT.md +95 -0
  5. package/docs/FLAME.md +54 -0
  6. package/docs/INTEGRATIONS.md +82 -0
  7. package/docs/PLAN.md +68 -0
  8. package/docs/SHOWCASE.md +22 -0
  9. package/docs/WORKFLOWS.md +141 -0
  10. package/extensions/index.ts +773 -0
  11. package/package.json +62 -0
  12. package/src/accent.ts +36 -0
  13. package/src/advisor.ts +194 -0
  14. package/src/animations.ts +23 -0
  15. package/src/ask-tool.ts +185 -0
  16. package/src/attachments.ts +79 -0
  17. package/src/changes.ts +146 -0
  18. package/src/commit.ts +61 -0
  19. package/src/compact-tools.ts +129 -0
  20. package/src/composer.ts +404 -0
  21. package/src/context-view.ts +141 -0
  22. package/src/delegate.ts +208 -0
  23. package/src/dialogs.ts +127 -0
  24. package/src/diff-view.ts +160 -0
  25. package/src/flame.ts +245 -0
  26. package/src/footer-settings.ts +32 -0
  27. package/src/footer.ts +147 -0
  28. package/src/goal-loop.ts +313 -0
  29. package/src/goals.ts +114 -0
  30. package/src/history-ui.ts +136 -0
  31. package/src/history.ts +168 -0
  32. package/src/info-panels.ts +75 -0
  33. package/src/mascot.ts +103 -0
  34. package/src/model-roles.ts +332 -0
  35. package/src/panel.ts +90 -0
  36. package/src/plan-utils.ts +191 -0
  37. package/src/plan-view.ts +149 -0
  38. package/src/plan.ts +460 -0
  39. package/src/prompt-search.ts +83 -0
  40. package/src/quota.ts +134 -0
  41. package/src/roles-ui.ts +190 -0
  42. package/src/roles.ts +25 -0
  43. package/src/session-gallery.ts +52 -0
  44. package/src/session-ui.ts +76 -0
  45. package/src/settings-ui.ts +154 -0
  46. package/src/settings.ts +96 -0
  47. package/src/shells.ts +319 -0
  48. package/src/side-model.ts +48 -0
  49. package/src/split-view.ts +74 -0
  50. package/src/status.ts +71 -0
  51. package/src/suggest.ts +89 -0
  52. package/src/task-tool.ts +135 -0
  53. package/src/tasks-ui.ts +73 -0
  54. package/src/tasks.ts +147 -0
  55. package/src/usage-view.ts +98 -0
  56. package/src/usage.ts +16 -0
  57. package/src/welcome.ts +260 -0
  58. package/src/working.ts +60 -0
  59. package/themes/pi-jar-dark-amber.json +84 -0
  60. package/themes/pi-jar-dark-azure.json +84 -0
  61. package/themes/pi-jar-dark-gray.json +84 -0
  62. package/themes/pi-jar-dark-pink.json +84 -0
  63. package/themes/pi-jar-dark-teal.json +84 -0
  64. package/themes/pi-jar-dark-violet.json +84 -0
  65. package/themes/pi-jar-dark.json +84 -0
package/src/history.ts ADDED
@@ -0,0 +1,168 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** A read-only snapshot: index only visible entries; never copy large tool outputs. */
4
+ export interface HistorySnapshot {
5
+ readonly entries: readonly SessionEntry[];
6
+ readonly visible: readonly number[];
7
+ }
8
+
9
+ export interface HistoryItem {
10
+ readonly entry: SessionEntry;
11
+ readonly sequence: number;
12
+ readonly actor: string;
13
+ readonly time?: string;
14
+ readonly summary: string;
15
+ readonly search: string;
16
+ readonly error: boolean;
17
+ readonly expandable: boolean;
18
+ }
19
+
20
+ export const HISTORY_PAGE_SIZE = 80;
21
+ export const HISTORY_CHUNK_SIZE = 2048;
22
+
23
+ /** Remove terminal escapes, bidi spoofing, zero-width marks and C0/C1 controls. */
24
+ export function safeHistoryText(text: string, limit = HISTORY_CHUNK_SIZE): string {
25
+ return text.slice(0, Math.max(0, limit) + 128)
26
+ .replace(/\r/g, "\n")
27
+ .replace(/\x1b(?:\][\s\S]*?(?:\x07|\x1b\\|$)|\[[0-?]*[ -/]*[@-~]|[P_^X][\s\S]*?(?:\x1b\\|$)|.)/g, "")
28
+ .replace(/\x9d[\s\S]*?(?:\x9c|$)|\x9b[0-?]*[ -/]*[@-~]/g, "")
29
+ .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff]/g, "")
30
+ .replace(/\t/g, " ")
31
+ .slice(0, limit);
32
+ }
33
+
34
+ function visibleEntry(entry: SessionEntry): boolean {
35
+ if (entry.type === "compaction" || entry.type === "branch_summary") return true;
36
+ if (entry.type === "custom_message") return entry.display === true;
37
+ if (entry.type !== "message") return false; // Skip future entry types and private metadata.
38
+ const role = entry.message?.role;
39
+ return role === "user" || role === "assistant" || role === "toolResult" || role === "bashExecution" || role === "custom" && entry.message.display === true;
40
+ }
41
+
42
+ export function createHistorySnapshot(entries: readonly SessionEntry[]): HistorySnapshot {
43
+ const visible: number[] = [];
44
+ for (let i = 0; i < entries.length; i++) if (visibleEntry(entries[i]!)) visible.push(i);
45
+ return { entries, visible };
46
+ }
47
+
48
+ function* contentParts(content: unknown): Generator<string> {
49
+ if (typeof content === "string") { yield content; return; }
50
+ if (!Array.isArray(content)) return;
51
+ for (const part of content) {
52
+ if (!part || typeof part !== "object") continue;
53
+ if (part.type === "text" && typeof part.text === "string") yield part.text;
54
+ else if (part.type === "image") yield "[image]"; // Never read or render image data.
55
+ else if (part.type === "toolCall") {
56
+ const name = typeof part.name === "string" ? part.name : "tool";
57
+ const args: unknown = part.arguments;
58
+ const details = args && typeof args === "object" ? args as Record<string, unknown> : {};
59
+ const hint = ["command", "path", "file_path"].map((key) => details[key]).find((value) => typeof value === "string");
60
+ yield `\n$ ${name}${typeof hint === "string" ? ` · ${hint.slice(0, 256)}` : ""}`;
61
+ }
62
+ // Thinking blocks, signatures and other private/non-text payloads are deliberately excluded.
63
+ }
64
+ }
65
+
66
+ function* entryParts(entry: SessionEntry): Generator<string> {
67
+ if (entry.type === "compaction" || entry.type === "branch_summary") { yield typeof entry.summary === "string" ? entry.summary : "[unavailable summary]"; return; }
68
+ if (entry.type === "custom_message") { yield* contentParts(entry.content); return; }
69
+ if (entry.type !== "message") return;
70
+ const message = entry.message;
71
+ if (message.role === "bashExecution") {
72
+ yield `$ ${typeof message.command === "string" ? message.command.slice(0, 4096) : "[unknown command]"}\n`;
73
+ if (typeof message.output === "string") yield message.output;
74
+ } else if (message.role === "assistant") {
75
+ yield* contentParts(message.content);
76
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
77
+ yield `\n[${message.stopReason}] ${message.errorMessage ?? ""}`;
78
+ }
79
+ } else if (message.role === "user" || message.role === "toolResult" || message.role === "custom") {
80
+ yield* contentParts(message.content);
81
+ }
82
+ }
83
+
84
+ function excerpt(entry: SessionEntry, limit: number, offset = 0): { text: string; raw: string; more: boolean } {
85
+ let remaining = Math.max(0, offset);
86
+ let text = "";
87
+ let more = false;
88
+ for (const part of entryParts(entry)) {
89
+ if (remaining >= part.length) { remaining -= part.length; continue; }
90
+ const from = remaining;
91
+ remaining = 0;
92
+ const available = limit - text.length;
93
+ if (available <= 0) { more = true; break; }
94
+ // Splitting a UTF-16 pair across chunks must never print a stray surrogate.
95
+ let slice = part.slice(from, from + available);
96
+ if (slice.length && /[\ud800-\udbff]/.test(slice.at(-1)!)) slice = slice.slice(0, -1);
97
+ if (slice.length && /[\udc00-\udfff]/.test(slice[0]!)) slice = slice.slice(1);
98
+ text += slice;
99
+ if (from + available < part.length) { more = true; break; }
100
+ }
101
+ return { text: safeHistoryText(text, limit), raw: text, more };
102
+ }
103
+
104
+ function stamp(value: unknown): string | undefined {
105
+ const date = typeof value === "string" ? new Date(value) : typeof value === "number" ? new Date(value) : null;
106
+ if (!date || !Number.isFinite(date.getTime())) return undefined;
107
+ return date.toISOString().slice(0, 16).replace("T", " ") + "Z";
108
+ }
109
+
110
+ export function historyItem(entry: SessionEntry, sequence: number): HistoryItem {
111
+ let actor = "NOTE";
112
+ let error = false;
113
+ if (entry.type === "compaction") actor = "COMPACT";
114
+ else if (entry.type === "branch_summary") actor = "BRANCH";
115
+ else if (entry.type === "message") {
116
+ const message = entry.message;
117
+ if (message.role === "user") actor = "YOU";
118
+ else if (message.role === "assistant") {
119
+ actor = "ASSISTANT";
120
+ error = message.stopReason === "error" || message.stopReason === "aborted";
121
+ } else if (message.role === "toolResult") { actor = `TOOL ${message.toolName}`; error = message.isError; }
122
+ else if (message.role === "bashExecution") { actor = "SHELL"; error = message.exitCode !== undefined && message.exitCode !== 0; }
123
+ }
124
+ const raw = excerpt(entry, 1024);
125
+ const search = raw.text.replace(/\s+/g, " ").trim();
126
+ const summary = search.slice(0, 240) || (entry.type === "message" && entry.message.role === "assistant" ? "[no visible text]" : "[empty]");
127
+ const time = stamp(entry.timestamp) ?? (entry.type === "message" ? stamp(entry.message.timestamp) : undefined);
128
+ return { entry, sequence, actor: safeHistoryText(actor, 48), time, summary, search, error, expandable: raw.more || search.length > 240 || entry.type === "message" && (entry.message.role === "toolResult" || entry.message.role === "bashExecution") };
129
+ }
130
+
131
+ export function historyPage(snapshot: HistorySnapshot, page = 0, size = HISTORY_PAGE_SIZE): { items: HistoryItem[]; page: number; totalPages: number } {
132
+ const count = snapshot.visible.length;
133
+ const pageSize = Math.max(1, Math.min(400, Math.floor(size) || HISTORY_PAGE_SIZE));
134
+ const totalPages = Math.max(1, Math.ceil(count / pageSize));
135
+ const at = Math.max(0, Math.min(totalPages - 1, Math.floor(page) || 0));
136
+ const start = Math.max(0, count - (at + 1) * pageSize);
137
+ const end = count - at * pageSize;
138
+ const items = snapshot.visible.slice(start, end).map((index, i) => historyItem(snapshot.entries[index]!, start + i + 1));
139
+ return { items, page: at, totalPages };
140
+ }
141
+
142
+ /** Fetch only the requested 2 KiB / 30-line segment; nextOffset never skips hidden lines. */
143
+ export function historyChunk(item: HistoryItem, offset = 0): { lines: string[]; more: boolean; nextOffset: number; trailingNewline: boolean } {
144
+ const at = Math.max(0, Math.min(200_000_000, Math.floor(offset) || 0));
145
+ const { raw, more } = excerpt(item.entry, HISTORY_CHUNK_SIZE, at);
146
+ // Limit UTF-8 bytes as well as UTF-16 code units (CJK/emoji can exceed 2 KiB).
147
+ let end = raw.length;
148
+ if (Buffer.byteLength(raw, "utf8") > HISTORY_CHUNK_SIZE) {
149
+ let low = 0;
150
+ let high = raw.length;
151
+ while (low < high) {
152
+ const middle = Math.ceil((low + high) / 2);
153
+ if (Buffer.byteLength(raw.slice(0, middle), "utf8") <= HISTORY_CHUNK_SIZE) low = middle;
154
+ else high = middle - 1;
155
+ }
156
+ end = low;
157
+ if (end && /[\ud800-\udbff]/.test(raw[end - 1]!)) end--;
158
+ }
159
+ let linesSeen = 0;
160
+ for (let i = 0; i < end; i++) {
161
+ if (raw[i] === "\n" && ++linesSeen === 30) { end = i + 1; break; }
162
+ }
163
+ const safe = safeHistoryText(raw.slice(0, end), HISTORY_CHUNK_SIZE);
164
+ const trailingNewline = safe.endsWith("\n");
165
+ const lines = safe.split("\n");
166
+ if (trailingNewline) lines.pop();
167
+ return { lines, more: more || end < raw.length, nextOffset: at + end, trailingNewline };
168
+ }
@@ -0,0 +1,75 @@
1
+ import { getAgentDir, SettingsManager, type ExtensionAPI, type ExtensionContext, type Skill } from "@earendil-works/pi-coding-agent";
2
+ import { contextBreakdown, contextLines } from "./context-view.ts";
3
+ import { openPanel, type PanelTab } from "./panel.ts";
4
+ import type { Quota } from "./quota.ts";
5
+ import type { SideUsage } from "./side-model.ts";
6
+ import { collectUsage, usageLines } from "./usage-view.ts";
7
+
8
+ /** Pi's default compaction reserve, used when its settings cannot be read. */
9
+ const DEFAULT_RESERVE = 16_384;
10
+
11
+ export interface InfoPanelDeps {
12
+ side: SideUsage;
13
+ quota(ctx: ExtensionContext): Quota | undefined;
14
+ quotaEnabled(): boolean;
15
+ }
16
+
17
+ function reserveTokens(ctx: ExtensionContext): number {
18
+ try {
19
+ const settings = SettingsManager.create(ctx.cwd, getAgentDir());
20
+ if (!settings.getCompactionEnabled()) return 0;
21
+ return settings.getCompactionReserveTokens(ctx.model ?? undefined);
22
+ } catch (error) {
23
+ ctx.ui.notify("pi-jar: could not read compaction settings, assuming the default buffer: " + String(error), "warning");
24
+ return DEFAULT_RESERVE;
25
+ }
26
+ }
27
+
28
+ /** Context files and skills as the last prompt saw them (captured on before_agent_start). */
29
+ export interface PromptParts { contextFiles: readonly { path: string; content: string }[]; skills: readonly Skill[] }
30
+
31
+ export function contextFor(pi: ExtensionAPI, ctx: ExtensionContext, options?: PromptParts) {
32
+ const usage = ctx.getContextUsage();
33
+ const active = new Set(pi.getActiveTools());
34
+ return contextBreakdown({
35
+ model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "no model",
36
+ window: usage?.contextWindow ?? ctx.model?.contextWindow ?? 0,
37
+ reported: usage?.tokens ?? null,
38
+ systemPrompt: ctx.getSystemPrompt(),
39
+ contextFiles: options?.contextFiles ?? [],
40
+ skills: (options?.skills ?? []).filter((skill) => !skill.disableModelInvocation),
41
+ tools: pi.getAllTools().filter((tool) => active.has(tool.name)),
42
+ messages: ctx.sessionManager.buildSessionProjection().messages as never,
43
+ reserve: reserveTokens(ctx)
44
+ });
45
+ }
46
+
47
+ /** `/usage` and `/context`: one tabbed panel, like Claude's usage and context views. */
48
+ export function registerInfoPanels(pi: ExtensionAPI, deps: InfoPanelDeps): void {
49
+ let parts: PromptParts | undefined;
50
+ pi.on("before_agent_start", (event) => {
51
+ parts = { contextFiles: event.systemPromptOptions.contextFiles ?? [], skills: event.systemPromptOptions.skills ?? [] };
52
+ });
53
+ pi.on("session_start", () => { parts = undefined; });
54
+ const tabs = (ctx: ExtensionContext): PanelTab[] => {
55
+ const context = contextFor(pi, ctx, parts);
56
+ return [
57
+ { name: "Usage", render: (width, fg) => usageLines({
58
+ stats: collectUsage(ctx.sessionManager.getBranch() as never, deps.side.all()),
59
+ ...(ctx.model?.provider ? { provider: ctx.model.provider } : {}),
60
+ ...(deps.quota(ctx) ? { quota: deps.quota(ctx)! } : {}),
61
+ quotaEnabled: deps.quotaEnabled(), now: Date.now() }, width, fg) },
62
+ { name: "Context", render: (width, fg) => contextLines(context, width, fg) }
63
+ ];
64
+ };
65
+ const plain = (_color: string, text: string) => text;
66
+ const open = async (ctx: ExtensionContext, tab: number) => {
67
+ try {
68
+ const pages = tabs(ctx);
69
+ if (ctx.hasUI && ctx.mode === "tui") await openPanel(ctx, pages, tab);
70
+ else ctx.ui.notify(pages[tab]!.render(100, plain).join("\n"), "info");
71
+ } catch (error) { ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error"); }
72
+ };
73
+ pi.registerCommand("usage", { description: "Session cost, tokens per model and plan limits (pi-jar)", handler: async (_args, ctx) => open(ctx, 0) });
74
+ pi.registerCommand("context", { description: "Breakdown of what fills the context window (pi-jar)", handler: async (_args, ctx) => open(ctx, 1) });
75
+ }
package/src/mascot.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { FLAME_RAMP, rgb, supportsTruecolor } from "./flame.ts";
2
+ import type { WorkingPhase } from "./working.ts";
3
+
4
+ /**
5
+ * Ember, the composer's mascot: a tiny flame that perches on the input's top border.
6
+ * Its face sits inside the border and its flickering tips sit on the row above.
7
+ */
8
+ export type MascotMood = "idle" | "blink" | "happy" | "thinking" | "tool" | "waiting" | "sleepy" | "error" | "complete" | "poke";
9
+ export type MascotPaint = (color: "accent" | "warning" | "error" | "muted" | "dim", text: string) => string;
10
+
11
+ const FACES: Record<MascotMood, string> = {
12
+ idle: "•ᴗ•", blink: "-ᴗ-", happy: "^ᴗ^", thinking: "°ᴗ°", tool: ">ᴗ<",
13
+ waiting: "•o•", sleepy: "-ω-", error: "×_×", complete: "★ᴗ★", poke: "^o^"
14
+ };
15
+ /** Five-column flame tips drawn above the face; `*` is a spark, `z` a sleepy puff. */
16
+ const TIPS: Record<MascotMood, readonly string[]> = {
17
+ idle: [" ▴▲▴ ", " ▲▴ ", " ▴▲ "],
18
+ blink: [" ▴▲▴ "],
19
+ happy: [" ▴▲▴ ", "▴ ▲ ▴"],
20
+ thinking: [" ▴▲∙ ", " ▴▲ ∙", "∙ ▲▴ ", " ∙▲▴ "],
21
+ tool: [" *▲* ", " ·▲· ", "* ▲ *"],
22
+ waiting: [" ▴▲▴?", " ▴▲▴ "],
23
+ sleepy: [" ▴ z", " ▴ "],
24
+ error: [" ˇ▲ˇ ", " ˇ▴ˇ "],
25
+ complete: [" *★* ", "* ★ *"],
26
+ poke: ["▴ ▲ ▴", " ▴▲▴ "]
27
+ };
28
+ export const MASCOT_FACE_WIDTH = 5;
29
+ export const MASCOT_TIP_WIDTH = 5;
30
+ export const SLEEPY_AFTER_MS = 120_000;
31
+ const BLINK_MS = 180;
32
+
33
+ export function mascotFace(mood: MascotMood): string { return "(" + FACES[mood] + ")"; }
34
+
35
+ export class Mascot {
36
+ private phase: WorkingPhase = "idle";
37
+ private lastActive: number;
38
+ private nextBlink: number;
39
+ private flashMood: { mood: MascotMood; until: number } | undefined;
40
+ private readonly random: () => number;
41
+
42
+ constructor(now = Date.now(), random: () => number = Math.random) {
43
+ this.random = random;
44
+ this.lastActive = now;
45
+ this.nextBlink = now + this.blinkGap();
46
+ }
47
+
48
+ private blinkGap(): number { return 3000 + Math.floor(this.random() * 3000); }
49
+
50
+ setPhase(phase: WorkingPhase, now = Date.now()): void {
51
+ if (phase !== "idle" || this.phase !== "idle") this.lastActive = now;
52
+ this.phase = phase;
53
+ }
54
+
55
+ /** Show a short-lived expression (error, goal complete, poke). */
56
+ flash(mood: "error" | "complete" | "poke", ms: number, now = Date.now()): void {
57
+ this.flashMood = { mood, until: now + ms };
58
+ this.lastActive = now;
59
+ }
60
+
61
+ mood(now = Date.now()): MascotMood {
62
+ if (this.flashMood && now < this.flashMood.until) return this.flashMood.mood;
63
+ this.flashMood = undefined;
64
+ if (this.phase === "generating") return Math.floor(now / 2400) % 3 === 2 ? "thinking" : "happy";
65
+ if (this.phase === "tool") return "tool";
66
+ if (this.phase === "waiting") return "waiting";
67
+ if (now - this.lastActive >= SLEEPY_AFTER_MS) return "sleepy";
68
+ if (now >= this.nextBlink) {
69
+ if (now < this.nextBlink + BLINK_MS) return "blink";
70
+ this.nextBlink = now + this.blinkGap();
71
+ }
72
+ return "idle";
73
+ }
74
+
75
+ face(now = Date.now()): string { return mascotFace(this.mood(now)); }
76
+
77
+ tip(now = Date.now(), frame = 0): string {
78
+ const tips = TIPS[this.mood(now)];
79
+ return tips[((frame % tips.length) + tips.length) % tips.length]!;
80
+ }
81
+
82
+ /** Visual identity for change detection (avoids needless renders). */
83
+ key(now = Date.now(), frame = 0): string { return this.mood(now) + ":" + this.tip(now, frame); }
84
+ }
85
+
86
+ /** Colorize the face; truecolor terminals get the flame palette, others theme colors. */
87
+ export function paintFace(face: string, mood: MascotMood, paint?: MascotPaint, truecolor = supportsTruecolor()): string {
88
+ const shell = mood === "error" ? FLAME_RAMP[4] : FLAME_RAMP[6];
89
+ const eyes = mood === "error" ? FLAME_RAMP[5] : mood === "sleepy" ? FLAME_RAMP[7] : FLAME_RAMP[9];
90
+ if (!truecolor) return paint ? paint(mood === "error" ? "error" : "warning", face) : face;
91
+ return rgb(shell!, face[0]!) + rgb(eyes!, face.slice(1, -1)) + rgb(shell!, face.at(-1)!);
92
+ }
93
+
94
+ export function paintTip(tip: string, mood: MascotMood, paint?: MascotPaint, truecolor = supportsTruecolor()): string {
95
+ if (!truecolor) return paint ? paint(mood === "sleepy" ? "dim" : "warning", tip) : tip;
96
+ return [...tip].map((char) => {
97
+ if (char === " ") return char;
98
+ if (char === "▲" || char === "★") return rgb(mood === "error" ? FLAME_RAMP[5]! : FLAME_RAMP[8]!, char);
99
+ if (char === "*" || char === "∙" || char === "·") return rgb(FLAME_RAMP[9]!, char);
100
+ if (char === "z" || char === "?") return rgb(FLAME_RAMP[7]!, char);
101
+ return rgb(mood === "sleepy" ? FLAME_RAMP[4]! : FLAME_RAMP[6]!, char);
102
+ }).join("");
103
+ }