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.
- package/LICENSE +21 -0
- package/README.md +288 -0
- package/docs/DESIGN.md +85 -0
- package/docs/DEVELOPMENT.md +95 -0
- package/docs/FLAME.md +54 -0
- package/docs/INTEGRATIONS.md +82 -0
- package/docs/PLAN.md +68 -0
- package/docs/SHOWCASE.md +22 -0
- package/docs/WORKFLOWS.md +141 -0
- package/extensions/index.ts +773 -0
- package/package.json +62 -0
- package/src/accent.ts +36 -0
- package/src/advisor.ts +194 -0
- package/src/animations.ts +23 -0
- package/src/ask-tool.ts +185 -0
- package/src/attachments.ts +79 -0
- package/src/changes.ts +146 -0
- package/src/commit.ts +61 -0
- package/src/compact-tools.ts +129 -0
- package/src/composer.ts +404 -0
- package/src/context-view.ts +141 -0
- package/src/delegate.ts +208 -0
- package/src/dialogs.ts +127 -0
- package/src/diff-view.ts +160 -0
- package/src/flame.ts +245 -0
- package/src/footer-settings.ts +32 -0
- package/src/footer.ts +147 -0
- package/src/goal-loop.ts +313 -0
- package/src/goals.ts +114 -0
- package/src/history-ui.ts +136 -0
- package/src/history.ts +168 -0
- package/src/info-panels.ts +75 -0
- package/src/mascot.ts +103 -0
- package/src/model-roles.ts +332 -0
- package/src/panel.ts +90 -0
- package/src/plan-utils.ts +191 -0
- package/src/plan-view.ts +149 -0
- package/src/plan.ts +460 -0
- package/src/prompt-search.ts +83 -0
- package/src/quota.ts +134 -0
- package/src/roles-ui.ts +190 -0
- package/src/roles.ts +25 -0
- package/src/session-gallery.ts +52 -0
- package/src/session-ui.ts +76 -0
- package/src/settings-ui.ts +154 -0
- package/src/settings.ts +96 -0
- package/src/shells.ts +319 -0
- package/src/side-model.ts +48 -0
- package/src/split-view.ts +74 -0
- package/src/status.ts +71 -0
- package/src/suggest.ts +89 -0
- package/src/task-tool.ts +135 -0
- package/src/tasks-ui.ts +73 -0
- package/src/tasks.ts +147 -0
- package/src/usage-view.ts +98 -0
- package/src/usage.ts +16 -0
- package/src/welcome.ts +260 -0
- package/src/working.ts +60 -0
- package/themes/pi-jar-dark-amber.json +84 -0
- package/themes/pi-jar-dark-azure.json +84 -0
- package/themes/pi-jar-dark-gray.json +84 -0
- package/themes/pi-jar-dark-pink.json +84 -0
- package/themes/pi-jar-dark-teal.json +84 -0
- package/themes/pi-jar-dark-violet.json +84 -0
- package/themes/pi-jar-dark.json +84 -0
package/src/flame.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pixel flame: one continuous flame with a rounded base that tapers into a softly wavering
|
|
3
|
+
* tip, animated by smooth value noise and a gentle sway, drawn with half blocks (two simulated
|
|
4
|
+
* rows per terminal row) plus wisps, embers and sparks.
|
|
5
|
+
* Every frame is a pure function of (seed, frame) so tests and motion-off stay stable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Odd, so the flame is centered on a whole column (and on the π beneath it). */
|
|
9
|
+
export const FLAME_WIDTH = 21;
|
|
10
|
+
/** Simulated rows; rendered as FLAME_ROWS terminal rows. */
|
|
11
|
+
const SIM_ROWS = 24;
|
|
12
|
+
export const FLAME_ROWS = SIM_ROWS / 2;
|
|
13
|
+
const MAX_HEAT = 9;
|
|
14
|
+
const WARMUP = 28;
|
|
15
|
+
export const MAX_PARTICLES = 8;
|
|
16
|
+
/** Where the rounded base is widest (fraction of the flame's height) and its half width. */
|
|
17
|
+
const BELLY = 0.3;
|
|
18
|
+
const BELLY_HALF_WIDTH = 5.6;
|
|
19
|
+
const MAX_WISPS = 2;
|
|
20
|
+
|
|
21
|
+
/** Heat ramp from deep ember red to a pale-gold core. Index 0 is transparent. */
|
|
22
|
+
export const FLAME_RAMP = [
|
|
23
|
+
"", "#2B0A05", "#5C1407", "#8F2207", "#C7400C", "#E8601A", "#F58A24", "#FFB23A", "#FFD56A", "#FFF3C4"
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export type FlameColor = "accent" | "warning" | "error" | "muted" | "dim";
|
|
27
|
+
export type FlamePaint = (color: FlameColor, text: string) => string;
|
|
28
|
+
|
|
29
|
+
interface Particle { x: number; y: number; vy: number; drift: number; life: number; max: number; spark: boolean }
|
|
30
|
+
|
|
31
|
+
/** Deterministic PRNG (mulberry32). */
|
|
32
|
+
export function prng(seed: number): () => number {
|
|
33
|
+
let state = seed >>> 0;
|
|
34
|
+
return () => {
|
|
35
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
36
|
+
let value = state;
|
|
37
|
+
value = Math.imul(value ^ (value >>> 15), value | 1);
|
|
38
|
+
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
|
39
|
+
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hexRgb(hex: string): [number, number, number] | undefined {
|
|
44
|
+
const value = hex.replace("#", "");
|
|
45
|
+
if (value.length !== 6) return undefined;
|
|
46
|
+
return [0, 2, 4].map((at) => Number.parseInt(value.slice(at, at + 2), 16)) as [number, number, number];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function rgb(hex: string, text: string): string {
|
|
50
|
+
const color = hexRgb(hex);
|
|
51
|
+
return color ? `\x1b[38;2;${color[0]};${color[1]};${color[2]}m${text}\x1b[39m` : text;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cell(top: number, bottom: number): string {
|
|
55
|
+
const up = hexRgb(FLAME_RAMP[top] ?? "");
|
|
56
|
+
const down = hexRgb(FLAME_RAMP[bottom] ?? "");
|
|
57
|
+
if (up && down) return `\x1b[38;2;${up[0]};${up[1]};${up[2]}m\x1b[48;2;${down[0]};${down[1]};${down[2]}m▀\x1b[39m\x1b[49m`;
|
|
58
|
+
if (up) return `\x1b[38;2;${up[0]};${up[1]};${up[2]}m▀\x1b[39m`;
|
|
59
|
+
if (down) return `\x1b[38;2;${down[0]};${down[1]};${down[2]}m▄\x1b[39m`;
|
|
60
|
+
return " ";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Terminals without 24-bit color get shaded blocks in theme colors instead. */
|
|
64
|
+
function shadedCell(top: number, bottom: number, paint: FlamePaint): string {
|
|
65
|
+
const heat = Math.max(top, bottom);
|
|
66
|
+
if (!heat) return " ";
|
|
67
|
+
const glyph = heat >= 8 ? "█" : heat >= 6 ? "▓" : heat >= 4 ? "▒" : "░";
|
|
68
|
+
return paint(heat >= 7 ? "warning" : heat >= 4 ? "error" : "dim", glyph);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function supportsTruecolor(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
72
|
+
const value = (env.COLORTERM ?? "").toLowerCase();
|
|
73
|
+
return value === "truecolor" || value === "24bit" || /-direct$/.test(env.TERM ?? "");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Smooth 2D value noise in [0, 1) from a seeded integer lattice. */
|
|
77
|
+
function valueNoise(seed: number): (x: number, y: number) => number {
|
|
78
|
+
const lattice = (ix: number, iy: number) => {
|
|
79
|
+
let h = Math.imul(ix, 374761393) ^ Math.imul(iy, 668265263) ^ Math.imul(seed, 982451653);
|
|
80
|
+
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
|
81
|
+
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
|
82
|
+
};
|
|
83
|
+
const smooth = (t: number) => t * t * (3 - 2 * t);
|
|
84
|
+
return (x, y) => {
|
|
85
|
+
const ix = Math.floor(x), iy = Math.floor(y);
|
|
86
|
+
const fx = smooth(x - ix), fy = smooth(y - iy);
|
|
87
|
+
const top = lattice(ix, iy) + (lattice(ix + 1, iy) - lattice(ix, iy)) * fx;
|
|
88
|
+
const bottom = lattice(ix, iy + 1) + (lattice(ix + 1, iy + 1) - lattice(ix, iy + 1)) * fx;
|
|
89
|
+
return top + (bottom - top) * fy;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface Wisp { x: number; y: number; vy: number; life: number; max: number }
|
|
94
|
+
|
|
95
|
+
export class FlameSim {
|
|
96
|
+
readonly width: number;
|
|
97
|
+
readonly rows: number;
|
|
98
|
+
frame = 0;
|
|
99
|
+
private heat: number[];
|
|
100
|
+
private particles: Particle[] = [];
|
|
101
|
+
private wisps: Wisp[] = [];
|
|
102
|
+
private readonly random: () => number;
|
|
103
|
+
private readonly noise: (x: number, y: number) => number;
|
|
104
|
+
private readonly center: number;
|
|
105
|
+
/** Current flame height (fraction of the grid) and horizontal lean of the tip. */
|
|
106
|
+
private height = 0.9;
|
|
107
|
+
private sway = 0;
|
|
108
|
+
|
|
109
|
+
constructor(seed = 1, width = FLAME_WIDTH, rows = SIM_ROWS) {
|
|
110
|
+
this.width = width;
|
|
111
|
+
this.rows = rows;
|
|
112
|
+
this.center = (width - 1) / 2;
|
|
113
|
+
this.heat = new Array(width * rows).fill(0);
|
|
114
|
+
this.random = prng(seed);
|
|
115
|
+
this.noise = valueNoise(seed);
|
|
116
|
+
for (let index = 0; index < WARMUP; index++) this.step();
|
|
117
|
+
this.frame = 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Half width at height fraction `h` of the flame: a circular bulb below the belly, then a
|
|
122
|
+
* smooth taper to the tip. The base is rounded, not flat, and narrower than the belly.
|
|
123
|
+
*/
|
|
124
|
+
private profile(h: number): number {
|
|
125
|
+
if (h < BELLY) {
|
|
126
|
+
const k = (BELLY - h) / (BELLY + 0.03);
|
|
127
|
+
return BELLY_HALF_WIDTH * Math.sqrt(Math.max(0, 1 - k * k));
|
|
128
|
+
}
|
|
129
|
+
return BELLY_HALF_WIDTH * Math.pow(Math.max(0, 1 - (h - BELLY) / (1 - BELLY)), 1.25);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Center column at height fraction `h`: the tip sways and wanders more than the base. */
|
|
133
|
+
private axis(h: number): number {
|
|
134
|
+
const t = this.frame;
|
|
135
|
+
return this.center + this.sway * h * h + (this.noise(h * 1.6, t * 0.06 + 11) - 0.5) * 1.6 * h;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
step(): void {
|
|
139
|
+
const { width, rows, random } = this;
|
|
140
|
+
const t = this.frame;
|
|
141
|
+
// Slow, layered motion: breathing height and a sway that eases side to side.
|
|
142
|
+
this.height = 0.84 + 0.1 * this.noise(t * 0.05, 3.1) + 0.05 * Math.sin(t * 0.21);
|
|
143
|
+
this.sway = 1.4 * Math.sin(t * 0.07) + 0.6 * (this.noise(t * 0.09, 7.3) - 0.5) * 2;
|
|
144
|
+
const heat = this.heat;
|
|
145
|
+
for (let y = 0; y < rows; y++) {
|
|
146
|
+
const v = (rows - 1 - y) / (rows - 1);
|
|
147
|
+
const h = v / this.height;
|
|
148
|
+
const axis = this.axis(Math.min(1, h));
|
|
149
|
+
// Edges ripple upward; the ripple grows toward the tip so the top licks while the base stays calm.
|
|
150
|
+
const ripple = 1 + (this.noise(h * 3.2 - t * 0.22, 5.5) - 0.5) * (0.08 + 0.85 * h);
|
|
151
|
+
const half = h > 1 ? 0 : this.profile(h) * ripple;
|
|
152
|
+
for (let x = 0; x < width; x++) {
|
|
153
|
+
let value = 0;
|
|
154
|
+
if (half > 0.2 && x > 0 && x < width - 1) {
|
|
155
|
+
const d = Math.abs(x - axis) / half;
|
|
156
|
+
if (d < 1) {
|
|
157
|
+
// Hot, pale core low in the flame, cooling to red at the rim and the tip.
|
|
158
|
+
const core = Math.min(1, 1.3 - 0.95 * h);
|
|
159
|
+
const texture = 0.88 + 0.24 * this.noise(x * 0.8, y * 0.55 - t * 0.35);
|
|
160
|
+
value = MAX_HEAT * (1 - Math.pow(d, 1.8)) * core * texture;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
heat[y * width + x] = Math.max(0, Math.min(MAX_HEAT, Math.round(value)));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
this.moveWisps();
|
|
167
|
+
this.moveParticles();
|
|
168
|
+
this.frame++;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Small pieces of flame that break off the tip, rise and fade. */
|
|
172
|
+
private moveWisps(): void {
|
|
173
|
+
const { width, rows, random, heat } = this;
|
|
174
|
+
this.wisps = this.wisps.map((wisp) => ({ ...wisp, y: wisp.y + wisp.vy, x: wisp.x + this.sway * 0.05, life: wisp.life - 1 }))
|
|
175
|
+
.filter((wisp) => wisp.life > 0 && wisp.y >= 0);
|
|
176
|
+
if (this.wisps.length < MAX_WISPS && random() < 0.09) {
|
|
177
|
+
const tip = Math.round((rows - 1) * (1 - this.height));
|
|
178
|
+
const max = 4 + Math.floor(random() * 4);
|
|
179
|
+
this.wisps.push({ x: this.axis(1), y: Math.max(0, tip - 1), vy: -0.6 - random() * 0.4, life: max, max });
|
|
180
|
+
}
|
|
181
|
+
for (const wisp of this.wisps) {
|
|
182
|
+
const x = Math.round(wisp.x), y = Math.round(wisp.y);
|
|
183
|
+
if (x < 1 || x >= width - 1 || y < 0 || y >= rows) continue;
|
|
184
|
+
const value = Math.round(2 + 3 * (wisp.life / wisp.max));
|
|
185
|
+
heat[y * width + x] = Math.max(heat[y * width + x]!, value);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private topOf(x: number): number | undefined {
|
|
190
|
+
for (let y = 0; y < this.rows; y++) if (this.heat[y * this.width + x]! >= 3) return y;
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private moveParticles(): void {
|
|
195
|
+
const random = this.random;
|
|
196
|
+
this.particles = this.particles
|
|
197
|
+
.map((particle) => ({ ...particle, y: particle.y + particle.vy, x: particle.x + particle.drift + (random() - 0.5) * 0.3, life: particle.life - 1 }))
|
|
198
|
+
.filter((particle) => particle.life > 0 && particle.y >= 0 && particle.x >= 1 && particle.x < this.width - 1.5);
|
|
199
|
+
const spawn = (spark: boolean) => {
|
|
200
|
+
if (this.particles.length >= MAX_PARTICLES) return;
|
|
201
|
+
// Embers lift off the upper flame, near its swaying axis.
|
|
202
|
+
const x = Math.round(this.axis(0.8) + (random() - 0.5) * 5);
|
|
203
|
+
const top = this.topOf(Math.max(0, Math.min(this.width - 1, x)));
|
|
204
|
+
if (top == null) return;
|
|
205
|
+
const max = spark ? 2 + Math.floor(random() * 3) : 8 + Math.floor(random() * 13);
|
|
206
|
+
this.particles.push({ x, y: Math.max(0, top - 1), vy: spark ? -1.1 - random() * 0.4 : -0.3 - random() * 0.5,
|
|
207
|
+
drift: (random() - 0.5) * 0.5, life: max, max, spark });
|
|
208
|
+
};
|
|
209
|
+
if (random() < 0.3) spawn(false);
|
|
210
|
+
if (random() < 0.12) spawn(false);
|
|
211
|
+
if (random() < 0.08) spawn(true);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
particleCount(): number { return this.particles.length; }
|
|
215
|
+
|
|
216
|
+
render(paint: FlamePaint = (_color, text) => text, truecolor = true): string[] {
|
|
217
|
+
const rows: string[][] = Array.from({ length: this.rows / 2 }, (_, row) => Array.from({ length: this.width }, (_, x) => {
|
|
218
|
+
const top = this.heat[row * 2 * this.width + x]!;
|
|
219
|
+
const bottom = this.heat[(row * 2 + 1) * this.width + x]!;
|
|
220
|
+
return truecolor ? cell(top, bottom) : shadedCell(top, bottom, paint);
|
|
221
|
+
}));
|
|
222
|
+
for (const particle of this.particles) {
|
|
223
|
+
const x = Math.round(particle.x);
|
|
224
|
+
const simY = Math.round(particle.y);
|
|
225
|
+
const row = Math.floor(simY / 2);
|
|
226
|
+
if (row < 0 || row >= rows.length || x < 1 || x >= this.width - 1) continue;
|
|
227
|
+
if (this.heat[simY * this.width + x]! > 0 || rows[row]![x] !== " ") continue;
|
|
228
|
+
const fade = particle.life / particle.max;
|
|
229
|
+
const glyph = particle.spark ? (fade > 0.5 ? "✦" : "*") : fade > 0.66 ? "•" : fade > 0.33 ? "∙" : "·";
|
|
230
|
+
const heat = particle.spark ? MAX_HEAT : Math.max(3, Math.round(4 + fade * 4));
|
|
231
|
+
rows[row]![x] = truecolor ? rgb(FLAME_RAMP[heat]!, glyph) : paint(particle.spark ? "warning" : "error", glyph);
|
|
232
|
+
}
|
|
233
|
+
return rows.map((row) => row.join(""));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let cached: { seed: number; sim: FlameSim } | undefined;
|
|
238
|
+
|
|
239
|
+
/** Rendered flame rows for an absolute frame; replays deterministically for a seed. */
|
|
240
|
+
export function flameFrame(frame: number, seed = 1, paint?: FlamePaint, truecolor = supportsTruecolor()): string[] {
|
|
241
|
+
const target = Math.max(0, Math.floor(Number.isFinite(frame) ? frame : 0));
|
|
242
|
+
if (!cached || cached.seed !== seed || cached.sim.frame > target) cached = { seed, sim: new FlameSim(seed) };
|
|
243
|
+
while (cached.sim.frame < target) cached.sim.step();
|
|
244
|
+
return cached.sim.render(paint, truecolor);
|
|
245
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFileSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const FOOTER_FIELDS = ["model", "effort", "sessionName", "cwd", "context", "memory", "cost", "quota", "roles", "extras", "branch"] as const;
|
|
5
|
+
export type FooterField = (typeof FOOTER_FIELDS)[number];
|
|
6
|
+
export type FooterSettings = Record<FooterField, boolean>;
|
|
7
|
+
export const DEFAULT_FOOTER_SETTINGS: FooterSettings = Object.fromEntries(FOOTER_FIELDS.map((field) => [field, true])) as FooterSettings;
|
|
8
|
+
|
|
9
|
+
export function loadFooterSettings(directory: string): FooterSettings {
|
|
10
|
+
try {
|
|
11
|
+
const value: unknown = JSON.parse(readFileSync(join(directory, "pi-jar-footer.json"), "utf8"));
|
|
12
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return { ...DEFAULT_FOOTER_SETTINGS };
|
|
13
|
+
const entries = value as Record<string, unknown>;
|
|
14
|
+
return Object.fromEntries(FOOTER_FIELDS.map((field) => [field,
|
|
15
|
+
typeof entries[field] === "boolean" ? entries[field] : true])) as FooterSettings;
|
|
16
|
+
} catch {
|
|
17
|
+
return { ...DEFAULT_FOOTER_SETTINGS };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function saveFooterSettings(directory: string, settings: FooterSettings): void {
|
|
22
|
+
mkdirSync(directory, { recursive: true });
|
|
23
|
+
const target = join(directory, "pi-jar-footer.json");
|
|
24
|
+
const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
25
|
+
try {
|
|
26
|
+
writeFileSync(temporary, JSON.stringify(settings, null, 2) + "\n", { flag: "wx" });
|
|
27
|
+
renameSync(temporary, target);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
try { unlinkSync(temporary); } catch { /* No temporary file to clean up. */ }
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/footer.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { sep } from "node:path";
|
|
3
|
+
import { sliceByColumn, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { DEFAULT_FOOTER_SETTINGS, type FooterSettings } from "./footer-settings.ts";
|
|
5
|
+
import { roleFrame } from "./animations.ts";
|
|
6
|
+
import type { RoleStatus } from "./roles.ts";
|
|
7
|
+
import { ACTIVE_STATES, cleanText, type JarStatus } from "./status.ts";
|
|
8
|
+
import type { Quota } from "./quota.ts";
|
|
9
|
+
|
|
10
|
+
export type Color = "accent" | "warning" | "success" | "error" | "dim" | "muted"
|
|
11
|
+
| "thinkingOff" | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh" | "thinkingMax";
|
|
12
|
+
export interface FooterTheme { fg(color: Color, text: string): string }
|
|
13
|
+
export interface FooterView extends JarStatus {
|
|
14
|
+
model: string;
|
|
15
|
+
effort?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
16
|
+
sessionName?: string;
|
|
17
|
+
cwd?: string;
|
|
18
|
+
settings?: FooterSettings;
|
|
19
|
+
context: string;
|
|
20
|
+
goal?: string;
|
|
21
|
+
/** pi-jar's own short indicators (changes to review, running shells), shown before extras. */
|
|
22
|
+
chips?: readonly string[];
|
|
23
|
+
memory?: string;
|
|
24
|
+
cost?: string;
|
|
25
|
+
quota?: Quota;
|
|
26
|
+
branch: string | null;
|
|
27
|
+
demo: boolean;
|
|
28
|
+
animations: boolean;
|
|
29
|
+
frame: number;
|
|
30
|
+
motionBudget?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fitCwd(cwd: string, width: number): string {
|
|
34
|
+
if (visibleWidth(cwd) <= width) return cwd;
|
|
35
|
+
return "…" + sliceByColumn(cwd, visibleWidth(cwd) - width + 1, width - 1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function roleColor(role: RoleStatus): Color {
|
|
39
|
+
if (role.state === "failed") return "error";
|
|
40
|
+
if (role.state === "done") return "success";
|
|
41
|
+
if (role.state === "waiting" || role.state === "idle") return "dim";
|
|
42
|
+
if (role.state === "reviewing") return "warning";
|
|
43
|
+
return "accent";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Every line is bounded in terminal cells; critical role failure takes precedence. */
|
|
47
|
+
export function renderFooter(view: FooterView, width: number, theme: FooterTheme): string[] {
|
|
48
|
+
if (width <= 0) return [];
|
|
49
|
+
const show = view.settings ?? DEFAULT_FOOTER_SETTINGS;
|
|
50
|
+
if (!view.goal && !Object.entries(show).some(([field, enabled]) => enabled && (field !== "memory" || !!view.memory))) return [];
|
|
51
|
+
// Keep tiny terminals unframed so context and urgent role status stay readable.
|
|
52
|
+
const framed = width >= 32;
|
|
53
|
+
const contentWidth = framed ? width - 4 : width;
|
|
54
|
+
const roles = show.roles ? [...view.roles].sort((a, b) => Number(b.state === "failed") - Number(a.state === "failed")) : [];
|
|
55
|
+
const primary = roles.find((role) => role.state === "failed") ?? roles.find((role) => ACTIVE_STATES.has(role.state));
|
|
56
|
+
const model = cleanText(view.model, 24) || "no-model";
|
|
57
|
+
const context = cleanText(view.context, 12) || "ctx ?";
|
|
58
|
+
const goal = view.goal ? cleanText(view.goal, 240) : "";
|
|
59
|
+
const quota = view.quota;
|
|
60
|
+
const windows = [quota?.fiveHour && `5h ${Math.round(quota.fiveHour.used)}%`, quota?.week && `week ${Math.round(quota.week.used)}%`].filter((value): value is string => !!value);
|
|
61
|
+
const cost = view.cost ? cleanText(view.cost, 20) : undefined;
|
|
62
|
+
const prefix = view.demo ? theme.fg("warning", "DEMO") : "";
|
|
63
|
+
const effortColors = {
|
|
64
|
+
off: "thinkingOff", minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium",
|
|
65
|
+
high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
|
|
66
|
+
} as const;
|
|
67
|
+
const effort = show.effort && view.effort ? theme.fg(effortColors[view.effort], view.effort) : "";
|
|
68
|
+
const firstRole = primary ? `${primary.label} ${roleFrame(primary.state, view.frame, view.animations)}` : undefined;
|
|
69
|
+
const attention = firstRole ?? (show.extras ? view.extras[0] : undefined);
|
|
70
|
+
// At narrow widths reserve the right-hand slot for critical/active status, then context.
|
|
71
|
+
const modelLabel = show.model ? theme.fg("muted", model) : "";
|
|
72
|
+
const left = [prefix, modelLabel, effort].filter(Boolean).join(theme.fg("dim", " · "));
|
|
73
|
+
// Context is never dropped; optional metrics move to a second line on medium widths.
|
|
74
|
+
const metrics = [...(show.context ? [context] : []), ...(show.memory && contentWidth >= 90 && view.memory ? [view.memory] : []), ...(show.cost && contentWidth >= 52 && cost ? [cost] : []), ...(show.quota && contentWidth >= 90 ? windows : [])];
|
|
75
|
+
let right = theme.fg("dim", metrics.join(" · "));
|
|
76
|
+
if (contentWidth < 52 && attention && contentWidth >= 26) {
|
|
77
|
+
const room = Math.max(0, contentWidth - Math.min(visibleWidth(left), 12) - visibleWidth(right) - 4);
|
|
78
|
+
right = theme.fg(primary?.state === "failed" ? "error" : "warning", truncateToWidth(attention, Math.min(room, 14))) + " " + right;
|
|
79
|
+
}
|
|
80
|
+
const availableLeft = Math.max(0, contentWidth - visibleWidth(right) - 1);
|
|
81
|
+
// Never leave a dangling separator when the effort level gets squeezed out.
|
|
82
|
+
const fittedLeft = (() => {
|
|
83
|
+
if (visibleWidth(left) <= availableLeft) return left;
|
|
84
|
+
if (!effort) return truncateToWidth(left, availableLeft);
|
|
85
|
+
const beforeEffort = [prefix, modelLabel].filter(Boolean).join(theme.fg("dim", " · "));
|
|
86
|
+
const room = availableLeft - visibleWidth(effort) - 3;
|
|
87
|
+
if (room >= 3 && beforeEffort) return truncateToWidth(beforeEffort, room) + theme.fg("dim", " · ") + effort;
|
|
88
|
+
return truncateToWidth(beforeEffort || effort, availableLeft);
|
|
89
|
+
})();
|
|
90
|
+
const name = show.sessionName && view.sessionName ? cleanText(view.sessionName, 128) : "";
|
|
91
|
+
const rawCwd = show.cwd && view.cwd ? cleanText(view.cwd, 256) : "";
|
|
92
|
+
const home = homedir();
|
|
93
|
+
const cwd = home && rawCwd.startsWith(home + sep) ? `~${rawCwd.slice(home.length)}` : rawCwd;
|
|
94
|
+
let namedLeft = fittedLeft;
|
|
95
|
+
let space = availableLeft - visibleWidth(namedLeft);
|
|
96
|
+
if (cwd && space >= 7) {
|
|
97
|
+
const budget = name ? Math.min(24, Math.max(4, Math.floor((space - 6) / 2))) : Math.min(32, space - 3);
|
|
98
|
+
namedLeft += theme.fg("dim", " · ") + theme.fg("muted", fitCwd(cwd, budget));
|
|
99
|
+
space = availableLeft - visibleWidth(namedLeft);
|
|
100
|
+
}
|
|
101
|
+
if (name && space >= 7) namedLeft += theme.fg("dim", " · ") + theme.fg("muted", truncateToWidth(name, space - 3, "…"));
|
|
102
|
+
const gap = " ".repeat(Math.max(1, contentWidth - visibleWidth(namedLeft) - visibleWidth(right)));
|
|
103
|
+
const lines = [truncateToWidth(namedLeft + gap + right, contentWidth)];
|
|
104
|
+
if (goal) {
|
|
105
|
+
const goalPrefix = theme.fg("accent", "◎ goal") + theme.fg("dim", " · ");
|
|
106
|
+
const room = Math.max(0, contentWidth - visibleWidth(goalPrefix));
|
|
107
|
+
lines.push(truncateToWidth(goalPrefix + theme.fg("muted", truncateToWidth(goal, room, "…")), contentWidth));
|
|
108
|
+
}
|
|
109
|
+
if (contentWidth < 52) {
|
|
110
|
+
const small = [...(show.memory && view.memory ? [view.memory] : []), ...(show.quota ? windows : [])];
|
|
111
|
+
if (small.length && contentWidth >= 26) lines.push(truncateToWidth(theme.fg("dim", small.join(" · ")), contentWidth));
|
|
112
|
+
return framed ? frameFooter(lines, width, view, theme) : lines;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let moving = 0;
|
|
116
|
+
const segments = roles.map((role) => {
|
|
117
|
+
const animated = view.animations && ACTIVE_STATES.has(role.state) && moving < (view.motionBudget ?? 2);
|
|
118
|
+
if (animated) moving++;
|
|
119
|
+
const glyph = roleFrame(role.state, view.frame, animated);
|
|
120
|
+
const task = contentWidth >= 100 && role.task ? ` ${role.task}` : "";
|
|
121
|
+
return theme.fg(roleColor(role), `${role.label} ${glyph}${task}`);
|
|
122
|
+
});
|
|
123
|
+
const remaining = Math.max(0, view.extras.length - (contentWidth >= 100 ? 2 : 1));
|
|
124
|
+
const extras = show.extras ? view.extras.slice(0, contentWidth >= 100 ? 2 : 1).map((status) => theme.fg("muted", status)) : [];
|
|
125
|
+
if (show.extras && remaining) extras.push(theme.fg("dim", `+${remaining}`));
|
|
126
|
+
if (show.branch && contentWidth >= 52 && view.branch) extras.push(theme.fg("dim", `git ${cleanText(view.branch, 28)}`));
|
|
127
|
+
const chips = (view.chips ?? []).map((chip) => theme.fg("warning", cleanText(chip, 32)));
|
|
128
|
+
const joined = [...segments, ...chips, ...extras].join(theme.fg("dim", " · "));
|
|
129
|
+
if (joined) lines.push(truncateToWidth(joined, contentWidth));
|
|
130
|
+
if (contentWidth < 90 && ((show.quota && windows.length) || (show.memory && view.memory))) {
|
|
131
|
+
const summary = [...(show.memory && view.memory ? [view.memory] : []), ...(show.quota ? windows : [])].join(" · ");
|
|
132
|
+
if (summary) lines.push(truncateToWidth(theme.fg("dim", summary), contentWidth));
|
|
133
|
+
}
|
|
134
|
+
return framed ? frameFooter(lines, width, view, theme) : lines;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function frameFooter(lines: string[], width: number, view: FooterView, theme: FooterTheme): string[] {
|
|
138
|
+
const active = (view.settings?.roles ?? true) && view.roles.some((role) => ACTIVE_STATES.has(role.state));
|
|
139
|
+
const pulse = active && view.animations ? ["◇", "◈", "◆", "◈"][view.frame % 4] : "◇";
|
|
140
|
+
const top = theme.fg("dim", "╭─") + theme.fg(active ? "accent" : "dim", pulse)
|
|
141
|
+
+ theme.fg("dim", "─".repeat(width - 4) + "╮");
|
|
142
|
+
const bottom = theme.fg("dim", "╰" + "─".repeat(width - 2) + "╯");
|
|
143
|
+
// Only the glyph changes between frames; the outline and content never shift.
|
|
144
|
+
return [top, ...lines.map((line) =>
|
|
145
|
+
theme.fg("dim", "│ ") + line + " ".repeat(Math.max(0, width - 4 - visibleWidth(line))) + theme.fg("dim", " │")
|
|
146
|
+
), bottom];
|
|
147
|
+
}
|