@oh-my-pi/pi-coding-agent 4.3.1 → 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 (55) hide show
  1. package/CHANGELOG.md +49 -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/bash.ts +27 -11
  10. package/src/core/tools/calculator.ts +1 -1
  11. package/src/core/tools/edit.ts +2 -2
  12. package/src/core/tools/exa/render.ts +23 -11
  13. package/src/core/tools/index.test.ts +2 -0
  14. package/src/core/tools/index.ts +3 -0
  15. package/src/core/tools/jtd-to-json-schema.ts +1 -6
  16. package/src/core/tools/ls.ts +5 -2
  17. package/src/core/tools/lsp/config.ts +2 -2
  18. package/src/core/tools/lsp/render.ts +33 -12
  19. package/src/core/tools/notebook.ts +1 -1
  20. package/src/core/tools/output.ts +1 -1
  21. package/src/core/tools/read.ts +15 -49
  22. package/src/core/tools/render-utils.ts +65 -28
  23. package/src/core/tools/renderers.ts +2 -0
  24. package/src/core/tools/schema-validation.test.ts +501 -0
  25. package/src/core/tools/task/agents.ts +6 -2
  26. package/src/core/tools/task/commands.ts +9 -3
  27. package/src/core/tools/task/discovery.ts +3 -2
  28. package/src/core/tools/task/render.ts +10 -7
  29. package/src/core/tools/todo-write.ts +256 -0
  30. package/src/core/tools/web-fetch.ts +4 -2
  31. package/src/core/tools/web-scrapers/choosealicense.ts +2 -2
  32. package/src/core/tools/web-search/render.ts +13 -10
  33. package/src/core/tools/write.ts +2 -2
  34. package/src/discovery/builtin.ts +4 -4
  35. package/src/discovery/cline.ts +4 -3
  36. package/src/discovery/codex.ts +3 -3
  37. package/src/discovery/cursor.ts +2 -2
  38. package/src/discovery/github.ts +3 -2
  39. package/src/discovery/helpers.test.ts +14 -10
  40. package/src/discovery/helpers.ts +2 -39
  41. package/src/discovery/windsurf.ts +3 -3
  42. package/src/modes/interactive/components/custom-editor.ts +4 -11
  43. package/src/modes/interactive/components/index.ts +2 -1
  44. package/src/modes/interactive/components/read-tool-group.ts +118 -0
  45. package/src/modes/interactive/components/todo-display.ts +112 -0
  46. package/src/modes/interactive/components/tool-execution.ts +20 -4
  47. package/src/modes/interactive/controllers/command-controller.ts +2 -2
  48. package/src/modes/interactive/controllers/event-controller.ts +91 -32
  49. package/src/modes/interactive/controllers/input-controller.ts +19 -13
  50. package/src/modes/interactive/interactive-mode.ts +103 -3
  51. package/src/modes/interactive/theme/theme.ts +4 -0
  52. package/src/modes/interactive/types.ts +14 -2
  53. package/src/modes/interactive/utils/ui-helpers.ts +55 -26
  54. package/src/prompts/system/system-prompt.md +177 -126
  55. package/src/prompts/tools/todo-write.md +187 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,55 @@
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
+
48
+ ## [4.3.2] - 2026-01-11
49
+ ### Changed
50
+
51
+ - Increased default bash output preview from 5 to 10 lines when collapsed
52
+ - Updated expanded bash output view to show full untruncated output when available
53
+
5
54
  ## [4.3.1] - 2026-01-11
6
55
 
7
56
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "4.3.1",
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.1",
43
- "@oh-my-pi/pi-agent-core": "4.3.1",
44
- "@oh-my-pi/pi-git-tool": "4.3.1",
45
- "@oh-my-pi/pi-tui": "4.3.1",
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,
@@ -15,6 +15,8 @@ import { resolveToCwd } from "./path-utils";
15
15
  import { createToolUIKit } from "./render-utils";
16
16
  import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate";
17
17
 
