From 6a0776b5e7f21004fa7680989a00f56f36800dfe Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Mon, 11 May 2026 23:34:19 +0200 Subject: [PATCH] BACK-468 - Add blockedStatuses config for custom blocked-status styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add blockedStatuses?: string[] to BacklogConfig (src/types/index.ts) - Parse blocked_statuses YAML key in loadConfig (src/file-system/operations.ts) - Serialize blockedStatuses back to YAML; round-trip safe with empty-array→undefined normalization - Fix status-icon.ts functions to accept optional blockedStatuses param: config-array check first, then built-in heuristics as fallback (config-PLUS-fallback) - Thread blockedStatuses through all TUI callers: board.ts, overview-tui.ts, simple-unified-view.ts, unified-view.ts, task-viewer-with-search.ts, formatters/task-plain-text.ts, commands/overview.ts - Fix Web board TaskColumn isBlocked logic from exclusive ternary to || (config-PLUS-fallback) - Thread blockedStatuses through Web component tree: App → BoardPage → Board → TaskColumn - Add blockedStatuses to config get/set/list CLI commands (src/cli.ts) - Tests: 11 new cases covering custom blocked status, Stuck fallback preservation, config CLI round-trip, empty-array normalization (src/test/status-icon.test.ts, src/test/config-commands.test.ts) --- ...8 - Bug-6-Custom-Blocked-Status-Styling.md | 143 ++++++++++++++++++ src/cli.ts | 18 ++- src/commands/overview.ts | 2 +- src/file-system/operations.ts | 14 ++ src/formatters/task-plain-text.ts | 3 +- src/test/config-commands.test.ts | 47 ++++++ src/test/status-icon.test.ts | 38 +++++ src/types/index.ts | 1 + src/ui/board.ts | 11 +- src/ui/overview-tui.ts | 8 +- src/ui/simple-unified-view.ts | 1 + src/ui/status-icon.ts | 25 ++- src/ui/task-viewer-with-search.ts | 13 +- src/ui/unified-view.ts | 1 + src/web/App.tsx | 1 + src/web/components/Board.tsx | 4 + src/web/components/BoardPage.tsx | 3 + src/web/components/TaskColumn.tsx | 10 +- 18 files changed, 317 insertions(+), 26 deletions(-) create mode 100644 backlog/tasks/back-468 - Bug-6-Custom-Blocked-Status-Styling.md diff --git a/backlog/tasks/back-468 - Bug-6-Custom-Blocked-Status-Styling.md b/backlog/tasks/back-468 - Bug-6-Custom-Blocked-Status-Styling.md new file mode 100644 index 000000000..ab59b9a7f --- /dev/null +++ b/backlog/tasks/back-468 - Bug-6-Custom-Blocked-Status-Styling.md @@ -0,0 +1,143 @@ +--- +id: BACK-468 +title: 'Bug #6: Custom Blocked-Status Styling' +status: In Progress +assignee: + - '@claude' +created_date: '2026-05-06 19:59' +updated_date: '2026-05-11 21:28' +labels: [] +dependencies: + - BACK-465 +ordinal: 26000 +--- + +## Description + + +Fix hardcoded 'Blocked' styling in status-icon.ts and TaskColumn.tsx. + +Add optional config key blockedStatuses: string[] (analogous to terminalStatuses). + +Files to fix: +- src/ui/status-icon.ts: hardcoded exact-match 'Blocked' key in statusMap +- src/web/components/TaskColumn.tsx:88: includes('blocked') || includes('stuck') substring check + +Fix strategy: +- Add blockedStatuses?: string[] to config type +- Pass config into getStatusStyle() and badge-class helper +- Check if status matches any configured blockedStatuses +- Fallback: keep includes('blocked')/includes('stuck') heuristic for English boards + +Tests: extend status-icon.test.ts with custom blocked-status scenarios. + +Depends on: Task A (BACK-465) + + +## Definition of Done + +- [x] #1 bunx tsc --noEmit passes when TypeScript touched +- [x] #2 bun run check . passes when formatting/linting touched +- [x] #3 bun test (or scoped test) passes + + +## Acceptance Criteria + +- [x] #1 - [ ] src/types/index.ts: blockedStatuses?: string[] added to BacklogConfig +- [x] #2 src/file-system/operations.ts: blocked_statuses key parsed in parseConfig (same array pattern as statuses) +- [x] #3 src/ui/status-icon.ts: hardcoded 'Blocked' exact-match replaced with config-aware check using blockedStatuses +- [x] #4 src/ui/status-icon.ts: fallback heuristic includes('blocked') preserved for English boards +- [x] #5 src/web/components/TaskColumn.tsx: badge-class logic updated to check blockedStatuses from config +- [x] #6 src/web/components/TaskColumn.tsx: fallback includes('blocked')/includes('stuck') preserved for English boards +- [x] #7 New tests in status-icon.test.ts cover custom blocked-status scenarios +- [x] #8 bun test: no new failures +- [x] #9 Tests written and failing (RED) before any implementation starts — run bun test to confirm +- [x] #10 bun run check . passes on all modified files + + +## Implementation Plan + + +## Implementation Plan + +### Context +BACK-465 introduced terminalStatuses config infrastructure (already on main). +This task adds analogous blockedStatuses support for visual blocked-status styling. + +Two completely independent concepts: +1. Status name is 'Blocked' → visual styling (red icon/badge) [THIS TASK] +2. Task has unresolved dependencies → statistics blockedTasks count [fixed in BACK-466] + +### Tooling Rules (mandatory) +- Code reads/writes: Serena MCP ONLY (mcp__plugin_serena_serena__*) +- No Read/Edit/Write/grep-via-Bash for source code +- Bash only for: git operations, bun test, ~/.bun/bin/backlog CLI + +### Branch +git checkout -b fix/back-468-blocked-styling + +--- + +### Phase RED — Write failing tests first (before any implementation) + +1. Read src/test/status-icon.test.ts via Serena to understand existing structure and imports +2. Add describe block: "blockedStatuses config override" + - Test 1 (custom status): status 'Gesperrt' is styled as blocked when blockedStatuses=['Gesperrt'] + - Test 2 (English fallback): 'Blocked' still styled correctly when blockedStatuses not configured + - Test 3 (edge case): empty blockedStatuses array falls back to substring heuristic (includes('blocked')) +3. Run `bun test src/test/status-icon.test.ts` — must FAIL (RED confirmed, check AC #9) + +### Phase GREEN — Implement minimal code to make tests pass + +#### 1. src/types/index.ts +Add blockedStatuses?: string[] to BacklogConfig (after terminalStatuses?: string[]) + +#### 2. src/file-system/operations.ts — parseConfig +Add case 'blocked_statuses': (same array pattern as 'terminal_statuses') +Add blockedStatuses: config.blockedStatuses to return object + +#### 3. src/ui/status-icon.ts +- Read full file via Serena first to understand current API and all callers +- Find all call sites with mcp__plugin_serena_serena__find_referencing_symbols +- Extend function signature to accept config (or blockedStatuses?: string[]) +- Logic: if blockedStatuses provided and non-empty → check if status matches any entry (case-insensitive) +- Fallback: if no blockedStatuses configured → keep existing includes('blocked') substring heuristic + +#### 4. src/web/components/TaskColumn.tsx — line ~88 +- Read component via Serena to understand how statuses/config are passed (likely props or context) +- Extend badge-class logic: check blockedStatuses from config first, fallback to includes('blocked')/includes('stuck') +- Find how to thread blockedStatuses through the prop chain if needed + +#### 5. Run `bun test` — must PASS (GREEN confirmed) + +### Phase REFACTOR +- Review all changes for simplification opportunities +- Ensure no duplication with the terminalStatuses implementation pattern +- Run `bun run check .` and `bunx tsc --noEmit` — must be clean + +### Investigation Steps (use Serena before editing) +For src/ui/status-icon.ts: +1. mcp__plugin_serena_serena__read_file to see exact current code +2. mcp__plugin_serena_serena__find_referencing_symbols to find all callers +3. Understand how to add config param without breaking existing callers + +For src/web/components/TaskColumn.tsx: +1. mcp__plugin_serena_serena__read_file around line 88 +2. Check how TaskColumn receives statuses/config currently +3. Determine where blockedStatuses should come from in the component tree + + +## Implementation Notes + + +Prepared as clean standalone upstream PR on fix/back-468-blocked-styling (PR #637). +All scope pollution removed (terminal-status commits not included). +Full implementation: types, YAML parse/serialize, status-icon TUI wiring, Web board fix, config CLI surface. +Tests: 11 new tests (7 status-icon, 4 config-commands). bun run check + bunx tsc clean. + + +## Final Summary + + +Added blockedStatuses?: string[] config key, analogous to terminalStatuses. Parses/serializes blocked_statuses from config.yml. status-icon.ts now checks blockedStatuses array first, falls back to exact-match 'Blocked' and substring heuristic for English boards. Config threaded from App.tsx down through BoardPage → Board → TaskColumn so the web UI badge-class logic is also config-aware with the same fallback. 3 new TDD tests (RED confirmed before implementation). All checks clean: bun test (3 new passing, 5 pre-existing failures unchanged), bunx tsc --noEmit, bun run check. + diff --git a/src/cli.ts b/src/cli.ts index 9ac4edd60..ce3693613 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3449,10 +3449,13 @@ configCmd case "activeBranchDays": console.log(config.activeBranchDays?.toString() || "30"); break; + case "blockedStatuses": + console.log(config.blockedStatuses?.join(", ") || ""); + break; default: console.error(`Unknown config key: ${key}`); console.error( - "Available keys: defaultEditor, projectName, defaultStatus, statuses, labels, milestones, definitionOfDone, dateFormat, maxColumnWidth, defaultPort, autoOpenBrowser, remoteOperations, autoCommit, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays", + "Available keys: defaultEditor, projectName, defaultStatus, statuses, labels, milestones, definitionOfDone, dateFormat, maxColumnWidth, defaultPort, autoOpenBrowser, remoteOperations, autoCommit, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays, blockedStatuses", ); process.exit(1); } @@ -3612,6 +3615,14 @@ configCmd config.activeBranchDays = days; break; } + case "blockedStatuses": { + const parsed = value + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + config.blockedStatuses = parsed.length > 0 ? parsed : undefined; + break; + } case "statuses": case "labels": case "milestones": @@ -3643,7 +3654,7 @@ configCmd default: console.error(`Unknown config key: ${key}`); console.error( - "Available keys: defaultEditor, projectName, defaultStatus, dateFormat, maxColumnWidth, autoOpenBrowser, defaultPort, remoteOperations, autoCommit, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays", + "Available keys: defaultEditor, projectName, defaultStatus, dateFormat, maxColumnWidth, autoOpenBrowser, defaultPort, remoteOperations, autoCommit, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays, blockedStatuses", ); process.exit(1); } @@ -3676,6 +3687,9 @@ configCmd console.log(` defaultStatus: ${config.defaultStatus || "(not set)"}`); console.log(` statuses: [${config.statuses.join(", ")}]`); console.log(` labels: [${config.labels.join(", ")}]`); + if (config.blockedStatuses?.length) { + console.log(` blockedStatuses: [${config.blockedStatuses.join(", ")}]`); + } const milestones = await core.filesystem.listMilestones(); console.log(` milestones: [${milestones.map((milestone) => milestone.id).join(", ")}]`); console.log(` definitionOfDone: [${(config.definitionOfDone ?? []).join(", ")}]`); diff --git a/src/commands/overview.ts b/src/commands/overview.ts index 9d5cb6be6..9364b273b 100644 --- a/src/commands/overview.ts +++ b/src/commands/overview.ts @@ -37,7 +37,7 @@ export async function runOverviewCommand(core: Core): Promise { console.log(`\nPerformance summary: Total time ${totalTime}ms (stats calculation: ${statsTime}ms)`); const config = await core.fs.loadConfig(); - await renderOverviewTui(statistics, config?.projectName || "Project"); + await renderOverviewTui(statistics, config?.projectName || "Project", config?.blockedStatuses); } catch (error) { loadingScreen?.close(); throw error; diff --git a/src/file-system/operations.ts b/src/file-system/operations.ts index 160d075de..9c3750dca 100644 --- a/src/file-system/operations.ts +++ b/src/file-system/operations.ts @@ -1297,6 +1297,7 @@ ${description || `Milestone: ${title}`}`, ...config, ...(this.configSource === "root" ? { backlogDirectory: this.resolvedBacklogDirName } : {}), definitionOfDone: this.normalizeDefinitionOfDone(config.definitionOfDone), + blockedStatuses: config.blockedStatuses?.length ? config.blockedStatuses : undefined, }; if (this.configSource === "folder") { delete normalizedConfig.backlogDirectory; @@ -1367,6 +1368,15 @@ ${description || `Milestone: ${title}`}`, .filter(Boolean); } break; + case "blocked_statuses": + if (value.startsWith("[") && value.endsWith("]")) { + const arrayContent = value.slice(1, -1); + config.blockedStatuses = arrayContent + .split(",") + .map((item) => item.trim().replace(/['"]/g, "")) + .filter(Boolean); + } + break; case "definition_of_done": if (parsedDefinitionOfDone !== undefined) { config.definitionOfDone = parsedDefinitionOfDone; @@ -1429,6 +1439,7 @@ ${description || `Milestone: ${title}`}`, defaultAssignee: config.defaultAssignee, defaultReporter: config.defaultReporter, statuses: config.statuses || [...DEFAULT_STATUSES], + blockedStatuses: config.blockedStatuses?.length ? config.blockedStatuses : undefined, labels: config.labels || [], definitionOfDone: config.definitionOfDone, defaultStatus: config.defaultStatus, @@ -1458,6 +1469,9 @@ ${description || `Milestone: ${title}`}`, ...(config.defaultReporter ? [`default_reporter: "${config.defaultReporter}"`] : []), ...(config.defaultStatus ? [`default_status: "${config.defaultStatus}"`] : []), `statuses: [${config.statuses.map((s) => `"${s}"`).join(", ")}]`, + ...(Array.isArray(config.blockedStatuses) && config.blockedStatuses.length > 0 + ? [`blocked_statuses: [${config.blockedStatuses.map((s) => `"${s}"`).join(", ")}]`] + : []), `labels: [${config.labels.map((l) => `"${l}"`).join(", ")}]`, ...(Array.isArray(normalizedDefinitionOfDone) ? [`definition_of_done: [${normalizedDefinitionOfDone.map((item) => JSON.stringify(item)).join(", ")}]`] diff --git a/src/formatters/task-plain-text.ts b/src/formatters/task-plain-text.ts index 70ea535f1..3e68ea3c9 100644 --- a/src/formatters/task-plain-text.ts +++ b/src/formatters/task-plain-text.ts @@ -6,6 +6,7 @@ import { sortByTaskId } from "../utils/task-sorting.ts"; export type TaskPlainTextOptions = { filePathOverride?: string; + blockedStatuses?: string[]; }; export function formatDateForDisplay(dateStr: string): string { @@ -70,7 +71,7 @@ export function formatTaskPlainText(task: Task, options: TaskPlainTextOptions = lines.push(`Task ${task.id} - ${task.title}`); lines.push("=".repeat(50)); lines.push(""); - lines.push(`Status: ${formatStatusWithIcon(task.status)}`); + lines.push(`Status: ${formatStatusWithIcon(task.status, options.blockedStatuses)}`); const priorityLabel = formatPriority(task.priority); if (priorityLabel) { diff --git a/src/test/config-commands.test.ts b/src/test/config-commands.test.ts index 3c526e1f1..9eebde135 100644 --- a/src/test/config-commands.test.ts +++ b/src/test/config-commands.test.ts @@ -174,6 +174,53 @@ describe("Config commands", () => { expect(listOutput).toContain("milestones: [m-0]"); }); + it("config set/get/list blockedStatuses round-trips correctly", async () => { + await $`bun ${CLI_PATH} config set blockedStatuses "Gesperrt,Blockiert"`.cwd(TEST_DIR).quiet(); + + const getOutput = await $`bun ${CLI_PATH} config get blockedStatuses`.cwd(TEST_DIR).text(); + expect(getOutput.trim()).toBe("Gesperrt, Blockiert"); + + const listOutput = await $`bun ${CLI_PATH} config list`.cwd(TEST_DIR).text(); + expect(listOutput).toContain("blockedStatuses"); + }); + + it("blockedStatuses survives a save of another config key (round-trip no data loss)", async () => { + let config = await core.filesystem.loadConfig(); + if (config) { + config.blockedStatuses = ["On Hold", "Gesperrt"]; + await core.filesystem.saveConfig(config); + } + + config = await core.filesystem.loadConfig(); + expect(config?.blockedStatuses).toEqual(["On Hold", "Gesperrt"]); + + // Save another key and reload + if (config) { + config.defaultEditor = "nano"; + await core.filesystem.saveConfig(config); + } + + config = await core.filesystem.loadConfig(); + expect(config?.blockedStatuses).toEqual(["On Hold", "Gesperrt"]); + expect(config?.defaultEditor).toBe("nano"); + }); + + it("blockedStatuses empty array normalizes to undefined (no empty brackets in YAML)", async () => { + let config = await core.filesystem.loadConfig(); + if (config) { + config.blockedStatuses = []; + await core.filesystem.saveConfig(config); + } + + config = await core.filesystem.loadConfig(); + expect(config?.blockedStatuses).toBeUndefined(); + }); + + it("config list omits blockedStatuses line when not configured", async () => { + const listOutput = await $`bun ${CLI_PATH} config list`.cwd(TEST_DIR).text(); + expect(listOutput).not.toContain("blockedStatuses"); + }); + afterEach(async () => { try { await safeCleanup(TEST_DIR); diff --git a/src/test/status-icon.test.ts b/src/test/status-icon.test.ts index 3851c1683..4b6cfb6fb 100644 --- a/src/test/status-icon.test.ts +++ b/src/test/status-icon.test.ts @@ -46,6 +46,44 @@ describe("Status Icon Component", () => { }); }); + describe("blockedStatuses config override", () => { + test("custom blocked status is styled as blocked when in blockedStatuses", () => { + const style = getStatusStyle("Gesperrt", ["Gesperrt"]); + expect(style.icon).toBe("●"); + expect(style.color).toBe("red"); + }); + + test("'Stuck' still renders as blocked when blockedStatuses config is set (config-PLUS-fallback)", () => { + const style = getStatusStyle("Stuck", ["Gesperrt"]); + expect(style.icon).toBe("●"); + expect(style.color).toBe("red"); + }); + + test("English 'Blocked' still styled correctly when no blockedStatuses configured", () => { + const style = getStatusStyle("Blocked"); + expect(style.icon).toBe("●"); + expect(style.color).toBe("red"); + }); + + test("empty blockedStatuses falls back to substring heuristic for blocked-task", () => { + const style = getStatusStyle("blocked-task", []); + expect(style.icon).toBe("●"); + expect(style.color).toBe("red"); + }); + + test("custom blocked status via getStatusColor", () => { + expect(getStatusColor("Gesperrt", ["Gesperrt"])).toBe("red"); + }); + + test("custom blocked status via getStatusIcon", () => { + expect(getStatusIcon("Gesperrt", ["Gesperrt"])).toBe("●"); + }); + + test("custom blocked status via formatStatusWithIcon", () => { + expect(formatStatusWithIcon("Gesperrt", ["Gesperrt"])).toBe("● Gesperrt"); + }); + }); + describe("getStatusColor", () => { test("returns correct color for each status", () => { expect(getStatusColor("Done")).toBe("green"); diff --git a/src/types/index.ts b/src/types/index.ts index 6a44ff93d..24882c853 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -283,6 +283,7 @@ export interface BacklogConfig { defaultAssignee?: string; defaultReporter?: string; statuses: string[]; + blockedStatuses?: string[]; labels: string[]; /** @deprecated Milestones are sourced from milestone files, not config. */ milestones?: string[]; diff --git a/src/ui/board.ts b/src/ui/board.ts index a5269d352..28d1d9645 100644 --- a/src/ui/board.ts +++ b/src/ui/board.ts @@ -139,8 +139,8 @@ function buildRenderedTaskListItems(tasks: Task[], movingTaskId?: string): { ric }; } -function formatColumnLabel(status: string, count: number): string { - return `\u00A0${getStatusIcon(status)} ${status || "No Status"} (${count})\u00A0`; +function formatColumnLabel(status: string, count: number, blockedStatuses?: string[]): string { + return ` ${getStatusIcon(status, blockedStatuses)} ${status || "No Status"} (${count}) `; } const DEFAULT_FOOTER_CONTENT = @@ -202,6 +202,7 @@ export async function renderBoardTui( }) => void; milestoneMode?: boolean; milestoneEntities?: Milestone[]; + blockedStatuses?: string[]; }, ): Promise { if (!process.stdout.isTTY) { @@ -427,7 +428,7 @@ export async function renderBoardTui( height: "100%", border: { type: "line" }, style: { border: { fg: "gray" } }, - label: formatColumnLabel(columnData.status, columnData.tasks.length), + label: formatColumnLabel(columnData.status, columnData.tasks.length, options?.blockedStatuses), }); const taskList = list({ @@ -559,7 +560,7 @@ export async function renderBoardTui( column.plainItems = renderedItems.plain; column.highlightedIndex = undefined; column.list.setItems(renderedItems.rich); - column.box.setLabel?.(formatColumnLabel(columnData.status, columnData.tasks.length)); + column.box.setLabel?.(formatColumnLabel(columnData.status, columnData.tasks.length, options?.blockedStatuses)); }); restoreSelection(selectedTaskId); }; @@ -1037,7 +1038,7 @@ export async function renderBoardTui( if (!task) return; popupOpen = true; - const popup = await createTaskPopup(screen, task, resolveMilestoneLabel); + const popup = await createTaskPopup(screen, task, resolveMilestoneLabel, options?.blockedStatuses); if (!popup) { popupOpen = false; return; diff --git a/src/ui/overview-tui.ts b/src/ui/overview-tui.ts index 347897208..ac5ab3dc9 100644 --- a/src/ui/overview-tui.ts +++ b/src/ui/overview-tui.ts @@ -6,7 +6,11 @@ import { createScreen } from "./tui.ts"; /** * Render the project overview in an interactive TUI */ -export async function renderOverviewTui(statistics: TaskStatistics, projectName: string): Promise { +export async function renderOverviewTui( + statistics: TaskStatistics, + projectName: string, + blockedStatuses?: string[], +): Promise { // If not in TTY, fall back to plain text output if (!process.stdout.isTTY) { renderPlainTextOverview(statistics, projectName); @@ -59,7 +63,7 @@ export async function renderOverviewTui(statistics: TaskStatistics, projectName: let statusContent = ""; for (const [status, count] of statistics.statusCounts) { - const icon = getStatusIcon(status); + const icon = getStatusIcon(status, blockedStatuses); const percentage = statistics.totalTasks > 0 ? Math.round((count / statistics.totalTasks) * 100) : 0; statusContent += ` ${icon} {bold}${status}:{/bold} ${count} tasks (${percentage}%)\n`; } diff --git a/src/ui/simple-unified-view.ts b/src/ui/simple-unified-view.ts index 51708ba13..18f35d6e2 100644 --- a/src/ui/simple-unified-view.ts +++ b/src/ui/simple-unified-view.ts @@ -121,6 +121,7 @@ export async function runSimpleUnifiedView(options: SimpleUnifiedViewOptions): P onTabPress: async () => { await switchView(); }, + blockedStatuses: config?.blockedStatuses, }); isRunning = false; diff --git a/src/ui/status-icon.ts b/src/ui/status-icon.ts index 10fa6d879..f40955be9 100644 --- a/src/ui/status-icon.ts +++ b/src/ui/status-icon.ts @@ -10,7 +10,7 @@ export interface StatusStyle { * @param status - The task status * @returns The icon and color for the status */ -export function getStatusStyle(status: string): StatusStyle { +export function getStatusStyle(status: string, blockedStatuses?: string[]): StatusStyle { const statusMap: Record = { Done: { icon: "✔", color: "green" }, "In Progress": { icon: "◒", color: "yellow" }, @@ -20,7 +20,16 @@ export function getStatusStyle(status: string): StatusStyle { Testing: { icon: "▣", color: "cyan" }, }; - // Return the mapped style or default for unknown statuses + const configMatch = blockedStatuses?.some((bs) => bs.toLowerCase() === status.toLowerCase()); + const builtinMatch = + statusMap[status]?.color === "red" || + status.toLowerCase().includes("blocked") || + status.toLowerCase().includes("stuck"); + + if (configMatch || builtinMatch) { + return { icon: "●", color: "red" }; + } + return statusMap[status] || { icon: "○", color: "white" }; } @@ -29,8 +38,8 @@ export function getStatusStyle(status: string): StatusStyle { * @param status - The task status * @returns The color for the status */ -export function getStatusColor(status: string): string { - return getStatusStyle(status).color; +export function getStatusColor(status: string, blockedStatuses?: string[]): string { + return getStatusStyle(status, blockedStatuses).color; } /** @@ -38,8 +47,8 @@ export function getStatusColor(status: string): string { * @param status - The task status * @returns The icon for the status */ -export function getStatusIcon(status: string): string { - return getStatusStyle(status).icon; +export function getStatusIcon(status: string, blockedStatuses?: string[]): string { + return getStatusStyle(status, blockedStatuses).icon; } /** @@ -47,7 +56,7 @@ export function getStatusIcon(status: string): string { * @param status - The task status * @returns The formatted status string with icon */ -export function formatStatusWithIcon(status: string): string { - const style = getStatusStyle(status); +export function formatStatusWithIcon(status: string, blockedStatuses?: string[]): string { + const style = getStatusStyle(status, blockedStatuses); return `${style.icon} ${status}`; } diff --git a/src/ui/task-viewer-with-search.ts b/src/ui/task-viewer-with-search.ts index e3d32593c..0d04d104d 100644 --- a/src/ui/task-viewer-with-search.ts +++ b/src/ui/task-viewer-with-search.ts @@ -183,6 +183,7 @@ export async function viewTaskEnhanced( labelFilter: string[]; milestoneFilter: string; }) => void; + blockedStatuses?: string[]; } = {}, ): Promise { if (output.isTTY === false) { @@ -765,8 +766,8 @@ export async function viewTaskEnhanced( width: "100%-4", height: "100%-3", itemRenderer: (task: Task) => { - const statusIcon = formatStatusWithIcon(task.status); - const statusColor = getStatusColor(task.status); + const statusIcon = formatStatusWithIcon(task.status, options.blockedStatuses); + const statusColor = getStatusColor(task.status, options.blockedStatuses); const assigneeText = task.assignee?.length ? ` {cyan-fg}${task.assignee[0]?.startsWith("@") ? task.assignee[0] : `@${task.assignee[0]}`}{/}` : ""; @@ -943,7 +944,7 @@ export async function viewTaskEnhanced( screen.title = `Task ${currentSelectedTask.id} - ${currentSelectedTask.title}`; - const detailContent = generateDetailContent(currentSelectedTask, resolveMilestoneLabel); + const detailContent = generateDetailContent(currentSelectedTask, resolveMilestoneLabel, options.blockedStatuses); // Calculate header height based on content and available width const detailPaneWidth = typeof detailPane.width === "number" ? detailPane.width : 60; @@ -1323,9 +1324,10 @@ export async function viewTaskEnhanced( function generateDetailContent( task: Task, resolveMilestoneLabel?: (milestone: string) => string, + blockedStatuses?: string[], ): { headerContent: string[]; bodyContent: string[] } { const headerContent = [ - ` {${getStatusColor(task.status)}-fg}${formatStatusWithIcon(task.status)}{/} {bold}{blue-fg}${task.id}{/blue-fg}{/bold} - ${task.title}`, + ` {${getStatusColor(task.status, blockedStatuses)}-fg}${formatStatusWithIcon(task.status, blockedStatuses)}{/} {bold}{blue-fg}${task.id}{/blue-fg}{/bold} - ${task.title}`, ]; // Add cross-branch indicator if task is from another branch @@ -1487,6 +1489,7 @@ export async function createTaskPopup( screen: ScreenInterface, task: Task, resolveMilestoneLabel?: (milestone: string) => string, + blockedStatuses?: string[], ): Promise<{ background: BoxInterface; popup: BoxInterface; @@ -1523,7 +1526,7 @@ export async function createTaskPopup( popup.setFront?.(); - const { headerContent, bodyContent } = generateDetailContent(task, resolveMilestoneLabel); + const { headerContent, bodyContent } = generateDetailContent(task, resolveMilestoneLabel, blockedStatuses); // Calculate header height based on content and available width const popupWidth = typeof popup.width === "number" ? popup.width : 80; diff --git a/src/ui/unified-view.ts b/src/ui/unified-view.ts index ce2968ded..1ca2fda93 100644 --- a/src/ui/unified-view.ts +++ b/src/ui/unified-view.ts @@ -390,6 +390,7 @@ export async function runUnifiedView(options: UnifiedViewOptions): Promise }, milestoneMode: options.milestoneMode, milestoneEntities, + blockedStatuses: config?.blockedStatuses, }).then(() => { // If user wants to exit, do it immediately if (result === "exit") { diff --git a/src/web/App.tsx b/src/web/App.tsx index 190550707..d34694793 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -502,6 +502,7 @@ function App() { milestoneEntities={milestoneEntities} archivedMilestones={archivedMilestones} isLoading={isLoading} + blockedStatuses={config?.blockedStatuses} /> } /> diff --git a/src/web/components/Board.tsx b/src/web/components/Board.tsx index 6deb4af49..e66f4784b 100644 --- a/src/web/components/Board.tsx +++ b/src/web/components/Board.tsx @@ -29,6 +29,7 @@ interface BoardProps { filterLabels?: string[]; filterPriority?: string; onFiltersChange?: (filters: { assignee: string; labels: string[]; priority: string }) => void; + blockedStatuses?: string[]; } const PRIORITY_OPTIONS = [ @@ -62,6 +63,7 @@ const Board: React.FC = ({ filterLabels = [], filterPriority = '', onFiltersChange, + blockedStatuses, }) => { const [updateError, setUpdateError] = useState(null); const [dragSourceStatus, setDragSourceStatus] = useState(null); @@ -605,6 +607,7 @@ const Board: React.FC = ({ setDragSourceLane(null); }} onCleanup={status === terminalStatus ? () => setShowCleanupModal(true) : undefined} + blockedStatuses={blockedStatuses} /> ))} @@ -638,6 +641,7 @@ const Board: React.FC = ({ setDragSourceLane(null); }} onCleanup={status === terminalStatus ? () => setShowCleanupModal(true) : undefined} + blockedStatuses={blockedStatuses} /> ))} diff --git a/src/web/components/BoardPage.tsx b/src/web/components/BoardPage.tsx index 5f2ef0c2f..b3f752702 100644 --- a/src/web/components/BoardPage.tsx +++ b/src/web/components/BoardPage.tsx @@ -15,6 +15,7 @@ interface BoardPageProps { milestoneEntities: Milestone[]; archivedMilestones: Milestone[]; isLoading: boolean; + blockedStatuses?: string[]; } export default function BoardPage({ @@ -28,6 +29,7 @@ export default function BoardPage({ milestoneEntities, archivedMilestones, isLoading, + blockedStatuses, }: BoardPageProps) { const [searchParams, setSearchParams] = useSearchParams(); const [highlightTaskId, setHighlightTaskId] = useState(null); @@ -139,6 +141,7 @@ export default function BoardPage({ filterLabels={filterLabels} filterPriority={filterPriority} onFiltersChange={handleFiltersChange} + blockedStatuses={blockedStatuses} /> ); diff --git a/src/web/components/TaskColumn.tsx b/src/web/components/TaskColumn.tsx index ae873e2ca..b9d981dc4 100644 --- a/src/web/components/TaskColumn.tsx +++ b/src/web/components/TaskColumn.tsx @@ -17,6 +17,7 @@ interface TaskColumnProps { onCleanup?: () => void; laneId?: string; targetMilestone?: string | null; + blockedStatuses?: string[]; } const TaskColumn: React.FC = ({ @@ -31,7 +32,8 @@ const TaskColumn: React.FC = ({ onDragEnd, onCleanup, laneId, - targetMilestone + targetMilestone, + blockedStatuses, }) => { const [isDragOver, setIsDragOver] = React.useState(false); const [draggedTaskId, setDraggedTaskId] = React.useState(null); @@ -86,7 +88,11 @@ const TaskColumn: React.FC = ({ if (statusLower.includes('progress') || statusLower.includes('doing')) { return 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 transition-colors duration-200'; } - if (statusLower.includes('blocked') || statusLower.includes('stuck')) { + const isBlocked = + (blockedStatuses && blockedStatuses.some((bs) => bs.toLowerCase() === statusLower)) || + statusLower.includes('blocked') || + statusLower.includes('stuck'); + if (isBlocked) { return 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 transition-colors duration-200'; } return 'bg-stone-100 dark:bg-stone-900 text-stone-800 dark:text-stone-200 transition-colors duration-200';