@milanglacier/pi-plan-mode 0.5.1

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/state.ts ADDED
@@ -0,0 +1,164 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { requirePiTuiModule } from "./qna";
4
+
5
+ import type { PlanModeState } from "./types";
6
+
7
+ import { resolveActivePlanFilePath } from "./plan-files";
8
+ import { createInactivePlanModeState, isPlanModeState } from "./utils";
9
+
10
+ function getPiTui() {
11
+ return requirePiTuiModule() as {
12
+ truncateToWidth: (text: string, width: number) => string;
13
+ wrapTextWithAnsi: (text: string, width: number) => string[];
14
+ };
15
+ }
16
+
17
+ export const STATE_ENTRY_TYPE = "pi-plan:state";
18
+ export const CONTEXT_ENTRY_TYPE = "pi-plan:context";
19
+ const BANNER_WIDGET_KEY = "pi-plan-banner";
20
+ const PLAN_MODE_TOOL_NAMES = ["request_user_input", "set_plan"] as const;
21
+ const PLAN_MODE_TOOL_NAME_SET = new Set<string>(PLAN_MODE_TOOL_NAMES);
22
+
23
+ export function getLatestState(ctx: ExtensionContext): PlanModeState {
24
+ const entries = ctx.sessionManager.getEntries();
25
+ for (let i = entries.length - 1; i >= 0; i--) {
26
+ const entry = entries[i];
27
+ if (entry.type !== "custom" || entry.customType !== STATE_ENTRY_TYPE) {
28
+ continue;
29
+ }
30
+ if (isPlanModeState(entry.data)) {
31
+ return entry.data;
32
+ }
33
+ }
34
+ return createInactivePlanModeState();
35
+ }
36
+
37
+ export function hasEntryInSession(ctx: ExtensionContext, entryId: string | undefined): boolean {
38
+ if (!entryId) {
39
+ return false;
40
+ }
41
+ for (const entry of ctx.sessionManager.getEntries()) {
42
+ if (entry.id === entryId) {
43
+ return true;
44
+ }
45
+ }
46
+ return false;
47
+ }
48
+
49
+ export function getFirstUserMessageId(ctx: ExtensionContext): string | undefined {
50
+ for (const entry of ctx.sessionManager.getEntries()) {
51
+ if (entry.type === "message" && entry.message.role === "user") {
52
+ return entry.id;
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ export function createPlanModeStateManager(pi: ExtensionAPI) {
59
+ let state: PlanModeState = createInactivePlanModeState();
60
+
61
+ const persistState = () => {
62
+ pi.appendEntry(STATE_ENTRY_TYPE, state);
63
+ };
64
+
65
+ const areSameToolLists = (left: string[], right: string[]) => {
66
+ if (left.length !== right.length) {
67
+ return false;
68
+ }
69
+ for (let i = 0; i < left.length; i++) {
70
+ if (left[i] !== right[i]) {
71
+ return false;
72
+ }
73
+ }
74
+ return true;
75
+ };
76
+
77
+ const syncPlanModeTools = () => {
78
+ const activeTools = pi.getActiveTools();
79
+ const nextTools = state.active
80
+ ? [...activeTools, ...PLAN_MODE_TOOL_NAMES.filter((toolName) => !activeTools.includes(toolName))]
81
+ : activeTools.filter((toolName) => !PLAN_MODE_TOOL_NAME_SET.has(toolName));
82
+
83
+ if (areSameToolLists(activeTools, nextTools)) {
84
+ return;
85
+ }
86
+
87
+ pi.setActiveTools(nextTools);
88
+ };
89
+
90
+ const applyBanner = (ctx: ExtensionContext) => {
91
+ if (!ctx.hasUI) {
92
+ return;
93
+ }
94
+
95
+ if (!state.active) {
96
+ ctx.ui.setWidget(BANNER_WIDGET_KEY, undefined, {
97
+ placement: "aboveEditor",
98
+ });
99
+ return;
100
+ }
101
+
102
+ ctx.ui.setWidget(
103
+ BANNER_WIDGET_KEY,
104
+ (_tui, theme) => ({
105
+ invalidate: () => {},
106
+ render: (width: number) => {
107
+ const { truncateToWidth, wrapTextWithAnsi } = getPiTui();
108
+ const safeWidth = Math.max(1, width);
109
+ const activePlanFilePath = resolveActivePlanFilePath(ctx, state.planFilePath);
110
+ const lines = [
111
+ truncateToWidth(
112
+ `${theme.fg("warning", theme.bold(" Plan mode active"))}${theme.fg(
113
+ "muted",
114
+ "; `/plan` to exit. `/plan <location>` to move plan file.",
115
+ )}`,
116
+ safeWidth,
117
+ ),
118
+ ...wrapTextWithAnsi(theme.fg("dim", ` Plan file: ${activePlanFilePath}`), safeWidth),
119
+ ];
120
+ return lines;
121
+ },
122
+ }),
123
+ { placement: "aboveEditor" },
124
+ );
125
+ };
126
+
127
+ const setState = (ctx: ExtensionContext, nextState: PlanModeState) => {
128
+ state = nextState;
129
+ persistState();
130
+ syncPlanModeTools();
131
+ applyBanner(ctx);
132
+ };
133
+
134
+ const startPlanMode = (
135
+ ctx: ExtensionContext,
136
+ options: {
137
+ originLeafId?: string;
138
+ planFilePath: string;
139
+ },
140
+ ) => {
141
+ setState(ctx, {
142
+ active: true,
143
+ lastPlanLeafId: state.lastPlanLeafId,
144
+ originLeafId: options.originLeafId,
145
+ planFilePath: options.planFilePath,
146
+ version: state.version,
147
+ });
148
+ };
149
+
150
+ const refresh = (ctx: ExtensionContext) => {
151
+ state = getLatestState(ctx);
152
+ syncPlanModeTools();
153
+ applyBanner(ctx);
154
+ };
155
+
156
+ return {
157
+ applyBanner,
158
+ getState: () => state,
159
+ refresh,
160
+ setState,
161
+ startPlanMode,
162
+ syncTools: syncPlanModeTools,
163
+ };
164
+ }
package/types.ts ADDED
@@ -0,0 +1,36 @@
1
+ export interface PlanModeState {
2
+ version: number;
3
+ active: boolean;
4
+ originLeafId?: string;
5
+ planFilePath?: string;
6
+ lastPlanLeafId?: string;
7
+ }
8
+
9
+ export interface RequestUserInputOption {
10
+ label: string;
11
+ description: string;
12
+ }
13
+
14
+ export interface RequestUserInputQuestion {
15
+ id: string;
16
+ header: string;
17
+ question: string;
18
+ options?: RequestUserInputOption[];
19
+ }
20
+
21
+ export type NormalizedRequestUserInputQuestion = Omit<RequestUserInputQuestion, "options"> & {
22
+ options: RequestUserInputOption[];
23
+ };
24
+
25
+ export interface RequestUserInputAnswer {
26
+ answers: string[];
27
+ }
28
+
29
+ export interface RequestUserInputResponse {
30
+ answers: Record<string, RequestUserInputAnswer>;
31
+ }
32
+
33
+ export interface RequestUserInputDetails {
34
+ questions: NormalizedRequestUserInputQuestion[];
35
+ response: RequestUserInputResponse;
36
+ }
package/utils.ts ADDED
@@ -0,0 +1,64 @@
1
+ import path from "node:path";
2
+
3
+ import type { PlanModeState } from "./types";
4
+
5
+ export const PLAN_MODE_STATE_VERSION = 1;
6
+
7
+ export type { PlanModeState };
8
+
9
+ export const PLAN_MODE_START_OPTIONS = ["Empty branch", "Current branch"] as const;
10
+ export const PLAN_MODE_END_OPTIONS = ["Exit", "Exit & summarize branch"] as const;
11
+
12
+ export const PLAN_MODE_SUMMARY_PROMPT = `We are switching from a planning branch back to implementation work.
13
+ Summarize this planning branch so implementation can begin immediately.
14
+
15
+ Include:
16
+ 1. Goal and scope
17
+ 2. Key decisions and assumptions
18
+ 3. Ordered implementation steps
19
+ 4. Risks, validations, and open questions
20
+ 5. Important file paths, commands, and references gathered during planning
21
+
22
+ Use concise bullet points and preserve exact technical identifiers when relevant.`;
23
+
24
+ export function createInactivePlanModeState(): PlanModeState {
25
+ return {
26
+ active: false,
27
+ version: PLAN_MODE_STATE_VERSION,
28
+ };
29
+ }
30
+
31
+ export function isPlanModeState(value: unknown): value is PlanModeState {
32
+ if (!value || typeof value !== "object") {
33
+ return false;
34
+ }
35
+
36
+ const state = value as Partial<PlanModeState>;
37
+ return state.version === PLAN_MODE_STATE_VERSION && typeof state.active === "boolean";
38
+ }
39
+
40
+ export function resolvePlanFilePath(cwd: string, filePath: string): string | null {
41
+ const trimmed = filePath.trim();
42
+ if (!trimmed) {
43
+ return null;
44
+ }
45
+ return path.resolve(cwd, trimmed);
46
+ }
47
+
48
+ export function findDuplicateId(ids: string[]): string | null {
49
+ const seen = new Set<string>();
50
+ for (const id of ids) {
51
+ if (seen.has(id)) {
52
+ return id;
53
+ }
54
+ seen.add(id);
55
+ }
56
+ return null;
57
+ }
58
+
59
+ export function buildImplementationPrefill(planPath?: string): string {
60
+ if (planPath) {
61
+ return `Plan file: ${planPath}\nImplement the approved plan in this file. Keep changes focused, update tests, and summarize what was implemented.`;
62
+ }
63
+ return "Implement the approved plan step by step. Keep changes focused, update tests, and summarize what was implemented.";
64
+ }