diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index f4043f864..1de6d1ed0 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -1,9 +1,9 @@
# Changelog
## [Unreleased]
-
### Added
+- Extracted prompt formatting logic into reusable `formatPromptContent()` utility with configurable render phases and formatting options
- Added `type_definition` action to navigate to symbol type definitions with source context
- Added `implementation` action to find concrete implementations of symbols with source context
- Added `code_actions` action to list and apply language server code fixes, refactors, and import suggestions
@@ -19,6 +19,8 @@
### Changed
+- Refactored prompt template rendering to use unified `formatPromptContent()` function with phase-aware formatting (pre-render vs post-render)
+- Updated `format-prompts.ts` script to use centralized prompt formatting utility instead of inline implementation
- Replaced `column` parameter with `symbol` parameter for more intuitive position specification
- Removed `files` parameter; use glob patterns in `file` parameter instead
- Removed `end_line` and `end_character` parameters; range operations now use single position
diff --git a/packages/coding-agent/scripts/format-prompts.ts b/packages/coding-agent/scripts/format-prompts.ts
index 7e23322bb..bf013b93e 100644
--- a/packages/coding-agent/scripts/format-prompts.ts
+++ b/packages/coding-agent/scripts/format-prompts.ts
@@ -14,6 +14,7 @@
* 9. Bold RFC 2119 keywords (MUST, SHOULD, MAY, etc.) in prompt content
*/
import { Glob } from "bun";
+import { formatPromptContent } from "../src/utils/prompt-format";
const PROMPTS_DIR = new URL("../src/prompts/", import.meta.url).pathname;
const COMMIT_PROMPTS_DIR = new URL("../src/commit/prompts/", import.meta.url).pathname;
@@ -21,177 +22,11 @@ const AGENTIC_PROMPTS_DIR = new URL("../src/commit/agentic/prompts/", import.met
const PROMPT_DIRS = [PROMPTS_DIR, COMMIT_PROMPTS_DIR, AGENTIC_PROMPTS_DIR];
-// Opening XML tag (not self-closing, not closing)
-const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/;
-// Closing XML tag
-const CLOSING_XML = /^<\/([a-z_-]+)>$/;
-// Handlebars block start: {{#if}}, {{#has}}, {{#list}}, etc.
-const OPENING_HBS = /^\{\{#/;
-// Handlebars block end: {{/if}}, {{/has}}, {{/list}}, etc.
-const CLOSING_HBS = /^\{\{\//;
-// List item (- or * or 1.)
-const LIST_ITEM = /^[-*]|\d+\.\s/;
-// Code fence
-const CODE_FENCE = /^```/;
-// Table row
-const TABLE_ROW = /^\|.*\|$/;
-// Table separator (|---|---|)
-const TABLE_SEP = /^\|[-:\s|]+\|$/;
-
-/** RFC 2119 keywords used in prompts. */
-const RFC2119_KEYWORDS = /\b(?:MUST NOT|SHOULD NOT|SHALL NOT|RECOMMENDED|REQUIRED|OPTIONAL|SHOULD|SHALL|MUST|MAY)\b/g;
-
-function boldRfc2119Keywords(line: string): string {
- return line.replace(RFC2119_KEYWORDS, (match, offset, source) => {
- const isAlreadyBold =
- source[offset - 2] === "*" &&
- source[offset - 1] === "*" &&
- source[offset + match.length] === "*" &&
- source[offset + match.length + 1] === "*";
- if (isAlreadyBold) {
- return match;
- }
- return `**${match}**`;
- });
-}
-
-/** Compact a table row by trimming cell padding */
-function compactTableRow(line: string): string {
- // Split by |, trim each cell, rejoin
- const cells = line.split("|");
- return cells.map((c) => c.trim()).join("|");
-}
-
-/** Compact a table separator row */
-function compactTableSep(line: string): string {
- // Normalize to minimal |---|---|
- const cells = line.split("|").filter((c) => c.trim());
- const normalized = cells.map((c) => {
- const trimmed = c.trim();
- // Preserve alignment markers
- const left = trimmed.startsWith(":");
- const right = trimmed.endsWith(":");
- if (left && right) return ":---:";
- if (left) return ":---";
- if (right) return "---:";
- return "---";
- });
- return "|" + normalized.join("|") + "|";
-}
-
-function formatPrompt(content: string): string {
- const lines = content.split("\n");
- const result: string[] = [];
- let inCodeBlock = false;
- // Stack of tag names whose opening tag was at column 0 (top-level)
- const topLevelTags: string[] = [];
-
- for (let i = 0; i < lines.length; i++) {
- let line = lines[i].trimEnd();
-
- const trimmed = line.trimStart();
-
- // Track code blocks - don't modify inside them
- if (CODE_FENCE.test(trimmed)) {
- inCodeBlock = !inCodeBlock;
- result.push(line);
- continue;
- }
-
- if (inCodeBlock) {
- result.push(line);
- continue;
- }
-
- // Replace common ascii ellipsis and arrow patterns with their unicode equivalents
- line = line
- .replace(/\.{3}/g, "…")
- .replace(/->/g, "→")
- .replace(/<-/g, "←")
- .replace(/<->/g, "↔")
- .replace(/!=/g, "≠")
- .replace(/<=/g, "≤")
- .replace(/>=/g, "≥");
-
- // Track top-level XML opening tags for depth-aware indent stripping
- const isOpeningXml = OPENING_XML.test(trimmed) && !trimmed.endsWith("/>");
- if (isOpeningXml && line.length === trimmed.length) {
- // Opening tag at column 0 — track as top-level
- const match = OPENING_XML.exec(trimmed);
- if (match) topLevelTags.push(match[1]);
- }
-
- // Strip leading whitespace from top-level closing XML tags and Handlebars
- const closingMatch = CLOSING_XML.exec(trimmed);
- if (closingMatch) {
- const tagName = closingMatch[1];
- if (topLevelTags.length > 0 && topLevelTags[topLevelTags.length - 1] === tagName) {
- // Closing tag matches a top-level opener — strip indent
- line = trimmed;
- topLevelTags.pop();
- } else {
- line = line.trimEnd();
- }
- } else if (trimmed.startsWith("{{")) {
- line = trimmed;
- } else if (TABLE_SEP.test(trimmed)) {
- // Compact table separator
- line = compactTableSep(trimmed);
- } else if (TABLE_ROW.test(trimmed)) {
- // Compact table row
- line = compactTableRow(trimmed);
- } else {
- // Trim trailing whitespace (preserve leading for non-closing-tags)
- line = line.trimEnd();
- }
- line = boldRfc2119Keywords(line);
-
- const isBlank = trimmed === "";
-
- // Skip blank lines that violate our rules
- if (isBlank) {
- const prevLine = result[result.length - 1]?.trim() ?? "";
- const nextLine = lines[i + 1]?.trim() ?? "";
-
- // Rule 1: No blank line before list items
- if (LIST_ITEM.test(nextLine)) {
- continue;
- }
-
- // Rule 2: No blank after opening XML tag or Handlebars block
- if (OPENING_XML.test(prevLine) || OPENING_HBS.test(prevLine)) {
- continue;
- }
-
- // Rule 3: No blank before closing XML tag or Handlebars block
- if (CLOSING_XML.test(nextLine) || CLOSING_HBS.test(nextLine)) {
- continue;
- }
-
- // Rule 4: Collapse multiple blank lines
- const prevIsBlank = prevLine === "";
- if (prevIsBlank) {
- continue;
- }
- }
-
- // Rule 3 (cleanup): Remove trailing blanks before closing tag
- if (CLOSING_XML.test(trimmed) || CLOSING_HBS.test(trimmed)) {
- while (result.length > 0 && result[result.length - 1].trim() === "") {
- result.pop();
- }
- }
-
- result.push(line);
- }
-
- // Rule 8: No trailing newline at EOF
- while (result.length > 0 && result[result.length - 1].trim() === "") {
- result.pop();
- }
-
- return result.join("\n");
-}
+const PROMPT_FORMAT_OPTIONS = {
+ renderPhase: "pre-render",
+ replaceAsciiSymbols: true,
+ boldRfc2119Keywords: true,
+} as const;
async function main() {
const glob = new Glob("**/*.md");
@@ -207,7 +42,7 @@ async function main() {
for (const fullPath of files) {
const original = await Bun.file(fullPath).text();
- const formatted = formatPrompt(original);
+ const formatted = formatPromptContent(original, PROMPT_FORMAT_OPTIONS);
if (original !== formatted) {
if (check) {
diff --git a/packages/coding-agent/src/config/prompt-templates.ts b/packages/coding-agent/src/config/prompt-templates.ts
index 1ae485c8b..48aafe485 100644
--- a/packages/coding-agent/src/config/prompt-templates.ts
+++ b/packages/coding-agent/src/config/prompt-templates.ts
@@ -6,6 +6,7 @@ import { computeLineHash } from "../patch/hashline";
import { jtdToTypeScript } from "../tools/jtd-to-typescript";
import { parseCommandArgs, substituteArgs } from "../utils/command-args";
import { parseFrontmatter } from "../utils/frontmatter";
+import { formatPromptContent } from "../utils/prompt-format";
/**
* Represents a prompt template loaded from a markdown file
@@ -276,60 +277,7 @@ handlebars.registerHelper("hlinefull", (lineNum: unknown, content: unknown): str
export function renderPromptTemplate(template: string, context: TemplateContext = {}): string {
const compiled = handlebars.compile(template, { noEscape: true, strict: false });
const rendered = compiled(context ?? {});
- return optimizePromptLayout(rendered);
-}
-
-function optimizePromptLayout(input: string): string {
- // 1) strip CR / normalize line endings
- let s = input.replace(/\r\n?/g, "\n");
-
- // normalize NBSP -> space
- s = s.replace(/\u00A0/g, " ");
-
- const lines = s.split("\n").map(line => {
- // 2) remove trailing whitespace (spaces/tabs) per line
- let l = line.replace(/[ \t]+$/g, "");
-
- // 3) lines with only whitespace -> empty line
- if (/^[ \t]*$/.test(l)) return "";
-
- // 4) normalize leading indentation: every 2 spaces -> \t (preserve leftover 1 space)
- // NOTE: This is intentionally *only* leading indentation to avoid mangling prose.
- const m = l.match(/^[ \t]+/);
- if (m) {
- const indent = m[0];
- const rest = l.slice(indent.length);
-
- let out = "";
- let spaces = 0;
-
- for (const ch of indent) {
- if (ch === "\t") {
- // flush pending spaces before existing tab
- out += "\t".repeat(Math.floor(spaces / 2));
- if (spaces % 2) out += " ";
- spaces = 0;
- out += "\t";
- } else {
- spaces++;
- }
- }
-
- out += "\t".repeat(Math.floor(spaces / 2));
- if (spaces % 2) out += " ";
-
- l = out + rest;
- }
-
- return l;
- });
-
- s = lines.join("\n");
-
- // 5) collapse excessive blank lines
- s = s.replace(/\n{3,}/g, "\n\n");
-
- return s.trim();
+ return formatPromptContent(rendered, { renderPhase: "post-render" });
}
/**
diff --git a/packages/coding-agent/src/utils/prompt-format.ts b/packages/coding-agent/src/utils/prompt-format.ts
new file mode 100644
index 000000000..ba477cd65
--- /dev/null
+++ b/packages/coding-agent/src/utils/prompt-format.ts
@@ -0,0 +1,172 @@
+export type PromptRenderPhase = "pre-render" | "post-render";
+
+export interface PromptFormatOptions {
+ renderPhase?: PromptRenderPhase;
+ replaceAsciiSymbols?: boolean;
+ boldRfc2119Keywords?: boolean;
+}
+
+// Opening XML tag (not self-closing, not closing)
+const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/;
+// Closing XML tag
+const CLOSING_XML = /^<\/([a-z_-]+)>$/;
+// Handlebars block start: {{#if}}, {{#has}}, {{#list}}, etc.
+const OPENING_HBS = /^\{\{#/;
+// Handlebars block end: {{/if}}, {{/has}}, {{/list}}, etc.
+const CLOSING_HBS = /^\{\{\//;
+// List item (- or * or 1.)
+const LIST_ITEM = /^(?:[-*]\s|\d+\.\s)/;
+// Code fence
+const CODE_FENCE = /^```/;
+// Table row
+const TABLE_ROW = /^\|.*\|$/;
+// Table separator (|---|---|)
+const TABLE_SEP = /^\|[-:\s|]+\|$/;
+
+/** RFC 2119 keywords used in prompts. */
+const RFC2119_KEYWORDS = /\b(?:MUST NOT|SHOULD NOT|SHALL NOT|RECOMMENDED|REQUIRED|OPTIONAL|SHOULD|SHALL|MUST|MAY)\b/g;
+
+function boldRfc2119Keywords(line: string): string {
+ return line.replace(RFC2119_KEYWORDS, (match, offset, source) => {
+ const isAlreadyBold =
+ source[offset - 2] === "*" &&
+ source[offset - 1] === "*" &&
+ source[offset + match.length] === "*" &&
+ source[offset + match.length + 1] === "*";
+ if (isAlreadyBold) {
+ return match;
+ }
+ return `**${match}**`;
+ });
+}
+
+/** Compact a table row by trimming cell padding */
+function compactTableRow(line: string): string {
+ const cells = line.split("|");
+ return cells.map(c => c.trim()).join("|");
+}
+
+/** Compact a table separator row */
+function compactTableSep(line: string): string {
+ const cells = line.split("|").filter(c => c.trim());
+ const normalized = cells.map(c => {
+ const trimmed = c.trim();
+ const left = trimmed.startsWith(":");
+ const right = trimmed.endsWith(":");
+ if (left && right) return ":---:";
+ if (left) return ":---";
+ if (right) return "---:";
+ return "---";
+ });
+ return `|${normalized.join("|")}|`;
+}
+
+function replaceCommonAsciiSymbols(line: string): string {
+ return line
+ .replace(/\.{3}/g, "…")
+ .replace(/<->/g, "↔")
+ .replace(/->/g, "→")
+ .replace(/<-/g, "←")
+ .replace(/!=/g, "≠")
+ .replace(/<=/g, "≤")
+ .replace(/>=/g, "≥");
+}
+
+export function formatPromptContent(content: string, options: PromptFormatOptions = {}): string {
+ const {
+ renderPhase = "post-render",
+ replaceAsciiSymbols = false,
+ boldRfc2119Keywords: shouldBoldRfc2119 = false,
+ } = options;
+ const isPreRender = renderPhase === "pre-render";
+ const lines = content.split("\n");
+ const result: string[] = [];
+ let inCodeBlock = false;
+ const topLevelTags: string[] = [];
+
+ for (let i = 0; i < lines.length; i++) {
+ let line = lines[i].trimEnd();
+ const trimmed = line.trimStart();
+
+ if (CODE_FENCE.test(trimmed)) {
+ inCodeBlock = !inCodeBlock;
+ result.push(line);
+ continue;
+ }
+
+ if (inCodeBlock) {
+ result.push(line);
+ continue;
+ }
+
+ if (replaceAsciiSymbols) {
+ line = replaceCommonAsciiSymbols(line);
+ }
+
+ const isOpeningXml = OPENING_XML.test(trimmed) && !trimmed.endsWith("/>");
+ if (isOpeningXml && line.length === trimmed.length) {
+ const match = OPENING_XML.exec(trimmed);
+ if (match) topLevelTags.push(match[1]);
+ }
+
+ const closingMatch = CLOSING_XML.exec(trimmed);
+ if (closingMatch) {
+ const tagName = closingMatch[1];
+ if (topLevelTags.length > 0 && topLevelTags[topLevelTags.length - 1] === tagName) {
+ line = trimmed;
+ topLevelTags.pop();
+ } else {
+ line = line.trimEnd();
+ }
+ } else if (isPreRender && trimmed.startsWith("{{")) {
+ line = trimmed;
+ } else if (TABLE_SEP.test(trimmed)) {
+ line = compactTableSep(trimmed);
+ } else if (TABLE_ROW.test(trimmed)) {
+ line = compactTableRow(trimmed);
+ } else {
+ line = line.trimEnd();
+ }
+
+ if (shouldBoldRfc2119) {
+ line = boldRfc2119Keywords(line);
+ }
+
+ const isBlank = trimmed === "";
+ if (isBlank) {
+ const prevLine = result[result.length - 1]?.trim() ?? "";
+ const nextLine = lines[i + 1]?.trim() ?? "";
+
+ if (LIST_ITEM.test(nextLine)) {
+ continue;
+ }
+
+ if (OPENING_XML.test(prevLine) || (isPreRender && OPENING_HBS.test(prevLine))) {
+ continue;
+ }
+
+ if (CLOSING_XML.test(nextLine) || (isPreRender && CLOSING_HBS.test(nextLine))) {
+ continue;
+ }
+
+ const prevIsBlank = prevLine === "";
+ if (prevIsBlank) {
+ continue;
+ }
+ }
+
+ if (CLOSING_XML.test(trimmed) || (isPreRender && CLOSING_HBS.test(trimmed))) {
+ while (result.length > 0 && result[result.length - 1].trim() === "") {
+ result.pop();
+ }
+ }
+
+ result.push(line);
+ }
+
+ while (result.length > 0 && result[result.length - 1].trim() === "") {
+ result.pop();
+ }
+
+ return result.join("\n");
+}
diff --git a/packages/coding-agent/test/prompt-format.test.ts b/packages/coding-agent/test/prompt-format.test.ts
new file mode 100644
index 000000000..6afbdeaaf
--- /dev/null
+++ b/packages/coding-agent/test/prompt-format.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, test } from "bun:test";
+import { formatPromptContent } from "@oh-my-pi/pi-coding-agent/utils/prompt-format";
+
+describe("formatPromptContent renderPhase", () => {
+ test("pre-render mode strips indentation from Handlebars block lines", () => {
+ const input = "\n {{#if ok}}\n value\n {{/if}}\n";
+
+ const output = formatPromptContent(input, { renderPhase: "pre-render" });
+
+ expect(output).toBe("\n{{#if ok}}\n value\n{{/if}}\n");
+ });
+
+ test("post-render mode preserves indentation on Handlebars-like lines", () => {
+ const input = "\n {{#if ok}}\n value\n {{/if}}\n";
+
+ const output = formatPromptContent(input, { renderPhase: "post-render" });
+
+ expect(output).toBe("\n {{#if ok}}\n value\n {{/if}}\n");
+ });
+
+ test("pre-render removes blank line before closing Handlebars block while post-render keeps it", () => {
+ const input = "\n{{#if ok}}\nvalue\n\n{{/if}}\n";
+
+ const preRender = formatPromptContent(input, { renderPhase: "pre-render" });
+ const postRender = formatPromptContent(input, { renderPhase: "post-render" });
+
+ expect(preRender).toBe("\n{{#if ok}}\nvalue\n{{/if}}\n");
+ expect(postRender).toBe("\n{{#if ok}}\nvalue\n\n{{/if}}\n");
+ });
+});
diff --git a/packages/coding-agent/test/tools/task-template.test.ts b/packages/coding-agent/test/tools/task-template.test.ts
index c55b6577f..9d5065512 100644
--- a/packages/coding-agent/test/tools/task-template.test.ts
+++ b/packages/coding-agent/test/tools/task-template.test.ts
@@ -31,7 +31,7 @@ describe("renderTemplate", () => {
description: "label",
assignment: "the real work",
});
- expect(result.task).toStartWith(`${sectionSeparator("Background").trimStart()}\ncontext`);
+ expect(result.task).toStartWith(`${sectionSeparator("Background").trimStart()}\n\ncontext`);
expect(result.task).toContain("the real work");
});