@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +5 -6
  3. package/src/core/frontmatter.ts +98 -0
  4. package/src/core/keybindings.ts +1 -1
  5. package/src/core/prompt-templates.ts +5 -34
  6. package/src/core/sdk.ts +3 -0
  7. package/src/core/skills.ts +3 -3
  8. package/src/core/slash-commands.ts +14 -5
  9. package/src/core/tools/calculator.ts +1 -1
  10. package/src/core/tools/edit.ts +2 -2
  11. package/src/core/tools/exa/render.ts +23 -11
  12. package/src/core/tools/index.test.ts +2 -0
  13. package/src/core/tools/index.ts +3 -0
  14. package/src/core/tools/jtd-to-json-schema.ts +1 -6
  15. package/src/core/tools/ls.ts +5 -2
  16. package/src/core/tools/lsp/config.ts +2 -2
  17. package/src/core/tools/lsp/render.ts +33 -12
  18. package/src/core/tools/notebook.ts +1 -1
  19. package/src/core/tools/output.ts +1 -1
  20. package/src/core/tools/read.ts +15 -49
  21. package/src/core/tools/render-utils.ts +61 -24
  22. package/src/core/tools/renderers.ts +2 -0
  23. package/src/core/tools/schema-validation.test.ts +501 -0
  24. package/src/core/tools/task/agents.ts +6 -2
  25. package/src/core/tools/task/commands.ts +9 -3
  26. package/src/core/tools/task/discovery.ts +3 -2
  27. package/src/core/tools/task/render.ts +10 -7
  28. package/src/core/tools/todo-write.ts +256 -0
  29. package/src/core/tools/web-fetch.ts +4 -2
  30. package/src/core/tools/web-scrapers/choosealicense.ts +2 -2
  31. package/src/core/tools/web-search/render.ts +13 -10
  32. package/src/core/tools/write.ts +2 -2
  33. package/src/discovery/builtin.ts +4 -4
  34. package/src/discovery/cline.ts +4 -3
  35. package/src/discovery/codex.ts +3 -3
  36. package/src/discovery/cursor.ts +2 -2
  37. package/src/discovery/github.ts +3 -2
  38. package/src/discovery/helpers.test.ts +14 -10
  39. package/src/discovery/helpers.ts +2 -39
  40. package/src/discovery/windsurf.ts +3 -3
  41. package/src/modes/interactive/components/custom-editor.ts +4 -11
  42. package/src/modes/interactive/components/index.ts +2 -1
  43. package/src/modes/interactive/components/read-tool-group.ts +118 -0
  44. package/src/modes/interactive/components/todo-display.ts +112 -0
  45. package/src/modes/interactive/components/tool-execution.ts +18 -2
  46. package/src/modes/interactive/controllers/command-controller.ts +2 -2
  47. package/src/modes/interactive/controllers/event-controller.ts +91 -32
  48. package/src/modes/interactive/controllers/input-controller.ts +19 -13
  49. package/src/modes/interactive/interactive-mode.ts +103 -3
  50. package/src/modes/interactive/theme/theme.ts +4 -0
  51. package/src/modes/interactive/types.ts +14 -2
  52. package/src/modes/interactive/utils/ui-helpers.ts +55 -26
  53. package/src/prompts/system/system-prompt.md +177 -126
  54. package/src/prompts/tools/todo-write.md +187 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,49 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [4.4.5] - 2026-01-11
