schub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "schub",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "schub": "./src/index.ts"
7
+ },
8
+ "files": [
9
+ "dist",
10
+ "src",
11
+ "templates"
12
+ ],
13
+ "scripts": {
14
+ "schub": "bun ./src/index.ts",
15
+ "build": "bun build ./src/index.ts --outdir dist --target node",
16
+ "lint": "bunx @biomejs/biome lint .",
17
+ "format": "bunx @biomejs/biome format --write .",
18
+ "test": "bun test"
19
+ },
20
+ "dependencies": {
21
+ "chalk": "^5.6.2",
22
+ "ink": "^6.6.0",
23
+ "react": "^19.2.3",
24
+ "react-devtools-core": "^6.1.2"
25
+ },
26
+ "devDependencies": {
27
+ "@types/bun": "latest",
28
+ "@types/node": "^24.3.0",
29
+ "@types/react": "^19.2.8",
30
+ "ink-testing-library": "^4.0.0"
31
+ }
32
+ }
package/src/App.tsx ADDED
@@ -0,0 +1,103 @@
1
+ import { homedir } from "node:os";
2
+ import { Box, Text, useInput, useStdout } from "ink";
3
+ import React from "react";
4
+ import packageJson from "../package.json";
5
+ import PlanView from "./components/PlanView";
6
+ import StatusView from "./components/StatusView";
7
+ import { findSchubRoot } from "./features/tasks";
8
+
9
+ type Mode = "status" | "plan";
10
+
11
+ type TabDefinition = {
12
+ id: Mode;
13
+ label: string;
14
+ };
15
+
16
+ const tabs: TabDefinition[] = [
17
+ { id: "status", label: "Status" },
18
+ { id: "plan", label: "Plan" },
19
+ ];
20
+
21
+ export default function App() {
22
+ const [mode, setMode] = React.useState<Mode>("status");
23
+ const { stdout } = useStdout();
24
+ const [dimensions, setDimensions] = React.useState(() => ({
25
+ columns: stdout.columns,
26
+ rows: stdout.rows,
27
+ }));
28
+ const versionLabel = `${packageJson.version}`;
29
+ const homeDir = homedir();
30
+ const schubDir = findSchubRoot(process.env.SCHUB_CWD ?? process.cwd())!;
31
+ const displaySchubDir =
32
+ schubDir === homeDir ? "~" : schubDir.startsWith(`${homeDir}/`) ? `~${schubDir.slice(homeDir.length)}` : schubDir;
33
+
34
+ React.useEffect(() => {
35
+ const handleResize = () => {
36
+ setDimensions({ columns: stdout.columns, rows: stdout.rows });
37
+ };
38
+
39
+ stdout.on("resize", handleResize);
40
+
41
+ return () => {
42
+ stdout.off("resize", handleResize);
43
+ };
44
+ }, [stdout]);
45
+
46
+ useInput((_input, key) => {
47
+ if (key.tab) {
48
+ setMode((current) => (current === "status" ? "plan" : "status"));
49
+ }
50
+ });
51
+
52
+ return (
53
+ <Box backgroundColor="black" flexDirection="column" width={dimensions.columns} height={dimensions.rows}>
54
+ <Box flexDirection="column" paddingX={2} paddingY={1} flexShrink={0}>
55
+ <Box flexDirection="row" justifyContent="space-between" alignItems="center">
56
+ <Box flexDirection="row">
57
+ {tabs.map((tab) => {
58
+ const isSelected = mode === tab.id;
59
+ return (
60
+ <Box
61
+ key={tab.id}
62
+ marginRight={4}
63
+ borderStyle="bold"
64
+ borderLeft={isSelected}
65
+ borderTop={false}
66
+ borderRight={false}
67
+ borderBottom={false}
68
+ borderLeftColor={"blueBright"}
69
+ flexDirection="row"
70
+ alignItems="center"
71
+ paddingLeft={1}
72
+ >
73
+ <Text color={isSelected ? "white" : "gray"} bold={isSelected}>
74
+ {tab.label}
75
+ </Text>
76
+ </Box>
77
+ );
78
+ })}
79
+ </Box>
80
+ </Box>
81
+ </Box>
82
+ <Box flexDirection="column" paddingX={2} paddingY={1} flexGrow={1} flexShrink={1}>
83
+ {mode === "status" ? <StatusView /> : <PlanView />}
84
+ </Box>
85
+ <Box flexDirection="column" paddingX={2} paddingBottom={1} flexShrink={0}>
86
+ <Box justifyContent="flex-end">
87
+ <Box marginRight={2}>
88
+ <Text>tab</Text>
89
+ <Text color="gray"> switch mode</Text>
90
+ </Box>
91
+ <Box>
92
+ <Text>ctrl+p</Text>
93
+ <Text color="gray"> commands</Text>
94
+ </Box>
95
+ </Box>
96
+ <Box justifyContent="space-between" marginTop={1}>
97
+ <Text color="gray">{displaySchubDir}</Text>
98
+ <Text color="gray">{versionLabel}</Text>
99
+ </Box>
100
+ </Box>
101
+ </Box>
102
+ );
103
+ }
package/src/changes.ts ADDED
@@ -0,0 +1,237 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export type ChangeInfo = {
6
+ id: string;
7
+ title: string;
8
+ status: string;
9
+ path: string;
10
+ };
11
+
12
+ const isDirectory = (path: string) => {
13
+ try {
14
+ return statSync(path).isDirectory();
15
+ } catch {
16
+ return false;
17
+ }
18
+ };
19
+
20
+ const parseProposal = (content: string, changeId: string) => {
21
+ const titleMatch = content.match(/^#\s+Proposal\s+-\s+(.*)$/m);
22
+ const statusMatch = content.match(/^\*\*Status\*\*:\s*(.+)$/m);
23
+
24
+ return {
25
+ title: titleMatch?.[1]?.trim() ?? changeId,
26
+ status: statusMatch?.[1]?.trim() ?? "unknown",
27
+ };
28
+ };
29
+
30
+ const changeNumber = (id: string) => {
31
+ const match = id.match(/\d+/);
32
+ return match ? Number(match[0]) : Number.POSITIVE_INFINITY;
33
+ };
34
+
35
+ export const listChanges = (schubDir: string) => {
36
+ const changesRoot = join(schubDir, "changes");
37
+ if (!existsSync(changesRoot) || !isDirectory(changesRoot)) {
38
+ return [];
39
+ }
40
+
41
+ const repoRoot = dirname(schubDir);
42
+ const entries = readdirSync(changesRoot, { withFileTypes: true });
43
+ const changes: ChangeInfo[] = [];
44
+
45
+ for (const entry of entries) {
46
+ if (!entry.isDirectory()) {
47
+ continue;
48
+ }
49
+
50
+ const proposalPath = join(changesRoot, entry.name, "proposal.md");
51
+ if (!existsSync(proposalPath)) {
52
+ continue;
53
+ }
54
+
55
+ const content = readFileSync(proposalPath, "utf8");
56
+ const parsed = parseProposal(content, entry.name);
57
+ changes.push({
58
+ id: entry.name,
59
+ title: parsed.title,
60
+ status: parsed.status,
61
+ path: relative(repoRoot, proposalPath),
62
+ });
63
+ }
64
+
65
+ return changes.sort((a, b) => {
66
+ const numberDiff = changeNumber(a.id) - changeNumber(b.id);
67
+ if (numberDiff !== 0) {
68
+ return numberDiff;
69
+ }
70
+ return a.id.localeCompare(b.id);
71
+ });
72
+ };
73
+
74
+ const slugify = (value: string) => {
75
+ let slug = value.trim().toLowerCase();
76
+ slug = slug.replace(/[^a-z0-9]+/g, "-");
77
+ slug = slug.replace(/-{2,}/g, "-");
78
+ return slug.replace(/^-|-$/g, "");
79
+ };
80
+
81
+ const splitPrefixedChangeId = (changeId: string) => {
82
+ const match = changeId.match(/^([Cc])(\d{3})_([a-z0-9]+(?:-[a-z0-9]+)*)$/);
83
+ if (match) {
84
+ return { prefix: match[2], slug: match[3] };
85
+ }
86
+ return { prefix: null, slug: changeId };
87
+ };
88
+
89
+ const nextChangePrefix = (schubDir: string) => {
90
+ const changesRoot = join(schubDir, "changes");
91
+ const archiveRoot = join(schubDir, "archive", "changes");
92
+ const prefixes: number[] = [];
93
+
94
+ const scan = (root: string) => {
95
+ if (!existsSync(root) || !isDirectory(root)) return;
96
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
97
+ if (!entry.isDirectory()) {
98
+ continue;
99
+ }
100
+ const match = entry.name.match(/^[Cc](\d{3})_/);
101
+ if (match) {
102
+ prefixes.push(Number.parseInt(match[1], 10));
103
+ }
104
+ }
105
+ };
106
+
107
+ scan(changesRoot);
108
+ scan(archiveRoot);
109
+
110
+ const next = prefixes.length > 0 ? Math.max(...prefixes) + 1 : 1;
111
+ return next.toString().padStart(3, "0");
112
+ };
113
+
114
+ const CHANGE_PREFIX = "C";
115
+
116
+ const PROPOSAL_TEMPLATE_PATH = fileURLToPath(
117
+ new URL("../templates/create-proposal/proposal-template.md", import.meta.url),
118
+ );
119
+
120
+ const readProposalTemplate = () => {
121
+ try {
122
+ return readFileSync(PROPOSAL_TEMPLATE_PATH, "utf8");
123
+ } catch {
124
+ throw new Error(`[ERROR] Template not found: ${PROPOSAL_TEMPLATE_PATH}`);
125
+ }
126
+ };
127
+
128
+ const isValidSlug = (value: string) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
129
+
130
+ const changeExists = (schubDir: string, changeId: string) => {
131
+ const active = join(schubDir, "changes", changeId);
132
+ if (existsSync(active)) {
133
+ return true;
134
+ }
135
+
136
+ const archiveRoot = join(schubDir, "archive", "changes");
137
+ if (!existsSync(archiveRoot) || !isDirectory(archiveRoot)) {
138
+ return false;
139
+ }
140
+
141
+ for (const entry of readdirSync(archiveRoot, { withFileTypes: true })) {
142
+ if (entry.isDirectory() && entry.name.includes(changeId)) {
143
+ return true;
144
+ }
145
+ }
146
+
147
+ return false;
148
+ };
149
+
150
+ export const resolveChangeRoot = (startDir: string = process.cwd()) => {
151
+ const start = resolve(startDir);
152
+ const fallback = join(start, ".schub");
153
+ let current = start;
154
+
155
+ while (true) {
156
+ if (basename(current) === ".schub" && isDirectory(current)) {
157
+ return current;
158
+ }
159
+
160
+ const candidate = join(current, ".schub");
161
+ if (isDirectory(candidate)) {
162
+ return candidate;
163
+ }
164
+
165
+ const parent = dirname(current);
166
+ if (parent === current) {
167
+ return fallback;
168
+ }
169
+ current = parent;
170
+ }
171
+ };
172
+
173
+ export const createChange = (
174
+ schubDir: string,
175
+ options: { changeId?: string; title?: string; input?: string; overwrite?: boolean },
176
+ ) => {
177
+ let changeId = (options.changeId || "").trim();
178
+ let title = (options.title || "").trim();
179
+ const input = (options.input || "").trim();
180
+
181
+ if (!changeId) {
182
+ if (!title) {
183
+ throw new Error("Provide --change-id or --title.");
184
+ }
185
+ changeId = slugify(title);
186
+ if (!changeId) {
187
+ throw new Error("Unable to derive change-id from title.");
188
+ }
189
+ console.warn(`[WARN] Derived change-id '${changeId}' from --title. Prefer verb-led ids.`);
190
+ }
191
+
192
+ const originalChangeId = changeId;
193
+ const { prefix: existingPrefix, slug } = splitPrefixedChangeId(changeId);
194
+ if (!isValidSlug(slug)) {
195
+ throw new Error(`Invalid change-id '${changeId}'. Use kebab-case (lowercase letters/digits and hyphens).`);
196
+ }
197
+
198
+ if (!existingPrefix) {
199
+ const prefix = nextChangePrefix(schubDir);
200
+ changeId = `${CHANGE_PREFIX}${prefix}_${slug}`;
201
+ console.error(`[INFO] Prefixed change-id with '${CHANGE_PREFIX}${prefix}_'.`);
202
+ } else {
203
+ changeId = `${CHANGE_PREFIX}${existingPrefix}_${slug}`;
204
+ if (changeId !== originalChangeId) {
205
+ console.error(`[INFO] Normalized change-id to '${changeId}'.`);
206
+ }
207
+ }
208
+
209
+ if (!title) {
210
+ title = slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
211
+ }
212
+
213
+ const changeDir = join(schubDir, "changes", changeId);
214
+ const proposalPath = join(changeDir, "proposal.md");
215
+
216
+ if (changeExists(schubDir, changeId) && !options.overwrite) {
217
+ throw new Error(`Change '${changeId}' already exists under ${schubDir}. Choose a unique id or pass --overwrite.`);
218
+ }
219
+
220
+ const template = readProposalTemplate();
221
+ const today = new Date().toISOString().split("T")[0];
222
+ const rendered = template
223
+ .replace("{{CHANGE_TITLE}}", title)
224
+ .replace("{{CHANGE_ID}}", changeId)
225
+ .replace("{{DATE}}", today)
226
+ .replace("{{INPUT}}", input || "[no input provided]")
227
+ .replace("{{AGENT_ROOT}}", schubDir);
228
+
229
+ if (existsSync(proposalPath) && !options.overwrite) {
230
+ throw new Error(`Refusing to overwrite existing file: ${proposalPath}`);
231
+ }
232
+
233
+ mkdirSync(changeDir, { recursive: true });
234
+ writeFileSync(proposalPath, rendered, "utf8");
235
+
236
+ return proposalPath;
237
+ };
@@ -0,0 +1,91 @@
1
+ import { Box, Text } from "ink";
2
+ import React from "react";
3
+ import {
4
+ buildTaskGraph,
5
+ findSchubRoot,
6
+ loadTaskDependencies,
7
+ renderTaskGraphLines,
8
+ type TaskStatus,
9
+ trimTaskTitle,
10
+ } from "../features/tasks";
11
+
12
+ const PLAN_TASK_STATUSES: readonly TaskStatus[] = ["backlog", "ready", "wip", "blocked"];
13
+ const READY_TASK_STATUSES: readonly TaskStatus[] = ["backlog", "ready"];
14
+
15
+ export default function PlanView() {
16
+ const schubDir = React.useMemo(() => findSchubRoot(), []);
17
+ const planData = React.useMemo(() => {
18
+ if (!schubDir) {
19
+ return { visibleTasks: [], readyTasks: [], graphLines: [] };
20
+ }
21
+
22
+ const allTasks = loadTaskDependencies(schubDir);
23
+ const visibleTasks = allTasks.filter((task) => PLAN_TASK_STATUSES.includes(task.status));
24
+ const tasksById = new Map(allTasks.map((task) => [task.id, task]));
25
+ const readyTasks = visibleTasks.filter((task) => {
26
+ if (!READY_TASK_STATUSES.includes(task.status)) {
27
+ return false;
28
+ }
29
+
30
+ return task.dependsOn.every((dependencyId) => tasksById.get(dependencyId)?.status === "done");
31
+ });
32
+
33
+ if (visibleTasks.length === 0) {
34
+ return { visibleTasks, readyTasks, graphLines: [] };
35
+ }
36
+
37
+ const graph = buildTaskGraph(visibleTasks);
38
+ const graphLines = renderTaskGraphLines(graph);
39
+ return { visibleTasks, readyTasks, graphLines };
40
+ }, [schubDir]);
41
+
42
+ if (!schubDir) {
43
+ return (
44
+ <Box flexDirection="column">
45
+ <Text color="red">No .schub directory found.</Text>
46
+ </Box>
47
+ );
48
+ }
49
+
50
+ if (planData.visibleTasks.length === 0) {
51
+ return (
52
+ <Box flexDirection="column">
53
+ <Text color="gray">No tasks found in .schub</Text>
54
+ </Box>
55
+ );
56
+ }
57
+
58
+ return (
59
+ <Box flexDirection="column">
60
+ <Box flexDirection="column">
61
+ <Box marginBottom={1}>
62
+ <Text bold color="white">
63
+ Ready to Implement
64
+ </Text>
65
+ </Box>
66
+ {planData.readyTasks.length === 0 ? (
67
+ <Text color="gray">No tasks ready for implementation.</Text>
68
+ ) : (
69
+ planData.readyTasks.map((task) => (
70
+ <Box key={task.id}>
71
+ <Text color="white">{`${task.id}`}</Text>
72
+ <Text color="gray"> {trimTaskTitle(task.title)}</Text>
73
+ </Box>
74
+ ))
75
+ )}
76
+ </Box>
77
+ <Box flexDirection="column" marginTop={2}>
78
+ <Box marginBottom={1}>
79
+ <Text bold color="white">
80
+ Dependency Plan
81
+ </Text>
82
+ </Box>
83
+ {planData.graphLines.map((line, index) => (
84
+ <Text key={`${line.text}-${index}`} color={"grey"}>
85
+ {line.text}
86
+ </Text>
87
+ ))}
88
+ </Box>
89
+ </Box>
90
+ );
91
+ }
@@ -0,0 +1,217 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import React from "react";
3
+ import { type ChangeInfo, listChanges } from "../changes";
4
+ import { findSchubRoot, loadTaskDependencies, type TaskInfo, trimTaskTitle } from "../features/tasks";
5
+
6
+ type StatusSortable = {
7
+ id: string;
8
+ title: string;
9
+ status: string;
10
+ };
11
+
12
+ const compareText = (left: string, right: string): number =>
13
+ left.localeCompare(right, undefined, { sensitivity: "base" });
14
+
15
+ const sortByStatusThenTitle = (left: StatusSortable, right: StatusSortable): number => {
16
+ const statusCompare = compareText(left.status, right.status);
17
+ if (statusCompare !== 0) {
18
+ return statusCompare;
19
+ }
20
+
21
+ const titleCompare = compareText(left.title, right.title);
22
+ if (titleCompare !== 0) {
23
+ return titleCompare;
24
+ }
25
+
26
+ return compareText(left.id, right.id);
27
+ };
28
+
29
+ export default function StatusView() {
30
+ const schubDir = React.useMemo(() => findSchubRoot(), []);
31
+
32
+ const { pendingReview, pendingImplementation, drafts, blocked, wip, ready, backlog } = React.useMemo(() => {
33
+ if (!schubDir) {
34
+ return {
35
+ pendingReview: [],
36
+ pendingImplementation: [],
37
+ drafts: [],
38
+ blocked: [],
39
+ wip: [],
40
+ ready: [],
41
+ backlog: [],
42
+ };
43
+ }
44
+
45
+ const allTasks = loadTaskDependencies(schubDir, ["blocked", "wip", "ready", "backlog"]);
46
+
47
+ const blockedTasks: TaskInfo[] = [];
48
+ const wipTasks: TaskInfo[] = [];
49
+ const readyTasks: TaskInfo[] = [];
50
+ const backlogTasks: TaskInfo[] = [];
51
+
52
+ const activeChangeIds = new Set<string>();
53
+
54
+ for (const task of allTasks) {
55
+ const s = task.status.toLowerCase();
56
+ if (s === "blocked") blockedTasks.push(task);
57
+ else if (s === "wip") wipTasks.push(task);
58
+ else if (s === "ready") readyTasks.push(task);
59
+ else if (s === "backlog") backlogTasks.push(task);
60
+
61
+ if (task.changeId) {
62
+ activeChangeIds.add(task.changeId);
63
+ }
64
+ }
65
+
66
+ const allChanges = listChanges(schubDir);
67
+ const pendingReviewChanges: ChangeInfo[] = [];
68
+ const pendingImplementationChanges: ChangeInfo[] = [];
69
+ const draftChanges: ChangeInfo[] = [];
70
+
71
+ for (const change of allChanges) {
72
+ const normalized = change.status.toLowerCase();
73
+ if (normalized.includes("review")) {
74
+ pendingReviewChanges.push(change);
75
+ } else if (
76
+ activeChangeIds.has(change.id) ||
77
+ normalized.includes("implementing") ||
78
+ normalized.includes("accepted")
79
+ ) {
80
+ pendingImplementationChanges.push(change);
81
+ } else if (normalized.includes("draft")) {
82
+ draftChanges.push(change);
83
+ }
84
+ }
85
+
86
+ return {
87
+ pendingReview: pendingReviewChanges.sort(sortByStatusThenTitle),
88
+ pendingImplementation: pendingImplementationChanges.sort(sortByStatusThenTitle),
89
+ drafts: draftChanges.sort(sortByStatusThenTitle),
90
+ blocked: blockedTasks.sort(sortByStatusThenTitle),
91
+ wip: wipTasks.sort(sortByStatusThenTitle),
92
+ ready: readyTasks.sort(sortByStatusThenTitle),
93
+ backlog: backlogTasks.sort(sortByStatusThenTitle),
94
+ };
95
+ }, [schubDir]);
96
+
97
+ const allItems = React.useMemo(
98
+ () => [...pendingReview, ...pendingImplementation, ...drafts, ...blocked, ...wip, ...ready, ...backlog],
99
+ [pendingReview, pendingImplementation, drafts, blocked, wip, ready, backlog],
100
+ );
101
+
102
+ const [selection, setSelection] = React.useState(0);
103
+ const totalItems = allItems.length;
104
+
105
+ React.useEffect(() => {
106
+ if (totalItems === 0) {
107
+ setSelection(0);
108
+ return;
109
+ }
110
+ setSelection((current) => Math.min(current, totalItems - 1));
111
+ }, [totalItems]);
112
+
113
+ useInput((_input, key) => {
114
+ if (totalItems === 0) {
115
+ return;
116
+ }
117
+
118
+ if (key.downArrow) {
119
+ setSelection((current) => Math.min(current + 1, totalItems - 1));
120
+ }
121
+
122
+ if (key.upArrow) {
123
+ setSelection((current) => Math.max(current - 1, 0));
124
+ }
125
+ });
126
+
127
+ if (!schubDir) {
128
+ return (
129
+ <Box flexDirection="column">
130
+ <Text bold>Status</Text>
131
+ <Text color="red">No .schub directory found.</Text>
132
+ </Box>
133
+ );
134
+ }
135
+
136
+ if (totalItems === 0) {
137
+ return (
138
+ <Box flexDirection="column">
139
+ <Text color="gray">No active changes or tasks found.</Text>
140
+ </Box>
141
+ );
142
+ }
143
+
144
+ const renderRow = (item: ChangeInfo | TaskInfo, index: number) => {
145
+ const selected = index === selection;
146
+ const isTask = "path" in item && item.path.includes("tasks/");
147
+ const title = isTask ? trimTaskTitle(item.title) : "";
148
+
149
+ return (
150
+ <Box key={item.id} marginLeft={1}>
151
+ <Text color={selected ? "blue" : "gray"}>{selected ? "›" : " "}</Text>
152
+ <Box marginLeft={1}>
153
+ <Text color="white" bold={selected}>
154
+ {item.id}
155
+ </Text>
156
+ {title ? <Text color="gray"> {title}</Text> : null}
157
+ </Box>
158
+ </Box>
159
+ );
160
+ };
161
+
162
+ let currentIndex = 0;
163
+
164
+ const renderSection = (title: string, items: (ChangeInfo | TaskInfo)[]) => {
165
+ if (items.length === 0) return null;
166
+
167
+ const sectionStartIndex = currentIndex;
168
+ const sectionItems = items.map((item, i) => renderRow(item, sectionStartIndex + i));
169
+ currentIndex += items.length;
170
+
171
+ return (
172
+ <Box flexDirection="column" marginBottom={1}>
173
+ <Box marginBottom={0}>
174
+ <Text color="white">{title}</Text>
175
+ </Box>
176
+ {sectionItems}
177
+ </Box>
178
+ );
179
+ };
180
+
181
+ return (
182
+ <Box flexDirection="column">
183
+ <Box flexDirection="column" marginBottom={1}>
184
+ <Box marginBottom={1}>
185
+ <Text bold color="white" underline>
186
+ Change Proposals
187
+ </Text>
188
+ </Box>
189
+ {renderSection("Pending Review", pendingReview)}
190
+ {renderSection("Pending Implementation", pendingImplementation)}
191
+ {renderSection("Drafts", drafts)}
192
+ {pendingReview.length === 0 && pendingImplementation.length === 0 && drafts.length === 0 && (
193
+ <Box marginLeft={1}>
194
+ <Text color="gray">No active proposals.</Text>
195
+ </Box>
196
+ )}
197
+ </Box>
198
+
199
+ <Box flexDirection="column">
200
+ <Box marginBottom={1}>
201
+ <Text bold color="white" underline>
202
+ Tasks
203
+ </Text>
204
+ </Box>
205
+ {renderSection("Blocked", blocked)}
206
+ {renderSection("WIP", wip)}
207
+ {renderSection("Ready", ready)}
208
+ {renderSection("Backlog", backlog)}
209
+ {blocked.length === 0 && wip.length === 0 && ready.length === 0 && backlog.length === 0 && (
210
+ <Box marginLeft={1}>
211
+ <Text color="gray">No active tasks.</Text>
212
+ </Box>
213
+ )}
214
+ </Box>
215
+ </Box>
216
+ );
217
+ }
@@ -0,0 +1 @@
1
+ export const statusColor = (_status?: string): string => "magenta";