pi-jar 0.1.3 → 0.1.5
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/README.md +57 -15
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/WORKFLOWS.md +32 -7
- package/extensions/index.ts +127 -48
- package/package.json +1 -1
- package/src/activity-view.ts +324 -44
- package/src/advisor.ts +9 -3
- package/src/ask-tool.ts +12 -7
- package/src/changes.ts +35 -11
- package/src/child-baselines.ts +42 -0
- package/src/compact-tools.ts +58 -24
- package/src/composer.ts +39 -12
- package/src/delegate-worktree.ts +360 -0
- package/src/delegate.ts +1137 -129
- package/src/democracy.ts +192 -0
- package/src/dialogs.ts +9 -5
- package/src/diff-view.ts +13 -1
- package/src/discussion.ts +153 -0
- package/src/flame.ts +18 -5
- package/src/footer.ts +38 -19
- package/src/goal-loop.ts +8 -1
- package/src/history-ui.ts +19 -12
- package/src/history.ts +3 -1
- package/src/model-roles.ts +120 -36
- package/src/panel.ts +5 -1
- package/src/prompt-search.ts +16 -8
- package/src/roles-ui.ts +18 -10
- package/src/row-cache.ts +26 -0
- package/src/session-ui.ts +9 -4
- package/src/settings-ui.ts +46 -24
- package/src/settings.ts +9 -1
- package/src/shells.ts +2 -1
- package/src/side-model.ts +45 -25
- package/src/split-view.ts +29 -12
- package/src/status.ts +2 -1
- package/src/task-tool.ts +156 -54
- package/src/tasks-ui.ts +15 -5
- package/src/tasks.ts +224 -38
- package/src/welcome.ts +26 -13
package/src/history-ui.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Input, Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
-
import { createHistorySnapshot, historyChunk, historyPage, safeHistoryText, type HistoryItem } from "./history.ts";
|
|
3
|
+
import { createHistorySnapshot, historyChunk, historyPage, safeHistoryText, type HistoryChunk, type HistoryItem } from "./history.ts";
|
|
4
|
+
import { RowCache } from "./row-cache.ts";
|
|
4
5
|
import { sessionBranch } from "./session-branch.ts";
|
|
5
6
|
|
|
6
7
|
/** Separate, read-only view. Pi's native transcript and session file are never modified. */
|
|
@@ -52,7 +53,15 @@ export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
|
|
|
52
53
|
tui.requestRender();
|
|
53
54
|
};
|
|
54
55
|
input.onEscape = () => { searching = false; input.focused = false; tui.requestRender(); };
|
|
55
|
-
|
|
56
|
+
// Every render (each streamed token while this is open) needs the selected chunk; re-slice only on change.
|
|
57
|
+
let loaded: { item: HistoryItem; offset: number; chunk: HistoryChunk } | undefined;
|
|
58
|
+
const chunks = () => {
|
|
59
|
+
const item = selectedItem();
|
|
60
|
+
if (!item) return undefined;
|
|
61
|
+
const offset = offsets.at(-1)!;
|
|
62
|
+
if (loaded?.item !== item || loaded.offset !== offset) loaded = { item, offset, chunk: historyChunk(item, offset) };
|
|
63
|
+
return loaded.chunk;
|
|
64
|
+
};
|
|
56
65
|
const moveChunk = (direction: number) => {
|
|
57
66
|
const chunk = chunks();
|
|
58
67
|
if (direction > 0 && chunk?.more && chunk.nextOffset > offsets.at(-1)!) offsets.push(chunk.nextOffset);
|
|
@@ -60,13 +69,10 @@ export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
|
|
|
60
69
|
detailScroll = 0;
|
|
61
70
|
expanded = true;
|
|
62
71
|
};
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return fit(theme.fg("dim", "│ ") + clipped + " ".repeat(Math.max(0, inside - visibleWidth(clipped))) + theme.fg("dim", " │"));
|
|
68
|
-
};
|
|
69
|
-
const border = (left: string, middle: string, right: string) => fit(theme.fg("dim", left + middle.repeat(Math.max(0, width - 2)) + right));
|
|
72
|
+
// Rows are built from exact-width parts: one truncate-and-pad pass per new row, reused while it stays on screen.
|
|
73
|
+
const framed = new RowCache();
|
|
74
|
+
const line = (content: string) => framed.get(content, (row) => theme.fg("dim", "│ ") + truncateToWidth(row, Math.max(0, width - 4), "...", true) + theme.fg("dim", " │"));
|
|
75
|
+
const border = (left: string, middle: string, right: string) => theme.fg("dim", left + middle.repeat(Math.max(0, width - 2)) + right);
|
|
70
76
|
return {
|
|
71
77
|
invalidate() { input.invalidate(); },
|
|
72
78
|
handleInput(data: string) {
|
|
@@ -103,11 +109,12 @@ export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
|
|
|
103
109
|
},
|
|
104
110
|
render(available: number): string[] {
|
|
105
111
|
width = Math.max(8, available);
|
|
112
|
+
framed.begin(`${width}\0${theme.fg("dim", "│")}`);
|
|
106
113
|
const start = first();
|
|
107
114
|
const items = current.items.slice(start, start + listRows());
|
|
108
115
|
const heading = ` pi-jar · history · ${snapshot.visible.length} turns`;
|
|
109
|
-
const top =
|
|
110
|
-
+ "─".repeat(Math.max(0, width - 4 - visibleWidth(heading))) + "×╮")
|
|
116
|
+
const top = theme.fg("accent", "╭─" + truncateToWidth(heading, Math.max(0, width - 6))
|
|
117
|
+
+ "─".repeat(Math.max(0, width - 4 - visibleWidth(heading))) + "×╮");
|
|
111
118
|
const subtitle = ` Active branch · page ${current.page + 1}/${current.totalPages} · snapshot at open`;
|
|
112
119
|
const rows = items.length ? items.map((item, position) => {
|
|
113
120
|
const index = start + position;
|
|
@@ -130,7 +137,7 @@ export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
|
|
|
130
137
|
const help = searching ? line(input.render(Math.max(1, width - 4))[0] ?? "/")
|
|
131
138
|
: line(theme.fg("dim", notice || (width >= 66 ? "↑↓/Pg: move · /: search · n/N: next · e: expand · d/u: detail · [ ]: chunks · p/o: pages · Esc" : "↑↓ move · / search · e expand · p/o pages · Esc")));
|
|
132
139
|
return [top, line(theme.fg("muted", subtitle)), border("├", "─", "┤"), ...rows,
|
|
133
|
-
border("├", "─", "┤"), line(theme.fg("accent", headingDetail)), ...body, help, border("╰", "─", "╯")]
|
|
140
|
+
border("├", "─", "┤"), line(theme.fg("accent", headingDetail)), ...body, help, border("╰", "─", "╯")];
|
|
134
141
|
}
|
|
135
142
|
};
|
|
136
143
|
}, { overlay: true, overlayOptions: { anchor: "center", width: 92, maxHeight: "80%" } });
|
package/src/history.ts
CHANGED
|
@@ -139,8 +139,10 @@ export function historyPage(snapshot: HistorySnapshot, page = 0, size = HISTORY_
|
|
|
139
139
|
return { items, page: at, totalPages };
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
export interface HistoryChunk { lines: string[]; more: boolean; nextOffset: number; trailingNewline: boolean }
|
|
143
|
+
|
|
142
144
|
/** Fetch only the requested 2 KiB / 30-line segment; nextOffset never skips hidden lines. */
|
|
143
|
-
export function historyChunk(item: HistoryItem, offset = 0):
|
|
145
|
+
export function historyChunk(item: HistoryItem, offset = 0): HistoryChunk {
|
|
144
146
|
const at = Math.max(0, Math.min(200_000_000, Math.floor(offset) || 0));
|
|
145
147
|
const { raw, more } = excerpt(item.entry, HISTORY_CHUNK_SIZE, at);
|
|
146
148
|
// Limit UTF-8 bytes as well as UTF-16 code units (CJK/emoji can exceed 2 KiB).
|
package/src/model-roles.ts
CHANGED
|
@@ -11,7 +11,11 @@ export const BUILTIN_ROLES = [
|
|
|
11
11
|
{ role: "plan", label: "Architect", usedBy: "plan mode" },
|
|
12
12
|
{ role: "implement", label: "Builder", usedBy: "goal implement rounds and approved plans" },
|
|
13
13
|
{ role: "advisor", label: "Advisor", usedBy: "second opinions, stuck-work gates and the goal audit" },
|
|
14
|
-
{ role: "
|
|
14
|
+
{ role: "moderator", label: "Moderator", usedBy: "main-agent multi-agent coordination" },
|
|
15
|
+
{ role: "scout", label: "Scout", usedBy: "cheap delegated discovery and codebase scouting" },
|
|
16
|
+
{ role: "worker", label: "Worker", usedBy: "sandboxed delegated implementation" },
|
|
17
|
+
{ role: "reviewer", label: "Reviewer", usedBy: "forked delegated review and verification" },
|
|
18
|
+
{ role: "task", label: "Subtask", usedBy: "legacy or explicitly generic delegated tasks" },
|
|
15
19
|
{ role: "commit", label: "Commit", usedBy: "/jar commit messages" }
|
|
16
20
|
] as const;
|
|
17
21
|
export const MODEL_ROLES = BUILTIN_ROLES.map((item) => item.role);
|
|
@@ -21,10 +25,10 @@ export type RoleScope = "global" | "project";
|
|
|
21
25
|
export interface RoleAssignment { provider: string; model: string; thinking?: ThinkingLevel }
|
|
22
26
|
export interface ResolvedRole extends RoleAssignment { via: string[] }
|
|
23
27
|
export interface RoleTag { name?: string; color?: string }
|
|
24
|
-
export interface RoleConfig { version: 2; roles: Record<string, string>; cycleOrder?: string[]; tags?: Record<string, RoleTag> }
|
|
28
|
+
export interface RoleConfig { version: 2; roles: Record<string, string>; cycleOrder?: string[]; tags?: Record<string, RoleTag>; fallbacks?: Record<string, string[]> }
|
|
25
29
|
export interface RoleRow {
|
|
26
30
|
role: string; label: string; usedBy?: string; custom: boolean;
|
|
27
|
-
spec?: string; scope?: RoleScope; resolved?: ResolvedRole; error?: string;
|
|
31
|
+
spec?: string; scope?: RoleScope; resolved?: ResolvedRole; error?: string; fallbacks: string[];
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export const ROLE_FILE = "pi-jar-roles.json";
|
|
@@ -67,6 +71,17 @@ export function parseRoleConfig(raw: unknown): RoleConfig {
|
|
|
67
71
|
}
|
|
68
72
|
if (spec) config.roles[role] = spec;
|
|
69
73
|
}
|
|
74
|
+
if (value.fallbacks && typeof value.fallbacks === "object") {
|
|
75
|
+
const fallbacks: Record<string, string[]> = {};
|
|
76
|
+
for (const [role, entries] of Object.entries(value.fallbacks)) {
|
|
77
|
+
if (!isRoleName(role) || !Array.isArray(entries)) continue;
|
|
78
|
+
fallbacks[role] = [...new Set(entries.flatMap((entry) => {
|
|
79
|
+
const spec = typeof entry === "string" ? normalizeSpec(entry) : undefined;
|
|
80
|
+
return spec ? [spec] : [];
|
|
81
|
+
}))].slice(0, 8);
|
|
82
|
+
}
|
|
83
|
+
if (Object.keys(fallbacks).length) config.fallbacks = fallbacks;
|
|
84
|
+
}
|
|
70
85
|
if (Array.isArray(value.cycleOrder)) {
|
|
71
86
|
const order = value.cycleOrder.filter((item): item is string => typeof item === "string" && isRoleName(item));
|
|
72
87
|
if (order.length) config.cycleOrder = [...new Set(order)];
|
|
@@ -143,6 +158,8 @@ export class ModelRoleManager {
|
|
|
143
158
|
private applying = false;
|
|
144
159
|
/** Bumped whenever the user picks a model or effort themselves. */
|
|
145
160
|
private manualEpoch = 0;
|
|
161
|
+
/** Last `summary()`, valid while the same (replaced-on-write) configs are loaded. */
|
|
162
|
+
private summaryMemo: { global: RoleConfig; project: RoleConfig; limit: number; text: string } | undefined;
|
|
146
163
|
|
|
147
164
|
constructor(pi: ExtensionAPI) {
|
|
148
165
|
this.pi = pi;
|
|
@@ -167,6 +184,23 @@ export class ModelRoleManager {
|
|
|
167
184
|
return result && !("error" in result) ? result : undefined;
|
|
168
185
|
}
|
|
169
186
|
|
|
187
|
+
/** Primary role target followed by its configured fallbacks, de-duplicated in priority order. */
|
|
188
|
+
resolveCandidates(role: string): ResolvedRole[] {
|
|
189
|
+
const candidates: ResolvedRole[] = [];
|
|
190
|
+
const primary = this.resolve(role);
|
|
191
|
+
if (primary) candidates.push(primary);
|
|
192
|
+
for (const spec of this.fallbackSpecs(role)) {
|
|
193
|
+
try { candidates.push(this.resolveSpec(spec)); } catch { /* malformed alias targets are skipped */ }
|
|
194
|
+
}
|
|
195
|
+
const seen = new Set<string>();
|
|
196
|
+
return candidates.filter((item) => {
|
|
197
|
+
const key = item.provider + "/" + item.model + ":" + (item.thinking ?? "");
|
|
198
|
+
if (seen.has(key)) return false;
|
|
199
|
+
seen.add(key);
|
|
200
|
+
return true;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
170
204
|
get(role: string): RoleAssignment | undefined {
|
|
171
205
|
const resolved = this.resolve(role);
|
|
172
206
|
if (!resolved) return undefined;
|
|
@@ -183,17 +217,21 @@ export class ModelRoleManager {
|
|
|
183
217
|
];
|
|
184
218
|
return rows.map((row) => {
|
|
185
219
|
const result = resolveRole(roles, row.role);
|
|
186
|
-
return { ...row, spec: roles[row.role], scope: this.scopeOf(row.role),
|
|
220
|
+
return { ...row, spec: roles[row.role], scope: this.scopeOf(row.role), fallbacks: this.fallbackSpecs(row.role),
|
|
187
221
|
...(result && "error" in result ? { error: result.error } : result ? { resolved: result } : {}) };
|
|
188
222
|
});
|
|
189
223
|
}
|
|
190
224
|
|
|
191
|
-
/** Summary for compact surfaces such as the welcome card. */
|
|
225
|
+
/** Summary for compact surfaces such as the welcome card, which repaints every frame. */
|
|
192
226
|
summary(limit = 3): string {
|
|
227
|
+
const memo = this.summaryMemo;
|
|
228
|
+
if (memo?.global === this.global && memo.project === this.project && memo.limit === limit) return memo.text;
|
|
193
229
|
const rows = this.list().filter((row) => row.resolved || row.spec);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
230
|
+
const text = !rows.length ? "all roles follow the current model"
|
|
231
|
+
: rows.slice(0, limit).map((row) => row.role + "→" + (row.spec?.startsWith("@") ? row.spec.split(":")[0] : row.resolved?.model ?? "?")).join(" · ")
|
|
232
|
+
+ (rows.length > limit ? ` · +${rows.length - limit}` : "");
|
|
233
|
+
this.summaryMemo = { global: this.global, project: this.project, limit, text };
|
|
234
|
+
return text;
|
|
197
235
|
}
|
|
198
236
|
|
|
199
237
|
private status(ctx: ExtensionContext): void {
|
|
@@ -202,39 +240,46 @@ export class ModelRoleManager {
|
|
|
202
240
|
}
|
|
203
241
|
|
|
204
242
|
async activate(role: string, ctx: ExtensionContext, quiet = false): Promise<boolean> {
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
243
|
+
const primary = resolveRole(this.merged(), role);
|
|
244
|
+
const primaryError = primary && "error" in primary ? primary.error : undefined;
|
|
245
|
+
const candidates = this.resolveCandidates(role);
|
|
246
|
+
if (!candidates.length) {
|
|
247
|
+
if (primaryError) {
|
|
248
|
+
if (!quiet) ctx.ui.notify("Role " + role + ": " + primaryError, "warning");
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
211
251
|
if (!quiet) ctx.ui.notify("Role " + role + " follows the current model; assign one with /roles", "info");
|
|
212
252
|
this.active = role;
|
|
213
253
|
this.status(ctx);
|
|
214
254
|
return true;
|
|
215
255
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (!
|
|
226
|
-
|
|
256
|
+
|
|
257
|
+
const failures: string[] = [];
|
|
258
|
+
for (let index = 0; index < candidates.length; index++) {
|
|
259
|
+
const candidate = candidates[index]!;
|
|
260
|
+
const model = ctx.modelRegistry.find(candidate.provider, candidate.model);
|
|
261
|
+
if (!model) { failures.push(candidate.provider + "/" + candidate.model + " not found"); continue; }
|
|
262
|
+
let changed: boolean;
|
|
263
|
+
this.applying = true;
|
|
264
|
+
try { changed = await this.pi.setModel(model); } finally { this.applying = false; }
|
|
265
|
+
if (!changed) { failures.push(candidate.provider + "/" + candidate.model + " unavailable"); continue; }
|
|
266
|
+
if (candidate.thinking) this.withApplying(() => this.pi.setThinkingLevel(candidate.thinking!));
|
|
267
|
+
this.active = role;
|
|
268
|
+
this.status(ctx);
|
|
269
|
+
if (!quiet) {
|
|
270
|
+
ctx.ui.notify("Role " + role + " · " + candidate.provider + "/" + candidate.model
|
|
271
|
+
+ (candidate.thinking ? " · " + candidate.thinking : "") + (index ? " · fallback " + index : ""), "info");
|
|
272
|
+
}
|
|
273
|
+
return true;
|
|
227
274
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (!quiet) ctx.ui.notify("Role " + role + " · " + result.provider + "/" + result.model + (result.thinking ? " · " + result.thinking : ""), "info");
|
|
232
|
-
return true;
|
|
275
|
+
|
|
276
|
+
if (!quiet) ctx.ui.notify("No usable model for role " + role + ": " + failures.join("; "), "warning");
|
|
277
|
+
return false;
|
|
233
278
|
}
|
|
234
279
|
|
|
235
280
|
/** Switch to a role for a bounded workflow; the returned function restores the previous model. */
|
|
236
281
|
async activateTemporary(role: string, ctx: ExtensionContext): Promise<() => Promise<void>> {
|
|
237
|
-
if (!this.
|
|
282
|
+
if (!this.resolveCandidates(role).length) return async () => {};
|
|
238
283
|
const previousModel = ctx.model;
|
|
239
284
|
const previousThinking = this.pi.getThinkingLevel();
|
|
240
285
|
const previousActive = this.active;
|
|
@@ -259,7 +304,7 @@ export class ModelRoleManager {
|
|
|
259
304
|
|
|
260
305
|
/** Activate the next assigned role in the cycle order. */
|
|
261
306
|
async cycle(ctx: ExtensionContext): Promise<string | undefined> {
|
|
262
|
-
const order = this.cycleOrder().filter((role) => this.
|
|
307
|
+
const order = this.cycleOrder().filter((role) => this.resolveCandidates(role).length > 0);
|
|
263
308
|
if (!order.length) { ctx.ui.notify("No roles in the cycle order are assigned; configure them with /roles", "info"); return undefined; }
|
|
264
309
|
const next = order[(order.indexOf(this.active ?? "") + 1) % order.length]!;
|
|
265
310
|
return await this.activate(next, ctx) ? next : undefined;
|
|
@@ -279,7 +324,33 @@ export class ModelRoleManager {
|
|
|
279
324
|
if (scope === "project") this.project = next; else this.global = next;
|
|
280
325
|
}
|
|
281
326
|
|
|
282
|
-
|
|
327
|
+
/** Project lists replace global lists; an empty project list disables inherited fallbacks. */
|
|
328
|
+
fallbackSpecs(role: string): string[] {
|
|
329
|
+
return [...(this.project.fallbacks?.[role] ?? this.global.fallbacks?.[role] ?? [])];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
resolveSpec(spec: string): ResolvedRole {
|
|
333
|
+
const result = resolveRole({ ...this.merged(), __fallback__: spec }, "__fallback__");
|
|
334
|
+
if (!result || "error" in result) throw new Error(result?.error ?? "Invalid fallback model");
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
updateFallbacks(role: string, specs: string[], scope: RoleScope = "global"): void {
|
|
339
|
+
if (!isRoleName(role)) throw new Error("Invalid role name: " + role);
|
|
340
|
+
if (specs.length > 8) throw new Error("At most 8 fallback models are allowed");
|
|
341
|
+
const normalized = specs.map((spec) => {
|
|
342
|
+
const target = normalizeSpec(spec);
|
|
343
|
+
if (!target) throw new Error("Invalid fallback target: " + spec);
|
|
344
|
+
return target;
|
|
345
|
+
});
|
|
346
|
+
if (scope === "project" && !this.cwd) throw new Error("Project roles need a working directory");
|
|
347
|
+
const target = scope === "project" ? this.project : this.global;
|
|
348
|
+
const next: RoleConfig = { ...target, fallbacks: { ...target.fallbacks, [role]: [...new Set(normalized)] } };
|
|
349
|
+
writeConfig(scope === "project" ? projectRoleFile(this.cwd!) : join(getAgentDir(), ROLE_FILE), next);
|
|
350
|
+
if (scope === "project") this.project = next; else this.global = next;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
register(openUi?: (ctx: ExtensionContext) => Promise<void>, options: { activateDefault?: () => boolean } = {}): void {
|
|
283
354
|
const manual = (_event: unknown, ctx: ExtensionContext) => {
|
|
284
355
|
if (this.applying) return;
|
|
285
356
|
this.manualEpoch++;
|
|
@@ -290,17 +361,30 @@ export class ModelRoleManager {
|
|
|
290
361
|
this.pi.on("session_start", async (_event, ctx) => {
|
|
291
362
|
this.load(ctx.cwd);
|
|
292
363
|
this.active = undefined;
|
|
293
|
-
|
|
364
|
+
// Delegated children already received their role model/effort via CLI arguments.
|
|
365
|
+
if (options.activateDefault?.() !== false && this.resolveCandidates("default").length) await this.activate("default", ctx, true);
|
|
294
366
|
});
|
|
295
367
|
|
|
296
368
|
this.pi.registerCommand("roles", {
|
|
297
|
-
description: "Configure model roles
|
|
369
|
+
description: "Configure model roles: /roles, /roles set ROLE provider/model[:effort]|@role, /roles fallback ROLE MODEL...|clear, /roles <role>",
|
|
298
370
|
handler: async (args, ctx) => {
|
|
299
371
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
300
372
|
const verb = (parts[0] ?? "").toLowerCase();
|
|
301
373
|
if (!verb || verb === "list") {
|
|
302
374
|
if (!verb && ctx.hasUI && ctx.mode === "tui" && openUi) { await openUi(ctx); return; }
|
|
303
|
-
ctx.ui.notify(this.list().map(describeRole).
|
|
375
|
+
ctx.ui.notify(this.list().map((row) => describeRole(row) + (this.fallbackSpecs(row.role).length
|
|
376
|
+
? " · fallbacks: " + this.fallbackSpecs(row.role).join(" → ") : "")).join("\n"), "info");
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (verb === "fallback") {
|
|
380
|
+
const role = parts[1] ?? "";
|
|
381
|
+
const scope: RoleScope = parts.includes("--project") ? "project" : "global";
|
|
382
|
+
const targets = parts.slice(2).filter((part) => part !== "--project");
|
|
383
|
+
if (!isRoleName(role)) { ctx.ui.notify("Usage: /roles fallback ROLE [PROVIDER/MODEL[:effort]|@ROLE ...|clear] [--project]", "error"); return; }
|
|
384
|
+
if (!targets.length) { ctx.ui.notify(role + " fallbacks: " + (this.fallbackSpecs(role).join(" → ") || "none"), "info"); return; }
|
|
385
|
+
try { this.updateFallbacks(role, targets.length === 1 && targets[0] === "clear" ? [] : targets, scope); }
|
|
386
|
+
catch (error) { ctx.ui.notify("Could not save fallbacks: " + (error as Error).message, "error"); return; }
|
|
387
|
+
ctx.ui.notify("Saved " + role + " fallbacks (" + scope + ")", "info");
|
|
304
388
|
return;
|
|
305
389
|
}
|
|
306
390
|
if (verb === "cycle") { await this.cycle(ctx); return; }
|
package/src/panel.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { RowCache } from "./row-cache.ts";
|
|
3
4
|
|
|
4
5
|
export type Paint = (color: string, text: string) => string;
|
|
5
6
|
export interface PanelTab { name: string; render(width: number, fg: Paint): string[] }
|
|
@@ -13,6 +14,8 @@ export async function openPanel(ctx: ExtensionContext, tabs: readonly PanelTab[]
|
|
|
13
14
|
let body: string[] = [];
|
|
14
15
|
let rows = 10;
|
|
15
16
|
let tabHits: { start: number; end: number }[] = [];
|
|
17
|
+
// The panel repaints on every Pi render while open; its body rows rarely change between frames.
|
|
18
|
+
const framed = new RowCache();
|
|
16
19
|
const fg: Paint = (color, text) => theme.fg(color as never, text);
|
|
17
20
|
const move = (delta: number) => { scroll = Math.max(0, Math.min(Math.max(0, body.length - rows), scroll + delta)); };
|
|
18
21
|
const select = (index: number) => { tab = (index + tabs.length) % tabs.length; scroll = 0; };
|
|
@@ -40,7 +43,8 @@ export async function openPanel(ctx: ExtensionContext, tabs: readonly PanelTab[]
|
|
|
40
43
|
rows = Math.max(4, (process.stdout.rows ?? 24) - 6);
|
|
41
44
|
body = tabs[tab]!.render(inner, fg);
|
|
42
45
|
scroll = Math.min(scroll, Math.max(0, body.length - rows));
|
|
43
|
-
|
|
46
|
+
framed.begin(`${inner}\0${fg("dim", "│")}`);
|
|
47
|
+
const line = (text: string) => framed.get(text, (row) => fg("dim", "│ ") + truncateToWidth(row, inner, "...", true) + fg("dim", " │"));
|
|
44
48
|
tabHits = [];
|
|
45
49
|
// Every tab label is name + 2 cells wide ("[name]" or " name "), separated by one space.
|
|
46
50
|
let x = 2;
|
package/src/prompt-search.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { fuzzyFilter, Key, matchesKey, truncateToWidth,
|
|
2
|
+
import { fuzzyFilter, Key, matchesKey, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
3
|
import { cleanText } from "./status.ts";
|
|
4
4
|
|
|
5
5
|
export interface PastPrompt { text: string; source: "this session" | "earlier session"; at?: number }
|
|
@@ -58,6 +58,13 @@ export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly
|
|
|
58
58
|
let visible: PastPrompt[] = pool;
|
|
59
59
|
const rows = 10;
|
|
60
60
|
const filter = () => { visible = query ? fuzzyFilter(pool, query, (item) => item.text) : pool; selected = 0; first = 0; };
|
|
61
|
+
// Prompts can be several KiB; sanitize each once, not on every repaint.
|
|
62
|
+
const cleaned = new Map<PastPrompt, string>();
|
|
63
|
+
const preview = (item: PastPrompt) => {
|
|
64
|
+
let text = cleaned.get(item);
|
|
65
|
+
if (text === undefined) cleaned.set(item, text = cleanText(item.text, 400));
|
|
66
|
+
return text;
|
|
67
|
+
};
|
|
61
68
|
return {
|
|
62
69
|
invalidate() {},
|
|
63
70
|
handleInput(data: string) {
|
|
@@ -81,21 +88,22 @@ export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly
|
|
|
81
88
|
const inner = w - 4;
|
|
82
89
|
if (selected < first) first = selected;
|
|
83
90
|
if (selected >= first + rows) first = selected - rows + 1;
|
|
84
|
-
const fit = (text: string) => { const value = truncateToWidth(text, inner); return value + " ".repeat(Math.max(0, inner - visibleWidth(value))); };
|
|
85
91
|
const dim = (text: string) => theme.fg("dim", text);
|
|
92
|
+
// Rows are exactly `w` wide; only the two borders can overflow a narrow overlay.
|
|
86
93
|
const lines = [
|
|
87
|
-
theme.fg("accent", "╭─ ⌕ PROMPT HISTORY ") + dim("─".repeat(Math.max(0, w - 21)) + "╮"),
|
|
88
|
-
dim("│ ") +
|
|
94
|
+
truncateToWidth(theme.fg("accent", "╭─ ⌕ PROMPT HISTORY ") + dim("─".repeat(Math.max(0, w - 21)) + "╮"), w),
|
|
95
|
+
dim("│ ") + truncateToWidth(theme.fg("accent", "› ") + query + "\x1b[7m \x1b[0m" + dim(` ${visible.length}/${prompts.length}`), inner, "...", true) + dim(" │")
|
|
89
96
|
];
|
|
90
97
|
for (let row = 0; row < rows; row++) {
|
|
91
98
|
const item = visible[first + row];
|
|
92
99
|
const active = first + row === selected;
|
|
93
|
-
const text = item ? (active ? "❯ " : " ") +
|
|
100
|
+
const text = item ? (active ? "❯ " : " ") + preview(item) : "";
|
|
94
101
|
const tag = item?.source === "earlier session" ? dim(" · earlier") : "";
|
|
95
|
-
|
|
102
|
+
const content = item ? theme.fg(active ? "accent" : "muted", truncateToWidth(text, Math.max(4, inner - 10))) + tag : "";
|
|
103
|
+
lines.push(dim("│ ") + truncateToWidth(content, inner, "...", true) + dim(" │"));
|
|
96
104
|
}
|
|
97
|
-
lines.push(dim("╰─ type to filter · ↑↓ select · Enter edit · Esc close " + "─".repeat(Math.max(0, w - 57)) + "╯"));
|
|
98
|
-
return lines
|
|
105
|
+
lines.push(truncateToWidth(dim("╰─ type to filter · ↑↓ select · Enter edit · Esc close " + "─".repeat(Math.max(0, w - 57)) + "╯"), w));
|
|
106
|
+
return lines;
|
|
99
107
|
}
|
|
100
108
|
};
|
|
101
109
|
}, { overlay: true, overlayOptions: { width: "80%", maxHeight: "60%" } });
|
package/src/roles-ui.ts
CHANGED
|
@@ -4,10 +4,11 @@ import { promptText } from "./dialogs.ts";
|
|
|
4
4
|
import { isRoleName, normalizeSpec, THINKING, type ModelRoleManager, type RoleRow } from "./model-roles.ts";
|
|
5
5
|
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
6
6
|
|
|
7
|
-
type RoleAction = "model" | "alias" | "thinking" | "scope" | "activate" | "clear" | "new" | "delete";
|
|
7
|
+
type RoleAction = "model" | "fallback" | "alias" | "thinking" | "scope" | "activate" | "clear" | "new" | "delete";
|
|
8
8
|
const ACTIONS: { action: RoleAction; key: string; hint: string; label: string }[] = [
|
|
9
9
|
{ action: "activate", key: "\r", hint: "⏎", label: "Activate this role now" },
|
|
10
10
|
{ action: "model", key: "m", hint: "m", label: "Assign a model" },
|
|
11
|
+
{ action: "fallback", key: "f", hint: "f", label: "Set fallback model" },
|
|
11
12
|
{ action: "alias", key: "a", hint: "a", label: "Alias another role (@role)" },
|
|
12
13
|
{ action: "thinking", key: "t", hint: "t", label: "Set thinking effort" },
|
|
13
14
|
{ action: "scope", key: "s", hint: "s", label: "Move between global and project" },
|
|
@@ -28,6 +29,7 @@ function detail(row: RoleRow, active: string | undefined): [ThemeColor, string][
|
|
|
28
29
|
[target[0], "Model " + target[1]],
|
|
29
30
|
["muted", "Alias " + (row.resolved && row.resolved.via.length > 1 ? row.resolved.via.join(" → ") : "—")],
|
|
30
31
|
["muted", "Effort " + (row.resolved?.thinking ?? "follow current")],
|
|
32
|
+
["muted", "Fallback " + (row.fallbacks.length ? row.fallbacks.join(" → ") : "—")],
|
|
31
33
|
["muted", "Scope " + (row.scope ?? "unset") + (row.scope === "project" ? " (.pi/pi-jar-roles.json)" : row.scope === "global" ? " (agent dir)" : "")],
|
|
32
34
|
["dim", ""],
|
|
33
35
|
["dim", "Specs: provider/model[:effort] · @role[:effort] · *"],
|
|
@@ -41,7 +43,6 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
|
|
|
41
43
|
let scroll = 0;
|
|
42
44
|
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
43
45
|
let width = 80;
|
|
44
|
-
let actionsTop = -1;
|
|
45
46
|
const rows = () => roles.list();
|
|
46
47
|
const finish = (action: RoleAction) => done({ action, index: selected });
|
|
47
48
|
return {
|
|
@@ -67,9 +68,9 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
|
|
|
67
68
|
if (event.type !== "click" || event.button !== "left") return;
|
|
68
69
|
if (event.y === 0 && event.x >= width - 3) { done(undefined); return { handled: true }; }
|
|
69
70
|
const row = event.y - layout.top;
|
|
70
|
-
// Actions
|
|
71
|
-
const actionIndex =
|
|
72
|
-
if (
|
|
71
|
+
// Actions stay in the footer so even short terminals can click every action.
|
|
72
|
+
const actionIndex = event.y - layout.footerTop;
|
|
73
|
+
if (event.x >= 2 && event.x < width - 2 && actionIndex >= 0 && actionIndex < ACTIONS.length) {
|
|
73
74
|
finish(ACTIONS[actionIndex]!.action); return { handled: true };
|
|
74
75
|
}
|
|
75
76
|
if (row >= 0 && row < layout.rows && (layout.leftWidth === 0 || event.x < layout.leftWidth + 3)) {
|
|
@@ -81,7 +82,7 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
|
|
|
81
82
|
width = Math.max(24, available);
|
|
82
83
|
const list = rows();
|
|
83
84
|
selected = Math.max(0, Math.min(list.length - 1, selected));
|
|
84
|
-
const height = Math.min(contentRows(
|
|
85
|
+
const height = Math.min(contentRows(ACTIONS.length + 4, 4), Math.max(13, list.length));
|
|
85
86
|
if (selected < scroll) scroll = selected;
|
|
86
87
|
if (selected >= scroll + height) scroll = selected - height + 1;
|
|
87
88
|
const active = roles.activeRole();
|
|
@@ -97,11 +98,9 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
|
|
|
97
98
|
const header = narrow ? [theme.fg("accent", `‹ ${selected + 1}/${list.length} ${row.role} ›`)] : [];
|
|
98
99
|
const actions = optionList(theme, ACTIONS.map((item) => item.label), -1, ACTIONS.map((item) => item.hint))
|
|
99
100
|
.map((line, at) => ACTIONS[at]!.action === "delete" && !row.custom ? theme.fg("dim", stripTerminalSequences(line)) : line);
|
|
100
|
-
|
|
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;
|
|
101
|
+
const body = [...header, ...info];
|
|
104
102
|
const split = splitFrame(theme, width, "pi-jar · roles", narrow ? [] : left, body, [
|
|
103
|
+
...actions,
|
|
105
104
|
theme.fg("dim", "↑↓ choose role · press a key or click an action · Esc close")
|
|
106
105
|
], height);
|
|
107
106
|
layout = split.layout;
|
|
@@ -149,6 +148,15 @@ async function applyAction(ctx: ExtensionContext, roles: ModelRoleManager, row:
|
|
|
149
148
|
if (selected) roles.update(row.role, selected + effort(), scope);
|
|
150
149
|
return;
|
|
151
150
|
}
|
|
151
|
+
case "fallback": {
|
|
152
|
+
const models = ctx.modelRegistry.getAvailable().slice().sort((a, b) => (a.provider + "/" + a.id).localeCompare(b.provider + "/" + b.id));
|
|
153
|
+
if (!models.length) { ctx.ui.notify("No authenticated models are currently available", "warning"); return; }
|
|
154
|
+
const choices = ["Clear fallback", ...models.map((model) => model.provider + "/" + model.id)];
|
|
155
|
+
const selected = await ctx.ui.select("Fallback model for " + row.role, choices);
|
|
156
|
+
if (!selected) return;
|
|
157
|
+
roles.updateFallbacks(row.role, selected === "Clear fallback" ? [] : [selected], scope);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
152
160
|
case "alias": {
|
|
153
161
|
const targets = roles.list().filter((item) => item.role !== row.role).map((item) => "@" + item.role);
|
|
154
162
|
const selected = await ctx.ui.select("Alias " + row.role + " to", targets);
|
package/src/row-cache.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row memo across consecutive renders. pi-tui re-segments every grapheme on each truncate, and pi-jar's
|
|
3
|
+
* chrome and overlays repaint on every Pi render (each streamed token) with mostly the same rows, or one
|
|
4
|
+
* changed row while typing or scrolling. Rows transformed by the previous render are reused while the
|
|
5
|
+
* scope (width, painter sample) holds, so memory stays bounded by two renders' rows.
|
|
6
|
+
*/
|
|
7
|
+
export class RowCache {
|
|
8
|
+
private previous = new Map<string, string>();
|
|
9
|
+
private current = new Map<string, string>();
|
|
10
|
+
private scope: string | undefined;
|
|
11
|
+
|
|
12
|
+
/** Start a render. A different scope drops every cached row. */
|
|
13
|
+
begin(scope: string): void {
|
|
14
|
+
this.previous = scope === this.scope ? this.current : new Map();
|
|
15
|
+
this.current = new Map();
|
|
16
|
+
this.scope = scope;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `transform(row)` for this render; `transform` may depend only on the row and the scope. */
|
|
20
|
+
get(row: string, transform: (row: string) => string): string {
|
|
21
|
+
let value = this.current.get(row) ?? this.previous.get(row);
|
|
22
|
+
if (value === undefined) value = transform(row);
|
|
23
|
+
this.current.set(row, value);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/session-ui.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext, SessionInfo } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
3
|
import { sessionDisplayName } from "./composer.ts";
|
|
4
|
-
import { withIcon } from "./icons.ts";
|
|
4
|
+
import { iconSet, withIcon } from "./icons.ts";
|
|
5
5
|
import { contentRows, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
6
6
|
import { cleanText } from "./status.ts";
|
|
7
7
|
|
|
@@ -68,8 +68,13 @@ export async function pickSession(ctx: ExtensionContext, sessions: readonly Sess
|
|
|
68
68
|
const settle = (value: SessionDetails) => { if (closed) return; cache.set(session.path, value); tui.requestRender(); };
|
|
69
69
|
Promise.resolve().then(() => details(session)).then((value) => settle(value ?? {}), () => settle({}));
|
|
70
70
|
};
|
|
71
|
+
// Wrapping a 4 KiB first prompt is the costliest part of a frame; redo it only when its inputs change.
|
|
72
|
+
let described: { session: SessionInfo; inputs: string; loaded: SessionDetails | null | undefined; lines: string[] } | undefined;
|
|
71
73
|
const describe = (session: SessionInfo, bodyWidth: number, height: number): string[] => {
|
|
72
74
|
const date = session.modified;
|
|
75
|
+
const inputs = `${bodyWidth}\0${height}\0${age(date)}\0${iconSet()}`;
|
|
76
|
+
const loaded = details ? cache.get(session.path) : undefined;
|
|
77
|
+
if (described?.session === session && described.inputs === inputs && described.loaded === loaded) return described.lines;
|
|
73
78
|
const when = Number.isNaN(date.getTime()) ? "unknown"
|
|
74
79
|
: `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}` + (age(date) ? ` · ${age(date)} ago` : "");
|
|
75
80
|
const lines = [
|
|
@@ -80,7 +85,6 @@ export async function pickSession(ctx: ExtensionContext, sessions: readonly Sess
|
|
|
80
85
|
];
|
|
81
86
|
if (session.cwd) lines.push(fg("muted", withIcon("folder", cleanText(session.cwd, 400))));
|
|
82
87
|
if (details) {
|
|
83
|
-
const loaded = cache.get(session.path);
|
|
84
88
|
if (!loaded) lines.push(fg("dim", "Loading details…"));
|
|
85
89
|
else {
|
|
86
90
|
for (const [key, text] of [["goal", loaded.goal], ["plan", loaded.plan]] as const) {
|
|
@@ -91,10 +95,11 @@ export async function pickSession(ctx: ExtensionContext, sessions: readonly Sess
|
|
|
91
95
|
lines.push("", fg("dim", "First prompt"));
|
|
92
96
|
const prompt = cleanText(session.firstMessage, 4096);
|
|
93
97
|
lines.push(...(prompt ? wrapTextWithAnsi(prompt, Math.max(1, bodyWidth)).map((line) => fg("muted", line)) : [fg("dim", "No first prompt")]));
|
|
94
|
-
|
|
98
|
+
described = { session, inputs, loaded, lines: lines.slice(0, height).map((line) => truncateToWidth(line, Math.max(0, bodyWidth))) };
|
|
99
|
+
return described.lines;
|
|
95
100
|
};
|
|
96
101
|
return {
|
|
97
|
-
invalidate() {},
|
|
102
|
+
invalidate() { described = undefined; },
|
|
98
103
|
handleInput(data: string) {
|
|
99
104
|
if (matchesKey(data, Key.escape)) return finish(undefined);
|
|
100
105
|
if (matchesKey(data, Key.enter)) return finish(rows[selected]?.path);
|