pi-jar 0.1.1 → 0.1.3
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 +16 -12
- package/docs/DEVELOPMENT.md +4 -2
- package/docs/WORKFLOWS.md +2 -1
- package/extensions/index.ts +212 -89
- package/package.json +1 -1
- package/src/activity-view.ts +252 -0
- package/src/advisor.ts +30 -6
- package/src/changes.ts +12 -20
- package/src/compact-tools.ts +62 -18
- package/src/composer.ts +36 -14
- package/src/delegate.ts +187 -23
- package/src/diff-view.ts +2 -2
- package/src/footer.ts +105 -18
- package/src/goal-loop.ts +34 -9
- package/src/goals.ts +13 -2
- package/src/history-ui.ts +2 -1
- package/src/icons.ts +57 -0
- package/src/info-panels.ts +19 -7
- package/src/plan.ts +37 -10
- package/src/prompt-search.ts +27 -8
- package/src/quota.ts +14 -4
- package/src/session-branch.ts +24 -0
- package/src/session-gallery.ts +10 -5
- package/src/session-ui.ts +119 -45
- package/src/settings-ui.ts +5 -2
- package/src/settings.ts +5 -1
- package/src/shells.ts +77 -99
- package/src/side-model.ts +14 -2
- package/src/status.ts +36 -24
- package/src/suggest.ts +6 -25
- package/src/task-tool.ts +35 -11
- package/src/tasks.ts +24 -7
- package/src/usage.ts +2 -1
- package/src/welcome.ts +32 -12
- package/src/working.ts +3 -1
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, 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,49 @@ 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
|
+
|
|
57
|
+
/** `ctx 58%` → `◫ 58%`: the icon replaces the word, and ascii keeps the word. */
|
|
58
|
+
const metric = (key: IconKey, word: string, text: string) => withIcon(key, text.startsWith(word + " ") ? text.slice(word.length + 1) : text);
|
|
59
|
+
|
|
60
|
+
function activityGlyph(state: ActivityState): { glyph: string; color: Color } {
|
|
61
|
+
if (state === "running") return { glyph: icon("running"), color: "accent" };
|
|
62
|
+
if (state === "queued") return { glyph: icon("pending"), color: "dim" };
|
|
63
|
+
if (state === "done") return { glyph: icon("success"), color: "success" };
|
|
64
|
+
if (state === "failed") return { glyph: icon("error"), color: "error" };
|
|
65
|
+
return { glyph: icon("stopped"), color: "dim" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function activityRow(item: FooterActivity, width: number, theme: FooterTheme): string {
|
|
69
|
+
const { glyph, color } = activityGlyph(item.state);
|
|
70
|
+
const head = theme.fg(color, glyph) + " " + theme.fg(item.state === "running" ? "accent" : "muted",
|
|
71
|
+
withIcon(item.kind === "subagent" ? "agent" : "shell", cleanText(item.name, 28)));
|
|
72
|
+
const stats = item.stats ? theme.fg("dim", cleanText(item.stats, 40)) : "";
|
|
73
|
+
const room = width - visibleWidth(head) - visibleWidth(stats) - 4;
|
|
74
|
+
const detail = item.detail && room >= 6 ? " " + theme.fg("dim", truncateToWidth(cleanText(item.detail, 200), room, "…")) : "";
|
|
75
|
+
const gap = " ".repeat(Math.max(1, width - visibleWidth(head) - visibleWidth(detail) - visibleWidth(stats)));
|
|
76
|
+
return truncateToWidth(head + detail + (stats ? gap + stats : ""), width);
|
|
77
|
+
}
|
|
78
|
+
|
|
33
79
|
function fitCwd(cwd: string, width: number): string {
|
|
34
80
|
if (visibleWidth(cwd) <= width) return cwd;
|
|
35
81
|
return "…" + sliceByColumn(cwd, visibleWidth(cwd) - width + 1, width - 1);
|
|
@@ -45,33 +91,50 @@ function roleColor(role: RoleStatus): Color {
|
|
|
45
91
|
|
|
46
92
|
/** Every line is bounded in terminal cells; critical role failure takes precedence. */
|
|
47
93
|
export function renderFooter(view: FooterView, width: number, theme: FooterTheme): string[] {
|
|
94
|
+
return renderFooterLayout(view, width, theme).lines;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Footer lines plus the clickable regions (activity rows, the session name) they contain. */
|
|
98
|
+
export function renderFooterLayout(view: FooterView, width: number, theme: FooterTheme): { lines: string[]; hits: FooterHit[] } {
|
|
99
|
+
const hits: FooterHit[] = [];
|
|
100
|
+
const lines = footerLines(view, width, theme, hits);
|
|
101
|
+
return { lines, hits };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function footerLines(view: FooterView, width: number, theme: FooterTheme, hits: FooterHit[]): string[] {
|
|
48
105
|
if (width <= 0) return [];
|
|
49
106
|
const show = view.settings ?? DEFAULT_FOOTER_SETTINGS;
|
|
50
|
-
|
|
107
|
+
const activity = view.activity ?? [];
|
|
108
|
+
if (!view.goal && !activity.length && !Object.entries(show).some(([field, enabled]) => enabled && (field !== "memory" || !!view.memory))) return [];
|
|
51
109
|
// Keep tiny terminals unframed so context and urgent role status stay readable.
|
|
52
110
|
const framed = width >= 32;
|
|
53
111
|
const contentWidth = framed ? width - 4 : width;
|
|
112
|
+
// Local cell offsets of content inside the frame, for click targets.
|
|
113
|
+
const offsetX = framed ? 2 : 0;
|
|
114
|
+
const offsetY = framed ? 1 : 0;
|
|
54
115
|
const roles = show.roles ? [...view.roles].sort((a, b) => Number(b.state === "failed") - Number(a.state === "failed")) : [];
|
|
55
116
|
const primary = roles.find((role) => role.state === "failed") ?? roles.find((role) => ACTIVE_STATES.has(role.state));
|
|
56
117
|
const model = cleanText(view.model, 24) || "no-model";
|
|
57
|
-
const context = cleanText(view.context, 12) || "ctx ?";
|
|
118
|
+
const context = metric("context", "ctx", cleanText(view.context, 12) || "ctx ?");
|
|
58
119
|
const goal = view.goal ? cleanText(view.goal, 240) : "";
|
|
59
120
|
const quota = view.quota;
|
|
60
|
-
const
|
|
61
|
-
const
|
|
121
|
+
const windowList = [quota?.fiveHour && `5h ${Math.round(quota.fiveHour.used)}%`, quota?.week && `week ${Math.round(quota.week.used)}%`].filter((value): value is string => !!value);
|
|
122
|
+
const windows = windowList.map((value, index) => index ? value : withIcon("quota", value));
|
|
123
|
+
const cost = view.cost ? metric("cost", "cost", cleanText(view.cost, 20)) : undefined;
|
|
124
|
+
const memory = view.memory ? metric("memory", "ram", view.memory) : undefined;
|
|
62
125
|
const prefix = view.demo ? theme.fg("warning", "DEMO") : "";
|
|
63
126
|
const effortColors = {
|
|
64
127
|
off: "thinkingOff", minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium",
|
|
65
128
|
high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
|
|
66
129
|
} as const;
|
|
67
|
-
const effort = show.effort && view.effort ? theme.fg(effortColors[view.effort], view.effort) : "";
|
|
130
|
+
const effort = show.effort && view.effort ? theme.fg(effortColors[view.effort], THINKING_ICONS[view.effort] ? withIcon(THINKING_ICONS[view.effort]!, view.effort) : view.effort) : "";
|
|
68
131
|
const firstRole = primary ? `${primary.label} ${roleFrame(primary.state, view.frame, view.animations)}` : undefined;
|
|
69
132
|
const attention = firstRole ?? (show.extras ? view.extras[0] : undefined);
|
|
70
133
|
// At narrow widths reserve the right-hand slot for critical/active status, then context.
|
|
71
|
-
const modelLabel = show.model ? theme.fg("muted", model) : "";
|
|
134
|
+
const modelLabel = show.model ? theme.fg("muted", withIcon("model", model)) : "";
|
|
72
135
|
const left = [prefix, modelLabel, effort].filter(Boolean).join(theme.fg("dim", " · "));
|
|
73
136
|
// Context is never dropped; optional metrics move to a second line on medium widths.
|
|
74
|
-
const metrics = [...(show.context ? [context] : []), ...(show.memory && contentWidth >= 90 &&
|
|
137
|
+
const metrics = [...(show.context ? [context] : []), ...(show.memory && contentWidth >= 90 && memory ? [memory] : []), ...(show.cost && contentWidth >= 52 && cost ? [cost] : []), ...(show.quota && contentWidth >= 90 ? windows : [])];
|
|
75
138
|
let right = theme.fg("dim", metrics.join(" · "));
|
|
76
139
|
if (contentWidth < 52 && attention && contentWidth >= 26) {
|
|
77
140
|
const room = Math.max(0, contentWidth - Math.min(visibleWidth(left), 12) - visibleWidth(right) - 4);
|
|
@@ -87,18 +150,24 @@ export function renderFooter(view: FooterView, width: number, theme: FooterTheme
|
|
|
87
150
|
if (room >= 3 && beforeEffort) return truncateToWidth(beforeEffort, room) + theme.fg("dim", " · ") + effort;
|
|
88
151
|
return truncateToWidth(beforeEffort || effort, availableLeft);
|
|
89
152
|
})();
|
|
90
|
-
const name = show.sessionName
|
|
153
|
+
const name = show.sessionName ? cleanText(view.sessionName || "sessions", 128) : "";
|
|
91
154
|
const rawCwd = show.cwd && view.cwd ? cleanText(view.cwd, 256) : "";
|
|
92
155
|
const home = homedir();
|
|
93
156
|
const cwd = home && rawCwd.startsWith(home + sep) ? `~${rawCwd.slice(home.length)}` : rawCwd;
|
|
94
157
|
let namedLeft = fittedLeft;
|
|
95
158
|
let space = availableLeft - visibleWidth(namedLeft);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
159
|
+
const folder = icon("folder") ? icon("folder") + " " : "";
|
|
160
|
+
if (cwd && space >= 7 + folder.length) {
|
|
161
|
+
const budget = name ? Math.min(24, Math.max(4, Math.floor((space - 6) / 2))) : Math.min(32, space - 3 - folder.length);
|
|
162
|
+
namedLeft += theme.fg("dim", " · ") + theme.fg("muted", folder + fitCwd(cwd, budget));
|
|
99
163
|
space = availableLeft - visibleWidth(namedLeft);
|
|
100
164
|
}
|
|
101
|
-
if (name && space >= 7)
|
|
165
|
+
if (name && space >= 7) {
|
|
166
|
+
const start = visibleWidth(namedLeft) + 3;
|
|
167
|
+
const label = truncateToWidth(withIcon("session", name), space - 3, "…");
|
|
168
|
+
namedLeft += theme.fg("dim", " · ") + theme.fg("muted", label);
|
|
169
|
+
hits.push({ y: offsetY, x0: offsetX + start, x1: offsetX + start + visibleWidth(label), target: { kind: "session" } });
|
|
170
|
+
}
|
|
102
171
|
const gap = " ".repeat(Math.max(1, contentWidth - visibleWidth(namedLeft) - visibleWidth(right)));
|
|
103
172
|
const lines = [truncateToWidth(namedLeft + gap + right, contentWidth)];
|
|
104
173
|
if (goal) {
|
|
@@ -107,8 +176,16 @@ export function renderFooter(view: FooterView, width: number, theme: FooterTheme
|
|
|
107
176
|
lines.push(truncateToWidth(goalPrefix + theme.fg("muted", truncateToWidth(goal, room, "…")), contentWidth));
|
|
108
177
|
}
|
|
109
178
|
if (contentWidth < 52) {
|
|
110
|
-
const small = [...(show.memory &&
|
|
179
|
+
const small = [...(show.memory && memory ? [memory] : []), ...(show.quota ? windows : [])];
|
|
111
180
|
if (small.length && contentWidth >= 26) lines.push(truncateToWidth(theme.fg("dim", small.join(" · ")), contentWidth));
|
|
181
|
+
// Too narrow for rows: one clickable count of live work.
|
|
182
|
+
const agents = activity.filter((item) => item.kind === "subagent" && (item.state === "running" || item.state === "queued")).length;
|
|
183
|
+
const shells = activity.filter((item) => item.kind === "shell" && item.state === "running").length;
|
|
184
|
+
const counts = [agents ? withIcon("subagents", `${agents}`) : "", shells ? withIcon("shell", `${shells}`) : ""].filter(Boolean).join(" ");
|
|
185
|
+
if (counts) {
|
|
186
|
+
hits.push({ y: offsetY + lines.length, x0: offsetX, x1: offsetX + contentWidth, target: { kind: "activity" } });
|
|
187
|
+
lines.push(truncateToWidth(theme.fg("accent", counts), contentWidth));
|
|
188
|
+
}
|
|
112
189
|
return framed ? frameFooter(lines, width, view, theme) : lines;
|
|
113
190
|
}
|
|
114
191
|
|
|
@@ -123,19 +200,29 @@ export function renderFooter(view: FooterView, width: number, theme: FooterTheme
|
|
|
123
200
|
const remaining = Math.max(0, view.extras.length - (contentWidth >= 100 ? 2 : 1));
|
|
124
201
|
const extras = show.extras ? view.extras.slice(0, contentWidth >= 100 ? 2 : 1).map((status) => theme.fg("muted", status)) : [];
|
|
125
202
|
if (show.extras && remaining) extras.push(theme.fg("dim", `+${remaining}`));
|
|
126
|
-
if (show.branch && contentWidth >= 52 && view.branch) extras.push(theme.fg("dim",
|
|
203
|
+
if (show.branch && contentWidth >= 52 && view.branch) extras.push(theme.fg("dim", withIcon("branch", cleanText(view.branch, 28))));
|
|
127
204
|
const chips = (view.chips ?? []).map((chip) => theme.fg("warning", cleanText(chip, 32)));
|
|
128
205
|
const joined = [...segments, ...chips, ...extras].join(theme.fg("dim", " · "));
|
|
129
206
|
if (joined) lines.push(truncateToWidth(joined, contentWidth));
|
|
130
|
-
if (contentWidth < 90 && ((show.quota && windows.length) || (show.memory &&
|
|
131
|
-
const summary = [...(show.memory &&
|
|
207
|
+
if (contentWidth < 90 && ((show.quota && windows.length) || (show.memory && memory))) {
|
|
208
|
+
const summary = [...(show.memory && memory ? [memory] : []), ...(show.quota ? windows : [])].join(" · ");
|
|
132
209
|
if (summary) lines.push(truncateToWidth(theme.fg("dim", summary), contentWidth));
|
|
133
210
|
}
|
|
211
|
+
const shown = activity.length > MAX_ACTIVITY_ROWS ? activity.slice(0, MAX_ACTIVITY_ROWS - 1) : activity;
|
|
212
|
+
for (const item of shown) {
|
|
213
|
+
hits.push({ y: offsetY + lines.length, x0: offsetX, x1: offsetX + contentWidth, target: { kind: item.kind, id: item.id } });
|
|
214
|
+
lines.push(activityRow(item, contentWidth, theme));
|
|
215
|
+
}
|
|
216
|
+
if (shown.length < activity.length) {
|
|
217
|
+
hits.push({ y: offsetY + lines.length, x0: offsetX, x1: offsetX + contentWidth, target: { kind: "activity" } });
|
|
218
|
+
lines.push(truncateToWidth(theme.fg("dim", ` +${activity.length - shown.length} more · ctrl+alt+a to manage`), contentWidth));
|
|
219
|
+
}
|
|
134
220
|
return framed ? frameFooter(lines, width, view, theme) : lines;
|
|
135
221
|
}
|
|
136
222
|
|
|
137
223
|
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))
|
|
224
|
+
const active = ((view.settings?.roles ?? true) && view.roles.some((role) => ACTIVE_STATES.has(role.state)))
|
|
225
|
+
|| !!view.activity?.some((item) => item.state === "running");
|
|
139
226
|
const pulse = active && view.animations ? ["◇", "◈", "◆", "◈"][view.frame % 4] : "◇";
|
|
140
227
|
const top = theme.fg("dim", "╭─") + theme.fg(active ? "accent" : "dim", pulse)
|
|
141
228
|
+ theme.fg("dim", "─".repeat(width - 4) + "╮");
|
package/src/goal-loop.ts
CHANGED
|
@@ -45,6 +45,7 @@ export class GoalLoop {
|
|
|
45
45
|
private readonly pi: ExtensionAPI;
|
|
46
46
|
private readonly options: GoalLoopOptions;
|
|
47
47
|
private prompting = 0;
|
|
48
|
+
private contextDirty = true;
|
|
48
49
|
private restoreRole: (() => Promise<void>) | undefined;
|
|
49
50
|
private roleInUse: "implement" | "advisor" | undefined;
|
|
50
51
|
|
|
@@ -63,6 +64,7 @@ export class GoalLoop {
|
|
|
63
64
|
const store = this.options.goals();
|
|
64
65
|
if (!store?.isActive()) return;
|
|
65
66
|
store.setStatus("paused", { reason });
|
|
67
|
+
this.contextDirty = true;
|
|
66
68
|
if (ctx) { ctx.ui.notify("◎ Goal paused · " + reason + " · /goal resume", "info"); this.options.changed?.(ctx); }
|
|
67
69
|
}
|
|
68
70
|
|
|
@@ -127,6 +129,7 @@ export class GoalLoop {
|
|
|
127
129
|
if (goal.phase !== "audit") return { ok: false, message: "Cannot complete yet: finish your turn; pi-jar runs an audit pass before a goal can be completed." };
|
|
128
130
|
if (!evidence.trim()) return { ok: false, message: "Provide concrete evidence (commands run, results, files) to complete the goal." };
|
|
129
131
|
if (!store.setStatus("complete", { evidence })) return { ok: false, message: "Could not record goal completion." };
|
|
132
|
+
this.contextDirty = true;
|
|
130
133
|
ctx.ui.notify("◎ Goal complete · " + goal.text, "info");
|
|
131
134
|
this.options.changed?.(ctx);
|
|
132
135
|
this.options.completed?.(ctx, { ...goal, status: "complete", evidence });
|
|
@@ -138,6 +141,7 @@ export class GoalLoop {
|
|
|
138
141
|
const store = this.options.goals();
|
|
139
142
|
if (!store) { ctx.ui.notify("Goal state is unavailable before the session starts", "warning"); return; }
|
|
140
143
|
if (!store.set(text)) { ctx.ui.notify("Could not set goal; keep it concise and plain-text", "error"); return; }
|
|
144
|
+
this.contextDirty = true;
|
|
141
145
|
this.options.changed?.(ctx);
|
|
142
146
|
ctx.ui.notify("◎ Goal active · " + store.current()!.text, "info");
|
|
143
147
|
if (this.options.planActive()) { this.pause(ctx, "plan mode is on"); return; }
|
|
@@ -174,6 +178,7 @@ export class GoalLoop {
|
|
|
174
178
|
if (!goal || goal.status !== "active") return { content: [{ type: "text", text: "No active goal." }], details: { action: "block", ok: false } };
|
|
175
179
|
const reason = (params.reason ?? "").trim() || "needs user input";
|
|
176
180
|
this.options.goals()?.setStatus("paused", { reason });
|
|
181
|
+
this.contextDirty = true;
|
|
177
182
|
ctx.ui.notify("◎ Goal blocked · " + reason, "warning");
|
|
178
183
|
this.options.changed?.(ctx);
|
|
179
184
|
return { content: [{ type: "text", text: "Goal paused for the user: " + reason }], details: { action: "block", ok: true }, terminate: true };
|
|
@@ -198,7 +203,7 @@ export class GoalLoop {
|
|
|
198
203
|
const verb = raw.toLowerCase();
|
|
199
204
|
if (/^(?:clear|off|none|drop)$/.test(verb)) {
|
|
200
205
|
await this.restore();
|
|
201
|
-
if (store.clear()) { this.options.changed?.(ctx); ctx.ui.notify("Goal cleared", "info"); }
|
|
206
|
+
if (store.clear()) { this.contextDirty = true; this.options.changed?.(ctx); ctx.ui.notify("Goal cleared", "info"); }
|
|
202
207
|
return;
|
|
203
208
|
}
|
|
204
209
|
if (verb === "pause") { this.pause(ctx, "paused by you"); return; }
|
|
@@ -208,6 +213,7 @@ export class GoalLoop {
|
|
|
208
213
|
if (this.options.planActive()) { ctx.ui.notify("Leave plan mode before resuming the goal", "warning"); return; }
|
|
209
214
|
store.setStatus("active");
|
|
210
215
|
store.setRound(0, goal.phase);
|
|
216
|
+
this.contextDirty = true;
|
|
211
217
|
this.options.changed?.(ctx);
|
|
212
218
|
this.pi.sendUserMessage("Resume work on the active goal: " + goal.text, { deliverAs: "followUp" });
|
|
213
219
|
return;
|
|
@@ -257,21 +263,37 @@ export class GoalLoop {
|
|
|
257
263
|
const goal = this.goal();
|
|
258
264
|
if (!goal || goal.status === "complete") return;
|
|
259
265
|
if (goal.status === "active" && !this.options.planActive()) await this.useRole(goal.phase === "audit" ? "advisor" : "implement", ctx);
|
|
266
|
+
this.contextDirty = true;
|
|
260
267
|
return { message: { customType: CONTEXT_TYPE, content: this.context(goal), display: false } };
|
|
261
268
|
});
|
|
262
269
|
|
|
263
|
-
// Keep only the newest goal context and continuation
|
|
270
|
+
// Keep only the newest goal context and continuation. Scan in place first and allocate a new
|
|
271
|
+
// message array only when stale pi-jar messages actually need pruning.
|
|
264
272
|
this.pi.on("context", async (event) => {
|
|
273
|
+
if (!this.contextDirty) return;
|
|
265
274
|
const goal = this.goal();
|
|
266
|
-
const
|
|
267
|
-
|
|
275
|
+
const active = !!goal && goal.status !== "complete";
|
|
276
|
+
let latestContext = -1;
|
|
277
|
+
let latestContinuation = -1;
|
|
278
|
+
let needsPrune = false;
|
|
279
|
+
for (let index = event.messages.length - 1; index >= 0; index--) {
|
|
280
|
+
const type = (event.messages[index] as { customType?: string }).customType;
|
|
281
|
+
if (type === CONTEXT_TYPE) {
|
|
282
|
+
if (!active || latestContext >= 0) needsPrune = true;
|
|
283
|
+
else latestContext = index;
|
|
284
|
+
} else if (type === CONTINUATION_TYPE) {
|
|
285
|
+
if (!active || latestContinuation >= 0) needsPrune = true;
|
|
286
|
+
else latestContinuation = index;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
this.contextDirty = false;
|
|
290
|
+
if (!needsPrune) return;
|
|
291
|
+
return { messages: event.messages.filter((raw, index) => {
|
|
268
292
|
const type = (raw as { customType?: string }).customType;
|
|
269
|
-
if (type
|
|
270
|
-
if (
|
|
271
|
-
seen.add(type);
|
|
293
|
+
if (type === CONTEXT_TYPE) return active && index === latestContext;
|
|
294
|
+
if (type === CONTINUATION_TYPE) return active && index === latestContinuation;
|
|
272
295
|
return true;
|
|
273
|
-
})
|
|
274
|
-
return { messages };
|
|
296
|
+
}) };
|
|
275
297
|
});
|
|
276
298
|
|
|
277
299
|
this.pi.on("agent_before_settle", async (event, ctx) => {
|
|
@@ -295,6 +317,7 @@ export class GoalLoop {
|
|
|
295
317
|
store.setRound(round, audit ? "audit" : "implement");
|
|
296
318
|
await this.useRole(audit ? "advisor" : "implement", ctx);
|
|
297
319
|
this.options.changed?.(ctx);
|
|
320
|
+
this.contextDirty = true;
|
|
298
321
|
return {
|
|
299
322
|
entries: [{ type: "custom_message", customType: CONTINUATION_TYPE, display: false,
|
|
300
323
|
content: audit ? this.auditMessage(goal, round) : this.implementMessage(goal, round) }],
|
|
@@ -308,6 +331,8 @@ export class GoalLoop {
|
|
|
308
331
|
this.options.changed?.(ctx);
|
|
309
332
|
});
|
|
310
333
|
|
|
334
|
+
this.pi.on("session_start", () => { this.contextDirty = true; });
|
|
335
|
+
this.pi.on("session_tree", () => { this.contextDirty = true; });
|
|
311
336
|
this.pi.on("session_shutdown", async () => { await this.restore(); });
|
|
312
337
|
}
|
|
313
338
|
}
|
package/src/goals.ts
CHANGED
|
@@ -72,11 +72,22 @@ export class GoalStore {
|
|
|
72
72
|
|
|
73
73
|
restore(branch: readonly unknown[]): void {
|
|
74
74
|
this.value = undefined;
|
|
75
|
-
|
|
75
|
+
// Collect only goal events while walking backward; stop at the newest valid set/clear
|
|
76
|
+
// checkpoint, then replay that tiny list forward.
|
|
77
|
+
const replay: unknown[] = [];
|
|
78
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
79
|
+
const raw = branch[index];
|
|
76
80
|
if (!raw || typeof raw !== "object") continue;
|
|
77
81
|
const entry = raw as Record<string, unknown>;
|
|
78
|
-
if (entry.type
|
|
82
|
+
if (entry.type !== "custom" || entry.customType !== GOAL_ENTRY || !entry.data || typeof entry.data !== "object") continue;
|
|
83
|
+
const data = entry.data as Record<string, unknown>;
|
|
84
|
+
replay.push(data);
|
|
85
|
+
const validCheckpoint = data.op === "clear" && data.v === 1
|
|
86
|
+
|| data.op === "set" && ((data.v === 1 && validGoal(data.text))
|
|
87
|
+
|| (data.v === 2 && validId(data.id) && validGoal(data.text)));
|
|
88
|
+
if (validCheckpoint) break;
|
|
79
89
|
}
|
|
90
|
+
for (let index = replay.length - 1; index >= 0; index--) this.apply(replay[index]);
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
private commit(event: GoalEvent): boolean {
|
package/src/history-ui.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
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
3
|
import { createHistorySnapshot, historyChunk, historyPage, safeHistoryText, type HistoryItem } from "./history.ts";
|
|
4
|
+
import { sessionBranch } from "./session-branch.ts";
|
|
4
5
|
|
|
5
6
|
/** Separate, read-only view. Pi's native transcript and session file are never modified. */
|
|
6
7
|
export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
|
|
7
8
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
8
9
|
// Capture the active branch ONCE on open. Paging and search operate on this snapshot.
|
|
9
|
-
const snapshot = createHistorySnapshot(ctx.sessionManager.getBranch
|
|
10
|
+
const snapshot = createHistorySnapshot(sessionBranch(ctx) as ReturnType<typeof ctx.sessionManager.getBranch>);
|
|
10
11
|
await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
|
11
12
|
let current = historyPage(snapshot);
|
|
12
13
|
let selected = Math.max(0, current.items.length - 1);
|
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/info-panels.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { openPanel, type PanelTab } from "./panel.ts";
|
|
|
4
4
|
import type { Quota } from "./quota.ts";
|
|
5
5
|
import type { SideUsage } from "./side-model.ts";
|
|
6
6
|
import { collectUsage, usageLines } from "./usage-view.ts";
|
|
7
|
+
import { sessionBranch } from "./session-branch.ts";
|
|
7
8
|
|
|
8
9
|
/** Pi's default compaction reserve, used when its settings cannot be read. */
|
|
9
10
|
const DEFAULT_RESERVE = 16_384;
|
|
@@ -52,14 +53,25 @@ export function registerInfoPanels(pi: ExtensionAPI, deps: InfoPanelDeps): void
|
|
|
52
53
|
});
|
|
53
54
|
pi.on("session_start", () => { parts = undefined; });
|
|
54
55
|
const tabs = (ctx: ExtensionContext): PanelTab[] => {
|
|
55
|
-
|
|
56
|
+
// These panels can scan a large branch/projection. Snapshot each expensive view once per
|
|
57
|
+
// panel open, lazily on first render, so scrolling/resizing does not repeat O(history) work.
|
|
58
|
+
let usage: ReturnType<typeof collectUsage> | undefined;
|
|
59
|
+
let context: ReturnType<typeof contextFor> | undefined;
|
|
56
60
|
return [
|
|
57
|
-
{ name: "Usage", render: (width, fg) =>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
{ name: "Usage", render: (width, fg) => {
|
|
62
|
+
usage ??= collectUsage(sessionBranch(ctx) as never, deps.side.all());
|
|
63
|
+
const quota = deps.quota(ctx);
|
|
64
|
+
return usageLines({
|
|
65
|
+
stats: usage,
|
|
66
|
+
...(ctx.model?.provider ? { provider: ctx.model.provider } : {}),
|
|
67
|
+
...(quota ? { quota } : {}),
|
|
68
|
+
quotaEnabled: deps.quotaEnabled(), now: Date.now()
|
|
69
|
+
}, width, fg);
|
|
70
|
+
} },
|
|
71
|
+
{ name: "Context", render: (width, fg) => {
|
|
72
|
+
context ??= contextFor(pi, ctx, parts);
|
|
73
|
+
return contextLines(context, width, fg);
|
|
74
|
+
} }
|
|
63
75
|
];
|
|
64
76
|
};
|
|
65
77
|
const plain = (_color: string, text: string) => text;
|
package/src/plan.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { type ModelRoleManager } from "./model-roles.ts";
|
|
|
9
9
|
import { openPlanView } from "./plan-view.ts";
|
|
10
10
|
import { extractApproachSteps, isSafePlanCommand, PLAN_TEMPLATE, planTextFromSteps, validatePlanDocument } from "./plan-utils.ts";
|
|
11
11
|
import { cleanText } from "./status.ts";
|
|
12
|
+
import { sessionBranch } from "./session-branch.ts";
|
|
12
13
|
import { type TodoStore } from "./tasks.ts";
|
|
13
14
|
|
|
14
15
|
export const PLAN_ENTRY = "pi-jar.plan";
|
|
@@ -74,6 +75,7 @@ export class PlanMode {
|
|
|
74
75
|
private reminders = 0;
|
|
75
76
|
private reviewing = false;
|
|
76
77
|
private compacting = false;
|
|
78
|
+
private contextDirty = true;
|
|
77
79
|
private compactGeneration = 0;
|
|
78
80
|
private executionRestore: (() => Promise<void>) | undefined;
|
|
79
81
|
private executionRunning = false;
|
|
@@ -135,6 +137,7 @@ export class PlanMode {
|
|
|
135
137
|
try { this.ensureDirectory(ctx); }
|
|
136
138
|
catch (error) { ctx.ui.notify("Plan mode unavailable: cannot create plan directory: " + (error as Error).message, "error"); return; }
|
|
137
139
|
this.enabled = true;
|
|
140
|
+
this.contextDirty = true;
|
|
138
141
|
this.pendingReview = false;
|
|
139
142
|
this.reminders = 0;
|
|
140
143
|
this.toolsBefore = this.pi.getActiveTools();
|
|
@@ -149,6 +152,7 @@ export class PlanMode {
|
|
|
149
152
|
private async leave(ctx: ExtensionContext, persist = true): Promise<void> {
|
|
150
153
|
const wasEnabled = this.enabled;
|
|
151
154
|
this.enabled = false;
|
|
155
|
+
this.contextDirty = true;
|
|
152
156
|
this.compacting = false;
|
|
153
157
|
this.pendingReview = false;
|
|
154
158
|
this.compactGeneration++;
|
|
@@ -293,14 +297,16 @@ export class PlanMode {
|
|
|
293
297
|
}
|
|
294
298
|
|
|
295
299
|
private restoreFrom(branch: readonly unknown[]): PlanState | undefined {
|
|
296
|
-
|
|
297
|
-
|
|
300
|
+
// Only the newest persisted plan state matters. Walk backward so resuming a long session
|
|
301
|
+
// does not inspect every historical transcript entry.
|
|
302
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
303
|
+
const raw = branch[index];
|
|
298
304
|
if (!raw || typeof raw !== "object") continue;
|
|
299
305
|
const entry = raw as Record<string, unknown>;
|
|
300
306
|
if (entry.type !== "custom" || entry.customType !== PLAN_ENTRY || !entry.data || typeof entry.data !== "object") continue;
|
|
301
307
|
const data = entry.data as Record<string, unknown>;
|
|
302
308
|
if ((data.v !== 1 && data.v !== 2) || typeof data.enabled !== "boolean" || !Array.isArray(data.steps)) continue;
|
|
303
|
-
|
|
309
|
+
return {
|
|
304
310
|
v: 2, enabled: data.enabled,
|
|
305
311
|
steps: data.steps.filter((item): item is string => typeof item === "string").map((item) => cleanText(item, 240)).filter(Boolean).slice(0, 50),
|
|
306
312
|
text: typeof data.text === "string" ? safePlanText(data.text) : "",
|
|
@@ -308,7 +314,7 @@ export class PlanMode {
|
|
|
308
314
|
...(typeof data.title === "string" ? { title: cleanText(data.title, 120) } : {})
|
|
309
315
|
};
|
|
310
316
|
}
|
|
311
|
-
return
|
|
317
|
+
return undefined;
|
|
312
318
|
}
|
|
313
319
|
|
|
314
320
|
private async restore(branch: readonly unknown[], ctx: ExtensionContext): Promise<void> {
|
|
@@ -411,14 +417,34 @@ export class PlanMode {
|
|
|
411
417
|
|
|
412
418
|
this.pi.on("before_agent_start", async () => {
|
|
413
419
|
if (!this.enabled) return;
|
|
420
|
+
this.contextDirty = true;
|
|
414
421
|
return { message: { customType: "pi-jar.plan-context", content: this.context(), display: false } };
|
|
415
422
|
});
|
|
416
423
|
|
|
424
|
+
// Keep plan-only prompt messages bounded. When plan mode is active, retain only the newest
|
|
425
|
+
// context and newest reminder; when it is off, remove all stale plan prompt messages.
|
|
417
426
|
this.pi.on("context", async (event) => {
|
|
418
|
-
if (this.
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
427
|
+
if (!this.contextDirty) return;
|
|
428
|
+
let latestContext = -1;
|
|
429
|
+
let latestReminder = -1;
|
|
430
|
+
let needsPrune = false;
|
|
431
|
+
for (let index = event.messages.length - 1; index >= 0; index--) {
|
|
432
|
+
const type = (event.messages[index] as { customType?: string }).customType;
|
|
433
|
+
if (type === "pi-jar.plan-context") {
|
|
434
|
+
if (!this.enabled || latestContext >= 0) needsPrune = true;
|
|
435
|
+
else latestContext = index;
|
|
436
|
+
} else if (type === "pi-jar.plan-reminder") {
|
|
437
|
+
if (!this.enabled || latestReminder >= 0) needsPrune = true;
|
|
438
|
+
else latestReminder = index;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
this.contextDirty = false;
|
|
442
|
+
if (!needsPrune) return;
|
|
443
|
+
return { messages: event.messages.filter((raw, index) => {
|
|
444
|
+
const type = (raw as { customType?: string }).customType;
|
|
445
|
+
if (type === "pi-jar.plan-context") return this.enabled && index === latestContext;
|
|
446
|
+
if (type === "pi-jar.plan-reminder") return this.enabled && index === latestReminder;
|
|
447
|
+
return true;
|
|
422
448
|
}) };
|
|
423
449
|
});
|
|
424
450
|
|
|
@@ -434,6 +460,7 @@ export class PlanMode {
|
|
|
434
460
|
return;
|
|
435
461
|
}
|
|
436
462
|
this.reminders++;
|
|
463
|
+
this.contextDirty = true;
|
|
437
464
|
return {
|
|
438
465
|
entries: [{ type: "custom_message", customType: "pi-jar.plan-reminder", display: false,
|
|
439
466
|
content: `[PI-JAR PLAN MODE] You ended the turn without ${PLAN_SUBMIT_TOOL}. If the plan is ready, write it to the plan directory and call ${PLAN_SUBMIT_TOOL}. If you need a decision from the user, use jar_ask. Do not ask for approval in chat.` }],
|
|
@@ -453,8 +480,8 @@ export class PlanMode {
|
|
|
453
480
|
if (this.enabled && this.pendingReview) await this.review(ctx);
|
|
454
481
|
});
|
|
455
482
|
|
|
456
|
-
this.pi.on("session_start", async (_event, ctx) => { await this.restore(ctx
|
|
457
|
-
this.pi.on("session_tree", async (_event, ctx) => { await this.restore(ctx
|
|
483
|
+
this.pi.on("session_start", async (_event, ctx) => { this.contextDirty = true; await this.restore(sessionBranch(ctx), ctx); });
|
|
484
|
+
this.pi.on("session_tree", async (_event, ctx) => { this.contextDirty = true; await this.restore(sessionBranch(ctx), ctx); });
|
|
458
485
|
this.pi.on("session_shutdown", async (_event, ctx) => { if (this.enabled) await this.leave(ctx, false); });
|
|
459
486
|
}
|
|
460
487
|
}
|