pi-jar 0.1.2 → 0.1.4
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 +46 -16
- package/docs/DEVELOPMENT.md +4 -2
- package/docs/WORKFLOWS.md +7 -3
- package/extensions/index.ts +194 -71
- package/package.json +1 -1
- package/src/activity-view.ts +525 -0
- package/src/advisor.ts +9 -3
- package/src/changes.ts +35 -11
- package/src/child-baselines.ts +42 -0
- package/src/compact-tools.ts +58 -24
- package/src/composer.ts +69 -21
- package/src/delegate.ts +492 -51
- package/src/dialogs.ts +9 -5
- package/src/diff-view.ts +13 -1
- package/src/flame.ts +18 -5
- package/src/footer.ts +131 -26
- package/src/history-ui.ts +19 -12
- package/src/history.ts +3 -1
- package/src/icons.ts +57 -0
- package/src/model-roles.ts +62 -7
- package/src/panel.ts +5 -1
- package/src/plan.ts +1 -0
- package/src/prompt-search.ts +20 -10
- package/src/row-cache.ts +26 -0
- package/src/session-gallery.ts +8 -3
- package/src/session-ui.ts +112 -42
- package/src/settings-ui.ts +10 -11
- package/src/settings.ts +5 -1
- package/src/shells.ts +8 -84
- package/src/side-model.ts +45 -25
- package/src/split-view.ts +29 -12
- package/src/status.ts +2 -0
- package/src/task-tool.ts +140 -53
- package/src/tasks-ui.ts +15 -5
- package/src/tasks.ts +224 -38
- package/src/welcome.ts +47 -14
- package/src/working.ts +3 -1
package/src/dialogs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Input, Key, matchesKey, truncateToWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
-
import { todoMark, type Todo } from "./tasks.ts";
|
|
3
|
+
import { todoMark, todoProgress, todoTotals, type Todo } from "./tasks.ts";
|
|
4
4
|
import { cleanText } from "./status.ts";
|
|
5
5
|
|
|
6
6
|
const fit = (line: string, width: number) => truncateToWidth(line, Math.max(0, width));
|
|
@@ -111,13 +111,17 @@ export async function todoView(ctx: ExtensionContext, todos: () => Todo[], filte
|
|
|
111
111
|
render(width: number): string[] {
|
|
112
112
|
const items = filtered();
|
|
113
113
|
selected = Math.min(selected, Math.max(0, items.length - 1));
|
|
114
|
-
const
|
|
114
|
+
const all = todos();
|
|
115
|
+
const totals = todoTotals(all);
|
|
115
116
|
const start = Math.max(0, selected - 11);
|
|
116
117
|
firstVisible = start;
|
|
117
118
|
return [
|
|
118
|
-
fit(theme.fg("accent", `╭─ pi-jar to-do · ${
|
|
119
|
-
...(items.length ? items.slice(start, start + 12).map((item, index) =>
|
|
120
|
-
|
|
119
|
+
fit(theme.fg("accent", `╭─ pi-jar to-do · ${totals.total - totals.done} open · ${filter.value} ─`), width),
|
|
120
|
+
...(items.length ? items.slice(start, start + 12).map((item, index) => {
|
|
121
|
+
const progress = todoProgress(all, item.id);
|
|
122
|
+
const row = `${start + index === selected ? "❯" : " "} ${item.parentId ? " " : ""}${todoMark(item)} ${item.title}${progress.total ? ` (${progress.done}/${progress.total})` : ""}`;
|
|
123
|
+
return fit(theme.fg(start + index === selected ? "accent" : item.done ? "dim" : item.status === "in_progress" ? "warning" : "muted", row), width);
|
|
124
|
+
})
|
|
121
125
|
: [fit(theme.fg("dim", " No items in this view"), width)]),
|
|
122
126
|
fit(theme.fg("dim", "╰─ ↑↓: select · Space: check · a: add · e: edit · d: delete · f: filter · Esc: close"), width)
|
|
123
127
|
];
|
package/src/diff-view.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Key, matchesKey, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
3
|
import { diffRows, lineDiff, type ChangeTracker, type DiffRow, type FileChange } from "./changes.ts";
|
|
4
|
+
import { CHILD_BASELINE_ENV, writeChildBaseline } from "./child-baselines.ts";
|
|
4
5
|
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
5
6
|
|
|
6
7
|
const ACTIONS = [
|
|
@@ -146,10 +147,21 @@ export function registerChangeReview(pi: ExtensionAPI, tracker: () => ChangeTrac
|
|
|
146
147
|
// that file. Older Pi builds may omit toolCallId here, hence the small FIFO fallback.
|
|
147
148
|
const pending = new Map<string, string>();
|
|
148
149
|
const fallback: string[] = [];
|
|
150
|
+
// Subagent side: hand each first-edit baseline to the parent so its /diff covers our edits.
|
|
151
|
+
const reported = new Set<string>();
|
|
149
152
|
pi.on("tool_call", (event) => {
|
|
150
153
|
if (event.toolName !== "edit" && event.toolName !== "write") return;
|
|
151
154
|
const path = (event.input as { path?: unknown }).path;
|
|
152
|
-
|
|
155
|
+
const current = tracker();
|
|
156
|
+
if (typeof path !== "string" || !path.trim() || !current?.capture(path)) return;
|
|
157
|
+
const dir = process.env[CHILD_BASELINE_ENV];
|
|
158
|
+
const abs = current.absolute(path);
|
|
159
|
+
const before = current.baseline(abs);
|
|
160
|
+
// A failed write is reported once and not retried: a later retry would share a newer baseline.
|
|
161
|
+
if (dir && before !== undefined && !reported.has(abs)) {
|
|
162
|
+
try { writeChildBaseline(dir, abs, before); }
|
|
163
|
+
catch (error) { reported.add(abs); console.error(`pi-jar: cannot share baseline for ${abs}: ${String(error)}`); }
|
|
164
|
+
}
|
|
153
165
|
const id = (event as { toolCallId?: unknown }).toolCallId;
|
|
154
166
|
if (typeof id === "string" && id) pending.set(id, path);
|
|
155
167
|
fallback.push(path);
|
package/src/flame.ts
CHANGED
|
@@ -46,9 +46,17 @@ function hexRgb(hex: string): [number, number, number] | undefined {
|
|
|
46
46
|
return [0, 2, 4].map((at) => Number.parseInt(value.slice(at, at + 2), 16)) as [number, number, number];
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** 24-bit foreground prefix per hex color: the flame and Ember repaint the same ramp colors every frame. */
|
|
50
|
+
const foregrounds = new Map<string, string>();
|
|
51
|
+
|
|
49
52
|
export function rgb(hex: string, text: string): string {
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
let prefix = foregrounds.get(hex);
|
|
54
|
+
if (prefix === undefined) {
|
|
55
|
+
const color = hexRgb(hex);
|
|
56
|
+
prefix = color ? `\x1b[38;2;${color[0]};${color[1]};${color[2]}m` : "";
|
|
57
|
+
if (foregrounds.size < 64) foregrounds.set(hex, prefix);
|
|
58
|
+
}
|
|
59
|
+
return prefix ? prefix + text + "\x1b[39m" : text;
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
function cell(top: number, bottom: number): string {
|
|
@@ -59,6 +67,8 @@ function cell(top: number, bottom: number): string {
|
|
|
59
67
|
if (down) return `\x1b[38;2;${down[0]};${down[1]};${down[2]}m▄\x1b[39m`;
|
|
60
68
|
return " ";
|
|
61
69
|
}
|
|
70
|
+
/** Every truecolor half-block pair, built once instead of parsing two hex colors per cell per frame. */
|
|
71
|
+
const CELLS = Array.from({ length: FLAME_RAMP.length ** 2 }, (_, index) => cell(Math.floor(index / FLAME_RAMP.length), index % FLAME_RAMP.length));
|
|
62
72
|
|
|
63
73
|
/** Terminals without 24-bit color get shaded blocks in theme colors instead. */
|
|
64
74
|
function shadedCell(top: number, bottom: number, paint: FlamePaint): string {
|
|
@@ -217,7 +227,7 @@ export class FlameSim {
|
|
|
217
227
|
const rows: string[][] = Array.from({ length: this.rows / 2 }, (_, row) => Array.from({ length: this.width }, (_, x) => {
|
|
218
228
|
const top = this.heat[row * 2 * this.width + x]!;
|
|
219
229
|
const bottom = this.heat[(row * 2 + 1) * this.width + x]!;
|
|
220
|
-
return truecolor ? cell(top, bottom) : shadedCell(top, bottom, paint);
|
|
230
|
+
return truecolor ? CELLS[top * FLAME_RAMP.length + bottom] ?? cell(top, bottom) : shadedCell(top, bottom, paint);
|
|
221
231
|
}));
|
|
222
232
|
for (const particle of this.particles) {
|
|
223
233
|
const x = Math.round(particle.x);
|
|
@@ -234,12 +244,15 @@ export class FlameSim {
|
|
|
234
244
|
}
|
|
235
245
|
}
|
|
236
246
|
|
|
237
|
-
let cached: { seed: number; sim: FlameSim } | undefined;
|
|
247
|
+
let cached: { seed: number; sim: FlameSim; frame?: number; rows?: string[] } | undefined;
|
|
238
248
|
|
|
239
249
|
/** Rendered flame rows for an absolute frame; replays deterministically for a seed. */
|
|
240
250
|
export function flameFrame(frame: number, seed = 1, paint?: FlamePaint, truecolor = supportsTruecolor()): string[] {
|
|
241
251
|
const target = Math.max(0, Math.floor(Number.isFinite(frame) ? frame : 0));
|
|
242
252
|
if (!cached || cached.seed !== seed || cached.sim.frame > target) cached = { seed, sim: new FlameSim(seed) };
|
|
243
253
|
while (cached.sim.frame < target) cached.sim.step();
|
|
244
|
-
return cached.sim.render(paint, truecolor);
|
|
254
|
+
if (!truecolor) return cached.sim.render(paint, truecolor);
|
|
255
|
+
// Truecolor rows never use `paint`, so repaints between flame ticks (typing, streaming) reuse the frame.
|
|
256
|
+
if (cached.frame !== target || !cached.rows) { cached.rows = cached.sim.render(paint, true); cached.frame = target; }
|
|
257
|
+
return cached.rows.slice();
|
|
245
258
|
}
|
package/src/footer.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { sep } from "node:path";
|
|
|
3
3
|
import { sliceByColumn, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import { DEFAULT_FOOTER_SETTINGS, type FooterSettings } from "./footer-settings.ts";
|
|
5
5
|
import { roleFrame } from "./animations.ts";
|
|
6
|
+
import { icon, iconSet, type IconKey, withIcon } from "./icons.ts";
|
|
6
7
|
import type { RoleStatus } from "./roles.ts";
|
|
7
8
|
import { ACTIVE_STATES, cleanText, type JarStatus } from "./status.ts";
|
|
8
9
|
import type { Quota } from "./quota.ts";
|
|
@@ -18,8 +19,10 @@ export interface FooterView extends JarStatus {
|
|
|
18
19
|
settings?: FooterSettings;
|
|
19
20
|
context: string;
|
|
20
21
|
goal?: string;
|
|
21
|
-
/** pi-jar's own short indicators (changes to review
|
|
22
|
+
/** pi-jar's own short indicators (changes to review), shown before extras. */
|
|
22
23
|
chips?: readonly string[];
|
|
24
|
+
/** Live subagents and background shells, one clickable row each. */
|
|
25
|
+
activity?: readonly FooterActivity[];
|
|
23
26
|
memory?: string;
|
|
24
27
|
cost?: string;
|
|
25
28
|
quota?: Quota;
|
|
@@ -30,6 +33,60 @@ export interface FooterView extends JarStatus {
|
|
|
30
33
|
motionBudget?: number;
|
|
31
34
|
}
|
|
32
35
|
|
|
36
|
+
export type ActivityState = "queued" | "running" | "done" | "failed" | "stopped";
|
|
37
|
+
export interface FooterActivity {
|
|
38
|
+
kind: "subagent" | "shell";
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
state: ActivityState;
|
|
42
|
+
/** What it is doing now: the current tool, or the shell command. */
|
|
43
|
+
detail?: string;
|
|
44
|
+
/** Right-aligned counters, e.g. `3 tools · 12s`. */
|
|
45
|
+
stats?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A clickable footer region, in the footer component's local cells. */
|
|
49
|
+
export type FooterTarget = { kind: "subagent" | "shell"; id: string } | { kind: "activity" } | { kind: "session" };
|
|
50
|
+
export interface FooterHit { y: number; x0: number; x1: number; target: FooterTarget }
|
|
51
|
+
|
|
52
|
+
const MAX_ACTIVITY_ROWS = 3;
|
|
53
|
+
const THINKING_ICONS: Record<string, IconKey> = {
|
|
54
|
+
minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium", high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
|
|
55
|
+
};
|
|
56
|
+
const EFFORT_COLORS = {
|
|
57
|
+
off: "thinkingOff", minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium",
|
|
58
|
+
high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
|
|
59
|
+
} as const;
|
|
60
|
+
/** Every other color the footer paints; one sample of each keys the render cache to the live theme. */
|
|
61
|
+
const FOOTER_COLORS: readonly Color[] = ["accent", "warning", "success", "error", "dim", "muted"];
|
|
62
|
+
|
|
63
|
+
/** `ctx 58%` → `◫ 58%`: the icon replaces the word, and ascii keeps the word. */
|
|
64
|
+
const metric = (key: IconKey, word: string, text: string) => withIcon(key, text.startsWith(word + " ") ? text.slice(word.length + 1) : text);
|
|
65
|
+
|
|
66
|
+
function activityGlyph(state: ActivityState): { glyph: string; color: Color } {
|
|
67
|
+
if (state === "running") return { glyph: icon("running"), color: "accent" };
|
|
68
|
+
if (state === "queued") return { glyph: icon("pending"), color: "dim" };
|
|
69
|
+
if (state === "done") return { glyph: icon("success"), color: "success" };
|
|
70
|
+
if (state === "failed") return { glyph: icon("error"), color: "error" };
|
|
71
|
+
return { glyph: icon("stopped"), color: "dim" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function activityRow(item: FooterActivity, width: number, theme: FooterTheme): string {
|
|
75
|
+
const { glyph, color } = activityGlyph(item.state);
|
|
76
|
+
const head = theme.fg(color, glyph) + " " + theme.fg(item.state === "running" ? "accent" : "muted",
|
|
77
|
+
withIcon(item.kind === "subagent" ? "agent" : "shell", cleanText(item.name, 28)));
|
|
78
|
+
const stats = item.stats ? theme.fg("dim", cleanText(item.stats, 40)) : "";
|
|
79
|
+
const headWidth = visibleWidth(head);
|
|
80
|
+
const statsWidth = visibleWidth(stats);
|
|
81
|
+
const room = width - headWidth - statsWidth - 4;
|
|
82
|
+
const detail = item.detail && room >= 6 ? " " + theme.fg("dim", truncateToWidth(cleanText(item.detail, 200), room, "…")) : "";
|
|
83
|
+
const detailWidth = visibleWidth(detail);
|
|
84
|
+
const gap = Math.max(1, width - headWidth - detailWidth - statsWidth);
|
|
85
|
+
const row = head + detail + (stats ? " ".repeat(gap) + stats : "");
|
|
86
|
+
// The parts are already measured; only a head and stats too wide on their own need cutting.
|
|
87
|
+
return headWidth + detailWidth + (stats ? gap + statsWidth : 0) <= width ? row : truncateToWidth(row, width);
|
|
88
|
+
}
|
|
89
|
+
|
|
33
90
|
function fitCwd(cwd: string, width: number): string {
|
|
34
91
|
if (visibleWidth(cwd) <= width) return cwd;
|
|
35
92
|
return "…" + sliceByColumn(cwd, visibleWidth(cwd) - width + 1, width - 1);
|
|
@@ -45,33 +102,53 @@ function roleColor(role: RoleStatus): Color {
|
|
|
45
102
|
|
|
46
103
|
/** Every line is bounded in terminal cells; critical role failure takes precedence. */
|
|
47
104
|
export function renderFooter(view: FooterView, width: number, theme: FooterTheme): string[] {
|
|
105
|
+
return renderFooterLayout(view, width, theme).lines;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Pi repaints the footer on every render (each streamed token) while its inputs change far less often. */
|
|
109
|
+
let lastFooter: { key: string; lines: string[]; hits: FooterHit[] } | undefined;
|
|
110
|
+
|
|
111
|
+
/** Footer lines plus the clickable regions (activity rows, the session name) they contain. */
|
|
112
|
+
export function renderFooterLayout(view: FooterView, width: number, theme: FooterTheme): { lines: string[]; hits: FooterHit[] } {
|
|
113
|
+
const colors = FOOTER_COLORS.map((color) => theme.fg(color, "x")).join("") + (view.effort ? theme.fg(EFFORT_COLORS[view.effort], "x") : "");
|
|
114
|
+
const key = `${width}\0${iconSet()}\0${homedir()}\0${colors}\0${JSON.stringify(view)}`;
|
|
115
|
+
if (lastFooter?.key !== key) {
|
|
116
|
+
const hits: FooterHit[] = [];
|
|
117
|
+
lastFooter = { key, lines: footerLines(view, width, theme, hits), hits };
|
|
118
|
+
}
|
|
119
|
+
return { lines: lastFooter.lines.slice(), hits: lastFooter.hits.slice() };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function footerLines(view: FooterView, width: number, theme: FooterTheme, hits: FooterHit[]): string[] {
|
|
48
123
|
if (width <= 0) return [];
|
|
49
124
|
const show = view.settings ?? DEFAULT_FOOTER_SETTINGS;
|
|
50
|
-
|
|
125
|
+
const activity = view.activity ?? [];
|
|
126
|
+
if (!view.goal && !activity.length && !Object.entries(show).some(([field, enabled]) => enabled && (field !== "memory" || !!view.memory))) return [];
|
|
51
127
|
// Keep tiny terminals unframed so context and urgent role status stay readable.
|
|
52
128
|
const framed = width >= 32;
|
|
53
129
|
const contentWidth = framed ? width - 4 : width;
|
|
130
|
+
// Local cell offsets of content inside the frame, for click targets.
|
|
131
|
+
const offsetX = framed ? 2 : 0;
|
|
132
|
+
const offsetY = framed ? 1 : 0;
|
|
54
133
|
const roles = show.roles ? [...view.roles].sort((a, b) => Number(b.state === "failed") - Number(a.state === "failed")) : [];
|
|
55
134
|
const primary = roles.find((role) => role.state === "failed") ?? roles.find((role) => ACTIVE_STATES.has(role.state));
|
|
56
135
|
const model = cleanText(view.model, 24) || "no-model";
|
|
57
|
-
const context = cleanText(view.context, 12) || "ctx ?";
|
|
136
|
+
const context = metric("context", "ctx", cleanText(view.context, 12) || "ctx ?");
|
|
58
137
|
const goal = view.goal ? cleanText(view.goal, 240) : "";
|
|
59
138
|
const quota = view.quota;
|
|
60
|
-
const
|
|
61
|
-
const
|
|
139
|
+
const windowList = [quota?.fiveHour && `5h ${Math.round(quota.fiveHour.used)}%`, quota?.week && `week ${Math.round(quota.week.used)}%`].filter((value): value is string => !!value);
|
|
140
|
+
const windows = windowList.map((value, index) => index ? value : withIcon("quota", value));
|
|
141
|
+
const cost = view.cost ? metric("cost", "cost", cleanText(view.cost, 20)) : undefined;
|
|
142
|
+
const memory = view.memory ? metric("memory", "ram", view.memory) : undefined;
|
|
62
143
|
const prefix = view.demo ? theme.fg("warning", "DEMO") : "";
|
|
63
|
-
const
|
|
64
|
-
off: "thinkingOff", minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium",
|
|
65
|
-
high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
|
|
66
|
-
} as const;
|
|
67
|
-
const effort = show.effort && view.effort ? theme.fg(effortColors[view.effort], view.effort) : "";
|
|
144
|
+
const effort = show.effort && view.effort ? theme.fg(EFFORT_COLORS[view.effort], THINKING_ICONS[view.effort] ? withIcon(THINKING_ICONS[view.effort]!, view.effort) : view.effort) : "";
|
|
68
145
|
const firstRole = primary ? `${primary.label} ${roleFrame(primary.state, view.frame, view.animations)}` : undefined;
|
|
69
146
|
const attention = firstRole ?? (show.extras ? view.extras[0] : undefined);
|
|
70
147
|
// At narrow widths reserve the right-hand slot for critical/active status, then context.
|
|
71
|
-
const modelLabel = show.model ? theme.fg("muted", model) : "";
|
|
148
|
+
const modelLabel = show.model ? theme.fg("muted", withIcon("model", model)) : "";
|
|
72
149
|
const left = [prefix, modelLabel, effort].filter(Boolean).join(theme.fg("dim", " · "));
|
|
73
150
|
// Context is never dropped; optional metrics move to a second line on medium widths.
|
|
74
|
-
const metrics = [...(show.context ? [context] : []), ...(show.memory && contentWidth >= 90 &&
|
|
151
|
+
const metrics = [...(show.context ? [context] : []), ...(show.memory && contentWidth >= 90 && memory ? [memory] : []), ...(show.cost && contentWidth >= 52 && cost ? [cost] : []), ...(show.quota && contentWidth >= 90 ? windows : [])];
|
|
75
152
|
let right = theme.fg("dim", metrics.join(" · "));
|
|
76
153
|
if (contentWidth < 52 && attention && contentWidth >= 26) {
|
|
77
154
|
const room = Math.max(0, contentWidth - Math.min(visibleWidth(left), 12) - visibleWidth(right) - 4);
|
|
@@ -87,28 +164,46 @@ export function renderFooter(view: FooterView, width: number, theme: FooterTheme
|
|
|
87
164
|
if (room >= 3 && beforeEffort) return truncateToWidth(beforeEffort, room) + theme.fg("dim", " · ") + effort;
|
|
88
165
|
return truncateToWidth(beforeEffort || effort, availableLeft);
|
|
89
166
|
})();
|
|
90
|
-
const name = show.sessionName
|
|
167
|
+
const name = show.sessionName ? cleanText(view.sessionName || "sessions", 128) : "";
|
|
91
168
|
const rawCwd = show.cwd && view.cwd ? cleanText(view.cwd, 256) : "";
|
|
92
169
|
const home = homedir();
|
|
93
170
|
const cwd = home && rawCwd.startsWith(home + sep) ? `~${rawCwd.slice(home.length)}` : rawCwd;
|
|
94
171
|
let namedLeft = fittedLeft;
|
|
95
172
|
let space = availableLeft - visibleWidth(namedLeft);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
173
|
+
const folder = icon("folder") ? icon("folder") + " " : "";
|
|
174
|
+
if (cwd && space >= 7 + folder.length) {
|
|
175
|
+
const budget = name ? Math.min(24, Math.max(4, Math.floor((space - 6) / 2))) : Math.min(32, space - 3 - folder.length);
|
|
176
|
+
namedLeft += theme.fg("dim", " · ") + theme.fg("muted", folder + fitCwd(cwd, budget));
|
|
99
177
|
space = availableLeft - visibleWidth(namedLeft);
|
|
100
178
|
}
|
|
101
|
-
if (name && space >= 7)
|
|
102
|
-
|
|
103
|
-
|
|
179
|
+
if (name && space >= 7) {
|
|
180
|
+
const start = visibleWidth(namedLeft) + 3;
|
|
181
|
+
const label = truncateToWidth(withIcon("session", name), space - 3, "…");
|
|
182
|
+
namedLeft += theme.fg("dim", " · ") + theme.fg("muted", label);
|
|
183
|
+
hits.push({ y: offsetY, x0: offsetX + start, x1: offsetX + start + visibleWidth(label), target: { kind: "session" } });
|
|
184
|
+
}
|
|
185
|
+
const namedWidth = visibleWidth(namedLeft);
|
|
186
|
+
const rightWidth = visibleWidth(right);
|
|
187
|
+
const gap = Math.max(1, contentWidth - namedWidth - rightWidth);
|
|
188
|
+
const first = namedLeft + " ".repeat(gap) + right;
|
|
189
|
+
const lines = [namedWidth + gap + rightWidth <= contentWidth ? first : truncateToWidth(first, contentWidth)];
|
|
104
190
|
if (goal) {
|
|
105
191
|
const goalPrefix = theme.fg("accent", "◎ goal") + theme.fg("dim", " · ");
|
|
106
|
-
const
|
|
107
|
-
|
|
192
|
+
const prefixWidth = visibleWidth(goalPrefix);
|
|
193
|
+
const line = goalPrefix + theme.fg("muted", truncateToWidth(goal, Math.max(0, contentWidth - prefixWidth), "…"));
|
|
194
|
+
lines.push(prefixWidth <= contentWidth ? line : truncateToWidth(line, contentWidth));
|
|
108
195
|
}
|
|
109
196
|
if (contentWidth < 52) {
|
|
110
|
-
const small = [...(show.memory &&
|
|
197
|
+
const small = [...(show.memory && memory ? [memory] : []), ...(show.quota ? windows : [])];
|
|
111
198
|
if (small.length && contentWidth >= 26) lines.push(truncateToWidth(theme.fg("dim", small.join(" · ")), contentWidth));
|
|
199
|
+
// Too narrow for rows: one clickable count of live work.
|
|
200
|
+
const agents = activity.filter((item) => item.kind === "subagent" && (item.state === "running" || item.state === "queued")).length;
|
|
201
|
+
const shells = activity.filter((item) => item.kind === "shell" && item.state === "running").length;
|
|
202
|
+
const counts = [agents ? withIcon("subagents", `${agents}`) : "", shells ? withIcon("shell", `${shells}`) : ""].filter(Boolean).join(" ");
|
|
203
|
+
if (counts) {
|
|
204
|
+
hits.push({ y: offsetY + lines.length, x0: offsetX, x1: offsetX + contentWidth, target: { kind: "activity" } });
|
|
205
|
+
lines.push(truncateToWidth(theme.fg("accent", counts), contentWidth));
|
|
206
|
+
}
|
|
112
207
|
return framed ? frameFooter(lines, width, view, theme) : lines;
|
|
113
208
|
}
|
|
114
209
|
|
|
@@ -123,19 +218,29 @@ export function renderFooter(view: FooterView, width: number, theme: FooterTheme
|
|
|
123
218
|
const remaining = Math.max(0, view.extras.length - (contentWidth >= 100 ? 2 : 1));
|
|
124
219
|
const extras = show.extras ? view.extras.slice(0, contentWidth >= 100 ? 2 : 1).map((status) => theme.fg("muted", status)) : [];
|
|
125
220
|
if (show.extras && remaining) extras.push(theme.fg("dim", `+${remaining}`));
|
|
126
|
-
if (show.branch && contentWidth >= 52 && view.branch) extras.push(theme.fg("dim",
|
|
221
|
+
if (show.branch && contentWidth >= 52 && view.branch) extras.push(theme.fg("dim", withIcon("branch", cleanText(view.branch, 28))));
|
|
127
222
|
const chips = (view.chips ?? []).map((chip) => theme.fg("warning", cleanText(chip, 32)));
|
|
128
223
|
const joined = [...segments, ...chips, ...extras].join(theme.fg("dim", " · "));
|
|
129
224
|
if (joined) lines.push(truncateToWidth(joined, contentWidth));
|
|
130
|
-
if (contentWidth < 90 && ((show.quota && windows.length) || (show.memory &&
|
|
131
|
-
const summary = [...(show.memory &&
|
|
225
|
+
if (contentWidth < 90 && ((show.quota && windows.length) || (show.memory && memory))) {
|
|
226
|
+
const summary = [...(show.memory && memory ? [memory] : []), ...(show.quota ? windows : [])].join(" · ");
|
|
132
227
|
if (summary) lines.push(truncateToWidth(theme.fg("dim", summary), contentWidth));
|
|
133
228
|
}
|
|
229
|
+
const shown = activity.length > MAX_ACTIVITY_ROWS ? activity.slice(0, MAX_ACTIVITY_ROWS - 1) : activity;
|
|
230
|
+
for (const item of shown) {
|
|
231
|
+
hits.push({ y: offsetY + lines.length, x0: offsetX, x1: offsetX + contentWidth, target: { kind: item.kind, id: item.id } });
|
|
232
|
+
lines.push(activityRow(item, contentWidth, theme));
|
|
233
|
+
}
|
|
234
|
+
if (shown.length < activity.length) {
|
|
235
|
+
hits.push({ y: offsetY + lines.length, x0: offsetX, x1: offsetX + contentWidth, target: { kind: "activity" } });
|
|
236
|
+
lines.push(truncateToWidth(theme.fg("dim", ` +${activity.length - shown.length} more · ctrl+alt+a to manage`), contentWidth));
|
|
237
|
+
}
|
|
134
238
|
return framed ? frameFooter(lines, width, view, theme) : lines;
|
|
135
239
|
}
|
|
136
240
|
|
|
137
241
|
function frameFooter(lines: string[], width: number, view: FooterView, theme: FooterTheme): string[] {
|
|
138
|
-
const active = (view.settings?.roles ?? true) && view.roles.some((role) => ACTIVE_STATES.has(role.state))
|
|
242
|
+
const active = ((view.settings?.roles ?? true) && view.roles.some((role) => ACTIVE_STATES.has(role.state)))
|
|
243
|
+
|| !!view.activity?.some((item) => item.state === "running");
|
|
139
244
|
const pulse = active && view.animations ? ["◇", "◈", "◆", "◈"][view.frame % 4] : "◇";
|
|
140
245
|
const top = theme.fg("dim", "╭─") + theme.fg(active ? "accent" : "dim", pulse)
|
|
141
246
|
+ theme.fg("dim", "─".repeat(width - 4) + "╮");
|
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/icons.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glyph sets for pi-jar chrome, modelled on omp's symbol presets:
|
|
3
|
+
* - `unicode`: symbols every monospace font ships (default);
|
|
4
|
+
* - `nerd`: Nerd Font icons (install a Nerd Font, e.g. via `brew install --cask font-jetbrains-mono-nerd-font`);
|
|
5
|
+
* - `ascii`: no decorative glyphs; key hints spelled out.
|
|
6
|
+
* Decorative keys may map to "" in `ascii`; always render through `withIcon`.
|
|
7
|
+
*/
|
|
8
|
+
export const ICON_SETS = ["unicode", "nerd", "ascii"] as const;
|
|
9
|
+
export type IconSet = (typeof ICON_SETS)[number];
|
|
10
|
+
|
|
11
|
+
export type IconKey =
|
|
12
|
+
| "pi" | "model" | "folder" | "branch" | "context" | "memory" | "cost" | "quota" | "session"
|
|
13
|
+
| "subagents" | "agent" | "shell" | "changes" | "goal" | "plan" | "tasks" | "time" | "tools" | "roles"
|
|
14
|
+
| "esc" | "enter" | "tab" | "shiftEnter"
|
|
15
|
+
| "success" | "error" | "running" | "pending" | "stopped" | "warning"
|
|
16
|
+
| "thinkingMinimal" | "thinkingLow" | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh" | "thinkingMax";
|
|
17
|
+
|
|
18
|
+
const UNICODE: Record<IconKey, string> = {
|
|
19
|
+
pi: "π", model: "⬢", folder: "⌂", branch: "⎇", context: "◫", memory: "▤", cost: "", quota: "◔", session: "◈",
|
|
20
|
+
subagents: "⧉", agent: "◉", shell: "⚙", changes: "±", goal: "◎", plan: "◆", tasks: "☰", time: "◷", tools: "⚒", roles: "⇄",
|
|
21
|
+
esc: "⎋", enter: "⏎", tab: "⇥", shiftEnter: "⇧⏎",
|
|
22
|
+
success: "✔", error: "✖", running: "●", pending: "○", stopped: "■", warning: "⚠",
|
|
23
|
+
thinkingMinimal: "○", thinkingLow: "◔", thinkingMedium: "◑", thinkingHigh: "◒", thinkingXhigh: "◕", thinkingMax: "◉"
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Code points follow omp's nerd preset (Font Awesome / Codicons / Material Design ranges).
|
|
27
|
+
const NERD: Record<IconKey, string> = {
|
|
28
|
+
pi: "\u{f0d57}", model: "\uec19", folder: "\uf115", branch: "\uf126", context: "\ue70f", memory: "\u{f035b}",
|
|
29
|
+
cost: "\uf0d6", quota: "\uf0e4", session: "\u{f02da}",
|
|
30
|
+
subagents: "\uf0c0", agent: "\uf007", shell: "\uf120", changes: "\uf044", goal: "\uf140", plan: "\uf2d2",
|
|
31
|
+
tasks: "\uf0ae", time: "\uf017", tools: "\uf0ad", roles: "\u{f04e1}",
|
|
32
|
+
esc: "\u{f12b7}", enter: "\u{f0311}", tab: "\u{f0312}", shiftEnter: "\u{f0636}\u{f0311}",
|
|
33
|
+
success: "\uf00c", error: "\uf00d", running: "\uf111", pending: "\uf10c", stopped: "\uf04d", warning: "\uf071",
|
|
34
|
+
thinkingMinimal: "\u{f0a9e}", thinkingLow: "\u{f0a9f}", thinkingMedium: "\u{f0aa1}", thinkingHigh: "\u{f0aa3}",
|
|
35
|
+
thinkingXhigh: "\u{f0aa5}", thinkingMax: "\uf06d"
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const ASCII: Record<IconKey, string> = {
|
|
39
|
+
pi: "", model: "", folder: "", branch: "git", context: "ctx", memory: "ram", cost: "cost", quota: "quota", session: "",
|
|
40
|
+
subagents: "", agent: "", shell: "$", changes: "+/-", goal: "goal", plan: "plan", tasks: "", time: "", tools: "", roles: "",
|
|
41
|
+
esc: "esc", enter: "enter", tab: "tab", shiftEnter: "shift+enter",
|
|
42
|
+
success: "ok", error: "x", running: "*", pending: "-", stopped: "#", warning: "!",
|
|
43
|
+
thinkingMinimal: "", thinkingLow: "", thinkingMedium: "", thinkingHigh: "", thinkingXhigh: "", thinkingMax: ""
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const SETS: Record<IconSet, Record<IconKey, string>> = { unicode: UNICODE, nerd: NERD, ascii: ASCII };
|
|
47
|
+
let active: IconSet = "unicode";
|
|
48
|
+
|
|
49
|
+
/** Process-wide: renderers read the active set on every paint, so a settings change applies on the next frame. */
|
|
50
|
+
export function setIconSet(set: IconSet): void { active = set; }
|
|
51
|
+
export function iconSet(): IconSet { return active; }
|
|
52
|
+
export function icon(key: IconKey): string { return SETS[active][key]; }
|
|
53
|
+
/** `glyph text`, or just `text` when the active set has no glyph for `key`. */
|
|
54
|
+
export function withIcon(key: IconKey, text: string): string {
|
|
55
|
+
const glyph = SETS[active][key];
|
|
56
|
+
return glyph ? (text ? glyph + " " + text : glyph) : text;
|
|
57
|
+
}
|
package/src/model-roles.ts
CHANGED
|
@@ -21,7 +21,7 @@ export type RoleScope = "global" | "project";
|
|
|
21
21
|
export interface RoleAssignment { provider: string; model: string; thinking?: ThinkingLevel }
|
|
22
22
|
export interface ResolvedRole extends RoleAssignment { via: string[] }
|
|
23
23
|
export interface RoleTag { name?: string; color?: string }
|
|
24
|
-
export interface RoleConfig { version: 2; roles: Record<string, string>; cycleOrder?: string[]; tags?: Record<string, RoleTag> }
|
|
24
|
+
export interface RoleConfig { version: 2; roles: Record<string, string>; cycleOrder?: string[]; tags?: Record<string, RoleTag>; fallbacks?: Record<string, string[]> }
|
|
25
25
|
export interface RoleRow {
|
|
26
26
|
role: string; label: string; usedBy?: string; custom: boolean;
|
|
27
27
|
spec?: string; scope?: RoleScope; resolved?: ResolvedRole; error?: string;
|
|
@@ -67,6 +67,17 @@ export function parseRoleConfig(raw: unknown): RoleConfig {
|
|
|
67
67
|
}
|
|
68
68
|
if (spec) config.roles[role] = spec;
|
|
69
69
|
}
|
|
70
|
+
if (value.fallbacks && typeof value.fallbacks === "object") {
|
|
71
|
+
const fallbacks: Record<string, string[]> = {};
|
|
72
|
+
for (const [role, entries] of Object.entries(value.fallbacks)) {
|
|
73
|
+
if (!isRoleName(role) || !Array.isArray(entries)) continue;
|
|
74
|
+
fallbacks[role] = [...new Set(entries.flatMap((entry) => {
|
|
75
|
+
const spec = typeof entry === "string" ? normalizeSpec(entry) : undefined;
|
|
76
|
+
return spec ? [spec] : [];
|
|
77
|
+
}))].slice(0, 8);
|
|
78
|
+
}
|
|
79
|
+
if (Object.keys(fallbacks).length) config.fallbacks = fallbacks;
|
|
80
|
+
}
|
|
70
81
|
if (Array.isArray(value.cycleOrder)) {
|
|
71
82
|
const order = value.cycleOrder.filter((item): item is string => typeof item === "string" && isRoleName(item));
|
|
72
83
|
if (order.length) config.cycleOrder = [...new Set(order)];
|
|
@@ -143,6 +154,8 @@ export class ModelRoleManager {
|
|
|
143
154
|
private applying = false;
|
|
144
155
|
/** Bumped whenever the user picks a model or effort themselves. */
|
|
145
156
|
private manualEpoch = 0;
|
|
157
|
+
/** Last `summary()`, valid while the same (replaced-on-write) configs are loaded. */
|
|
158
|
+
private summaryMemo: { global: RoleConfig; project: RoleConfig; limit: number; text: string } | undefined;
|
|
146
159
|
|
|
147
160
|
constructor(pi: ExtensionAPI) {
|
|
148
161
|
this.pi = pi;
|
|
@@ -188,12 +201,16 @@ export class ModelRoleManager {
|
|
|
188
201
|
});
|
|
189
202
|
}
|
|
190
203
|
|
|
191
|
-
/** Summary for compact surfaces such as the welcome card. */
|
|
204
|
+
/** Summary for compact surfaces such as the welcome card, which repaints every frame. */
|
|
192
205
|
summary(limit = 3): string {
|
|
206
|
+
const memo = this.summaryMemo;
|
|
207
|
+
if (memo?.global === this.global && memo.project === this.project && memo.limit === limit) return memo.text;
|
|
193
208
|
const rows = this.list().filter((row) => row.resolved || row.spec);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
209
|
+
const text = !rows.length ? "all roles follow the current model"
|
|
210
|
+
: rows.slice(0, limit).map((row) => row.role + "→" + (row.spec?.startsWith("@") ? row.spec.split(":")[0] : row.resolved?.model ?? "?")).join(" · ")
|
|
211
|
+
+ (rows.length > limit ? ` · +${rows.length - limit}` : "");
|
|
212
|
+
this.summaryMemo = { global: this.global, project: this.project, limit, text };
|
|
213
|
+
return text;
|
|
197
214
|
}
|
|
198
215
|
|
|
199
216
|
private status(ctx: ExtensionContext): void {
|
|
@@ -279,6 +296,32 @@ export class ModelRoleManager {
|
|
|
279
296
|
if (scope === "project") this.project = next; else this.global = next;
|
|
280
297
|
}
|
|
281
298
|
|
|
299
|
+
/** Project lists replace global lists; an empty project list disables inherited fallbacks. */
|
|
300
|
+
fallbackSpecs(role: string): string[] {
|
|
301
|
+
return [...(this.project.fallbacks?.[role] ?? this.global.fallbacks?.[role] ?? [])];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
resolveSpec(spec: string): ResolvedRole {
|
|
305
|
+
const result = resolveRole({ ...this.merged(), __fallback__: spec }, "__fallback__");
|
|
306
|
+
if (!result || "error" in result) throw new Error(result?.error ?? "Invalid fallback model");
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
updateFallbacks(role: string, specs: string[], scope: RoleScope = "global"): void {
|
|
311
|
+
if (!isRoleName(role)) throw new Error("Invalid role name: " + role);
|
|
312
|
+
if (specs.length > 8) throw new Error("At most 8 fallback models are allowed");
|
|
313
|
+
const normalized = specs.map((spec) => {
|
|
314
|
+
const target = normalizeSpec(spec);
|
|
315
|
+
if (!target) throw new Error("Invalid fallback target: " + spec);
|
|
316
|
+
return target;
|
|
317
|
+
});
|
|
318
|
+
if (scope === "project" && !this.cwd) throw new Error("Project roles need a working directory");
|
|
319
|
+
const target = scope === "project" ? this.project : this.global;
|
|
320
|
+
const next: RoleConfig = { ...target, fallbacks: { ...target.fallbacks, [role]: [...new Set(normalized)] } };
|
|
321
|
+
writeConfig(scope === "project" ? projectRoleFile(this.cwd!) : join(getAgentDir(), ROLE_FILE), next);
|
|
322
|
+
if (scope === "project") this.project = next; else this.global = next;
|
|
323
|
+
}
|
|
324
|
+
|
|
282
325
|
register(openUi?: (ctx: ExtensionContext) => Promise<void>): void {
|
|
283
326
|
const manual = (_event: unknown, ctx: ExtensionContext) => {
|
|
284
327
|
if (this.applying) return;
|
|
@@ -294,13 +337,25 @@ export class ModelRoleManager {
|
|
|
294
337
|
});
|
|
295
338
|
|
|
296
339
|
this.pi.registerCommand("roles", {
|
|
297
|
-
description: "Configure model roles
|
|
340
|
+
description: "Configure model roles: /roles, /roles set ROLE provider/model[:effort]|@role, /roles fallback ROLE MODEL...|clear, /roles <role>",
|
|
298
341
|
handler: async (args, ctx) => {
|
|
299
342
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
300
343
|
const verb = (parts[0] ?? "").toLowerCase();
|
|
301
344
|
if (!verb || verb === "list") {
|
|
302
345
|
if (!verb && ctx.hasUI && ctx.mode === "tui" && openUi) { await openUi(ctx); return; }
|
|
303
|
-
ctx.ui.notify(this.list().map(describeRole).
|
|
346
|
+
ctx.ui.notify(this.list().map((row) => describeRole(row) + (this.fallbackSpecs(row.role).length
|
|
347
|
+
? " · fallbacks: " + this.fallbackSpecs(row.role).join(" → ") : "")).join("\n"), "info");
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (verb === "fallback") {
|
|
351
|
+
const role = parts[1] ?? "";
|
|
352
|
+
const scope: RoleScope = parts.includes("--project") ? "project" : "global";
|
|
353
|
+
const targets = parts.slice(2).filter((part) => part !== "--project");
|
|
354
|
+
if (!isRoleName(role)) { ctx.ui.notify("Usage: /roles fallback ROLE [PROVIDER/MODEL[:effort]|@ROLE ...|clear] [--project]", "error"); return; }
|
|
355
|
+
if (!targets.length) { ctx.ui.notify(role + " fallbacks: " + (this.fallbackSpecs(role).join(" → ") || "none"), "info"); return; }
|
|
356
|
+
try { this.updateFallbacks(role, targets.length === 1 && targets[0] === "clear" ? [] : targets, scope); }
|
|
357
|
+
catch (error) { ctx.ui.notify("Could not save fallbacks: " + (error as Error).message, "error"); return; }
|
|
358
|
+
ctx.ui.notify("Saved " + role + " fallbacks (" + scope + ")", "info");
|
|
304
359
|
return;
|
|
305
360
|
}
|
|
306
361
|
if (verb === "cycle") { await this.cycle(ctx); return; }
|