feat: add --compact flag for minimal task list output (#1054)
* feat: add --compact flag for minimal task list output --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
12
.changeset/cute-files-pay.md
Normal file
12
.changeset/cute-files-pay.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
"task-master-ai": minor
|
||||
---
|
||||
|
||||
Add compact mode --compact / -c flag to the `tm list` CLI command
|
||||
|
||||
- outputs tasks in a minimal, git-style one-line format. This reduces verbose output from ~30+ lines of dashboards and tables to just 1 line per task, making it much easier to quickly scan available tasks.
|
||||
- Git-style format: ID STATUS TITLE (PRIORITY) → DEPS
|
||||
- Color-coded status, priority, and dependencies
|
||||
- Smart title truncation and dependency abbreviation
|
||||
- Subtask support with indentation
|
||||
- Full backward compatibility with existing list options
|
||||
@@ -1,4 +1,4 @@
|
||||
# Available Models as of August 8, 2025
|
||||
# Available Models as of August 11, 2025
|
||||
|
||||
## Main Models
|
||||
|
||||
|
||||
@@ -1753,6 +1753,7 @@ function registerCommands(programInstance) {
|
||||
)
|
||||
.option('-s, --status <status>', 'Filter by status')
|
||||
.option('--with-subtasks', 'Show subtasks for each task')
|
||||
.option('-c, --compact', 'Display tasks in compact one-line format')
|
||||
.option('--tag <tag>', 'Specify tag context for task operations')
|
||||
.action(async (options) => {
|
||||
// Initialize TaskMaster
|
||||
@@ -1770,18 +1771,21 @@ function registerCommands(programInstance) {
|
||||
|
||||
const statusFilter = options.status;
|
||||
const withSubtasks = options.withSubtasks || false;
|
||||
const compact = options.compact || false;
|
||||
const tag = taskMaster.getCurrentTag();
|
||||
// Show current tag context
|
||||
displayCurrentTagIndicator(tag);
|
||||
|
||||
console.log(
|
||||
chalk.blue(`Listing tasks from: ${taskMaster.getTasksPath()}`)
|
||||
);
|
||||
if (statusFilter) {
|
||||
console.log(chalk.blue(`Filtering by status: ${statusFilter}`));
|
||||
}
|
||||
if (withSubtasks) {
|
||||
console.log(chalk.blue('Including subtasks in listing'));
|
||||
if (!compact) {
|
||||
console.log(
|
||||
chalk.blue(`Listing tasks from: ${taskMaster.getTasksPath()}`)
|
||||
);
|
||||
if (statusFilter) {
|
||||
console.log(chalk.blue(`Filtering by status: ${statusFilter}`));
|
||||
}
|
||||
if (withSubtasks) {
|
||||
console.log(chalk.blue('Including subtasks in listing'));
|
||||
}
|
||||
}
|
||||
|
||||
await listTasks(
|
||||
@@ -1789,7 +1793,7 @@ function registerCommands(programInstance) {
|
||||
statusFilter,
|
||||
taskMaster.getComplexityReportPath(),
|
||||
withSubtasks,
|
||||
'text',
|
||||
compact ? 'compact' : 'text',
|
||||
{ projectRoot: taskMaster.getProjectRoot(), tag }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -294,6 +294,11 @@ function listTasks(
|
||||
});
|
||||
}
|
||||
|
||||
// For compact output, return minimal one-line format
|
||||
if (outputFormat === 'compact') {
|
||||
return renderCompactOutput(filteredTasks, withSubtasks);
|
||||
}
|
||||
|
||||
// ... existing code for text output ...
|
||||
|
||||
// Calculate status breakdowns as percentages of total
|
||||
@@ -962,4 +967,98 @@ function generateMarkdownOutput(data, filteredTasks, stats) {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format dependencies for compact output with truncation and coloring
|
||||
* @param {Array} dependencies - Array of dependency IDs
|
||||
* @returns {string} - Formatted dependency string with arrow prefix
|
||||
*/
|
||||
function formatCompactDependencies(dependencies) {
|
||||
if (!dependencies || dependencies.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (dependencies.length > 5) {
|
||||
const visible = dependencies.slice(0, 5).join(',');
|
||||
const remaining = dependencies.length - 5;
|
||||
return ` → ${chalk.cyan(visible)}${chalk.gray('... (+' + remaining + ' more)')}`;
|
||||
} else {
|
||||
return ` → ${chalk.cyan(dependencies.join(','))}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single task in compact one-line format
|
||||
* @param {Object} task - Task object
|
||||
* @param {number} maxTitleLength - Maximum title length before truncation
|
||||
* @returns {string} - Formatted task line
|
||||
*/
|
||||
function formatCompactTask(task, maxTitleLength = 50) {
|
||||
const status = task.status || 'pending';
|
||||
const priority = task.priority || 'medium';
|
||||
const title = truncate(task.title || 'Untitled', maxTitleLength);
|
||||
|
||||
// Use colored status from existing function
|
||||
const coloredStatus = getStatusWithColor(status, true);
|
||||
|
||||
// Color priority based on level
|
||||
const priorityColors = {
|
||||
high: chalk.red,
|
||||
medium: chalk.yellow,
|
||||
low: chalk.gray
|
||||
};
|
||||
const priorityColor = priorityColors[priority] || chalk.white;
|
||||
|
||||
// Format dependencies using shared helper
|
||||
const depsText = formatCompactDependencies(task.dependencies);
|
||||
|
||||
return `${chalk.cyan(task.id)} ${coloredStatus} ${chalk.white(title)} ${priorityColor('(' + priority + ')')}${depsText}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a subtask in compact format with indentation
|
||||
* @param {Object} subtask - Subtask object
|
||||
* @param {string|number} parentId - Parent task ID
|
||||
* @param {number} maxTitleLength - Maximum title length before truncation
|
||||
* @returns {string} - Formatted subtask line
|
||||
*/
|
||||
function formatCompactSubtask(subtask, parentId, maxTitleLength = 47) {
|
||||
const status = subtask.status || 'pending';
|
||||
const title = truncate(subtask.title || 'Untitled', maxTitleLength);
|
||||
|
||||
// Use colored status from existing function
|
||||
const coloredStatus = getStatusWithColor(status, true);
|
||||
|
||||
// Format dependencies using shared helper
|
||||
const depsText = formatCompactDependencies(subtask.dependencies);
|
||||
|
||||
return ` ${chalk.cyan(parentId + '.' + subtask.id)} ${coloredStatus} ${chalk.dim(title)}${depsText}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render complete compact output
|
||||
* @param {Array} filteredTasks - Tasks to display
|
||||
* @param {boolean} withSubtasks - Whether to include subtasks
|
||||
* @returns {void} - Outputs directly to console
|
||||
*/
|
||||
function renderCompactOutput(filteredTasks, withSubtasks) {
|
||||
if (filteredTasks.length === 0) {
|
||||
console.log('No tasks found');
|
||||
return;
|
||||
}
|
||||
|
||||
const output = [];
|
||||
|
||||
filteredTasks.forEach((task) => {
|
||||
output.push(formatCompactTask(task));
|
||||
|
||||
if (withSubtasks && task.subtasks && task.subtasks.length > 0) {
|
||||
task.subtasks.forEach((subtask) => {
|
||||
output.push(formatCompactSubtask(subtask, task.id));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(output.join('\n'));
|
||||
}
|
||||
|
||||
export default listTasks;
|
||||
|
||||
@@ -1430,6 +1430,20 @@ function ensureTagMetadata(tagObj, opts = {}) {
|
||||
return tagObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI color codes from a string
|
||||
* Useful for testing, logging to files, or when clean text output is needed
|
||||
* @param {string} text - The text that may contain ANSI color codes
|
||||
* @returns {string} - The text with ANSI color codes removed
|
||||
*/
|
||||
function stripAnsiCodes(text) {
|
||||
if (typeof text !== 'string') {
|
||||
return text;
|
||||
}
|
||||
// Remove ANSI escape sequences (color codes, cursor movements, etc.)
|
||||
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
// Export all utility functions and configuration
|
||||
export {
|
||||
LOG_LEVELS,
|
||||
@@ -1467,5 +1481,6 @@ export {
|
||||
markMigrationForNotice,
|
||||
flattenTasksWithSubtasks,
|
||||
ensureTagMetadata,
|
||||
stripAnsiCodes,
|
||||
normalizeTaskIds
|
||||
};
|
||||
|
||||
@@ -22,7 +22,10 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
|
||||
),
|
||||
addComplexityToTask: jest.fn(),
|
||||
readComplexityReport: jest.fn(() => null),
|
||||
getTagAwareFilePath: jest.fn((tag, path) => '/mock/tagged/report.json')
|
||||
getTagAwareFilePath: jest.fn((tag, path) => '/mock/tagged/report.json'),
|
||||
stripAnsiCodes: jest.fn((text) =>
|
||||
text ? text.replace(/\x1b\[[0-9;]*m/g, '') : text
|
||||
)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
|
||||
@@ -45,8 +48,13 @@ jest.unstable_mockModule(
|
||||
);
|
||||
|
||||
// Import the mocked modules
|
||||
const { readJSON, log, readComplexityReport, addComplexityToTask } =
|
||||
await import('../../../../../scripts/modules/utils.js');
|
||||
const {
|
||||
readJSON,
|
||||
log,
|
||||
readComplexityReport,
|
||||
addComplexityToTask,
|
||||
stripAnsiCodes
|
||||
} = await import('../../../../../scripts/modules/utils.js');
|
||||
const { displayTaskList } = await import(
|
||||
'../../../../../scripts/modules/ui.js'
|
||||
);
|
||||
@@ -584,4 +592,140 @@ describe('listTasks', () => {
|
||||
expect(taskIds).toContain(5); // review task
|
||||
});
|
||||
});
|
||||
|
||||
describe('Compact output format', () => {
|
||||
test('should output compact format when outputFormat is compact', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
|
||||
await listTasks(tasksPath, null, null, false, 'compact', {
|
||||
tag: 'master'
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
const output = consoleSpy.mock.calls.map((call) => call[0]).join('\n');
|
||||
// Strip ANSI color codes for testing
|
||||
const cleanOutput = stripAnsiCodes(output);
|
||||
|
||||
// Should contain compact format elements: ID status title (priority) [→ dependencies]
|
||||
expect(cleanOutput).toContain('1 done Setup Project (high)');
|
||||
expect(cleanOutput).toContain(
|
||||
'2 pending Implement Core Features (high) → 1'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should format single task compactly', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
|
||||
await listTasks(tasksPath, null, null, false, 'compact', {
|
||||
tag: 'master'
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
const output = consoleSpy.mock.calls.map((call) => call[0]).join('\n');
|
||||
|
||||
// Should be compact (no verbose headers)
|
||||
expect(output).not.toContain('Project Dashboard');
|
||||
expect(output).not.toContain('Progress:');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should handle compact format with subtasks', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
|
||||
await listTasks(
|
||||
tasksPath,
|
||||
null,
|
||||
null,
|
||||
true, // withSubtasks = true
|
||||
'compact',
|
||||
{ tag: 'master' }
|
||||
);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
const output = consoleSpy.mock.calls.map((call) => call[0]).join('\n');
|
||||
// Strip ANSI color codes for testing
|
||||
const cleanOutput = stripAnsiCodes(output);
|
||||
|
||||
// Should handle both tasks and subtasks
|
||||
expect(cleanOutput).toContain('1 done Setup Project (high)');
|
||||
expect(cleanOutput).toContain('3.1 done Create Header Component');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should handle empty task list in compact format', async () => {
|
||||
readJSON.mockReturnValue({ tasks: [] });
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
|
||||
await listTasks(tasksPath, null, null, false, 'compact', {
|
||||
tag: 'master'
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('No tasks found');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should format dependencies correctly with shared helper', async () => {
|
||||
// Create mock tasks with various dependency scenarios
|
||||
const tasksWithDeps = {
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Task with no dependencies',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Task with few dependencies',
|
||||
status: 'pending',
|
||||
priority: 'high',
|
||||
dependencies: [1, 3]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Task with many dependencies',
|
||||
status: 'pending',
|
||||
priority: 'low',
|
||||
dependencies: [1, 2, 4, 5, 6, 7, 8, 9]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
readJSON.mockReturnValue(tasksWithDeps);
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
const tasksPath = 'tasks/tasks.json';
|
||||
|
||||
await listTasks(tasksPath, null, null, false, 'compact', {
|
||||
tag: 'master'
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
const output = consoleSpy.mock.calls.map((call) => call[0]).join('\n');
|
||||
// Strip ANSI color codes for testing
|
||||
const cleanOutput = stripAnsiCodes(output);
|
||||
|
||||
// Should format tasks correctly with compact output including priority
|
||||
expect(cleanOutput).toContain(
|
||||
'1 pending Task with no dependencies (medium)'
|
||||
);
|
||||
expect(cleanOutput).toContain('Task with few dependencies');
|
||||
expect(cleanOutput).toContain('Task with many dependencies');
|
||||
// Should show dependencies with arrow when they exist
|
||||
expect(cleanOutput).toMatch(/2.*→.*1,3/);
|
||||
// Should truncate many dependencies with "+X more" format
|
||||
expect(cleanOutput).toMatch(/3.*→.*1,2,4,5,6.*\(\+\d+ more\)/);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
56
tests/unit/utils-strip-ansi.test.js
Normal file
56
tests/unit/utils-strip-ansi.test.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Tests for the stripAnsiCodes utility function
|
||||
*/
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Import the module under test
|
||||
const { stripAnsiCodes } = await import('../../scripts/modules/utils.js');
|
||||
|
||||
describe('stripAnsiCodes', () => {
|
||||
test('should remove ANSI color codes from text', () => {
|
||||
const textWithColors = '\x1b[31mRed text\x1b[0m \x1b[32mGreen text\x1b[0m';
|
||||
const result = stripAnsiCodes(textWithColors);
|
||||
expect(result).toBe('Red text Green text');
|
||||
});
|
||||
|
||||
test('should handle text without ANSI codes', () => {
|
||||
const plainText = 'This is plain text';
|
||||
const result = stripAnsiCodes(plainText);
|
||||
expect(result).toBe('This is plain text');
|
||||
});
|
||||
|
||||
test('should handle empty string', () => {
|
||||
const result = stripAnsiCodes('');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
test('should handle complex ANSI sequences', () => {
|
||||
// Test with various ANSI escape sequences
|
||||
const complexText =
|
||||
'\x1b[1;31mBold red\x1b[0m \x1b[4;32mUnderlined green\x1b[0m \x1b[33;46mYellow on cyan\x1b[0m';
|
||||
const result = stripAnsiCodes(complexText);
|
||||
expect(result).toBe('Bold red Underlined green Yellow on cyan');
|
||||
});
|
||||
|
||||
test('should handle non-string input gracefully', () => {
|
||||
expect(stripAnsiCodes(null)).toBe(null);
|
||||
expect(stripAnsiCodes(undefined)).toBe(undefined);
|
||||
expect(stripAnsiCodes(123)).toBe(123);
|
||||
expect(stripAnsiCodes({})).toEqual({});
|
||||
});
|
||||
|
||||
test('should handle real chalk output patterns', () => {
|
||||
// Test patterns similar to what chalk produces
|
||||
const chalkLikeText =
|
||||
'1 \x1b[32m✓ done\x1b[39m Setup Project \x1b[31m(high)\x1b[39m';
|
||||
const result = stripAnsiCodes(chalkLikeText);
|
||||
expect(result).toBe('1 ✓ done Setup Project (high)');
|
||||
});
|
||||
|
||||
test('should handle multiline text with ANSI codes', () => {
|
||||
const multilineText =
|
||||
'\x1b[31mLine 1\x1b[0m\n\x1b[32mLine 2\x1b[0m\n\x1b[33mLine 3\x1b[0m';
|
||||
const result = stripAnsiCodes(multilineText);
|
||||
expect(result).toBe('Line 1\nLine 2\nLine 3');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user