From 086c2c23c2b974c464afd4903c2f243b676ef4a2 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 6 Jul 2026 21:52:31 +0900 Subject: [PATCH 1/5] fix(formatter): omit null/undefined and indent array items in plain output Plain format printed literal "undefined"/"null" for empty fields and only indented the first key of each array item, collapsing item boundaries. Items now use a "- " marker with aligned continuation lines, nested objects indent under their parent key, and empty fields are omitted (json format still keeps null for scripting). --- src/utils/formatter.ts | 69 ++++++++++++++++++------------------ test/utils/formatter.test.ts | 52 +++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 34 deletions(-) diff --git a/src/utils/formatter.ts b/src/utils/formatter.ts index f63c75f..904e41e 100644 --- a/src/utils/formatter.ts +++ b/src/utils/formatter.ts @@ -100,51 +100,59 @@ function formatJson(data: any): string { * @returns Plain text formatted string */ function formatPlain(data: any, colors: boolean): string { - // This will be implemented based on the specific data structure - // For now, return a basic representation if (Array.isArray(data)) { - return formatPlainArray(data, colors) + return formatPlainArray(data, colors, '') } if (typeof data === 'object' && data !== null) { - return formatPlainObject(data, colors) + return formatPlainObject(data, colors, '') } return String(data) } /** - * Format array as plain text + * Format array as plain text. Each item starts with a `- ` marker and every + * continuation line is indented to align under the marker. */ -function formatPlainArray(data: any[], colors: boolean): string { - const lines: string[] = [] - - for (const item of data) { - if (typeof item === 'object' && item !== null) { - lines.push(formatPlainObject(item, colors)) - } - else { - lines.push(String(item)) - } - } - - return lines.join('\n') +function formatPlainArray(data: any[], colors: boolean, indent: string): string { + const itemIndent = `${indent} ` + + return data + .map((item) => { + if (typeof item === 'object' && item !== null) { + const body = formatPlainObject(item, colors, itemIndent) + // Replace the first line's indent with the `- ` marker. + return `${indent}- ${body.slice(itemIndent.length)}` + } + return `${indent}- ${formatPlainScalar(item, colors)}` + }) + .join('\n') } /** - * Format object as plain text with key-value pairs + * Format object as plain text with key-value pairs. Keys with `undefined` or + * `null` values are omitted — plain output is human-facing, and empty fields + * are noise there (json keeps null for scripting). */ -function formatPlainObject(data: Record, colors: boolean): string { +function formatPlainObject(data: Record, colors: boolean, indent: string): string { const lines: string[] = [] for (const [key, value] of Object.entries(data)) { + if (value === undefined || value === null) { + continue + } const keyLabel = colors ? chalk.bold(key) : key if (Array.isArray(value)) { - lines.push(`${keyLabel}:`) - lines.push(...value.map(item => ` ${formatPlainValue(item, colors)}`)) + lines.push(`${indent}${keyLabel}:`) + lines.push(formatPlainArray(value, colors, `${indent} `)) + } + else if (typeof value === 'object') { + lines.push(`${indent}${keyLabel}:`) + lines.push(formatPlainObject(value, colors, `${indent} `)) } else { - lines.push(`${keyLabel}: ${formatPlainValue(value, colors)}`) + lines.push(`${indent}${keyLabel}: ${formatPlainScalar(value, colors)}`) } } @@ -152,18 +160,11 @@ function formatPlainObject(data: Record, colors: boolean): string { } /** - * Format a single value for plain text output + * Format a scalar value for plain text output */ -function formatPlainValue(value: any, colors: boolean): string { - if (typeof value === 'boolean') { - if (colors) { - return value ? chalk.green('true') : chalk.yellow('false') - } - return String(value) - } - - if (typeof value === 'object' && value !== null) { - return formatPlainObject(value, colors) +function formatPlainScalar(value: any, colors: boolean): string { + if (typeof value === 'boolean' && colors) { + return value ? chalk.green('true') : chalk.yellow('false') } return String(value) diff --git a/test/utils/formatter.test.ts b/test/utils/formatter.test.ts index 4572ea1..a2b4fe7 100644 --- a/test/utils/formatter.test.ts +++ b/test/utils/formatter.test.ts @@ -169,6 +169,58 @@ describe('formatOutput', () => { expect(result).toContain('john@example.com') }) + test('should omit undefined values instead of printing "undefined"', () => { + const data = { name: 'John', due_on: undefined } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result).not.toContain('undefined') + expect(result).not.toContain('due_on') + expect(result).toContain('name: John') + }) + + test('should omit null values instead of printing "null"', () => { + const data = { name: 'John', assignee: null } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result).not.toContain('null') + expect(result).not.toContain('assignee') + expect(result).toContain('name: John') + }) + + test('should indent every line of array items and mark item boundaries', () => { + const data = { + tasks: [ + { gid: '123', name: 'Task 1' }, + { gid: '456', name: 'Task 2' }, + ], + } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result.split('\n')).toEqual([ + 'tasks:', + ' - gid: 123', + ' name: Task 1', + ' - gid: 456', + ' name: Task 2', + ]) + }) + + test('should indent nested object lines under their parent key', () => { + const data = { + user: { + name: 'John', + email: 'john@example.com', + }, + } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result.split('\n')).toEqual([ + 'user:', + ' name: John', + ' email: john@example.com', + ]) + }) + test('should not throw error with colors enabled in plain format', () => { const data = { name: 'John', completed: true } From 9e7e5e0effddf9e6b37d6fe539f45b4a1bd909d1 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 6 Jul 2026 21:52:55 +0900 Subject: [PATCH 2/5] docs(formatter): correct TOON token-savings claim to measured ~37% The 58.9% figure was not reproducible; benchmarking real TaskView list output (10-200 rows) with o200k/cl100k tokenizers measures ~37% vs JSON, and ~6% on detail views dominated by long free text. --- src/utils/formatter.ts | 6 ++++-- test/utils/formatter.test.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/utils/formatter.ts b/src/utils/formatter.ts index 904e41e..0bd0431 100644 --- a/src/utils/formatter.ts +++ b/src/utils/formatter.ts @@ -40,7 +40,7 @@ export function getOutputFormat(command: { optsWithGlobals: () => Record]{idname}:\n 1Task 1 * @@ -72,7 +72,9 @@ export function formatOutput(data: any, options: FormatterOptions): string { /** * Format data as TOON (Token-Oriented Object Notation) * - * Uses tab delimiter for maximum token efficiency (58.9% savings vs JSON). + * Uses tab delimiter for token efficiency: ~37% savings vs JSON, measured + * with the o200k/cl100k tokenizers on TaskView-shaped list output (10-200 + * rows). Savings on single-item detail views with long notes are ~6%. * Powered by @pleaseai/cli-toolkit. * * @param data - The data to format diff --git a/test/utils/formatter.test.ts b/test/utils/formatter.test.ts index a2b4fe7..b64b361 100644 --- a/test/utils/formatter.test.ts +++ b/test/utils/formatter.test.ts @@ -23,7 +23,7 @@ describe('formatOutput', () => { } const result = formatOutput(data, { format: 'toon' }) - // cli-toolkit uses tab delimiter (not comma) for 58.9% token savings + // cli-toolkit uses tab delimiter (not comma) for ~37% token savings vs JSON expect(result).toContain('\t') // Should have tabular structure with field names separated by tabs expect(result).toMatch(/\{[^\t}]*\t[^}]*\}/) From d3f269d4c8f0539c73e8368bff9d9c3b4841fc9b Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 6 Jul 2026 22:00:00 +0900 Subject: [PATCH 3/5] chore(formatter): apply AI review suggestions - handle nested arrays in plain format via Array.isArray branch (gemini: nested arrays were rendered as index-keyed objects) - skip empty array/object bodies to avoid stray blank lines (coderabbit/greptile: empty collections emitted a blank line) - add regression tests for both cases --- src/utils/formatter.ts | 14 ++++++++++++-- test/utils/formatter.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/utils/formatter.ts b/src/utils/formatter.ts index 0bd0431..30e86ff 100644 --- a/src/utils/formatter.ts +++ b/src/utils/formatter.ts @@ -122,6 +122,10 @@ function formatPlainArray(data: any[], colors: boolean, indent: string): string return data .map((item) => { + if (Array.isArray(item)) { + const body = formatPlainArray(item, colors, itemIndent) + return `${indent}- ${body.slice(itemIndent.length)}` + } if (typeof item === 'object' && item !== null) { const body = formatPlainObject(item, colors, itemIndent) // Replace the first line's indent with the `- ` marker. @@ -146,12 +150,18 @@ function formatPlainObject(data: Record, colors: boolean, indent: s } const keyLabel = colors ? chalk.bold(key) : key if (Array.isArray(value)) { + const body = formatPlainArray(value, colors, `${indent} `) lines.push(`${indent}${keyLabel}:`) - lines.push(formatPlainArray(value, colors, `${indent} `)) + if (body) { + lines.push(body) + } } else if (typeof value === 'object') { + const body = formatPlainObject(value, colors, `${indent} `) lines.push(`${indent}${keyLabel}:`) - lines.push(formatPlainObject(value, colors, `${indent} `)) + if (body) { + lines.push(body) + } } else { lines.push(`${indent}${keyLabel}: ${formatPlainScalar(value, colors)}`) diff --git a/test/utils/formatter.test.ts b/test/utils/formatter.test.ts index b64b361..0dd3cfd 100644 --- a/test/utils/formatter.test.ts +++ b/test/utils/formatter.test.ts @@ -205,6 +205,35 @@ describe('formatOutput', () => { ]) }) + test('should not emit a blank line for empty arrays or objects', () => { + const data = { tasks: [], meta: {}, name: 'John' } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result.split('\n')).toEqual([ + 'tasks:', + 'meta:', + 'name: John', + ]) + }) + + test('should correctly format and indent nested arrays', () => { + const data = { + matrix: [ + [1, 2], + [3, 4], + ], + } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result.split('\n')).toEqual([ + 'matrix:', + ' - - 1', + ' - 2', + ' - - 3', + ' - 4', + ]) + }) + test('should indent nested object lines under their parent key', () => { const data = { user: { From 724aa53c8ef16702ad8f0c124919384784799be9 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 6 Jul 2026 22:04:22 +0900 Subject: [PATCH 4/5] chore(formatter): indent multiline string continuation lines in plain output Continuation lines of multiline scalar values now align under their key (gemini review suggestion, with the behavior implemented alongside the requested regression test) --- src/utils/formatter.ts | 4 +++- test/utils/formatter.test.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/utils/formatter.ts b/src/utils/formatter.ts index 30e86ff..bc9a488 100644 --- a/src/utils/formatter.ts +++ b/src/utils/formatter.ts @@ -164,7 +164,9 @@ function formatPlainObject(data: Record, colors: boolean, indent: s } } else { - lines.push(`${indent}${keyLabel}: ${formatPlainScalar(value, colors)}`) + // Indent continuation lines of multiline strings under their key. + const scalar = formatPlainScalar(value, colors).split('\n').join(`\n${indent} `) + lines.push(`${indent}${keyLabel}: ${scalar}`) } } diff --git a/test/utils/formatter.test.ts b/test/utils/formatter.test.ts index 0dd3cfd..aa5f4d9 100644 --- a/test/utils/formatter.test.ts +++ b/test/utils/formatter.test.ts @@ -250,6 +250,24 @@ describe('formatOutput', () => { ]) }) + test('should correctly indent multiline strings in plain format', () => { + const data = { + description: 'Line 1\nLine 2', + tasks: [ + { name: 'Task 1\nDetail 1' }, + ], + } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result.split('\n')).toEqual([ + 'description: Line 1', + ' Line 2', + 'tasks:', + ' - name: Task 1', + ' Detail 1', + ]) + }) + test('should not throw error with colors enabled in plain format', () => { const data = { name: 'John', completed: true } From b1284e782fda82d61070111887d7bbae9dae7b70 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 6 Jul 2026 22:07:36 +0900 Subject: [PATCH 5/5] chore(formatter): indent multiline scalar array items under their marker Continuation lines of multiline strings in array items now align under the `- ` marker content, matching the object-value behavior (gemini review suggestion, with regression test) --- src/utils/formatter.ts | 4 +++- test/utils/formatter.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/utils/formatter.ts b/src/utils/formatter.ts index bc9a488..89431a7 100644 --- a/src/utils/formatter.ts +++ b/src/utils/formatter.ts @@ -131,7 +131,9 @@ function formatPlainArray(data: any[], colors: boolean, indent: string): string // Replace the first line's indent with the `- ` marker. return `${indent}- ${body.slice(itemIndent.length)}` } - return `${indent}- ${formatPlainScalar(item, colors)}` + // Indent continuation lines of multiline strings under the marker. + const scalar = formatPlainScalar(item, colors).split('\n').join(`\n${indent} `) + return `${indent}- ${scalar}` }) .join('\n') } diff --git a/test/utils/formatter.test.ts b/test/utils/formatter.test.ts index aa5f4d9..a99db39 100644 --- a/test/utils/formatter.test.ts +++ b/test/utils/formatter.test.ts @@ -268,6 +268,18 @@ describe('formatOutput', () => { ]) }) + test('should indent multiline scalar array items under their marker', () => { + const data = { tags: ['line1\nline2', 'single'] } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result.split('\n')).toEqual([ + 'tags:', + ' - line1', + ' line2', + ' - single', + ]) + }) + test('should not throw error with colors enabled in plain format', () => { const data = { name: 'John', completed: true }