6
+
7
+ ### Changed
8
+
9
+ - Removed `format: "date-time"` from timestamp type conversion in JTD to JSON Schema transformation
10
+ - Reorganized system prompt to display context, environment, and tools sections before discipline guidelines
11
+ - Updated system prompt to show file paths more clearly in output
12
+ - Improved YAML frontmatter parsing with better error messages including source file information
13
+
14
+ ### Fixed
15
+
16
+ - Fixed frontmatter parsing to properly report source location when YAML parsing fails
17
+
18
+ ## [4.4.4] - 2026-01-11
19
+ ### Added
20
+
21
+ - Added `todo_write` tool for creating and managing structured task lists during coding sessions
22
+ - Added persistent todo panel above the editor that displays task progress
23
+ - Added `Ctrl+T` keybinding to toggle todo list expansion
24
+ - Added grouped display for consecutive Read tool calls, showing multiple file reads in a compact tree view
25
+ - Added `todo_write` tool and persistent todo panel above the editor
26
+
27
+ ### Changed
28
+
29
+ - Changed `Ctrl+Enter` to insert a newline when not streaming (previously `Alt+Enter`)
30
+ - Changed `Ctrl+T` from toggling thinking block visibility to toggling todo list expansion
31
+ - Changed system prompt to use more direct, field-oriented language with emphasis on verification and assumptions
32
+ - Changed temporary model selector keybinding from Ctrl+Y to Alt+P
33
+ - Changed expand hint text from "Ctrl+O to expand" to "Ctrl+O for more"
34
+ - Changed Read tool result display to hide content by default, showing only file path and status
35
+ - Changed `Ctrl+T` to toggle todo panel expansion
36
+
37
+ ### Removed
38
+
39
+ - Removed `yaml` package dependency in favor of Bun's built-in YAML parser
40
+
41
+ ### Fixed
42
+
43
+ - Fixed Alt+Enter to insert a newline when not streaming, instead of submitting the message
44
+ - Fixed Alt+Enter inserting a new line when not streaming instead of submitting a message
45
+ - Fixed Cursor provider to avoid advertising the Edit tool, relying on full-file Write operations instead
46
+ - Fixed prompt template loading to strip leading HTML comment metadata blocks
47
+
5
48
  ## [4.3.2] - 2026-01-11
6
49
  ### Changed
7
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "4.3.2",
3
+ "version": "4.4.5",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,10 +39,10 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "4.3.2",
43
- "@oh-my-pi/pi-agent-core": "4.3.2",
44
- "@oh-my-pi/pi-git-tool": "4.3.2",
45
- "@oh-my-pi/pi-tui": "4.3.2",
42
+ "@oh-my-pi/pi-ai": "4.4.5",
43
+ "@oh-my-pi/pi-agent-core": "4.4.5",
44
+ "@oh-my-pi/pi-git-tool": "4.4.5",
45
+ "@oh-my-pi/pi-tui": "4.4.5",
46
46
  "@openai/agents": "^0.3.7",
47
47
  "@sinclair/typebox": "^0.34.46",
48
48
  "ajv": "^8.17.1",
@@ -63,7 +63,6 @@
63
63
  "strip-ansi": "^7.1.2",
64
64
  "winston": "^3.17.0",
65
65
  "winston-daily-rotate-file": "^5.0.0",
66
- "yaml": "^2.8.2",
67
66
  "zod": "^4.3.5"
68
67
  },
