pi-jar 0.1.0 → 0.1.2
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/extensions/index.ts +74 -53
- package/package.json +1 -1
- package/src/advisor.ts +30 -6
- package/src/animations.ts +1 -1
- package/src/changes.ts +58 -17
- package/src/compact-tools.ts +62 -18
- package/src/composer.ts +32 -17
- package/src/delegate.ts +38 -9
- package/src/diff-view.ts +25 -3
- package/src/goal-loop.ts +34 -9
- package/src/goals.ts +13 -2
- package/src/history-ui.ts +2 -1
- package/src/info-panels.ts +19 -7
- package/src/mascot.ts +14 -0
- package/src/plan.ts +36 -10
- package/src/prompt-search.ts +25 -8
- package/src/quota.ts +14 -4
- package/src/session-branch.ts +24 -0
- package/src/session-gallery.ts +2 -2
- package/src/session-ui.ts +15 -6
- package/src/shells.ts +78 -23
- package/src/side-model.ts +14 -2
- package/src/status.ts +34 -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/composer.ts
CHANGED
|
@@ -11,6 +11,7 @@ type MouseEvent = Parameters<NonNullable<EditorComponent["handleMouse"]>>[0];
|
|
|
11
11
|
const PHASE_MOODS: Record<WorkingPhase, readonly MascotMood[]> = {
|
|
12
12
|
idle: ["idle"], generating: ["happy", "thinking"], tool: ["tool"], waiting: ["waiting"]
|
|
13
13
|
};
|
|
14
|
+
export const COMPOSER_ACTIVE_INTERVAL_MS = 600;
|
|
14
15
|
/** Plain one-line face for a phase; every phase has the same width. */
|
|
15
16
|
export function composerIcon(phase: WorkingPhase, frame = 0): string {
|
|
16
17
|
const moods = PHASE_MOODS[phase];
|
|
@@ -256,7 +257,7 @@ export class ComposerStyle {
|
|
|
256
257
|
private owner?: EditorFactory;
|
|
257
258
|
private previous?: EditorFactory;
|
|
258
259
|
private tui?: TUI;
|
|
259
|
-
private timer?: ReturnType<typeof
|
|
260
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
260
261
|
private phase: WorkingPhase = "idle";
|
|
261
262
|
private frame = 0;
|
|
262
263
|
private animations = true;
|
|
@@ -279,7 +280,8 @@ export class ComposerStyle {
|
|
|
279
280
|
tip: (width) => {
|
|
280
281
|
if (!this.mascotOn || width < 40) return undefined;
|
|
281
282
|
const mood = this.animations ? this.mascot.mood() : "idle";
|
|
282
|
-
const
|
|
283
|
+
const animationFrame = this.phase === "idle" ? this.frame : Math.floor(Date.now() / COMPOSER_ACTIVE_INTERVAL_MS);
|
|
284
|
+
const tip = this.animations ? this.mascot.tip(Date.now(), animationFrame) : this.mascot.tip(0, 0);
|
|
283
285
|
return " ".repeat(3) + paintTip(tip, mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined);
|
|
284
286
|
},
|
|
285
287
|
session: () => this.session,
|
|
@@ -291,7 +293,7 @@ export class ComposerStyle {
|
|
|
291
293
|
return text;
|
|
292
294
|
},
|
|
293
295
|
typed: () => this.suggestions?.clear(),
|
|
294
|
-
poke: () => { this.mascot.flash("poke", 1500); this.tui?.requestRender(); },
|
|
296
|
+
poke: () => { this.mascot.flash("poke", 1500); this.restartTimer(); this.tui?.requestRender(); },
|
|
295
297
|
dim: (text) => this.colors?.fg("dim", text) ?? `\x1b[2m${text}\x1b[22m`,
|
|
296
298
|
attachments: (text) => {
|
|
297
299
|
if (!/\.(png|jpe?g|gif|webp)\b/i.test(text)) return undefined;
|
|
@@ -318,36 +320,49 @@ export class ComposerStyle {
|
|
|
318
320
|
/** Short-lived expression, e.g. on errors or a completed goal. */
|
|
319
321
|
flash(mood: "error" | "complete" | "poke", ms = 3000): void {
|
|
320
322
|
this.mascot.flash(mood, ms);
|
|
323
|
+
this.restartTimer();
|
|
321
324
|
if (this.enabled) this.tui?.requestRender();
|
|
322
325
|
}
|
|
323
326
|
|
|
324
327
|
setActivity(phase: WorkingPhase, animations: boolean): void {
|
|
325
328
|
const changed = this.phase !== phase || this.animations !== animations;
|
|
326
329
|
this.mascot.setPhase(phase);
|
|
327
|
-
if (!changed
|
|
330
|
+
if (!changed) return;
|
|
328
331
|
this.phase = phase;
|
|
329
332
|
this.animations = animations;
|
|
330
|
-
|
|
333
|
+
this.frame = 0;
|
|
331
334
|
this.restartTimer();
|
|
332
|
-
if (
|
|
335
|
+
if (this.enabled) this.tui?.requestRender();
|
|
333
336
|
}
|
|
334
337
|
|
|
335
|
-
/**
|
|
338
|
+
/**
|
|
339
|
+
* Pi's own streaming/tool updates already repaint active sessions. Never add another active
|
|
340
|
+
* animation loop: active frames derive from wall time when Pi naturally renders. Idle only
|
|
341
|
+
* schedules exact blink/sleep/flash transitions.
|
|
342
|
+
*/
|
|
336
343
|
private restartTimer(): void {
|
|
337
344
|
this.stopTimer();
|
|
338
|
-
if (!this.enabled || !this.animations) return;
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
345
|
+
if (!this.enabled || !this.animations || !this.mascotOn) return;
|
|
346
|
+
this.lastKey = this.mascot.key(Date.now(), this.frame);
|
|
347
|
+
this.scheduleTimer();
|
|
348
|
+
}
|
|
349
|
+
private scheduleTimer(): void {
|
|
350
|
+
if (!this.enabled || !this.animations || !this.mascotOn || this.timer) return;
|
|
351
|
+
const delay = this.mascot.nextTransitionDelay(Date.now());
|
|
352
|
+
if (delay === undefined) return;
|
|
353
|
+
this.timer = setTimeout(() => {
|
|
354
|
+
this.timer = undefined;
|
|
342
355
|
this.frame++;
|
|
343
|
-
const key = this.
|
|
344
|
-
if (key
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
356
|
+
const key = this.mascot.key(Date.now(), this.frame);
|
|
357
|
+
if (key !== this.lastKey) {
|
|
358
|
+
this.lastKey = key;
|
|
359
|
+
this.tui?.requestRender();
|
|
360
|
+
}
|
|
361
|
+
this.scheduleTimer();
|
|
362
|
+
}, delay);
|
|
348
363
|
this.timer.unref?.();
|
|
349
364
|
}
|
|
350
|
-
private stopTimer(): void { if (this.timer)
|
|
365
|
+
private stopTimer(): void { if (this.timer) clearTimeout(this.timer); this.timer = undefined; }
|
|
351
366
|
refreshSession(ctx: ExtensionContext): void {
|
|
352
367
|
const manager = ctx.sessionManager as { getSessionId?: () => string; getSessionName?: () => string | undefined } | undefined;
|
|
353
368
|
const next = sessionDisplayName(manager?.getSessionName?.(), manager?.getSessionId?.());
|
package/src/delegate.ts
CHANGED
|
@@ -15,7 +15,9 @@ export const MAX_DELEGATES = 4;
|
|
|
15
15
|
export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const;
|
|
16
16
|
const MAX_OUTPUT_CHARS = 12_000;
|
|
17
17
|
const TIMEOUT_MS = 20 * 60_000;
|
|
18
|
-
const STATUS_REFRESH_MS =
|
|
18
|
+
const STATUS_REFRESH_MS = 20_000;
|
|
19
|
+
const LIVE_UPDATE_MS = 1000;
|
|
20
|
+
const MAX_EVENT_TEXT = 16_000;
|
|
19
21
|
|
|
20
22
|
export type DelegateState = "queued" | "working" | "done" | "failed";
|
|
21
23
|
export interface DelegateRun {
|
|
@@ -56,9 +58,20 @@ export function delegateArgs(task: string, model: string | undefined, thinking:
|
|
|
56
58
|
return args;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
const textOf = (message: { content?: unknown }) =>
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
const textOf = (message: { content?: unknown }, limit = MAX_EVENT_TEXT): string => {
|
|
62
|
+
if (typeof message.content === "string") return message.content.slice(0, limit);
|
|
63
|
+
if (!Array.isArray(message.content)) return "";
|
|
64
|
+
let out = "";
|
|
65
|
+
for (const part of message.content as Array<{ type?: string; text?: string }>) {
|
|
66
|
+
if (part?.type !== "text" || typeof part.text !== "string" || !part.text) continue;
|
|
67
|
+
const separator = out ? "\n" : "";
|
|
68
|
+
const room = limit - out.length - separator.length;
|
|
69
|
+
if (room <= 0) break;
|
|
70
|
+
out += separator + part.text.slice(0, room);
|
|
71
|
+
if (part.text.length > room) break;
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
};
|
|
62
75
|
|
|
63
76
|
/** Run one child Pi, feeding progress into `run` and calling `update` on every change. */
|
|
64
77
|
export function runDelegate(run: DelegateRun, args: string[], cwd: string, signal: AbortSignal | undefined, update: () => void, spawnProcess: Spawn = spawn as Spawn): Promise<void> {
|
|
@@ -171,20 +184,36 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
|
|
|
171
184
|
} catch { /* status is decoration */ }
|
|
172
185
|
}
|
|
173
186
|
};
|
|
174
|
-
const details = () => ({ runs: runs.map((run) =>
|
|
175
|
-
|
|
187
|
+
const details = (includeOutput = true) => ({ runs: runs.map((run) => includeOutput ? { ...run } : { ...run, output: "" }), write });
|
|
188
|
+
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
189
|
+
let lastUpdateAt = 0;
|
|
190
|
+
const emitUpdate = () => {
|
|
191
|
+
updateTimer = undefined;
|
|
192
|
+
lastUpdateAt = Date.now();
|
|
176
193
|
publish();
|
|
177
|
-
onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}`).join("\n") }], details: details() });
|
|
194
|
+
onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}`).join("\n") }], details: details(false) });
|
|
195
|
+
};
|
|
196
|
+
const update = () => {
|
|
197
|
+
if (!onUpdate) { publish(); return; }
|
|
198
|
+
const wait = Math.max(0, LIVE_UPDATE_MS - (Date.now() - lastUpdateAt));
|
|
199
|
+
if (wait === 0) { if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; } emitUpdate(); return; }
|
|
200
|
+
if (!updateTimer) {
|
|
201
|
+
updateTimer = setTimeout(emitUpdate, wait);
|
|
202
|
+
updateTimer.unref?.();
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const flushUpdate = () => {
|
|
206
|
+
if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; }
|
|
207
|
+
emitUpdate();
|
|
178
208
|
};
|
|
179
209
|
const refresh = setInterval(publish, STATUS_REFRESH_MS);
|
|
180
210
|
refresh.unref?.();
|
|
181
211
|
try {
|
|
182
|
-
update();
|
|
183
212
|
await Promise.all(runs.map((run) => runDelegate(run, delegateArgs(run.task, run.model, thinking(run.role), write), ctx.cwd, signal, update, spawnProcess)));
|
|
184
213
|
} finally {
|
|
185
214
|
clearInterval(refresh);
|
|
186
215
|
for (const run of runs) if (run.state !== "done") run.state = "failed";
|
|
187
|
-
|
|
216
|
+
flushUpdate();
|
|
188
217
|
}
|
|
189
218
|
const report = runs.map((run) => `## [${run.index}] ${run.name} (${run.role}${run.model ? " · " + run.model : ""}) — ${run.state}${run.error ? ": " + run.error : ""}\n${run.output || "(no report)"}`).join("\n\n");
|
|
190
219
|
return { content: [{ type: "text", text: report }], details: details() };
|
package/src/diff-view.ts
CHANGED
|
@@ -18,7 +18,7 @@ export const safeLine = (text: string) => text.slice(0, 4000).replace(/\t/g, "
|
|
|
18
18
|
|
|
19
19
|
/** Styled diff rows for one file, with a gutter of old/new line numbers. */
|
|
20
20
|
export function renderDiff(change: FileChange, width: number, fg: (color: string, text: string) => string): string[] {
|
|
21
|
-
const ops = lineDiff(change.before, change.after);
|
|
21
|
+
const ops = change.diff === null ? undefined : (change.diff ?? lineDiff(change.before, change.after));
|
|
22
22
|
if (!ops) return [fg("warning", `Too large for an inline diff: +${change.added} −${change.removed} lines.`)];
|
|
23
23
|
const rows: DiffRow[] = diffRows(ops);
|
|
24
24
|
if (!rows.length) return [fg("dim", "No textual changes.")];
|
|
@@ -142,12 +142,34 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
142
142
|
|
|
143
143
|
/** Track edit/write targets before they run, and expose `/diff` plus ctrl+alt+d. */
|
|
144
144
|
export function registerChangeReview(pi: ExtensionAPI, tracker: () => ChangeTracker | undefined, changed: () => void): void {
|
|
145
|
+
// Keep the path that belonged to each in-flight edit/write so tool completion dirties only
|
|
146
|
+
// that file. Older Pi builds may omit toolCallId here, hence the small FIFO fallback.
|
|
147
|
+
const pending = new Map<string, string>();
|
|
148
|
+
const fallback: string[] = [];
|
|
145
149
|
pi.on("tool_call", (event) => {
|
|
146
150
|
if (event.toolName !== "edit" && event.toolName !== "write") return;
|
|
147
151
|
const path = (event.input as { path?: unknown }).path;
|
|
148
|
-
if (typeof path
|
|
152
|
+
if (typeof path !== "string" || !path.trim() || !tracker()?.capture(path)) return;
|
|
153
|
+
const id = (event as { toolCallId?: unknown }).toolCallId;
|
|
154
|
+
if (typeof id === "string" && id) pending.set(id, path);
|
|
155
|
+
fallback.push(path);
|
|
156
|
+
});
|
|
157
|
+
pi.on("tool_result", (event) => {
|
|
158
|
+
if (event.toolName !== "edit" && event.toolName !== "write") return;
|
|
159
|
+
const id = (event as { toolCallId?: unknown }).toolCallId;
|
|
160
|
+
let path: string | undefined;
|
|
161
|
+
if (typeof id === "string" && id) {
|
|
162
|
+
path = pending.get(id);
|
|
163
|
+
pending.delete(id);
|
|
164
|
+
if (path) {
|
|
165
|
+
const index = fallback.indexOf(path);
|
|
166
|
+
if (index >= 0) fallback.splice(index, 1);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (!path) path = fallback.shift();
|
|
170
|
+
const failed = (event as { isError?: unknown }).isError === true;
|
|
171
|
+
if (path && !failed && tracker()?.markDirty(path)) changed();
|
|
149
172
|
});
|
|
150
|
-
pi.on("tool_result", (event) => { if (event.toolName === "edit" || event.toolName === "write") changed(); });
|
|
151
173
|
const open = async (ctx: ExtensionContext) => {
|
|
152
174
|
const current = tracker();
|
|
153
175
|
if (!current) return;
|
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/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/mascot.ts
CHANGED
|
@@ -79,6 +79,20 @@ export class Mascot {
|
|
|
79
79
|
return tips[((frame % tips.length) + tips.length) % tips.length]!;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Milliseconds until an idle mascot can visibly change again. Active phases are driven by
|
|
84
|
+
* the composer's low-frequency animation clock; idle uses this deadline instead of polling.
|
|
85
|
+
*/
|
|
86
|
+
nextTransitionDelay(now = Date.now()): number | undefined {
|
|
87
|
+
const current = this.mood(now);
|
|
88
|
+
if (this.flashMood && now < this.flashMood.until) return Math.max(16, this.flashMood.until - now);
|
|
89
|
+
if (this.phase !== "idle") return undefined;
|
|
90
|
+
const sleepyAt = this.lastActive + SLEEPY_AFTER_MS;
|
|
91
|
+
if (now >= sleepyAt) return undefined;
|
|
92
|
+
const next = current === "blink" ? Math.min(this.nextBlink + BLINK_MS, sleepyAt) : Math.min(this.nextBlink, sleepyAt);
|
|
93
|
+
return Math.max(16, next - now);
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
/** Visual identity for change detection (avoids needless renders). */
|
|
83
97
|
key(now = Date.now(), frame = 0): string { return this.mood(now) + ":" + this.tip(now, frame); }
|
|
84
98
|
}
|
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";
|
|
@@ -135,6 +136,7 @@ export class PlanMode {
|
|
|
135
136
|
try { this.ensureDirectory(ctx); }
|
|
136
137
|
catch (error) { ctx.ui.notify("Plan mode unavailable: cannot create plan directory: " + (error as Error).message, "error"); return; }
|
|
137
138
|
this.enabled = true;
|
|
139
|
+
this.contextDirty = true;
|
|
138
140
|
this.pendingReview = false;
|
|
139
141
|
this.reminders = 0;
|
|
140
142
|
this.toolsBefore = this.pi.getActiveTools();
|
|
@@ -149,6 +151,7 @@ export class PlanMode {
|
|
|
149
151
|
private async leave(ctx: ExtensionContext, persist = true): Promise<void> {
|
|
150
152
|
const wasEnabled = this.enabled;
|
|
151
153
|
this.enabled = false;
|
|
154
|
+
this.contextDirty = true;
|
|
152
155
|
this.compacting = false;
|
|
153
156
|
this.pendingReview = false;
|
|
154
157
|
this.compactGeneration++;
|
|
@@ -293,14 +296,16 @@ export class PlanMode {
|
|
|
293
296
|
}
|
|
294
297
|
|
|
295
298
|
private restoreFrom(branch: readonly unknown[]): PlanState | undefined {
|
|
296
|
-
|
|
297
|
-
|
|
299
|
+
// Only the newest persisted plan state matters. Walk backward so resuming a long session
|
|
300
|
+
// does not inspect every historical transcript entry.
|
|
301
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
302
|
+
const raw = branch[index];
|
|
298
303
|
if (!raw || typeof raw !== "object") continue;
|
|
299
304
|
const entry = raw as Record<string, unknown>;
|
|
300
305
|
if (entry.type !== "custom" || entry.customType !== PLAN_ENTRY || !entry.data || typeof entry.data !== "object") continue;
|
|
301
306
|
const data = entry.data as Record<string, unknown>;
|
|
302
307
|
if ((data.v !== 1 && data.v !== 2) || typeof data.enabled !== "boolean" || !Array.isArray(data.steps)) continue;
|
|
303
|
-
|
|
308
|
+
return {
|
|
304
309
|
v: 2, enabled: data.enabled,
|
|
305
310
|
steps: data.steps.filter((item): item is string => typeof item === "string").map((item) => cleanText(item, 240)).filter(Boolean).slice(0, 50),
|
|
306
311
|
text: typeof data.text === "string" ? safePlanText(data.text) : "",
|
|
@@ -308,7 +313,7 @@ export class PlanMode {
|
|
|
308
313
|
...(typeof data.title === "string" ? { title: cleanText(data.title, 120) } : {})
|
|
309
314
|
};
|
|
310
315
|
}
|
|
311
|
-
return
|
|
316
|
+
return undefined;
|
|
312
317
|
}
|
|
313
318
|
|
|
314
319
|
private async restore(branch: readonly unknown[], ctx: ExtensionContext): Promise<void> {
|
|
@@ -411,14 +416,34 @@ export class PlanMode {
|
|
|
411
416
|
|
|
412
417
|
this.pi.on("before_agent_start", async () => {
|
|
413
418
|
if (!this.enabled) return;
|
|
419
|
+
this.contextDirty = true;
|
|
414
420
|
return { message: { customType: "pi-jar.plan-context", content: this.context(), display: false } };
|
|
415
421
|
});
|
|
416
422
|
|
|
423
|
+
// Keep plan-only prompt messages bounded. When plan mode is active, retain only the newest
|
|
424
|
+
// context and newest reminder; when it is off, remove all stale plan prompt messages.
|
|
417
425
|
this.pi.on("context", async (event) => {
|
|
418
|
-
if (this.
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
426
|
+
if (!this.contextDirty) return;
|
|
427
|
+
let latestContext = -1;
|
|
428
|
+
let latestReminder = -1;
|
|
429
|
+
let needsPrune = false;
|
|
430
|
+
for (let index = event.messages.length - 1; index >= 0; index--) {
|
|
431
|
+
const type = (event.messages[index] as { customType?: string }).customType;
|
|
432
|
+
if (type === "pi-jar.plan-context") {
|
|
433
|
+
if (!this.enabled || latestContext >= 0) needsPrune = true;
|
|
434
|
+
else latestContext = index;
|
|
435
|
+
} else if (type === "pi-jar.plan-reminder") {
|
|
436
|
+
if (!this.enabled || latestReminder >= 0) needsPrune = true;
|
|
437
|
+
else latestReminder = index;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
this.contextDirty = false;
|
|
441
|
+
if (!needsPrune) return;
|
|
442
|
+
return { messages: event.messages.filter((raw, index) => {
|
|
443
|
+
const type = (raw as { customType?: string }).customType;
|
|
444
|
+
if (type === "pi-jar.plan-context") return this.enabled && index === latestContext;
|
|
445
|
+
if (type === "pi-jar.plan-reminder") return this.enabled && index === latestReminder;
|
|
446
|
+
return true;
|
|
422
447
|
}) };
|
|
423
448
|
});
|
|
424
449
|
|
|
@@ -434,6 +459,7 @@ export class PlanMode {
|
|
|
434
459
|
return;
|
|
435
460
|
}
|
|
436
461
|
this.reminders++;
|
|
462
|
+
this.contextDirty = true;
|
|
437
463
|
return {
|
|
438
464
|
entries: [{ type: "custom_message", customType: "pi-jar.plan-reminder", display: false,
|
|
439
465
|
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 +479,8 @@ export class PlanMode {
|
|
|
453
479
|
if (this.enabled && this.pendingReview) await this.review(ctx);
|
|
454
480
|
});
|
|
455
481
|
|
|
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
|
|
482
|
+
this.pi.on("session_start", async (_event, ctx) => { this.contextDirty = true; await this.restore(sessionBranch(ctx), ctx); });
|
|
483
|
+
this.pi.on("session_tree", async (_event, ctx) => { this.contextDirty = true; await this.restore(sessionBranch(ctx), ctx); });
|
|
458
484
|
this.pi.on("session_shutdown", async (_event, ctx) => { if (this.enabled) await this.leave(ctx, false); });
|
|
459
485
|
}
|
|
460
486
|
}
|
package/src/prompt-search.ts
CHANGED
|
@@ -4,8 +4,21 @@ import { cleanText } from "./status.ts";
|
|
|
4
4
|
|
|
5
5
|
export interface PastPrompt { text: string; source: "this session" | "earlier session"; at?: number }
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
|
|
7
|
+
const MAX_PROMPT_CHARS = 8000;
|
|
8
|
+
const userText = (message: { content?: unknown }): string => {
|
|
9
|
+
if (typeof message.content === "string") return message.content.slice(0, MAX_PROMPT_CHARS + 1);
|
|
10
|
+
if (!Array.isArray(message.content)) return "";
|
|
11
|
+
let text = "";
|
|
12
|
+
for (const part of message.content as Array<{ type?: string; text?: string }>) {
|
|
13
|
+
if (part?.type !== "text" || typeof part.text !== "string") continue;
|
|
14
|
+
const separator = text ? "\n" : "";
|
|
15
|
+
const room = MAX_PROMPT_CHARS + 1 - text.length - separator.length;
|
|
16
|
+
if (room <= 0) break;
|
|
17
|
+
text += separator + part.text.slice(0, room);
|
|
18
|
+
if (part.text.length > room) break;
|
|
19
|
+
}
|
|
20
|
+
return text;
|
|
21
|
+
};
|
|
9
22
|
|
|
10
23
|
/**
|
|
11
24
|
* Prompts newest first and deduplicated: every user message in this session file (all branches),
|
|
@@ -17,13 +30,17 @@ export function collectPrompts(entries: readonly unknown[], earlier: readonly {
|
|
|
17
30
|
const add = (text: string, source: PastPrompt["source"], at?: number) => {
|
|
18
31
|
const value = text.trim();
|
|
19
32
|
const key = value.replace(/\s+/g, " ");
|
|
20
|
-
if (!value || value.length >
|
|
33
|
+
if (!value || value.length > MAX_PROMPT_CHARS || seen.has(key)) return;
|
|
21
34
|
seen.add(key);
|
|
22
35
|
result.push({ text: value, source, ...(at ? { at } : {}) });
|
|
23
36
|
};
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
37
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
38
|
+
const raw = entries[index];
|
|
39
|
+
if (!raw || typeof raw !== "object") continue;
|
|
40
|
+
const entry = raw as { type?: string; message?: { role?: string; content?: unknown }; timestamp?: string };
|
|
41
|
+
if (entry.type !== "message" || entry.message?.role !== "user") continue;
|
|
42
|
+
add(userText(entry.message), "this session", entry.timestamp ? Date.parse(entry.timestamp) : undefined);
|
|
43
|
+
}
|
|
27
44
|
for (const session of earlier) if (session.firstMessage) add(session.firstMessage, "earlier session", session.modified?.getTime());
|
|
28
45
|
return result;
|
|
29
46
|
}
|
|
@@ -36,9 +53,9 @@ export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly
|
|
|
36
53
|
let query = "";
|
|
37
54
|
let selected = 0;
|
|
38
55
|
let first = 0;
|
|
39
|
-
let visible: PastPrompt[] =
|
|
56
|
+
let visible: PastPrompt[] = prompts.slice();
|
|
40
57
|
const rows = 10;
|
|
41
|
-
const filter = () => { visible = query ? fuzzyFilter(
|
|
58
|
+
const filter = () => { visible = query ? fuzzyFilter(prompts, query, (item) => item.text) : prompts.slice(); selected = 0; first = 0; };
|
|
42
59
|
return {
|
|
43
60
|
invalidate() {},
|
|
44
61
|
handleInput(data: string) {
|