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
@@ -0,0 +1,98 @@
1
+ import type { Quota, QuotaWindow } from "./quota.ts";
2
+ import type { SideCall } from "./side-model.ts";
3
+ import { bar, duration, money, row, tokens, type Paint } from "./panel.ts";
4
+
5
+ export interface ModelUsage { label: string; cost: number; input: number; output: number; cacheRead: number; cacheWrite: number; calls: number }
6
+ export interface UsageStats { started?: number; prompts: number; responses: number; models: ModelUsage[]; side: ModelUsage[]; total: ModelUsage }
7
+
8
+ type Entry = { type: string; timestamp?: string; message?: { role?: string; provider?: string; model?: string;
9
+ usage?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; cost?: { total?: number } } } };
10
+
11
+ const empty = (label: string): ModelUsage => ({ label, cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, calls: 0 });
12
+ const num = (value: unknown) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
13
+ function add(target: ModelUsage, usage: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; cost?: number }): void {
14
+ target.input += num(usage.input); target.output += num(usage.output); target.cacheRead += num(usage.cacheRead);
15
+ target.cacheWrite += num(usage.cacheWrite); target.cost += num(usage.cost); target.calls++;
16
+ }
17
+
18
+ /** Session totals from the active branch plus pi-jar's own side calls (advisor, commit, ...). */
19
+ export function collectUsage(entries: readonly Entry[], side: readonly SideCall[]): UsageStats {
20
+ const models = new Map<string, ModelUsage>();
21
+ const sideModels = new Map<string, ModelUsage>();
22
+ const total = empty("total");
23
+ let started: number | undefined;
24
+ let prompts = 0;
25
+ let responses = 0;
26
+ for (const entry of entries) {
27
+ const at = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
28
+ if (Number.isFinite(at) && (started === undefined || at < started)) started = at;
29
+ const message = entry.type === "message" ? entry.message : undefined;
30
+ if (message?.role === "user") prompts++;
31
+ if (message?.role !== "assistant" || !message.usage) continue;
32
+ responses++;
33
+ const label = `${message.provider ?? "?"}/${message.model ?? "?"}`;
34
+ const usage = { ...message.usage, cost: message.usage.cost?.total };
35
+ add(models.get(label) ?? models.set(label, empty(label)).get(label)!, usage);
36
+ add(total, usage);
37
+ }
38
+ for (const call of side) {
39
+ const label = `${call.role} → ${call.model}`;
40
+ add(sideModels.get(label) ?? sideModels.set(label, empty(label)).get(label)!, call);
41
+ add(total, call);
42
+ }
43
+ const byCost = (a: ModelUsage, b: ModelUsage) => b.cost - a.cost || b.calls - a.calls;
44
+ return { ...(started !== undefined ? { started } : {}), prompts, responses, models: [...models.values()].sort(byCost), side: [...sideModels.values()].sort(byCost), total };
45
+ }
46
+
47
+ function resetText(window: QuotaWindow, now: number): string {
48
+ if (!window.resetsAt) return "";
49
+ const at = window.resetsAt < 1e12 ? window.resetsAt * 1000 : window.resetsAt;
50
+ const date = new Date(at);
51
+ const soon = at - now < 24 * 3_600_000;
52
+ const when = date.toLocaleString(undefined, soon ? { hour: "numeric", minute: "2-digit" } : { weekday: "short", hour: "numeric", minute: "2-digit" });
53
+ return `Resets ${when} (in ${duration(Math.max(0, at - now))})`;
54
+ }
55
+
56
+ export interface UsageView { stats: UsageStats; provider?: string; quota?: Quota; quotaEnabled: boolean; now: number }
57
+
58
+ /** Claude-style usage page: session totals, per-model breakdown, then plan limit bars. */
59
+ export function usageLines(view: UsageView, width: number, fg: Paint): string[] {
60
+ const { stats, now } = view;
61
+ const heading = (text: string) => fg("accent", text);
62
+ const inner = Math.max(20, width - 2);
63
+ const tokenLine = (usage: ModelUsage) => [`${tokens(usage.input)} in`, `${tokens(usage.output)} out`,
64
+ ...(usage.cacheRead ? [`${tokens(usage.cacheRead)} cache read`] : []), ...(usage.cacheWrite ? [`${tokens(usage.cacheWrite)} cache write`] : [])].join(" · ");
65
+ const lines = [
66
+ heading("Session"),
67
+ " " + row("Total cost", money(stats.total.cost), inner - 2),
68
+ " " + row("Total duration", stats.started !== undefined ? duration(now - stats.started) : "—", inner - 2),
69
+ " " + row("Turns", `${stats.prompts} prompt${stats.prompts === 1 ? "" : "s"} · ${stats.responses} response${stats.responses === 1 ? "" : "s"}`, inner - 2),
70
+ " " + row("Tokens", tokenLine(stats.total), inner - 2),
71
+ "",
72
+ heading("Usage by model")
73
+ ];
74
+ if (!stats.models.length && !stats.side.length) lines.push(fg("dim", " No model calls yet in this session."));
75
+ for (const usage of [...stats.models, ...stats.side]) {
76
+ const calls = stats.side.includes(usage) ? ` · ${usage.calls} call${usage.calls === 1 ? "" : "s"}` : "";
77
+ lines.push(" " + row(usage.label, fg("success", money(usage.cost)), inner - 2), fg("dim", " " + tokenLine(usage) + calls));
78
+ }
79
+ lines.push("", heading(`Plan usage limits${view.provider ? " · " + view.provider : ""}`));
80
+ const windows: [string, QuotaWindow | undefined][] = [["Current session (5h)", view.quota?.fiveHour], ["Current week", view.quota?.week]];
81
+ if (!windows.some(([, window]) => window)) {
82
+ lines.push(fg("dim", view.provider === "anthropic" || view.provider === "openai-codex"
83
+ ? view.quotaEnabled ? " Loading limits… reopen in a moment." : " Limit lookup is off; enable it with /jar quota on."
84
+ : " Plan limits are available for anthropic and openai-codex subscriptions."));
85
+ return lines;
86
+ }
87
+ for (const [label, window] of windows) {
88
+ if (!window) continue;
89
+ const used = Math.round(window.used);
90
+ const color = used >= 90 ? "error" : used >= 70 ? "warning" : "accent";
91
+ const suffix = ` ${used}% used`;
92
+ lines.push(" " + label, " " + bar(fg, window.used, Math.min(50, inner - 4 - suffix.length), color) + suffix);
93
+ const reset = resetText(window, now);
94
+ if (reset) lines.push(fg("dim", " " + reset));
95
+ lines.push("");
96
+ }
97
+ return lines;
98
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** Sum the active session branch, not all historical branches, using Pi's reported monetary cost. */
4
+ export function sessionCost(ctx: Pick<ExtensionContext, "sessionManager">): number {
5
+ let total = 0;
6
+ for (const entry of ctx.sessionManager.getBranch()) {
7
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
8
+ const value = entry.message.usage.cost.total;
9
+ if (Number.isFinite(value) && value >= 0) total += value;
10
+ }
11
+ return total;
12
+ }
13
+
14
+ export function formatCost(value: number): string {
15
+ return `cost $${value < 0.01 && value > 0 ? value.toFixed(4) : value.toFixed(2)}`;
16
+ }
package/src/welcome.ts ADDED
@@ -0,0 +1,260 @@
1
+ import { sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { FLAME_RAMP, FLAME_WIDTH, flameFrame, rgb, supportsTruecolor } from "./flame.ts";
3
+ import { cleanText } from "./status.ts";
4
+
5
+ export type WelcomeAction = "settings" | "refresh" | "roles" | "plan" | "goal" | `resume:${number}`;
6
+
7
+ export interface WelcomeInfo {
8
+ version?: string;
9
+ model?: string;
10
+ effort?: string;
11
+ activeRole?: string;
12
+ project?: string;
13
+ context?: string;
14
+ cost?: string;
15
+ managers?: readonly ("tasks" | "subagents")[];
16
+ quotaEnabled?: boolean;
17
+ quota?: number;
18
+ /** Publisher-supplied live roles (other extensions, subagents). */
19
+ roles?: readonly { name: string; state: string; task?: string }[];
20
+ tasks?: number;
21
+ nextTask?: string;
22
+ plan?: { enabled: boolean; title?: string; steps: number };
23
+ goal?: string;
24
+ rolesSummary?: string;
25
+ branch?: string;
26
+ dirty?: boolean;
27
+ message?: string;
28
+ /** Pi fullscreen mode delivers mouse events; otherwise keyboard hints are shown. */
29
+ settingsClickable?: boolean;
30
+ flameSeed?: number;
31
+ /** Recent sessions for this project (loaded in the background). */
32
+ recent?: readonly { title: string; age: string; goal?: string; plan?: string }[];
33
+ }
34
+
35
+ type WelcomeColor = "accent" | "warning" | "error" | "muted" | "dim";
36
+ type Paint = (color: WelcomeColor, text: string) => string;
37
+
38
+ export const HOPEFUL_WELCOME_MESSAGES = [
39
+ "A small spark is enough to begin.",
40
+ "Keep the light on. There is a way through.",
41
+ "Even long nights end. One clear step at a time.",
42
+ "Carry the light forward. The path will meet you.",
43
+ "Steady hands, warm light, good work ahead.",
44
+ "The next clear step is enough for now.",
45
+ "Leave a little light for the work ahead.",
46
+ "Every useful thing starts as a small spark."
47
+ ] as const;
48
+
49
+ export function hopefulWelcomeMessage(random: () => number = Math.random, previous?: string): string {
50
+ const pick = () => {
51
+ const value = random();
52
+ return HOPEFUL_WELCOME_MESSAGES[Math.min(HOPEFUL_WELCOME_MESSAGES.length - 1,
53
+ Math.max(0, Math.floor((Number.isFinite(value) ? value : 0) * HOPEFUL_WELCOME_MESSAGES.length)))]!;
54
+ };
55
+ let message = pick();
56
+ // Refresh should visibly change the message.
57
+ for (let attempt = 0; previous && message === previous && attempt < 4; attempt++) message = pick();
58
+ return message;
59
+ }
60
+
61
+ const PI_LARGE = [
62
+ " ▄▄▄▄▄▄▄▄▄▄▄ ",
63
+ " ▄█████████████▄ ",
64
+ " ██ ██ ",
65
+ " ██ ██ ",
66
+ " ██ ██ ",
67
+ " ██ ██ ",
68
+ " ▄██▄ ▄██▄ "
69
+ ] as const;
70
+ const PI_COMPACT = [
71
+ " ▄▄▄▄▄▄▄ ",
72
+ " ▄███████▄ ",
73
+ " █ █ ",
74
+ " █ █ ",
75
+ " ▄█ █▄ "
76
+ ] as const;
77
+
78
+ const ART_WIDTH = 28;
79
+ const ACTIONS: readonly { action: WelcomeAction; label: string; hint: string }[] = [
80
+ { action: "settings", label: "[ ⚙ Settings ]", hint: "ctrl+alt+s settings" },
81
+ { action: "refresh", label: "[ ↻ Refresh ]", hint: "ctrl+alt+r refresh" },
82
+ { action: "roles", label: "[ ◆ Roles ]", hint: "/roles" },
83
+ { action: "plan", label: "[ ▤ Plan ]", hint: "/plan" },
84
+ { action: "goal", label: "[ ◎ Goal ]", hint: "/goal" }
85
+ ];
86
+
87
+ const center = (line: string, width: number) => {
88
+ const room = Math.max(0, width - visibleWidth(line));
89
+ const left = Math.floor(room / 2);
90
+ return " ".repeat(left) + line + " ".repeat(room - left);
91
+ };
92
+ const fixedCell = (line: string, width: number) => {
93
+ const value = truncateToWidth(line, width);
94
+ return value + " ".repeat(Math.max(0, width - visibleWidth(value)));
95
+ };
96
+
97
+ /** Word-wrap plain text into lines no wider than `width` columns. */
98
+ function wrap(text: string, width: number, space = " "): string[] {
99
+ const lines: string[] = [];
100
+ for (const word of text.split(" ").filter(Boolean)) {
101
+ const last = lines.length - 1;
102
+ if (last >= 0 && visibleWidth(lines[last]! + space + word) <= width) lines[last] += space + word;
103
+ else lines.push(word);
104
+ }
105
+ return lines;
106
+ }
107
+
108
+ /** Double-width (fullwidth) forms of printable ASCII: the closest a terminal gets to larger type. */
109
+ export function fullwidth(text: string): string {
110
+ return [...text].map((char) => {
111
+ const code = char.charCodeAt(0);
112
+ return code > 0x20 && code < 0x7f ? String.fromCharCode(code + 0xfee0) : char;
113
+ }).join("");
114
+ }
115
+
116
+ /**
117
+ * The welcome message as a hero: bold, flame-colored and double-width when it fits in
118
+ * three lines, otherwise bold at normal width. Returns centered lines of exactly `width`.
119
+ */
120
+ export function heroMessage(message: string, width: number, fg: Paint, truecolor = supportsTruecolor()): string[] {
121
+ const text = cleanText(message, 100);
122
+ const big = wrap(fullwidth(text), width - 2, " ");
123
+ const lines = big.length <= 3 && big.every((line) => visibleWidth(line) <= width - 2) ? big : wrap(text, width - 2);
124
+ return lines.map((line) => {
125
+ const chars = [...line];
126
+ // Pale gold at the start cooling to orange, like the flame's core.
127
+ const painted = chars.map((char, index) => {
128
+ if (char === " ") return char;
129
+ const heat = 9 - Math.round((index / Math.max(1, chars.length - 1)) * 3);
130
+ return truecolor ? rgb(FLAME_RAMP[heat]!, char) : fg("warning", char);
131
+ }).join("");
132
+ return center("\x1b[1m" + painted + "\x1b[22m", width);
133
+ });
134
+ }
135
+
136
+ /** Pack chips into rows no wider than `width`. */
137
+ function pack(items: readonly string[], width: number, gap = " "): string[] {
138
+ const rows: string[] = [];
139
+ for (const item of items) {
140
+ const last = rows.length - 1;
141
+ if (last >= 0 && visibleWidth(rows[last]!) + visibleWidth(gap) + visibleWidth(item) <= width) rows[last] += gap + item;
142
+ else rows.push(item);
143
+ }
144
+ return rows;
145
+ }
146
+
147
+ function card(width: number, fg: Paint, info: WelcomeInfo): string[] {
148
+ const w = Math.max(12, width);
149
+ const inner = w - 4;
150
+ const narrow = w < 48;
151
+ const cell = (value: string) => {
152
+ const text = truncateToWidth(value, inner);
153
+ return fg("dim", "│ ") + text + " ".repeat(Math.max(0, inner - visibleWidth(text))) + fg("dim", " │");
154
+ };
155
+ const divider = fg("dim", "├" + "─".repeat(w - 2) + "┤");
156
+ const labelWidth = narrow ? 8 : 10;
157
+ const label = (name: string, value: string, color: WelcomeColor = "muted") =>
158
+ fg("dim", name.padEnd(labelWidth)) + fg(color, cleanText(value, 120));
159
+
160
+ const header = [fg("accent", "pi-jar" + (info.version ? " " + info.version : "")),
161
+ info.model ? fg("muted", cleanText(info.model, 40)) : fg("dim", "no model"),
162
+ info.effort && info.effort !== "off" ? fg("muted", info.effort) : "",
163
+ info.activeRole ? fg("warning", "role:" + cleanText(info.activeRole, 16)) : ""].filter(Boolean).join(fg("dim", " · "));
164
+
165
+ const git = info.branch ? "git " + cleanText(info.branch, 24) + (info.dirty ? " · dirty" : "") : "git unavailable";
166
+ const quota = info.quota != null ? `quota ${Math.round(info.quota)}%` : `quota ${info.quotaEnabled ? "unavailable" : "off"}`;
167
+ const plan = info.plan?.enabled ? "◆ planning" + (info.plan.title ? " · " + info.plan.title : "")
168
+ : info.plan?.title ? "last · " + info.plan.title + ` (${info.plan.steps} steps)` : "none · /plan <idea>";
169
+ const tasks = info.tasks ? `${info.tasks} open${info.nextTask ? " · next: " + info.nextTask : ""}` : "none open";
170
+ const live = info.roles?.filter((role) => role.name !== "assistant") ?? [];
171
+ const lead = info.roles?.find((item) => ["working", "thinking", "reviewing", "failed"].includes(item.state));
172
+ const managers = info.managers?.length ? info.managers.map((m) => m === "tasks" ? "/tasks" : "/subagents-fleet").join(" · ") : "";
173
+ const team = [lead ? `${cleanText(lead.name, 16)} ${lead.state}` : "", live.length ? `${live.length} subagent${live.length === 1 ? "" : "s"}` : "",
174
+ lead?.task ? cleanText(lead.task, 30) : "", managers].filter(Boolean).join(" · ");
175
+
176
+ const actions = info.settingsClickable === false
177
+ ? pack(ACTIONS.map((item) => item.hint), inner, fg("dim", " · ")).map((row) => fg("dim", row))
178
+ : pack(ACTIONS.map((item) => fg(item.action === "settings" ? "accent" : "muted", item.label)), inner);
179
+ return [
180
+ fg("dim", "╭" + "─".repeat(w - 2) + "╮"),
181
+ cell(header),
182
+ divider,
183
+ cell(""),
184
+ ...heroMessage(info.message ?? HOPEFUL_WELCOME_MESSAGES[0], inner, fg).map(cell),
185
+ cell(center(fg("dim", "— welcome to ") + fg("accent", "pi-jar") + fg("dim", " —"), inner)),
186
+ cell(""),
187
+ divider,
188
+ cell(label("PROJECT", (info.project || "unavailable") + " · " + git, info.dirty ? "warning" : "muted")),
189
+ cell(label("SESSION", [info.context ?? "ctx ?", quota, info.cost ?? ""].filter(Boolean).join(" · "))),
190
+ divider,
191
+ cell(label("PLAN", plan, info.plan?.enabled ? "warning" : "muted")),
192
+ cell(label("GOAL", info.goal ?? "none · /goal <outcome>", info.goal ? "accent" : "muted")),
193
+ cell(label("TASKS", tasks, info.tasks ? "accent" : "muted")),
194
+ cell(label("ROLES", info.rolesSummary ?? "all roles follow the current model")),
195
+ ...(team ? [cell(label("TEAM", team, lead?.state === "failed" ? "error" : "muted"))] : []),
196
+ ...(info.recent?.length ? [divider, ...info.recent.map((session, index) => cell(
197
+ fg("dim", (index ? "" : "RECENT").padEnd(labelWidth)) + fg("accent", `↺ ${index + 1} `) + fg("muted", cleanText(session.title, 60))
198
+ + fg("dim", " · " + session.age + (session.goal ? " · goal: " + session.goal : "") + (session.plan ? " · plan: " + session.plan : ""))
199
+ )), ...(info.settingsClickable === false ? [cell(fg("dim", " ".repeat(labelWidth) + "/jar resume N to continue"))] : [])] : []),
200
+ divider,
201
+ ...actions.map(cell),
202
+ fg("dim", "╰" + "─".repeat(w - 2) + "╯")
203
+ ];
204
+ }
205
+
206
+ /** Which welcome action (if any) sits under a zero-based cell in the rendered lines. */
207
+ export function welcomeHit(lines: readonly string[], x: number, y: number): WelcomeAction | undefined {
208
+ const line = lines[y];
209
+ if (!line) return undefined;
210
+ const text = stripTerminalSequences(line);
211
+ // A whole RECENT row resumes that session.
212
+ const recent = /│ (?:RECENT)?\s*↺ (\d) /.exec(text);
213
+ if (recent && x >= visibleWidth(text.slice(0, recent.index))) return `resume:${Number(recent[1])}`;
214
+ for (const item of ACTIONS) {
215
+ const offset = text.indexOf(item.label);
216
+ if (offset < 0) continue;
217
+ const start = visibleWidth(text.slice(0, offset));
218
+ if (x >= start && x < start + visibleWidth(item.label)) return item.action;
219
+ }
220
+ return undefined;
221
+ }
222
+
223
+ /** Back-compat helper: true when the Settings chip is under the pointer. */
224
+ export function welcomeSettingsHit(lines: readonly string[], x: number, y: number): boolean {
225
+ return welcomeHit(lines, x, y) === "settings";
226
+ }
227
+
228
+ export function welcomeLines(width: number, frame: number, fg: Paint, info: WelcomeInfo = {}): string[] {
229
+ if (width <= 0) return [];
230
+ const fit = (line: string) => truncateToWidth(line, width);
231
+ const seed = info.flameSeed ?? 1;
232
+ const flame = flameFrame(frame, seed, (color, text) => fg(color, text));
233
+ if (width < 32) {
234
+ const start = Math.max(0, Math.floor((FLAME_WIDTH - width) / 2));
235
+ return [
236
+ ...flame.slice(4).map((line) => fit(sliceByColumn(line, start, Math.min(width, FLAME_WIDTH)))),
237
+ ...PI_COMPACT.map((line) => fit(fg("accent", line))),
238
+ fit(fg("accent", "pi-jar")),
239
+ ...wrap(cleanText(info.message ?? HOPEFUL_WELCOME_MESSAGES[0], 100), width).map((line) => fit("\x1b[1m" + fg("warning", line) + "\x1b[22m")),
240
+ fit(fg(info.settingsClickable === false ? "dim" : "accent", info.settingsClickable === false ? "ctrl+alt+s settings" : "[ ⚙ Settings ]")),
241
+ ""
242
+ ];
243
+ }
244
+ const wide = width >= 72;
245
+ const flameArt = flame.map((line) => center(fixedCell(line, FLAME_WIDTH), ART_WIDTH));
246
+ 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), ""];
250
+ }
251
+ 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);
253
+ const topPad = Math.max(0, Math.floor((details.length - art.length) / 2));
254
+ const artRows = [...Array.from({ length: topPad }, () => " ".repeat(ART_WIDTH)), ...art];
255
+ const merged = Array.from({ length: Math.max(artRows.length, details.length) }, (_, index) => {
256
+ const left = artRows[index] ?? " ".repeat(ART_WIDTH);
257
+ return fit(left + " " + (details[index] ?? ""));
258
+ });
259
+ return [...merged, "", ""];
260
+ }
package/src/working.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { cleanText } from "./status.ts";
2
+
3
+ export type WorkingPhase = "idle" | "generating" | "tool" | "waiting";
4
+ export type WorkingColor = "accent" | "warning" | "success" | "muted";
5
+ export interface WorkingView { message?: string; frames: string[]; color: WorkingColor }
6
+
7
+ const PROGRESSION = [
8
+ "A spark remains…", "Kindling a thought…", "Following the faint light…",
9
+ "Gathering scattered sparks…", "The path is beginning to glow…",
10
+ "There is a way through…", "A clearer path is forming…", "The horizon is clearer now…"
11
+ ] as const;
12
+ const STAGES = [0, 8, 20, 35, 50, 70, 90, 120] as const;
13
+ function duration(seconds: number): string {
14
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
15
+ }
16
+ function tokenCount(tokens: number): string {
17
+ return tokens < 1000 ? `${tokens}` : `${(tokens / 1000).toFixed(1)}k`;
18
+ }
19
+
20
+ /** A label describes only observed Pi lifecycle activity, never hidden reasoning. */
21
+ export class WorkingState {
22
+ private generating = false;
23
+ private waiting = false;
24
+ private tools = new Map<string, string>();
25
+ private startedAt?: number;
26
+ private outputTokens = 0;
27
+ get phase(): WorkingPhase {
28
+ if (this.waiting) return "waiting";
29
+ if (this.tools.size) return "tool";
30
+ return this.generating ? "generating" : "idle";
31
+ }
32
+ start(now = Date.now()): void {
33
+ if (this.startedAt === undefined) { this.startedAt = now; this.outputTokens = 0; }
34
+ this.generating = true; this.waiting = false;
35
+ }
36
+ end(): void { this.generating = false; this.waiting = false; this.tools.clear(); this.startedAt = undefined; this.outputTokens = 0; }
37
+ reportOutputTokens(tokens: number): void {
38
+ if (this.startedAt !== undefined && Number.isSafeInteger(tokens) && tokens >= 0) this.outputTokens += tokens;
39
+ }
40
+ prompt(open: boolean): void { this.waiting = open; }
41
+ toolStart(id: string, name: string): void { this.tools.set(id, cleanText(name, 24) || "tool"); }
42
+ toolEnd(id: string): void { this.tools.delete(id); }
43
+ /** `task` is the running jar_todo's active form; like Claude, it replaces the generic wording. */
44
+ view(animations: boolean, fg: (color: WorkingColor, text: string) => string, now = Date.now(), effort?: string, task?: string): WorkingView {
45
+ const phase = this.phase;
46
+ const activity = task ? cleanText(task, 60).replace(/[.…]+$/, "") : "";
47
+ const elapsed = this.startedAt === undefined ? 0 : Math.max(0, Math.floor((now - this.startedAt) / 1000));
48
+ const stage = STAGES.reduce<number>((index, threshold, next) => elapsed >= threshold ? next : index, 0);
49
+ const word = activity && (phase === "tool" || phase === "generating") ? activity + "…"
50
+ : phase === "tool" ? `Tracing light through ${[...this.tools.values()][0] ?? "tool"}…`
51
+ : phase === "generating" ? PROGRESSION[Math.max(0, stage)]
52
+ : phase === "waiting" ? "Holding the lantern for your answer…" : undefined;
53
+ const color: WorkingColor = phase === "tool" ? "warning" : phase === "waiting" ? "muted" : "accent";
54
+ const symbols = phase === "tool" ? ["◐", "◓", "◑", "◒"] : phase === "generating" ? ["✢", "✣", "✤", "✣"] : ["✢"];
55
+ const frames = animations ? symbols.map((symbol) => fg(color, symbol)) : [fg(color, symbols[0] ?? "✢")];
56
+ const details = phase === "idle" || this.startedAt === undefined ? "" :
57
+ ` (${duration(elapsed)}${this.outputTokens ? ` · ↓ ${tokenCount(this.outputTokens)} tokens` : ""}${effort && effort !== "off" ? ` · ${effort} effort` : ""})`;
58
+ return { message: word ? fg(color, word) + fg("muted", details) : undefined, frames, color };
59
+ }
60
+ }
@@ -0,0 +1,84 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
3
+ "name": "pi-jar-dark-amber",
4
+ "vars": {
5
+ "accent": "#F7B955",
6
+ "blue": "#8DB9FF",
7
+ "green": "#48DDAE",
8
+ "red": "#FF8A70",
9
+ "amber": "#FFC060",
10
+ "text": "#F7FAFF",
11
+ "muted": "#ADBCCD",
12
+ "dim": "#899AAD",
13
+ "border": "#405269",
14
+ "selected": "#3A2B0F",
15
+ "userBg": "#16202E",
16
+ "pendingBg": "#182231",
17
+ "successBg": "#15352E",
18
+ "errorBg": "#382623",
19
+ "customBg": "#172233"
20
+ },
21
+ "colors": {
22
+ "accent": "accent",
23
+ "border": "border",
24
+ "borderAccent": "accent",
25
+ "borderMuted": "dim",
26
+ "success": "green",
27
+ "error": "red",
28
+ "warning": "amber",
29
+ "muted": "muted",
30
+ "dim": "dim",
31
+ "text": "text",
32
+ "thinkingText": "blue",
33
+ "selectedBg": "selected",
34
+ "scrollbarTrack": "border",
35
+ "scrollbarThumb": "muted",
36
+ "searchMatchBg": "selected",
37
+ "searchMatchText": "text",
38
+ "userMessageBg": "userBg",
39
+ "userMessageText": "text",
40
+ "customMessageBg": "customBg",
41
+ "customMessageText": "text",
42
+ "customMessageLabel": "accent",
43
+ "toolPendingBg": "pendingBg",
44
+ "toolSuccessBg": "successBg",
45
+ "toolErrorBg": "errorBg",
46
+ "toolTitle": "accent",
47
+ "toolOutput": "muted",
48
+ "mdHeading": "amber",
49
+ "mdLink": "blue",
50
+ "mdLinkUrl": "dim",
51
+ "mdCode": "accent",
52
+ "mdCodeBlock": "green",
53
+ "mdCodeBlockBorder": "border",
54
+ "mdQuote": "muted",
55
+ "mdQuoteBorder": "border",
56
+ "mdHr": "border",
57
+ "mdListBullet": "accent",
58
+ "toolDiffAdded": "green",
59
+ "toolDiffRemoved": "red",
60
+ "toolDiffContext": "muted",
61
+ "syntaxComment": "#8A9CA6",
62
+ "syntaxKeyword": "blue",
63
+ "syntaxFunction": "#B5A67A",
64
+ "syntaxVariable": "#9FBAC1",
65
+ "syntaxString": "#9FB58C",
66
+ "syntaxNumber": "#C4A77D",
67
+ "syntaxType": "accent",
68
+ "syntaxOperator": "text",
69
+ "syntaxPunctuation": "muted",
70
+ "thinkingOff": "dim",
71
+ "thinkingMinimal": "#8A9CA6",
72
+ "thinkingLow": "#668C9B",
73
+ "thinkingMedium": "accent",
74
+ "thinkingHigh": "#A28BB8",
75
+ "thinkingXhigh": "#B887B5",
76
+ "thinkingMax": "#C78FC0",
77
+ "bashMode": "green"
78
+ },
79
+ "export": {
80
+ "pageBg": "#0B1018",
81
+ "cardBg": "#111927",
82
+ "infoBg": "#3A2E12"
83
+ }
84
+ }
@@ -0,0 +1,84 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
3
+ "name": "pi-jar-dark-azure",
4
+ "vars": {
5
+ "accent": "#75BCFF",
6
+ "blue": "#8DB9FF",
7
+ "green": "#48DDAE",
8
+ "red": "#FF8A70",
9
+ "amber": "#FFC060",
10
+ "text": "#F7FAFF",
11
+ "muted": "#ADBCCD",
12
+ "dim": "#899AAD",
13
+ "border": "#405269",
14
+ "selected": "#132A41",
15
+ "userBg": "#16202E",
16
+ "pendingBg": "#182231",
17
+ "successBg": "#15352E",
18
+ "errorBg": "#382623",
19
+ "customBg": "#172233"
20
+ },
21
+ "colors": {
22
+ "accent": "accent",
23
+ "border": "border",
24
+ "borderAccent": "accent",
25
+ "borderMuted": "dim",
26
+ "success": "green",
27
+ "error": "red",
28
+ "warning": "amber",
29
+ "muted": "muted",
30
+ "dim": "dim",
31
+ "text": "text",
32
+ "thinkingText": "blue",
33
+ "selectedBg": "selected",
34
+ "scrollbarTrack": "border",
35
+ "scrollbarThumb": "muted",
36
+ "searchMatchBg": "selected",
37
+ "searchMatchText": "text",
38
+ "userMessageBg": "userBg",
39
+ "userMessageText": "text",
40
+ "customMessageBg": "customBg",
41
+ "customMessageText": "text",
42
+ "customMessageLabel": "accent",
43
+ "toolPendingBg": "pendingBg",
44
+ "toolSuccessBg": "successBg",
45
+ "toolErrorBg": "errorBg",
46
+ "toolTitle": "accent",
47
+ "toolOutput": "muted",
48
+ "mdHeading": "amber",
49
+ "mdLink": "blue",
50
+ "mdLinkUrl": "dim",
51
+ "mdCode": "accent",
52
+ "mdCodeBlock": "green",
53
+ "mdCodeBlockBorder": "border",
54
+ "mdQuote": "muted",
55
+ "mdQuoteBorder": "border",
56
+ "mdHr": "border",
57
+ "mdListBullet": "accent",
58
+ "toolDiffAdded": "green",
59
+ "toolDiffRemoved": "red",
60
+ "toolDiffContext": "muted",
61
+ "syntaxComment": "#8A9CA6",
62
+ "syntaxKeyword": "blue",
63
+ "syntaxFunction": "#B5A67A",
64
+ "syntaxVariable": "#9FBAC1",
65
+ "syntaxString": "#9FB58C",
66
+ "syntaxNumber": "#C4A77D",
67
+ "syntaxType": "accent",
68
+ "syntaxOperator": "text",
69
+ "syntaxPunctuation": "muted",
70
+ "thinkingOff": "dim",
71
+ "thinkingMinimal": "#8A9CA6",
72
+ "thinkingLow": "#668C9B",
73
+ "thinkingMedium": "accent",
74
+ "thinkingHigh": "#A28BB8",
75
+ "thinkingXhigh": "#B887B5",
76
+ "thinkingMax": "#C78FC0",
77
+ "bashMode": "green"
78
+ },
79
+ "export": {
80
+ "pageBg": "#0B1018",
81
+ "cardBg": "#111927",
82
+ "infoBg": "#3A2E12"
83
+ }
84
+ }