69
68
  "devDependencies": {
@@ -0,0 +1,98 @@
1
+ import { YAML } from "bun";
2
+ import { logger } from "./logger";
3
+
4
+ function stripHtmlComments(content: string): string {
5
+ return content.replace(/<!--[\s\S]*?-->/g, "");
6
+ }
7
+
8
+ function toError(value: unknown): Error {
9
+ return value instanceof Error ? value : new Error(String(`YAML: ${value}`));
10
+ }
11
+
12
+ function truncate(content: string, maxLength: number): string {
13
+ return content.length > maxLength ? `${content.slice(0, maxLength)}...` : content;
14
+ }
15
+
16
+ export class FrontmatterError extends Error {
17
+ constructor(
18
+ error: Error,
19
+ public readonly source?: unknown,
20
+ ) {
21
+ super(`Failed to parse YAML frontmatter: ${error.message}`, { cause: error });
22
+ this.name = "FrontmatterError";
23
+ }
24
+
25
+ toString(): string {
26
+ // Format the error with stack and detail, including the error message, stack, and source if present
27
+ const details: string[] = [this.message];
28
+ if (this.source !== undefined) {
29
+ details.push(`Source: ${JSON.stringify(this.source)}`);
30
+ }
31
+ if (this.cause && typeof this.cause === "object" && "stack" in this.cause && this.cause.stack) {
32
+ details.push(`Stack:\n${this.cause.stack}`);
33
+ } else if (this.stack) {
34
+ details.push(`Stack:\n${this.stack}`);
35
+ }
36
+ return details.join("\n\n");
37
+ }
38
+ }
39
+
40
+ export interface FrontmatterOptions {
41
+ /** Source of the content */
42
+ source?: unknown;
43
+ /** Fallback frontmatter values */
44
+ fallback?: Record<string, unknown>;
45
+ /** Normalize the content */
46
+ normalize?: boolean;
47
+ /** Level of error handling */
48
+ level?: "off" | "warn" | "fatal";
49
+ }
50
+
51
+ /**
52
+ * Parse YAML frontmatter from markdown content
53
+ * Returns { frontmatter, body } where body has frontmatter stripped
54
+ */
55
+ export function parseFrontmatter(
56
+ content: string,
57
+ options?: FrontmatterOptions,
58
+ ): { frontmatter: Record<string, unknown>; body: string } {
59
+ const { source, fallback, normalize = true, level = "warn" } = options ?? {};
60
+ const frontmatter: Record<string, unknown> = Object.assign({}, fallback);
61
+
62
+ const normalized = normalize ? stripHtmlComments(content.replace(/\r\n/g, "\n").replace(/\r/g, "\n")) : content;
63
+ if (!normalized.startsWith("---")) {
64
+ return { frontmatter, body: normalized };
65
+ }
66
+
67
+ const endIndex = normalized.indexOf("\n---", 3);
68
+ if (endIndex === -1) {
69
+ return { frontmatter, body: normalized };
70
+ }
71
+
72
+ const metadata = normalized.slice(4, endIndex);
73
+ const body = normalized.slice(endIndex + 4).trim();
74
+
75
+ try {
76
+ // Replace tabs with spaces for YAML compatibility, use failsafe mode for robustness
77
+ const loaded = YAML.parse(metadata.replaceAll("\t", " ")) as Record<string, unknown> | null;
78
+ return { frontmatter: Object.assign(frontmatter, loaded), body: body };
79
+ } catch (error) {
80
+ const err = new FrontmatterError(toError(error), source ?? `Inline '${truncate(content, 64)}'`);
81
+ if (level === "warn" || level === "fatal") {
82
+ logger.warn("Failed to parse YAML frontmatter", { err: err.toString() });
83
+ }
84
+ if (level === "fatal") {
85
+ throw err;
86
+ }
87
+
88
+ // Simple YAML parsing - just key: value pairs
89
+ for (const line of metadata.split("\n")) {
90
+ const match = line.match(/^(\w+):\s*(.*)$/);
91
+ if (match) {
92
+ frontmatter[match[1]] = match[2].trim();
93
+ }
94
+ }
95
+
96
+ return { frontmatter, body: body };
97
+ }
98
+ }
@@ -59,7 +59,7 @@ export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
59
59
  expandTools: "ctrl+o",
60
60
  toggleThinking: "ctrl+t",
61
61
  externalEditor: "ctrl+g",
62
- followUp: "alt+enter",
62
+ followUp: "ctrl+enter",
63
63
  dequeue: "alt+up",
64
64
  };
65
65
 
@@ -1,6 +1,7 @@
1
1
  import { join, resolve } from "node:path";
2
2
  import Handlebars from "handlebars";
3
3
  import { CONFIG_DIR_NAME, getPromptsDir } from "../config";
4
+ import { parseFrontmatter } from "./frontmatter";
4
5
  import { logger } from "./logger";
5
6
 
