schub 0.1.2 → 0.1.4
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 +27 -0
- package/dist/index.js +12830 -3057
- package/package.json +5 -2
- package/skills/create-proposal/SKILL.md +5 -1
- package/skills/create-tasks/SKILL.md +5 -4
- package/skills/implement-task/SKILL.md +6 -1
- package/skills/review-proposal/SKILL.md +3 -2
- package/skills/update-roadmap/SKILL.md +23 -0
- package/src/changes.test.ts +166 -0
- package/src/changes.ts +159 -54
- package/src/commands/adr.test.ts +6 -5
- package/src/commands/changes.test.ts +136 -14
- package/src/commands/changes.ts +102 -1
- package/src/commands/cookbook.test.ts +6 -5
- package/src/commands/init.test.ts +69 -2
- package/src/commands/init.ts +48 -5
- package/src/commands/review.test.ts +7 -6
- package/src/commands/review.ts +1 -1
- package/src/commands/roadmap.test.ts +84 -0
- package/src/commands/roadmap.ts +84 -0
- package/src/commands/tasks-create.test.ts +22 -22
- package/src/commands/tasks-implement.test.ts +253 -0
- package/src/commands/tasks-implement.ts +121 -0
- package/src/commands/tasks-list.test.ts +27 -27
- package/src/commands/tasks-update.test.ts +92 -0
- package/src/commands/tasks.ts +98 -1
- package/src/features/roadmap/index.ts +230 -0
- package/src/features/roadmap/roadmap.test.ts +77 -0
- package/src/features/tasks/constants.ts +1 -0
- package/src/features/tasks/create.ts +10 -8
- package/src/features/tasks/filesystem.test.ts +285 -18
- package/src/features/tasks/filesystem.ts +152 -39
- package/src/features/tasks/graph.ts +18 -3
- package/src/features/tasks/index.ts +10 -1
- package/src/features/tasks/worktree.ts +48 -0
- package/src/frontmatter.ts +115 -0
- package/src/index.test.ts +42 -6
- package/src/index.ts +226 -109
- package/src/opencode.test.ts +53 -0
- package/src/opencode.ts +74 -0
- package/src/tasks.ts +2 -0
- package/src/tui/App.test.tsx +418 -0
- package/src/tui/App.tsx +343 -0
- package/src/tui/components/PlanView.test.tsx +101 -0
- package/src/tui/components/PlanView.tsx +89 -0
- package/src/tui/components/PreviewPage.test.tsx +69 -0
- package/src/tui/components/PreviewPage.tsx +87 -0
- package/src/tui/components/ProposalDetailView.test.tsx +169 -0
- package/src/tui/components/ProposalDetailView.tsx +166 -0
- package/src/tui/components/RoadmapView.test.tsx +85 -0
- package/src/tui/components/RoadmapView.tsx +369 -0
- package/src/tui/components/StatusView.test.tsx +1351 -0
- package/src/tui/components/StatusView.tsx +519 -0
- package/src/tui/components/markdown-renderer.test.ts +46 -0
- package/src/tui/components/markdown-renderer.ts +89 -0
- package/src/tui/components/status-view-data.ts +322 -0
- package/src/tui/components/status-view-render.tsx +329 -0
- package/src/tui/index.ts +16 -0
- package/templates/create-proposal/adr-template.md +6 -4
- package/templates/create-proposal/cookbook-template.md +5 -3
- package/templates/create-proposal/proposal-template.md +8 -6
- package/templates/create-roadmap/roadmap.md +5 -0
- package/templates/create-tasks/task-template.md +9 -4
- package/templates/review-proposal/q&a-template.md +8 -3
- package/templates/review-proposal/review-me-template.md +6 -4
- package/templates/setup-project/project-overview-template.md +5 -0
- package/templates/setup-project/project-setup-template.md +5 -0
- package/templates/setup-project/project-wow-template.md +5 -0
- package/src/App.test.tsx +0 -93
- package/src/App.tsx +0 -155
- package/src/components/PlanView.test.tsx +0 -113
- package/src/components/PlanView.tsx +0 -160
- package/src/components/StatusView.test.tsx +0 -380
- package/src/components/StatusView.tsx +0 -367
- package/src/ide.ts +0 -7
- package/templates/templates-parity.test.ts +0 -45
- /package/src/{clipboard.ts → tui/clipboard.ts} +0 -0
- /package/src/{components → tui/components}/statusColor.ts +0 -0
- /package/src/{terminal.test.ts → tui/terminal.test.ts} +0 -0
- /package/src/{terminal.ts → tui/terminal.ts} +0 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { addRoadmapItem, listRoadmapItems, proposeRoadmapItem, type RoadmapItem } from "../../features/roadmap";
|
|
4
|
+
import { findSchubRoot } from "../../features/tasks";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_REFRESH_INTERVAL_MS = 1000;
|
|
7
|
+
|
|
8
|
+
type RoadmapViewProps = {
|
|
9
|
+
refreshIntervalMs?: number;
|
|
10
|
+
startDir?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type Command = {
|
|
14
|
+
id: "add" | "propose";
|
|
15
|
+
label: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const COMMANDS: Command[] = [
|
|
19
|
+
{ id: "add", label: "Add story" },
|
|
20
|
+
{ id: "propose", label: "Create proposal" },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const compareText = (left: string, right: string) => left.localeCompare(right, undefined, { sensitivity: "base" });
|
|
24
|
+
|
|
25
|
+
const sortRoadmapItems = (items: RoadmapItem[]) =>
|
|
26
|
+
[...items].sort((left, right) => {
|
|
27
|
+
const titleCompare = compareText(left.story, right.story);
|
|
28
|
+
if (titleCompare !== 0) {
|
|
29
|
+
return titleCompare;
|
|
30
|
+
}
|
|
31
|
+
return left.index - right.index;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const loadRoadmapState = (schubDir: string) => {
|
|
35
|
+
try {
|
|
36
|
+
return { items: sortRoadmapItems(listRoadmapItems(schubDir)), error: null };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
39
|
+
return { items: [] as RoadmapItem[], error: message };
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const formatRef = (ref: string | null) => (ref ? ref : "--");
|
|
44
|
+
|
|
45
|
+
const normalizeTypedValue = (input: string, keyName?: string, keySequence?: string) => {
|
|
46
|
+
if (input) {
|
|
47
|
+
return input;
|
|
48
|
+
}
|
|
49
|
+
if (keySequence && keySequence.length === 1 && keySequence !== "\r" && keySequence !== "\n") {
|
|
50
|
+
return keySequence;
|
|
51
|
+
}
|
|
52
|
+
if (!keyName) {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
if (keyName.length === 1) {
|
|
56
|
+
return keyName;
|
|
57
|
+
}
|
|
58
|
+
if (keyName === "space") {
|
|
59
|
+
return " ";
|
|
60
|
+
}
|
|
61
|
+
if (keyName === "period") {
|
|
62
|
+
return ".";
|
|
63
|
+
}
|
|
64
|
+
if (keyName === "dot") {
|
|
65
|
+
return ".";
|
|
66
|
+
}
|
|
67
|
+
return "";
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export default function RoadmapView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, startDir }: RoadmapViewProps) {
|
|
71
|
+
const resolvedStartDir = startDir ?? process.env.SCHUB_CWD ?? process.cwd();
|
|
72
|
+
const schubDir = findSchubRoot(resolvedStartDir);
|
|
73
|
+
const initialStateRef = React.useRef<{ items: RoadmapItem[]; error: string | null } | null>(null);
|
|
74
|
+
if (!initialStateRef.current) {
|
|
75
|
+
initialStateRef.current = schubDir ? loadRoadmapState(schubDir) : { items: [], error: null };
|
|
76
|
+
}
|
|
77
|
+
const [items, setItems] = React.useState<RoadmapItem[]>(initialStateRef.current.items);
|
|
78
|
+
const [selection, setSelection] = React.useState(0);
|
|
79
|
+
const [errorMessage, setErrorMessage] = React.useState<string | null>(initialStateRef.current.error);
|
|
80
|
+
const [inputActive, setInputActive] = React.useState(false);
|
|
81
|
+
const [inputValue, setInputValue] = React.useState("");
|
|
82
|
+
const [paletteOpen, setPaletteOpen] = React.useState(false);
|
|
83
|
+
const [paletteSelection, setPaletteSelection] = React.useState(0);
|
|
84
|
+
const itemsRef = React.useRef(items);
|
|
85
|
+
const selectionRef = React.useRef(selection);
|
|
86
|
+
const paletteOpenRef = React.useRef(paletteOpen);
|
|
87
|
+
const inputActiveRef = React.useRef(inputActive);
|
|
88
|
+
const inputValueRef = React.useRef(inputValue);
|
|
89
|
+
|
|
90
|
+
React.useEffect(() => {
|
|
91
|
+
if (!schubDir) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const refresh = () => {
|
|
96
|
+
const { items: loaded, error } = loadRoadmapState(schubDir);
|
|
97
|
+
setItems(loaded);
|
|
98
|
+
setErrorMessage(error);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
refresh();
|
|
102
|
+
const interval = setInterval(() => {
|
|
103
|
+
refresh();
|
|
104
|
+
}, refreshIntervalMs);
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
clearInterval(interval);
|
|
108
|
+
};
|
|
109
|
+
}, [refreshIntervalMs, schubDir]);
|
|
110
|
+
|
|
111
|
+
React.useEffect(() => {
|
|
112
|
+
if (items.length === 0) {
|
|
113
|
+
setSelection(0);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
setSelection((current) => Math.min(current, items.length - 1));
|
|
117
|
+
}, [items.length]);
|
|
118
|
+
|
|
119
|
+
React.useEffect(() => {
|
|
120
|
+
itemsRef.current = items;
|
|
121
|
+
selectionRef.current = selection;
|
|
122
|
+
paletteOpenRef.current = paletteOpen;
|
|
123
|
+
inputActiveRef.current = inputActive;
|
|
124
|
+
inputValueRef.current = inputValue;
|
|
125
|
+
}, [items, selection, paletteOpen, inputActive, inputValue]);
|
|
126
|
+
|
|
127
|
+
const openPalette = () => {
|
|
128
|
+
setPaletteOpen(true);
|
|
129
|
+
setPaletteSelection(0);
|
|
130
|
+
setInputActive(false);
|
|
131
|
+
inputValueRef.current = "";
|
|
132
|
+
setInputValue("");
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const closePalette = () => {
|
|
136
|
+
setPaletteOpen(false);
|
|
137
|
+
setPaletteSelection(0);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const startInput = () => {
|
|
141
|
+
inputActiveRef.current = true;
|
|
142
|
+
setInputActive(true);
|
|
143
|
+
inputValueRef.current = "";
|
|
144
|
+
setInputValue("");
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const handleAdd = () => {
|
|
148
|
+
if (!schubDir) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const trimmed = inputValueRef.current.trim();
|
|
152
|
+
if (!trimmed) {
|
|
153
|
+
setInputActive(false);
|
|
154
|
+
inputValueRef.current = "";
|
|
155
|
+
setInputValue("");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
addRoadmapItem(schubDir, trimmed);
|
|
160
|
+
setInputActive(false);
|
|
161
|
+
inputValueRef.current = "";
|
|
162
|
+
setInputValue("");
|
|
163
|
+
const { items: loaded, error } = loadRoadmapState(schubDir);
|
|
164
|
+
setItems(loaded);
|
|
165
|
+
setErrorMessage(error);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
168
|
+
setErrorMessage(message);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const handlePropose = () => {
|
|
173
|
+
if (!schubDir) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const selected = itemsRef.current[selectionRef.current];
|
|
177
|
+
if (!selected) {
|
|
178
|
+
setErrorMessage("Select a roadmap item first.");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
proposeRoadmapItem(schubDir, selected.index);
|
|
183
|
+
const { items: loaded, error } = loadRoadmapState(schubDir);
|
|
184
|
+
setItems(loaded);
|
|
185
|
+
setErrorMessage(error);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
188
|
+
setErrorMessage(message);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
useInput((input, key) => {
|
|
193
|
+
const lowerInput = input.toLowerCase();
|
|
194
|
+
const keyName = (key as { name?: string }).name;
|
|
195
|
+
const keySequence = (key as { sequence?: string }).sequence;
|
|
196
|
+
const isEscape = key.escape || keyName === "escape" || keySequence === "\u001B" || input === "\u001B";
|
|
197
|
+
const isEnter =
|
|
198
|
+
key.return ||
|
|
199
|
+
(key as { enter?: boolean }).enter ||
|
|
200
|
+
keyName === "return" ||
|
|
201
|
+
keyName === "enter" ||
|
|
202
|
+
keySequence === "\r" ||
|
|
203
|
+
keySequence === "\n" ||
|
|
204
|
+
input === "\r" ||
|
|
205
|
+
input === "\n";
|
|
206
|
+
const isCtrlP =
|
|
207
|
+
keySequence === "\u0010" ||
|
|
208
|
+
input === "\u0010" ||
|
|
209
|
+
(key.ctrl && (lowerInput === "p" || keyName === "p")) ||
|
|
210
|
+
(!input && keyName === "p");
|
|
211
|
+
const typedValue = normalizeTypedValue(input, keyName, keySequence);
|
|
212
|
+
|
|
213
|
+
const paletteOpenCurrent = paletteOpenRef.current;
|
|
214
|
+
const inputActiveCurrent = inputActiveRef.current;
|
|
215
|
+
const itemsCurrent = itemsRef.current;
|
|
216
|
+
const selectionCurrent = selectionRef.current;
|
|
217
|
+
|
|
218
|
+
if (paletteOpenCurrent) {
|
|
219
|
+
if (isEscape || lowerInput === "q") {
|
|
220
|
+
closePalette();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (key.upArrow || key.leftArrow) {
|
|
224
|
+
setPaletteSelection((current) => Math.max(0, current - 1));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (key.downArrow || key.rightArrow) {
|
|
228
|
+
setPaletteSelection((current) => Math.min(COMMANDS.length - 1, current + 1));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (isEnter) {
|
|
232
|
+
const command = COMMANDS[paletteSelection];
|
|
233
|
+
closePalette();
|
|
234
|
+
if (command?.id === "add") {
|
|
235
|
+
startInput();
|
|
236
|
+
} else if (command?.id === "propose") {
|
|
237
|
+
handlePropose();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (inputActiveCurrent) {
|
|
244
|
+
if (isEscape) {
|
|
245
|
+
setInputActive(false);
|
|
246
|
+
inputValueRef.current = "";
|
|
247
|
+
setInputValue("");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (isEnter) {
|
|
251
|
+
handleAdd();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (key.backspace || key.delete) {
|
|
255
|
+
setInputValue((current) => {
|
|
256
|
+
const nextValue = current.slice(0, -1);
|
|
257
|
+
inputValueRef.current = nextValue;
|
|
258
|
+
return nextValue;
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (typedValue && !key.ctrl) {
|
|
263
|
+
setInputValue((current) => {
|
|
264
|
+
const nextValue = current + typedValue;
|
|
265
|
+
inputValueRef.current = nextValue;
|
|
266
|
+
return nextValue;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (isCtrlP) {
|
|
273
|
+
openPalette();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (typedValue && !key.ctrl) {
|
|
278
|
+
inputActiveRef.current = true;
|
|
279
|
+
setInputActive(true);
|
|
280
|
+
inputValueRef.current = typedValue;
|
|
281
|
+
setInputValue(typedValue);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (key.upArrow) {
|
|
286
|
+
const nextSelection = Math.max(0, selectionCurrent - 1);
|
|
287
|
+
selectionRef.current = nextSelection;
|
|
288
|
+
setSelection(nextSelection);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (key.downArrow) {
|
|
292
|
+
const nextSelection = Math.min(itemsCurrent.length - 1, selectionCurrent + 1);
|
|
293
|
+
selectionRef.current = nextSelection;
|
|
294
|
+
setSelection(nextSelection);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
if (!schubDir) {
|
|
299
|
+
return (
|
|
300
|
+
<Box flexDirection="column">
|
|
301
|
+
<Text bold>Roadmap</Text>
|
|
302
|
+
<Text color="red">No .schub directory found.</Text>
|
|
303
|
+
</Box>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<Box flexDirection="column">
|
|
309
|
+
<Box marginBottom={1}>
|
|
310
|
+
<Text bold color="white">
|
|
311
|
+
Roadmap
|
|
312
|
+
</Text>
|
|
313
|
+
</Box>
|
|
314
|
+
{errorMessage ? (
|
|
315
|
+
<Box marginBottom={1}>
|
|
316
|
+
<Text color="red">{errorMessage}</Text>
|
|
317
|
+
</Box>
|
|
318
|
+
) : null}
|
|
319
|
+
{items.length === 0 ? (
|
|
320
|
+
<Box marginLeft={1}>
|
|
321
|
+
<Text color="gray">No roadmap items found.</Text>
|
|
322
|
+
</Box>
|
|
323
|
+
) : (
|
|
324
|
+
<Box flexDirection="column">
|
|
325
|
+
{items.map((item, index) => {
|
|
326
|
+
const selected = index === selection;
|
|
327
|
+
return (
|
|
328
|
+
<Box key={`${item.index}-${item.story}`} marginLeft={1}>
|
|
329
|
+
<Text color={selected ? "blue" : "gray"}>{selected ? "›" : " "}</Text>
|
|
330
|
+
<Box marginLeft={1}>
|
|
331
|
+
<Text color="white" bold={selected}>
|
|
332
|
+
{item.index}
|
|
333
|
+
</Text>
|
|
334
|
+
<Text color="gray"> [{formatRef(item.proposalRef)}]</Text>
|
|
335
|
+
<Text color="gray"> {item.story}</Text>
|
|
336
|
+
</Box>
|
|
337
|
+
</Box>
|
|
338
|
+
);
|
|
339
|
+
})}
|
|
340
|
+
</Box>
|
|
341
|
+
)}
|
|
342
|
+
{inputActive ? (
|
|
343
|
+
<Box marginTop={1} marginLeft={1}>
|
|
344
|
+
<Text color="white">Add story:</Text>
|
|
345
|
+
<Text color="gray"> {inputValue || " "}</Text>
|
|
346
|
+
</Box>
|
|
347
|
+
) : null}
|
|
348
|
+
{paletteOpen ? (
|
|
349
|
+
<Box borderStyle="round" borderColor="gray" flexDirection="column" marginTop={1} paddingX={1}>
|
|
350
|
+
<Text bold color="white">
|
|
351
|
+
Command Palette
|
|
352
|
+
</Text>
|
|
353
|
+
<Box flexDirection="column" marginTop={1}>
|
|
354
|
+
{COMMANDS.map((command, index) => {
|
|
355
|
+
const selected = index === paletteSelection;
|
|
356
|
+
return (
|
|
357
|
+
<Box key={command.id}>
|
|
358
|
+
<Text color={selected ? "blue" : "gray"}>{selected ? "›" : " "}</Text>
|
|
359
|
+
<Text color="white"> {command.label}</Text>
|
|
360
|
+
</Box>
|
|
361
|
+
);
|
|
362
|
+
})}
|
|
363
|
+
</Box>
|
|
364
|
+
<Text color="gray">enter to run · esc to cancel</Text>
|
|
365
|
+
</Box>
|
|
366
|
+
) : null}
|
|
367
|
+
</Box>
|
|
368
|
+
);
|
|
369
|
+
}
|