@janvitos/pi-plan-build 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/index.ts +102 -12
- package/package.json +1 -1
- package/utils.ts +82 -5
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ A global [Pi coding agent](https://github.com/badlogic/pi-mono) extension that a
|
|
|
8
8
|
|
|
9
9
|
- New sessions start in **Build** mode.
|
|
10
10
|
- Bare `Tab` cycles **Build → Plan → Build**, while active autocomplete dropdowns retain Pi's normal Tab completion.
|
|
11
|
-
-
|
|
11
|
+
- The composer uses OpenCode prompt-inspired blue/orange mode colors on a rounded `╭─` top border and `│` rail, plus a mode/model/thinking metadata row and Pi's bottom border. The footer keeps the remaining path and usage stats without duplicating model metadata.
|
|
12
12
|
- `/plan`, `/build`, and the `--plan` startup flag.
|
|
13
13
|
- Per-session plans at `~/.pi/agent/plans/<session-id>.md`.
|
|
14
14
|
- In Plan mode, built-in `edit` and `write` are restricted to the exact plan file.
|
package/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { CustomEditor, getAgentDir, getMarkdownTheme, type EntryRenderer, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { Key, Markdown, matchesKey, Text,
|
|
5
|
+
import { Key, Markdown, matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
6
6
|
import { Type } from "typebox";
|
|
7
7
|
import { registerQuestionTool } from "./question-ui.ts";
|
|
8
8
|
import {
|
|
@@ -20,7 +20,11 @@ import {
|
|
|
20
20
|
classifyPlanExitChoice,
|
|
21
21
|
decodeModeState,
|
|
22
22
|
extractPromptHistory,
|
|
23
|
-
|
|
23
|
+
formatFooterCwd,
|
|
24
|
+
formatModeMetadata,
|
|
25
|
+
formatModeRail,
|
|
26
|
+
formatModeTopBorder,
|
|
27
|
+
formatTokens,
|
|
24
28
|
isAllowedPlanMutation,
|
|
25
29
|
makePlanPath,
|
|
26
30
|
nextMode,
|
|
@@ -28,6 +32,7 @@ import {
|
|
|
28
32
|
PLAN_EXIT_FRESH_CHOICE,
|
|
29
33
|
PLAN_EXIT_STAY_ACKNOWLEDGEMENT,
|
|
30
34
|
PLAN_EXIT_STAY_CHOICE,
|
|
35
|
+
renderModeComposer,
|
|
31
36
|
type Mode,
|
|
32
37
|
unique,
|
|
33
38
|
} from "./utils.ts";
|
|
@@ -390,6 +395,84 @@ export default function planBuildModes(pi: ExtensionAPI): void {
|
|
|
390
395
|
updateModeIndicator(ctx);
|
|
391
396
|
|
|
392
397
|
if (ctx.mode === "tui") {
|
|
398
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
399
|
+
const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
|
|
400
|
+
return {
|
|
401
|
+
dispose: unsubscribe,
|
|
402
|
+
invalidate() {},
|
|
403
|
+
render(width: number): string[] {
|
|
404
|
+
let input = 0;
|
|
405
|
+
let output = 0;
|
|
406
|
+
let cacheRead = 0;
|
|
407
|
+
let cacheWrite = 0;
|
|
408
|
+
let cost = 0;
|
|
409
|
+
let latestCacheHitRate: number | undefined;
|
|
410
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
411
|
+
const usage =
|
|
412
|
+
entry.type === "message" &&
|
|
413
|
+
(entry.message.role === "assistant" || entry.message.role === "toolResult")
|
|
414
|
+
? entry.message.usage
|
|
415
|
+
: (entry.type === "branch_summary" || entry.type === "compaction")
|
|
416
|
+
? entry.usage
|
|
417
|
+
: undefined;
|
|
418
|
+
if (!usage) continue;
|
|
419
|
+
input += usage.input;
|
|
420
|
+
output += usage.output;
|
|
421
|
+
cacheRead += usage.cacheRead;
|
|
422
|
+
cacheWrite += usage.cacheWrite;
|
|
423
|
+
cost += usage.cost.total;
|
|
424
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
425
|
+
const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
426
|
+
latestCacheHitRate = promptTokens > 0 ? (usage.cacheRead / promptTokens) * 100 : undefined;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let cwd = formatFooterCwd(ctx.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
|
|
431
|
+
const branch = footerData.getGitBranch();
|
|
432
|
+
if (branch) cwd += ` (${branch})`;
|
|
433
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
434
|
+
if (sessionName) cwd += ` • ${sessionName}`;
|
|
435
|
+
|
|
436
|
+
const stats: string[] = [];
|
|
437
|
+
if (input) stats.push(`↑${formatTokens(input)}`);
|
|
438
|
+
if (output) stats.push(`↓${formatTokens(output)}`);
|
|
439
|
+
if (cacheRead) stats.push(`R${formatTokens(cacheRead)}`);
|
|
440
|
+
if (cacheWrite) stats.push(`W${formatTokens(cacheWrite)}`);
|
|
441
|
+
if ((cacheRead || cacheWrite) && latestCacheHitRate !== undefined) {
|
|
442
|
+
stats.push(`CH${latestCacheHitRate.toFixed(1)}%`);
|
|
443
|
+
}
|
|
444
|
+
const usingSubscription = ctx.model
|
|
445
|
+
? ctx.model.provider === "kimi-coding" || ctx.modelRegistry.isUsingOAuth(ctx.model)
|
|
446
|
+
: false;
|
|
447
|
+
if (cost || usingSubscription) {
|
|
448
|
+
stats.push(`$${cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const contextUsage = ctx.getContextUsage();
|
|
452
|
+
const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
453
|
+
const contextPercent = contextUsage?.percent;
|
|
454
|
+
const contextText = `${contextPercent === null || contextPercent === undefined ? "?" : `${contextPercent.toFixed(1)}%`}/${formatTokens(contextWindow)} (auto)`;
|
|
455
|
+
stats.push(
|
|
456
|
+
contextPercent !== null && contextPercent !== undefined && contextPercent > 90
|
|
457
|
+
? theme.fg("error", contextText)
|
|
458
|
+
: contextPercent !== null && contextPercent !== undefined && contextPercent > 70
|
|
459
|
+
? theme.fg("warning", contextText)
|
|
460
|
+
: contextText,
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
const lines = [
|
|
464
|
+
truncateToWidth(theme.fg("dim", cwd), width, theme.fg("dim", "...")),
|
|
465
|
+
truncateToWidth(theme.fg("dim", stats.join(" ")), width, theme.fg("dim", "...")),
|
|
466
|
+
];
|
|
467
|
+
const statuses = Array.from(footerData.getExtensionStatuses().entries())
|
|
468
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
469
|
+
.map(([, text]) => text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim());
|
|
470
|
+
if (statuses.length) lines.push(truncateToWidth(statuses.join(" "), width, theme.fg("dim", "...")));
|
|
471
|
+
return lines;
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
});
|
|
475
|
+
|
|
393
476
|
// Startup history is populated after session_start; replacement flows recreate the editor after that step.
|
|
394
477
|
const promptHistory = event.reason === "startup" ? [] : extractPromptHistory(ctx.sessionManager.getBranch());
|
|
395
478
|
class ModeEditor extends CustomEditor {
|
|
@@ -400,18 +483,24 @@ export default function planBuildModes(pi: ExtensionAPI): void {
|
|
|
400
483
|
}
|
|
401
484
|
|
|
402
485
|
override render(width: number): string[] {
|
|
403
|
-
const
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
const paddingWidth = Math.min(promptWidth, Math.max(0, Math.floor((width - 1) / 2)));
|
|
407
|
-
if (this.getPaddingX() !== paddingWidth) this.setPaddingX(paddingWidth);
|
|
486
|
+
const railWidth = 2;
|
|
487
|
+
const paddingWidth = Math.min(railWidth, Math.max(0, Math.floor((width - 1) / 2)));
|
|
488
|
+
if (this.getPaddingX() !== railWidth) this.setPaddingX(railWidth);
|
|
408
489
|
|
|
409
490
|
const lines = super.render(width);
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
491
|
+
if (paddingWidth !== railWidth) return lines;
|
|
492
|
+
|
|
493
|
+
const rail = `${formatModeRail(selectedMode)} `;
|
|
494
|
+
const metadata = truncateToWidth(
|
|
495
|
+
formatModeMetadata(selectedMode, pi.getThinkingLevel(), ctx.ui.theme, this.borderColor, {
|
|
496
|
+
modelName: ctx.model?.id ?? "no-model",
|
|
497
|
+
modelColor: (text) => ctx.ui.theme.fg("dim", text),
|
|
498
|
+
}),
|
|
499
|
+
width,
|
|
500
|
+
"",
|
|
501
|
+
);
|
|
502
|
+
const topBorder = formatModeTopBorder(selectedMode, width);
|
|
503
|
+
return renderModeComposer(lines, topBorder, rail, metadata, railWidth);
|
|
415
504
|
}
|
|
416
505
|
|
|
417
506
|
override handleInput(data: string): void {
|
|
@@ -436,6 +525,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
|
|
|
436
525
|
|
|
437
526
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
438
527
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
528
|
+
ctx.ui.setFooter(undefined);
|
|
439
529
|
ctx.ui.setEditorComponent(undefined);
|
|
440
530
|
requestEditorRender = undefined;
|
|
441
531
|
currentContext = undefined;
|
package/package.json
CHANGED
package/utils.ts
CHANGED
|
@@ -1,21 +1,98 @@
|
|
|
1
|
-
import path from "node:path";
|
|
1
|
+
import path, { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
2
|
|
|
3
3
|
export type Mode = "build" | "plan";
|
|
4
4
|
|
|
5
5
|
const ANSI_RESET = "\x1b[0m";
|
|
6
6
|
const MODE_LABELS: Record<Mode, { color: string; text: string }> = {
|
|
7
|
-
plan: { color: "38;2;
|
|
8
|
-
build: { color: "38;2;
|
|
7
|
+
plan: { color: "38;2;245;167;66", text: "plan" },
|
|
8
|
+
build: { color: "38;2;92;156;245", text: "build" },
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
export interface ModeStatusTheme {
|
|
12
12
|
bold(text: string): string;
|
|
13
|
+
fg(color: "dim", text: string): string;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export
|
|
16
|
+
export interface PromptMetadataOptions {
|
|
17
|
+
modelName: string;
|
|
18
|
+
modelColor: (text: string) => string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatModeColor(mode: Mode, text: string): string {
|
|
22
|
+
return `\x1b[${MODE_LABELS[mode].color}m${text}${ANSI_RESET}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatModeRail(mode: Mode): string {
|
|
26
|
+
return formatModeColor(mode, "│");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatModeTopBorder(mode: Mode, width: number): string {
|
|
30
|
+
if (width <= 0) return "";
|
|
31
|
+
return formatModeColor(mode, `╭${"─".repeat(width - 1)}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatModeMetadata(
|
|
35
|
+
mode: Mode,
|
|
36
|
+
thinkingLevel: string,
|
|
37
|
+
theme: ModeStatusTheme,
|
|
38
|
+
thinkingColor: (text: string) => string,
|
|
39
|
+
options?: PromptMetadataOptions,
|
|
40
|
+
): string {
|
|
16
41
|
const label = MODE_LABELS[mode];
|
|
17
42
|
const modeText = `\x1b[${label.color}m${theme.bold(label.text)}${ANSI_RESET}`;
|
|
18
|
-
|
|
43
|
+
const modelText = options
|
|
44
|
+
? `${theme.fg("dim", " • ")}${options.modelColor(options.modelName)}`
|
|
45
|
+
: "";
|
|
46
|
+
const thinkingSeparator = options ? " • " : " · ";
|
|
47
|
+
return `${formatModeRail(mode)} ${modeText}${modelText}${theme.fg("dim", thinkingSeparator)}${thinkingColor(thinkingLevel)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatTokens(count: number): string {
|
|
51
|
+
if (count < 1000) return count.toString();
|
|
52
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
53
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
54
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
55
|
+
return `${Math.round(count / 1000000)}M`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatFooterCwd(cwd: string, home: string | undefined): string {
|
|
59
|
+
if (!home) return cwd;
|
|
60
|
+
const resolvedCwd = resolve(cwd);
|
|
61
|
+
const relativeToHome = relative(resolve(home), resolvedCwd);
|
|
62
|
+
const isInsideHome =
|
|
63
|
+
relativeToHome === "" ||
|
|
64
|
+
(relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
|
|
65
|
+
if (!isInsideHome) return cwd;
|
|
66
|
+
return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function renderModeComposer(
|
|
70
|
+
lines: string[],
|
|
71
|
+
topBorder: string,
|
|
72
|
+
railPrefix: string,
|
|
73
|
+
metadata: string,
|
|
74
|
+
reservedWidth: number,
|
|
75
|
+
): string[] {
|
|
76
|
+
if (reservedWidth <= 0 || lines.length < 3) return lines;
|
|
77
|
+
const reservedPrefix = " ".repeat(reservedWidth);
|
|
78
|
+
const bottomBorderIndex = lines.findIndex((line, index) => index > 0 && !line.startsWith(reservedPrefix));
|
|
79
|
+
if (bottomBorderIndex < 2) return lines;
|
|
80
|
+
const promptLines = lines
|
|
81
|
+
.slice(1, bottomBorderIndex)
|
|
82
|
+
.map((line) => railPrefix + line.slice(reservedPrefix.length));
|
|
83
|
+
const bottomBorder = lines[bottomBorderIndex]!.replace(
|
|
84
|
+
/^((?:\x1b\[[0-?]*[ -/]*[@-~])*)./u,
|
|
85
|
+
"$1 ",
|
|
86
|
+
);
|
|
87
|
+
return [
|
|
88
|
+
topBorder,
|
|
89
|
+
...promptLines,
|
|
90
|
+
railPrefix,
|
|
91
|
+
metadata,
|
|
92
|
+
bottomBorder,
|
|
93
|
+
"",
|
|
94
|
+
...lines.slice(bottomBorderIndex + 1),
|
|
95
|
+
];
|
|
19
96
|
}
|
|
20
97
|
|
|
21
98
|
export const PLAN_EXIT_APPROVE_CHOICE = "Switch to Build and implement here";
|