6
7
  /**
@@ -280,36 +281,6 @@ function optimizePromptLayout(input: string): string {
280
281
  return s.trim();
281
282
  }
282
283
 
283
- /**
284
- * Parse YAML frontmatter from markdown content
285
- * Returns { frontmatter, content } where content has frontmatter stripped
286
- */
287
- function parseFrontmatter(content: string): { frontmatter: Record<string, string>; content: string } {
288
- const frontmatter: Record<string, string> = {};
289
-
290
- if (!content.startsWith("---")) {
291
- return { frontmatter, content };
292
- }
293
-
294
- const endIndex = content.indexOf("\n---", 3);
295
- if (endIndex === -1) {
296
- return { frontmatter, content };
297
- }
298
-
299
- const frontmatterBlock = content.slice(4, endIndex);
300
- const remainingContent = content.slice(endIndex + 4).trim();
301
-
302
- // Simple YAML parsing - just key: value pairs
303
- for (const line of frontmatterBlock.split("\n")) {
304
- const match = line.match(/^(\w+):\s*(.*)$/);
305
- if (match) {
306
- frontmatter[match[1]] = match[2].trim();
307
- }
308
- }
309
-
310
- return { frontmatter, content: remainingContent };
311
- }
312
-
313
284
  /**
314
285
  * Parse command arguments respecting quoted strings (bash-style)
315
286
  * Returns array of arguments
@@ -413,7 +384,7 @@ async function loadTemplatesFromDir(
413
384
 
414
385
  if (entry.endsWith(".md")) {
415
386
  const rawContent = await file.text();
416
- const { frontmatter, content } = parseFrontmatter(rawContent);
387
+ const { frontmatter, body } = parseFrontmatter(rawContent, { source: fullPath });
417
388
 
418
389
  const name = entry.split("/").pop()!.slice(0, -3); // Remove .md extension
419
390
 
@@ -429,9 +400,9 @@ async function loadTemplatesFromDir(
429
400
  }
430
401
 
431
402
  // Get description from frontmatter or first non-empty line
432
- let description = frontmatter.description || "";
403
+ let description = String(frontmatter.description || "");
433
404
  if (!description) {
434
- const firstLine = content.split("\n").find((line) => line.trim());
405
+ const firstLine = body.split("\n").find((line) => line.trim());
435
406
  if (firstLine) {
436
407
  // Truncate if too long
437
408
  description = firstLine.slice(0, 60);
@@ -445,7 +416,7 @@ async function loadTemplatesFromDir(
445
416
  templates.push({
446
417
  name,
447
418
  description,
448
- content,
419
+ content: body,
449
420
  source: sourceStr,
450
421
  });
451
422
  }
package/src/core/sdk.ts CHANGED
@@ -855,6 +855,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
855
855
  toolRegistry.set(tool.name, wrapToolWithExtensions(tool, extensionRunner));
856
856
  }
857
857
  }
858
+ if (model?.provider === "cursor") {
859
+ toolRegistry.delete("edit");
860
+ }
858
861
  time("combineTools");
859
862
 
860
863
  let cursorEventEmitter: ((event: AgentEvent) => void) | undefined;
@@ -6,7 +6,7 @@ import { skillCapability } from "../capability/skill";
6
6
  import type { SourceMeta } from "../capability/types";
7
7
  import type { Skill as CapabilitySkill, SkillFrontmatter as ImportedSkillFrontmatter } from "../discovery";
8
8
  import { loadCapability } from "../discovery";
9
- import { parseFrontmatter } from "../discovery/helpers";
9
+ import { parseFrontmatter } from "./frontmatter";
10
10
  import { logger } from "./logger";
11
11
  import type { SkillsSettings } from "./settings-manager";
12
12
 
@@ -54,7 +54,7 @@ export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkills
54
54
  if (seenPaths.has(skillFile)) return;
55
55
  try {
56
56
  const content = readFileSync(skillFile, "utf-8");
57
- const { frontmatter } = parseFrontmatter(content);
57
+ const { frontmatter } = parseFrontmatter(content, { source: skillFile });
58
58
  const name = (frontmatter.name as string) || dirName;
59
59
  const description = frontmatter.description as string;
60
60
 
@@ -118,7 +118,7 @@ function scanDirectoryForSkills(dir: string): LoadSkillsResult {
118
118
  if (seenPaths.has(skillFile)) return;
119
119
  try {
120
120
  const content = readFileSync(skillFile, "utf-8");
121
- const { frontmatter } = parseFrontmatter(content);
121
+ const { frontmatter } = parseFrontmatter(content, { source: skillFile });
122
122
  const name = (frontmatter.name as string) || dirName;
123
123
  const description = frontmatter.description as string;
124
124
 
@@ -1,7 +1,7 @@
1
1
  import { slashCommandCapability } from "../capability/slash-command";
2
2
  import type { SlashCommand } from "../discovery";
3
3
  import { loadCapability } from "../discovery";
4
- import { parseFrontmatter } from "../discovery/helpers";
4
+ import { parseFrontmatter } from "./frontmatter";
5
5
  import { renderPromptTemplate } from "./prompt-templates";
6
6
  import { EMBEDDED_COMMAND_TEMPLATES } from "./tools/task/commands";
7
7
 
@@ -19,8 +19,11 @@ export interface FileSlashCommand {
19
19
 
20
20
  const EMBEDDED_SLASH_COMMANDS = EMBEDDED_COMMAND_TEMPLATES;
21
21
 
22
- function parseCommandTemplate(content: string): { description: string; body: string } {
23
- const { frontmatter, body } = parseFrontmatter(content);
22
+ function parseCommandTemplate(
23
+ content: string,
24
+ options: { source: string; level?: "off" | "warn" | "fatal" },
25
+ ): { description: string; body: string } {
26
+ const { frontmatter, body } = parseFrontmatter(content, options);
24
27
  const frontmatterDesc = typeof frontmatter.description === "string" ? frontmatter.description.trim() : "";
25
28
 
26
29
  // Get description from frontmatter or first non-empty line
@@ -112,7 +115,10 @@ export async function loadSlashCommands(options: LoadSlashCommandsOptions = {}):
112
115
  const result = await loadCapability<SlashCommand>(slashCommandCapability.id, { cwd: options.cwd });
113
116
 
114
117
  const fileCommands: FileSlashCommand[] = result.items.map((cmd) => {
115
- const { description, body } = parseCommandTemplate(cmd.content);
118
+ const { description, body } = parseCommandTemplate(cmd.content, {
119
+ source: cmd.path ?? `slash-command:${cmd.name}`,
120
+ level: cmd.level === "native" ? "fatal" : "warn",
121
+ });
116
122
 
117
123
  // Format source label: "via ProviderName Level"
118
124
  const capitalizedLevel = cmd.level.charAt(0).toUpperCase() + cmd.level.slice(1);
@@ -132,7 +138,10 @@ export async function loadSlashCommands(options: LoadSlashCommandsOptions = {}):
132
138
  const name = cmd.name.replace(/\.md$/, "");
133
139
  if (seenNames.has(name)) continue;
134
140
 
135
- const { description, body } = parseCommandTemplate(cmd.content);
141
+ const { description, body } = parseCommandTemplate(cmd.content, {
142
+ source: `embedded:${cmd.name}`,
143
+ level: "fatal",
144
+ });
136
145
  fileCommands.push({
137
146
  name,
138
147
  description,
@@ -478,7 +478,7 @@ export const calculatorToolRenderer = {
478
478
  const hasMore = outputs.length > maxItems;
479
479
  const icon = uiTheme.styledSymbol("status.success", "success");
480
480
  const summary = uiTheme.fg("dim", formatCount("result", outputs.length));
481
- const expandHint = formatExpandHint(expanded, hasMore, uiTheme);
481
+ const expandHint = formatExpandHint(uiTheme, expanded, hasMore);
482
482
  let text = `${icon} ${summary}${expandHint}`;
483
483
 
484
484
  // Render each result as a tree branch
@@ -21,7 +21,7 @@ import {
21
21
  import type { ToolSession } from "./index";
22
22
  import { createLspWritethrough, type FileDiagnosticsResult, writethroughNoop } from "./lsp/index";
23
23
  import { resolveToCwd } from "./path-utils";
24
- import { createToolUIKit, getDiffStats, shortenPath, truncateDiffByHunk } from "./render-utils";
24
+ import { createToolUIKit, formatExpandHint, getDiffStats, shortenPath, truncateDiffByHunk } from "./render-utils";
25
25
 
26
26
  const editSchema = Type.Object({
27
27
  path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
@@ -308,7 +308,7 @@ export const editToolRenderer = {
308
308
  if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
309
309
  text += uiTheme.fg(
310
310
  "toolOutput",
311
- `\n${uiTheme.format.ellipsis} (${remainder.join(", ")}) ${ui.wrapBrackets("Ctrl+O to expand")}`,
311
+ `\n${uiTheme.format.ellipsis} (${remainder.join(", ")}) ${formatExpandHint(uiTheme)}`,
312
312
  );
313
313
  }
314
314
  }
@@ -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,
@@ -32,14 +32,14 @@ const MAX_HIGHLIGHT_LEN = TRUNCATE_LENGTHS.CONTENT;
32
32
  function renderErrorMessage(message: string, theme: Theme): Text {
33
33
  const clean = message.replace(/^Error:\s*/, "").trim();
