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/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,31 +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 sampleMemory = () => {
|
|
364
|
-
if (disposed) return;
|
|
365
|
-
memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
|
|
366
|
-
tui.requestRender();
|
|
367
|
-
memoryTimer = setTimeout(sampleMemory, 3000);
|
|
368
|
-
memoryTimer.unref?.();
|
|
369
|
-
};
|
|
370
|
-
if (footerSettings.memory) {
|
|
371
|
-
memoryTimer = setTimeout(sampleMemory, 3000);
|
|
372
|
-
memoryTimer.unref?.();
|
|
373
|
-
}
|
|
374
|
-
let timer: ReturnType<typeof setInterval> | undefined;
|
|
375
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;
|
|
376
362
|
let disposed = false;
|
|
377
363
|
const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
|
|
378
364
|
const dispose = () => {
|
|
379
365
|
if (disposed) return;
|
|
380
366
|
disposed = true;
|
|
381
|
-
if (memoryTimer) clearTimeout(memoryTimer);
|
|
382
|
-
if (timer) clearInterval(timer);
|
|
383
367
|
if (expiryTimer) clearTimeout(expiryTimer);
|
|
384
|
-
timer = undefined;
|
|
385
368
|
expiryTimer = undefined;
|
|
369
|
+
expiryAt = undefined;
|
|
386
370
|
if (footerTui === tui) { footerTui = undefined; welcomeStatuses = () => new Map<string, string>(); welcomeBranch = () => null; }
|
|
387
371
|
unsubscribe();
|
|
388
372
|
};
|
|
@@ -396,30 +380,44 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
396
380
|
const statuses = footerData.getExtensionStatuses();
|
|
397
381
|
const live = collectStatuses(statuses, now);
|
|
398
382
|
const roles: JarRole[] = demo ? createDemoRoles() : live.roles;
|
|
399
|
-
if (expiryTimer) clearTimeout(expiryTimer);
|
|
400
|
-
expiryTimer = undefined;
|
|
401
383
|
const nearest = demo ? undefined : roles.reduce<number | undefined>((min, role) =>
|
|
402
384
|
role.expiresAt != null ? Math.min(min ?? Infinity, role.expiresAt) : min, undefined);
|
|
403
|
-
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.
|
|
404
396
|
const active = animations && footerSettings.roles && roles.some((role) => ACTIVE_STATES.has(role.state));
|
|
405
|
-
if (active
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
+
}
|
|
410
408
|
const quota = footerSettings.quota ? quotaCache?.get(ctx.model?.provider, statuses, now) : undefined;
|
|
411
409
|
return renderFooter({
|
|
412
410
|
model: ctx.model?.id ?? "no-model", effort: ctx.model?.reasoning === false ? "off" : (pi.getThinkingLevel?.() ?? "off"),
|
|
413
411
|
sessionName: ctx.sessionManager?.getSessionName?.(),
|
|
414
412
|
cwd: ctx.cwd, settings: footerSettings, branch: footerData.getGitBranch(),
|
|
415
|
-
context, goal: goalLoop.progress(), chips: footerChips(),
|
|
413
|
+
context, goal: goalLoop.progress(), chips: footerChips(),
|
|
414
|
+
memory, cost: formatCost(cost), quota, roles, extras: live.extras,
|
|
416
415
|
demo, animations, frame, motionBudget: ctx.isIdle() ? 2 : 1
|
|
417
416
|
}, width, ctx.ui.theme ?? theme);
|
|
418
417
|
} catch {
|
|
419
|
-
if (timer) clearInterval(timer);
|
|
420
418
|
if (expiryTimer) clearTimeout(expiryTimer);
|
|
421
|
-
timer = undefined;
|
|
422
419
|
expiryTimer = undefined;
|
|
420
|
+
expiryAt = undefined;
|
|
423
421
|
return width > 0 ? ["pi-jar: UI unavailable (run /jar ui off)".slice(0, width)] : [];
|
|
424
422
|
}
|
|
425
423
|
}
|
|
@@ -478,12 +476,16 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
478
476
|
try { cost = sessionCost(ctx); } catch { cost = 0; }
|
|
479
477
|
footerTui?.requestRender();
|
|
480
478
|
};
|
|
481
|
-
const
|
|
482
|
-
|
|
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([]); }
|
|
483
485
|
updateTaskWidget(ctx);
|
|
484
486
|
};
|
|
485
|
-
const restoreGoal = (ctx: ExtensionContext) => {
|
|
486
|
-
try { goals?.restore(ctx
|
|
487
|
+
const restoreGoal = (ctx: ExtensionContext, branch?: readonly unknown[]) => {
|
|
488
|
+
try { goals?.restore(branch ?? sessionBranch(ctx)); } catch { goals?.restore([]); }
|
|
487
489
|
footerTui?.requestRender();
|
|
488
490
|
};
|
|
489
491
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -495,7 +497,6 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
495
497
|
footerSettings = visualSettings.footer;
|
|
496
498
|
demo = false;
|
|
497
499
|
composer.disable(ctx);
|
|
498
|
-
stopWorkingClock();
|
|
499
500
|
workingIndicatorKey = "";
|
|
500
501
|
working.end();
|
|
501
502
|
quotaCache?.stop();
|
|
@@ -506,8 +507,10 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
506
507
|
shells = new ShellManager(onShellEvent);
|
|
507
508
|
shells.onChange = () => footerTui?.requestRender();
|
|
508
509
|
goals = new GoalStore((entry) => pi.appendEntry(GOAL_ENTRY, entry));
|
|
509
|
-
|
|
510
|
-
|
|
510
|
+
let branch: readonly unknown[] = [];
|
|
511
|
+
try { branch = sessionBranch(ctx); } catch { /* keep empty */ }
|
|
512
|
+
restoreTodos(ctx, branch);
|
|
513
|
+
restoreGoal(ctx, branch);
|
|
511
514
|
// Quota is enabled per session; no credentials or consent are persisted.
|
|
512
515
|
quotaCache = new QuotaCache(
|
|
513
516
|
(provider: QuotaProvider, signal) => fetchQuota(provider, (id) => ctx.modelRegistry.getProviderAuth(id), signal),
|
|
@@ -525,7 +528,17 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
525
528
|
if (visualSettings.composer && enabled) composer.enable(ctx);
|
|
526
529
|
suggest.sync();
|
|
527
530
|
if (visualSettings.accent !== "follow") selectAccent(ctx, visualSettings.accent);
|
|
528
|
-
|
|
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);
|
|
529
542
|
});
|
|
530
543
|
// Any real prompt dismisses the welcome. Slash commands bypass `input`, so `agent_start` covers them.
|
|
531
544
|
const dismissWelcome = (ctx: ExtensionContext) => {
|
|
@@ -550,6 +563,7 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
550
563
|
pi.on("message_end", (event, ctx) => {
|
|
551
564
|
if (event.message.role === "assistant") {
|
|
552
565
|
working.reportOutputTokens(event.message.usage?.output ?? 0);
|
|
566
|
+
addMessageCost(event.message);
|
|
553
567
|
applyWorking(ctx);
|
|
554
568
|
}
|
|
555
569
|
});
|
|
@@ -558,19 +572,26 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
558
572
|
pi.on("ui_prompt_start", (_event, ctx) => { working.prompt(true); applyWorking(ctx); });
|
|
559
573
|
pi.on("ui_prompt_end", (_event, ctx) => { working.prompt(false); applyWorking(ctx); });
|
|
560
574
|
// A request may span several tool turns; keep its elapsed time and reported tokens until agent_end.
|
|
561
|
-
pi.on("turn_end", (_event, ctx) => { applyWorking(ctx);
|
|
575
|
+
pi.on("turn_end", (_event, ctx) => { applyWorking(ctx); });
|
|
562
576
|
pi.on("agent_end", (_event, ctx) => { working.end(); applyWorking(ctx); });
|
|
563
577
|
pi.on("agent_before_settle", (event) => { if (event.outcome === "error") composer.flash("error"); });
|
|
564
578
|
pi.on("agent_settled", (_event, ctx) => { working.end(); applyWorking(ctx); });
|
|
565
|
-
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
|
+
});
|
|
566
587
|
pi.on("session_compact", (_event, ctx) => { restoreTodos(ctx); restoreGoal(ctx); updateCost(ctx); });
|
|
567
588
|
pi.on("model_select", (_event, _ctx) => footerTui?.requestRender());
|
|
568
589
|
pi.on("thinking_level_select", (_event, _ctx) => footerTui?.requestRender());
|
|
569
590
|
pi.on("session_info_changed", (_event, ctx) => { composer.refreshSession(ctx); footerTui?.requestRender(); });
|
|
570
591
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
592
|
+
clearSessionBranchCache(ctx);
|
|
571
593
|
stopWelcome(ctx);
|
|
572
594
|
composer.disable(ctx);
|
|
573
|
-
stopWorkingClock();
|
|
574
595
|
workingIndicatorKey = "";
|
|
575
596
|
working.end();
|
|
576
597
|
try { if (ctx.hasUI && ctx.mode === "tui") { ctx.ui.setWorkingMessage?.(); ctx.ui.setWorkingIndicator(); ctx.ui.setWidget("pi-jar.todos", undefined); } } catch {}
|
|
@@ -604,7 +625,7 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
604
625
|
earlier = (await SessionManager.list(ctx.cwd)).filter((session) => session.path !== current)
|
|
605
626
|
.sort((a, b) => b.modified.getTime() - a.modified.getTime()).slice(0, 50);
|
|
606
627
|
} catch (error) { ctx.ui.notify("pi-jar: earlier sessions unavailable: " + String(error), "warning"); }
|
|
607
|
-
const picked = await openPromptSearch(ctx, collectPrompts(ctx
|
|
628
|
+
const picked = await openPromptSearch(ctx, collectPrompts(sessionBranch(ctx), earlier));
|
|
608
629
|
if (picked !== undefined) ctx.ui.setEditorText(picked);
|
|
609
630
|
}
|
|
610
631
|
});
|
|
@@ -750,7 +771,7 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
750
771
|
if (command === "commit" || command.startsWith("commit ")) { await jarCommit(pi, ctx, modelRoles, sideUsage, args.trim().slice(6).trim()); return; }
|
|
751
772
|
if (command === "shells") {
|
|
752
773
|
if (!shells) return;
|
|
753
|
-
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; }
|
|
754
775
|
await openShellsView(ctx, shells);
|
|
755
776
|
return;
|
|
756
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/animations.ts
CHANGED
|
@@ -20,4 +20,4 @@ export function roleFrame(state: RoleState, frame: number, animations = true): s
|
|
|
20
20
|
|
|
21
21
|
export const WORKING_FRAMES = ["◇", "◆", "◇", "◈"] as const;
|
|
22
22
|
/** Pixel-fire tick for the welcome flame; frozen when motion is disabled. */
|
|
23
|
-
export const WELCOME_INTERVAL_MS =
|
|
23
|
+
export const WELCOME_INTERVAL_MS = 150;
|
package/src/changes.ts
CHANGED
|
@@ -4,11 +4,14 @@ import { isAbsolute, relative, resolve } from "node:path";
|
|
|
4
4
|
/** Files larger than this, or binary files, are not tracked for review. */
|
|
5
5
|
export const MAX_TRACKED_BYTES = 1024 * 1024;
|
|
6
6
|
export const MAX_TRACKED_FILES = 200;
|
|
7
|
-
/**
|
|
8
|
-
const
|
|
7
|
+
/** Bound total retained baselines so a long editing session cannot pin hundreds of MiB. */
|
|
8
|
+
export const MAX_TRACKED_TOTAL_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
/** Keep the quadratic fallback small; larger edits render a summary instead of allocating a huge matrix. */
|
|
10
|
+
const MAX_DIFF_CELLS = 500_000;
|
|
11
|
+
const MAX_DIFF_MIDDLE_LINES = 4_000;
|
|
9
12
|
|
|
10
13
|
export type ChangeStatus = "added" | "modified" | "deleted";
|
|
11
|
-
export interface FileChange { path: string; rel: string; status: ChangeStatus; before: string; after: string; added: number; removed: number }
|
|
14
|
+
export interface FileChange { path: string; rel: string; status: ChangeStatus; before: string; after: string; added: number; removed: number; diff?: DiffOp[] | null }
|
|
12
15
|
export type DiffOp = { op: " " | "+" | "-"; text: string };
|
|
13
16
|
export type DiffRow = { kind: "hunk" | "context" | "add" | "remove" | "note"; text: string; oldLine?: number; newLine?: number };
|
|
14
17
|
|
|
@@ -32,10 +35,11 @@ export function lineDiff(before: string, after: string): DiffOp[] | undefined {
|
|
|
32
35
|
let endA = a.length, endB = b.length;
|
|
33
36
|
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
|
|
34
37
|
const midA = a.slice(start, endA), midB = b.slice(start, endB);
|
|
35
|
-
if (midA.length * midB.length > MAX_DIFF_CELLS) return undefined;
|
|
38
|
+
if (midA.length + midB.length > MAX_DIFF_MIDDLE_LINES || midA.length * midB.length > MAX_DIFF_CELLS) return undefined;
|
|
36
39
|
const n = midA.length, m = midB.length;
|
|
37
|
-
// lcs[i][j] = LCS length of midA[i..] and midB[j..], flattened.
|
|
38
|
-
|
|
40
|
+
// lcs[i][j] = LCS length of midA[i..] and midB[j..], flattened. The matrix is
|
|
41
|
+
// intentionally capped above and Uint16 is enough because the capped middle is < 65k lines.
|
|
42
|
+
const lcs = new Uint16Array((n + 1) * (m + 1));
|
|
39
43
|
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
|
|
40
44
|
lcs[i * (m + 1) + j] = midA[i] === midB[j] ? lcs[(i + 1) * (m + 1) + j + 1]! + 1
|
|
41
45
|
: Math.max(lcs[(i + 1) * (m + 1) + j]!, lcs[i * (m + 1) + j + 1]!);
|
|
@@ -95,10 +99,18 @@ export function diffRows(ops: readonly DiffOp[], context = 3): DiffRow[] {
|
|
|
95
99
|
*/
|
|
96
100
|
export class ChangeTracker {
|
|
97
101
|
private baselines = new Map<string, string | null>();
|
|
102
|
+
private dirty = new Set<string>();
|
|
103
|
+
private baselineBytes = 0;
|
|
98
104
|
private readonly cwd: () => string;
|
|
99
105
|
constructor(cwd: () => string) { this.cwd = cwd; }
|
|
100
106
|
|
|
101
107
|
private absolute(path: string): string { return isAbsolute(path) ? resolve(path) : resolve(this.cwd(), path); }
|
|
108
|
+
private bytes(content: string | null): number { return content === null ? 0 : Buffer.byteLength(content, "utf8"); }
|
|
109
|
+
private drop(target: string): void {
|
|
110
|
+
if (this.baselines.has(target)) this.baselineBytes = Math.max(0, this.baselineBytes - this.bytes(this.baselines.get(target)!));
|
|
111
|
+
this.baselines.delete(target);
|
|
112
|
+
this.dirty.delete(target);
|
|
113
|
+
}
|
|
102
114
|
|
|
103
115
|
/** Record the pre-change content once; returns false when the file cannot be tracked. */
|
|
104
116
|
capture(path: string): boolean {
|
|
@@ -110,29 +122,58 @@ export class ChangeTracker {
|
|
|
110
122
|
if (this.baselines.size >= MAX_TRACKED_FILES) return false;
|
|
111
123
|
const content = readText(target);
|
|
112
124
|
if (content === undefined) return false;
|
|
125
|
+
const bytes = this.bytes(content);
|
|
126
|
+
if (this.baselineBytes + bytes > MAX_TRACKED_TOTAL_BYTES) return false;
|
|
113
127
|
this.baselines.set(target, content);
|
|
128
|
+
this.baselineBytes += bytes;
|
|
114
129
|
return true;
|
|
115
130
|
}
|
|
116
131
|
|
|
132
|
+
/**
|
|
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.
|
|
135
|
+
*/
|
|
136
|
+
markDirty(path: string): boolean {
|
|
137
|
+
const target = this.absolute(path);
|
|
138
|
+
if (!this.baselines.has(target)) return false;
|
|
139
|
+
this.dirty.add(target);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Materialize detailed diffs lazily for the review UI only. */
|
|
117
144
|
changes(): FileChange[] {
|
|
118
145
|
const result: FileChange[] = [];
|
|
119
|
-
for (const
|
|
146
|
+
for (const path of [...this.dirty]) {
|
|
147
|
+
const before = this.baselines.get(path);
|
|
148
|
+
if (before === undefined) { this.dirty.delete(path); continue; }
|
|
120
149
|
const now = readText(path);
|
|
121
|
-
if (now === undefined
|
|
150
|
+
if (now === undefined) { this.drop(path); continue; }
|
|
151
|
+
if (now === before) { this.dirty.delete(path); continue; }
|
|
122
152
|
const ops = lineDiff(before ?? "", now ?? "");
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
}
|
|
125
160
|
const rel = relative(this.cwd(), path);
|
|
126
|
-
|
|
127
|
-
|
|
161
|
+
const change: FileChange = {
|
|
162
|
+
path, rel: rel && !rel.startsWith("..") ? rel : path,
|
|
163
|
+
status: before === null ? "added" : now === null ? "deleted" : "modified",
|
|
164
|
+
before: before ?? "", after: now ?? "", added, removed, diff: ops ?? null
|
|
165
|
+
};
|
|
166
|
+
result.push(change);
|
|
128
167
|
}
|
|
129
168
|
return result.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
130
169
|
}
|
|
131
170
|
|
|
132
|
-
|
|
171
|
+
/** O(1): the footer never calculates file diffs just to show its badge. */
|
|
172
|
+
count(): number { return this.dirty.size; }
|
|
173
|
+
trackedBytes(): number { return this.baselineBytes; }
|
|
133
174
|
/** Keep the current content: stop tracking the file. */
|
|
134
|
-
accept(path: string): void { this.
|
|
135
|
-
acceptAll(): void { this.baselines.clear(); }
|
|
175
|
+
accept(path: string): void { this.drop(this.absolute(path)); }
|
|
176
|
+
acceptAll(): void { this.baselines.clear(); this.dirty.clear(); this.baselineBytes = 0; }
|
|
136
177
|
/** Restore the pre-change content (or remove a file the agent created). */
|
|
137
178
|
revert(path: string): void {
|
|
138
179
|
const target = this.absolute(path);
|
|
@@ -140,7 +181,7 @@ export class ChangeTracker {
|
|
|
140
181
|
const before = this.baselines.get(target)!;
|
|
141
182
|
if (before === null) { if (existsSync(target)) unlinkSync(target); }
|
|
142
183
|
else writeFileSync(target, before);
|
|
143
|
-
this.
|
|
184
|
+
this.drop(target);
|
|
144
185
|
}
|
|
145
|
-
clear(): void { this.
|
|
186
|
+
clear(): void { this.acceptAll(); }
|
|
146
187
|
}
|
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
|
}
|