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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { fuzzyFilter, Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { cleanText } from "./status.ts";
|
|
4
|
+
|
|
5
|
+
export interface PastPrompt { text: string; source: "this session" | "earlier session"; at?: number }
|
|
6
|
+
|
|
7
|
+
const userText = (message: { content?: unknown }) => typeof message.content === "string" ? message.content
|
|
8
|
+
: Array.isArray(message.content) ? message.content.filter((part: { type?: string }) => part?.type === "text").map((part: { text?: string }) => part.text ?? "").join("\n") : "";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Prompts newest first and deduplicated: every user message in this session file (all branches),
|
|
12
|
+
* then the opening prompt of earlier sessions for the project.
|
|
13
|
+
*/
|
|
14
|
+
export function collectPrompts(entries: readonly unknown[], earlier: readonly { firstMessage?: string; modified?: Date }[] = []): PastPrompt[] {
|
|
15
|
+
const seen = new Set<string>();
|
|
16
|
+
const result: PastPrompt[] = [];
|
|
17
|
+
const add = (text: string, source: PastPrompt["source"], at?: number) => {
|
|
18
|
+
const value = text.trim();
|
|
19
|
+
const key = value.replace(/\s+/g, " ");
|
|
20
|
+
if (!value || value.length > 8000 || seen.has(key)) return;
|
|
21
|
+
seen.add(key);
|
|
22
|
+
result.push({ text: value, source, ...(at ? { at } : {}) });
|
|
23
|
+
};
|
|
24
|
+
const own = entries.filter((raw): raw is { type: string; message: { role: string; content?: unknown }; timestamp?: string } =>
|
|
25
|
+
!!raw && typeof raw === "object" && (raw as { type?: string }).type === "message" && (raw as { message?: { role?: string } }).message?.role === "user");
|
|
26
|
+
for (const entry of [...own].reverse()) add(userText(entry.message), "this session", entry.timestamp ? Date.parse(entry.timestamp) : undefined);
|
|
27
|
+
for (const session of earlier) if (session.firstMessage) add(session.firstMessage, "earlier session", session.modified?.getTime());
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Type to fuzzy-filter past prompts; Enter puts the selection in the composer for editing. */
|
|
32
|
+
export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly PastPrompt[]): Promise<string | undefined> {
|
|
33
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
|
|
34
|
+
if (!prompts.length) { ctx.ui.notify("pi-jar: no earlier prompts yet", "info"); return undefined; }
|
|
35
|
+
return ctx.ui.custom<string | undefined>((tui, theme, _keys, done) => {
|
|
36
|
+
let query = "";
|
|
37
|
+
let selected = 0;
|
|
38
|
+
let first = 0;
|
|
39
|
+
let visible: PastPrompt[] = [...prompts];
|
|
40
|
+
const rows = 10;
|
|
41
|
+
const filter = () => { visible = query ? fuzzyFilter([...prompts], query, (item) => item.text) : [...prompts]; selected = 0; first = 0; };
|
|
42
|
+
return {
|
|
43
|
+
invalidate() {},
|
|
44
|
+
handleInput(data: string) {
|
|
45
|
+
if (matchesKey(data, Key.escape)) return done(undefined);
|
|
46
|
+
if (matchesKey(data, Key.enter)) return done(visible[selected]?.text);
|
|
47
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.ctrl("p"))) selected = Math.max(0, selected - 1);
|
|
48
|
+
else if (matchesKey(data, Key.down) || matchesKey(data, Key.ctrl("n"))) selected = Math.min(visible.length - 1, selected + 1);
|
|
49
|
+
else if (matchesKey(data, Key.backspace)) { query = [...query].slice(0, -1).join(""); filter(); }
|
|
50
|
+
else if (matchesKey(data, Key.ctrl("u"))) { query = ""; filter(); }
|
|
51
|
+
else if (data.length >= 1 && !data.startsWith("\x1b") && !/[\x00-\x1f\x7f]/.test(data)) { query += data; filter(); }
|
|
52
|
+
tui.requestRender();
|
|
53
|
+
},
|
|
54
|
+
handleMouse(event: TuiMouseEvent) {
|
|
55
|
+
if (event.type === "wheel" && event.wheelDelta) { selected = Math.max(0, Math.min(visible.length - 1, selected + Math.sign(event.wheelDelta))); tui.requestRender(); return { handled: true }; }
|
|
56
|
+
if (event.type !== "click" || event.button !== "left") return;
|
|
57
|
+
const index = first + event.y - 2;
|
|
58
|
+
if (event.y >= 2 && event.y < 2 + rows && visible[index]) { done(visible[index]!.text); return { handled: true }; }
|
|
59
|
+
},
|
|
60
|
+
render(width: number): string[] {
|
|
61
|
+
const w = Math.max(20, width);
|
|
62
|
+
const inner = w - 4;
|
|
63
|
+
if (selected < first) first = selected;
|
|
64
|
+
if (selected >= first + rows) first = selected - rows + 1;
|
|
65
|
+
const fit = (text: string) => { const value = truncateToWidth(text, inner); return value + " ".repeat(Math.max(0, inner - visibleWidth(value))); };
|
|
66
|
+
const dim = (text: string) => theme.fg("dim", text);
|
|
67
|
+
const lines = [
|
|
68
|
+
theme.fg("accent", "╭─ ⌕ PROMPT HISTORY ") + dim("─".repeat(Math.max(0, w - 21)) + "╮"),
|
|
69
|
+
dim("│ ") + fit(theme.fg("accent", "› ") + query + "\x1b[7m \x1b[0m" + dim(` ${visible.length}/${prompts.length}`)) + dim(" │")
|
|
70
|
+
];
|
|
71
|
+
for (let row = 0; row < rows; row++) {
|
|
72
|
+
const item = visible[first + row];
|
|
73
|
+
const active = first + row === selected;
|
|
74
|
+
const text = item ? (active ? "❯ " : " ") + cleanText(item.text, 400) : "";
|
|
75
|
+
const tag = item?.source === "earlier session" ? dim(" · earlier") : "";
|
|
76
|
+
lines.push(dim("│ ") + fit(item ? theme.fg(active ? "accent" : "muted", truncateToWidth(text, Math.max(4, inner - 10))) + tag : "") + dim(" │"));
|
|
77
|
+
}
|
|
78
|
+
lines.push(dim("╰─ type to filter · ↑↓ select · Enter edit · Esc close " + "─".repeat(Math.max(0, w - 57)) + "╯"));
|
|
79
|
+
return lines.map((line) => truncateToWidth(line, w));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}, { overlay: true, overlayOptions: { width: "80%", maxHeight: "60%" } });
|
|
83
|
+
}
|
package/src/quota.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export type QuotaProvider = "openai-codex" | "anthropic";
|
|
2
|
+
export interface QuotaWindow { used: number; resetsAt?: number }
|
|
3
|
+
export interface Quota { fiveHour?: QuotaWindow; week?: QuotaWindow }
|
|
4
|
+
|
|
5
|
+
export const QUOTA_TTL_MS = 5 * 60_000;
|
|
6
|
+
export const QUOTA_PREFIX = "pi-jar.quota.";
|
|
7
|
+
|
|
8
|
+
function windowValue(value: unknown): QuotaWindow | undefined {
|
|
9
|
+
if (!value || typeof value !== "object") return undefined;
|
|
10
|
+
const item = value as Record<string, unknown>;
|
|
11
|
+
if (typeof item.used !== "number" || !Number.isFinite(item.used) || item.used < 0 || item.used > 100) return undefined;
|
|
12
|
+
return { used: item.used, ...(typeof item.resetsAt === "number" && Number.isFinite(item.resetsAt) ? { resetsAt: item.resetsAt } : {}) };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseQuota(value: unknown): Quota | undefined {
|
|
16
|
+
if (!value || typeof value !== "object") return undefined;
|
|
17
|
+
const object = value as Record<string, unknown>;
|
|
18
|
+
const fiveHour = windowValue(object.fiveHour);
|
|
19
|
+
const week = windowValue(object.week);
|
|
20
|
+
return fiveHour || week ? { ...(fiveHour ? { fiveHour } : {}), ...(week ? { week } : {}) } : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Public statuses are authoritative only when valid and fresh. No arbitrary status prose is parsed. */
|
|
24
|
+
export function publishedQuota(statuses: ReadonlyMap<string, string>, provider: string, now: number): Quota | undefined {
|
|
25
|
+
const raw = statuses.get(QUOTA_PREFIX + provider);
|
|
26
|
+
if (typeof raw !== "string" || raw.length > 2048) return undefined;
|
|
27
|
+
try {
|
|
28
|
+
const data: unknown = JSON.parse(raw);
|
|
29
|
+
if (!data || typeof data !== "object") return undefined;
|
|
30
|
+
const obj = data as Record<string, unknown>;
|
|
31
|
+
if (typeof obj.expiresAt !== "number" || obj.expiresAt <= now || obj.expiresAt > now + QUOTA_TTL_MS) return undefined;
|
|
32
|
+
return parseQuota(obj);
|
|
33
|
+
} catch { return undefined; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function codexAccountId(token: string): string | undefined {
|
|
37
|
+
try {
|
|
38
|
+
const payload = token.split(".")[1];
|
|
39
|
+
if (!payload) return undefined;
|
|
40
|
+
const claims: unknown = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
41
|
+
if (!claims || typeof claims !== "object") return undefined;
|
|
42
|
+
const auth = (claims as Record<string, unknown>)["https://api.openai.com/auth"];
|
|
43
|
+
const id = auth && typeof auth === "object" ? (auth as Record<string, unknown>).chatgpt_account_id : undefined;
|
|
44
|
+
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
45
|
+
} catch { return undefined; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Never persist credentials; resolve through Pi's public model registry only on explicit opt-in. */
|
|
49
|
+
export async function fetchQuota(provider: QuotaProvider, resolveAuth: (provider: string) => Promise<{ auth: { apiKey?: string }; source?: string } | undefined>, signal: AbortSignal): Promise<Quota | undefined> {
|
|
50
|
+
try {
|
|
51
|
+
const resolved = await resolveAuth(provider);
|
|
52
|
+
if (signal.aborted || !resolved?.source?.toLowerCase().includes("oauth") || !resolved.auth.apiKey) return undefined;
|
|
53
|
+
const token = resolved.auth.apiKey;
|
|
54
|
+
const headers: Record<string, string> = { Authorization: `Bearer ${token}`, Accept: "application/json" };
|
|
55
|
+
let url: string;
|
|
56
|
+
if (provider === "openai-codex") {
|
|
57
|
+
const id = codexAccountId(token);
|
|
58
|
+
if (!id) return undefined;
|
|
59
|
+
headers["ChatGPT-Account-Id"] = id;
|
|
60
|
+
url = "https://chatgpt.com/backend-api/wham/usage";
|
|
61
|
+
} else {
|
|
62
|
+
headers["anthropic-beta"] = "oauth-2025-04-20";
|
|
63
|
+
url = "https://api.anthropic.com/api/oauth/usage";
|
|
64
|
+
}
|
|
65
|
+
const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) });
|
|
66
|
+
if (!response.ok) return undefined;
|
|
67
|
+
const data: unknown = await response.json();
|
|
68
|
+
if (!data || typeof data !== "object") return undefined;
|
|
69
|
+
const body = data as Record<string, unknown>;
|
|
70
|
+
if (provider === "openai-codex") {
|
|
71
|
+
const rate = body.rate_limit && typeof body.rate_limit === "object" ? body.rate_limit as Record<string, unknown> : {};
|
|
72
|
+
const parse = (window: unknown): QuotaWindow | undefined => {
|
|
73
|
+
if (!window || typeof window !== "object") return undefined;
|
|
74
|
+
const item = window as Record<string, unknown>;
|
|
75
|
+
return windowValue({ used: item.used_percent, resetsAt: typeof item.reset_at === "number" ? item.reset_at * 1000 : undefined });
|
|
76
|
+
};
|
|
77
|
+
return parseQuota({ fiveHour: parse(rate.primary_window), week: parse(rate.secondary_window) });
|
|
78
|
+
}
|
|
79
|
+
const parse = (window: unknown): QuotaWindow | undefined => {
|
|
80
|
+
if (!window || typeof window !== "object") return undefined;
|
|
81
|
+
const item = window as Record<string, unknown>;
|
|
82
|
+
const utilization = item.utilization;
|
|
83
|
+
const resetsAt = typeof item.resets_at === "string" ? Date.parse(item.resets_at) : undefined;
|
|
84
|
+
return windowValue({ used: typeof utilization === "number" && utilization <= 1 ? utilization * 100 : utilization, resetsAt });
|
|
85
|
+
};
|
|
86
|
+
return parseQuota({ fiveHour: parse(body.five_hour), week: parse(body.seven_day) });
|
|
87
|
+
} catch { return undefined; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One in-flight request per provider, negative-cache failures, never show expired values. */
|
|
91
|
+
export class QuotaCache {
|
|
92
|
+
private entries = new Map<QuotaProvider, { value?: Quota; expiresAt: number }>();
|
|
93
|
+
private pending = new Map<QuotaProvider, AbortController>();
|
|
94
|
+
enabled = false;
|
|
95
|
+
private readonly fetcher: (provider: QuotaProvider, signal: AbortSignal) => Promise<Quota | undefined>;
|
|
96
|
+
private readonly changed: () => void;
|
|
97
|
+
constructor(fetcher: (provider: QuotaProvider, signal: AbortSignal) => Promise<Quota | undefined>, changed: () => void) {
|
|
98
|
+
this.fetcher = fetcher;
|
|
99
|
+
this.changed = changed;
|
|
100
|
+
}
|
|
101
|
+
get(provider: string | undefined, statuses: ReadonlyMap<string, string>, now: number): Quota | undefined {
|
|
102
|
+
if (provider !== "openai-codex" && provider !== "anthropic") return undefined;
|
|
103
|
+
const published = publishedQuota(statuses, provider, now);
|
|
104
|
+
if (published) {
|
|
105
|
+
this.pending.get(provider)?.abort();
|
|
106
|
+
this.pending.delete(provider);
|
|
107
|
+
return published;
|
|
108
|
+
}
|
|
109
|
+
if (!this.enabled) return undefined;
|
|
110
|
+
const cached = this.entries.get(provider);
|
|
111
|
+
if (cached && cached.expiresAt > now) return cached.value;
|
|
112
|
+
if (!this.pending.has(provider)) {
|
|
113
|
+
const controller = new AbortController();
|
|
114
|
+
this.pending.set(provider, controller);
|
|
115
|
+
// A microtask keeps IO outside the synchronous render cycle.
|
|
116
|
+
queueMicrotask(() => {
|
|
117
|
+
if (controller.signal.aborted) {
|
|
118
|
+
if (this.pending.get(provider) === controller) this.pending.delete(provider);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
void this.fetcher(provider, controller.signal).then((value) => {
|
|
122
|
+
if (!controller.signal.aborted) {
|
|
123
|
+
this.entries.set(provider, { value, expiresAt: Date.now() + QUOTA_TTL_MS });
|
|
124
|
+
this.changed();
|
|
125
|
+
}
|
|
126
|
+
}).catch(() => {}).finally(() => {
|
|
127
|
+
if (this.pending.get(provider) === controller) this.pending.delete(provider);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
stop(): void { for (const controller of this.pending.values()) controller.abort(); this.pending.clear(); this.entries.clear(); }
|
|
134
|
+
}
|
package/src/roles-ui.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, stripTerminalSequences, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { promptText } from "./dialogs.ts";
|
|
4
|
+
import { isRoleName, normalizeSpec, THINKING, type ModelRoleManager, type RoleRow } from "./model-roles.ts";
|
|
5
|
+
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
6
|
+
|
|
7
|
+
type RoleAction = "model" | "alias" | "thinking" | "scope" | "activate" | "clear" | "new" | "delete";
|
|
8
|
+
const ACTIONS: { action: RoleAction; key: string; hint: string; label: string }[] = [
|
|
9
|
+
{ action: "activate", key: "\r", hint: "⏎", label: "Activate this role now" },
|
|
10
|
+
{ action: "model", key: "m", hint: "m", label: "Assign a model" },
|
|
11
|
+
{ action: "alias", key: "a", hint: "a", label: "Alias another role (@role)" },
|
|
12
|
+
{ action: "thinking", key: "t", hint: "t", label: "Set thinking effort" },
|
|
13
|
+
{ action: "scope", key: "s", hint: "s", label: "Move between global and project" },
|
|
14
|
+
{ action: "clear", key: "c", hint: "c", label: "Clear the assignment" },
|
|
15
|
+
{ action: "new", key: "n", hint: "n", label: "New custom role" },
|
|
16
|
+
{ action: "delete", key: "d", hint: "d", label: "Delete this custom role" }
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function detail(row: RoleRow, active: string | undefined): [ThemeColor, string][] {
|
|
20
|
+
const target: [ThemeColor, string] = row.error ? ["error", "⚠ " + row.error]
|
|
21
|
+
: row.resolved ? ["accent", row.resolved.provider + "/" + row.resolved.model] : ["dim", "follows the current model"];
|
|
22
|
+
return [
|
|
23
|
+
["accent", row.label + (row.custom ? " (custom role)" : "")],
|
|
24
|
+
["dim", ""],
|
|
25
|
+
["muted", "Role " + row.role + (active === row.role ? " ● active" : "")],
|
|
26
|
+
["muted", "Used by " + (row.usedBy ?? "your prompts and other extensions")],
|
|
27
|
+
["muted", "Spec " + (row.spec ?? "—")],
|
|
28
|
+
[target[0], "Model " + target[1]],
|
|
29
|
+
["muted", "Alias " + (row.resolved && row.resolved.via.length > 1 ? row.resolved.via.join(" → ") : "—")],
|
|
30
|
+
["muted", "Effort " + (row.resolved?.thinking ?? "follow current")],
|
|
31
|
+
["muted", "Scope " + (row.scope ?? "unset") + (row.scope === "project" ? " (.pi/pi-jar-roles.json)" : row.scope === "global" ? " (agent dir)" : "")],
|
|
32
|
+
["dim", ""],
|
|
33
|
+
["dim", "Specs: provider/model[:effort] · @role[:effort] · *"],
|
|
34
|
+
["dim", "Project assignments override global ones."]
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initial: number): Promise<{ action: RoleAction; index: number } | undefined> {
|
|
39
|
+
return ctx.ui.custom<{ action: RoleAction; index: number } | undefined>((tui, theme, _keys, done) => {
|
|
40
|
+
let selected = initial;
|
|
41
|
+
let scroll = 0;
|
|
42
|
+
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
43
|
+
let width = 80;
|
|
44
|
+
let actionsTop = -1;
|
|
45
|
+
const rows = () => roles.list();
|
|
46
|
+
const finish = (action: RoleAction) => done({ action, index: selected });
|
|
47
|
+
return {
|
|
48
|
+
invalidate() {},
|
|
49
|
+
handleInput(data: string) {
|
|
50
|
+
const count = rows().length;
|
|
51
|
+
if (matchesKey(data, Key.escape) || data === "q") return done(undefined);
|
|
52
|
+
if (matchesKey(data, Key.up) || data === "k") selected = (selected + count - 1) % count;
|
|
53
|
+
else if (matchesKey(data, Key.down) || data === "j") selected = (selected + 1) % count;
|
|
54
|
+
else if (matchesKey(data, Key.enter)) return finish("activate");
|
|
55
|
+
else {
|
|
56
|
+
const hit = ACTIONS.find((item) => item.key === data);
|
|
57
|
+
if (hit) return finish(hit.action);
|
|
58
|
+
}
|
|
59
|
+
tui.requestRender();
|
|
60
|
+
},
|
|
61
|
+
handleMouse(event: TuiMouseEvent) {
|
|
62
|
+
const count = rows().length;
|
|
63
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
64
|
+
selected = Math.max(0, Math.min(count - 1, selected + Math.sign(event.wheelDelta)));
|
|
65
|
+
tui.requestRender(); return { handled: true };
|
|
66
|
+
}
|
|
67
|
+
if (event.type !== "click" || event.button !== "left") return;
|
|
68
|
+
if (event.y === 0 && event.x >= width - 3) { done(undefined); return { handled: true }; }
|
|
69
|
+
const row = event.y - layout.top;
|
|
70
|
+
// Actions are listed in the detail pane, one per row.
|
|
71
|
+
const actionIndex = row - actionsTop;
|
|
72
|
+
if (row >= 0 && row < layout.rows && event.x >= layout.bodyX && actionIndex >= 0 && actionIndex < ACTIONS.length) {
|
|
73
|
+
finish(ACTIONS[actionIndex]!.action); return { handled: true };
|
|
74
|
+
}
|
|
75
|
+
if (row >= 0 && row < layout.rows && (layout.leftWidth === 0 || event.x < layout.leftWidth + 3)) {
|
|
76
|
+
const index = scroll + row;
|
|
77
|
+
if (index < count) { selected = index; tui.requestRender(); return { handled: true, focus: true }; }
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
render(available: number): string[] {
|
|
81
|
+
width = Math.max(24, available);
|
|
82
|
+
const list = rows();
|
|
83
|
+
selected = Math.max(0, Math.min(list.length - 1, selected));
|
|
84
|
+
const height = Math.min(contentRows(5, 8), Math.max(12 + ACTIONS.length + 2, list.length));
|
|
85
|
+
if (selected < scroll) scroll = selected;
|
|
86
|
+
if (selected >= scroll + height) scroll = selected - height + 1;
|
|
87
|
+
const active = roles.activeRole();
|
|
88
|
+
const left = list.slice(scroll, scroll + height).map((row, index) => {
|
|
89
|
+
const current = scroll + index === selected;
|
|
90
|
+
const mark = row.error ? "⚠" : row.resolved ? "●" : "○";
|
|
91
|
+
return theme.fg(current ? "accent" : row.resolved ? "muted" : "dim", (current ? "❯ " : " ") + mark + " " + row.role + (active === row.role ? " *" : ""));
|
|
92
|
+
});
|
|
93
|
+
const row = list[selected]!;
|
|
94
|
+
const info = detail(row, active).map(([color, text]) => theme.fg(color, text));
|
|
95
|
+
// Narrow terminals collapse the sidebar into a pager header above the details.
|
|
96
|
+
const narrow = sidebarWidth(width) === 0;
|
|
97
|
+
const header = narrow ? [theme.fg("accent", `‹ ${selected + 1}/${list.length} ${row.role} ›`)] : [];
|
|
98
|
+
const actions = optionList(theme, ACTIONS.map((item) => item.label), -1, ACTIONS.map((item) => item.hint))
|
|
99
|
+
.map((line, at) => ACTIONS[at]!.action === "delete" && !row.custom ? theme.fg("dim", stripTerminalSequences(line)) : line);
|
|
100
|
+
// Actions come right after the facts; the spec help (last three lines) goes last.
|
|
101
|
+
const facts = info.slice(0, -3), help = info.slice(-3);
|
|
102
|
+
const body = [...header, ...facts, theme.fg("accent", "Actions"), ...actions, ...help];
|
|
103
|
+
actionsTop = header.length + facts.length + 1;
|
|
104
|
+
const split = splitFrame(theme, width, "pi-jar · roles", narrow ? [] : left, body, [
|
|
105
|
+
theme.fg("dim", "↑↓ choose role · press a key or click an action · Esc close")
|
|
106
|
+
], height);
|
|
107
|
+
layout = split.layout;
|
|
108
|
+
return split.lines;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Keyboard- and pointer-driven model role manager. */
|
|
115
|
+
export async function openRolesUi(ctx: ExtensionContext, roles: ModelRoleManager): Promise<void> {
|
|
116
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
117
|
+
let index = 0;
|
|
118
|
+
while (true) {
|
|
119
|
+
const result = await roleScreen(ctx, roles, index);
|
|
120
|
+
if (!result) return;
|
|
121
|
+
index = result.index;
|
|
122
|
+
const row = roles.list()[index];
|
|
123
|
+
if (!row) return;
|
|
124
|
+
try {
|
|
125
|
+
await applyAction(ctx, roles, row, result.action, (next) => { index = next; });
|
|
126
|
+
} catch (error) {
|
|
127
|
+
ctx.ui.notify("Role change failed: " + (error as Error).message, "error");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function applyAction(ctx: ExtensionContext, roles: ModelRoleManager, row: RoleRow, action: RoleAction, select: (index: number) => void): Promise<void> {
|
|
133
|
+
const scope = row.scope ?? "global";
|
|
134
|
+
const effort = () => {
|
|
135
|
+
const spec = row.spec ?? "";
|
|
136
|
+
const colon = spec.lastIndexOf(":");
|
|
137
|
+
return colon > 0 && THINKING.includes(spec.slice(colon + 1) as never) ? spec.slice(colon) : "";
|
|
138
|
+
};
|
|
139
|
+
const base = () => {
|
|
140
|
+
const spec = row.spec ?? "";
|
|
141
|
+
return effort() ? spec.slice(0, spec.length - effort().length) : spec;
|
|
142
|
+
};
|
|
143
|
+
switch (action) {
|
|
144
|
+
case "model": {
|
|
145
|
+
const models = ctx.modelRegistry.getAvailable().slice().sort((a, b) => (a.provider + "/" + a.id).localeCompare(b.provider + "/" + b.id));
|
|
146
|
+
if (!models.length) { ctx.ui.notify("No authenticated models are currently available", "warning"); return; }
|
|
147
|
+
const labels = models.map((model) => model.provider + "/" + model.id);
|
|
148
|
+
const selected = await ctx.ui.select("Model for " + row.role, labels);
|
|
149
|
+
if (selected) roles.update(row.role, selected + effort(), scope);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
case "alias": {
|
|
153
|
+
const targets = roles.list().filter((item) => item.role !== row.role).map((item) => "@" + item.role);
|
|
154
|
+
const selected = await ctx.ui.select("Alias " + row.role + " to", targets);
|
|
155
|
+
if (selected) roles.update(row.role, selected + effort(), scope);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
case "thinking": {
|
|
159
|
+
if (!row.spec) { ctx.ui.notify("Assign a model or alias first", "warning"); return; }
|
|
160
|
+
const selected = await ctx.ui.select("Thinking effort for " + row.role, ["follow current", ...THINKING]);
|
|
161
|
+
if (selected) roles.update(row.role, base() + (selected === "follow current" ? "" : ":" + selected), scope);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
case "scope": {
|
|
165
|
+
if (!row.spec) { ctx.ui.notify("Assign the role before moving it between scopes", "warning"); return; }
|
|
166
|
+
const next = scope === "global" ? "project" : "global";
|
|
167
|
+
roles.update(row.role, row.spec, next);
|
|
168
|
+
roles.update(row.role, undefined, scope);
|
|
169
|
+
ctx.ui.notify(`Role ${row.role} moved to ${next} scope`, "info");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
case "activate": await roles.activate(row.role, ctx); return;
|
|
173
|
+
case "clear": if (row.spec) roles.update(row.role, undefined, scope); return;
|
|
174
|
+
case "new": {
|
|
175
|
+
const name = (await promptText(ctx, "New role", "Role name (lowercase, e.g. review)"))?.toLowerCase();
|
|
176
|
+
if (!name) return;
|
|
177
|
+
if (!isRoleName(name)) { ctx.ui.notify("Role names use a-z, 0-9 and -, starting with a letter", "warning"); return; }
|
|
178
|
+
const spec = await promptText(ctx, "Role target", "provider/model[:effort] or @role", "@default");
|
|
179
|
+
if (!spec || !normalizeSpec(spec)) { ctx.ui.notify("Invalid role target", "warning"); return; }
|
|
180
|
+
roles.update(name, spec, "global");
|
|
181
|
+
select(roles.list().findIndex((item) => item.role === name));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
case "delete":
|
|
185
|
+
if (!row.custom) { ctx.ui.notify("Built-in roles can be cleared, not deleted", "info"); return; }
|
|
186
|
+
roles.update(row.role, undefined, scope);
|
|
187
|
+
select(0);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/roles.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type RoleState =
|
|
2
|
+
| "idle"
|
|
3
|
+
| "thinking"
|
|
4
|
+
| "working"
|
|
5
|
+
| "waiting"
|
|
6
|
+
| "reviewing"
|
|
7
|
+
| "done"
|
|
8
|
+
| "failed";
|
|
9
|
+
|
|
10
|
+
export interface RoleStatus {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
name: string;
|
|
14
|
+
state: RoleState;
|
|
15
|
+
task?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Sample data only. Live teammates are supplied by external publishers, not by this list. */
|
|
19
|
+
export function createDemoRoles(): RoleStatus[] {
|
|
20
|
+
return [
|
|
21
|
+
{ id: "explorer", label: "EXP", name: "Explorer", state: "thinking", task: "analyze" },
|
|
22
|
+
{ id: "builder", label: "BLD", name: "Builder", state: "working", task: "implement" },
|
|
23
|
+
{ id: "reviewer", label: "REV", name: "Reviewer", state: "waiting", task: "waiting" }
|
|
24
|
+
];
|
|
25
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import type { SessionInfo } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { sessionDisplayName } from "./composer.ts";
|
|
4
|
+
import { GOAL_ENTRY, GoalStore } from "./goals.ts";
|
|
5
|
+
import { cleanText } from "./status.ts";
|
|
6
|
+
|
|
7
|
+
export const RECENT_SESSIONS = 3;
|
|
8
|
+
/** Larger session files are listed without goal/plan details. */
|
|
9
|
+
const MAX_SCAN_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export interface RecentSession { path: string; title: string; modified: Date; messages: number; goal?: string; plan?: string }
|
|
12
|
+
|
|
13
|
+
export function ago(date: Date, now = Date.now()): string {
|
|
14
|
+
const minutes = Math.max(0, Math.floor((now - date.getTime()) / 60_000));
|
|
15
|
+
if (minutes < 1) return "now";
|
|
16
|
+
if (minutes < 60) return `${minutes}m`;
|
|
17
|
+
if (minutes < 60 * 24) return `${Math.round(minutes / 60)}h`;
|
|
18
|
+
return `${Math.round(minutes / 60 / 24)}d`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Latest goal and plan title recorded by pi-jar in a session file's JSONL. */
|
|
22
|
+
export function sessionWorkflow(jsonl: string): { goal?: string; plan?: string } {
|
|
23
|
+
const entries: unknown[] = [];
|
|
24
|
+
let plan: string | undefined;
|
|
25
|
+
for (const line of jsonl.split("\n")) {
|
|
26
|
+
if (!line.includes('"pi-jar.')) continue;
|
|
27
|
+
let entry: { type?: string; customType?: string; data?: { title?: unknown } };
|
|
28
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
29
|
+
if (entry.type !== "custom") continue;
|
|
30
|
+
if (entry.customType === GOAL_ENTRY) entries.push(entry);
|
|
31
|
+
else if (entry.customType === "pi-jar.plan" && typeof entry.data?.title === "string" && entry.data.title.trim()) plan = cleanText(entry.data.title, 60);
|
|
32
|
+
}
|
|
33
|
+
const goals = new GoalStore(() => {});
|
|
34
|
+
goals.restore(entries);
|
|
35
|
+
const goal = goals.current();
|
|
36
|
+
return { ...(goal && goal.status !== "dropped" ? { goal: cleanText(goal.text, 60) + (goal.status === "complete" ? " ✔" : goal.status === "paused" ? " (paused)" : "") } : {}),
|
|
37
|
+
...(plan ? { plan } : {}) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Most recent sessions for the project other than the current one, with their goal and plan. */
|
|
41
|
+
export async function recentSessions(sessions: readonly SessionInfo[], current: string | undefined, limit = RECENT_SESSIONS,
|
|
42
|
+
read: (path: string) => Promise<string> = (path) => readFile(path, "utf8"), size: (path: string) => Promise<number> = async (path) => (await stat(path)).size): Promise<RecentSession[]> {
|
|
43
|
+
const picked = [...sessions].filter((session) => session.path !== current && session.messageCount > 0)
|
|
44
|
+
.sort((a, b) => b.modified.getTime() - a.modified.getTime()).slice(0, limit);
|
|
45
|
+
return Promise.all(picked.map(async (session) => {
|
|
46
|
+
const title = session.name ? cleanText(session.name, 60) : cleanText(session.firstMessage || sessionDisplayName(undefined, session.id), 60);
|
|
47
|
+
let workflow: { goal?: string; plan?: string } = {};
|
|
48
|
+
try { if (await size(session.path) <= MAX_SCAN_BYTES) workflow = sessionWorkflow(await read(session.path)); }
|
|
49
|
+
catch { /* unreadable session: show it without details */ }
|
|
50
|
+
return { path: session.path, title, modified: session.modified, messages: session.messageCount, ...workflow };
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ExtensionContext, SessionInfo } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { sessionDisplayName } from "./composer.ts";
|
|
4
|
+
import { cleanText } from "./status.ts";
|
|
5
|
+
|
|
6
|
+
/** Search session titles and first prompts, without exposing raw JSONL contents in the picker. */
|
|
7
|
+
export function filterSessions(sessions: readonly SessionInfo[], query: string): SessionInfo[] {
|
|
8
|
+
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
9
|
+
return sessions.filter((session) => {
|
|
10
|
+
const haystack = `${session.name ?? ""} ${sessionDisplayName(session.name, session.id)} ${session.firstMessage}`.toLowerCase();
|
|
11
|
+
return terms.every((term) => haystack.includes(term));
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function pickSession(ctx: ExtensionContext, sessions: readonly SessionInfo[], initial = ""): Promise<string | undefined> {
|
|
16
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
|
|
17
|
+
return ctx.ui.custom<string | undefined>((tui, theme, _keys, done) => {
|
|
18
|
+
let query = initial.slice(0, 100);
|
|
19
|
+
let selected = 0;
|
|
20
|
+
let first = 0;
|
|
21
|
+
let width = 80;
|
|
22
|
+
const pageSize = () => Math.max(2, Math.min(10, Math.floor(((process.stdout.rows ?? 24) - 7) / 2)));
|
|
23
|
+
const matches = () => filterSessions(sessions, query);
|
|
24
|
+
const refresh = () => { selected = 0; first = 0; tui.requestRender(); };
|
|
25
|
+
return {
|
|
26
|
+
invalidate() {},
|
|
27
|
+
handleInput(data: string) {
|
|
28
|
+
const rows = matches();
|
|
29
|
+
if (matchesKey(data, Key.escape)) return done(undefined);
|
|
30
|
+
if (matchesKey(data, Key.enter)) return done(rows[selected]?.path);
|
|
31
|
+
if (matchesKey(data, Key.up)) selected = Math.max(0, selected - 1);
|
|
32
|
+
else if (matchesKey(data, Key.down)) selected = Math.min(rows.length - 1, selected + 1);
|
|
33
|
+
else if (matchesKey(data, Key.pageUp)) selected = Math.max(0, selected - pageSize());
|
|
34
|
+
else if (matchesKey(data, Key.pageDown)) selected = Math.min(rows.length - 1, selected + pageSize());
|
|
35
|
+
else if (matchesKey(data, Key.backspace) || data === "\x7f") { query = query.slice(0, -1); return refresh(); }
|
|
36
|
+
else if (/^[\x20-\x7e]$/.test(data) && query.length < 100) { query += data; return refresh(); }
|
|
37
|
+
first = Math.max(0, Math.min(selected, first), selected - pageSize() + 1);
|
|
38
|
+
tui.requestRender();
|
|
39
|
+
},
|
|
40
|
+
handleMouse(event: TuiMouseEvent) {
|
|
41
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
42
|
+
selected = Math.max(0, Math.min(matches().length - 1, selected + Math.sign(event.wheelDelta)));
|
|
43
|
+
first = Math.max(0, Math.min(selected, first), selected - pageSize() + 1);
|
|
44
|
+
tui.requestRender(); return { handled: true };
|
|
45
|
+
}
|
|
46
|
+
if (event.type !== "click" || event.button !== "left" || event.x >= width) return;
|
|
47
|
+
const index = first + Math.floor((event.y - 2) / 2);
|
|
48
|
+
const row = matches()[index];
|
|
49
|
+
if (event.y >= 2 && event.y < 2 + 2 * pageSize() && row) {
|
|
50
|
+
selected = index; done(row.path); return { handled: true };
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
render(available: number): string[] {
|
|
54
|
+
width = Math.max(0, available);
|
|
55
|
+
const fit = (line: string) => truncateToWidth(line, width);
|
|
56
|
+
const rows = matches();
|
|
57
|
+
selected = Math.max(0, Math.min(selected, rows.length - 1));
|
|
58
|
+
const lines = [
|
|
59
|
+
fit(theme.fg("accent", `╭─ SESSIONS · ${rows.length}/${sessions.length} ─`)),
|
|
60
|
+
fit(theme.fg("muted", `│ Search: ${cleanText(query, 100)}${query ? "" : "(type to filter)"}`))
|
|
61
|
+
];
|
|
62
|
+
for (let i = first; i < Math.min(rows.length, first + pageSize()); i++) {
|
|
63
|
+
const session = rows[i]!;
|
|
64
|
+
const title = cleanText(sessionDisplayName(session.name, session.id), 55);
|
|
65
|
+
const prompt = cleanText(session.firstMessage, 90);
|
|
66
|
+
const when = Number.isNaN(session.modified.getTime()) ? "" : session.modified.toISOString().slice(0, 10);
|
|
67
|
+
lines.push(fit(theme.fg(i === selected ? "accent" : "muted", `│ ${i === selected ? "❯" : " "} ${title} · ${when}`)));
|
|
68
|
+
lines.push(fit(theme.fg("dim", `│ ${prompt || "No first prompt"}`)));
|
|
69
|
+
}
|
|
70
|
+
if (!rows.length) lines.push(fit(theme.fg("dim", "│ No matching sessions")));
|
|
71
|
+
lines.push(fit(theme.fg("dim", "╰─ Type to search · ↑↓/PgUp/PgDn select · Enter resume · Esc cancel")));
|
|
72
|
+
return lines;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
}
|