@oh-my-pi/pi-coding-agent 4.3.2 → 4.4.5
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 +43 -0
- package/package.json +5 -6
- package/src/core/frontmatter.ts +98 -0
- package/src/core/keybindings.ts +1 -1
- package/src/core/prompt-templates.ts +5 -34
- package/src/core/sdk.ts +3 -0
- package/src/core/skills.ts +3 -3
- package/src/core/slash-commands.ts +14 -5
- package/src/core/tools/calculator.ts +1 -1
- package/src/core/tools/edit.ts +2 -2
- package/src/core/tools/exa/render.ts +23 -11
- package/src/core/tools/index.test.ts +2 -0
- package/src/core/tools/index.ts +3 -0
- package/src/core/tools/jtd-to-json-schema.ts +1 -6
- package/src/core/tools/ls.ts +5 -2
- package/src/core/tools/lsp/config.ts +2 -2
- package/src/core/tools/lsp/render.ts +33 -12
- package/src/core/tools/notebook.ts +1 -1
- package/src/core/tools/output.ts +1 -1
- package/src/core/tools/read.ts +15 -49
- package/src/core/tools/render-utils.ts +61 -24
- package/src/core/tools/renderers.ts +2 -0
- package/src/core/tools/schema-validation.test.ts +501 -0
- package/src/core/tools/task/agents.ts +6 -2
- package/src/core/tools/task/commands.ts +9 -3
- package/src/core/tools/task/discovery.ts +3 -2
- package/src/core/tools/task/render.ts +10 -7
- package/src/core/tools/todo-write.ts +256 -0
- package/src/core/tools/web-fetch.ts +4 -2
- package/src/core/tools/web-scrapers/choosealicense.ts +2 -2
- package/src/core/tools/web-search/render.ts +13 -10
- package/src/core/tools/write.ts +2 -2
- package/src/discovery/builtin.ts +4 -4
- package/src/discovery/cline.ts +4 -3
- package/src/discovery/codex.ts +3 -3
- package/src/discovery/cursor.ts +2 -2
- package/src/discovery/github.ts +3 -2
- package/src/discovery/helpers.test.ts +14 -10
- package/src/discovery/helpers.ts +2 -39
- package/src/discovery/windsurf.ts +3 -3
- package/src/modes/interactive/components/custom-editor.ts +4 -11
- package/src/modes/interactive/components/index.ts +2 -1
- package/src/modes/interactive/components/read-tool-group.ts +118 -0
- package/src/modes/interactive/components/todo-display.ts +112 -0
- package/src/modes/interactive/components/tool-execution.ts +18 -2
- package/src/modes/interactive/controllers/command-controller.ts +2 -2
- package/src/modes/interactive/controllers/event-controller.ts +91 -32
- package/src/modes/interactive/controllers/input-controller.ts +19 -13
- package/src/modes/interactive/interactive-mode.ts +103 -3
- package/src/modes/interactive/theme/theme.ts +4 -0
- package/src/modes/interactive/types.ts +14 -2
- package/src/modes/interactive/utils/ui-helpers.ts +55 -26
- package/src/prompts/system/system-prompt.md +177 -126
- package/src/prompts/tools/todo-write.md +187 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
5
|
+
import type { Component } from "@oh-my-pi/pi-tui";
|
|
6
|
+
import { Text } from "@oh-my-pi/pi-tui";
|
|
7
|
+
import { Type } from "@sinclair/typebox";
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
import type { Theme } from "../../modes/interactive/theme/theme";
|
|
10
|
+
import todoWriteDescription from "../../prompts/tools/todo-write.md" with { type: "text" };
|
|
11
|
+
import type { RenderResultOptions } from "../custom-tools/types";
|
|
12
|
+
import { logger } from "../logger";
|
|
13
|
+
import { renderPromptTemplate } from "../prompt-templates";
|
|
14
|
+
import type { ToolSession } from "../sdk";
|
|
15
|
+
import { ensureArtifactsDir, getArtifactsDir } from "./task/artifacts";
|
|
16
|
+
|
|
17
|
+
const todoWriteSchema = Type.Object({
|
|
18
|
+
todos: Type.Array(
|
|
19
|
+
Type.Object({
|
|
20
|
+
id: Type.Optional(Type.String({ description: "Stable todo id" })),
|
|
21
|
+
content: Type.String({ minLength: 1, description: "Imperative task description (e.g., 'Run tests')" }),
|
|
22
|
+
activeForm: Type.String({ minLength: 1, description: "Present continuous form (e.g., 'Running tests')" }),
|
|
23
|
+
status: Type.Union([Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("completed")]),
|
|
24
|
+
}),
|
|
25
|
+
{ description: "The updated todo list" },
|
|
26
|
+
),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
type TodoStatus = "pending" | "in_progress" | "completed";
|
|
30
|
+
|
|
31
|
+
export interface TodoItem {
|
|
32
|
+
id: string;
|
|
33
|
+
content: string;
|
|
34
|
+
activeForm: string;
|
|
35
|
+
status: TodoStatus;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface TodoFile {
|
|
39
|
+
updatedAt: number;
|
|
40
|
+
todos: TodoItem[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TodoWriteToolDetails {
|
|
44
|
+
todos: TodoItem[];
|
|
45
|
+
updatedAt: number;
|
|
46
|
+
storage: "session" | "memory";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const TODO_FILE_NAME = "todos.json";
|
|
50
|
+
|
|
51
|
+
function normalizeTodoStatus(status?: string): TodoStatus {
|
|
52
|
+
switch (status) {
|
|
53
|
+
case "in_progress":
|
|
54
|
+
return "in_progress";
|
|
55
|
+
case "completed":
|
|
56
|
+
case "done":
|
|
57
|
+
case "complete":
|
|
58
|
+
return "completed";
|
|
59
|
+
default:
|
|
60
|
+
return "pending";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeTodos(
|
|
65
|
+
items: Array<{ id?: string; content?: string; activeForm?: string; status?: string }>,
|
|
66
|
+
): TodoItem[] {
|
|
67
|
+
return items.map((item) => {
|
|
68
|
+
if (!item.content || !item.activeForm) {
|
|
69
|
+
throw new Error("Todo content and activeForm are required.");
|
|
70
|
+
}
|
|
71
|
+
const content = item.content.trim();
|
|
72
|
+
const activeForm = item.activeForm.trim();
|
|
73
|
+
if (!content) {
|
|
74
|
+
throw new Error("Todo content cannot be empty.");
|
|
75
|
+
}
|
|
76
|
+
if (!activeForm) {
|
|
77
|
+
throw new Error("Todo activeForm cannot be empty.");
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
id: item.id && item.id.trim().length > 0 ? item.id : randomUUID(),
|
|
81
|
+
content,
|
|
82
|
+
activeForm,
|
|
83
|
+
status: normalizeTodoStatus(item.status),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateSequentialTodos(todos: TodoItem[]): { valid: boolean; error?: string } {
|
|
89
|
+
if (todos.length === 0) return { valid: true };
|
|
90
|
+
|
|
91
|
+
const firstIncompleteIndex = todos.findIndex((todo) => todo.status !== "completed");
|
|
92
|
+
if (firstIncompleteIndex >= 0) {
|
|
93
|
+
for (let i = firstIncompleteIndex + 1; i < todos.length; i++) {
|
|
94
|
+
if (todos[i].status === "completed") {
|
|
95
|
+
return {
|
|
96
|
+
valid: false,
|
|
97
|
+
error: `Error: Cannot complete "${todos[i].content}" before completing "${todos[firstIncompleteIndex].content}". Todos must be completed sequentially.`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const inProgressIndices = todos.reduce<number[]>((acc, todo, index) => {
|
|
104
|
+
if (todo.status === "in_progress") acc.push(index);
|
|
105
|
+
return acc;
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
if (inProgressIndices.length > 1) {
|
|
109
|
+
return { valid: false, error: "Only one todo can be in progress at a time." };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (inProgressIndices.length === 1 && firstIncompleteIndex >= 0) {
|
|
113
|
+
if (inProgressIndices[0] !== firstIncompleteIndex) {
|
|
114
|
+
return { valid: false, error: "Todo in progress must be the next incomplete item." };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { valid: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function loadTodoFile(filePath: string): Promise<TodoFile | null> {
|
|
122
|
+
const file = Bun.file(filePath);
|
|
123
|
+
if (!(await file.exists())) return null;
|
|
124
|
+
try {
|
|
125
|
+
const text = await file.text();
|
|
126
|
+
const data = JSON.parse(text) as TodoFile;
|
|
127
|
+
if (!data || !Array.isArray(data.todos)) return null;
|
|
128
|
+
return data;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
logger.warn("Failed to read todo file", { path: filePath, error: String(error) });
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function saveTodoFile(filePath: string, data: TodoFile): Promise<void> {
|
|
136
|
+
await Bun.write(filePath, JSON.stringify(data, null, 2));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string {
|
|
140
|
+
const checkbox = uiTheme.checkbox;
|
|
141
|
+
const displayText =
|
|
142
|
+
item.status === "in_progress" && item.activeForm !== item.content ? item.activeForm : item.content;
|
|
143
|
+
switch (item.status) {
|
|
144
|
+
case "completed":
|
|
145
|
+
return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`);
|
|
146
|
+
case "in_progress":
|
|
147
|
+
return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${displayText}`);
|
|
148
|
+
default:
|
|
149
|
+
return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function formatTodoSummary(todos: TodoItem[]): string {
|
|
154
|
+
if (todos.length === 0) return "Todo list cleared.";
|
|
155
|
+
const completed = todos.filter((t) => t.status === "completed").length;
|
|
156
|
+
const inProgress = todos.filter((t) => t.status === "in_progress").length;
|
|
157
|
+
const pending = todos.filter((t) => t.status === "pending").length;
|
|
158
|
+
return `Saved ${todos.length} todos (${pending} pending, ${inProgress} in progress, ${completed} completed).`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function createTodoWriteTool(session: ToolSession): AgentTool<typeof todoWriteSchema, TodoWriteToolDetails> {
|
|
162
|
+
return {
|
|
163
|
+
name: "todo_write",
|
|
164
|
+
label: "Todo Write",
|
|
165
|
+
description: renderPromptTemplate(todoWriteDescription),
|
|
166
|
+
parameters: todoWriteSchema,
|
|
167
|
+
execute: async (
|
|
168
|
+
_toolCallId: string,
|
|
169
|
+
params: { todos: Array<{ id?: string; content?: string; activeForm?: string; status?: string }> },
|
|
170
|
+
) => {
|
|
171
|
+
const todos = normalizeTodos(params.todos ?? []);
|
|
172
|
+
const validation = validateSequentialTodos(todos);
|
|
173
|
+
if (!validation.valid) {
|
|
174
|
+
throw new Error(validation.error ?? "Todos must be completed sequentially.");
|
|
175
|
+
}
|
|
176
|
+
const updatedAt = Date.now();
|
|
177
|
+
|
|
178
|
+
const sessionFile = session.getSessionFile();
|
|
179
|
+
if (!sessionFile) {
|
|
180
|
+
return {
|
|
181
|
+
content: [{ type: "text", text: formatTodoSummary(todos) }],
|
|
182
|
+
details: { todos, updatedAt, storage: "memory" },
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const artifactsDir = getArtifactsDir(sessionFile);
|
|
187
|
+
if (!artifactsDir) {
|
|
188
|
+
return {
|
|
189
|
+
content: [{ type: "text", text: formatTodoSummary(todos) }],
|
|
190
|
+
details: { todos, updatedAt, storage: "memory" },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
ensureArtifactsDir(artifactsDir);
|
|
195
|
+
const todoPath = path.join(artifactsDir, TODO_FILE_NAME);
|
|
196
|
+
const existing = await loadTodoFile(todoPath);
|
|
197
|
+
const storedTodos = existing?.todos ?? [];
|
|
198
|
+
const merged = todos.length > 0 ? todos : [];
|
|
199
|
+
const fileData: TodoFile = { updatedAt, todos: merged };
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
mkdirSync(artifactsDir, { recursive: true });
|
|
203
|
+
await saveTodoFile(todoPath, fileData);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
logger.error("Failed to write todo file", { path: todoPath, error: String(error) });
|
|
206
|
+
return {
|
|
207
|
+
content: [{ type: "text", text: "Failed to save todos." }],
|
|
208
|
+
details: { todos: storedTodos, updatedAt, storage: "session" },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
content: [{ type: "text", text: formatTodoSummary(merged) }],
|
|
214
|
+
details: { todos: merged, updatedAt, storage: "session" },
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// =============================================================================
|
|
221
|
+
// TUI Renderer
|
|
222
|
+
// =============================================================================
|
|
223
|
+
|
|
224
|
+
interface TodoWriteRenderArgs {
|
|
225
|
+
todos?: Array<{ id?: string; content?: string; activeForm?: string; status?: string }>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const todoWriteToolRenderer = {
|
|
229
|
+
renderCall(args: TodoWriteRenderArgs, uiTheme: Theme): Component {
|
|
230
|
+
const count = args.todos?.length ?? 0;
|
|
231
|
+
const summary = count > 0 ? uiTheme.fg("accent", `${count} items`) : uiTheme.fg("toolOutput", "empty");
|
|
232
|
+
return new Text(`${uiTheme.fg("toolTitle", uiTheme.bold("Todo Write"))} ${summary}`, 0, 0);
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
renderResult(
|
|
236
|
+
result: { content: Array<{ type: string; text?: string }>; details?: TodoWriteToolDetails },
|
|
237
|
+
_options: RenderResultOptions,
|
|
238
|
+
uiTheme: Theme,
|
|
239
|
+
_args?: TodoWriteRenderArgs,
|
|
240
|
+
): Component {
|
|
241
|
+
const todos = result.details?.todos ?? [];
|
|
242
|
+
const indent = " ";
|
|
243
|
+
const hook = uiTheme.tree.hook;
|
|
244
|
+
const lines = [indent + uiTheme.bold(uiTheme.fg("accent", "Todos"))];
|
|
245
|
+
|
|
246
|
+
if (todos.length > 0) {
|
|
247
|
+
const visibleTodos = todos;
|
|
248
|
+
visibleTodos.forEach((todo, index) => {
|
|
249
|
+
const prefix = `${indent}${index === 0 ? hook : " "} `;
|
|
250
|
+
lines.push(formatTodoLine(todo, uiTheme, prefix));
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
255
|
+
},
|
|
256
|
+
};
|
|
@@ -12,6 +12,7 @@ import { ensureTool } from "../../utils/tools-manager";
|
|
|
12
12
|
import type { RenderResultOptions } from "../custom-tools/types";
|
|
13
13
|
import { renderPromptTemplate } from "../prompt-templates";
|
|
14
14
|
import type { ToolSession } from "./index";
|
|
15
|
+
import { formatExpandHint } from "./render-utils";
|
|
15
16
|
import { specialHandlers } from "./web-scrapers/index";
|
|
16
17
|
import type { RenderResult } from "./web-scrapers/types";
|
|
17
18
|
import { finalizeOutput, loadPage } from "./web-scrapers/types";
|
|
@@ -947,8 +948,9 @@ export function renderWebFetchResult(
|
|
|
947
948
|
const statusIcon = details.truncated
|
|
948
949
|
? uiTheme.styledSymbol("status.warning", "warning")
|
|
949
950
|
: uiTheme.styledSymbol("status.success", "success");
|
|
950
|
-
const expandHint =
|
|
951
|
-
|
|
951
|
+
const expandHint = formatExpandHint(uiTheme, expanded);
|
|
952
|
+
const expandSuffix = expandHint ? ` ${expandHint}` : "";
|
|
953
|
+
let text = `${statusIcon} ${uiTheme.fg("accent", `(${domain})`)}${uiTheme.sep.dot}${uiTheme.fg("dim", details.method)}${expandSuffix}`;
|
|
952
954
|
|
|
953
955
|
// Get content text
|
|
954
956
|
const contentText = result.content[0]?.text ?? "";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseFrontmatter } from "../../../
|
|
1
|
+
import { parseFrontmatter } from "../../../core/frontmatter";
|
|
2
2
|
import type { RenderResult, SpecialHandler } from "./types";
|
|
3
3
|
import { finalizeOutput, loadPage } from "./types";
|
|
4
4
|
|
|
@@ -69,7 +69,7 @@ export const handleChooseALicense: SpecialHandler = async (
|
|
|
69
69
|
const result = await loadPage(rawUrl, { timeout, headers: { Accept: "text/plain" }, signal });
|
|
70
70
|
if (!result.ok) return null;
|
|
71
71
|
|
|
72
|
-
const { frontmatter, body } = parseFrontmatter(result.content);
|
|
72
|
+
const { frontmatter, body } = parseFrontmatter(result.content, { source: rawUrl });
|
|
73
73
|
|
|
74
74
|
const title = asString(frontmatter.title) ?? formatLabel(licenseSlug);
|
|
75
75
|
const spdxId = asString(frontmatter["spdx-id"]) ?? "Unknown";
|
|
@@ -13,9 +13,9 @@ import {
|
|
|
13
13
|
formatCount,
|
|
14
14
|
formatExpandHint,
|
|
15
15
|
formatMoreItems,
|
|
16
|
+
formatStatusIcon,
|
|
16
17
|
getDomain,
|
|
17
18
|
getPreviewLines,
|
|
18
|
-
getStyledStatusIcon,
|
|
19
19
|
PREVIEW_LIMITS,
|
|
20
20
|
TRUNCATE_LENGTHS,
|
|
21
21
|
truncate,
|
|
@@ -38,8 +38,8 @@ function renderFallbackText(contentText: string, expanded: boolean, theme: Theme
|
|
|
38
38
|
const displayLines = lines.slice(0, maxLines).map((line) => truncate(line.trim(), 110, theme.format.ellipsis));
|
|
39
39
|
const remaining = lines.length - displayLines.length;
|
|
40
40
|
|
|
41
|
-
const headerIcon =
|
|
42
|
-
const expandHint = formatExpandHint(expanded, remaining > 0
|
|
41
|
+
const headerIcon = formatStatusIcon("warning", theme);
|
|
42
|
+
const expandHint = formatExpandHint(theme, expanded, remaining > 0);
|
|
43
43
|
let text = `${headerIcon} ${theme.fg("dim", "Response")}${expandHint}`;
|
|
44
44
|
|
|
45
45
|
if (displayLines.length === 0) {
|
|
@@ -116,14 +116,14 @@ export function renderWebSearchResult(
|
|
|
116
116
|
: provider === "exa"
|
|
117
117
|
? "Exa"
|
|
118
118
|
: "Unknown";
|
|
119
|
-
const headerIcon =
|
|
119
|
+
const headerIcon = formatStatusIcon(sourceCount > 0 ? "success" : "warning", theme);
|
|
120
120
|
const hasMore =
|
|
121
121
|
totalAnswerLines > answerPreview.length ||
|
|
122
122
|
sourceCount > 0 ||
|
|
123
123
|
citationCount > 0 ||
|
|
124
124
|
relatedCount > 0 ||
|
|
125
125
|
searchQueries.length > 0;
|
|
126
|
-
const expandHint = formatExpandHint(expanded, hasMore
|
|
126
|
+
const expandHint = formatExpandHint(theme, expanded, hasMore);
|
|
127
127
|
let text = `${headerIcon} ${theme.fg("dim", `(${providerLabel})`)}${theme.sep.dot}${theme.fg(
|
|
128
128
|
"dim",
|
|
129
129
|
formatCount("source", sourceCount),
|
|
@@ -257,7 +257,10 @@ export function renderWebSearchResult(
|
|
|
257
257
|
}
|
|
258
258
|
if (response.requestId) {
|
|
259
259
|
metaLines.push(
|
|
260
|
-
`${theme.fg("muted", "Request:")} ${theme.fg(
|
|
260
|
+
`${theme.fg("muted", "Request:")} ${theme.fg(
|
|
261
|
+
"text",
|
|
262
|
+
truncate(response.requestId, MAX_REQUEST_ID_LEN, theme.format.ellipsis),
|
|
263
|
+
)}`,
|
|
261
264
|
);
|
|
262
265
|
}
|
|
263
266
|
if (searchQueries.length > 0) {
|
|
@@ -274,22 +277,22 @@ export function renderWebSearchResult(
|
|
|
274
277
|
const sections: Array<{ title: string; icon: string; lines: string[] }> = [
|
|
275
278
|
{
|
|
276
279
|
title: "Answer",
|
|
277
|
-
icon:
|
|
280
|
+
icon: formatStatusIcon("info", theme),
|
|
278
281
|
lines: answerSectionLines,
|
|
279
282
|
},
|
|
280
283
|
{
|
|
281
284
|
title: "Sources",
|
|
282
|
-
icon:
|
|
285
|
+
icon: formatStatusIcon(sourceCount > 0 ? "success" : "warning", theme),
|
|
283
286
|
lines: sourceLines,
|
|
284
287
|
},
|
|
285
288
|
{
|
|
286
289
|
title: "Related",
|
|
287
|
-
icon:
|
|
290
|
+
icon: formatStatusIcon(relatedCount > 0 ? "info" : "warning", theme),
|
|
288
291
|
lines: relatedLines,
|
|
289
292
|
},
|
|
290
293
|
{
|
|
291
294
|
title: "Meta",
|
|
292
|
-
icon:
|
|
295
|
+
icon: formatStatusIcon("info", theme),
|
|
293
296
|
lines: metaLines,
|
|
294
297
|
},
|
|
295
298
|
];
|
package/src/core/tools/write.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type { ToolSession } from "../sdk";
|
|
|
10
10
|
import { untilAborted } from "../utils";
|
|
11
11
|
import { createLspWritethrough, type FileDiagnosticsResult, writethroughNoop } from "./lsp/index";
|
|
12
12
|
import { resolveToCwd } from "./path-utils";
|
|
13
|
-
import { formatDiagnostics, replaceTabs, shortenPath } from "./render-utils";
|
|
13
|
+
import { formatDiagnostics, formatExpandHint, replaceTabs, shortenPath } from "./render-utils";
|
|
14
14
|
|
|
15
15
|
const writeSchema = Type.Object({
|
|
16
16
|
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
|
|
@@ -132,7 +132,7 @@ export const writeToolRenderer = {
|
|
|
132
132
|
outputLines.push(
|
|
133
133
|
uiTheme.fg(
|
|
134
134
|
"toolOutput",
|
|
135
|
-
`${uiTheme.format.ellipsis} (${remaining} more lines, ${totalLines} total) ${uiTheme
|
|
135
|
+
`${uiTheme.format.ellipsis} (${remaining} more lines, ${totalLines} total) ${formatExpandHint(uiTheme)}`,
|
|
136
136
|
),
|
|
137
137
|
);
|
|
138
138
|
}
|
package/src/discovery/builtin.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { type SlashCommand, slashCommandCapability } from "../capability/slash-c
|
|
|
22
22
|
import { type SystemPrompt, systemPromptCapability } from "../capability/system-prompt";
|
|
23
23
|
import { type CustomTool, toolCapability } from "../capability/tool";
|
|
24
24
|
import type { LoadContext, LoadResult } from "../capability/types";
|
|
25
|
+
import { parseFrontmatter } from "../core/frontmatter";
|
|
25
26
|
import {
|
|
26
27
|
createSourceMeta,
|
|
27
28
|
discoverExtensionModulePaths,
|
|
@@ -29,7 +30,6 @@ import {
|
|
|
29
30
|
getExtensionNameFromPath,
|
|
30
31
|
loadFilesFromDir,
|
|
31
32
|
loadSkillsFromDir,
|
|
32
|
-
parseFrontmatter,
|
|
33
33
|
parseJSON,
|
|
34
34
|
SOURCE_PATHS,
|
|
35
35
|
} from "./helpers";
|
|
@@ -266,7 +266,7 @@ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
|
|
|
266
266
|
const result = await loadFilesFromDir<Rule>(ctx, rulesDir, PROVIDER_ID, level, {
|
|
267
267
|
extensions: ["md", "mdc"],
|
|
268
268
|
transform: (name, content, path, source) => {
|
|
269
|
-
const { frontmatter, body } = parseFrontmatter(content);
|
|
269
|
+
const { frontmatter, body } = parseFrontmatter(content, { source: path });
|
|
270
270
|
return {
|
|
271
271
|
name: name.replace(/\.(md|mdc)$/, ""),
|
|
272
272
|
path,
|
|
@@ -516,7 +516,7 @@ async function loadInstructions(ctx: LoadContext): Promise<LoadResult<Instructio
|
|
|
516
516
|
const result = await loadFilesFromDir<Instruction>(ctx, instructionsDir, PROVIDER_ID, level, {
|
|
517
517
|
extensions: ["md"],
|
|
518
518
|
transform: (name, content, path, source) => {
|
|
519
|
-
const { frontmatter, body } = parseFrontmatter(content);
|
|
519
|
+
const { frontmatter, body } = parseFrontmatter(content, { source: path });
|
|
520
520
|
return {
|
|
521
521
|
name: name.replace(/\.instructions\.md$/, "").replace(/\.md$/, ""),
|
|
522
522
|
path,
|
|
@@ -636,7 +636,7 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
|
|
|
636
636
|
_source: source,
|
|
637
637
|
};
|
|
638
638
|
}
|
|
639
|
-
const { frontmatter } = parseFrontmatter(content);
|
|
639
|
+
const { frontmatter } = parseFrontmatter(content, { source: path });
|
|
640
640
|
return {
|
|
641
641
|
name: (frontmatter.name as string) || name.replace(/\.md$/, ""),
|
|
642
642
|
path,
|
package/src/discovery/cline.ts
CHANGED
|
@@ -11,7 +11,8 @@ import { registerProvider } from "../capability/index";
|
|
|
11
11
|
import type { Rule } from "../capability/rule";
|
|
12
12
|
import { ruleCapability } from "../capability/rule";
|
|
13
13
|
import type { LoadContext, LoadResult } from "../capability/types";
|
|
14
|
-
import {
|
|
14
|
+
import { parseFrontmatter } from "../core/frontmatter";
|
|
15
|
+
import { createSourceMeta, loadFilesFromDir } from "./helpers";
|
|
15
16
|
|
|
16
17
|
const PROVIDER_ID = "cline";
|
|
17
18
|
const DISPLAY_NAME = "Cline";
|
|
@@ -54,7 +55,7 @@ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
|
|
|
54
55
|
const result = await loadFilesFromDir(ctx, found.path, PROVIDER_ID, "project", {
|
|
55
56
|
extensions: ["md"],
|
|
56
57
|
transform: (name, content, path, source) => {
|
|
57
|
-
const { frontmatter, body } = parseFrontmatter(content);
|
|
58
|
+
const { frontmatter, body } = parseFrontmatter(content, { source: path });
|
|
58
59
|
const ruleName = name.replace(/\.md$/, "");
|
|
59
60
|
|
|
60
61
|
// Parse globs (can be array or single string)
|
|
@@ -88,7 +89,7 @@ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
|
|
|
88
89
|
return { items, warnings };
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
const { frontmatter, body } = parseFrontmatter(content);
|
|
92
|
+
const { frontmatter, body } = parseFrontmatter(content, { source: found.path });
|
|
92
93
|
const source = createSourceMeta(PROVIDER_ID, found.path, "project");
|
|
93
94
|
|
|
94
95
|
// Parse globs (can be array or single string)
|
package/src/discovery/codex.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { slashCommandCapability } from "../capability/slash-command";
|
|
|
29
29
|
import type { CustomTool } from "../capability/tool";
|
|
30
30
|
import { toolCapability } from "../capability/tool";
|
|
31
31
|
import type { LoadContext, LoadResult } from "../capability/types";
|
|
32
|
+
import { parseFrontmatter } from "../core/frontmatter";
|
|
32
33
|
import { logger } from "../core/logger";
|
|
33
34
|
import {
|
|
34
35
|
createSourceMeta,
|
|
@@ -36,7 +37,6 @@ import {
|
|
|
36
37
|
getExtensionNameFromPath,
|
|
37
38
|
loadFilesFromDir,
|
|
38
39
|
loadSkillsFromDir,
|
|
39
|
-
parseFrontmatter,
|
|
40
40
|
SOURCE_PATHS,
|
|
41
41
|
} from "./helpers";
|
|
42
42
|
|
|
@@ -279,7 +279,7 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
|
|
|
279
279
|
const transformCommand =
|
|
280
280
|
(level: "user" | "project") =>
|
|
281
281
|
(name: string, content: string, path: string, source: ReturnType<typeof createSourceMeta>) => {
|
|
282
|
-
const { frontmatter, body } = parseFrontmatter(content);
|
|
282
|
+
const { frontmatter, body } = parseFrontmatter(content, { source: path });
|
|
283
283
|
const commandName = frontmatter.name || name.replace(/\.md$/, "");
|
|
284
284
|
return {
|
|
285
285
|
name: String(commandName),
|
|
@@ -322,7 +322,7 @@ async function loadPrompts(ctx: LoadContext): Promise<LoadResult<Prompt>> {
|
|
|
322
322
|
path: string,
|
|
323
323
|
source: ReturnType<typeof createSourceMeta>,
|
|
324
324
|
) => {
|
|
325
|
-
const { frontmatter, body } = parseFrontmatter(content);
|
|
325
|
+
const { frontmatter, body } = parseFrontmatter(content, { source: path });
|
|
326
326
|
const promptName = frontmatter.name || name.replace(/\.md$/, "");
|
|
327
327
|
return {
|
|
328
328
|
name: String(promptName),
|
package/src/discovery/cursor.ts
CHANGED
|
@@ -22,13 +22,13 @@ import { ruleCapability } from "../capability/rule";
|
|
|
22
22
|
import type { Settings } from "../capability/settings";
|
|
23
23
|
import { settingsCapability } from "../capability/settings";
|
|
24
24
|
import type { LoadContext, LoadResult } from "../capability/types";
|
|
25
|
+
import { parseFrontmatter } from "../core/frontmatter";
|
|
25
26
|
import {
|
|
26
27
|
createSourceMeta,
|
|
27
28
|
expandEnvVarsDeep,
|
|
28
29
|
getProjectPath,
|
|
29
30
|
getUserPath,
|
|
30
31
|
loadFilesFromDir,
|
|
31
|
-
parseFrontmatter,
|
|
32
32
|
parseJSON,
|
|
33
33
|
} from "./helpers";
|
|
34
34
|
|
|
@@ -143,7 +143,7 @@ function transformMDCRule(
|
|
|
143
143
|
path: string,
|
|
144
144
|
source: ReturnType<typeof createSourceMeta>,
|
|
145
145
|
): Rule {
|
|
146
|
-
const { frontmatter, body } = parseFrontmatter(content);
|
|
146
|
+
const { frontmatter, body } = parseFrontmatter(content, { source: path });
|
|
147
147
|
|
|
148
148
|
// Extract frontmatter fields
|
|
149
149
|
const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined;
|
package/src/discovery/github.ts
CHANGED
|
@@ -18,7 +18,8 @@ import { readFile } from "../capability/fs";
|
|
|
18
18
|
import { registerProvider } from "../capability/index";
|
|
19
19
|
import { type Instruction, instructionCapability } from "../capability/instruction";
|
|
20
20
|
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
|
|
21
|
-
import {
|
|
21
|
+
import { parseFrontmatter } from "../core/frontmatter";
|
|
22
|
+
import { calculateDepth, createSourceMeta, getProjectPath, loadFilesFromDir } from "./helpers";
|
|
22
23
|
|
|
23
24
|
const PROVIDER_ID = "github";
|
|
24
25
|
const DISPLAY_NAME = "GitHub Copilot";
|
|
@@ -79,7 +80,7 @@ function transformInstruction(name: string, content: string, path: string, sourc
|
|
|
79
80
|
return null;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
const { frontmatter, body } = parseFrontmatter(content);
|
|
83
|
+
const { frontmatter, body } = parseFrontmatter(content, { source: path });
|
|
83
84
|
|
|
84
85
|
// Extract applyTo glob pattern from frontmatter
|
|
85
86
|
const applyTo = typeof frontmatter.applyTo === "string" ? frontmatter.applyTo : undefined;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { parseFrontmatter } from "
|
|
2
|
+
import { parseFrontmatter } from "../core/frontmatter";
|
|
3
3
|
|
|
4
4
|
describe("parseFrontmatter", () => {
|
|
5
|
+
const parse = (content: string) => parseFrontmatter(content, { source: "tests:frontmatter", level: "off" });
|
|
6
|
+
|
|
5
7
|
test("parses simple key-value pairs", () => {
|
|
6
8
|
const content = `---
|
|
7
9
|
name: test
|
|
@@ -9,7 +11,7 @@ enabled: true
|
|
|
9
11
|
---
|
|
10
12
|
Body content`;
|
|
11
13
|
|
|
12
|
-
const result =
|
|
14
|
+
const result = parse(content);
|
|
13
15
|
expect(result.frontmatter).toEqual({ name: "test", enabled: true });
|
|
14
16
|
expect(result.body).toBe("Body content");
|
|
15
17
|
});
|
|
@@ -23,7 +25,7 @@ tags:
|
|
|
23
25
|
---
|
|
24
26
|
Body content`;
|
|
25
27
|
|
|
26
|
-
const result =
|
|
28
|
+
const result = parse(content);
|
|
27
29
|
expect(result.frontmatter).toEqual({
|
|
28
30
|
tags: ["javascript", "typescript", "react"],
|
|
29
31
|
});
|
|
@@ -39,7 +41,7 @@ description: |
|
|
|
39
41
|
---
|
|
40
42
|
Body content`;
|
|
41
43
|
|
|
42
|
-
const result =
|
|
44
|
+
const result = parse(content);
|
|
43
45
|
expect(result.frontmatter).toEqual({
|
|
44
46
|
description: "This is a multi-line\ndescription block\nwith several lines\n",
|
|
45
47
|
});
|
|
@@ -57,7 +59,7 @@ config:
|
|
|
57
59
|
---
|
|
58
60
|
Body content`;
|
|
59
61
|
|
|
60
|
-
const result =
|
|
62
|
+
const result = parse(content);
|
|
61
63
|
expect(result.frontmatter).toEqual({
|
|
62
64
|
config: {
|
|
63
65
|
server: { port: 3000, host: "localhost" },
|
|
@@ -83,7 +85,7 @@ description: |
|
|
|
83
85
|
---
|
|
84
86
|
Body content`;
|
|
85
87
|
|
|
86
|
-
const result =
|
|
88
|
+
const result = parse(content);
|
|
87
89
|
expect(result.frontmatter).toEqual({
|
|
88
90
|
name: "complex-test",
|
|
89
91
|
version: "1.0.0",
|
|
@@ -99,7 +101,7 @@ Body content`;
|
|
|
99
101
|
|
|
100
102
|
test("handles missing frontmatter", () => {
|
|
101
103
|
const content = "Just body content";
|
|
102
|
-
const result =
|
|
104
|
+
const result = parse(content);
|
|
103
105
|
expect(result.frontmatter).toEqual({});
|
|
104
106
|
expect(result.body).toBe("Just body content");
|
|
105
107
|
});
|
|
@@ -110,8 +112,10 @@ invalid: [unclosed array
|
|
|
110
112
|
---
|
|
111
113
|
Body content`;
|
|
112
114
|
|
|
113
|
-
const result =
|
|
114
|
-
|
|
115
|
+
const result = parse(content);
|
|
116
|
+
// Simple fallback parser extracts key:value pairs it can parse
|
|
117
|
+
expect(result.frontmatter).toEqual({ invalid: "[unclosed array" });
|
|
118
|
+
// Body is still extracted even with invalid YAML
|
|
115
119
|
expect(result.body).toBe("Body content");
|
|
116
120
|
});
|
|
117
121
|
|
|
@@ -120,7 +124,7 @@ Body content`;
|
|
|
120
124
|
---
|
|
121
125
|
Body content`;
|
|
122
126
|
|
|
123
|
-
const result =
|
|
127
|
+
const result = parse(content);
|
|
124
128
|
expect(result.frontmatter).toEqual({});
|
|
125
129
|
expect(result.body).toBe("Body content");
|
|
126
130
|
});
|