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,332 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { cleanText } from "./status.ts";
5
+
6
+ /** Built-in roles and the pi-jar feature that uses each one. Any other valid name is a custom role. */
7
+ export const BUILTIN_ROLES = [
8
+ { role: "default", label: "Default", usedBy: "session start and approved plans" },
9
+ { role: "smol", label: "Fast", usedBy: "quick edits and cycling" },
10
+ { role: "slow", label: "Thinking", usedBy: "deep reasoning and cycling" },
11
+ { role: "plan", label: "Architect", usedBy: "plan mode" },
12
+ { role: "implement", label: "Builder", usedBy: "goal implement rounds and approved plans" },
13
+ { role: "advisor", label: "Advisor", usedBy: "second opinions, stuck-work gates and the goal audit" },
14
+ { role: "task", label: "Subtask", usedBy: "delegated tasks" },
15
+ { role: "commit", label: "Commit", usedBy: "/jar commit messages" }
16
+ ] as const;
17
+ export const MODEL_ROLES = BUILTIN_ROLES.map((item) => item.role);
18
+ export type ModelRole = string;
19
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
20
+ export type RoleScope = "global" | "project";
21
+ export interface RoleAssignment { provider: string; model: string; thinking?: ThinkingLevel }
22
+ export interface ResolvedRole extends RoleAssignment { via: string[] }
23
+ export interface RoleTag { name?: string; color?: string }
24
+ export interface RoleConfig { version: 2; roles: Record<string, string>; cycleOrder?: string[]; tags?: Record<string, RoleTag> }
25
+ export interface RoleRow {
26
+ role: string; label: string; usedBy?: string; custom: boolean;
27
+ spec?: string; scope?: RoleScope; resolved?: ResolvedRole; error?: string;
28
+ }
29
+
30
+ export const ROLE_FILE = "pi-jar-roles.json";
31
+ export const THINKING: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
32
+ const DEFAULT_CYCLE = ["smol", "default", "slow"];
33
+ const MAX_ALIAS_DEPTH = 5;
34
+ export const isRoleName = (value: string): boolean => /^[a-z][a-z0-9-]{0,31}$/.test(value);
35
+ export const isThinking = (value: unknown): value is ThinkingLevel => typeof value === "string" && THINKING.includes(value as ThinkingLevel);
36
+
37
+ /** Parse `provider/model[:thinking]`, `@role[:thinking]` or `*`; returns a canonical spec or undefined. */
38
+ export function normalizeSpec(raw: string): string | undefined {
39
+ const value = raw.trim();
40
+ if (value === "*") return "@default";
41
+ const colon = value.lastIndexOf(":");
42
+ const suffix = colon > 0 ? value.slice(colon + 1) : "";
43
+ const head = colon > 0 && isThinking(suffix) ? value.slice(0, colon) : value;
44
+ const thinking = head === value ? "" : ":" + suffix;
45
+ if (head.startsWith("@")) return isRoleName(head.slice(1)) ? head + thinking : undefined;
46
+ const slash = head.indexOf("/");
47
+ if (slash <= 0 || slash === head.length - 1 || /\s/.test(head)) return undefined;
48
+ return cleanText(head.slice(0, slash), 64) + "/" + cleanText(head.slice(slash + 1), 160) + thinking;
49
+ }
50
+
51
+ /** Accepts v2 files and migrates v1 `{provider, model, thinking}` objects in memory. */
52
+ export function parseRoleConfig(raw: unknown): RoleConfig {
53
+ const config: RoleConfig = { version: 2, roles: {} };
54
+ if (!raw || typeof raw !== "object") return config;
55
+ const value = raw as Record<string, unknown>;
56
+ if (value.version !== 1 && value.version !== 2) return config;
57
+ const source = value.roles && typeof value.roles === "object" ? value.roles as Record<string, unknown> : {};
58
+ for (const [role, entry] of Object.entries(source)) {
59
+ if (!isRoleName(role)) continue;
60
+ let spec: string | undefined;
61
+ if (typeof entry === "string") spec = normalizeSpec(entry);
62
+ else if (entry && typeof entry === "object") {
63
+ const item = entry as Record<string, unknown>;
64
+ if (typeof item.provider === "string" && typeof item.model === "string") {
65
+ spec = normalizeSpec(item.provider + "/" + item.model + (isThinking(item.thinking) ? ":" + item.thinking : ""));
66
+ }
67
+ }
68
+ if (spec) config.roles[role] = spec;
69
+ }
70
+ if (Array.isArray(value.cycleOrder)) {
71
+ const order = value.cycleOrder.filter((item): item is string => typeof item === "string" && isRoleName(item));
72
+ if (order.length) config.cycleOrder = [...new Set(order)];
73
+ }
74
+ if (value.tags && typeof value.tags === "object") {
75
+ const tags: Record<string, RoleTag> = {};
76
+ for (const [role, tag] of Object.entries(value.tags as Record<string, unknown>)) {
77
+ if (!isRoleName(role) || !tag || typeof tag !== "object") continue;
78
+ const item = tag as Record<string, unknown>;
79
+ tags[role] = {
80
+ ...(typeof item.name === "string" ? { name: cleanText(item.name, 24) } : {}),
81
+ ...(typeof item.color === "string" ? { color: cleanText(item.color, 16) } : {})
82
+ };
83
+ }
84
+ if (Object.keys(tags).length) config.tags = tags;
85
+ }
86
+ return config;
87
+ }
88
+
89
+ /** Follow `@alias` chains; an explicit thinking suffix on the referring role wins over the target's. */
90
+ export function resolveRole(roles: Readonly<Record<string, string>>, role: string): ResolvedRole | { error: string } | undefined {
91
+ const via: string[] = [];
92
+ let current = role;
93
+ let thinking: ThinkingLevel | undefined;
94
+ for (let depth = 0; depth <= MAX_ALIAS_DEPTH; depth++) {
95
+ if (via.includes(current)) return { error: "alias cycle: " + [...via, current].join(" → ") };
96
+ via.push(current);
97
+ const spec = roles[current];
98
+ if (!spec) return depth === 0 ? undefined : { error: "@" + current + " is not assigned" };
99
+ const colon = spec.lastIndexOf(":");
100
+ const suffix = colon > 0 ? spec.slice(colon + 1) : "";
101
+ const head = colon > 0 && isThinking(suffix) ? spec.slice(0, colon) : spec;
102
+ if (head !== spec) thinking ??= suffix as ThinkingLevel;
103
+ if (head.startsWith("@")) { current = head.slice(1); continue; }
104
+ const slash = head.indexOf("/");
105
+ return { provider: head.slice(0, slash), model: head.slice(slash + 1), ...(thinking ? { thinking } : {}), via };
106
+ }
107
+ return { error: "alias chain deeper than " + MAX_ALIAS_DEPTH };
108
+ }
109
+
110
+ function readConfig(file: string): RoleConfig {
111
+ try { return parseRoleConfig(JSON.parse(readFileSync(file, "utf8"))); }
112
+ catch { return { version: 2, roles: {} }; }
113
+ }
114
+
115
+ function writeConfig(file: string, config: RoleConfig): void {
116
+ mkdirSync(dirname(file), { recursive: true });
117
+ const temporary = file + "." + String(process.pid) + "." + Math.random().toString(36).slice(2) + ".tmp";
118
+ try {
119
+ writeFileSync(temporary, JSON.stringify(config, null, 2) + "\n", { flag: "wx" });
120
+ renameSync(temporary, file);
121
+ } catch (error) {
122
+ try { if (existsSync(temporary)) unlinkSync(temporary); } catch {}
123
+ throw error;
124
+ }
125
+ }
126
+
127
+ export const projectRoleFile = (cwd: string) => join(cwd, ".pi", ROLE_FILE);
128
+
129
+ export function describeRole(row: RoleRow): string {
130
+ const target = row.error ? "⚠ " + row.error
131
+ : row.resolved ? row.resolved.provider + "/" + row.resolved.model + (row.resolved.thinking ? " · " + row.resolved.thinking : "")
132
+ : "follows current model";
133
+ return row.role + " · " + (row.spec?.startsWith("@") ? row.spec + " → " : "") + target + (row.scope === "project" ? " · project" : "");
134
+ }
135
+
136
+ export class ModelRoleManager {
137
+ private global: RoleConfig = readConfig(join(getAgentDir(), ROLE_FILE));
138
+ private project: RoleConfig = { version: 2, roles: {} };
139
+ private cwd: string | undefined;
140
+ private active: string | undefined;
141
+ private readonly pi: ExtensionAPI;
142
+ /** True while pi-jar itself is switching models, so its own changes are not seen as manual. */
143
+ private applying = false;
144
+ /** Bumped whenever the user picks a model or effort themselves. */
145
+ private manualEpoch = 0;
146
+
147
+ constructor(pi: ExtensionAPI) {
148
+ this.pi = pi;
149
+ }
150
+
151
+ /** Reload global and project files; project assignments override global ones per role. */
152
+ load(cwd?: string): void {
153
+ this.global = readConfig(join(getAgentDir(), ROLE_FILE));
154
+ this.cwd = cwd;
155
+ this.project = cwd ? readConfig(projectRoleFile(cwd)) : { version: 2, roles: {} };
156
+ }
157
+
158
+ private merged(): Record<string, string> { return { ...this.global.roles, ...this.project.roles }; }
159
+ activeRole(): string | undefined { return this.active; }
160
+ cycleOrder(): string[] { return this.project.cycleOrder ?? this.global.cycleOrder ?? DEFAULT_CYCLE; }
161
+ tag(role: string): RoleTag | undefined { return this.project.tags?.[role] ?? this.global.tags?.[role]; }
162
+ spec(role: string): string | undefined { return this.merged()[role]; }
163
+ scopeOf(role: string): RoleScope | undefined { return role in this.project.roles ? "project" : role in this.global.roles ? "global" : undefined; }
164
+
165
+ resolve(role: string): ResolvedRole | undefined {
166
+ const result = resolveRole(this.merged(), role);
167
+ return result && !("error" in result) ? result : undefined;
168
+ }
169
+
170
+ get(role: string): RoleAssignment | undefined {
171
+ const resolved = this.resolve(role);
172
+ if (!resolved) return undefined;
173
+ const { via: _via, ...assignment } = resolved;
174
+ return assignment;
175
+ }
176
+
177
+ list(): RoleRow[] {
178
+ const roles = this.merged();
179
+ const custom = Object.keys(roles).filter((role) => !MODEL_ROLES.includes(role as never)).sort();
180
+ const rows = [
181
+ ...BUILTIN_ROLES.map((item) => ({ role: item.role, label: item.label, usedBy: item.usedBy as string, custom: false })),
182
+ ...custom.map((role) => ({ role, label: this.tag(role)?.name ?? role, custom: true }))
183
+ ];
184
+ return rows.map((row) => {
185
+ const result = resolveRole(roles, row.role);
186
+ return { ...row, spec: roles[row.role], scope: this.scopeOf(row.role),
187
+ ...(result && "error" in result ? { error: result.error } : result ? { resolved: result } : {}) };
188
+ });
189
+ }
190
+
191
+ /** Summary for compact surfaces such as the welcome card. */
192
+ summary(limit = 3): string {
193
+ const rows = this.list().filter((row) => row.resolved || row.spec);
194
+ if (!rows.length) return "all roles follow the current model";
195
+ return rows.slice(0, limit).map((row) => row.role + "→" + (row.spec?.startsWith("@") ? row.spec.split(":")[0] : row.resolved?.model ?? "?")).join(" · ")
196
+ + (rows.length > limit ? ` · +${rows.length - limit}` : "");
197
+ }
198
+
199
+ private status(ctx: ExtensionContext): void {
200
+ try { ctx.ui.setStatus("pi-jar.model-role", this.active ? ctx.ui.theme.fg("accent", "role:" + this.active) : undefined); }
201
+ catch { /* status is decorative */ }
202
+ }
203
+
204
+ async activate(role: string, ctx: ExtensionContext, quiet = false): Promise<boolean> {
205
+ const result = resolveRole(this.merged(), role);
206
+ if (result && "error" in result) {
207
+ if (!quiet) ctx.ui.notify("Role " + role + ": " + result.error, "warning");
208
+ return false;
209
+ }
210
+ if (!result) {
211
+ if (!quiet) ctx.ui.notify("Role " + role + " follows the current model; assign one with /roles", "info");
212
+ this.active = role;
213
+ this.status(ctx);
214
+ return true;
215
+ }
216
+ const model = ctx.modelRegistry.find(result.provider, result.model);
217
+ if (!model) {
218
+ if (!quiet) ctx.ui.notify("Model not found for role " + role + ": " + result.provider + "/" + result.model, "warning");
219
+ return false;
220
+ }
221
+ let changed: boolean;
222
+ this.applying = true;
223
+ try { changed = await this.pi.setModel(model); } finally { this.applying = false; }
224
+ if (!changed) {
225
+ if (!quiet) ctx.ui.notify("No configured authentication for " + result.provider + "/" + result.model, "warning");
226
+ return false;
227
+ }
228
+ if (result.thinking) this.withApplying(() => this.pi.setThinkingLevel(result.thinking!));
229
+ this.active = role;
230
+ this.status(ctx);
231
+ if (!quiet) ctx.ui.notify("Role " + role + " · " + result.provider + "/" + result.model + (result.thinking ? " · " + result.thinking : ""), "info");
232
+ return true;
233
+ }
234
+
235
+ /** Switch to a role for a bounded workflow; the returned function restores the previous model. */
236
+ async activateTemporary(role: string, ctx: ExtensionContext): Promise<() => Promise<void>> {
237
+ if (!this.resolve(role)) return async () => {};
238
+ const previousModel = ctx.model;
239
+ const previousThinking = this.pi.getThinkingLevel();
240
+ const previousActive = this.active;
241
+ const applied = await this.activate(role, ctx, true);
242
+ if (!applied) return async () => {};
243
+ const epoch = this.manualEpoch;
244
+ return async () => {
245
+ // A model or effort the user picked during the workflow wins over the saved one.
246
+ if (this.manualEpoch !== epoch) { this.active = undefined; this.status(ctx); return; }
247
+ this.applying = true;
248
+ try { if (previousModel) await this.pi.setModel(previousModel); this.pi.setThinkingLevel(previousThinking); }
249
+ finally { this.applying = false; }
250
+ this.active = previousActive;
251
+ this.status(ctx);
252
+ };
253
+ }
254
+
255
+ private withApplying(action: () => void): void {
256
+ this.applying = true;
257
+ try { action(); } finally { this.applying = false; }
258
+ }
259
+
260
+ /** Activate the next assigned role in the cycle order. */
261
+ async cycle(ctx: ExtensionContext): Promise<string | undefined> {
262
+ const order = this.cycleOrder().filter((role) => this.resolve(role));
263
+ if (!order.length) { ctx.ui.notify("No roles in the cycle order are assigned; configure them with /roles", "info"); return undefined; }
264
+ const next = order[(order.indexOf(this.active ?? "") + 1) % order.length]!;
265
+ return await this.activate(next, ctx) ? next : undefined;
266
+ }
267
+
268
+ /** Assign (or clear with undefined) a role in the chosen scope. */
269
+ update(role: string, spec: string | undefined, scope: RoleScope = this.scopeOf(role) ?? "global"): void {
270
+ if (!isRoleName(role)) throw new Error("Invalid role name: " + role);
271
+ const normalized = spec === undefined ? undefined : normalizeSpec(spec);
272
+ if (spec !== undefined && !normalized) throw new Error("Invalid role target: " + spec);
273
+ if (scope === "project" && !this.cwd) throw new Error("Project roles need a working directory");
274
+ const target = scope === "project" ? this.project : this.global;
275
+ const roles = { ...target.roles };
276
+ if (normalized) roles[role] = normalized; else delete roles[role];
277
+ const next: RoleConfig = { ...target, version: 2, roles };
278
+ writeConfig(scope === "project" ? projectRoleFile(this.cwd!) : join(getAgentDir(), ROLE_FILE), next);
279
+ if (scope === "project") this.project = next; else this.global = next;
280
+ }
281
+
282
+ register(openUi?: (ctx: ExtensionContext) => Promise<void>): void {
283
+ const manual = (_event: unknown, ctx: ExtensionContext) => {
284
+ if (this.applying) return;
285
+ this.manualEpoch++;
286
+ if (this.active) { this.active = undefined; this.status(ctx); }
287
+ };
288
+ this.pi.on("model_select", manual);
289
+ this.pi.on("thinking_level_select", manual);
290
+ this.pi.on("session_start", async (_event, ctx) => {
291
+ this.load(ctx.cwd);
292
+ this.active = undefined;
293
+ if (this.resolve("default")) await this.activate("default", ctx, true);
294
+ });
295
+
296
+ this.pi.registerCommand("roles", {
297
+ description: "Configure model roles (default/smol/slow/plan/advisor/task/commit or custom): /roles, /roles set ROLE provider/model[:effort]|@role, /roles <role>",
298
+ handler: async (args, ctx) => {
299
+ const parts = args.trim().split(/\s+/).filter(Boolean);
300
+ const verb = (parts[0] ?? "").toLowerCase();
301
+ if (!verb || verb === "list") {
302
+ if (!verb && ctx.hasUI && ctx.mode === "tui" && openUi) { await openUi(ctx); return; }
303
+ ctx.ui.notify(this.list().map(describeRole).join("\n"), "info");
304
+ return;
305
+ }
306
+ if (verb === "cycle") { await this.cycle(ctx); return; }
307
+ if (verb === "set") {
308
+ const role = (parts[1] ?? "").toLowerCase();
309
+ const legacyThinking = parts[3];
310
+ const target = (parts[2] ?? "") + (legacyThinking && isThinking(legacyThinking) ? ":" + legacyThinking : "");
311
+ const scope: RoleScope = parts.includes("--project") ? "project" : "global";
312
+ if (legacyThinking && legacyThinking !== "--project" && !isThinking(legacyThinking)) { ctx.ui.notify("Unknown thinking effort: " + legacyThinking, "error"); return; }
313
+ if (!isRoleName(role) || !normalizeSpec(target)) { ctx.ui.notify("Usage: /roles set ROLE PROVIDER/MODEL[:effort]|@ROLE [--project]", "error"); return; }
314
+ try { this.update(role, target, scope); }
315
+ catch (error) { ctx.ui.notify("Could not save role: " + (error as Error).message, "error"); return; }
316
+ ctx.ui.notify("Assigned " + role + " → " + normalizeSpec(target) + (scope === "project" ? " (project)" : ""), "info");
317
+ return;
318
+ }
319
+ if (verb === "clear") {
320
+ const role = (parts[1] ?? "").toLowerCase();
321
+ if (!isRoleName(role)) { ctx.ui.notify("Usage: /roles clear ROLE", "error"); return; }
322
+ try { this.update(role, undefined); }
323
+ catch (error) { ctx.ui.notify("Could not save role: " + (error as Error).message, "error"); return; }
324
+ ctx.ui.notify("Cleared role " + role, "info");
325
+ return;
326
+ }
327
+ if (!isRoleName(verb)) { ctx.ui.notify("Unknown role. Use: " + this.list().map((row) => row.role).join(", "), "error"); return; }
328
+ await this.activate(verb, ctx);
329
+ }
330
+ });
331
+ }
332
+ }
package/src/panel.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
+
4
+ export type Paint = (color: string, text: string) => string;
5
+ export interface PanelTab { name: string; render(width: number, fg: Paint): string[] }
6
+
7
+ /** Tabbed, scrollable info panel (Usage / Context). Tab or click switches tabs; Esc closes. */
8
+ export async function openPanel(ctx: ExtensionContext, tabs: readonly PanelTab[], initial = 0): Promise<void> {
9
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
10
+ await ctx.ui.custom<void>((tui, theme, _keys, done) => {
11
+ let tab = Math.max(0, Math.min(tabs.length - 1, initial));
12
+ let scroll = 0;
13
+ let body: string[] = [];
14
+ let rows = 10;
15
+ let tabHits: { start: number; end: number }[] = [];
16
+ const fg: Paint = (color, text) => theme.fg(color as never, text);
17
+ const move = (delta: number) => { scroll = Math.max(0, Math.min(Math.max(0, body.length - rows), scroll + delta)); };
18
+ const select = (index: number) => { tab = (index + tabs.length) % tabs.length; scroll = 0; };
19
+ return {
20
+ invalidate() {},
21
+ handleInput(data: string) {
22
+ if (matchesKey(data, Key.escape) || data === "q") return done();
23
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right) || data === "l") select(tab + 1);
24
+ else if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left) || data === "h") select(tab - 1);
25
+ else if (matchesKey(data, Key.down) || data === "j") move(1);
26
+ else if (matchesKey(data, Key.up) || data === "k") move(-1);
27
+ else if (matchesKey(data, Key.pageDown) || data === " ") move(rows - 1);
28
+ else if (matchesKey(data, Key.pageUp)) move(1 - rows);
29
+ tui.requestRender();
30
+ },
31
+ handleMouse(event: TuiMouseEvent) {
32
+ if (event.type === "wheel" && event.wheelDelta) { move(Math.sign(event.wheelDelta) * 3); tui.requestRender(); return { handled: true }; }
33
+ if (event.type !== "click" || event.button !== "left" || event.y !== 1) return;
34
+ const hit = tabHits.findIndex((item) => event.x >= item.start && event.x < item.end);
35
+ if (hit >= 0) { select(hit); tui.requestRender(); return { handled: true }; }
36
+ },
37
+ render(available: number): string[] {
38
+ const width = Math.max(30, available);
39
+ const inner = width - 4;
40
+ rows = Math.max(4, (process.stdout.rows ?? 24) - 6);
41
+ body = tabs[tab]!.render(inner, fg);
42
+ scroll = Math.min(scroll, Math.max(0, body.length - rows));
43
+ const line = (text: string) => fg("dim", "│ ") + truncateToWidth(text, inner) + " ".repeat(Math.max(0, inner - visibleWidth(truncateToWidth(text, inner)))) + fg("dim", " │");
44
+ tabHits = [];
45
+ // Every tab label is name + 2 cells wide ("[name]" or " name "), separated by one space.
46
+ let x = 2;
47
+ const header = tabs.map((item, index) => {
48
+ tabHits.push({ start: x, end: x + item.name.length + 2 });
49
+ x += item.name.length + 3;
50
+ return index === tab ? theme.bold(fg("accent", `[${item.name}]`)) : fg("muted", ` ${item.name} `);
51
+ }).join(" ");
52
+ const more = body.length > rows ? fg("dim", ` ${scroll + 1}–${Math.min(body.length, scroll + rows)}/${body.length}`) : "";
53
+ return [
54
+ fg("dim", "╭" + "─".repeat(width - 2) + "╮"),
55
+ line(header),
56
+ fg("dim", "├" + "─".repeat(width - 2) + "┤"),
57
+ ...body.slice(scroll, scroll + rows).map(line),
58
+ line(fg("dim", "Tab switch · ↑↓/PgUp/PgDn scroll · Esc close") + more),
59
+ fg("dim", "╰" + "─".repeat(width - 2) + "╯")
60
+ ];
61
+ }
62
+ };
63
+ }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
64
+ }
65
+
66
+ export const tokens = (value: number): string => value < 1000 ? String(Math.round(value))
67
+ : value < 1_000_000 ? (value / 1000).toFixed(value < 10_000 ? 1 : 0).replace(/\.0$/, "") + "k"
68
+ : (value / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
69
+
70
+ export const money = (value: number): string => "$" + (value > 0 && value < 0.01 ? value.toFixed(4) : value.toFixed(2));
71
+
72
+ export function duration(ms: number): string {
73
+ const minutes = Math.floor(ms / 60_000);
74
+ if (minutes < 1) return Math.max(0, Math.floor(ms / 1000)) + "s";
75
+ if (minutes < 60) return minutes + "m";
76
+ const hours = Math.floor(minutes / 60);
77
+ if (hours < 24) return `${hours}h ${minutes % 60}m`;
78
+ return `${Math.floor(hours / 24)}d ${hours % 24}h`;
79
+ }
80
+
81
+ /** A proportional bar: filled cells in `color`, the rest dim. */
82
+ export function bar(fg: Paint, percent: number, width: number, color = "accent"): string {
83
+ const cells = Math.max(4, width);
84
+ const filled = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)));
85
+ return fg(color, "█".repeat(filled)) + fg("dim", "░".repeat(cells - filled));
86
+ }
87
+
88
+ /** `label` left, value right-aligned in `width`. */
89
+ export const row = (label: string, value: string, width: number): string =>
90
+ label + " ".repeat(Math.max(1, width - visibleWidth(label) - visibleWidth(value))) + value;
@@ -0,0 +1,191 @@
1
+ import { cleanText } from "./status.ts";
2
+
3
+ const SIMPLE_READ_COMMANDS = new Set([
4
+ "cat", "head", "tail", "grep", "rg", "ls", "pwd", "wc", "sort", "diff",
5
+ "file", "stat", "du", "df", "tree", "which", "whereis", "uname", "date",
6
+ "ps", "fd", "eza", "realpath", "basename", "dirname"
7
+ ]);
8
+
9
+ // Avoid shell expansions and quoting that could change the options seen by the
10
+ // allowlist after validation (for example: git grep '--op=command').
11
+ const SHELL_MUTATION = /(?:[;&]|\|\||[`$\\'"*?\[\]{}]|>|<|\n|\r)/;
12
+ const FIND_MUTATION = /(?:^|\s)-(?:delete|exec|execdir|ok|okdir|fprint|fprintf|fls)\b/;
13
+ const GIT_MUTATION = /(?:^|\s)(?:-d|-D|-m|-M|--delete|--move|--set-upstream-to|--unset-upstream)\b/;
14
+ const OUTPUT_FLAG = /(?:^|\s)(?:--output(?:=|\s)|-o(?:\s|$))/;
15
+
16
+ function safeGit(tokens: string[]): boolean {
17
+ const sub = tokens[1] ?? "";
18
+ if (!["status", "log", "diff", "show", "remote", "grep", "ls-files", "rev-parse", "describe"].includes(sub)) return false;
19
+ const rest = tokens.slice(2).join(" ");
20
+ if (GIT_MUTATION.test(rest) || OUTPUT_FLAG.test(rest)) return false;
21
+ // Git accepts unique long-option prefixes; do not only reject the full
22
+ // spelling of flags that write files or run external programs.
23
+ const dangerous = sub === "grep"
24
+ ? ["output", "ext-diff", "textconv", "open-files-in-pager"]
25
+ : ["output", "ext-diff", "textconv"];
26
+ if (tokens.slice(2).some((token) => {
27
+ if (sub === "grep" && /^-[^-]*O/.test(token)) return true;
28
+ if (!token.startsWith("--")) return false;
29
+ const flag = token.slice(2).split("=", 1)[0] ?? "";
30
+ return flag.length >= 2 && dangerous.some((option) => option.startsWith(flag));
31
+ })) return false;
32
+ if (sub === "remote" && tokens.length > 2 && !["-v", "show", "get-url"].includes(tokens[2] ?? "")) return false;
33
+ return true;
34
+ }
35
+
36
+ function safePackageQuery(tokens: string[]): boolean {
37
+ const sub = tokens[1] ?? "";
38
+ return ["list", "ls", "view", "info", "why", "outdated"].includes(sub);
39
+ }
40
+
41
+ function safeSegment(segment: string): boolean {
42
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
43
+ const command = tokens[0] ?? "";
44
+ if (!command) return false;
45
+
46
+ if (SIMPLE_READ_COMMANDS.has(command)) {
47
+ if (OUTPUT_FLAG.test(segment)) return false;
48
+ if (command === "sort" && tokens.some((token) => token.startsWith("--compress-program"))) return false;
49
+ if (command === "date" && tokens.some((token) => token === "-s" || token.startsWith("--set"))) return false;
50
+ if (command === "rg" && tokens.some((token) => token === "--pre" || token.startsWith("--pre="))) return false;
51
+ return true;
52
+ }
53
+ if (command === "find") return !FIND_MUTATION.test(segment);
54
+ if (command === "git") return safeGit(tokens);
55
+ if (["npm", "pnpm", "yarn", "bun"].includes(command)) return safePackageQuery(tokens);
56
+ return false;
57
+ }
58
+
59
+ /** Conservative shell allowlist used while plan mode is read-only. */
60
+ export function isSafePlanCommand(command: string): boolean {
61
+ const trimmed = command.trim();
62
+ if (!trimmed || trimmed.length > 8192 || SHELL_MUTATION.test(trimmed)) return false;
63
+ const segments = trimmed.split(/\s*\|\s*/);
64
+ return segments.length > 0 && segments.every(safeSegment);
65
+ }
66
+
67
+ export function extractPlanSteps(text: string): string[] {
68
+ const lines = text.replace(/\r/g, "").split("\n");
69
+ let inPlan = false;
70
+ const steps: string[] = [];
71
+ for (const raw of lines) {
72
+ const line = raw.trim();
73
+ if (!inPlan) {
74
+ if (/^(?:#{1,6}\s*)?plan\s*:?$/i.test(line)) inPlan = true;
75
+ continue;
76
+ }
77
+ const match = /^(\d+)[.)]\s+(.+)$/.exec(line);
78
+ if (match) {
79
+ const value = cleanText(match[2] ?? "", 240);
80
+ if (value) steps.push(value);
81
+ continue;
82
+ }
83
+ if (steps.length && /^(?:#{1,6}\s+|[A-Z][A-Za-z ]+:$)/.test(line)) break;
84
+ }
85
+ return steps.slice(0, 50);
86
+ }
87
+
88
+ export function planTextFromSteps(steps: readonly string[]): string {
89
+ return steps.map((step, index) => String(index + 1) + ". " + cleanText(step, 240)).join("\n");
90
+ }
91
+
92
+ export interface PlanSection { level: number; title: string; start: number; end: number; body: string }
93
+
94
+ /** Split markdown into ATX-heading sections, ignoring `#` lines inside fenced code. */
95
+ export function parsePlanSections(text: string): PlanSection[] {
96
+ const lines = text.replace(/\r/g, "").split("\n");
97
+ const sections: PlanSection[] = [];
98
+ let fence: string | undefined;
99
+ lines.forEach((line, index) => {
100
+ const marker = /^\s{0,3}(`{3,}|~{3,})/.exec(line)?.[1];
101
+ if (marker) {
102
+ if (!fence) fence = marker[0]!.repeat(marker.length);
103
+ else if (marker.startsWith(fence)) fence = undefined;
104
+ return;
105
+ }
106
+ if (fence) return;
107
+ const heading = /^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
108
+ if (!heading) return;
109
+ const previous = sections.at(-1);
110
+ if (previous) previous.end = index;
111
+ sections.push({ level: heading[1]!.length, title: cleanText(heading[2]!, 120), start: index, end: lines.length, body: "" });
112
+ });
113
+ for (const section of sections) section.body = lines.slice(section.start + 1, section.end).join("\n").replace(/^\n+|\n+$/g, "");
114
+ return sections;
115
+ }
116
+
117
+ /** Sections every submitted plan must contain (matched case-insensitively by prefix). */
118
+ export const REQUIRED_PLAN_SECTIONS = ["Context", "Approach", "Critical files", "Verification"] as const;
119
+ export const PLAN_TEMPLATE = [
120
+ "# <Plan title>",
121
+ "",
122
+ "## Context",
123
+ "Why this change is needed, the literal request, and the intended end state (2–4 sentences).",
124
+ "",
125
+ "## Approach",
126
+ "1. Ordered, behavior-grouped steps. Name exact files, symbols, reused helpers, signatures and error handling.",
127
+ "2. …",
128
+ "",
129
+ "## Critical files",
130
+ "- `path/to/file.ts` — symbol — why it changes (at most ~5 files).",
131
+ "",
132
+ "## Verification",
133
+ "- Exact commands to run and at least one concrete check of new behavior (input → expected output).",
134
+ "",
135
+ "## Assumptions",
136
+ "- Only decisions the user could override, each with a pre-decided fallback."
137
+ ].join("\n");
138
+
139
+ const sectionMatches = (title: string, required: string) => title.toLowerCase().replace(/[^a-z ]/g, "").trim().startsWith(required.toLowerCase());
140
+
141
+ /** A section with its nested subsections (everything until the next heading of the same or higher level). */
142
+ function sectionTree(sections: readonly PlanSection[], index: number): PlanSection[] {
143
+ const root = sections[index]!;
144
+ const tree = [root];
145
+ for (const section of sections.slice(index + 1)) {
146
+ if (section.level <= root.level) break;
147
+ tree.push(section);
148
+ }
149
+ return tree;
150
+ }
151
+
152
+ const findSection = (sections: readonly PlanSection[], name: string, minLevel = 1) =>
153
+ sections.findIndex((section) => section.level >= minLevel && sectionMatches(section.title, name));
154
+
155
+ export interface PlanValidation { ok: boolean; title?: string; missing: string[]; problems: string[] }
156
+
157
+ export function validatePlanDocument(text: string): PlanValidation {
158
+ const sections = parsePlanSections(text);
159
+ const title = sections.find((section) => section.level === 1)?.title;
160
+ const missing: string[] = [];
161
+ const problems: string[] = [];
162
+ if (!title) problems.push("add a single `# Title` heading at the top");
163
+ for (const required of REQUIRED_PLAN_SECTIONS) {
164
+ const index = findSection(sections, required, 2);
165
+ if (index < 0) { missing.push(required); continue; }
166
+ const content = sectionTree(sections, index).map((section) => section.body).join("\n");
167
+ if (!content.trim() && sectionTree(sections, index).length === 1) problems.push(`\`## ${sections[index]!.title}\` is empty`);
168
+ else if (required === "Approach" && sectionTree(sections, index).length === 1 && !/^\s*(?:\d+[.)]|[-*])\s+\S/m.test(content)) {
169
+ problems.push("list concrete numbered steps under `## Approach`");
170
+ }
171
+ }
172
+ return { ok: !missing.length && !problems.length, ...(title ? { title } : {}), missing, problems };
173
+ }
174
+
175
+ /** Actionable steps from the Approach section (`###` step headings or numbered items), falling back to a legacy `Plan:` list. */
176
+ export function extractApproachSteps(text: string): string[] {
177
+ const sections = parsePlanSections(text);
178
+ const index = findSection(sections, "Approach", 2);
179
+ if (index < 0) return extractPlanSteps(text);
180
+ const [approach, ...nested] = sectionTree(sections, index);
181
+ const direct = nested.filter((section) => section.level === approach!.level + 1);
182
+ const steps = direct.length
183
+ ? direct.map((section) => section.title.replace(/^(?:step\s*)?\d+[.):]?\s*/i, ""))
184
+ : approach!.body.split("\n").map((line) => /^\s*\d+[.)]\s+(.+)$/.exec(line)?.[1] ?? "").map((step) => step.replace(/\*\*/g, ""));
185
+ return steps.map((step) => cleanText(step, 240)).filter(Boolean).slice(0, 50);
186
+ }
187
+
188
+ /** Filesystem-safe slug for plan file names. */
189
+ export function planSlug(value: string): string {
190
+ return value.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "plan";
191
+ }