Skip to content
Closed
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
14 changes: 12 additions & 2 deletions packages/herdr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b
- **Browse work items** — Lists work items from `wl next` in a scrollable, keyboard-navigable list. The top-level list is root-only: child work items are hidden and appear only under their parent via expand — **at any depth** (epic → feature → task and deeper): any item with children (its `childCount > 0`) can be expanded with Tab/Enter, its children fetched on demand via `wl list --parent` and shown indented at their hierarchy depth (WL-0MSQ3FH1K000MMJW). Expanded parents **stay expanded across refreshes**: each auto/manual refresh re-fetches their children in parallel with the top-level list and swaps both in atomically, so the hierarchy never momentarily collapses or flickers (WL-0MSBVBNGH002RDP5).
- **Filter by stage** — Press `f` followed by a chord key (`i`=idea, `n`=intake, `p`=plan, `r`=review, `s`=sprint back to the default view), or type `/wl <stage>` (shorthand alias or canonical stage name, e.g. `/wl intake_complete` or `/wl progress`), to filter items by stage. Stage-filtered views show every root item in the selected stage matching the stage's status rule (open items for most stages; `completed`/`in-progress`/`open` for the in_review stage) — no `browseItemCount` cap and no `wl next` selection omission (WL-0MSDT8X1V003206G, WL-0MSKCRX730052IIW)
- **View details** — Press Enter on any item to see its full details (description, acceptance criteria, metadata, tags, priority, GitHub issue number, and audit status information such as audit result, review status, and last audit timestamp)
- **Audit indicators** — The list view shows audit icons next to `in_review` items (✅ audited, ❌ failed, ❓ unaudited). The detail view metadata section additionally shows the review status (❌ needs review / ✅ reviewed) and the last audit timestamp.
- **Audit indicators** — The list view shows audit icons next to `in_review` items (✅ audited, ❌ failed, ❓ unaudited). The metadata section (list-mode panel and detail view) mirrors the list's icons with text labels — the selected item's Stage row uses the same audit-aware `in_review` icon (✅/❌/❓ fresh, ⏳ stale-passed, 🔍 otherwise), and the Audit/Reviewed rows pair their icons with text (e.g. `✅ ready to close`, `❌ needs review`). The detail view additionally shows the last audit timestamp.
- **Chord shortcuts** — Multi-key chord sequences provide quick actions like updating priorities, stage/status, title, closing/deleting items, running workflows, and toggling review status (configurable via `shortcuts.json`)
- **Command output** — When a chord resolves to a non-`/wl` command (e.g., `!!wl update <id> --priority high`), the resolved command is executed **visibly in a new herdr pane** (see `scripts/run-in-pane.sh`) so the user sees the command line and its output; the wrapper keeps the pane's process alive so the pane stays open for inspection — dismiss it with Enter or close it with `prefix+x`
- **Command input form** — When a chord command contains unknown `<identifier>` placeholders (e.g. `!!wl update <id> --status <status> --stage <stage>`), the plugin shows a modal input form so you can fill in the values before the command runs. Known identifiers like `<id>` are still auto-substituted with the selected item's ID. The form is a full-pane page (no border/centering) that wraps text at the pane width and grows downward as content is entered. See [Command input form](#command-input-form).
Expand Down Expand Up @@ -177,7 +177,7 @@ Settings are persisted in `~/.config/herdr/worklog-plugin.json`. Key settings in
- `syncIntervalMs` — Interval in ms between background `wl sync` calls (default: `60000`, minimum: `60000`; set to `0` to disable auto-sync)
- `browseItemCount` — Max number of non-mandatory items to show in the list (default: `20`, range `1`–`50`; critical and completed/in_review items are always shown regardless)
- `showHelpText` — Show the shortcut hint line at the bottom of the list (default: `true`). When `false`, **all** shortcut hint lines are hidden — including the chord-in-progress footer (`chord: <keys> _ <hints>`) — consistent with the pi browse widget (WL-0MSGJDSMJ004128E). Chord key *handling* still works while hints are hidden; only rendering is affected. Changes apply on the next render without a plugin restart
- `showIcons` — Toggle icons in the list (default: `true`); changes apply on the next render without a plugin restart
- `showIcons` — Toggle icons in the list and metadata (default: `true`); changes apply on the next render without a plugin restart. When disabled, list rows use text fallbacks (`[OPEN]`, `[IDEA]`, …) and metadata values fall back to plain text (no emoji)

### Downtime worker (local-LLM idle dispatch)

