@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.
- package/CHANGELOG.md +50 -0
- package/package.json +7 -15
- package/scripts/build-binary.ts +1 -1
- package/src/cli/update-cli.ts +25 -1
- package/src/config/model-registry.ts +21 -19
- package/src/config/settings-schema.ts +11 -16
- package/src/discovery/claude-plugins.ts +28 -3
- package/src/edit/modes/atom.ts +50 -19
- package/src/edit/modes/hashline.ts +171 -110
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +14 -2
- package/src/extensibility/extensions/runner.ts +34 -1
- package/src/extensibility/extensions/types.ts +8 -0
- package/src/internal-urls/docs-index.generated.ts +54 -54
- package/src/lsp/client.ts +27 -35
- package/src/memories/index.ts +5 -0
- package/src/modes/components/settings-defs.ts +1 -1
- package/src/modes/controllers/selector-controller.ts +2 -2
- package/src/modes/controllers/todo-command-controller.ts +22 -74
- package/src/modes/interactive-mode.ts +36 -9
- package/src/modes/theme/theme.ts +10 -1
- package/src/modes/types.ts +1 -3
- package/src/modes/utils/ui-helpers.ts +19 -6
- package/src/prompts/system/auto-continue.md +1 -0
- package/src/prompts/system/eager-todo.md +1 -1
- package/src/prompts/tools/github.md +3 -3
- package/src/prompts/tools/todo-write.md +19 -19
- package/src/sdk.ts +13 -2
- package/src/session/agent-session.ts +196 -96
- package/src/session/session-manager.ts +19 -2
- package/src/tools/bash.ts +9 -4
- package/src/tools/gh.ts +267 -119
- package/src/tools/todo-write.ts +157 -195
- package/src/utils/git.ts +61 -2
- package/src/web/search/providers/searxng.ts +71 -13
- package/examples/custom-tools/todo/index.ts +0 -211
- 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
|
|
13
|
-
* searxng.token
|
|
14
|
-
* searxng.
|
|
15
|
-
* searxng.
|
|
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
|
|
19
|
-
* SEARXNG_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
|
-
|
|
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 (
|
|
126
|
-
headers.Authorization = `
|
|
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
|
-
|
|
200
|
+
auth: SearXNGAuth | null,
|
|
143
201
|
): Promise<SearXNGResponse> {
|
|
144
|
-
const { url, headers } = buildRequest(endpoint, params,
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|