34
34
  return new Text(
35
- `${getStyledStatusIcon("error", theme)} ${theme.fg("error", `Error: ${clean || "Unknown error"}`)}`,
35
+ `${formatStatusIcon("error", theme)} ${theme.fg("error", `Error: ${clean || "Unknown error"}`)}`,
36
36
  0,
37
37
  0,
38
38
  );
39
39
  }
40
40
 
41
41
  function renderEmptyMessage(message: string, theme: Theme): Text {
42
- return new Text(`${getStyledStatusIcon("warning", theme)} ${theme.fg("muted", message)}`, 0, 0);
42
+ return new Text(`${formatStatusIcon("warning", theme)} ${theme.fg("muted", message)}`, 0, 0);
43
43
  }
44
44
 
45
45
  /** Render Exa result with tree-based layout */
@@ -64,9 +64,9 @@ export function renderExaResult(
64
64
  const maxLines = expanded ? rawLines.length : Math.min(rawLines.length, COLLAPSED_PREVIEW_LINES);
65
65
  const displayLines = rawLines.slice(0, maxLines);
66
66
  const remaining = rawLines.length - maxLines;
67
- const expandHint = formatExpandHint(expanded, remaining > 0, uiTheme);
67
+ const expandHint = formatExpandHint(uiTheme, expanded, remaining > 0);
68
68
 
69
- let text = `${getStyledStatusIcon("info", uiTheme)} ${uiTheme.fg("dim", "Raw response")}${expandHint}`;
69
+ let text = `${formatStatusIcon("info", uiTheme)} ${uiTheme.fg("dim", "Raw response")}${expandHint}`;
70
70
 
71
71
  for (let i = 0; i < displayLines.length; i++) {
72
72
  const isLast = i === displayLines.length - 1 && remaining === 0;
@@ -78,7 +78,10 @@ export function renderExaResult(
78
78
  }
79
79
 
80
80
  if (remaining > 0) {
81
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", formatMoreItems(remaining, "line", uiTheme))}`;
81
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
82
+ "muted",
83
+ formatMoreItems(remaining, "line", uiTheme),
84
+ )}`;
82
85
  }
83
86
 
84
87
  return new Text(text, 0, 0);
@@ -91,7 +94,7 @@ export function renderExaResult(
91
94
  const cost = response.costDollars?.total;
92
95
  const time = response.searchTime;
93
96
 
94
- const icon = getStyledStatusIcon(resultCount > 0 ? "success" : "warning", uiTheme);
97
+ const icon = formatStatusIcon(resultCount > 0 ? "success" : "warning", uiTheme);
95
98
 
96
99
  const metaParts = [formatCount("result", resultCount)];
97
100
  if (cost !== undefined) metaParts.push(`cost:$${cost.toFixed(4)}`);
@@ -104,7 +107,7 @@ export function renderExaResult(
104
107
  const totalLines = previewText.split("\n").filter((l) => l.trim()).length;
105
108
  hasMorePreview = totalLines > COLLAPSED_PREVIEW_LINES || resultCount > 1;
106
109
  }
107
- const expandHint = formatExpandHint(expanded, hasMorePreview, uiTheme);
110
+ const expandHint = formatExpandHint(uiTheme, expanded, hasMorePreview);
108
111
 
109
112
  let text = `${icon} ${uiTheme.fg("dim", summaryText)}${expandHint}`;
110
113
 
@@ -165,11 +168,17 @@ export function renderExaResult(
165
168
  text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", title)}${domainPart}`;
166
169
 
167
170
  if (res.url) {
168
- text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg("mdLinkUrl", res.url)}`;
171
+ text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
172
+ "mdLinkUrl",
173
+ res.url,
174
+ )}`;
169
175
  }
