@allpepper/task-orchestrator-tui 1.0.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.
Files changed (50) hide show
  1. package/README.md +78 -0
  2. package/package.json +54 -0
  3. package/src/tui/app.tsx +308 -0
  4. package/src/tui/components/column-filter-bar.tsx +52 -0
  5. package/src/tui/components/confirm-dialog.tsx +45 -0
  6. package/src/tui/components/dependency-list.tsx +115 -0
  7. package/src/tui/components/empty-state.tsx +28 -0
  8. package/src/tui/components/entity-table.tsx +120 -0
  9. package/src/tui/components/error-message.tsx +41 -0
  10. package/src/tui/components/feature-kanban-card.tsx +216 -0
  11. package/src/tui/components/footer.tsx +34 -0
  12. package/src/tui/components/form-dialog.tsx +338 -0
  13. package/src/tui/components/header.tsx +54 -0
  14. package/src/tui/components/index.ts +16 -0
  15. package/src/tui/components/kanban-board.tsx +335 -0
  16. package/src/tui/components/kanban-card.tsx +70 -0
  17. package/src/tui/components/kanban-column.tsx +173 -0
  18. package/src/tui/components/priority-badge.tsx +16 -0
  19. package/src/tui/components/section-list.tsx +96 -0
  20. package/src/tui/components/status-actions.tsx +87 -0
  21. package/src/tui/components/status-badge.tsx +22 -0
  22. package/src/tui/components/tree-view.tsx +295 -0
  23. package/src/tui/components/view-mode-chips.tsx +23 -0
  24. package/src/tui/index.tsx +33 -0
  25. package/src/tui/screens/dashboard.tsx +248 -0
  26. package/src/tui/screens/feature-detail.tsx +312 -0
  27. package/src/tui/screens/index.ts +6 -0
  28. package/src/tui/screens/kanban-view.tsx +251 -0
  29. package/src/tui/screens/project-detail.tsx +305 -0
  30. package/src/tui/screens/project-view.tsx +498 -0
  31. package/src/tui/screens/search.tsx +257 -0
  32. package/src/tui/screens/task-detail.tsx +294 -0
  33. package/src/ui/adapters/direct.ts +429 -0
  34. package/src/ui/adapters/index.ts +14 -0
  35. package/src/ui/adapters/types.ts +269 -0
  36. package/src/ui/context/adapter-context.tsx +31 -0
  37. package/src/ui/context/theme-context.tsx +43 -0
  38. package/src/ui/hooks/index.ts +20 -0
  39. package/src/ui/hooks/use-data.ts +919 -0
  40. package/src/ui/hooks/use-debounce.ts +37 -0
  41. package/src/ui/hooks/use-feature-kanban.ts +151 -0
  42. package/src/ui/hooks/use-kanban.ts +96 -0
  43. package/src/ui/hooks/use-navigation.tsx +94 -0
  44. package/src/ui/index.ts +73 -0
  45. package/src/ui/lib/colors.ts +79 -0
  46. package/src/ui/lib/format.ts +114 -0
  47. package/src/ui/lib/types.ts +157 -0
  48. package/src/ui/themes/dark.ts +63 -0
  49. package/src/ui/themes/light.ts +63 -0
  50. package/src/ui/themes/types.ts +71 -0
