@oh-my-pi/pi-coding-agent 14.5.9 → 14.5.11

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 (37) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +7 -15
  3. package/scripts/build-binary.ts +1 -1
  4. package/src/cli/update-cli.ts +25 -1
  5. package/src/config/model-registry.ts +21 -19
  6. package/src/config/settings-schema.ts +11 -16
  7. package/src/discovery/claude-plugins.ts +28 -3
  8. package/src/edit/modes/atom.ts +50 -19
  9. package/src/edit/modes/hashline.ts +171 -110
  10. package/src/export/html/template.generated.ts +1 -1
  11. package/src/export/html/template.js +14 -2
  12. package/src/extensibility/extensions/runner.ts +34 -1
  13. package/src/extensibility/extensions/types.ts +8 -0
  14. package/src/internal-urls/docs-index.generated.ts +54 -54
  15. package/src/lsp/client.ts +27 -35
  16. package/src/memories/index.ts +5 -0
  17. package/src/modes/components/settings-defs.ts +1 -1
  18. package/src/modes/controllers/selector-controller.ts +2 -2
  19. package/src/modes/controllers/todo-command-controller.ts +22 -74
  20. package/src/modes/interactive-mode.ts +36 -9
  21. package/src/modes/theme/theme.ts +10 -1
  22. package/src/modes/types.ts +1 -3
  23. package/src/modes/utils/ui-helpers.ts +19 -6
  24. package/src/prompts/system/auto-continue.md +1 -0
  25. package/src/prompts/system/eager-todo.md +1 -1
  26. package/src/prompts/tools/github.md +3 -3
  27. package/src/prompts/tools/todo-write.md +19 -19
  28. package/src/sdk.ts +13 -2
  29. package/src/session/agent-session.ts +196 -96
  30. package/src/session/session-manager.ts +19 -2
  31. package/src/tools/bash.ts +9 -4
  32. package/src/tools/gh.ts +267 -119
  33. package/src/tools/todo-write.ts +157 -195
  34. package/src/utils/git.ts +61 -2
  35. package/src/web/search/providers/searxng.ts +71 -13
  36. package/examples/custom-tools/todo/index.ts +0 -211
  37. package/examples/extensions/todo.ts +0 -295
@@ -9,14 +9,18 @@
9
9
  * and various authentication methods (bearer token, basic auth, or none).
10
10
  *
11
11
  * Configuration via settings:
12
- * searxng.endpoint - Base URL of the SearXNG instance (e.g. https://searx.example.org)
13
- * searxng.token - Optional bearer token for authentication
14
- * searxng.categories - Optional comma-separated categories filter
15
- * searxng.language - Optional language code (e.g. en, zh-CN)
12
+ * searxng.endpoint - Base URL of the SearXNG instance (e.g. https://searx.example.org)
13
+ * searxng.token - Optional bearer token for authentication
14
+ * searxng.basicUsername - Optional RFC 7617 Basic auth username
15
+ * searxng.basicPassword - Optional RFC 7617 Basic auth password
16
+ * searxng.categories - Optional comma-separated categories filter
17
+ * searxng.language - Optional language code (e.g. en, zh-CN)
16
18
  *
17
19
  * Environment variable fallbacks:
18
- * SEARXNG_ENDPOINT - Base URL of the SearXNG instance
19
- * SEARXNG_TOKEN - Optional bearer token
20
+ * SEARXNG_ENDPOINT - Base URL of the SearXNG instance
21
+ * SEARXNG_TOKEN - Optional bearer token
22
+ * SEARXNG_BASIC_USERNAME - Optional RFC 7617 Basic auth username
23
+ * SEARXNG_BASIC_PASSWORD - Optional RFC 7617 Basic auth password
20
24
  *
21
25
  * Reference: https://docs.searxng.org/dev/search_api.html
22
26
  */
@@ -61,6 +65,11 @@ interface SearXNGResponse {
61
65
  unresponsive_engines?: Array<[string, string]>;
62
66
  }
63
67
 
68
+ interface SearXNGAuth {
69
+ type: "basic" | "bearer";
70
+ value: string;
71
+ }
72
+
64
73
  /** Find SearXNG endpoint from settings or environment. */
65
74
  function findEndpoint(): string | null {
66
75
  try {
@@ -83,6 +92,53 @@ function findToken(): string | null {
83
92
  return process.env.SEARXNG_TOKEN ?? null;
84
93
  }
85
94
 
95
+ /** Find SearXNG Basic auth username from settings or environment. */
96
+ function findBasicUsername(): string | null {
97
+ try {
98
+ const username = settings.get("searxng.basicUsername");
99
+ if (username !== undefined) return username;
100
+ } catch {
101
+ // Settings not initialized yet
102
+ }
103
+ return process.env.SEARXNG_BASIC_USERNAME ?? null;
104
+ }
105
+
106
+ /** Find SearXNG Basic auth password from settings or environment. */
107
+ function findBasicPassword(): string | null {
108
+ try {
109
+ const password = settings.get("searxng.basicPassword");
110
+ if (password !== undefined) return password;
111
+ } catch {
112
+ // Settings not initialized yet
113
+ }
114
+ return process.env.SEARXNG_BASIC_PASSWORD ?? null;
115
+ }
116
+
117
+ /** Build the RFC 7617 Basic auth credential using UTF-8 bytes. */
118
+ function buildBasicAuthValue(username: string, password: string): string {
119
+ return Buffer.from(`${username}:${password}`, "utf-8").toString("base64");
120
+ }
121
+
122
+ /** Find SearXNG authentication from settings or environment. Basic auth takes precedence over bearer tokens. */
123
+ function findAuth(): SearXNGAuth | null {
124
+ const basicUsername = findBasicUsername();
125
+ const basicPassword = findBasicPassword();
126
+ if (basicUsername !== null || basicPassword !== null) {
127
+ if (basicUsername === null || basicPassword === null) {
128
+ throw new Error(
129
+ "SearXNG Basic auth requires both searxng.basicUsername and searxng.basicPassword, or SEARXNG_BASIC_USERNAME and SEARXNG_BASIC_PASSWORD.",
130
+ );
131
+ }
132
+ if (basicUsername.includes(":")) {
133
+ throw new Error("SearXNG Basic auth username cannot contain ':' because RFC 7617 uses it as the separator.");
134
+ }
135
+ return { type: "basic", value: buildBasicAuthValue(basicUsername, basicPassword) };
136
+ }
137
+
138
+ const token = findToken();
139
+ return token ? { type: "bearer", value: token } : null;
140
+ }
141
+
86
142
  /** Build the search URL and headers for a SearXNG request */
87
143
  function buildRequest(
88
144
  endpoint: string,
@@ -94,7 +150,7 @@ function buildRequest(
94
150
  language?: string;
95
151
  signal?: AbortSignal;
96
152
  },
97
- token: string | null,
153
+ auth: SearXNGAuth | null,
98
154
  ): { url: URL; headers: Record<string, string> } {
99
155
  const base = endpoint.replace(/\/+$/, "");
100
156
  const url = new URL(`${base}/search`);
@@ -122,8 +178,10 @@ function buildRequest(
122
178
  Accept: "application/json",
123
179
  };
124
180
 
125
- if (token) {
126
- headers.Authorization = `Bearer ${token}`;
181
+ if (auth?.type === "basic") {
182
+ headers.Authorization = `Basic ${auth.value}`;
183
+ } else if (auth?.type === "bearer") {
184
+ headers.Authorization = `Bearer ${auth.value}`;
127
185
  }
128
186
 
129
187
  return { url, headers };
@@ -139,9 +197,9 @@ async function callSearXNGSearch(
139
197
  language?: string;
140
198
  signal?: AbortSignal;
141
199
  },
142
- token: string | null,
200
+ auth: SearXNGAuth | null,
143
201
  ): Promise<SearXNGResponse> {
144
- const { url, headers } = buildRequest(endpoint, params, token);
202
+ const { url, headers } = buildRequest(endpoint, params, auth);
145
203
 
146
204
  const response = await fetch(url, {
147
205
  headers,
@@ -172,7 +230,7 @@ export async function searchSearXNG(params: {
172
230
  );
173
231
  }
174
232
 
175
- const token = findToken();
233
+ const auth = findAuth();
176
234
 
177
235
  let categories: string | undefined;
178
236
  let language: string | undefined;
@@ -190,7 +248,7 @@ export async function searchSearXNG(params: {
190
248
  categories,
191
249
  language,
192
250
  },
193
- token,
251
+ auth,
194
252
  );
195
253
 
196
254
  const sources: SearchSource[] = [];
@@ -1,211 +0,0 @@
1
- /**
2
- * Todo Tool - Demonstrates state management via session entries
3
- *
4
- * This tool stores state in tool result details (not external files),
5
- * which allows proper branching - when you branch, the todo state
6
- * is automatically correct for that point in history.
7
- *
8
- * The onSession callback reconstructs state by scanning past tool results.
9
- */
10
- import type {
11
- CustomTool,
12
- CustomToolContext,
13
- CustomToolFactory,
14
- CustomToolSessionEvent,
15
- } from "@oh-my-pi/pi-coding-agent";
16
-
17
- interface Todo {
18
- id: number;
19
- text: string;
20
- done: boolean;
21
- }
22
-
23
- // State stored in tool result details
24
- interface TodoDetails {
25
- action: "list" | "add" | "toggle" | "clear";
26
- todos: Todo[];
27
- nextId: number;
28
- error?: string;
29
- }
30
-
31
- const factory: CustomToolFactory = pi => {
32
- const { Type } = pi.typebox;
33
- const { StringEnum, Text } = pi.pi;
34
-
35
- // Define schema separately for proper type inference
36
- const TodoParams = Type.Object({
37
- action: StringEnum(["list", "add", "toggle", "clear"] as const),
38
- text: Type.Optional(Type.String({ description: "Todo text (for add)" })),
39
- id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })),
40
- });
41
- // In-memory state (reconstructed from session on load)
42
- let todos: Todo[] = [];
43
- let nextId = 1;
44
-
45
- /**
46
- * Reconstruct state from session entries.
47
- * Scans tool results for this tool and applies them in order.
48
- */
49
- const reconstructState = (_event: CustomToolSessionEvent, ctx: CustomToolContext) => {
50
- todos = [];
51
- nextId = 1;
52
-
53
- // Use getBranch() to get entries on the current branch
54
- for (const entry of ctx.sessionManager.getBranch()) {
55
- if (entry.type !== "message") continue;
56
- const msg = entry.message;
57
-
58
- // Tool results have role "toolResult"
59
- if (msg.role !== "toolResult") continue;
60
- if (msg.toolName !== "todo") continue;
61
-
62
- const details = msg.details as TodoDetails | undefined;
63
- if (details) {
64
- todos = details.todos;
65
- nextId = details.nextId;
66
- }
67
- }
68
- };
69
-
70
- const tool: CustomTool<typeof TodoParams, TodoDetails> = {
71
- name: "todo",
72
- label: "Todo",
73
- description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
74
- parameters: TodoParams,
75
-
76
- // Called on session start/switch/branch/clear
77
- onSession: reconstructState,
78
-
79
- async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
80
- switch (params.action) {
81
- case "list":
82
- return {
83
- content: [
84
- {
85
- type: "text",
86
- text: todos.length
87
- ? todos.map(t => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n")
88
- : "No todos",
89
- },
90
- ],
91
- details: { action: "list", todos: [...todos], nextId },
92
- };
93
-
94
- case "add": {
95
- if (!params.text) {
96
- return {
97
- content: [{ type: "text", text: "Error: text required for add" }],
98
- details: { action: "add", todos: [...todos], nextId, error: "text required" },
99
- };
100
- }
101
- const newTodo: Todo = { id: nextId++, text: params.text, done: false };
102
- todos.push(newTodo);
103
- return {
104
- content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }],
105
- details: { action: "add", todos: [...todos], nextId },
106
- };
107
- }
108
-
109
- case "toggle": {
110
- if (params.id === undefined) {
111
- return {
112
- content: [{ type: "text", text: "Error: id required for toggle" }],
113
- details: { action: "toggle", todos: [...todos], nextId, error: "id required" },
114
- };
115
- }
116
- const todo = todos.find(t => t.id === params.id);
117
- if (!todo) {
118
- return {
119
- content: [{ type: "text", text: `Todo #${params.id} not found` }],
120
- details: { action: "toggle", todos: [...todos], nextId, error: `#${params.id} not found` },
121
- };
122
- }
123
- todo.done = !todo.done;
124
- return {
125
- content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }],
126
- details: { action: "toggle", todos: [...todos], nextId },
127
- };
128
- }
129
-
130
- case "clear": {
131
- const count = todos.length;
132
- todos = [];
133
- nextId = 1;
134
- return {
135
- content: [{ type: "text", text: `Cleared ${count} todos` }],
136
- details: { action: "clear", todos: [], nextId: 1 },
137
- };
138
- }
139
-
140
- default:
141
- return {
142
- content: [{ type: "text", text: `Unknown action: ${params.action}` }],
143
- details: { action: "list", todos: [...todos], nextId, error: `unknown action: ${params.action}` },
144
- };
145
- }
146
- },
147
-
148
- renderCall(args, theme) {
149
- let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", String(args.action));
150
- if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
151
- if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
152
- return new Text(text, 0, 0);
153
- },
154
-
155
- renderResult(result, { expanded }, theme) {
156
- const { details } = result;
157
- if (!details) {
158
- const text = result.content[0];
159
- return new Text(text?.type === "text" ? text.text : "", 0, 0);
160
- }
161
-
162
- // Error
163
- if (details.error) {
164
- return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
165
- }
166
-
167
- const todoList = details.todos;
168
-
169
- switch (details.action) {
170
- case "list": {
171
- if (todoList.length === 0) {
172
- return new Text(theme.fg("dim", "No todos"), 0, 0);
173
- }
174
- let listText = theme.fg("muted", `${todoList.length} todo(s):`);
175
- const display = expanded ? todoList : todoList.slice(0, 5);
176
- for (const t of display) {
177
- const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○");
178
- const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
179
- listText += `\n${check} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
180
- }
181
- if (!expanded && todoList.length > 5) {
182
- listText += `\n${theme.fg("dim", `... ${todoList.length - 5} more`)}`;
183
- }
184
- return new Text(listText, 0, 0);
185
- }
186
-
187
- case "add": {
188
- const added = todoList[todoList.length - 1];
189
- return new Text(
190
- `${theme.fg("success", "✓ Added ") + theme.fg("accent", `#${added.id}`)} ${theme.fg("muted", added.text)}`,
191
- 0,
192
- 0,
193
- );
194
- }
195
-
196
- case "toggle": {
197
- const text = result.content[0];
198
- const msg = text?.type === "text" ? text.text : "";
199
- return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
200
- }
201
-
202
- case "clear":
203
- return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0);
204
- }
205
- },
206
- };
207
-
208
- return tool;
209
- };
210
-
211
- export default factory;
@@ -1,295 +0,0 @@
1
- /**
2
- * Todo Extension - Demonstrates state management via session entries
3
- *
4
- * This extension:
5
- * - Registers a `todo` tool for the LLM to manage todos
6
- * - Registers a `/todos` command for users to view the list
7
- *
8
- * State is stored in tool result details (not external files), which allows
9
- * proper branching - when you branch, the todo state is automatically
10
- * correct for that point in history.
11
- */
12
- import { StringEnum } from "@oh-my-pi/pi-ai";
13
- import type { ExtensionAPI, ExtensionContext, Theme } from "@oh-my-pi/pi-coding-agent";
14
- import { matchesKey, Text, truncateToWidth } from "@oh-my-pi/pi-tui";
15
- import { Type } from "@sinclair/typebox";
16
-
17
- interface Todo {
18
- id: number;
19
- text: string;
20
- done: boolean;
21
- }
22
-
23
- interface TodoDetails {
24
- action: "list" | "add" | "toggle" | "clear";
25
- todos: Todo[];
26
- nextId: number;
27
- error?: string;
28
- }
29
-
30
- const TodoParams = Type.Object({
31
- action: StringEnum(["list", "add", "toggle", "clear"] as const),
32
- text: Type.Optional(Type.String({ description: "Todo text (for add)" })),
33
- id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })),
34
- });
35
-
36
- /**
37
- * UI component for the /todos command
38
- */
39
- class TodoListComponent {
40
- private todos: Todo[];
41
- private theme: Theme;
42
- private onClose: () => void;
43
- private cachedWidth?: number;
44
- private cachedLines?: string[];
45
-
46
- constructor(todos: Todo[], theme: Theme, onClose: () => void) {
47
- this.todos = todos;
48
- this.theme = theme;
49
- this.onClose = onClose;
50
- }
51
-
52
- handleInput(data: string): void {
53
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
54
- this.onClose();
55
- }
56
- }
57
-
58
- render(width: number): string[] {
59
- if (this.cachedLines && this.cachedWidth === width) {
60
- return this.cachedLines;
61
- }
62
-
63
- const lines: string[] = [];
64
- const th = this.theme;
65
-
66
- lines.push("");
67
- const title = th.fg("accent", " Todos ");
68
- const headerLine =
69
- th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10)));
70
- lines.push(truncateToWidth(headerLine, width));
71
- lines.push("");
72
-
73
- if (this.todos.length === 0) {
74
- lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width));
75
- } else {
76
- const done = this.todos.filter(t => t.done).length;
77
- const total = this.todos.length;
78
- lines.push(truncateToWidth(` ${th.fg("muted", `${done}/${total} completed`)}`, width));
79
- lines.push("");
80
-
81
- for (const todo of this.todos) {
82
- const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○");
83
- const id = th.fg("accent", `#${todo.id}`);
84
- const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text);
85
- lines.push(truncateToWidth(` ${check} ${id} ${text}`, width));
86
- }
87
- }
88
-
89
- lines.push("");
90
- lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
91
- lines.push("");
92
-
93
- this.cachedWidth = width;
94
- this.cachedLines = lines;
95
- return lines;
96
- }
97
-
98
- invalidate(): void {
99
- this.cachedWidth = undefined;
100
- this.cachedLines = undefined;
101
- }
102
- }
103
-
104
- export default function (pi: ExtensionAPI) {
105
- // In-memory state (reconstructed from session on load)
106
- let todos: Todo[] = [];
107
- let nextId = 1;
108
-
109
- /**
110
- * Reconstruct state from session entries.
111
- * Scans tool results for this tool and applies them in order.
112
- */
113
- const reconstructState = (ctx: ExtensionContext) => {
114
- todos = [];
115
- nextId = 1;
116
-
117
- for (const entry of ctx.sessionManager.getBranch()) {
118
- if (entry.type !== "message") continue;
119
- const msg = (entry as { message?: { role?: string; toolName?: string; details?: unknown } }).message;
120
- if (!msg || msg.role !== "toolResult" || msg.toolName !== "todo") continue;
121
-
122
- const details = msg.details as TodoDetails | undefined;
123
- if (details) {
124
- todos = details.todos;
125
- nextId = details.nextId;
126
- }
127
- }
128
- };
129
-
130
- // Reconstruct state on session events
131
- pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
132
- pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
133
- pi.on("session_branch", async (_event, ctx) => reconstructState(ctx));
134
- pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
135
-
136
- // Register the todo tool for the LLM
137
- pi.registerTool({
138
- name: "todo",
139
- label: "Todo",
140
- description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
141
- parameters: TodoParams,
142
-
143
- async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
144
- switch (params.action) {
145
- case "list":
146
- return {
147
- content: [
148
- {
149
- type: "text",
150
- text: todos.length
151
- ? todos.map(t => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n")
152
- : "No todos",
153
- },
154
- ],
155
- details: { action: "list", todos: [...todos], nextId } as TodoDetails,
156
- };
157
-
158
- case "add": {
159
- if (!params.text) {
160
- return {
161
- content: [{ type: "text", text: "Error: text required for add" }],
162
- details: { action: "add", todos: [...todos], nextId, error: "text required" } as TodoDetails,
163
- };
164
- }
165
- const newTodo: Todo = { id: nextId++, text: params.text, done: false };
166
- todos.push(newTodo);
167
- return {
168
- content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }],
169
- details: { action: "add", todos: [...todos], nextId } as TodoDetails,
170
- };
171
- }
172
-
173
- case "toggle": {
174
- if (params.id === undefined) {
175
- return {
176
- content: [{ type: "text", text: "Error: id required for toggle" }],
177
- details: { action: "toggle", todos: [...todos], nextId, error: "id required" } as TodoDetails,
178
- };
179
- }
180
- const todo = todos.find(t => t.id === params.id);
181
- if (!todo) {
182
- return {
183
- content: [{ type: "text", text: `Todo #${params.id} not found` }],
184
- details: {
185
- action: "toggle",
186
- todos: [...todos],
187
- nextId,
188
- error: `#${params.id} not found`,
189
- } as TodoDetails,
190
- };
191
- }
192
- todo.done = !todo.done;
193
- return {
194
- content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }],
195
- details: { action: "toggle", todos: [...todos], nextId } as TodoDetails,
196
- };
197
- }
198
-
199
- case "clear": {
200
- const count = todos.length;
201
- todos = [];
202
- nextId = 1;
203
- return {
204
- content: [{ type: "text", text: `Cleared ${count} todos` }],
205
- details: { action: "clear", todos: [], nextId: 1 } as TodoDetails,
206
- };
207
- }
208
-
209
- default:
210
- return {
211
- content: [{ type: "text", text: `Unknown action: ${params.action}` }],
212
- details: {
213
- action: "list",
214
- todos: [...todos],
215
- nextId,
216
- error: `unknown action: ${params.action}`,
217
- } as TodoDetails,
218
- };
219
- }
220
- },
221
-
222
- renderCall(args, theme) {
223
- let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
224
- if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
225
- if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
226
- return new Text(text, 0, 0);
227
- },
228
-
229
- renderResult(result, { expanded }, theme) {
230
- const details = result.details as TodoDetails | undefined;
231
- if (!details) {
232
- const text = result.content[0] as { type: string; text?: string } | undefined;
233
- return new Text(text?.type === "text" && text.text ? text.text : "", 0, 0);
234
- }
235
-
236
- if (details.error) {
237
- return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
238
- }
239
-
240
- const todoList = details.todos;
241
-
242
- switch (details.action) {
243
- case "list": {
244
- if (todoList.length === 0) {
245
- return new Text(theme.fg("dim", "No todos"), 0, 0);
246
- }
247
- let listText = theme.fg("muted", `${todoList.length} todo(s):`);
248
- const display = expanded ? todoList : todoList.slice(0, 5);
249
- for (const t of display) {
250
- const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○");
251
- const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
252
- listText += `\n${check} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
253
- }
254
- if (!expanded && todoList.length > 5) {
255
- listText += `\n${theme.fg("dim", `... ${todoList.length - 5} more`)}`;
256
- }
257
- return new Text(listText, 0, 0);
258
- }
259
-
260
- case "add": {
261
- const added = todoList[todoList.length - 1];
262
- return new Text(
263
- `${theme.fg("success", "✓ Added ") + theme.fg("accent", `#${added.id}`)} ${theme.fg("muted", added.text)}`,
264
- 0,
265
- 0,
266
- );
267
- }
268
-
269
- case "toggle": {
270
- const text = result.content[0] as { type: string; text?: string } | undefined;
271
- const msg = text?.type === "text" && text.text ? text.text : "";
272
- return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
273
- }
274
-
275
- case "clear":
276
- return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0);
277
- }
278
- },
279
- });
280
-
281
- // Register the /todos command for users
282
- pi.registerCommand("todos", {
283
- description: "Show all todos on the current branch",
284
- handler: async (_args, ctx) => {
285
- if (!ctx.hasUI) {
286
- ctx.ui.notify("/todos requires interactive mode", "error");
287
- return;
288
- }
289
-
290
- await ctx.ui.custom<void>((_tui, theme, done) => {
291
- return new TodoListComponent(todos, theme, () => done());
292
- });
293
- },
294
- });
295
- }