170
176
 
171
177
  if (res.author) {
172
- text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg("muted", `Author: ${res.author}`)}`;
178
+ text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
179
+ "muted",
180
+ `Author: ${res.author}`,
181
+ )}`;
173
182
  }
174
183
 
175
184
  if (res.publishedDate) {
@@ -197,7 +206,10 @@ export function renderExaResult(
197
206
  }
198
207
 
199
208
  if (res.highlights?.length) {
200
- text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg("accent", "Highlights")}`;
209
+ text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
210
+ "accent",
211
+ "Highlights",
212
+ )}`;
201
213
  const maxHighlights = Math.min(res.highlights.length, 3);
202
214
  for (let j = 0; j < maxHighlights; j++) {
203
215
  const h = res.highlights[j];
@@ -29,6 +29,7 @@ describe("createTools", () => {
29
29
  expect(names).toContain("lsp");
30
30
  expect(names).toContain("notebook");
31
31
  expect(names).toContain("task");
32
+ expect(names).toContain("todo_write");
32
33
  expect(names).toContain("output");
33
34
  expect(names).toContain("web_fetch");
34
35
  expect(names).toContain("web_search");
@@ -164,6 +165,7 @@ describe("createTools", () => {
164
165
  "output",
165
166
  "read",
166
167
  "task",
168
+ "todo_write",
167
169
  "web_fetch",
168
170
  "web_search",
169
171
  "write",
@@ -28,6 +28,7 @@ export { createReadTool, type ReadToolDetails } from "./read";
28
28
  export { reportFindingTool, type SubmitReviewDetails } from "./review";
29
29
  export { createSshTool, type SSHToolDetails } from "./ssh";
30
30
  export { BUNDLED_AGENTS, createTaskTool, taskTool } from "./task/index";
31
+ export { createTodoWriteTool, type TodoItem, type TodoWriteToolDetails } from "./todo-write";
31
32
  export {
32
33
  DEFAULT_MAX_BYTES,
33
34
  DEFAULT_MAX_LINES,
@@ -79,6 +80,7 @@ import { createReadTool } from "./read";
79
80
  import { reportFindingTool } from "./review";
80
81
  import { createSshTool } from "./ssh";
81
82
  import { createTaskTool } from "./task/index";
83
+ import { createTodoWriteTool } from "./todo-write";
82
84
  import { createWebFetchTool } from "./web-fetch";
83
85
  import { createWebSearchTool } from "./web-search/index";
84
86
  import { createWriteTool } from "./write";
@@ -145,6 +147,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
145
147
  output: createOutputTool,
146
148
  read: createReadTool,
147
149
  task: createTaskTool,
150
+ todo_write: createTodoWriteTool,
148
151
  web_fetch: createWebFetchTool,
149
152
  web_search: createWebSearchTool,
150
153
  write: createWriteTool,
@@ -108,12 +108,7 @@ function convertSchema(schema: unknown): unknown {
108
108
  if (!jsonType) {
109
109
  return { type: schema.type };
110
110
  }
111
- const result: Record<string, unknown> = { type: jsonType };
112
- // Add format for timestamp
113
- if (schema.type === "timestamp") {
114
- result.format = "date-time";
115
- }
116
- return result;
111
+ return { type: jsonType };
117
112
  }
118
113
 
119
114
  // Enum form: { enum: ["a", "b"] } → { enum: ["a", "b"] }
@@ -265,7 +265,7 @@ export const lsToolRenderer = {
265
265
  );
266
266
  const maxEntries = expanded ? entries.length : Math.min(entries.length, COLLAPSED_LIST_LIMIT);
267
267
  const hasMoreEntries = entries.length > maxEntries;
268
- const expandHint = formatExpandHint(expanded, hasMoreEntries, uiTheme);
268
+ const expandHint = formatExpandHint(uiTheme, expanded, hasMoreEntries);
269
269
 
270
270
  let text = `${icon} ${uiTheme.fg("dim", summaryText)}${formatTruncationSuffix(truncated, uiTheme)}${expandHint}`;
271
271
 
@@ -302,7 +302,10 @@ export const lsToolRenderer = {
302
302
  }
303
303
 
304
304
  if (hasTruncation) {
305
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)}`;
305
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
306
+ "warning",
307
+ `truncated: ${truncationReasons.join(", ")}`,
308
+ )}`;
306
309
  }
307
310
 
308
311
  return new Text(text, 0, 0);
@@ -1,7 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { basename, extname, join } from "node:path";
3
+ import { YAML } from "bun";
3
4
  import { globSync } from "glob";
4
- import { parse as parseYaml } from "yaml";
5
5
  import { getConfigDirPaths } from "../../../config";
6
6
  import { logger } from "../../logger";
7
7
  import { createBiomeClient } from "./clients/biome-client";
@@ -32,7 +32,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
32
32
  function parseConfigContent(content: string, filePath: string): unknown {
33
33
  const extension = extname(filePath).toLowerCase();
34
34
  if (extension === ".yaml" || extension === ".yml") {
35
- return parseYaml(content) as unknown;
35
+ return YAML.parse(content) as unknown;
36
36
  }
37
37
  return JSON.parse(content) as unknown;
38
38
  }