Expand Down Expand Up @@ -608,6 +608,16 @@ clamped to a minimum of 3 rows so it is always usable.
metadata (status, stage, priority, type, risk, effort, children/parent
counts, tags, GitHub issue number, created/updated timestamps, and audit
state).
- Metadata values are rendered as **icon + text label** using the same icon
helpers as the list rows, so the two sections can never diverge: e.g.
`🔄 in_progress`, `⭐ high`, `📥 intake_complete`. The Stage row mirrors
the list's audit-aware `in_review` icon (✅/❌/❓ when the audit is fresh,
⏳ when stale-but-passed, 🔍 otherwise); the Type row shows the epic icon
(⊙) for `epic` items only (other types stay text-only); and the Audit and
Reviewed rows pair their icons with text labels (`✅ ready to close`,
`❌ needs review`, …). When icons are disabled (`showIcons: false`) every
metadata value falls back to **plain text** — no emoji and no bracketed
fallbacks (unlike the list rows, which use `[OPEN]`-style labels).
- When the item's description has a `Key Files:` section containing one or
more `.md` paths, the panel also shows a **`Related Docs`** row listing
every markdown path (joined with `, `; long values are truncated to the
Expand Down
32 changes: 32 additions & 0 deletions packages/herdr/src/icons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
agentStatusIcon,
getIconPrefix,
iconsEnabled,
stageDisplayIcon,
stringDisplayWidth,
} from './icons.js';
import { formatItemLine } from './worklist.js';
Expand Down Expand Up @@ -67,6 +68,37 @@ describe('agentStatusIcon', () => {
});
});

describe('stageDisplayIcon — audit-aware stage icon shared by list and metadata (WL-0MSGIXHHI009KFW9)', () => {
const item = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
stage: 'in_review',
auditResult: null,
auditedAt: null,
updatedAt: '2026-08-02T10:00:00.000Z',
...over,
});

it('shows the audit-result icon for a fresh audit (✅/❌/❓)', () => {
expect(stageDisplayIcon(item({ auditResult: true, auditedAt: '2026-08-02T10:00:30.000Z' }))).toBe('\u{2705}');
expect(stageDisplayIcon(item({ auditResult: false, auditedAt: '2026-08-02T10:00:30.000Z' }))).toBe('\u{274C}');
expect(stageDisplayIcon(item({ auditedAt: '2026-08-02T10:00:30.000Z' }))).toBe('\u{2753}');
});

it('shows the stale-passed hourglass when the audit is stale but passed', () => {
expect(stageDisplayIcon(item({ auditResult: true, auditedAt: '2026-08-01T10:00:00.000Z' }))).toBe('\u{23F3}');
});

it('falls back to the plain stage icon otherwise (stale/no audit, non-in_review stages)', () => {
expect(stageDisplayIcon(item())).toBe('\u{1F50D}'); // 🔍 no audit
expect(stageDisplayIcon(item({ stage: 'idea' }))).toBe('\u{1F4A1}'); // 💡
expect(stageDisplayIcon({ stage: 'in_progress' })).toBe('\u{1F6E0}\u{FE0F}'); // 🛠️
});

it('honours the noIcons flag like the other icon helpers', () => {
expect(stageDisplayIcon(item({ stage: 'idea' }), { noIcons: true })).toBe('[IDEA]');
expect(stageDisplayIcon(item({ auditResult: true, auditedAt: '2026-08-02T10:00:30.000Z' }), { noIcons: true })).toBe('[ready]');
});
});