@@ -0,0 +1,87 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { StatusBadge } from './status-badge';
4
+ import { useTheme } from '../../ui/context/theme-context';
5
+
6
+ export interface StatusActionsProps {
7
+ currentStatus: string;
8
+ allowedTransitions: string[];
9
+ onTransition: (newStatus: string) => void;
10
+ isActive?: boolean;
11
+ loading?: boolean;
12
+ }
13
+
14
+ export function StatusActions({
15
+ currentStatus,
16
+ allowedTransitions,
17
+ onTransition,
18
+ isActive = true,
19
+ loading = false,
20
+ }: StatusActionsProps) {
21
+ const { theme } = useTheme();
22
+ const [selectedIndex, setSelectedIndex] = useState(0);
23
+
24
+ useInput((input, key) => {
25
+ if (!isActive || loading || allowedTransitions.length === 0) return;
26
+
27
+ if (input === 'j' || key.downArrow) {
28
+ const nextIndex = (selectedIndex + 1) % allowedTransitions.length;
29
+ setSelectedIndex(nextIndex);
30
+ } else if (input === 'k' || key.upArrow) {
31
+ const prevIndex = (selectedIndex - 1 + allowedTransitions.length) % allowedTransitions.length;
32
+ setSelectedIndex(prevIndex);
33
+ } else if (key.return) {
34
+ const selectedStatus = allowedTransitions[selectedIndex];
35
+ if (selectedStatus !== undefined) {
36
+ onTransition(selectedStatus);
37
+ }
38
+ }
39
+ });
40
+
41
+ return (
42
+ <Box flexDirection="column">
43
+ {/* Current Status */}
44
+ <Box marginBottom={1}>
45
+ <Text>Status: </Text>
46
+ <StatusBadge status={currentStatus} />
47
+ </Box>
48
+
49
+ {/* Loading Indicator */}
50
+ {loading && (
51
+ <Box>
52
+ <Text dimColor>Loading...</Text>
53
+ </Box>
54
+ )}
55
+
56
+ {/* Transitions Section */}
57
+ {!loading && (
58
+ <>
59
+ {allowedTransitions.length === 0 ? (
60
+ <Box>
61
+ <Text dimColor>No transitions available</Text>
62
+ </Box>
63
+ ) : (
64
+ <Box flexDirection="column">
65
+ <Box marginBottom={1}>
66
+ <Text>Change to:</Text>
67
+ </Box>
68
+ {allowedTransitions.map((status, index) => {
69
+ const isSelected = index === selectedIndex;
70
+ return (
71
+ <Box key={status} marginLeft={2}>
72
+ <Text color={isSelected ? theme.colors.highlight : undefined}>
73
+ {isSelected ? '▎' : ' '}
74
+ </Text>
75
+ <Text bold={isSelected}>
76
+ {' '}{status}
77
+ </Text>
78
+ </Box>
79
+ );
80
+ })}
81
+ </Box>
82
+ )}
83
+ </>
84
+ )}
85
+ </Box>
86
+ );
87
+ }
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ import { Text } from 'ink';
3
+ import { getStatusColor } from '../../ui/lib/colors';
4
+ import { useTheme } from '../../ui/context/theme-context';
5
+ import { formatStatus } from '../../ui/lib/format';
6
+
7
+ interface StatusBadgeProps {
8
+ status: string;
9
+ isSelected?: boolean;
10
+ }
11
+
12
+ export function StatusBadge({ status }: StatusBadgeProps) {
13
+ const { theme } = useTheme();
14
+ const color = getStatusColor(status, theme);
15
+ const formattedStatus = formatStatus(status);
16
+
17
+ return (
18
+ <Text color={color}>
19
+ ● {formattedStatus}
20
+ </Text>
21
+ );
22
+ }
@@ -0,0 +1,295 @@
1
+ import React from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { StatusBadge } from './status-badge';
4
+ import { PriorityBadge } from './priority-badge';
5
+ import { useTheme } from '../../ui/context/theme-context';
6
+ import type { Feature, Task } from 'task-orchestrator-bun/src/domain/types';
7
+
8
+ export type TreeRow =
9
+ | { type: 'feature'; feature: Feature; taskCount: number; expanded: boolean; expandable?: boolean }
10
+ | { type: 'task'; task: Task; isLast: boolean; featureName?: string; depth?: number }
11
+ | { type: 'separator'; label: string }
12
+ | { type: 'group'; id: string; label: string; status: string; taskCount: number; expanded: boolean; depth?: number; expandable?: boolean; featureId?: string };
13
+
14
+ export interface TreeViewProps {
15
+ rows: TreeRow[];
16
+ selectedIndex: number;
17
+ onSelectedIndexChange: (index: number) => void;
18
+ onToggleFeature: (featureId: string) => void;
19
+ onToggleGroup?: (groupId: string) => void;
20
+ onSelectTask: (taskId: string) => void;
21
+ onSelectFeature?: (featureId: string) => void;
22
+ onBack?: () => void;
23
+ isActive?: boolean;
24
+ }
25
+
26
+ export function TreeView({
27
+ rows,
28
+ selectedIndex,
29
+ onSelectedIndexChange,
30
+ onToggleFeature,
31
+ onToggleGroup,
32
+ onSelectTask,
33
+ onSelectFeature,
34
+ onBack,
35
+ isActive = true,
36
+ }: TreeViewProps) {
37
+ const { theme } = useTheme();
38
+ const getRowDepth = (row: TreeRow): number => {
39
+ if (row.type === 'group') return row.depth ?? 0;
40
+ if (row.type === 'task') return row.depth ?? 1;
41
+ return 0;
42
+ };
43
+
44
+ const isRowExpandable = (row: TreeRow): boolean => {
45
+ if (row.type === 'feature') {
46
+ return row.expandable ?? row.taskCount > 0;
47
+ }
48
+ if (row.type === 'group') {
49
+ return row.expandable ?? row.taskCount > 0;
50
+ }
51
+ return false;
52
+ };
53
+
54
+ const toggleRow = (row: TreeRow) => {
55
+ if (row.type === 'feature') {
56
+ onToggleFeature(row.feature.id);
57
+ } else if (row.type === 'group' && onToggleGroup) {
58
+ onToggleGroup(row.id);
59
+ }
60
+ };
61
+
62
+ const findFirstChildIndex = (index: number): number => {
63
+ const current = rows[index];
64
+ if (!current) return -1;
65
+ const currentDepth = getRowDepth(current);
66
+
67
+ for (let i = index + 1; i < rows.length; i++) {
68
+ const nextDepth = getRowDepth(rows[i]!);
69
+ if (nextDepth <= currentDepth) return -1;
70
+ if (nextDepth === currentDepth + 1) return i;
71
+ }
72
+
73
+ return -1;
74
+ };
75
+
76
+ const findParentIndex = (index: number): number => {
77
+ const current = rows[index];
78
+ if (!current) return -1;
79
+ const currentDepth = getRowDepth(current);
80
+ if (currentDepth <= 0) return -1;
81
+
82
+ for (let i = index - 1; i >= 0; i--) {
83
+ if (getRowDepth(rows[i]!) === currentDepth - 1) {
84
+ return i;
85
+ }
86
+ }
87
+
88
+ return -1;
89
+ };
90
+
91
+ const handleNavigateRight = () => {
92
+ const row = rows[selectedIndex];
93
+ if (!row) return;
94
+
95
+ if (row.type === 'task') {
96
+ onSelectTask(row.task.id);
97
+ return;
98
+ }
99
+
100
+ if (row.type === 'separator') return;
101
+
102
+ const expandable = isRowExpandable(row);
103
+ if (expandable && !row.expanded) {
104
+ toggleRow(row);
105
+ return;
106
+ }
107
+
108
+ if (row.type === 'feature') {
109
+ onSelectFeature?.(row.feature.id);
110
+ return;
111
+ }
112
+
113
+ const childIndex = findFirstChildIndex(selectedIndex);
114
+ if (childIndex >= 0) {
115
+ onSelectedIndexChange(childIndex);
116
+ }
117
+ };
118
+
119
+ const handleNavigateLeft = () => {
120
+ const row = rows[selectedIndex];
121
+ if (!row) return;
122
+
123
+ if (row.type === 'task') {
124
+ const parentIndex = findParentIndex(selectedIndex);
125
+ if (parentIndex >= 0) {
126
+ const parentRow = rows[parentIndex];
127
+ if (
128
+ parentRow &&
129
+ (parentRow.type === 'feature' || parentRow.type === 'group') &&
130
+ isRowExpandable(parentRow) &&
131
+ parentRow.expanded
132
+ ) {
133
+ toggleRow(parentRow);
134
+ }
135
+ onSelectedIndexChange(parentIndex);
136
+ return;
137
+ }
138
+
139
+ onBack?.();
140
+ return;
141
+ }
142
+
143
+ if ((row.type === 'feature' || row.type === 'group') && isRowExpandable(row) && row.expanded) {
144
+ toggleRow(row);
145
+ return;
146
+ }
147
+
148
+ const parentIndex = findParentIndex(selectedIndex);
149
+ if (parentIndex >= 0) {
150
+ onSelectedIndexChange(parentIndex);
151
+ return;
152
+ }
153
+
154
+ onBack?.();
155
+ };
156
+
157
+ useInput((input, key) => {
158
+ if (!isActive) return;
159
+ if (rows.length === 0) return;
160
+
161
+ // Navigation: j/down or k/up
162
+ if (input === 'j' || key.downArrow) {
163
+ const nextIndex = (selectedIndex + 1) % rows.length;
164
+ onSelectedIndexChange(nextIndex);
165
+ } else if (input === 'k' || key.upArrow) {
166
+ const prevIndex = (selectedIndex - 1 + rows.length) % rows.length;
167
+ onSelectedIndexChange(prevIndex);
168
+ } else if (input === 'l' || key.rightArrow) {
169
+ handleNavigateRight();
170
+ } else if (input === 'h' || key.leftArrow) {
171
+ handleNavigateLeft();
172
+ } else if ((key.return || key.tab || input === ' ') && rows[selectedIndex]) {
173
+ // Selection: Enter or Space
174
+ const row = rows[selectedIndex];
175
+ if (row.type === 'feature') {
176
+ if (isRowExpandable(row)) {
177
+ onToggleFeature(row.feature.id);
178
+ }
179
+ } else if (row.type === 'group' && onToggleGroup) {
180
+ if (isRowExpandable(row)) {
181
+ onToggleGroup(row.id);
182
+ }
183
+ } else if (row.type === 'task') {
184
+ onSelectTask(row.task.id);
185
+ }
186
+ // Separator rows do nothing on selection
187
+ }
188
+ }, { isActive });
189
+
190
+ const renderRow = (row: TreeRow, index: number) => {
191
+ const isSelected = index === selectedIndex;
192
+
193
+ if (row.type === 'separator') {
194
+ return (
195
+ <Box key={`separator-${index}`} width="100%">
196
+ {/* Selection gutter */}
197
+ <Text color={isSelected ? theme.colors.highlight : undefined}>
198
+ {isSelected ? '▎' : ' '}
199
+ </Text>
200
+ <Text dimColor bold={isSelected}>
201
+ ─────────── {row.label} ───────────
202
+ </Text>
203
+ </Box>
204
+ );
205
+ }
206
+
207
+ if (row.type === 'group') {
208
+ const expandable = isRowExpandable(row);
209
+ const expandIcon = row.expanded ? '▼' : '▶';
210
+ const indent = row.depth ? ' '.repeat(row.depth) : '';
211
+ return (
212
+ <Box key={row.id} width="100%">
213
+ {/* Selection gutter */}
214
+ <Text color={isSelected ? theme.colors.highlight : undefined}>
215
+ {isSelected ? '▎' : ' '}
216
+ </Text>
217
+ <Text color={theme.colors.muted}>
218
+ {indent}
219
+ </Text>
220
+ {expandable && (
221
+ <Text color={theme.colors.muted}>
222
+ {expandIcon}{' '}
223
+ </Text>
224
+ )}
225
+ <Text bold={isSelected}>
226
+ {row.label}
227
+ {' '}
228
+ </Text>
229
+ <StatusBadge status={row.status} />
230
+ <Text bold={isSelected}>
231
+ {' '}
232
+ ({row.taskCount})
233
+ </Text>
234
+ </Box>
235
+ );
236
+ }
237
+
238
+ if (row.type === 'feature') {
239
+ const expandable = isRowExpandable(row);
240
+ const expandIcon = row.expanded ? '▼' : '▶';
241
+ return (
242
+ <Box key={row.feature.id} width="100%">
243
+ {/* Selection gutter */}
244
+ <Text color={isSelected ? theme.colors.highlight : undefined}>
245
+ {isSelected ? '▎' : ' '}
246
+ </Text>
247
+ {expandable && (
248
+ <Text color={theme.colors.muted}>
249
+ {expandIcon}{' '}
250
+ </Text>
251
+ )}
252
+ <Text bold={isSelected}>
253
+ {row.feature.name}
254
+ {' '}
255
+ </Text>
256
+ <StatusBadge status={row.feature.status} />
257
+ <Text bold={isSelected}>
258
+ {' '}
259
+ {row.taskCount} tasks
260
+ </Text>
261
+ </Box>
262
+ );
263
+ }
264
+
265
+ if (row.type === 'task') {
266
+ const indent = row.depth ? ' '.repeat(row.depth) : '';
267
+ const treePrefix = row.isLast ? '└─ ' : '├─ ';
268
+ return (
269
+ <Box key={row.task.id} width="100%">
270
+ {/* Selection gutter */}
271
+ <Text color={isSelected ? theme.colors.highlight : undefined}>
272
+ {isSelected ? '▎' : ' '}
273
+ </Text>
274
+ <Text color={theme.colors.muted}>
275
+ {indent} {treePrefix}
276
+ </Text>
277
+ <Text bold={isSelected}>
278
+ {row.task.title}
279
+ {row.featureName !== undefined && (row.featureName ? ` [${row.featureName}]` : ' [unassigned]')}
280
+ {' '}
281
+ </Text>
282
+ <PriorityBadge priority={row.task.priority} />
283
+ </Box>
284
+ );
285
+ }
286
+
287
+ return null;
288
+ };
289
+
290
+ return (
291
+ <Box flexDirection="column">
292
+ {rows.map((row, index) => renderRow(row, index))}
293
+ </Box>
294
+ );
295
+ }
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ interface ViewModeChipsProps {
5
+ modes: Array<{ key: string; label: string }>;
6
+ activeMode: string;
7
+ onModeChange: (mode: string) => void;
8
+ }
9
+
10
+ export function ViewModeChips({ modes, activeMode }: ViewModeChipsProps) {
11
+ return (
12
+ <Box flexDirection="row" gap={1}>
13
+ {modes.map((mode) => {
14
+ const isActive = mode.key === activeMode;
15
+ return (
16
+ <Text key={mode.key} inverse={isActive} dimColor={!isActive}>
17
+ {isActive ? `[${mode.label}]` : ` ${mode.label} `}
18
+ </Text>
19
+ );
20
+ })}
21
+ </Box>
22
+ );
23
+ }
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bun
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ // Set default DB path BEFORE dynamically importing modules that read it
6
+ if (!process.env.DATABASE_PATH) {
7
+ process.env.DATABASE_PATH = join(homedir(), '.task-orchestrator', 'tasks.db');
8
+ }
9
+
10
+ async function main() {
11
+ // Check if we're in a TTY environment
12
+ if (!process.stdin.isTTY) {
13
+ console.error('TUI requires an interactive terminal. Run directly in a terminal, not through a pipe.');
14
+ process.exit(1);
15
+ }
16
+
17
+ // Dynamic imports so DATABASE_PATH is set before db/client.ts loads
18
+ const [{ render }, React, { runMigrations }, { App }] = await Promise.all([
19
+ import('ink'),
20
+ import('react'),
21
+ import('task-orchestrator-bun/src/db/migrate'),
22
+ import('./app'),
23
+ ]);
24
+
25
+ // Run database migrations first
26
+ await runMigrations();
27
+
28
+ // Render the TUI
29
+ const { waitUntilExit } = render(<App />);
30
+ await waitUntilExit();
31
+ }
32
+
33
+ main().catch(console.error);