From ce7e73b5037278ea81ed755d75f4247e09f71497 Mon Sep 17 00:00:00 2001 From: Richard Gill Date: Thu, 15 Jan 2026 23:31:53 +0000 Subject: [PATCH] Centralize frontmatter parsing + parse frontmatter with yaml library (#728) * Add frontmatter utility and tidy coding agent prompts * Add frontmatter parsing utilities and tests * Parse frontmatter with YAML parser * Simplify frontmatter parsing utilities * strip body in 1 place * Improve frontmatter parsing error handling * Normalize multiline skill and select-list descriptions --- package-lock.json | 27 ++++++--- .../examples/extensions/subagent/agents.ts | 35 +---------- packages/coding-agent/package.json | 3 +- .../coding-agent/src/core/prompt-templates.ts | 37 ++---------- packages/coding-agent/src/core/skills.ts | 50 ++-------------- packages/coding-agent/src/index.ts | 1 + .../src/modes/interactive/interactive-mode.ts | 5 +- .../coding-agent/src/utils/frontmatter.ts | 39 ++++++++++++ .../fixtures/skills/invalid-yaml/SKILL.md | 8 +++ .../skills/multiline-description/SKILL.md | 11 ++++ .../coding-agent/test/frontmatter.test.ts | 60 +++++++++++++++++++ packages/coding-agent/test/skills.test.ts | 22 +++++++ packages/tui/src/components/select-list.ts | 11 ++-- packages/tui/test/select-list.test.ts | 30 ++++++++++ 14 files changed, 213 insertions(+), 126 deletions(-) create mode 100644 packages/coding-agent/src/utils/frontmatter.ts create mode 100644 packages/coding-agent/test/fixtures/skills/invalid-yaml/SKILL.md create mode 100644 packages/coding-agent/test/fixtures/skills/multiline-description/SKILL.md create mode 100644 packages/coding-agent/test/frontmatter.test.ts create mode 100644 packages/tui/test/select-list.test.ts diff --git a/package-lock.json b/package-lock.json index 0de754c0..2132ea1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6409,7 +6409,6 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -7764,7 +7763,6 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -7793,8 +7791,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -7912,7 +7909,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8009,7 +8005,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -8089,7 +8084,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8204,7 +8198,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8485,6 +8478,21 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", @@ -8682,7 +8690,8 @@ "glob": "^11.0.3", "marked": "^15.0.12", "minimatch": "^10.1.1", - "proper-lockfile": "^4.1.2" + "proper-lockfile": "^4.1.2", + "yaml": "^2.8.2" }, "bin": { "pi": "dist/cli.js" diff --git a/packages/coding-agent/examples/extensions/subagent/agents.ts b/packages/coding-agent/examples/extensions/subagent/agents.ts index 448659cc..ae74e361 100644 --- a/packages/coding-agent/examples/extensions/subagent/agents.ts +++ b/packages/coding-agent/examples/extensions/subagent/agents.ts @@ -5,6 +5,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { parseFrontmatter } from "@mariozechner/pi-coding-agent"; export type AgentScope = "user" | "project" | "both"; @@ -23,36 +24,6 @@ export interface AgentDiscoveryResult { projectAgentsDir: string | null; } -function parseFrontmatter(content: string): { frontmatter: Record; body: string } { - const frontmatter: Record = {}; - const normalized = content.replace(/\r\n/g, "\n"); - - if (!normalized.startsWith("---")) { - return { frontmatter, body: normalized }; - } - - const endIndex = normalized.indexOf("\n---", 3); - if (endIndex === -1) { - return { frontmatter, body: normalized }; - } - - const frontmatterBlock = normalized.slice(4, endIndex); - const body = normalized.slice(endIndex + 4).trim(); - - for (const line of frontmatterBlock.split("\n")) { - const match = line.match(/^([\w-]+):\s*(.*)$/); - if (match) { - let value = match[2].trim(); - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - frontmatter[match[1]] = value; - } - } - - return { frontmatter, body }; -} - function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] { const agents: AgentConfig[] = []; @@ -79,7 +50,7 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig continue; } - const { frontmatter, body } = parseFrontmatter(content); + const { frontmatter, body } = parseFrontmatter>(content); if (!frontmatter.name || !frontmatter.description) { continue; @@ -87,7 +58,7 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig const tools = frontmatter.tools ?.split(",") - .map((t) => t.trim()) + .map((t: string) => t.trim()) .filter(Boolean); agents.push({ diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 95d22b59..3ee8eb19 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -43,6 +43,7 @@ "@mariozechner/pi-agent-core": "^0.46.0", "@mariozechner/pi-ai": "^0.46.0", "@mariozechner/pi-tui": "^0.46.0", + "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", @@ -51,7 +52,7 @@ "marked": "^15.0.12", "minimatch": "^10.1.1", "proper-lockfile": "^4.1.2", - "@silvia-odwyer/photon-node": "^0.3.4" + "yaml": "^2.8.2" }, "devDependencies": { "@types/diff": "^7.0.2", diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index 135f327d..aeff8af9 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -1,6 +1,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import { join, resolve } from "path"; import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js"; +import { parseFrontmatter } from "../utils/frontmatter.js"; /** * Represents a prompt template loaded from a markdown file @@ -12,36 +13,6 @@ export interface PromptTemplate { source: string; // e.g., "(user)", "(project)", "(project:frontend)" } -/** - * Parse YAML frontmatter from markdown content - * Returns { frontmatter, content } where content has frontmatter stripped - */ -function parseFrontmatter(content: string): { frontmatter: Record; content: string } { - const frontmatter: Record = {}; - - if (!content.startsWith("---")) { - return { frontmatter, content }; - } - - const endIndex = content.indexOf("\n---", 3); - if (endIndex === -1) { - return { frontmatter, content }; - } - - const frontmatterBlock = content.slice(4, endIndex); - const remainingContent = content.slice(endIndex + 4).trim(); - - // Simple YAML parsing - just key: value pairs - for (const line of frontmatterBlock.split("\n")) { - const match = line.match(/^(\w+):\s*(.*)$/); - if (match) { - frontmatter[match[1]] = match[2].trim(); - } - } - - return { frontmatter, content: remainingContent }; -} - /** * Parse command arguments respecting quoted strings (bash-style) * Returns array of arguments @@ -145,7 +116,7 @@ function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: s } else if (isFile && entry.name.endsWith(".md")) { try { const rawContent = readFileSync(fullPath, "utf-8"); - const { frontmatter, content } = parseFrontmatter(rawContent); + const { frontmatter, body } = parseFrontmatter>(rawContent); const name = entry.name.slice(0, -3); // Remove .md extension @@ -160,7 +131,7 @@ function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: s // Get description from frontmatter or first non-empty line let description = frontmatter.description || ""; if (!description) { - const firstLine = content.split("\n").find((line) => line.trim()); + const firstLine = body.split("\n").find((line) => line.trim()); if (firstLine) { // Truncate if too long description = firstLine.slice(0, 60); @@ -174,7 +145,7 @@ function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: s templates.push({ name, description, - content, + content: body, source: sourceStr, }); } catch (_error) { diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index 262b1e2b..2a922596 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -3,6 +3,7 @@ import { minimatch } from "minimatch"; import { homedir } from "os"; import { basename, dirname, join, resolve } from "path"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; +import { parseFrontmatter } from "../utils/frontmatter.js"; import type { SkillsSettings } from "./settings-manager.js"; /** @@ -50,48 +51,6 @@ export interface LoadSkillsResult { type SkillFormat = "recursive" | "claude"; -function stripQuotes(value: string): string { - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { - return value.slice(1, -1); - } - return value; -} - -function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; body: string; allKeys: string[] } { - const frontmatter: SkillFrontmatter = {}; - const allKeys: string[] = []; - - const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - - if (!normalizedContent.startsWith("---")) { - return { frontmatter, body: normalizedContent, allKeys }; - } - - const endIndex = normalizedContent.indexOf("\n---", 3); - if (endIndex === -1) { - return { frontmatter, body: normalizedContent, allKeys }; - } - - const frontmatterBlock = normalizedContent.slice(4, endIndex); - const body = normalizedContent.slice(endIndex + 4).trim(); - - for (const line of frontmatterBlock.split("\n")) { - const match = line.match(/^(\w[\w-]*):\s*(.*)$/); - if (match) { - const key = match[1]; - const value = stripQuotes(match[2].trim()); - allKeys.push(key); - if (key === "name") { - frontmatter.name = value; - } else if (key === "description") { - frontmatter.description = value; - } - } - } - - return { frontmatter, body, allKeys }; -} - /** * Validate skill name per Agent Skills spec. * Returns array of validation error messages (empty if valid). @@ -244,7 +203,8 @@ function loadSkillFromFile(filePath: string, source: string): { skill: Skill | n try { const rawContent = readFileSync(filePath, "utf-8"); - const { frontmatter, allKeys } = parseFrontmatter(rawContent); + const { frontmatter } = parseFrontmatter(rawContent); + const allKeys = Object.keys(frontmatter); const skillDir = dirname(filePath); const parentDirName = basename(skillDir); @@ -284,7 +244,9 @@ function loadSkillFromFile(filePath: string, source: string): { skill: Skill | n }, warnings, }; - } catch { + } catch (error) { + const message = error instanceof Error ? error.message : "failed to parse skill file"; + warnings.push({ skillPath: filePath, message }); return { skill: null, warnings }; } } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 8dc936e3..83e99cc6 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -276,3 +276,4 @@ export { Theme, type ThemeColor, } from "./modes/interactive/theme/theme.js"; +export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.js"; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3811ac7c..d5f8b410 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -60,7 +60,7 @@ import type { TruncationResult } from "../../core/tools/truncate.js"; import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js"; import { copyToClipboard } from "../../utils/clipboard.js"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js"; - +import { stripFrontmatter } from "../../utils/frontmatter.js"; import { ensureTool } from "../../utils/tools-manager.js"; import { ArminComponent } from "./components/armin.js"; import { AssistantMessageComponent } from "./components/assistant-message.js"; @@ -3316,8 +3316,7 @@ export class InteractiveMode { private async handleSkillCommand(skillPath: string, args: string): Promise { try { const content = fs.readFileSync(skillPath, "utf-8"); - // Strip YAML frontmatter if present - const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim(); + const body = stripFrontmatter(content).trim(); const skillDir = path.dirname(skillPath); const header = `Skill location: ${skillPath}\nReferences are relative to ${skillDir}.`; const skillMessage = `${header}\n\n${body}`; diff --git a/packages/coding-agent/src/utils/frontmatter.ts b/packages/coding-agent/src/utils/frontmatter.ts new file mode 100644 index 00000000..847e2e53 --- /dev/null +++ b/packages/coding-agent/src/utils/frontmatter.ts @@ -0,0 +1,39 @@ +import { parse } from "yaml"; + +type ParsedFrontmatter> = { + frontmatter: T; + body: string; +}; + +const normalizeNewlines = (value: string): string => value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + +const extractFrontmatter = (content: string): { yamlString: string | null; body: string } => { + const normalized = normalizeNewlines(content); + + if (!normalized.startsWith("---")) { + return { yamlString: null, body: normalized }; + } + + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) { + return { yamlString: null, body: normalized }; + } + + return { + yamlString: normalized.slice(4, endIndex), + body: normalized.slice(endIndex + 4).trim(), + }; +}; + +export const parseFrontmatter = = Record>( + content: string, +): ParsedFrontmatter => { + const { yamlString, body } = extractFrontmatter(content); + if (!yamlString) { + return { frontmatter: {} as T, body }; + } + const parsed = parse(yamlString); + return { frontmatter: (parsed ?? {}) as T, body }; +}; + +export const stripFrontmatter = (content: string): string => parseFrontmatter(content).body; diff --git a/packages/coding-agent/test/fixtures/skills/invalid-yaml/SKILL.md b/packages/coding-agent/test/fixtures/skills/invalid-yaml/SKILL.md new file mode 100644 index 00000000..13be0a2e --- /dev/null +++ b/packages/coding-agent/test/fixtures/skills/invalid-yaml/SKILL.md @@ -0,0 +1,8 @@ +--- +name: invalid-yaml +description: [unclosed bracket +--- + +# Invalid YAML Skill + +This skill has invalid YAML in the frontmatter. diff --git a/packages/coding-agent/test/fixtures/skills/multiline-description/SKILL.md b/packages/coding-agent/test/fixtures/skills/multiline-description/SKILL.md new file mode 100644 index 00000000..206cf2e5 --- /dev/null +++ b/packages/coding-agent/test/fixtures/skills/multiline-description/SKILL.md @@ -0,0 +1,11 @@ +--- +name: multiline-description +description: | + This is a multiline description. + It spans multiple lines. + And should be normalized. +--- + +# Multiline Description Skill + +This skill tests that multiline YAML descriptions are normalized to single lines. diff --git a/packages/coding-agent/test/frontmatter.test.ts b/packages/coding-agent/test/frontmatter.test.ts new file mode 100644 index 00000000..3879fedf --- /dev/null +++ b/packages/coding-agent/test/frontmatter.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { parseFrontmatter, stripFrontmatter } from "../src/utils/frontmatter.js"; + +describe("parseFrontmatter", () => { + it("parses keys, strips quotes, and returns body", () => { + const input = "---\nname: \"skill-name\"\ndescription: 'A desc'\nfoo-bar: value\n---\n\nBody text"; + const { frontmatter, body } = parseFrontmatter>(input); + expect(frontmatter.name).toBe("skill-name"); + expect(frontmatter.description).toBe("A desc"); + expect(frontmatter["foo-bar"]).toBe("value"); + expect(body).toBe("Body text"); + }); + + it("normalizes newlines and handles CRLF", () => { + const input = "---\r\nname: test\r\n---\r\nLine one\r\nLine two"; + const { body } = parseFrontmatter>(input); + expect(body).toBe("Line one\nLine two"); + }); + + it("throws on invalid YAML frontmatter", () => { + const input = "---\nfoo: [bar\n---\nBody"; + expect(() => parseFrontmatter>(input)).toThrow(/at line 1, column 10/); + }); + + it("parses | multiline yaml syntax", () => { + const input = "---\ndescription: |\n Line one\n Line two\n---\n\nBody"; + const { frontmatter, body } = parseFrontmatter>(input); + expect(frontmatter.description).toBe("Line one\nLine two\n"); + expect(body).toBe("Body"); + }); + + it("returns original content when frontmatter is missing or unterminated", () => { + const noFrontmatter = "Just text\nsecond line"; + const missingEnd = "---\nname: test\nBody without terminator"; + const resultNoFrontmatter = parseFrontmatter>(noFrontmatter); + const resultMissingEnd = parseFrontmatter>(missingEnd); + expect(resultNoFrontmatter.body).toBe("Just text\nsecond line"); + expect(resultMissingEnd.body).toBe( + "---\nname: test\nBody without terminator".replace(/\r\n/g, "\n").replace(/\r/g, "\n"), + ); + }); + + it("returns empty object for empty or comment-only frontmatter", () => { + const input = "---\n# just a comment\n---\nBody"; + const { frontmatter } = parseFrontmatter(input); + expect(frontmatter).toEqual({}); + }); +}); + +describe("stripFrontmatter", () => { + it("removes frontmatter and trims body", () => { + const input = "---\nkey: value\n---\n\nBody\n"; + expect(stripFrontmatter(input)).toBe("Body"); + }); + + it("returns body when no frontmatter present", () => { + const input = "\n No frontmatter body \n"; + expect(stripFrontmatter(input)).toBe("\n No frontmatter body \n"); + }); +}); diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index 863d16b0..ba1a9a06 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -95,6 +95,28 @@ describe("skills", () => { expect(warnings.some((w) => w.message.includes("description is required"))).toBe(true); }); + it("should warn and skip skill when YAML frontmatter is invalid", () => { + const { skills, warnings } = loadSkillsFromDir({ + dir: join(fixturesDir, "invalid-yaml"), + source: "test", + }); + + expect(skills).toHaveLength(0); + expect(warnings.some((w) => w.message.includes("at line"))).toBe(true); + }); + + it("should preserve multiline descriptions from YAML", () => { + const { skills, warnings } = loadSkillsFromDir({ + dir: join(fixturesDir, "multiline-description"), + source: "test", + }); + + expect(skills).toHaveLength(1); + expect(skills[0].description).toContain("\n"); + expect(skills[0].description).toContain("This is a multiline description."); + expect(warnings).toHaveLength(0); + }); + it("should warn when name contains consecutive hyphens", () => { const { skills, warnings } = loadSkillsFromDir({ dir: join(fixturesDir, "consecutive-hyphens"), diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index 06352ac4..e4664a05 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -2,6 +2,8 @@ import { getEditorKeybindings } from "../keybindings.js"; import type { Component } from "../tui.js"; import { truncateToWidth } from "../utils.js"; +const normalizeToSingleLine = (text: string): string => text.replace(/[\r\n]+/g, " ").trim(); + export interface SelectItem { value: string; label: string; @@ -70,6 +72,7 @@ export class SelectList implements Component { if (!item) continue; const isSelected = i === this.selectedIndex; + const descriptionSingleLine = item.description ? normalizeToSingleLine(item.description) : undefined; let line = ""; if (isSelected) { @@ -77,7 +80,7 @@ export class SelectList implements Component { const prefixWidth = 2; // "→ " is 2 characters visually const displayValue = item.label || item.value; - if (item.description && width > 40) { + if (descriptionSingleLine && width > 40) { // Calculate how much space we have for value + description const maxValueWidth = Math.min(30, width - prefixWidth - 4); const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); @@ -88,7 +91,7 @@ export class SelectList implements Component { const remainingWidth = width - descriptionStart - 2; // -2 for safety if (remainingWidth > 10) { - const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); + const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, ""); // Apply selectedText to entire line content line = this.theme.selectedText(`→ ${truncatedValue}${spacing}${truncatedDesc}`); } else { @@ -105,7 +108,7 @@ export class SelectList implements Component { const displayValue = item.label || item.value; const prefix = " "; - if (item.description && width > 40) { + if (descriptionSingleLine && width > 40) { // Calculate how much space we have for value + description const maxValueWidth = Math.min(30, width - prefix.length - 4); const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); @@ -116,7 +119,7 @@ export class SelectList implements Component { const remainingWidth = width - descriptionStart - 2; // -2 for safety if (remainingWidth > 10) { - const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); + const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, ""); const descText = this.theme.description(spacing + truncatedDesc); line = prefix + truncatedValue + descText; } else { diff --git a/packages/tui/test/select-list.test.ts b/packages/tui/test/select-list.test.ts new file mode 100644 index 00000000..5ea5766b --- /dev/null +++ b/packages/tui/test/select-list.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { SelectList } from "../src/components/select-list.js"; + +const testTheme = { + selectedPrefix: (text: string) => text, + selectedText: (text: string) => text, + description: (text: string) => text, + scrollInfo: (text: string) => text, + noMatch: (text: string) => text, +}; + +describe("SelectList", () => { + it("normalizes multiline descriptions to single line", () => { + const items = [ + { + value: "test", + label: "test", + description: "Line one\nLine two\nLine three", + }, + ]; + + const list = new SelectList(items, 5, testTheme); + const rendered = list.render(100); + + assert.ok(rendered.length > 0); + assert.ok(!rendered[0].includes("\n")); + assert.ok(rendered[0].includes("Line one Line two Line three")); + }); +});