@matyah00/openpi 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.
Files changed (98) hide show
  1. package/README.md +117 -0
  2. package/agents/agent-chain.yaml +113 -0
  3. package/agents/backend.md +13 -0
  4. package/agents/basher.md +27 -0
  5. package/agents/builder.md +14 -0
  6. package/agents/code-searcher.md +27 -0
  7. package/agents/context-pruner.md +29 -0
  8. package/agents/directory-lister.md +25 -0
  9. package/agents/documenter.md +13 -0
  10. package/agents/editor.md +27 -0
  11. package/agents/file-picker.md +27 -0
  12. package/agents/frontend.md +14 -0
  13. package/agents/glob-matcher.md +25 -0
  14. package/agents/librarian.md +27 -0
  15. package/agents/loop-controller.md +41 -0
  16. package/agents/pi-pi/agent-expert.md +97 -0
  17. package/agents/pi-pi/cli-expert.md +41 -0
  18. package/agents/pi-pi/config-expert.md +63 -0
  19. package/agents/pi-pi/ext-expert.md +43 -0
  20. package/agents/pi-pi/keybinding-expert.md +134 -0
  21. package/agents/pi-pi/pi-orchestrator.md +57 -0
  22. package/agents/pi-pi/prompt-expert.md +70 -0
  23. package/agents/pi-pi/skill-expert.md +42 -0
  24. package/agents/pi-pi/theme-expert.md +40 -0
  25. package/agents/pi-pi/tui-expert.md +85 -0
  26. package/agents/plan-reviewer.md +22 -0
  27. package/agents/planner.md +14 -0
  28. package/agents/problem-architect.md +55 -0
  29. package/agents/red-team.md +13 -0
  30. package/agents/reviewer.md +14 -0
  31. package/agents/rule-verifier.md +35 -0
  32. package/agents/scout.md +14 -0
  33. package/agents/security-auditor.md +35 -0
  34. package/agents/ship-guard.md +34 -0
  35. package/agents/spec-reviewer.md +41 -0
  36. package/agents/teams.yaml +73 -0
  37. package/agents/tester.md +27 -0
  38. package/agents/thinker.md +26 -0
  39. package/agents/worker.md +27 -0
  40. package/damage-control-rules.yaml +277 -0
  41. package/extensions/agent-chain.ts +293 -0
  42. package/extensions/agent-team.ts +312 -0
  43. package/extensions/audit-tools.ts +260 -0
  44. package/extensions/commands.ts +169 -0
  45. package/extensions/damage-control-continue.ts +243 -0
  46. package/extensions/lib/packagePaths.ts +13 -0
  47. package/extensions/minimal.ts +34 -0
  48. package/extensions/openpi.ts +255 -0
  49. package/extensions/pure-focus.ts +24 -0
  50. package/extensions/purpose-gate.ts +84 -0
  51. package/extensions/search-tools.ts +277 -0
  52. package/extensions/state-tools.ts +276 -0
  53. package/extensions/system-select.ts +120 -0
  54. package/extensions/theme-cycler.ts +181 -0
  55. package/extensions/themeMap.ts +145 -0
  56. package/extensions/tool-counter-widget.ts +68 -0
  57. package/extensions/tool-counter.ts +102 -0
  58. package/extensions/workflow.ts +642 -0
  59. package/package.json +60 -0
  60. package/prompts/blueprint.md +66 -0
  61. package/prompts/clarify.md +26 -0
  62. package/prompts/compress.md +23 -0
  63. package/prompts/debate.md +23 -0
  64. package/prompts/deep.md +36 -0
  65. package/prompts/deps.md +24 -0
  66. package/prompts/explore.md +22 -0
  67. package/prompts/ghost-test.md +22 -0
  68. package/prompts/goal.md +26 -0
  69. package/prompts/parallel.md +42 -0
  70. package/prompts/plan-team.md +31 -0
  71. package/prompts/prime.md +17 -0
  72. package/prompts/review.md +23 -0
  73. package/prompts/sentinel.md +29 -0
  74. package/prompts/ship.md +30 -0
  75. package/prompts/snapshot.md +26 -0
  76. package/prompts/spec.md +58 -0
  77. package/prompts/test.md +13 -0
  78. package/prompts/validate.md +19 -0
  79. package/skills/bowser/SKILL.md +114 -0
  80. package/skills/env-scanner/SKILL.md +25 -0
  81. package/skills/security-guard/SKILL.md +24 -0
  82. package/skills/session-continuity/SKILL.md +20 -0
  83. package/skills/spec-driven/SKILL.md +25 -0
  84. package/skills/test-first/SKILL.md +23 -0
  85. package/skills/ultrathink/SKILL.md +27 -0
  86. package/themes/catppuccin-mocha.json +86 -0
  87. package/themes/cyberpunk.json +81 -0
  88. package/themes/dracula.json +81 -0
  89. package/themes/everforest.json +82 -0
  90. package/themes/gruvbox.json +80 -0
  91. package/themes/midnight-ocean.json +76 -0
  92. package/themes/nord.json +84 -0
  93. package/themes/ocean-breeze.json +83 -0
  94. package/themes/rose-pine.json +82 -0
  95. package/themes/synthwave.json +82 -0
  96. package/themes/tokyo-night.json +83 -0
  97. package/tsconfig.json +15 -0
  98. package/types/pi-shims.d.ts +102 -0