18
+ export const BASH_DEFAULT_PREVIEW_LINES = 10;
19
+
18
20
  const bashSchema = Type.Object({
19
21
  command: Type.String({ description: "Bash command to execute" }),
20
22
  timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
@@ -26,6 +28,7 @@ const bashSchema = Type.Object({
26
28
  export interface BashToolDetails {
27
29
  truncation?: TruncationResult;
28
30
  fullOutputPath?: string;
31
+ fullOutput?: string;
29
32
  }
30
33
 
31
34
  /**
@@ -101,9 +104,12 @@ export function createBashTool(session: ToolSession, options?: BashToolOptions):
101
104
  const truncation = truncateTail(currentOutput);
102
105
  onUpdate({
103
106
  content: [{ type: "text", text: truncation.content || "" }],
104
- details: {
105
- truncation: truncation.truncated ? truncation : undefined,
106
- },
107
+ details: truncation.truncated
108
+ ? {
109
+ truncation,
110
+ fullOutput: currentOutput,
111
+ }
112
+ : undefined,
107
113
  });
108
114
  }
109
115
  },
@@ -129,6 +135,7 @@ export function createBashTool(session: ToolSession, options?: BashToolOptions):
129
135
  details = {
130
136
  truncation,
131
137
  fullOutputPath: result.fullOutputPath,
138
+ fullOutput: currentOutput,
132
139
  };
133
140
 
134
141
  const startLine = truncation.totalLines - truncation.outputLines + 1;
@@ -173,6 +180,9 @@ interface BashRenderContext {
173
180
  previewLines?: number;
174
181
  }
175
182
 
183
+ // Preview line limit when not expanded (matches tool-execution behavior)
184
+ export const BASH_PREVIEW_LINES = 10;
185
+
176
186
  export const bashToolRenderer = {
177
187
  renderCall(args: BashRenderArgs, uiTheme: Theme): Component {
178
188
  const ui = createToolUIKit(uiTheme);
@@ -214,21 +224,25 @@ export const bashToolRenderer = {
214
224
  const { renderContext } = options;
215
225
  const details = result.details;
216
226
 
227
+ const expanded = renderContext?.expanded ?? options.expanded;
228
+ const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES;
229
+
217
230
  // Get output from context (preferred) or fall back to result content
218
231
  const output = renderContext?.output ?? (result.content?.find((c) => c.type === "text")?.text ?? "").trim();
219
- const expanded = renderContext?.expanded ?? options.expanded;
220
- const previewLines = renderContext?.previewLines ?? 5;
232
+ const fullOutput = details?.fullOutput;
233
+ const displayOutput = expanded ? (fullOutput ?? output) : output;
234
+ const showingFullOutput = expanded && fullOutput !== undefined;
221
235
 
222
236
  // Build truncation warning lines (static, doesn't depend on width)
223
237
  const truncation = details?.truncation;
224
238
  const fullOutputPath = details?.fullOutputPath;
225
239
  let warningLine: string | undefined;
226
- if (truncation?.truncated || fullOutputPath) {
240
+ if (fullOutputPath || (truncation?.truncated && !showingFullOutput)) {
227
241
  const warnings: string[] = [];
228
242
  if (fullOutputPath) {
229
243
  warnings.push(`Full output: ${fullOutputPath}`);
230
244
  }
231
- if (truncation?.truncated) {
245
+ if (truncation?.truncated && !showingFullOutput) {
232
246
  if (truncation.truncatedBy === "lines") {
233
247
  warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
234
248
  } else {
@@ -237,17 +251,19 @@ export const bashToolRenderer = {
237
251
  );
238
252
  }
239
253
  }
240
- warningLine = uiTheme.fg("warning", ui.wrapBrackets(warnings.join(". ")));
254
+ if (warnings.length > 0) {
255
+ warningLine = uiTheme.fg("warning", ui.wrapBrackets(warnings.join(". ")));
256
+ }
241
257
  }
242
258
 
243
- if (!output) {
259
+ if (!displayOutput) {
244
260
  // No output - just show warning if any
245
261
  return new Text(warningLine ?? "", 0, 0);
246
262
  }
247
263
 
248
264
  if (expanded) {
249
265
  // Show all lines when expanded
250
- const styledOutput = output
266
+ const styledOutput = displayOutput
251
267
  .split("\n")
252
268
  .map((line) => uiTheme.fg("toolOutput", line))
253
269
  .join("\n");
@@ -256,7 +272,7 @@ export const bashToolRenderer = {
256
272
  }
257
273
 
258
274
  // Collapsed: use width-aware caching component
259
- const styledOutput = output
275
+ const styledOutput = displayOutput
260
276
  .split("\n")
261
277
  .map((line) => uiTheme.fg("toolOutput", line))
262
278
  .join("\n");
@@ -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);