describe('getIconPrefix — fixed-width agent slot (AC3 alignment invariance)', () => {
const base = { status: 'open', stage: 'idea' } as const;
const withAgent = { status: 'open', stage: 'idea', agentState: 'working' } as const;
Expand Down
49 changes: 31 additions & 18 deletions packages/herdr/src/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,33 @@ export function isAuditFresh(
return auditTime > updateTime - 60000;
}

/**
* Get the display icon for an item's stage with the list's audit-aware
* `in_review` handling: a fresh audit shows the audit-result icon
* (✅/❌/❓), a stale-but-passed audit shows the stale-passed hourglass
* (⏳), a stale or missing audit on an `in_review` item falls back to the
* plain stage icon (🔍), and every other stage shows the plain stage icon.
*
* Shared by the list row prefix (`getIconPrefix`) and the metadata Stage
* row so the two sections can never diverge (WL-0MSGIXHHI009KFW9 AC2).
*/
export function stageDisplayIcon(
item: { stage?: string; auditResult?: boolean | null; auditedAt?: string | null; updatedAt?: string },
opts?: IconOptions,
): string {
const noIcons = opts?.noIcons ?? false;
if (item.stage === 'in_review') {
const fresh = isAuditFresh(item.auditedAt, item.updatedAt);
if (fresh) {
return auditIcon(item.auditResult, { noIcons });
}
if (item.auditResult === true) {
return auditStaleIcon(item.auditResult, { noIcons });
}
}
return stageIcon(item.stage, { noIcons });
}

// ── Stage colour ──────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -361,24 +388,10 @@ export function getIconPrefix(

const sIcon = statusIcon(item.status, { noIcons });

// Column 2: stage or audit-aware icon for in_review
let secondIcon: string;
if (item.stage === 'in_review') {
const fresh = isAuditFresh(item.auditedAt, item.updatedAt);
if (fresh) {
// Fresh audit: show based on audit result
secondIcon = auditIcon(item.auditResult, { noIcons });
} else {
// No audit or stale audit: show stale-passed icon if passed, else stage icon
if (item.auditResult === true) {
secondIcon = auditStaleIcon(item.auditResult, { noIcons });
} else {
secondIcon = stageIcon(item.stage, { noIcons });
}
}
} else {
secondIcon = stageIcon(item.stage, { noIcons });
}
// Column 2: stage or audit-aware icon for in_review — via the shared
// stageDisplayIcon helper so the list prefix and the metadata Stage row
// can never diverge (WL-0MSGIXHHI009KFW9).
const secondIcon = stageDisplayIcon(item, { noIcons });

// Column 3: producer review flag
const prIcon = needsProducerReviewIcon(item.needsProducerReview, { noIcons });
Expand Down
2 changes: 1 addition & 1 deletion packages/herdr/src/worklist-icons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ describe('worklist — showIcons gating through runWorklistTui', () => {
expect(output).toContain('[OPEN]'); // status text fallback
expect(output).toContain('WL-1');
expect(output).not.toContain(AUDIT_UNKNOWN_ICON); // metadata panel audit icon
expect(output).toContain('[?]'); // audit text fallback
expect(output).toContain('unknown'); // audit text label (metadata falls back to plain text, WL-0MSGIXHHI009KFW9)

await quit(p);
});
Expand Down
122 changes: 121 additions & 1 deletion packages/herdr/src/worklist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2321,6 +2321,126 @@ describe('buildMetaRows — timestamps rendered via formatTimestamp', () => {
});
});

// ── Icon + text metadata values (WL-0MSGIXHHI009KFW9) ──────────────────
// The metadata section (list-mode panel and detail view) renders the same
// icons as the list rows, paired with their text label (icon + text, e.g.
// `🔄 in_progress`, `⭐ high`, `📥 intake_complete`). The Stage row mirrors
// the list's audit-aware in_review icon via the shared stageDisplayIcon
// helper; the Type row shows the epic icon for epic items only; Audit and
// Reviewed rows pair their icons with text labels. With icons disabled the
// values fall back to plain text — no emoji, no bracketed fallbacks.