@@ -0,0 +1,276 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { Text } from "@earendil-works/pi-tui";
5
+ import { Type } from "typebox";
6
+
7
+ type GoalAction = "list" | "set" | "complete" | "block";
8
+
9
+ type Milestone = {
10
+ id: string;
11
+ title?: string;
12
+ files?: string[];
13
+ dependsOn?: string[];
14
+ status?: string;
15
+ };
16
+
17
+ function memoryDir(cwd: string): string {
18
+ return path.join(cwd, ".pi", "memory");
19
+ }
20
+
21
+ function goalsPath(cwd: string): string {
22
+ return path.join(memoryDir(cwd), "goals.md");
23
+ }
24
+
25
+ function checkpointsDir(cwd: string): string {
26
+ return path.join(memoryDir(cwd), "checkpoints");
27
+ }
28
+
29
+ function ensureMemory(cwd: string) {
30
+ fs.mkdirSync(memoryDir(cwd), { recursive: true });
31
+ fs.mkdirSync(checkpointsDir(cwd), { recursive: true });
32
+ const file = goalsPath(cwd);
33
+ if (!fs.existsSync(file)) {
34
+ fs.writeFileSync(file, [
35
+ "# Pi Goals",
36
+ "",
37
+ "## Current Threads",
38
+ "",
39
+ "## In Progress",
40
+ "",
41
+ "## Next Actions",
42
+ "",
43
+ "## Done",
44
+ "",
45
+ "## Blocked",
46
+ "",
47
+ ].join("\n"), "utf-8");
48
+ }
49
+ }
50
+
51
+ function readText(filePath: string): string {
52
+ try {
53
+ return fs.readFileSync(filePath, "utf-8");
54
+ } catch {
55
+ return "";
56
+ }
57
+ }
58
+
59
+ function writeGoals(cwd: string, content: string) {
60
+ ensureMemory(cwd);
61
+ fs.writeFileSync(goalsPath(cwd), content.endsWith("\n") ? content : `${content}\n`, "utf-8");
62
+ }
63
+
64
+ function appendUnderHeading(content: string, heading: string, line: string): string {
65
+ const marker = `## ${heading}`;
66
+ const index = content.indexOf(marker);
67
+ if (index === -1) return `${content.trimEnd()}\n\n${marker}\n${line}\n`;
68
+ const after = index + marker.length;
69
+ return `${content.slice(0, after)}\n${line}${content.slice(after)}`;
70
+ }
71
+
72
+ function replaceSection(content: string, heading: string, lines: string[]): string {
73
+ const marker = `## ${heading}`;
74
+ const start = content.indexOf(marker);
75
+ const section = `${marker}\n${lines.join("\n")}\n`;
76
+ if (start === -1) return `${content.trimEnd()}\n\n${section}`;
77
+ const next = content.indexOf("\n## ", start + marker.length);
78
+ if (next === -1) return `${content.slice(0, start)}${section}`;
79
+ return `${content.slice(0, start)}${section}${content.slice(next + 1)}`;
80
+ }
81
+
82
+ function timestampForFile(date = new Date()): string {
83
+ return date.toISOString().replace(/[:.]/g, "-").slice(0, 19);
84
+ }
85
+
86
+ function listLatestCheckpoint(cwd: string): string | null {
87
+ const dir = checkpointsDir(cwd);
88
+ if (!fs.existsSync(dir)) return null;
89
+ const files = fs.readdirSync(dir).filter((name) => name.endsWith(".md")).sort();
90
+ return files.length ? path.join(dir, files[files.length - 1]) : null;
91
+ }
92
+
93
+ function normalizeFile(file: string): string {
94
+ return file.replace(/\\/g, "/").toLowerCase();
95
+ }
96
+
97
+ function parallelConflicts(milestones: Milestone[]): string[] {
98
+ const conflicts: string[] = [];
99
+ for (let i = 0; i < milestones.length; i++) {
100
+ for (let j = i + 1; j < milestones.length; j++) {
101
+ const left = milestones[i];
102
+ const right = milestones[j];
103
+ const leftFiles = new Set((left.files || []).map(normalizeFile));
104
+ const rightFiles = new Set((right.files || []).map(normalizeFile));
105
+ const shared = Array.from(leftFiles).filter((file) => rightFiles.has(file));
106
+ if (shared.length) conflicts.push(`${left.id} conflicts with ${right.id}: shared files ${shared.join(", ")}`);
107
+
108
+ const sensitive = ["package.json", "requirements.txt", "pyproject.toml", "cargo.toml", "go.mod", "schema.prisma"];
109
+ const leftSensitive = Array.from(leftFiles).filter((file) => sensitive.some((item) => file.endsWith(item)));
110
+ const rightSensitive = Array.from(rightFiles).filter((file) => sensitive.some((item) => file.endsWith(item)));
111
+ if (leftSensitive.length && rightSensitive.length) {
112
+ conflicts.push(`${left.id} conflicts with ${right.id}: both touch dependency/schema files`);
113
+ }
114
+
115
+ if ((left.dependsOn || []).includes(right.id) || (right.dependsOn || []).includes(left.id)) {
116
+ conflicts.push(`${left.id} conflicts with ${right.id}: dependency relationship`);
117
+ }
118
+ }
119
+ if (["blocked", "done"].includes((milestones[i].status || "").toLowerCase())) {
120
+ conflicts.push(`${milestones[i].id} is ${milestones[i].status}; do not parallelize`);
121
+ }
122
+ }
123
+ return conflicts;
124
+ }
125
+
126
+ export default function stateToolsExtension(pi: ExtensionAPI) {
127
+ pi.registerTool({
128
+ name: "session_state",
129
+ label: "Session State",
130
+ description: "Read Pi-native goals and latest checkpoint from .pi/memory.",
131
+ promptSnippet: "Use session_state when resuming, checking goals, or deciding whether to snapshot.",
132
+ parameters: Type.Object({}),
133
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
134
+ ensureMemory(ctx.cwd);
135
+ const latest = listLatestCheckpoint(ctx.cwd);
136
+ const text = [
137
+ `goals: ${goalsPath(ctx.cwd)}`,
138
+ "",
139
+ readText(goalsPath(ctx.cwd)) || "(no goals)",
140
+ "",
141
+ latest ? `latest checkpoint: ${latest}\n\n${readText(latest)}` : "latest checkpoint: none",
142
+ ].join("\n");
143
+ return { content: [{ type: "text", text }] };
144
+ },
145
+ renderCall(_args, theme) {
146
+ return new Text(theme.fg("toolTitle", theme.bold("session_state")), 0, 0);
147
+ },
148
+ });
149
+
150
+ pi.registerTool({
151
+ name: "goal_state",
152
+ label: "Goal State",
153
+ description: "Manage Pi-native goals in .pi/memory/goals.md.",
154
+ promptSnippet: "Use goal_state to list, set, complete, or block durable session goals.",
155
+ parameters: Type.Object({
156
+ action: Type.String({ description: "list, set, complete, or block." }),
157
+ goal: Type.Optional(Type.String({ description: "Goal text for set/complete/block." })),
158
+ note: Type.Optional(Type.String({ description: "Optional note or evidence." })),
159
+ }),
160
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
161
+ ensureMemory(ctx.cwd);
162
+ const action = String(params.action || "list") as GoalAction;
163
+ const goal = String(params.goal || "").trim();
164
+ const note = String(params.note || "").trim();
165
+ let content = readText(goalsPath(ctx.cwd));
166
+ const stamp = new Date().toISOString();
167
+
168
+ if (action === "set") {
169
+ if (!goal) throw new Error("goal_state set requires goal");
170
+ content = appendUnderHeading(content, "In Progress", `- ${stamp} - ${goal}${note ? ` | ${note}` : ""}`);
171
+ content = appendUnderHeading(content, "Current Threads", `- ${goal}`);
172
+ writeGoals(ctx.cwd, content);
173
+ } else if (action === "complete") {
174
+ if (!goal) throw new Error("goal_state complete requires goal");
175
+ content = appendUnderHeading(content, "Done", `- ${stamp} - ${goal}${note ? ` | ${note}` : ""}`);
176
+ writeGoals(ctx.cwd, content);
177
+ } else if (action === "block") {
178
+ if (!goal) throw new Error("goal_state block requires goal");
179
+ content = appendUnderHeading(content, "Blocked", `- ${stamp} - ${goal}${note ? ` | ${note}` : ""}`);
180
+ writeGoals(ctx.cwd, content);
181
+ }
182
+
183
+ return { content: [{ type: "text", text: readText(goalsPath(ctx.cwd)) }] };
184
+ },
185
+ renderCall(args, theme) {
186
+ return new Text(`${theme.fg("toolTitle", theme.bold("goal_state "))}${theme.fg("accent", args.action || "list")}`, 0, 0);
187
+ },
188
+ });
189
+
190
+ pi.registerTool({
191
+ name: "write_snapshot",
192
+ label: "Write Snapshot",
193
+ description: "Write a mid-session checkpoint to .pi/memory/checkpoints and update next actions.",
194
+ promptSnippet: "Use write_snapshot before context compaction, pausing, or after major decisions.",
195
+ parameters: Type.Object({
196
+ label: Type.Optional(Type.String()),
197
+ currentTask: Type.String(),
198
+ decisions: Type.Optional(Type.Array(Type.String())),
199
+ knownContext: Type.Optional(Type.Array(Type.String())),
200
+ nextActions: Type.Optional(Type.Array(Type.String())),
201
+ risks: Type.Optional(Type.Array(Type.String())),
202
+ }),
203
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
204
+ ensureMemory(ctx.cwd);
205
+ const stamp = new Date().toISOString();
206
+ const label = String(params.label || "mid-session").trim();
207
+ const filePath = path.join(checkpointsDir(ctx.cwd), `${timestampForFile()}-${label.replace(/[^a-zA-Z0-9_-]+/g, "-").slice(0, 40)}.md`);
208
+ const decisions = (params.decisions || []) as string[];
209
+ const knownContext = (params.knownContext || []) as string[];
210
+ const nextActions = (params.nextActions || []) as string[];
211
+ const risks = (params.risks || []) as string[];
212
+ const checkpoint = [
213
+ "---",
214
+ `date: ${stamp}`,
215
+ `label: ${label}`,
216
+ "---",
217
+ "",
218
+ "## Current Task",
219
+ String(params.currentTask),
220
+ "",
221
+ "## Decisions",
222
+ ...(decisions.length ? decisions.map((item) => `- ${item}`) : ["- None recorded"]),
223
+ "",
224
+ "## Known Context",
225
+ ...(knownContext.length ? knownContext.map((item) => `- ${item}`) : ["- None recorded"]),
226
+ "",
227
+ "## Next Actions",
228
+ ...(nextActions.length ? nextActions.map((item, index) => `${index + 1}. ${item}`) : ["1. None recorded"]),
229
+ "",
230
+ "## Risks",
231
+ ...(risks.length ? risks.map((item) => `- ${item}`) : ["- None"]),
232
+ "",
233
+ ].join("\n");
234
+ fs.writeFileSync(filePath, checkpoint, "utf-8");
235
+
236
+ let goals = readText(goalsPath(ctx.cwd));
237
+ goals = appendUnderHeading(goals, "Current Threads", `- [checkpoint] ${stamp} - ${label} -> ${path.relative(ctx.cwd, filePath).replace(/\\/g, "/")}`);
238
+ if (nextActions.length) goals = replaceSection(goals, "Next Actions", nextActions.map((item, index) => `${index + 1}. ${item}`));
239
+ writeGoals(ctx.cwd, goals);
240
+
241
+ return { content: [{ type: "text", text: `Snapshot written: ${filePath}\n\n${checkpoint}` }] };
242
+ },
243
+ renderCall(args, theme) {
244
+ return new Text(`${theme.fg("toolTitle", theme.bold("write_snapshot "))}${theme.fg("accent", args.label || "mid-session")}`, 0, 0);
245
+ },
246
+ });
247
+
248
+ pi.registerTool({
249
+ name: "parallel_safety_check",
250
+ label: "Parallel Safety Check",
251
+ description: "Check milestone file ownership, dependency, and status conflicts before parallel agent dispatch.",
252
+ promptSnippet: "Use parallel_safety_check before running multiple agents in parallel.",
253
+ parameters: Type.Object({
254
+ milestones: Type.Array(Type.Object({
255
+ id: Type.String(),
256
+ title: Type.Optional(Type.String()),
257
+ files: Type.Optional(Type.Array(Type.String())),
258
+ dependsOn: Type.Optional(Type.Array(Type.String())),
259
+ status: Type.Optional(Type.String()),
260
+ })),
261
+ }),
262
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
263
+ const milestones = (params.milestones || []) as Milestone[];
264
+ const conflicts = parallelConflicts(milestones);
265
+ const verdict = conflicts.length ? "SEQUENTIAL_REQUIRED" : "PARALLEL_SAFE";
266
+ return {
267
+ content: [{ type: "text", text: [`parallel_safety_check: ${verdict}`, "", ...(conflicts.length ? conflicts : ["No ownership, dependency, or terminal-status conflicts detected."])].join("\n") }],
268
+ details: { verdict, conflicts },
269
+ };
270
+ },
271
+ renderCall(args, theme) {
272
+ const count = Array.isArray(args.milestones) ? args.milestones.length : 0;
273
+ return new Text(`${theme.fg("toolTitle", theme.bold("parallel_safety_check "))}${theme.fg("accent", `${count} milestones`)}`, 0, 0);
274
+ },
275
+ });
276
+ }
@@ -0,0 +1,120 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, join } from "node:path";
5
+ import { bundledAgentsDir, bundledPiPiAgentsDir } from "./lib/packagePaths.ts";
6
+
7
+ type AgentDef = {
8
+ name: string;
9
+ description: string;
10
+ tools: string[];
11
+ body: string;
12
+ source: string;
13
+ };
14
+
15
+ function parseFrontmatter(raw: string): { fields: Record<string, string>; body: string } {
16
+ const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
17
+ if (!match) return { fields: {}, body: raw };
18
+ const fields: Record<string, string> = {};
19
+ for (const line of match[1].split("\n")) {
20
+ const idx = line.indexOf(":");
21
+ if (idx > 0) fields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
22
+ }
23
+ return { fields, body: match[2] };
24
+ }
25
+
26
+ function scanAgents(dir: string, source: string): AgentDef[] {
27
+ if (!existsSync(dir)) return [];
28
+ const agents: AgentDef[] = [];
29
+ for (const file of readdirSync(dir)) {
30
+ if (!file.endsWith(".md")) continue;
31
+ const raw = readFileSync(join(dir, file), "utf-8");
32
+ const { fields, body } = parseFrontmatter(raw);
33
+ agents.push({
34
+ name: fields.name || basename(file, ".md"),
35
+ description: fields.description || "",
36
+ tools: fields.tools ? fields.tools.split(",").map((tool) => tool.trim()).filter(Boolean) : [],
37
+ body: body.trim(),
38
+ source,
39
+ });
40
+ }
41
+ return agents;
42
+ }
43
+
44
+ function displayName(name: string): string {
45
+ return name.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
46
+ }
47
+
48
+ export default function (pi: ExtensionAPI) {
49
+ let activeAgent: AgentDef | null = null;
50
+ let allAgents: AgentDef[] = [];
51
+ let defaultTools: string[] = [];
52
+
53
+ pi.on("session_start", async (_event, ctx) => {
54
+ activeAgent = null;
55
+ allAgents = [];
56
+
57
+ const dirs: [string, string][] = [
58
+ [join(ctx.cwd, ".pi", "agents"), ".pi"],
59
+ [join(ctx.cwd, "agents"), "agents"],
60
+ [bundledAgentsDir, "openpi"],
61
+ [bundledPiPiAgentsDir, "openpi/pi-pi"],
62
+ [join(homedir(), ".pi", "agent", "agents"), "~/.pi"],
63
+ ];
64
+
65
+ const seen = new Set<string>();
66
+ const sourceCounts: Record<string, number> = {};
67
+
68
+ for (const [dir, source] of dirs) {
69
+ for (const agent of scanAgents(dir, source)) {
70
+ const key = agent.name.toLowerCase();
71
+ if (seen.has(key)) continue;
72
+ seen.add(key);
73
+ allAgents.push(agent);
74
+ sourceCounts[source] = (sourceCounts[source] || 0) + 1;
75
+ }
76
+ }
77
+
78
+ defaultTools = pi.getActiveTools();
79
+ if (ctx.hasUI) {
80
+ ctx.ui.setStatus("system-prompt", "System Prompt: Default");
81
+ const loaded = Object.entries(sourceCounts).map(([src, count]) => `${count} from ${src}`).join(", ");
82
+ ctx.ui.notify(allAgents.length ? `Loaded ${allAgents.length} Pi agents (${loaded})` : "No Pi agents found", "info");
83
+ }
84
+ });
85
+
86
+ pi.registerCommand("system", {
87
+ description: "Select a Pi-native system persona",
88
+ handler: async (_args, ctx) => {
89
+ if (allAgents.length === 0) {
90
+ ctx.ui.notify("No agents found in .pi/agents, agents, or bundled openpi agents", "warning");
91
+ return;
92
+ }
93
+
94
+ const options = ["Reset to Default", ...allAgents.map((agent) => `${agent.name} - ${agent.description} [${agent.source}]`)];
95
+ const choice = await ctx.ui.select("Select System Prompt", options);
96
+ if (choice === undefined) return;
97
+
98
+ if (choice === options[0]) {
99
+ activeAgent = null;
100
+ pi.setActiveTools(defaultTools);
101
+ ctx.ui.setStatus("system-prompt", "System Prompt: Default");
102
+ ctx.ui.notify("System Prompt reset to Default", "success");
103
+ return;
104
+ }
105
+
106
+ const agent = allAgents[options.indexOf(choice) - 1];
107
+ activeAgent = agent;
108
+ pi.setActiveTools(agent.tools.length ? agent.tools : defaultTools);
109
+ ctx.ui.setStatus("system-prompt", `System Prompt: ${displayName(agent.name)}`);
110
+ ctx.ui.notify(`System Prompt switched to: ${displayName(agent.name)}`, "success");
111
+ },
112
+ });
113
+
114
+ pi.on("before_agent_start", async (event) => {
115
+ if (!activeAgent) return;
116
+ return {
117
+ systemPrompt: `${activeAgent.body}\n\n${event.systemPrompt}`,
118
+ };
119
+ });
120
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Theme Cycler — Keyboard shortcuts to cycle through available themes
3
+ *
4
+ * Shortcuts:
5
+ * Ctrl+X — Cycle theme forward
6
+ * Ctrl+Q — Cycle theme backward
7
+ *
8
+ * Commands:
9
+ * /theme — Open select picker to choose a theme
10
+ * /theme <name> — Switch directly by name
11
+ *
12
+ * Features:
13
+ * - Status line shows current theme name with accent color
14
+ * - Color swatch widget flashes briefly after each switch
15
+ * - Auto-dismisses swatch after 3 seconds
16
+ *
17
+ * Usage: pi -e extensions/theme-cycler.ts -e extensions/minimal.ts
18
+ */
19
+
20
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
21
+ import { truncateToWidth } from "@mariozechner/pi-tui";
22
+ import { applyExtensionDefaults } from "./themeMap.ts";
23
+
24
+ export default function (pi: ExtensionAPI) {
25
+ let currentCtx: ExtensionContext | undefined;
26
+ let swatchTimer: ReturnType<typeof setTimeout> | null = null;
27
+
28
+ function updateStatus(ctx: ExtensionContext) {
29
+ if (!ctx.hasUI) return;
30
+ const name = ctx.ui.theme.name;
31
+ ctx.ui.setStatus("theme", `🎨 ${name}`);
32
+ }
33
+
34
+ function showSwatch(ctx: ExtensionContext) {
35
+ if (!ctx.hasUI) return;
36
+
37
+ if (swatchTimer) {
38
+ clearTimeout(swatchTimer);
39
+ swatchTimer = null;
40
+ }
41
+
42
+ ctx.ui.setWidget(
43
+ "theme-swatch",
44
+ (_tui, theme) => ({
45
+ invalidate() {},
46
+ render(width: number): string[] {
47
+ const block = "\u2588\u2588\u2588";
48
+ const swatch =
49
+ theme.fg("success", block) +
50
+ " " +
51
+ theme.fg("accent", block) +
52
+ " " +
53
+ theme.fg("warning", block) +
54
+ " " +
55
+ theme.fg("dim", block) +
56
+ " " +
57
+ theme.fg("muted", block);
58
+ const label = theme.fg("accent", " 🎨 ") + theme.fg("muted", ctx.ui.theme.name) + " " + swatch;
59
+ const border = theme.fg("borderMuted", "─".repeat(Math.max(0, width)));
60
+ return [border, truncateToWidth(" " + label, width), border];
61
+ },
62
+ }),
63
+ { placement: "belowEditor" },
64
+ );
65
+
66
+ swatchTimer = setTimeout(() => {
67
+ ctx.ui.setWidget("theme-swatch", undefined);
68
+ swatchTimer = null;
69
+ }, 3000);
70
+ }
71
+
72
+ function getThemeList(ctx: ExtensionContext) {
73
+ return ctx.ui.getAllThemes();
74
+ }
75
+
76
+ function findCurrentIndex(ctx: ExtensionContext): number {
77
+ const themes = getThemeList(ctx);
78
+ const current = ctx.ui.theme.name;
79
+ return themes.findIndex((t) => t.name === current);
80
+ }
81
+
82
+ function cycleTheme(ctx: ExtensionContext, direction: 1 | -1) {
83
+ if (!ctx.hasUI) return;
84
+
85
+ const themes = getThemeList(ctx);
86
+ if (themes.length === 0) {
87
+ ctx.ui.notify("No themes available", "warning");
88
+ return;
89
+ }
90
+
91
+ let index = findCurrentIndex(ctx);
92
+ if (index === -1) index = 0;
93
+
94
+ index = (index + direction + themes.length) % themes.length;
95
+ const theme = themes[index];
96
+ const result = ctx.ui.setTheme(theme.name);
97
+
98
+ if (result.success) {
99
+ updateStatus(ctx);
100
+ showSwatch(ctx);
101
+ ctx.ui.notify(`${theme.name} (${index + 1}/${themes.length})`, "info");
102
+ } else {
103
+ ctx.ui.notify(`Failed to set theme: ${result.error}`, "error");
104
+ }
105
+ }
106
+
107
+ // --- Shortcuts ---
108
+
109
+ pi.registerShortcut("ctrl+x", {
110
+ description: "Cycle theme forward",
111
+ handler: async (ctx) => {
112
+ currentCtx = ctx;
113
+ cycleTheme(ctx, 1);
114
+ },
115
+ });
116
+
117
+ pi.registerShortcut("ctrl+q", {
118
+ description: "Cycle theme backward",
119
+ handler: async (ctx) => {
120
+ currentCtx = ctx;
121
+ cycleTheme(ctx, -1);
122
+ },
123
+ });
124
+
125
+ // --- Command: /theme ---
126
+
127
+ pi.registerCommand("theme", {
128
+ description: "Select a theme: /theme or /theme <name>",
129
+ handler: async (args, ctx) => {
130
+ currentCtx = ctx;
131
+ if (!ctx.hasUI) return;
132
+
133
+ const themes = getThemeList(ctx);
134
+ const arg = args.trim();
135
+
136
+ if (arg) {
137
+ const result = ctx.ui.setTheme(arg);
138
+ if (result.success) {
139
+ updateStatus(ctx);
140
+ showSwatch(ctx);
141
+ ctx.ui.notify(`Theme: ${arg}`, "info");
142
+ } else {
143
+ ctx.ui.notify(`Theme not found: ${arg}. Use /theme to see available themes.`, "error");
144
+ }
145
+ return;
146
+ }
147
+
148
+ const items = themes.map((t) => {
149
+ const desc = t.path ? t.path : "built-in";
150
+ const active = t.name === ctx.ui.theme.name ? " (active)" : "";
151
+ return `${t.name}${active} — ${desc}`;
152
+ });
153
+
154
+ const selected = await ctx.ui.select("Select Theme", items);
155
+ if (!selected) return;
156
+
157
+ const selectedName = selected.split(/\s/)[0];
158
+ const result = ctx.ui.setTheme(selectedName);
159
+ if (result.success) {
160
+ updateStatus(ctx);
161
+ showSwatch(ctx);
162
+ ctx.ui.notify(`Theme: ${selectedName}`, "info");
163
+ }
164
+ },
165
+ });
166
+
167
+ // --- Session init ---
168
+
169
+ pi.on("session_start", async (_event, ctx) => {
170
+ currentCtx = ctx;
171
+ applyExtensionDefaults(import.meta.url, ctx);
172
+ updateStatus(ctx);
173
+ });
174
+
175
+ pi.on("session_shutdown", async () => {
176
+ if (swatchTimer) {
177
+ clearTimeout(swatchTimer);
178
+ swatchTimer = null;
179
+ }
180
+ });
181
+ }