Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ Options:
`-c, --children` — Also display descendants in a tree layout (optional).
`--prefix <prefix>` (optional)

The output always includes `Risk` and `Effort` fields. When a field has no value a placeholder `—` is shown so the field is consistently visible for triage and prioritization.

Examples:

```sh
Expand Down
1 change: 1 addition & 0 deletions TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This document describes the interactive terminal UI shipped as the `wl tui` (or
- The TUI presents a tree view of work items on the left and a details pane on the right.
- It can show all items, or be limited to in-progress items via `--in-progress`.
- The details pane uses the same human formatter as the CLI so what you see in the TUI matches `wl show --format full`.
- The metadata pane (top-right) shows Status, Stage, Priority, Risk, Effort, Comments, Tags, Assignee, Created, Updated, and GitHub link for the selected item. `Risk` and `Effort` always appear; when a field has no value a placeholder `—` is shown.
- Integrated OpenCode AI assistant for intelligent work item management and coding assistance.

## Controls
Expand Down
14 changes: 8 additions & 6 deletions src/commands/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ export function displayItemTree(items: WorkItem[]): void {
? `Status: ${item.status} · Stage: ${effectiveStage} | Priority: ${item.priority}`
: `Status: ${item.status} | Priority: ${item.priority}`;
console.log(`${detailIndent}${statusSummary}`);
if (item.risk) console.log(`${detailIndent}Risk: ${item.risk}`);
if (item.effort) console.log(`${detailIndent}Effort: ${item.effort}`);
console.log(`${detailIndent}Risk: ${item.risk || '—'}`);
console.log(`${detailIndent}Effort: ${item.effort || '—'}`);
if (item.assignee) console.log(`${detailIndent}Assignee: ${item.assignee}`);
if (item.tags.length > 0) console.log(`${detailIndent}Tags: ${item.tags.join(', ')}`);
}
Expand Down Expand Up @@ -257,8 +257,8 @@ export function humanFormatWorkItem(item: WorkItem, db: WorklogDatabase | null,
lines.push(`Status: ${statusLabel} | Priority: ${item.priority}`);
}
lines.push(sortIndexLabel);
if (item.risk) lines.push(`Risk: ${item.risk}`);
if (item.effort) lines.push(`Effort: ${item.effort}`);
lines.push(`Risk: ${item.risk || '—'}`);
lines.push(`Effort: ${item.effort || '—'}`);
if (item.assignee) lines.push(`Assignee: ${item.assignee}`);
if (item.tags && item.tags.length > 0) lines.push(`Tags: ${item.tags.join(', ')}`);
return lines.join('\n');
Expand All @@ -277,8 +277,8 @@ export function humanFormatWorkItem(item: WorkItem, db: WorklogDatabase | null,
lines.push(`Status: ${statusLabel} | Priority: ${item.priority}`);
}
lines.push(sortIndexLabel);
if (item.risk) lines.push(`Risk: ${item.risk}`);
if (item.effort) lines.push(`Effort: ${item.effort}`);
lines.push(`Risk: ${item.risk || '—'}`);
lines.push(`Effort: ${item.effort || '—'}`);
if (item.assignee) lines.push(`Assignee: ${item.assignee}`);
if (item.parentId) lines.push(`Parent: ${item.parentId}`);
if (item.description) lines.push(`Description: ${item.description}`);
Expand Down Expand Up @@ -323,7 +323,9 @@ export function humanFormatWorkItem(item: WorkItem, db: WorklogDatabase | null,
['SortIndex', String(item.sortIndex)]
];
if (item.risk) frontmatter.push(['Risk', item.risk]);
else frontmatter.push(['Risk', '—']);
if (item.effort) frontmatter.push(['Effort', item.effort]);
else frontmatter.push(['Effort', '—']);
if (item.assignee) frontmatter.push(['Assignee', item.assignee]);
if (item.parentId) frontmatter.push(['Parent', item.parentId]);
if (item.tags && item.tags.length > 0) frontmatter.push(['Tags', item.tags.join(', ')]);
Expand Down
5 changes: 5 additions & 0 deletions src/tui/components/metadata-pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export class MetadataPaneComponent {
status?: string;
stage?: string;
priority?: string;
risk?: string;
effort?: string;
tags?: string[];
assignee?: string;
createdAt?: Date | string;
Expand All @@ -75,10 +77,13 @@ export class MetadataPaneComponent {
this.box.setContent('');
return;
}
const placeholder = '—';
const lines: string[] = [];
lines.push(`Status: ${item.status ?? ''}`);
lines.push(`Stage: ${item.stage ?? ''}`);
lines.push(`Priority: ${item.priority ?? ''}`);
lines.push(`Risk: ${item.risk && item.risk.trim() ? item.risk : placeholder}`);
lines.push(`Effort: ${item.effort && item.effort.trim() ? item.effort : placeholder}`);
lines.push(`Comments: ${commentCount}`);
lines.push(`Tags: ${item.tags && item.tags.length > 0 ? item.tags.join(', ') : ''}`);
lines.push(`Assignee: ${item.assignee ?? ''}`);
Expand Down
50 changes: 50 additions & 0 deletions tests/cli/helpers-tree-rendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,54 @@ describe('tree rendering helpers', () => {
expect(childBIndex).toBeGreaterThanOrEqual(0);
expect(childBIndex).toBeLessThan(childAIndex);
});

it('shows Risk and Effort placeholders when fields are empty', () => {
const item = baseWorkItem({ id: 'TEST-RISK-1', title: 'Risk Test', risk: '', effort: '' });

const { lines, spy } = captureConsole();
displayItemTree([item]);
spy.mockRestore();

const normalized = lines.map(stripAnsi);
expect(normalized.some(line => line.includes('Risk: —'))).toBe(true);
expect(normalized.some(line => line.includes('Effort: —'))).toBe(true);
});

it('shows Risk and Effort values when set', () => {
const item = baseWorkItem({ id: 'TEST-RISK-2', title: 'Risk Set', risk: 'High', effort: 'M' });

const { lines, spy } = captureConsole();
displayItemTree([item]);
spy.mockRestore();

const normalized = lines.map(stripAnsi);
expect(normalized.some(line => line.includes('Risk: High'))).toBe(true);
expect(normalized.some(line => line.includes('Effort: M'))).toBe(true);
});

it('shows Risk and Effort in concise format output', () => {
const item = baseWorkItem({ id: 'TEST-RISK-3', title: 'Concise Test', risk: 'Low', effort: 'XS' });
const items = [item];

const { lines, spy } = captureConsole();
displayItemTreeWithFormat(items, null, 'concise');
spy.mockRestore();

const normalized = lines.map(stripAnsi).join('\n');
expect(normalized).toContain('Risk: Low');
expect(normalized).toContain('Effort: XS');
});

it('shows Risk and Effort placeholders in normal format when fields are empty', () => {
const item = baseWorkItem({ id: 'TEST-RISK-4', title: 'Normal Test', risk: '', effort: '' });
const items = [item];

const { lines, spy } = captureConsole();
displayItemTreeWithFormat(items, null, 'normal');
spy.mockRestore();

const normalized = lines.map(stripAnsi).join('\n');
expect(normalized).toContain('Risk: —');
expect(normalized).toContain('Effort: —');
});
});
10 changes: 6 additions & 4 deletions tests/tui/tui-50-50-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,8 @@ describe('TUI 50/50 split layout', () => {
expect(capturedContent).toContain('in-progress');
expect(capturedContent).toContain('Priority:');
expect(capturedContent).toContain('high');
expect(capturedContent).toContain('Risk:');
expect(capturedContent).toContain('Effort:');
expect(capturedContent).toContain('Comments: 3');
expect(capturedContent).toContain('Tags:');
expect(capturedContent).toContain('backend');
Expand All @@ -460,11 +462,11 @@ describe('TUI 50/50 split layout', () => {
expect(capturedContent).toContain('Jan 1, 2024');
expect(capturedContent).toContain('Updated:');
expect(capturedContent).toContain('Jun 1, 2024');
// GitHub row is always present (9th row)
// GitHub row is always present (11th row)
expect(capturedContent).toContain('GitHub:');
// All rows should always be present (consistent layout)
const lines = capturedContent.split('\n');
expect(lines.length).toBe(9);
expect(lines.length).toBe(11);
});

it('MetadataPaneComponent.updateFromItem clears content for null item', () => {
Expand Down Expand Up @@ -518,9 +520,9 @@ describe('TUI 50/50 split layout', () => {
assignee: '',
}, 0);

// All 9 rows should always be present for consistent layout
// All 11 rows should always be present for consistent layout
const lines = capturedContent.split('\n');
expect(lines.length).toBe(9);
expect(lines.length).toBe(11);
expect(capturedContent).toContain('Status:');
expect(capturedContent).toContain('Tags:');
expect(capturedContent).toContain('Assignee:');
Expand Down
37 changes: 32 additions & 5 deletions tests/tui/tui-github-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,20 +103,20 @@ describe('MetadataPaneComponent GitHub row', () => {
expect(content).toContain('G to push');
});

it('always renders exactly 9 rows regardless of GitHub state', () => {
it('always renders exactly 11 rows regardless of GitHub state', () => {
const { comp, getContent } = createMockMetadataPane();

// With no github fields
comp.updateFromItem({ status: 'open' }, 0);
expect(getContent().split('\n').length).toBe(9);
expect(getContent().split('\n').length).toBe(11);

// With github mapping
comp.updateFromItem({ status: 'open', githubRepo: 'o/r', githubIssueNumber: 1 }, 0);
expect(getContent().split('\n').length).toBe(9);
expect(getContent().split('\n').length).toBe(11);

// With github configured but no mapping
comp.updateFromItem({ status: 'open', githubRepo: 'o/r' }, 0);
expect(getContent().split('\n').length).toBe(9);
expect(getContent().split('\n').length).toBe(11);
});

it('clears content for null item', () => {
Expand All @@ -127,8 +127,35 @@ describe('MetadataPaneComponent GitHub row', () => {
});

// ---------------------------------------------------------------------------
// Integration tests: controller passes github fields to updateFromItem
// Unit tests: MetadataPaneComponent Risk and Effort row rendering
// ---------------------------------------------------------------------------
describe('MetadataPaneComponent Risk and Effort rows', () => {
it('shows placeholder when risk and effort are not set', () => {
const { comp, getContent } = createMockMetadataPane();
comp.updateFromItem({ status: 'open' }, 0);
expect(getContent()).toContain('Risk:');
expect(getContent()).toContain('Effort:');
expect(getContent()).toMatch(/Risk:\s+—/);
expect(getContent()).toMatch(/Effort:\s+—/);
});

it('shows risk and effort values when set', () => {
const { comp, getContent } = createMockMetadataPane();
comp.updateFromItem({ status: 'open', risk: 'High', effort: 'M' }, 0);
const content = getContent();
expect(content).toMatch(/Risk:\s+High/);
expect(content).toMatch(/Effort:\s+M/);
});

it('shows placeholder when risk and effort are empty strings', () => {
const { comp, getContent } = createMockMetadataPane();
comp.updateFromItem({ status: 'open', risk: '', effort: '' }, 0);
const content = getContent();
expect(content).toMatch(/Risk:\s+—/);
expect(content).toMatch(/Effort:\s+—/);
});
});

describe('TUI metadata pane receives GitHub fields', () => {
it('calls updateFromItem after start', async () => {
const ctx = createTuiTestContext();
Expand Down
Loading