describe('buildMetaRows — icon + text metadata values (WL-0MSGIXHHI009KFW9)', () => {
it('prefixes Status, Stage, Priority, Risk and Effort with the list icons', () => {
const rows = new Map(buildMetaRows(makeRichItem()));
// makeRichItem: status in_progress, stage in_progress, priority high,
// risk medium, effort 3 (free-form — no icon key).
expect(rows.get('Status')).toBe('\u{1F504} in_progress'); // 🔄
expect(rows.get('Stage')).toBe('\u{1F6E0}\u{FE0F} in_progress'); // 🛠️
expect(rows.get('Priority')).toBe('\u{2B50} high'); // ⭐
expect(rows.get('Risk')).toBe('\u{1F7E1} medium'); // 🟡
});

it('keeps unknown free-form Effort values text-only (consistent with the list)', () => {
const rows = new Map(buildMetaRows(makeRichItem())); // effort: '3'
expect(rows.get('Effort')).toBe('3');
});

it('shows the epic icon on the Type row for epic items only', () => {
const epic = { ...makeRichItem(), issueType: 'epic' };
expect(new Map(buildMetaRows(epic)).get('Type')).toBe('\u{2299} epic'); // ⊙
expect(new Map(buildMetaRows(makeRichItem())).get('Type')).toBe('feature');
});

it('pairs Audit and Reviewed icons with text labels', () => {
// makeRichItem: auditResult true → `✅ ready to close`,
// needsProducerReview true → `❌ needs review`.
const rows = new Map(buildMetaRows(makeRichItem()));
expect(rows.get('Audit')).toBe('\u{2705} ready to close'); // ✅
expect(rows.get('Reviewed')).toBe('\u{274C} needs review'); // ❌
const reviewed = new Map(buildMetaRows({ ...makeRichItem(), needsProducerReview: false }));
expect(reviewed.get('Reviewed')).toBe('\u{2705} reviewed'); // ✅
});

it('renders plain text values when icons are disabled (no emoji, no [BRACKET] fallbacks)', () => {
const rows = new Map(buildMetaRows(makeRichItem(), true));
expect(rows.get('Status')).toBe('in_progress');
expect(rows.get('Stage')).toBe('in_progress');
expect(rows.get('Priority')).toBe('high');
expect(rows.get('Type')).toBe('feature');
expect(rows.get('Risk')).toBe('medium');
expect(rows.get('Effort')).toBe('3');
expect(rows.get('Audit')).toBe('ready to close');
expect(rows.get('Reviewed')).toBe('needs review');
// No icon glyphs leak into the noIcons values.
for (const [label, value] of rows) {
if (['Status', 'Stage', 'Priority', 'Type', 'Risk', 'Effort', 'Audit', 'Reviewed'].includes(label)) {
expect(value).not.toMatch(/[\u{1F300}-\u{1FAFF}\u{2700}-\u{27BF}\u{2600}-\u{26FF}\u{2299}]/u);
expect(value).not.toMatch(/^\[/);
}
}
});
});

describe('buildMetaRows — audit-aware in_review Stage row (WL-0MSGIXHHI009KFW9 AC2)', () => {
const base = (over: Partial<WorkItem> = {}): WorkItem => ({
...makeRichItem(),
stage: 'in_review',
auditResult: null,
auditedAt: null,
updatedAt: '2026-08-02T10:00:00.000Z',
...over,
});

it('shows the fresh audit result icon (✅/❌/❓) when the audit is fresh', () => {
const updatedAt = '2026-08-02T10:00:00.000Z';
const freshPass = base({ auditResult: true, auditedAt: '2026-08-02T10:00:30.000Z', updatedAt });
expect(new Map(buildMetaRows(freshPass)).get('Stage')).toBe('\u{2705} in_review'); // ✅
const freshFail = base({ auditResult: false, auditedAt: '2026-08-02T10:00:30.000Z', updatedAt });
expect(new Map(buildMetaRows(freshFail)).get('Stage')).toBe('\u{274C} in_review'); // ❌
const freshUnknown = base({ auditResult: null, auditedAt: '2026-08-02T10:00:30.000Z', updatedAt });
expect(new Map(buildMetaRows(freshUnknown)).get('Stage')).toBe('\u{2753} in_review'); // ❓
});

it('shows the stale-passed hourglass when the audit is stale but passed', () => {
const stalePass = base({ auditResult: true, auditedAt: '2026-08-01T10:00:00.000Z' });
expect(new Map(buildMetaRows(stalePass)).get('Stage')).toBe('\u{23F3} in_review'); // ⏳
});

it('falls back to the plain in_review stage icon otherwise (no audit / stale fail)', () => {
const noAudit = base();
expect(new Map(buildMetaRows(noAudit)).get('Stage')).toBe('\u{1F50D} in_review'); // 🔍
});
});

describe('metadata panel and detail view — icon + text values (WL-0MSGIXHHI009KFW9 AC6)', () => {
it('renders icon+text in the list-mode metadata panel', () => {
const joined = formatMetadataPanel(makeRichItem(), 80, 20, 0).join('\n');
expect(joined).toContain('\u{1F504} in_progress'); // 🔄 status
expect(joined).toContain('\u{2B50} high'); // ⭐ priority
expect(joined).toContain('\u{2705} ready to close'); // ✅ audit
});

it('renders icon+text in the detail-view metadata table', () => {
const joined = formatDetailContent(makeRichItem(), 80).join('\n');
expect(joined).toContain('\u{1F504} in_progress');
expect(joined).toContain('\u{2B50} high');
expect(joined).toContain('\u{2705} ready to close');
});

it('renders plain text in both views when icons are disabled', () => {
const panel = formatMetadataPanel(makeRichItem(), 80, 20, 0, undefined, true).join('\n');
expect(panel).toContain(' in_progress');
expect(panel).toContain(' ready to close');
expect(panel).not.toContain('\u{1F504}');
expect(panel).not.toContain('[INPR]');
const detail = formatDetailContent(makeRichItem(), 80, undefined, true).join('\n');
expect(detail).toContain('in_progress');
expect(detail).toContain('ready to close');
expect(detail).not.toContain('\u{1F504}');
});
});

// ── Downtime status indicator (WL-0MSF49FMW009M06K, F4) ───────────────

describe('renderDowntimeStatus', () => {
Expand Down Expand Up @@ -2417,7 +2537,7 @@ describe('createListRenderer — showIcons gating', () => {
expect(output).toContain('[OPEN]'); // status text fallback
expect(output).toContain('[IDEA]'); // stage text fallback
expect(output).not.toContain(AUDIT_UNKNOWN_ICON); // metadata panel audit icon
expect(output).toContain('[?]'); // audit text fallback
expect(output).toContain('unknown'); // audit text label (metadata falls back to plain text, WL-0MSGIXHHI009KFW9)
});

it('re-reads the getter on every render (settings re-read path)', () => {
Expand Down
Loading
Loading