pi-jar 0.1.1 → 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 -56
- package/package.json +1 -1
- package/src/advisor.ts +30 -6
- package/src/changes.ts +12 -20
- package/src/compact-tools.ts +62 -18
- package/src/composer.ts +6 -5
- package/src/delegate.ts +38 -9
- package/src/diff-view.ts +2 -2
- 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/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/extensions/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { ChangeTracker } from "../src/changes.ts";
|
|
|
37
37
|
import { registerDelegate } from "../src/delegate.ts";
|
|
38
38
|
import { collectPrompts, openPromptSearch } from "../src/prompt-search.ts";
|
|
39
39
|
import { ago, recentSessions, type RecentSession } from "../src/session-gallery.ts";
|
|
40
|
+
import { clearSessionBranchCache, sessionBranch } from "../src/session-branch.ts";
|
|
40
41
|
import { registerChangeReview } from "../src/diff-view.ts";
|
|
41
42
|
import { openShellsView, registerShells, SHELL_MESSAGE, shellEventMessage, ShellManager, type ShellEvent } from "../src/shells.ts";
|
|
42
43
|
import { hopefulWelcomeMessage, welcomeHit, welcomeLines, type WelcomeAction } from "../src/welcome.ts";
|
|
@@ -48,6 +49,7 @@ const VERSION = (() => {
|
|
|
48
49
|
})();
|
|
49
50
|
/** Ignore the click Pi may synthesize right after a press we already acted on. */
|
|
50
51
|
const WELCOME_CLICK_DEDUPE_MS = 400;
|
|
52
|
+
const LARGE_SESSION_ENTRIES = 800;
|
|
51
53
|
export default function piJar(pi: ExtensionAPI): void {
|
|
52
54
|
installCompactBuiltinTools(pi);
|
|
53
55
|
let demo = false;
|
|
@@ -74,9 +76,7 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
74
76
|
let footerSettings = visualSettings.footer;
|
|
75
77
|
let welcomeStatuses = (): ReadonlyMap<string, string> => new Map();
|
|
76
78
|
const working = new WorkingState();
|
|
77
|
-
let workingClock: ReturnType<typeof setInterval> | undefined;
|
|
78
79
|
let workingIndicatorKey = "";
|
|
79
|
-
const stopWorkingClock = () => { if (workingClock) clearInterval(workingClock); workingClock = undefined; };
|
|
80
80
|
let openSettings: (ctx: ExtensionContext) => Promise<void> = async () => {};
|
|
81
81
|
let settingsOpen = false;
|
|
82
82
|
|
|
@@ -158,8 +158,7 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
158
158
|
const suggestions = new SuggestionState();
|
|
159
159
|
composer.attachSuggestions(suggestions);
|
|
160
160
|
const suggest = registerSuggestions(pi, suggestions, {
|
|
161
|
-
enabled: () => enabled && visualSettings.composer && visualSettings.suggestions && composer.enabled
|
|
162
|
-
skip: () => planMode.isEnabled() || !!goals?.isActive()
|
|
161
|
+
enabled: () => enabled && visualSettings.composer && visualSettings.suggestions && composer.enabled
|
|
163
162
|
});
|
|
164
163
|
let liveTui: { setCopyOnSelect?: (enabled: boolean) => void } | undefined;
|
|
165
164
|
/** Pi's own settings (TUI mode, copy-on-select); written through Pi's SettingsManager. */
|
|
@@ -175,8 +174,6 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
175
174
|
};
|
|
176
175
|
|
|
177
176
|
const applyWorking = (ctx: ExtensionContext) => {
|
|
178
|
-
const active = enabled && working.phase !== "idle";
|
|
179
|
-
if (!active || !ctx.hasUI || ctx.mode !== "tui") stopWorkingClock();
|
|
180
177
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
181
178
|
composer.setActivity(enabled ? working.phase : "idle", animations);
|
|
182
179
|
try {
|
|
@@ -187,14 +184,12 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
187
184
|
ctx.ui.setWorkingMessage?.(view.message);
|
|
188
185
|
const indicatorKey = `${working.phase}:${animations}`;
|
|
189
186
|
if (indicatorKey !== workingIndicatorKey) {
|
|
190
|
-
|
|
187
|
+
// Pi's normal streaming/tool lifecycle already causes renders. A static indicator avoids
|
|
188
|
+
// forcing the entire transcript to repaint on a timer in long sessions.
|
|
189
|
+
ctx.ui.setWorkingIndicator({ frames: [view.frames[0] ?? "✢"] });
|
|
191
190
|
workingIndicatorKey = indicatorKey;
|
|
192
191
|
}
|
|
193
|
-
|
|
194
|
-
workingClock = setInterval(() => applyWorking(ctx), 1000);
|
|
195
|
-
workingClock.unref?.();
|
|
196
|
-
}
|
|
197
|
-
} catch { stopWorkingClock(); /* Working decoration must never interrupt a Pi turn. */ }
|
|
192
|
+
} catch { /* Working decoration must never interrupt a Pi turn. */ }
|
|
198
193
|
};
|
|
199
194
|
|
|
200
195
|
const stopWelcome = (ctx?: ExtensionContext) => {
|
|
@@ -358,32 +353,20 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
358
353
|
welcomeStatuses = () => footerData.getExtensionStatuses();
|
|
359
354
|
welcomeBranch = () => footerData.getGitBranch();
|
|
360
355
|
let frame = 0;
|
|
361
|
-
let memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
|
|
362
|
-
let memoryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
363
|
-
const MEMORY_SAMPLE_MS = 10_000;
|
|
364
|
-
const sampleMemory = () => {
|
|
365
|
-
if (disposed) return;
|
|
366
|
-
const next = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
|
|
367
|
-
if (next !== memory) { memory = next; tui.requestRender(); }
|
|
368
|
-
memoryTimer = setTimeout(sampleMemory, MEMORY_SAMPLE_MS);
|
|
369
|
-
memoryTimer.unref?.();
|
|
370
|
-
};
|
|
371
|
-
if (footerSettings.memory) {
|
|
372
|
-
memoryTimer = setTimeout(sampleMemory, MEMORY_SAMPLE_MS);
|
|
373
|
-
memoryTimer.unref?.();
|
|
374
|
-
}
|
|
375
|
-
let timer: ReturnType<typeof setInterval> | undefined;
|
|
376
356
|
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
357
|
+
let expiryAt: number | undefined;
|
|
358
|
+
let memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
|
|
359
|
+
let memorySampledAt = 0;
|
|
360
|
+
let context = "ctx ?";
|
|
361
|
+
let contextSampledAt = 0;
|
|
377
362
|
let disposed = false;
|
|
378
363
|
const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
|
|
379
364
|
const dispose = () => {
|
|
380
365
|
if (disposed) return;
|
|
381
366
|
disposed = true;
|
|
382
|
-
if (memoryTimer) clearTimeout(memoryTimer);
|
|
383
|
-
if (timer) clearInterval(timer);
|
|
384
367
|
if (expiryTimer) clearTimeout(expiryTimer);
|
|
385
|
-
timer = undefined;
|
|
386
368
|
expiryTimer = undefined;
|
|
369
|
+
expiryAt = undefined;
|
|
387
370
|
if (footerTui === tui) { footerTui = undefined; welcomeStatuses = () => new Map<string, string>(); welcomeBranch = () => null; }
|
|
388
371
|
unsubscribe();
|
|
389
372
|
};
|
|
@@ -397,32 +380,44 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
397
380
|
const statuses = footerData.getExtensionStatuses();
|
|
398
381
|
const live = collectStatuses(statuses, now);
|
|
399
382
|
const roles: JarRole[] = demo ? createDemoRoles() : live.roles;
|
|
400
|
-
if (expiryTimer) clearTimeout(expiryTimer);
|
|
401
|
-
expiryTimer = undefined;
|
|
402
383
|
const nearest = demo ? undefined : roles.reduce<number | undefined>((min, role) =>
|
|
403
384
|
role.expiresAt != null ? Math.min(min ?? Infinity, role.expiresAt) : min, undefined);
|
|
404
|
-
if (nearest
|
|
385
|
+
if (nearest !== expiryAt) {
|
|
386
|
+
if (expiryTimer) clearTimeout(expiryTimer);
|
|
387
|
+
expiryTimer = undefined;
|
|
388
|
+
expiryAt = nearest;
|
|
389
|
+
if (nearest != null) {
|
|
390
|
+
expiryTimer = setTimeout(() => { expiryTimer = undefined; expiryAt = undefined; tui.requestRender(); }, Math.max(1, nearest - now));
|
|
391
|
+
expiryTimer.unref?.();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// Do not schedule cosmetic footer repaints. Role state still updates on Pi's native
|
|
395
|
+
// stream/tool renders, which keeps long transcripts responsive.
|
|
405
396
|
const active = animations && footerSettings.roles && roles.some((role) => ACTIVE_STATES.has(role.state));
|
|
406
|
-
if (active
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
397
|
+
if (active) frame++;
|
|
398
|
+
if (now - contextSampledAt >= 1000) {
|
|
399
|
+
const usage = ctx.getContextUsage();
|
|
400
|
+
context = usage?.percent == null || !Number.isFinite(usage.percent)
|
|
401
|
+
? "ctx ?" : `ctx ${Math.round(usage.percent)}%`;
|
|
402
|
+
contextSampledAt = now;
|
|
403
|
+
}
|
|
404
|
+
if (now - memorySampledAt >= 5000) {
|
|
405
|
+
memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
|
|
406
|
+
memorySampledAt = now;
|
|
407
|
+
}
|
|
413
408
|
const quota = footerSettings.quota ? quotaCache?.get(ctx.model?.provider, statuses, now) : undefined;
|
|
414
409
|
return renderFooter({
|
|
415
410
|
model: ctx.model?.id ?? "no-model", effort: ctx.model?.reasoning === false ? "off" : (pi.getThinkingLevel?.() ?? "off"),
|
|
416
411
|
sessionName: ctx.sessionManager?.getSessionName?.(),
|
|
417
412
|
cwd: ctx.cwd, settings: footerSettings, branch: footerData.getGitBranch(),
|
|
418
|
-
context, goal: goalLoop.progress(), chips: footerChips(),
|
|
413
|
+
context, goal: goalLoop.progress(), chips: footerChips(),
|
|
414
|
+
memory, cost: formatCost(cost), quota, roles, extras: live.extras,
|
|
419
415
|
demo, animations, frame, motionBudget: ctx.isIdle() ? 2 : 1
|
|
420
416
|
}, width, ctx.ui.theme ?? theme);
|
|
421
417
|
} catch {
|
|
422
|
-
if (timer) clearInterval(timer);
|
|
423
418
|
if (expiryTimer) clearTimeout(expiryTimer);
|
|
424
|
-
timer = undefined;
|
|
425
419
|
expiryTimer = undefined;
|
|
420
|
+
expiryAt = undefined;
|
|
426
421
|
return width > 0 ? ["pi-jar: UI unavailable (run /jar ui off)".slice(0, width)] : [];
|
|
427
422
|
}
|
|
428
423
|
}
|
|
@@ -481,12 +476,16 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
481
476
|
try { cost = sessionCost(ctx); } catch { cost = 0; }
|
|
482
477
|
footerTui?.requestRender();
|
|
483
478
|
};
|
|
484
|
-
const
|
|
485
|
-
|
|
479
|
+
const addMessageCost = (message: { usage?: { cost?: { total?: number } } }) => {
|
|
480
|
+
const value = message.usage?.cost?.total;
|
|
481
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) cost += value;
|
|
482
|
+
};
|
|
483
|
+
const restoreTodos = (ctx: ExtensionContext, branch?: readonly unknown[]) => {
|
|
484
|
+
try { todos?.restore(branch ?? sessionBranch(ctx)); } catch { todos?.restore([]); }
|
|
486
485
|
updateTaskWidget(ctx);
|
|
487
486
|
};
|
|
488
|
-
const restoreGoal = (ctx: ExtensionContext) => {
|
|
489
|
-
try { goals?.restore(ctx
|
|
487
|
+
const restoreGoal = (ctx: ExtensionContext, branch?: readonly unknown[]) => {
|
|
488
|
+
try { goals?.restore(branch ?? sessionBranch(ctx)); } catch { goals?.restore([]); }
|
|
490
489
|
footerTui?.requestRender();
|
|
491
490
|
};
|
|
492
491
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -498,7 +497,6 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
498
497
|
footerSettings = visualSettings.footer;
|
|
499
498
|
demo = false;
|
|
500
499
|
composer.disable(ctx);
|
|
501
|
-
stopWorkingClock();
|
|
502
500
|
workingIndicatorKey = "";
|
|
503
501
|
working.end();
|
|
504
502
|
quotaCache?.stop();
|
|
@@ -509,8 +507,10 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
509
507
|
shells = new ShellManager(onShellEvent);
|
|
510
508
|
shells.onChange = () => footerTui?.requestRender();
|
|
511
509
|
goals = new GoalStore((entry) => pi.appendEntry(GOAL_ENTRY, entry));
|
|
512
|
-
|
|
513
|
-
|
|
510
|
+
let branch: readonly unknown[] = [];
|
|
511
|
+
try { branch = sessionBranch(ctx); } catch { /* keep empty */ }
|
|
512
|
+
restoreTodos(ctx, branch);
|
|
513
|
+
restoreGoal(ctx, branch);
|
|
514
514
|
// Quota is enabled per session; no credentials or consent are persisted.
|
|
515
515
|
quotaCache = new QuotaCache(
|
|
516
516
|
(provider: QuotaProvider, signal) => fetchQuota(provider, (id) => ctx.modelRegistry.getProviderAuth(id), signal),
|
|
@@ -528,7 +528,17 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
528
528
|
if (visualSettings.composer && enabled) composer.enable(ctx);
|
|
529
529
|
suggest.sync();
|
|
530
530
|
if (visualSettings.accent !== "follow") selectAccent(ctx, visualSettings.accent);
|
|
531
|
-
|
|
531
|
+
if (branch.length >= LARGE_SESSION_ENTRIES) {
|
|
532
|
+
try {
|
|
533
|
+
const prefs = piPreferences(ctx)?.get();
|
|
534
|
+
if (prefs && !prefs.fullscreen) {
|
|
535
|
+
ctx.ui.notify(`Large session (${branch.length} entries): regular TUI redraw cost grows with history. Enable Fullscreen in /jar settings for smoother rendering.`, "warning");
|
|
536
|
+
}
|
|
537
|
+
} catch { /* Performance hint is optional. */ }
|
|
538
|
+
}
|
|
539
|
+
// Resumed sessions already have a transcript to paint. Rebuilding the animated welcome and
|
|
540
|
+
// scanning other session files competes with that expensive initial render for no real benefit.
|
|
541
|
+
if (branch.length === 0) showWelcome(ctx);
|
|
532
542
|
});
|
|
533
543
|
// Any real prompt dismisses the welcome. Slash commands bypass `input`, so `agent_start` covers them.
|
|
534
544
|
const dismissWelcome = (ctx: ExtensionContext) => {
|
|
@@ -553,6 +563,7 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
553
563
|
pi.on("message_end", (event, ctx) => {
|
|
554
564
|
if (event.message.role === "assistant") {
|
|
555
565
|
working.reportOutputTokens(event.message.usage?.output ?? 0);
|
|
566
|
+
addMessageCost(event.message);
|
|
556
567
|
applyWorking(ctx);
|
|
557
568
|
}
|
|
558
569
|
});
|
|
@@ -561,19 +572,26 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
561
572
|
pi.on("ui_prompt_start", (_event, ctx) => { working.prompt(true); applyWorking(ctx); });
|
|
562
573
|
pi.on("ui_prompt_end", (_event, ctx) => { working.prompt(false); applyWorking(ctx); });
|
|
563
574
|
// A request may span several tool turns; keep its elapsed time and reported tokens until agent_end.
|
|
564
|
-
pi.on("turn_end", (_event, ctx) => { applyWorking(ctx);
|
|
575
|
+
pi.on("turn_end", (_event, ctx) => { applyWorking(ctx); });
|
|
565
576
|
pi.on("agent_end", (_event, ctx) => { working.end(); applyWorking(ctx); });
|
|
566
577
|
pi.on("agent_before_settle", (event) => { if (event.outcome === "error") composer.flash("error"); });
|
|
567
578
|
pi.on("agent_settled", (_event, ctx) => { working.end(); applyWorking(ctx); });
|
|
568
|
-
pi.on("session_tree", (_event, ctx) => {
|
|
579
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
580
|
+
let branch: readonly unknown[] = [];
|
|
581
|
+
try { branch = sessionBranch(ctx); } catch { /* keep empty */ }
|
|
582
|
+
restoreTodos(ctx, branch);
|
|
583
|
+
restoreGoal(ctx, branch);
|
|
584
|
+
updateCost(ctx);
|
|
585
|
+
composer.refreshSession(ctx);
|
|
586
|
+
});
|
|
569
587
|
pi.on("session_compact", (_event, ctx) => { restoreTodos(ctx); restoreGoal(ctx); updateCost(ctx); });
|
|
570
588
|
pi.on("model_select", (_event, _ctx) => footerTui?.requestRender());
|
|
571
589
|
pi.on("thinking_level_select", (_event, _ctx) => footerTui?.requestRender());
|
|
572
590
|
pi.on("session_info_changed", (_event, ctx) => { composer.refreshSession(ctx); footerTui?.requestRender(); });
|
|
573
591
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
592
|
+
clearSessionBranchCache(ctx);
|
|
574
593
|
stopWelcome(ctx);
|
|
575
594
|
composer.disable(ctx);
|
|
576
|
-
stopWorkingClock();
|
|
577
595
|
workingIndicatorKey = "";
|
|
578
596
|
working.end();
|
|
579
597
|
try { if (ctx.hasUI && ctx.mode === "tui") { ctx.ui.setWorkingMessage?.(); ctx.ui.setWorkingIndicator(); ctx.ui.setWidget("pi-jar.todos", undefined); } } catch {}
|
|
@@ -607,7 +625,7 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
607
625
|
earlier = (await SessionManager.list(ctx.cwd)).filter((session) => session.path !== current)
|
|
608
626
|
.sort((a, b) => b.modified.getTime() - a.modified.getTime()).slice(0, 50);
|
|
609
627
|
} catch (error) { ctx.ui.notify("pi-jar: earlier sessions unavailable: " + String(error), "warning"); }
|
|
610
|
-
const picked = await openPromptSearch(ctx, collectPrompts(ctx
|
|
628
|
+
const picked = await openPromptSearch(ctx, collectPrompts(sessionBranch(ctx), earlier));
|
|
611
629
|
if (picked !== undefined) ctx.ui.setEditorText(picked);
|
|
612
630
|
}
|
|
613
631
|
});
|
|
@@ -753,7 +771,7 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
753
771
|
if (command === "commit" || command.startsWith("commit ")) { await jarCommit(pi, ctx, modelRoles, sideUsage, args.trim().slice(6).trim()); return; }
|
|
754
772
|
if (command === "shells") {
|
|
755
773
|
if (!shells) return;
|
|
756
|
-
if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify(shells.
|
|
774
|
+
if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify(shells.summaries().map((job) => `${job.id} ${job.name} ${job.status}`).join("\n") || "No background shells", "info"); return; }
|
|
757
775
|
await openShellsView(ctx, shells);
|
|
758
776
|
return;
|
|
759
777
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-jar",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "A warm, role-aware Pi terminal workspace with animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/advisor.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Type } from "typebox";
|
|
|
3
3
|
import type { ModelRoleManager } from "./model-roles.ts";
|
|
4
4
|
import { askRole, type SideUsage } from "./side-model.ts";
|
|
5
5
|
import { ROLE_PREFIX } from "./status.ts";
|
|
6
|
+
import { sessionBranch } from "./session-branch.ts";
|
|
6
7
|
|
|
7
8
|
export const ADVISOR_TOOL = "jar_advisor";
|
|
8
9
|
export const ADVISOR_MESSAGE = "pi-jar.advisor";
|
|
@@ -26,10 +27,33 @@ export const ADVISOR_SYSTEM = [
|
|
|
26
27
|
|
|
27
28
|
type Entry = { type: string; message?: { role?: string; content?: unknown; toolName?: string; isError?: boolean; customType?: string } };
|
|
28
29
|
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
const toolHint = (args: unknown): string => {
|
|
31
|
+
if (!args || typeof args !== "object") return "";
|
|
32
|
+
const object = args as Record<string, unknown>;
|
|
33
|
+
const keys = ["command", "path", "file_path", "query", "id"];
|
|
34
|
+
for (const key of keys) {
|
|
35
|
+
const value = object[key];
|
|
36
|
+
if (typeof value === "string" && value) return `${key}=${value.slice(0, 240)}`;
|
|
37
|
+
}
|
|
38
|
+
return "";
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const textOf = (content: unknown, limit = 4_000): string => {
|
|
42
|
+
if (typeof content === "string") return content.slice(0, limit);
|
|
43
|
+
if (!Array.isArray(content)) return "";
|
|
44
|
+
let out = "";
|
|
45
|
+
for (const part of content as Array<{ type?: string; text?: string; name?: string; arguments?: unknown }>) {
|
|
46
|
+
const piece = part.type === "text" ? part.text ?? ""
|
|
47
|
+
: part.type === "toolCall" ? `[tool ${part.name ?? "?"}${toolHint(part.arguments) ? " " + toolHint(part.arguments) : ""}]` : "";
|
|
48
|
+
if (!piece) continue;
|
|
49
|
+
const separator = out ? "\n" : "";
|
|
50
|
+
const room = limit - out.length - separator.length;
|
|
51
|
+
if (room <= 0) break;
|
|
52
|
+
out += separator + piece.slice(0, room);
|
|
53
|
+
if (piece.length > room) break;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
};
|
|
33
57
|
|
|
34
58
|
/** The most recent conversation, newest last, capped to `limit` characters. */
|
|
35
59
|
export function transcript(entries: readonly Entry[], limit = TRANSCRIPT_LIMIT): string {
|
|
@@ -38,7 +62,7 @@ export function transcript(entries: readonly Entry[], limit = TRANSCRIPT_LIMIT):
|
|
|
38
62
|
for (let index = entries.length - 1; index >= 0 && size < limit; index--) {
|
|
39
63
|
const message = entries[index]!.type === "message" ? entries[index]!.message : undefined;
|
|
40
64
|
if (!message?.role) continue;
|
|
41
|
-
let body = textOf(message.content).trim();
|
|
65
|
+
let body = textOf(message.content, Math.min(4_000, limit - size)).trim();
|
|
42
66
|
if (!body) continue;
|
|
43
67
|
if (message.role === "toolResult" && body.length > 1200) body = body.slice(0, 1200) + " …";
|
|
44
68
|
const label = message.role === "toolResult" ? `tool result (${message.toolName ?? "?"}${message.isError ? ", error" : ""})` : message.role;
|
|
@@ -118,7 +142,7 @@ export function registerAdvisor(pi: ExtensionAPI, roles: ModelRoleManager, optio
|
|
|
118
142
|
busy++;
|
|
119
143
|
status(ctx, request.trigger ?? request.question ?? "reviewing the current direction");
|
|
120
144
|
try {
|
|
121
|
-
const conversation = transcript(ctx
|
|
145
|
+
const conversation = transcript(sessionBranch(ctx) as readonly Entry[]);
|
|
122
146
|
return await askRole(ctx, roles, options.usage, "advisor", ADVISOR_SYSTEM, advisorPrompt(request, conversation, await gitState(ctx)), signal);
|
|
123
147
|
} finally { if (--busy === 0) status(ctx); }
|
|
124
148
|
};
|
package/src/changes.ts
CHANGED
|
@@ -100,7 +100,6 @@ export function diffRows(ops: readonly DiffOp[], context = 3): DiffRow[] {
|
|
|
100
100
|
export class ChangeTracker {
|
|
101
101
|
private baselines = new Map<string, string | null>();
|
|
102
102
|
private dirty = new Set<string>();
|
|
103
|
-
private diffCache = new Map<string, FileChange>();
|
|
104
103
|
private baselineBytes = 0;
|
|
105
104
|
private readonly cwd: () => string;
|
|
106
105
|
constructor(cwd: () => string) { this.cwd = cwd; }
|
|
@@ -111,7 +110,6 @@ export class ChangeTracker {
|
|
|
111
110
|
if (this.baselines.has(target)) this.baselineBytes = Math.max(0, this.baselineBytes - this.bytes(this.baselines.get(target)!));
|
|
112
111
|
this.baselines.delete(target);
|
|
113
112
|
this.dirty.delete(target);
|
|
114
|
-
this.diffCache.delete(target);
|
|
115
113
|
}
|
|
116
114
|
|
|
117
115
|
/** Record the pre-change content once; returns false when the file cannot be tracked. */
|
|
@@ -132,20 +130,12 @@ export class ChangeTracker {
|
|
|
132
130
|
}
|
|
133
131
|
|
|
134
132
|
/**
|
|
135
|
-
* Mark one captured file after
|
|
136
|
-
*
|
|
133
|
+
* Mark one captured file after a successful edit/write. Do not synchronously re-read the file on
|
|
134
|
+
* every tool result; the review UI reconciles content lazily when /diff is actually opened.
|
|
137
135
|
*/
|
|
138
136
|
markDirty(path: string): boolean {
|
|
139
137
|
const target = this.absolute(path);
|
|
140
138
|
if (!this.baselines.has(target)) return false;
|
|
141
|
-
const before = this.baselines.get(target)!;
|
|
142
|
-
const now = readText(target);
|
|
143
|
-
this.diffCache.delete(target);
|
|
144
|
-
if (now === undefined) { this.drop(target); return false; }
|
|
145
|
-
if (now === before) {
|
|
146
|
-
this.dirty.delete(target);
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
149
139
|
this.dirty.add(target);
|
|
150
140
|
return true;
|
|
151
141
|
}
|
|
@@ -155,22 +145,24 @@ export class ChangeTracker {
|
|
|
155
145
|
const result: FileChange[] = [];
|
|
156
146
|
for (const path of [...this.dirty]) {
|
|
157
147
|
const before = this.baselines.get(path);
|
|
158
|
-
if (before === undefined) { this.dirty.delete(path);
|
|
148
|
+
if (before === undefined) { this.dirty.delete(path); continue; }
|
|
159
149
|
const now = readText(path);
|
|
160
150
|
if (now === undefined) { this.drop(path); continue; }
|
|
161
|
-
if (now === before) { this.dirty.delete(path);
|
|
162
|
-
const cached = this.diffCache.get(path);
|
|
163
|
-
if (cached) { result.push(cached); continue; }
|
|
151
|
+
if (now === before) { this.dirty.delete(path); continue; }
|
|
164
152
|
const ops = lineDiff(before ?? "", now ?? "");
|
|
165
|
-
|
|
166
|
-
|
|
153
|
+
let added = 0, removed = 0;
|
|
154
|
+
if (ops) {
|
|
155
|
+
for (const op of ops) { if (op.op === "+") added++; else if (op.op === "-") removed++; }
|
|
156
|
+
} else {
|
|
157
|
+
added = splitLines(now ?? "").length;
|
|
158
|
+
removed = splitLines(before ?? "").length;
|
|
159
|
+
}
|
|
167
160
|
const rel = relative(this.cwd(), path);
|
|
168
161
|
const change: FileChange = {
|
|
169
162
|
path, rel: rel && !rel.startsWith("..") ? rel : path,
|
|
170
163
|
status: before === null ? "added" : now === null ? "deleted" : "modified",
|
|
171
164
|
before: before ?? "", after: now ?? "", added, removed, diff: ops ?? null
|
|
172
165
|
};
|
|
173
|
-
this.diffCache.set(path, change);
|
|
174
166
|
result.push(change);
|
|
175
167
|
}
|
|
176
168
|
return result.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
@@ -181,7 +173,7 @@ export class ChangeTracker {
|
|
|
181
173
|
trackedBytes(): number { return this.baselineBytes; }
|
|
182
174
|
/** Keep the current content: stop tracking the file. */
|
|
183
175
|
accept(path: string): void { this.drop(this.absolute(path)); }
|
|
184
|
-
acceptAll(): void { this.baselines.clear(); this.dirty.clear(); this.
|
|
176
|
+
acceptAll(): void { this.baselines.clear(); this.dirty.clear(); this.baselineBytes = 0; }
|
|
185
177
|
/** Restore the pre-change content (or remove a file the agent created). */
|
|
186
178
|
revert(path: string): void {
|
|
187
179
|
const target = this.absolute(path);
|
package/src/compact-tools.ts
CHANGED
|
@@ -12,12 +12,42 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
12
12
|
|
|
13
13
|
type ToolResult = { content: Array<{ type: string; text?: string }>; details?: unknown };
|
|
14
14
|
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const SUMMARY_SCAN_CHARS = 32 * 1024;
|
|
16
|
+
const DIFF_SCAN_CHARS = 64 * 1024;
|
|
17
|
+
const resultSummaryCache = new WeakMap<object, { sample: string; lines: number; truncated: boolean }>();
|
|
18
|
+
const writeCountCache = new WeakMap<object, { lines: number; truncated: boolean }>();
|
|
19
|
+
|
|
20
|
+
function summarizeResult(result: ToolResult): { sample: string; lines: number; truncated: boolean } {
|
|
21
|
+
if (result && typeof result === "object") {
|
|
22
|
+
const cached = resultSummaryCache.get(result as object);
|
|
23
|
+
if (cached) return cached;
|
|
24
|
+
}
|
|
25
|
+
let sample = "";
|
|
26
|
+
let truncated = false;
|
|
27
|
+
let textParts = 0;
|
|
28
|
+
for (const item of result.content) {
|
|
29
|
+
if (item.type !== "text" || typeof item.text !== "string") continue;
|
|
30
|
+
if (textParts++ && sample.length < SUMMARY_SCAN_CHARS) sample += "\n";
|
|
31
|
+
const room = SUMMARY_SCAN_CHARS - sample.length;
|
|
32
|
+
if (room <= 0) { truncated = true; break; }
|
|
33
|
+
sample += item.text.slice(0, room);
|
|
34
|
+
if (item.text.length > room) { truncated = true; break; }
|
|
35
|
+
}
|
|
36
|
+
const value = { sample, lines: sample ? sample.split("\n").length : 0, truncated };
|
|
37
|
+
if (result && typeof result === "object") resultSummaryCache.set(result as object, value);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function firstNonEmpty(text: string): string {
|
|
42
|
+
for (const line of text.split("\n")) if (line.trim()) return line;
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function boundedLineCount(text: string, limit = SUMMARY_SCAN_CHARS): { lines: number; truncated: boolean } {
|
|
47
|
+
const sample = text.slice(0, limit);
|
|
48
|
+
return { lines: sample ? sample.split("\n").length : 0, truncated: text.length > limit };
|
|
49
|
+
}
|
|
19
50
|
|
|
20
|
-
const nonEmptyLines = (text: string): string[] => text.split("\n").filter((line) => line.trim().length > 0);
|
|
21
51
|
const expandHint = " · [ expand ] Ctrl+O";
|
|
22
52
|
|
|
23
53
|
function compactText(text: string, max = 72): string {
|
|
@@ -25,12 +55,18 @@ function compactText(text: string, max = 72): string {
|
|
|
25
55
|
return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)) + "…";
|
|
26
56
|
}
|
|
27
57
|
|
|
58
|
+
const bashCache = new WeakMap<object, { cwd: string; trusted: boolean; tool: ReturnType<typeof createBashToolDefinition> }>();
|
|
28
59
|
function bashFor(ctx: ExtensionContext) {
|
|
29
|
-
const
|
|
30
|
-
|
|
60
|
+
const trusted = ctx.isProjectTrusted();
|
|
61
|
+
const hit = bashCache.get(ctx as object);
|
|
62
|
+
if (hit && hit.cwd === ctx.cwd && hit.trusted === trusted) return hit.tool;
|
|
63
|
+
const settings = SettingsManager.create(ctx.cwd, getAgentDir(), { projectTrusted: trusted });
|
|
64
|
+
const tool = createBashToolDefinition(ctx.cwd, {
|
|
31
65
|
shellPath: settings.getShellPath(),
|
|
32
66
|
commandPrefix: settings.getShellCommandPrefix()
|
|
33
67
|
});
|
|
68
|
+
bashCache.set(ctx as object, { cwd: ctx.cwd, trusted, tool });
|
|
69
|
+
return tool;
|
|
34
70
|
}
|
|
35
71
|
|
|
36
72
|
/**
|
|
@@ -58,9 +94,9 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
|
|
|
58
94
|
renderResult(result, options, theme, context) {
|
|
59
95
|
if (options.expanded && read.renderResult) return read.renderResult(result, options, theme, context);
|
|
60
96
|
if (options.isPartial) return new Text(theme.fg("warning", "reading…"), 0, 0);
|
|
61
|
-
const
|
|
62
|
-
const count =
|
|
63
|
-
return new Text(theme.fg("muted", `${count} line${
|
|
97
|
+
const summary = summarizeResult(result);
|
|
98
|
+
const count = `${summary.lines}${summary.truncated ? "+" : ""}`;
|
|
99
|
+
return new Text(theme.fg("muted", `${count} line${summary.lines === 1 && !summary.truncated ? "" : "s"}${expandHint}`), 0, 0);
|
|
64
100
|
}
|
|
65
101
|
});
|
|
66
102
|
|
|
@@ -75,12 +111,13 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
|
|
|
75
111
|
},
|
|
76
112
|
renderResult(result, options, theme, context) {
|
|
77
113
|
if (options.expanded && bash.renderResult) return bash.renderResult(result as Parameters<NonNullable<typeof bash.renderResult>>[0], options, theme, context);
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
const preview =
|
|
114
|
+
const summary = summarizeResult(result);
|
|
115
|
+
const first = firstNonEmpty(summary.sample);
|
|
116
|
+
const preview = first ? ` · ${compactText(first, 56)}` : "";
|
|
117
|
+
const count = `${summary.lines}${summary.truncated ? "+" : ""}`;
|
|
81
118
|
const label = options.isPartial ? "running" : "done";
|
|
82
119
|
return new Text(theme.fg(options.isPartial ? "warning" : "muted",
|
|
83
|
-
`${label} · ${
|
|
120
|
+
`${label} · ${count} line${summary.lines === 1 && !summary.truncated ? "" : "s"}${preview}${expandHint}`), 0, 0);
|
|
84
121
|
}
|
|
85
122
|
});
|
|
86
123
|
|
|
@@ -96,9 +133,11 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
|
|
|
96
133
|
const details = result.details as { diff?: string } | undefined;
|
|
97
134
|
const diff = details?.diff ?? "";
|
|
98
135
|
if (!diff) {
|
|
99
|
-
const
|
|
136
|
+
const summary = summarizeResult(result);
|
|
137
|
+
const text = summary.sample;
|
|
100
138
|
return new Text(theme.fg(text.startsWith("Error") ? "error" : "success", compactText(text || "applied", 96)), 0, 0);
|
|
101
139
|
}
|
|
140
|
+
if (diff.length > DIFF_SCAN_CHARS) return new Text(theme.fg("muted", `large diff${expandHint}`), 0, 0);
|
|
102
141
|
let additions = 0;
|
|
103
142
|
let removals = 0;
|
|
104
143
|
for (const line of diff.split("\n")) {
|
|
@@ -114,14 +153,19 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
|
|
|
114
153
|
...write,
|
|
115
154
|
renderCall(args, theme, context) {
|
|
116
155
|
if (context.expanded && write.renderCall) return write.renderCall(args, theme, context);
|
|
117
|
-
|
|
156
|
+
let count = args && typeof args === "object" ? writeCountCache.get(args as object) : undefined;
|
|
157
|
+
if (!count) {
|
|
158
|
+
count = boundedLineCount(args.content);
|
|
159
|
+
if (args && typeof args === "object") writeCountCache.set(args as object, count);
|
|
160
|
+
}
|
|
118
161
|
return new Text(theme.fg("toolTitle", theme.bold("write ")) + theme.fg("accent", args.path)
|
|
119
|
-
+ theme.fg("dim", ` · ${count} lines`), 0, 0);
|
|
162
|
+
+ theme.fg("dim", ` · ${count.lines}${count.truncated ? "+" : ""} lines`), 0, 0);
|
|
120
163
|
},
|
|
121
164
|
renderResult(result, options, theme, context) {
|
|
122
165
|
if (options.expanded && write.renderResult) return write.renderResult(result, options, theme, context);
|
|
123
166
|
if (options.isPartial) return new Text(theme.fg("warning", "writing…"), 0, 0);
|
|
124
|
-
const
|
|
167
|
+
const summary = summarizeResult(result);
|
|
168
|
+
const text = summary.sample;
|
|
125
169
|
return new Text(theme.fg(text.startsWith("Error") ? "error" : "success", compactText(text || "written", 96))
|
|
126
170
|
+ theme.fg("dim", text ? expandHint : ""), 0, 0);
|
|
127
171
|
}
|
package/src/composer.ts
CHANGED
|
@@ -280,7 +280,8 @@ export class ComposerStyle {
|
|
|
280
280
|
tip: (width) => {
|
|
281
281
|
if (!this.mascotOn || width < 40) return undefined;
|
|
282
282
|
const mood = this.animations ? this.mascot.mood() : "idle";
|
|
283
|
-
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);
|
|
284
285
|
return " ".repeat(3) + paintTip(tip, mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined);
|
|
285
286
|
},
|
|
286
287
|
session: () => this.session,
|
|
@@ -335,8 +336,9 @@ export class ComposerStyle {
|
|
|
335
336
|
}
|
|
336
337
|
|
|
337
338
|
/**
|
|
338
|
-
*
|
|
339
|
-
*
|
|
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.
|
|
340
342
|
*/
|
|
341
343
|
private restartTimer(): void {
|
|
342
344
|
this.stopTimer();
|
|
@@ -346,8 +348,7 @@ export class ComposerStyle {
|
|
|
346
348
|
}
|
|
347
349
|
private scheduleTimer(): void {
|
|
348
350
|
if (!this.enabled || !this.animations || !this.mascotOn || this.timer) return;
|
|
349
|
-
const
|
|
350
|
-
const delay = active ? COMPOSER_ACTIVE_INTERVAL_MS : this.mascot.nextTransitionDelay(Date.now());
|
|
351
|
+
const delay = this.mascot.nextTransitionDelay(Date.now());
|
|
351
352
|
if (delay === undefined) return;
|
|
352
353
|
this.timer = setTimeout(() => {
|
|
353
354
|
this.timer = undefined;
|