moonpi 0.4.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/src/tools.ts ADDED
@@ -0,0 +1,475 @@
1
+ import { StringEnum } from "@mariozechner/pi-ai";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import { Editor, Key, matchesKey, Text, truncateToWidth, type EditorTheme } from "@mariozechner/pi-tui";
4
+ import { Type, type Static } from "typebox";
5
+ import type { MoonpiController } from "./modes.js";
6
+ import { formatTodoList } from "./state.js";
7
+ import type { TodoStatus } from "./types.js";
8
+
9
+ const TodoStatusSchema = StringEnum(["todo", "in_progress", "done", "blocked"] as const);
10
+
11
+ const TodoItemInputSchema = Type.Object({
12
+ text: Type.String({ description: "Task text" }),
13
+ status: Type.Optional(TodoStatusSchema),
14
+ notes: Type.Optional(Type.String({ description: "Optional task notes" })),
15
+ });
16
+
17
+ const TodoParamsSchema = Type.Object({
18
+ action: StringEnum(["list", "set", "add", "update", "remove", "clear"] as const),
19
+ items: Type.Optional(Type.Array(TodoItemInputSchema, { description: "Items for set" })),
20
+ id: Type.Optional(Type.Number({ description: "TODO id for update/remove" })),
21
+ text: Type.Optional(Type.String({ description: "TODO text for add/update" })),
22
+ status: Type.Optional(TodoStatusSchema),
23
+ notes: Type.Optional(Type.String({ description: "TODO notes for add/update" })),
24
+ });
25
+
26
+ type TodoParams = Static<typeof TodoParamsSchema>;
27
+
28
+ interface TodoDetails {
29
+ todos: ReturnType<MoonpiController["state"]["snapshot"]>["todos"];
30
+ }
31
+
32
+ const QuestionTypeSchema = StringEnum(["single", "multiple", "open"] as const);
33
+
34
+ const QuestionParamsSchema = Type.Object({
35
+ question: Type.String({ description: "Question to ask the user" }),
36
+ options: Type.Optional(Type.Array(Type.String(), { description: "Candidate answers. Required for single and multiple types, ignored for open type." })),
37
+ type: Type.Optional(QuestionTypeSchema),
38
+ allowCustom: Type.Optional(Type.Boolean({ description: "Deprecated: free-text is always included for single and multiple types. Ignored." })),
39
+ });
40
+
41
+ type QuestionParams = Static<typeof QuestionParamsSchema>;
42
+
43
+ const FREE_TEXT_OPTION = "Other (type your answer)";
44
+
45
+ type QuestionType = "single" | "multiple" | "open";
46
+
47
+ interface QuestionDetails {
48
+ type: QuestionType;
49
+ /** For single: single string (or null). For multiple: array of strings. For open: single string (or null). */
50
+ answer: string | null;
51
+ answers: string[];
52
+ /** Which answers were free-text (not from the provided options). */
53
+ customAnswers: string[];
54
+ }
55
+
56
+ interface MultiSelectResult {
57
+ answers: string[];
58
+ customAnswers: string[];
59
+ cancelled: boolean;
60
+ }
61
+
62
+ const EndConversationParamsSchema = Type.Object({
63
+ reason: Type.Optional(Type.String({ description: "Why no TODO/action phase is needed" })),
64
+ });
65
+
66
+ interface EndConversationDetails {
67
+ reason?: string | null;
68
+ error?: string;
69
+ mode?: string;
70
+ autoPhase?: string;
71
+ }
72
+
73
+ export function installMoonpiTools(pi: ExtensionAPI, controller: MoonpiController): void {
74
+ pi.registerTool({
75
+ name: "todo",
76
+ label: "moonpi todo",
77
+ description:
78
+ "Create, replace, update, remove, clear, or list the active TODO list. Use this in Plan phases before implementation and in Act phases to track progress.",
79
+ promptSnippet: "Manage the required TODO list",
80
+ promptGuidelines: [
81
+ "Use todo to create concrete, ordered TODO items before acting in Plan or Auto planning.",
82
+ "When Moonpi Auto mode is in Plan phase, first inspect with read-only tools, then use todo to produce a concrete TODO list before any edits. If the user only asked a question or no work is needed, call end_conversation instead of creating TODOs.",
83
+ "When executing a TODO list in Act phases, update TODO statuses with todo as work progresses.",
84
+ "When a TODO item changes, update it with todo so the current list is returned. Use todo with action 'list' if the current TODO state is not visible.",
85
+ "todo is disabled in Moonpi Fast mode even though its schema remains advertised for prompt-cache stability.",
86
+ ],
87
+ parameters: TodoParamsSchema,
88
+ async execute(_toolCallId, params: TodoParams, _signal, _onUpdate, ctx) {
89
+ const wasAutoPlanning = (controller.state.mode === "auto" && controller.state.autoPhase === "plan") || controller.state.mode === "sprint:plan";
90
+ if (controller.state.mode === "fast") {
91
+ return {
92
+ content: [{ type: "text", text: "todo is disabled in Fast mode." }],
93
+ details: { todos: controller.state.todos } satisfies TodoDetails,
94
+ };
95
+ }
96
+
97
+ switch (params.action) {
98
+ case "set":
99
+ controller.state.replaceTodos(params.items ?? []);
100
+ break;
101
+ case "add":
102
+ if (!params.text) {
103
+ return {
104
+ content: [{ type: "text", text: "Error: text is required for action add." }],
105
+ details: { todos: controller.state.todos } satisfies TodoDetails,
106
+ };
107
+ }
108
+ controller.state.addTodo(params.text, params.status ?? "todo", params.notes);
109
+ break;
110
+ case "update":
111
+ if (params.id === undefined) {
112
+ return {
113
+ content: [{ type: "text", text: "Error: id is required for action update." }],
114
+ details: { todos: controller.state.todos } satisfies TodoDetails,
115
+ };
116
+ }
117
+ {
118
+ const patch: { text?: string; status?: TodoStatus; notes?: string } = {};
119
+ if (params.text !== undefined) patch.text = params.text;
120
+ if (params.status !== undefined) patch.status = params.status;
121
+ if (params.notes !== undefined) patch.notes = params.notes;
122
+ if (!controller.state.updateTodo(params.id, patch)) {
123
+ return {
124
+ content: [{ type: "text", text: `Error: TODO #${params.id} not found.` }],
125
+ details: { todos: controller.state.todos } satisfies TodoDetails,
126
+ };
127
+ }
128
+ }
129
+ break;
130
+ case "remove":
131
+ if (params.id === undefined) {
132
+ return {
133
+ content: [{ type: "text", text: "Error: id is required for action remove." }],
134
+ details: { todos: controller.state.todos } satisfies TodoDetails,
135
+ };
136
+ }
137
+ controller.state.removeTodo(params.id);
138
+ break;
139
+ case "clear":
140
+ controller.state.clearTodos();
141
+ break;
142
+ case "list":
143
+ break;
144
+ }
145
+
146
+ const shouldEndAutoPlan = wasAutoPlanning && params.action !== "list" && controller.state.todos.length > 0;
147
+ controller.updateUi(ctx);
148
+ controller.persist();
149
+ const suffix = shouldEndAutoPlan
150
+ ? controller.state.mode === "sprint:plan"
151
+ ? "\n\nMoonpi Sprint planning is complete. The next turn will switch to Sprint Act mode with editing tools enabled."
152
+ : "\n\nMoonpi Auto planning is complete. The next turn will switch to Act mode with editing tools enabled."
153
+ : "";
154
+ return {
155
+ content: [{ type: "text", text: `Current TODO list:\n${formatTodoList(controller.state.todos)}${suffix}` }],
156
+ details: { todos: controller.state.todos } satisfies TodoDetails,
157
+ terminate: shouldEndAutoPlan,
158
+ };
159
+ },
160
+ renderResult(result, _options, theme) {
161
+ const text = result.content
162
+ .filter((item) => item.type === "text")
163
+ .map((item) => item.text)
164
+ .join("\n");
165
+ return new Text(theme.fg("toolOutput", text), 0, 0);
166
+ },
167
+ });
168
+
169
+ pi.registerTool({
170
+ name: "question",
171
+ label: "moonpi question",
172
+ description:
173
+ "Ask the user a clarifying question when a decision is required before continuing. Supports three types: 'single' (pick one option, default), 'multiple' (pick several options), and 'open' (free-text answer). For single and multiple types, a free-text 'Other' option is always included automatically.",
174
+ promptSnippet: "Ask the user a concise clarifying question",
175
+ promptGuidelines: [
176
+ "Use type 'single' when the user must pick exactly one option.",
177
+ "Use type 'multiple' when the user can pick several options.",
178
+ "Use type 'open' when you need a free-text answer with no predefined options.",
179
+ "A free-text 'Other (type your answer)' option is always included for single and multiple types.",
180
+ ],
181
+ parameters: QuestionParamsSchema,
182
+ async execute(_toolCallId, params: QuestionParams, _signal, _onUpdate, ctx) {
183
+ if (!controller.isQuestionAllowed()) {
184
+ return { content: [{ type: "text", text: "question is disabled in Sprint and Fast modes." }], details: undefined };
185
+ }
186
+ if (!ctx.hasUI) {
187
+ return { content: [{ type: "text", text: "Error: interactive UI is not available." }], details: undefined };
188
+ }
189
+
190
+ const qType: QuestionType = params.type ?? "single";
191
+
192
+ // Open type: simple text input
193
+ if (qType === "open") {
194
+ const custom = await ctx.ui.input(params.question, "Your answer");
195
+ const detail: QuestionDetails = {
196
+ type: "open",
197
+ answer: custom ?? null,
198
+ answers: custom ? [custom] : [],
199
+ customAnswers: custom ? [custom] : [],
200
+ };
201
+ return {
202
+ content: [{ type: "text", text: custom ? `User answered: ${custom}` : "User did not provide an answer." }],
203
+ details: detail,
204
+ };
205
+ }
206
+
207
+ // Single and multiple types require options
208
+ if (!params.options || params.options.length === 0) {
209
+ return {
210
+ content: [{ type: "text", text: "Error: at least one option is required for single/multiple question types." }],
211
+ details: undefined,
212
+ };
213
+ }
214
+
215
+ // Single type: select one with always-included free-text option
216
+ if (qType === "single") {
217
+ const options = [...params.options, FREE_TEXT_OPTION];
218
+ const selected = await ctx.ui.select(params.question, options);
219
+ if (!selected) {
220
+ const detail: QuestionDetails = { type: "single", answer: null, answers: [], customAnswers: [] };
221
+ return { content: [{ type: "text", text: "User cancelled the question." }], details: detail };
222
+ }
223
+ if (selected === FREE_TEXT_OPTION) {
224
+ const custom = await ctx.ui.input(params.question, "Your answer");
225
+ const detail: QuestionDetails = {
226
+ type: "single",
227
+ answer: custom ?? null,
228
+ answers: custom ? [custom] : [],
229
+ customAnswers: custom ? [custom] : [],
230
+ };
231
+ return {
232
+ content: [{ type: "text", text: custom ? `User answered: ${custom}` : "User did not provide an answer." }],
233
+ details: detail,
234
+ };
235
+ }
236
+ const detail: QuestionDetails = {
237
+ type: "single",
238
+ answer: selected,
239
+ answers: [selected],
240
+ customAnswers: [],
241
+ };
242
+ return { content: [{ type: "text", text: `User answered: ${selected}` }], details: detail };
243
+ }
244
+
245
+ // Multiple type: custom checkbox UI with always-included free-text option
246
+ const allOptions = [...params.options, FREE_TEXT_OPTION];
247
+ const result = await ctx.ui.custom<MultiSelectResult>((tui, theme, _kb, done) => {
248
+ let cursorIndex = 0;
249
+ const selected = new Set<number>();
250
+ let inputMode = false;
251
+ let cachedLines: string[] | undefined;
252
+
253
+ const editorTheme: EditorTheme = {
254
+ borderColor: (s) => theme.fg("accent", s),
255
+ selectList: {
256
+ selectedPrefix: (t) => theme.fg("accent", t),
257
+ selectedText: (t) => theme.fg("accent", t),
258
+ description: (t) => theme.fg("muted", t),
259
+ scrollInfo: (t) => theme.fg("dim", t),
260
+ noMatch: (t) => theme.fg("warning", t),
261
+ },
262
+ };
263
+ const editor = new Editor(tui, editorTheme);
264
+ editor.onSubmit = (value) => {
265
+ const trimmed = value.trim();
266
+ if (trimmed) {
267
+ // Find or add the free-text option index and mark it
268
+ selected.add(allOptions.length - 1); // FREE_TEXT_OPTION index
269
+ // Store the custom text as metadata on that index
270
+ customTexts.set(allOptions.length - 1, trimmed);
271
+ }
272
+ inputMode = false;
273
+ editor.setText("");
274
+ cachedLines = undefined;
275
+ tui.requestRender();
276
+ };
277
+
278
+ const customTexts = new Map<number, string>();
279
+
280
+ function refresh() {
281
+ cachedLines = undefined;
282
+ tui.requestRender();
283
+ }
284
+
285
+ function handleInput(data: string) {
286
+ if (inputMode) {
287
+ if (matchesKey(data, Key.escape)) {
288
+ inputMode = false;
289
+ editor.setText("");
290
+ refresh();
291
+ return;
292
+ }
293
+ editor.handleInput(data);
294
+ refresh();
295
+ return;
296
+ }
297
+
298
+ if (matchesKey(data, Key.up)) {
299
+ cursorIndex = Math.max(0, cursorIndex - 1);
300
+ refresh();
301
+ return;
302
+ }
303
+ if (matchesKey(data, Key.down)) {
304
+ cursorIndex = Math.min(allOptions.length - 1, cursorIndex + 1);
305
+ refresh();
306
+ return;
307
+ }
308
+ if (matchesKey(data, Key.space)) {
309
+ // Toggle selection (but not on free-text option directly — that opens input)
310
+ if (cursorIndex === allOptions.length - 1) {
311
+ // Toggle the "Other" option
312
+ if (selected.has(cursorIndex)) {
313
+ selected.delete(cursorIndex);
314
+ customTexts.delete(cursorIndex);
315
+ } else {
316
+ inputMode = true;
317
+ editor.setText("");
318
+ }
319
+ } else {
320
+ if (selected.has(cursorIndex)) {
321
+ selected.delete(cursorIndex);
322
+ } else {
323
+ selected.add(cursorIndex);
324
+ }
325
+ }
326
+ refresh();
327
+ return;
328
+ }
329
+ if (matchesKey(data, Key.enter)) {
330
+ if (cursorIndex === allOptions.length - 1 && !selected.has(cursorIndex)) {
331
+ // Enter on "Other" when not yet selected → open input
332
+ inputMode = true;
333
+ editor.setText("");
334
+ refresh();
335
+ return;
336
+ }
337
+ // Submit
338
+ const answers: string[] = [];
339
+ const customs: string[] = [];
340
+ for (const idx of [...selected].sort()) {
341
+ const opt = allOptions[idx]!;
342
+ if (idx === allOptions.length - 1) {
343
+ const customVal = customTexts.get(idx);
344
+ if (customVal) {
345
+ answers.push(customVal);
346
+ customs.push(customVal);
347
+ } else {
348
+ answers.push(opt);
349
+ customs.push(opt);
350
+ }
351
+ } else {
352
+ answers.push(opt);
353
+ }
354
+ }
355
+ done({ answers, customAnswers: customs, cancelled: false });
356
+ return;
357
+ }
358
+ if (matchesKey(data, Key.escape)) {
359
+ done({ answers: [], customAnswers: [], cancelled: true });
360
+ }
361
+ }
362
+
363
+ function render(width: number): string[] {
364
+ if (cachedLines) return cachedLines;
365
+ const lines: string[] = [];
366
+ const add = (s: string) => lines.push(truncateToWidth(s, width));
367
+
368
+ add(theme.fg("accent", "─".repeat(width)));
369
+ add(theme.fg("text", ` ${params.question}`));
370
+ lines.push("");
371
+
372
+ if (inputMode) {
373
+ for (let i = 0; i < allOptions.length; i++) {
374
+ const isCursor = i === cursorIndex;
375
+ const isChecked = selected.has(i);
376
+ const prefix = isCursor ? theme.fg("accent", "> ") : " ";
377
+ const box = isChecked ? theme.fg("success", "☑") : theme.fg("dim", "☐");
378
+ const label =
379
+ i === allOptions.length - 1
380
+ ? theme.fg("accent", `${FREE_TEXT_OPTION} ✎`)
381
+ : theme.fg("text", allOptions[i]!);
382
+ add(`${prefix}${box} ${label}`);
383
+ }
384
+ lines.push("");
385
+ add(theme.fg("muted", " Your answer:"));
386
+ for (const line of editor.render(width - 2)) {
387
+ add(` ${line}`);
388
+ }
389
+ lines.push("");
390
+ add(theme.fg("dim", " Enter to submit • Esc to cancel"));
391
+ } else {
392
+ for (let i = 0; i < allOptions.length; i++) {
393
+ const isCursor = i === cursorIndex;
394
+ const isChecked = selected.has(i);
395
+ const prefix = isCursor ? theme.fg("accent", "> ") : " ";
396
+ const box = isChecked ? theme.fg("success", "☑") : theme.fg("dim", "☐");
397
+ let label: string;
398
+ if (i === allOptions.length - 1) {
399
+ const customVal = customTexts.get(i);
400
+ label = isChecked && customVal
401
+ ? theme.fg("success", `${FREE_TEXT_OPTION}: ${customVal}`)
402
+ : theme.fg("accent", FREE_TEXT_OPTION);
403
+ } else {
404
+ label = theme.fg("text", allOptions[i]!);
405
+ }
406
+ add(`${prefix}${box} ${label}`);
407
+ }
408
+ lines.push("");
409
+ const selectedCount = selected.size;
410
+ if (selectedCount > 0) {
411
+ add(theme.fg("dim", ` ${selectedCount} selected • Space toggle • Enter submit • Esc cancel`));
412
+ } else {
413
+ add(theme.fg("dim", " Space toggle • Enter submit • Esc cancel"));
414
+ }
415
+ }
416
+
417
+ add(theme.fg("accent", "─".repeat(width)));
418
+ cachedLines = lines;
419
+ return lines;
420
+ }
421
+
422
+ return {
423
+ render,
424
+ invalidate: () => {
425
+ cachedLines = undefined;
426
+ },
427
+ handleInput,
428
+ };
429
+ });
430
+
431
+ if (result.cancelled) {
432
+ const detail: QuestionDetails = { type: "multiple", answer: null, answers: [], customAnswers: [] };
433
+ return { content: [{ type: "text", text: "User cancelled the question." }], details: detail };
434
+ }
435
+
436
+ const detail: QuestionDetails = {
437
+ type: "multiple",
438
+ answer: result.answers.length > 0 ? result.answers.join(", ") : null,
439
+ answers: result.answers,
440
+ customAnswers: result.customAnswers,
441
+ };
442
+ return {
443
+ content: [{ type: "text", text: `User answered: ${result.answers.join(", ")}` }],
444
+ details: detail,
445
+ };
446
+ },
447
+ });
448
+
449
+ pi.registerTool<typeof EndConversationParamsSchema, EndConversationDetails>({
450
+ name: "end_conversation",
451
+ label: "end conversation",
452
+ description:
453
+ "In Moonpi Auto planning, call this instead of creating TODOs when the user only asked a question or no action is needed.",
454
+ promptSnippet: "End Auto planning without switching to Act",
455
+ promptGuidelines: [
456
+ "Use end_conversation only in Moonpi Auto Plan mode when the request needs no edits and no Act phase.",
457
+ ],
458
+ parameters: EndConversationParamsSchema,
459
+ async execute(_toolCallId, params: Static<typeof EndConversationParamsSchema>) {
460
+ if (!controller.isEndConversationAllowed()) {
461
+ return {
462
+ content: [{ type: "text", text: "end_conversation is only available in Moonpi Auto Plan phase." }],
463
+ details: { error: "invalid mode", mode: controller.state.mode, autoPhase: controller.state.autoPhase } satisfies EndConversationDetails,
464
+ };
465
+ }
466
+ controller.markEndConversationRequested();
467
+ const reason = params.reason ? ` Reason: ${params.reason}` : "";
468
+ return {
469
+ content: [{ type: "text", text: `Conversation ended without an Act phase.${reason}` }],
470
+ details: { reason: params.reason ?? null } satisfies EndConversationDetails,
471
+ terminate: true,
472
+ };
473
+ },
474
+ });
475
+ }
package/src/types.ts ADDED
@@ -0,0 +1,61 @@
1
+ export type MoonpiMode = "plan" | "act" | "auto" | "fast" | "sprint:plan" | "sprint:act";
2
+
3
+ export type AutoPhase = "plan" | "act";
4
+
5
+ export type TodoStatus = "todo" | "in_progress" | "done" | "blocked";
6
+
7
+ export interface TodoItem {
8
+ id: number;
9
+ text: string;
10
+ status: TodoStatus;
11
+ notes?: string;
12
+ }
13
+
14
+ export interface SprintLoopState {
15
+ sprintNumber: number;
16
+ currentPhaseId?: string;
17
+ pendingNextPhaseId?: string;
18
+ }
19
+
20
+ export interface MoonpiSnapshot {
21
+ mode: MoonpiMode;
22
+ autoPhase: AutoPhase;
23
+ todos: TodoItem[];
24
+ nextTodoId: number;
25
+ readFiles: string[];
26
+ endConversationRequested: boolean;
27
+ /** Relative file paths selected by /pick for project context injection. Undefined means use default context file matches. */
28
+ selectedContextFilePaths?: string[];
29
+ sprintLoop?: SprintLoopState;
30
+ }
31
+
32
+ export interface MoonpiConfig {
33
+ defaultMode: MoonpiMode;
34
+ preserveExternalTools: boolean;
35
+ /** Whether moonpi installs its custom mode-colored editor. Set to false to preserve
36
+ * editor customizations from other extensions (e.g. pi-wierd-statusline). */
37
+ customEditor: boolean;
38
+ contextFiles: {
39
+ enabled: boolean;
40
+ fileNames: string[];
41
+ maxTotalBytes: number;
42
+ /** Maximum directory depth to scan from cwd for default context files and /pick. */
43
+ maxDepth: number;
44
+ /** Maximum filesystem entries to inspect before stopping discovery/tree building. */
45
+ maxScannedEntries: number;
46
+ /** Maximum default context files to auto-select when no /pick selection exists. */
47
+ maxDefaultFiles: number;
48
+ /** File extensions (with dot, e.g. ".ts") and exact filenames (e.g. "Dockerfile") selectable in /pick. */
49
+ pickableExtensions: string[];
50
+ ignoreDirs: string[];
51
+ };
52
+ guards: {
53
+ cwdOnly: boolean;
54
+ allowedPaths: string[];
55
+ readBeforeWrite: boolean;
56
+ };
57
+ keybindings: {
58
+ cycleNext: string;
59
+ cyclePrevious: string;
60
+ };
61
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { CustomEditor, type ExtensionContext, type KeybindingsManager, type Theme } from "@mariozechner/pi-coding-agent";
2
+ import type { EditorTheme, TUI } from "@mariozechner/pi-tui";
3
+ import { createRequire } from "node:module";
4
+ import type { MoonpiMode } from "./types.js";
5
+
6
+ const require = createRequire(import.meta.url);
7
+ const { version } = require("../package.json") as { version: string };
8
+
9
+ /**
10
+ * MoonPi crescent ASCII logo with ANSI color support via the theme.
11
+ * Translated from the Python logo.py orbit-note design.
12
+ */
13
+ function getMoonpiBanner(theme: Theme): string[] {
14
+ // Color helpers matching the gold/amber moon palette
15
+ const m0 = (t: string) => theme.fg("warning", t); // cream highlight
16
+ const m1 = (t: string) => theme.fg("warning", t); // pale yellow
17
+ const m2 = (t: string) => theme.fg("warning", t); // warm yellow
18
+ const m3 = (t: string) => theme.fg("dim", t); // amber shadow
19
+ const pi = (t: string) => theme.fg("accent", t); // bright gold
20
+ const title = (t: string) => theme.fg("accent", t); // bright for moonpi
21
+ const muted = (t: string) => theme.fg("muted", t); // coding agent
22
+ const line = (t: string) => theme.fg("dim", t); // crescent lines
23
+
24
+ return [
25
+ "",
26
+ `${m3(" _.._")} ${line(".-.")}`,
27
+ `${m2(" .' .-'")}${m3("`")} ${line(".-' '-.")}`,
28
+ `${m1(" / /")} ${pi("\u03C0")} ${title("moonpi")} ${line(")")}`,
29
+ `${m0(" | |")} ${line("'-. .-'")}`,
30
+ `${m1(" \\ '.___.;")} ${line("'-'")} ${muted(`coding agent (v${version})`)}`,
31
+ `${m2(" '._ _.'")}`,
32
+ `${m3(" \`\`")}`,
33
+ "",
34
+ ];
35
+ }
36
+
37
+ export function installMoonpiHeader(ctx: ExtensionContext): void {
38
+ ctx.ui.setHeader((_tui, theme) => ({
39
+ render(_width: number): string[] {
40
+ return getMoonpiBanner(theme);
41
+ },
42
+ invalidate() {},
43
+ }));
44
+ }
45
+
46
+ function borderForMode(theme: Theme, mode: MoonpiMode): (text: string) => string {
47
+ switch (mode) {
48
+ case "plan":
49
+ return (text) => theme.fg("warning", text);
50
+ case "act":
51
+ return (text) => theme.fg("success", text);
52
+ case "auto":
53
+ return (text) => theme.fg("accent", text);
54
+ case "fast":
55
+ return (text) => theme.fg("error", text);
56
+ case "sprint:plan":
57
+ return (text) => theme.fg("warning", text);
58
+ case "sprint:act":
59
+ return (text) => theme.fg("success", text);
60
+ }
61
+ }
62
+
63
+ class MoonpiEditor extends CustomEditor {
64
+ constructor(
65
+ tui: TUI,
66
+ editorTheme: EditorTheme,
67
+ keybindings: KeybindingsManager,
68
+ private readonly moonpiTheme: Theme,
69
+ private readonly getMode: () => MoonpiMode,
70
+ ) {
71
+ super(tui, editorTheme, keybindings);
72
+ }
73
+
74
+ override render(width: number): string[] {
75
+ this.borderColor = borderForMode(this.moonpiTheme, this.getMode());
76
+ return super.render(width);
77
+ }
78
+ }
79
+
80
+ export function installMoonpiEditor(ctx: ExtensionContext, getMode: () => MoonpiMode): void {
81
+ ctx.ui.setEditorComponent((tui, editorTheme, keybindings) => {
82
+ return new MoonpiEditor(tui, editorTheme, keybindings, ctx.ui.theme, getMode);
83
+ });
84
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "noImplicitOverride": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "exactOptionalPropertyTypes": true,
10
+ "skipLibCheck": true,
11
+ "types": [
12
+ "node"
13
+ ]
14
+ },
15
+ "include": [
16
+ "src/**/*.ts"
17
+ ]
18
+ }