schub 0.1.0 → 0.1.2
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/README.md +68 -0
- package/dist/index.js +1573 -597
- package/package.json +3 -1
- package/skills/create-proposal/SKILL.md +33 -0
- package/skills/create-tasks/SKILL.md +40 -0
- package/skills/implement-task/SKILL.md +84 -0
- package/skills/review-proposal/SKILL.md +37 -0
- package/skills/setup-project/SKILL.md +29 -0
- package/src/App.test.tsx +93 -0
- package/src/App.tsx +62 -10
- package/src/changes.ts +86 -28
- package/src/clipboard.ts +5 -0
- package/src/commands/adr.test.ts +69 -0
- package/src/commands/adr.ts +107 -0
- package/src/commands/changes.test.ts +171 -0
- package/src/commands/changes.ts +163 -0
- package/src/commands/cookbook.test.ts +71 -0
- package/src/commands/cookbook.ts +95 -0
- package/src/commands/eject.test.ts +74 -0
- package/src/commands/eject.ts +100 -0
- package/src/commands/init.test.ts +78 -0
- package/src/commands/init.ts +144 -0
- package/src/commands/project.test.ts +113 -0
- package/src/commands/project.ts +75 -0
- package/src/commands/review.test.ts +100 -0
- package/src/commands/review.ts +231 -0
- package/src/commands/tasks-create.test.ts +172 -0
- package/src/commands/tasks-list.test.ts +177 -0
- package/src/commands/tasks.ts +172 -0
- package/src/components/PlanView.test.tsx +113 -0
- package/src/components/PlanView.tsx +95 -26
- package/src/components/StatusView.test.tsx +380 -0
- package/src/components/StatusView.tsx +233 -83
- package/src/features/tasks/constants.ts +2 -0
- package/src/features/tasks/create.ts +15 -7
- package/src/features/tasks/filesystem.test.ts +78 -0
- package/src/features/tasks/filesystem.ts +61 -7
- package/src/ide.ts +7 -0
- package/src/index.test.ts +23 -0
- package/src/index.ts +60 -383
- package/src/init.test.ts +43 -0
- package/src/init.ts +27 -0
- package/src/project.ts +5 -32
- package/src/schub-root.ts +33 -0
- package/src/templates.ts +18 -0
- package/src/terminal.test.ts +46 -0
- package/templates/create-proposal/cookbook-template.md +37 -0
- package/templates/review-proposal/q&a-template.md +5 -1
- package/templates/templates-parity.test.ts +45 -0
- package/templates/setup-project/review-me-template.md +0 -18
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { resolveChangeRoot } from "../changes";
|
|
2
|
+
import { createTask, listTasks, TASK_STATUSES, type TaskStatus } from "../features/tasks";
|
|
3
|
+
|
|
4
|
+
const parseStatusFilter = (value: string | undefined) => {
|
|
5
|
+
if (!value) {
|
|
6
|
+
return [...TASK_STATUSES];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const normalized = value
|
|
10
|
+
.split(",")
|
|
11
|
+
.map((status) => status.trim().toLowerCase())
|
|
12
|
+
.filter(Boolean);
|
|
13
|
+
|
|
14
|
+
const allowed = new Set(TASK_STATUSES);
|
|
15
|
+
const invalid = normalized.filter((status) => !allowed.has(status as TaskStatus));
|
|
16
|
+
if (invalid.length > 0) {
|
|
17
|
+
throw new Error(`Unknown status filter: ${invalid.join(", ")}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return normalized as TaskStatus[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const parseTaskListOptions = (args: string[]) => {
|
|
24
|
+
let statusValue: string | undefined;
|
|
25
|
+
let json = false;
|
|
26
|
+
const unknown: string[] = [];
|
|
27
|
+
|
|
28
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
29
|
+
const arg = args[index];
|
|
30
|
+
if (arg === "--json") {
|
|
31
|
+
json = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (arg === "--status") {
|
|
35
|
+
statusValue = args[index + 1];
|
|
36
|
+
if (!statusValue) {
|
|
37
|
+
throw new Error("Missing value for --status.");
|
|
38
|
+
}
|
|
39
|
+
index += 1;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (arg.startsWith("--status=")) {
|
|
43
|
+
statusValue = arg.slice("--status=".length);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
unknown.push(arg);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (unknown.length > 0) {
|
|
50
|
+
throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { statuses: parseStatusFilter(statusValue), json };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type TaskCreateOptions = {
|
|
57
|
+
changeId: string;
|
|
58
|
+
status?: string;
|
|
59
|
+
titles: string[];
|
|
60
|
+
overwrite: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const parseTaskCreateOptions = (args: string[]) => {
|
|
64
|
+
let changeId: string | undefined;
|
|
65
|
+
let status: string | undefined;
|
|
66
|
+
let overwrite = false;
|
|
67
|
+
const titles: string[] = [];
|
|
68
|
+
const unknown: string[] = [];
|
|
69
|
+
|
|
70
|
+
const rejectUnsupported = (flag: string) => {
|
|
71
|
+
throw new Error(`Unsupported option: ${flag}.`);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
75
|
+
const arg = args[index];
|
|
76
|
+
if (arg === "--overwrite") {
|
|
77
|
+
overwrite = true;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (arg === "--change-id") {
|
|
81
|
+
changeId = args[index + 1];
|
|
82
|
+
if (changeId === undefined) {
|
|
83
|
+
throw new Error("Missing value for --change-id.");
|
|
84
|
+
}
|
|
85
|
+
index += 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (arg.startsWith("--change-id=")) {
|
|
89
|
+
changeId = arg.slice("--change-id=".length);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (arg === "--status") {
|
|
93
|
+
status = args[index + 1];
|
|
94
|
+
if (status === undefined) {
|
|
95
|
+
throw new Error("Missing value for --status.");
|
|
96
|
+
}
|
|
97
|
+
index += 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (arg.startsWith("--status=")) {
|
|
101
|
+
status = arg.slice("--status=".length);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (arg === "--title") {
|
|
105
|
+
const title = args[index + 1];
|
|
106
|
+
if (title === undefined) {
|
|
107
|
+
throw new Error("Missing value for --title.");
|
|
108
|
+
}
|
|
109
|
+
titles.push(title);
|
|
110
|
+
index += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (arg.startsWith("--title=")) {
|
|
114
|
+
titles.push(arg.slice("--title=".length));
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (arg === "--schub-root" || arg === "--agent-root") {
|
|
118
|
+
rejectUnsupported(arg);
|
|
119
|
+
}
|
|
120
|
+
if (arg.startsWith("--schub-root=")) {
|
|
121
|
+
rejectUnsupported("--schub-root");
|
|
122
|
+
}
|
|
123
|
+
if (arg.startsWith("--agent-root=")) {
|
|
124
|
+
rejectUnsupported("--agent-root");
|
|
125
|
+
}
|
|
126
|
+
unknown.push(arg);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (unknown.length > 0) {
|
|
130
|
+
throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!changeId) {
|
|
134
|
+
throw new Error("Provide --change-id.");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const options: TaskCreateOptions = { changeId, status, titles, overwrite };
|
|
138
|
+
return options;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export const runTasksList = (schubDir: string | null, args: string[]) => {
|
|
142
|
+
if (!schubDir) {
|
|
143
|
+
throw new Error("No .schub directory found.");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const options = parseTaskListOptions(args);
|
|
147
|
+
const tasks = listTasks(schubDir, options.statuses);
|
|
148
|
+
|
|
149
|
+
if (options.json) {
|
|
150
|
+
process.stdout.write(`${JSON.stringify(tasks, null, 2)}\n`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const lines = tasks.map((task) => {
|
|
155
|
+
const checklistSuffix =
|
|
156
|
+
typeof task.checklistRemaining === "number" && typeof task.checklistTotal === "number"
|
|
157
|
+
? ` (${task.checklistRemaining}/${task.checklistTotal})`
|
|
158
|
+
: "";
|
|
159
|
+
return `${task.id} ${task.title} (${task.status})${checklistSuffix}`;
|
|
160
|
+
});
|
|
161
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const runTasksCreate = (args: string[], startDir: string) => {
|
|
165
|
+
const options = parseTaskCreateOptions(args);
|
|
166
|
+
const schubDir = resolveChangeRoot(startDir);
|
|
167
|
+
const created = createTask(schubDir, options);
|
|
168
|
+
|
|
169
|
+
for (const taskPath of created) {
|
|
170
|
+
process.stdout.write(`[OK] Wrote task: ${taskPath}\n`);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { render } from "ink-testing-library";
|
|
6
|
+
import PlanView from "./PlanView";
|
|
7
|
+
|
|
8
|
+
const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
9
|
+
const stripAnsi = (value: string) => value.replace(ansiPattern, "");
|
|
10
|
+
|
|
11
|
+
const extractSection = (output: string, start: string, end?: string): string => {
|
|
12
|
+
const startIndex = output.indexOf(start);
|
|
13
|
+
if (startIndex === -1) {
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const afterStart = output.slice(startIndex + start.length);
|
|
18
|
+
if (!end) {
|
|
19
|
+
return afterStart;
|
|
20
|
+
}
|
|
21
|
+
const endIndex = afterStart.indexOf(end);
|
|
22
|
+
if (endIndex === -1) {
|
|
23
|
+
return afterStart;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return afterStart.slice(0, endIndex);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const noopCopy = () => {};
|
|
30
|
+
|
|
31
|
+
test("plan shows backlog tasks with satisfied dependencies as ready", () => {
|
|
32
|
+
const originalCwd = process.cwd();
|
|
33
|
+
const base = mkdtempSync(join(tmpdir(), "schub-plan-view-"));
|
|
34
|
+
const tasksDir = join(base, ".schub", "tasks", "backlog");
|
|
35
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
36
|
+
writeFileSync(join(tasksDir, "T001_ready-task.md"), "# Task: T001 Ready Task\n", "utf8");
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
process.chdir(base);
|
|
40
|
+
const { lastFrame } = render(<PlanView onCopyId={noopCopy} />);
|
|
41
|
+
const output = stripAnsi(lastFrame() ?? "");
|
|
42
|
+
const readySection = extractSection(output, "Ready to Implement", "Dependency Plan");
|
|
43
|
+
|
|
44
|
+
expect(readySection).toContain("T001");
|
|
45
|
+
expect(readySection).not.toContain("No tasks ready for implementation.");
|
|
46
|
+
} finally {
|
|
47
|
+
process.chdir(originalCwd);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("plan view omits per-item shortcuts for ready tasks", () => {
|
|
52
|
+
const originalCwd = process.cwd();
|
|
53
|
+
const base = mkdtempSync(join(tmpdir(), "schub-plan-actions-"));
|
|
54
|
+
const tasksDir = join(base, ".schub", "tasks", "backlog");
|
|
55
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
56
|
+
writeFileSync(join(tasksDir, "T010_quick-action.md"), "# Task: T010 Quick Action\n", "utf8");
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
process.chdir(base);
|
|
60
|
+
const { lastFrame } = render(<PlanView onCopyId={noopCopy} />);
|
|
61
|
+
const output = stripAnsi(lastFrame() ?? "");
|
|
62
|
+
const readySection = extractSection(output, "Ready to Implement", "Dependency Plan");
|
|
63
|
+
|
|
64
|
+
expect(readySection).toContain("T010");
|
|
65
|
+
expect(readySection).not.toContain("[o open file]");
|
|
66
|
+
expect(readySection).not.toContain("[c copy]");
|
|
67
|
+
} finally {
|
|
68
|
+
process.chdir(originalCwd);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("plan view refreshes ready list and dependency graph when tasks change", async () => {
|
|
73
|
+
const originalCwd = process.cwd();
|
|
74
|
+
const base = mkdtempSync(join(tmpdir(), "schub-plan-refresh-"));
|
|
75
|
+
const tasksDir = join(base, ".schub", "tasks", "backlog");
|
|
76
|
+
const doneDir = join(base, ".schub", "tasks", "done");
|
|
77
|
+
const refreshIntervalMs = 50;
|
|
78
|
+
let unmount: (() => void) | undefined;
|
|
79
|
+
|
|
80
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
81
|
+
mkdirSync(doneDir, { recursive: true });
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
process.chdir(base);
|
|
85
|
+
writeFileSync(
|
|
86
|
+
join(tasksDir, "T002_waiting-task.md"),
|
|
87
|
+
"# Task: T002 Waiting Task\n\n**Depends on**: T003\n",
|
|
88
|
+
"utf8",
|
|
89
|
+
);
|
|
90
|
+
writeFileSync(join(tasksDir, "T003_dependency.md"), "# Task: T003 Dependency\n", "utf8");
|
|
91
|
+
const rendered = render(<PlanView refreshIntervalMs={refreshIntervalMs} onCopyId={noopCopy} />);
|
|
92
|
+
unmount = rendered.unmount;
|
|
93
|
+
const initial = stripAnsi(rendered.lastFrame() ?? "");
|
|
94
|
+
const initialReady = extractSection(initial, "Ready to Implement", "Dependency Plan");
|
|
95
|
+
expect(initialReady).not.toContain("T002");
|
|
96
|
+
const initialGraph = extractSection(initial, "Dependency Plan");
|
|
97
|
+
expect(initialGraph).toContain("T002");
|
|
98
|
+
expect(initialGraph).toContain("T003");
|
|
99
|
+
|
|
100
|
+
renameSync(join(tasksDir, "T003_dependency.md"), join(doneDir, "T003_dependency.md"));
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
|
|
102
|
+
|
|
103
|
+
const refreshed = stripAnsi(rendered.lastFrame() ?? "");
|
|
104
|
+
const refreshedReady = extractSection(refreshed, "Ready to Implement", "Dependency Plan");
|
|
105
|
+
expect(refreshedReady).toContain("T002");
|
|
106
|
+
const refreshedGraph = extractSection(refreshed, "Dependency Plan");
|
|
107
|
+
expect(refreshedGraph).toContain("T002");
|
|
108
|
+
expect(refreshedGraph).not.toContain("T003");
|
|
109
|
+
} finally {
|
|
110
|
+
unmount?.();
|
|
111
|
+
process.chdir(originalCwd);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
2
3
|
import React from "react";
|
|
3
4
|
import {
|
|
4
5
|
buildTaskGraph,
|
|
@@ -8,36 +9,96 @@ import {
|
|
|
8
9
|
type TaskStatus,
|
|
9
10
|
trimTaskTitle,
|
|
10
11
|
} from "../features/tasks";
|
|
12
|
+
import { openInVsCode } from "../ide";
|
|
11
13
|
|
|
12
14
|
const PLAN_TASK_STATUSES: readonly TaskStatus[] = ["backlog", "ready", "wip", "blocked"];
|
|
13
15
|
const READY_TASK_STATUSES: readonly TaskStatus[] = ["backlog", "ready"];
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
type PlanViewProps = {
|
|
18
|
+
refreshIntervalMs?: number;
|
|
19
|
+
onCopyId: (id: string) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const DEFAULT_REFRESH_INTERVAL_MS = 1000;
|
|
23
|
+
|
|
24
|
+
const buildPlanData = (schubDir: string | null) => {
|
|
25
|
+
if (!schubDir) {
|
|
26
|
+
return { visibleTasks: [], readyTasks: [], graphLines: [] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const allTasks = loadTaskDependencies(schubDir);
|
|
30
|
+
const visibleTasks = allTasks.filter((task) => PLAN_TASK_STATUSES.includes(task.status));
|
|
31
|
+
const tasksById = new Map(allTasks.map((task) => [task.id, task]));
|
|
32
|
+
const readyTasks = visibleTasks.filter((task) => {
|
|
33
|
+
if (!READY_TASK_STATUSES.includes(task.status)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return task.dependsOn.every((dependencyId) => tasksById.get(dependencyId)?.status === "done");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (visibleTasks.length === 0) {
|
|
41
|
+
return { visibleTasks, readyTasks, graphLines: [] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const graph = buildTaskGraph(visibleTasks);
|
|
45
|
+
const graphLines = renderTaskGraphLines(graph);
|
|
46
|
+
return { visibleTasks, readyTasks, graphLines };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default function PlanView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, onCopyId }: PlanViewProps) {
|
|
50
|
+
const schubDir = findSchubRoot();
|
|
51
|
+
const [, setRefreshTick] = React.useState(0);
|
|
52
|
+
const planData = buildPlanData(schubDir);
|
|
53
|
+
const [selection, setSelection] = React.useState(0);
|
|
54
|
+
const totalReadyTasks = planData.readyTasks.length;
|
|
55
|
+
const repoRoot = schubDir ? dirname(schubDir) : "";
|
|
56
|
+
|
|
57
|
+
React.useEffect(() => {
|
|
18
58
|
if (!schubDir) {
|
|
19
|
-
return
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const interval = setInterval(() => {
|
|
63
|
+
setRefreshTick((current) => current + 1);
|
|
64
|
+
}, refreshIntervalMs);
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
clearInterval(interval);
|
|
68
|
+
};
|
|
69
|
+
}, [refreshIntervalMs, schubDir]);
|
|
70
|
+
|
|
71
|
+
React.useEffect(() => {
|
|
72
|
+
if (totalReadyTasks === 0) {
|
|
73
|
+
setSelection(0);
|
|
74
|
+
return;
|
|
20
75
|
}
|
|
76
|
+
setSelection((current) => Math.min(current, totalReadyTasks - 1));
|
|
77
|
+
}, [totalReadyTasks]);
|
|
21
78
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (!READY_TASK_STATUSES.includes(task.status)) {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
79
|
+
useInput((input, key) => {
|
|
80
|
+
if (totalReadyTasks === 0) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
29
83
|
|
|
30
|
-
|
|
31
|
-
|
|
84
|
+
if (key.downArrow) {
|
|
85
|
+
setSelection((current) => Math.min(current + 1, totalReadyTasks - 1));
|
|
86
|
+
}
|
|
32
87
|
|
|
33
|
-
if (
|
|
34
|
-
|
|
88
|
+
if (key.upArrow) {
|
|
89
|
+
setSelection((current) => Math.max(current - 1, 0));
|
|
35
90
|
}
|
|
36
91
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
92
|
+
if (input === "o") {
|
|
93
|
+
const selectedTask = planData.readyTasks[selection];
|
|
94
|
+
openInVsCode(repoRoot, selectedTask.path);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (input === "c") {
|
|
98
|
+
const selectedTask = planData.readyTasks[selection];
|
|
99
|
+
onCopyId(selectedTask.id);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
41
102
|
|
|
42
103
|
if (!schubDir) {
|
|
43
104
|
return (
|
|
@@ -66,12 +127,20 @@ export default function PlanView() {
|
|
|
66
127
|
{planData.readyTasks.length === 0 ? (
|
|
67
128
|
<Text color="gray">No tasks ready for implementation.</Text>
|
|
68
129
|
) : (
|
|
69
|
-
planData.readyTasks.map((task) =>
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
130
|
+
planData.readyTasks.map((task, index) => {
|
|
131
|
+
const selected = index === selection;
|
|
132
|
+
return (
|
|
133
|
+
<Box key={task.id} marginLeft={1}>
|
|
134
|
+
<Text color={selected ? "blue" : "gray"}>{selected ? "›" : " "}</Text>
|
|
135
|
+
<Box marginLeft={1}>
|
|
136
|
+
<Text color="white" bold={selected}>
|
|
137
|
+
{task.id}
|
|
138
|
+
</Text>
|
|
139
|
+
<Text color="gray"> {trimTaskTitle(task.title)}</Text>
|
|
140
|
+
</Box>
|
|
141
|
+
</Box>
|
|
142
|
+
);
|
|
143
|
+
})
|
|
75
144
|
)}
|
|
76
145
|
</Box>
|
|
77
146
|
<Box flexDirection="column" marginTop={2}>
|