@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,498 @@
1
+ import React, { useState, useMemo, useEffect, useRef } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { useProjectTree } from '../../ui/hooks/use-data';
4
+ import { useAdapter } from '../../ui/context/adapter-context';
5
+ import { useTheme } from '../../ui/context/theme-context';
6
+ import { TreeView, type TreeRow } from '../components/tree-view';
7
+ import { StatusBadge } from '../components/status-badge';
8
+ import { ViewModeChips } from '../components/view-mode-chips';
9
+ import { ConfirmDialog } from '../components/confirm-dialog';
10
+ import { FormDialog } from '../components/form-dialog';
11
+ import { ErrorMessage } from '../components/error-message';
12
+ import { EmptyState } from '../components/empty-state';
13
+ import type { FeatureStatus, Priority } from 'task-orchestrator-bun/src/domain/types';
14
+
15
+ interface ProjectViewProps {
16
+ projectId: string;
17
+ expandedFeatures: Set<string>;
18
+ onExpandedFeaturesChange: (features: Set<string>) => void;
19
+ expandedGroups: Set<string>;
20
+ onExpandedGroupsChange: (groups: Set<string>) => void;
21
+ selectedIndex: number;
22
+ onSelectedIndexChange: (index: number) => void;
23
+ viewMode: 'features' | 'status' | 'feature-status';
24
+ onViewModeChange: (mode: 'features' | 'status' | 'feature-status') => void;
25
+ onSelectTask: (taskId: string) => void;
26
+ onSelectFeature: (featureId: string) => void;
27
+ onToggleBoard: () => void;
28
+ onBack: () => void;
29
+ }
30
+
31
+ export function ProjectView({ projectId, expandedFeatures, onExpandedFeaturesChange, expandedGroups, onExpandedGroupsChange, selectedIndex, onSelectedIndexChange, viewMode, onViewModeChange, onSelectTask, onSelectFeature, onToggleBoard, onBack }: ProjectViewProps) {
32
+ const { adapter } = useAdapter();
33
+ const { theme } = useTheme();
34
+ const { project, features, unassignedTasks, taskCounts, statusGroupedRows, featureStatusGroupedRows, loading, error, refresh } = useProjectTree(projectId, expandedGroups);
35
+ const [mode, setMode] = useState<'idle' | 'create-feature' | 'edit-feature' | 'delete-feature' | 'create-task' | 'edit-task' | 'delete-task' | 'feature-status'>('idle');
36
+ const [localError, setLocalError] = useState<string | null>(null);
37
+ const [featureTransitions, setFeatureTransitions] = useState<string[]>([]);
38
+ const [featureTransitionIndex, setFeatureTransitionIndex] = useState(0);
39
+
40
+ // Build flat list of rows - switch based on view mode
41
+ const rows = useMemo(() => {
42
+ if (viewMode === 'status') {
43
+ return statusGroupedRows;
44
+ } else if (viewMode === 'feature-status') {
45
+ return featureStatusGroupedRows;
46
+ }
47
+
48
+ // Feature-grouped view (original logic)
49
+ const result: TreeRow[] = [];
50
+
51
+ for (const feature of features) {
52
+ const isExpanded = expandedFeatures.has(feature.id);
53
+ result.push({
54
+ type: 'feature',
55
+ feature,
56
+ taskCount: feature.tasks.length,
57
+ expanded: isExpanded
58
+ });
59
+
60
+ if (isExpanded) {
61
+ feature.tasks.forEach((task, i) => {
62
+ result.push({
63
+ type: 'task',
64
+ task,
65
+ isLast: i === feature.tasks.length - 1,
66
+ depth: 1,
67
+ });
68
+ });
69
+ }
70
+ }
71
+
72
+ if (unassignedTasks.length > 0) {
73
+ result.push({ type: 'separator', label: 'Unassigned Tasks' });
74
+ unassignedTasks.forEach((task, i) => {
75
+ result.push({
76
+ type: 'task',
77
+ task,
78
+ isLast: i === unassignedTasks.length - 1
79
+ });
80
+ });
81
+ }
82
+
83
+ return result;
84
+ }, [viewMode, statusGroupedRows, featureStatusGroupedRows, features, unassignedTasks, expandedFeatures]);
85
+
86
+ // Handle keyboard
87
+ useInput((input, key) => {
88
+ if (mode !== 'idle') return;
89
+ if (key.escape) {
90
+ onBack();
91
+ }
92
+ if (input === 'r') {
93
+ refresh();
94
+ }
95
+ // Cycle view mode with 'v': features → status → feature-status → features
96
+ if (input === 'v') {
97
+ const next = viewMode === 'features' ? 'status' : viewMode === 'status' ? 'feature-status' : 'features';
98
+ onViewModeChange(next);
99
+ }
100
+ if (input === 'b') {
101
+ onToggleBoard();
102
+ }
103
+ if (input === 'n') {
104
+ setMode('create-feature');
105
+ }
106
+ if (input === 't') {
107
+ setMode('create-task');
108
+ }
109
+ // Navigate to feature detail screen
110
+ if (input === 'f') {
111
+ const currentRow = rows[selectedIndex];
112
+ const featureId =
113
+ currentRow?.type === 'feature'
114
+ ? currentRow.feature.id
115
+ : currentRow?.type === 'group'
116
+ ? currentRow.featureId
117
+ : currentRow?.type === 'task'
118
+ ? currentRow.task.featureId
119
+ : undefined;
120
+ if (featureId) onSelectFeature(featureId);
121
+ return;
122
+ }
123
+ if (input === 'e') {
124
+ const currentRow = rows[selectedIndex];
125
+ if (currentRow?.type === 'feature' || (currentRow?.type === 'group' && currentRow.featureId)) {
126
+ setMode('edit-feature');
127
+ } else if (currentRow?.type === 'task') {
128
+ setMode('edit-task');
129
+ }
130
+ }
131
+ if (input === 'd') {
132
+ const currentRow = rows[selectedIndex];
133
+ if (currentRow?.type === 'feature' || (currentRow?.type === 'group' && currentRow.featureId)) {
134
+ setMode('delete-feature');
135
+ } else if (currentRow?.type === 'task') {
136
+ setMode('delete-task');
137
+ }
138
+ }
139
+ if (input === 's') {
140
+ const currentRow = rows[selectedIndex];
141
+ const featureId =
142
+ currentRow?.type === 'feature'
143
+ ? currentRow.feature.id
144
+ : currentRow?.type === 'group'
145
+ ? currentRow.featureId
146
+ : currentRow?.type === 'task'
147
+ ? currentRow.task.featureId
148
+ : undefined;
149
+ const feature = featureId ? features.find((f) => f.id === featureId) : undefined;
150
+ if (feature) {
151
+ adapter.getAllowedTransitions('FEATURE', feature.status).then((result) => {
152
+ if (result.success) {
153
+ setFeatureTransitions(result.data);
154
+ setFeatureTransitionIndex(0);
155
+ setMode('feature-status');
156
+ }
157
+ });
158
+ }
159
+ }
160
+ });
161
+
162
+ useInput((input, key) => {
163
+ if (mode !== 'feature-status') return;
164
+ if (input === 'j' || key.downArrow) {
165
+ setFeatureTransitionIndex((prev) => (prev + 1) % Math.max(1, featureTransitions.length));
166
+ return;
167
+ }
168
+ if (input === 'k' || key.upArrow) {
169
+ setFeatureTransitionIndex((prev) => (prev - 1 + Math.max(1, featureTransitions.length)) % Math.max(1, featureTransitions.length));
170
+ return;
171
+ }
172
+ if (key.escape) {
173
+ setMode('idle');
174
+ return;
175
+ }
176
+ if (key.return) {
177
+ const currentRow = rows[selectedIndex];
178
+ const featureId =
179
+ currentRow?.type === 'feature'
180
+ ? currentRow.feature.id
181
+ : currentRow?.type === 'group'
182
+ ? currentRow.featureId
183
+ : currentRow?.type === 'task'
184
+ ? currentRow.task.featureId
185
+ : undefined;
186
+ const feature = featureId ? features.find((f) => f.id === featureId) : undefined;
187
+ const nextStatus = featureTransitions[featureTransitionIndex] as FeatureStatus | undefined;
188
+ if (feature && nextStatus) {
189
+ adapter.setFeatureStatus(feature.id, nextStatus, feature.version).then((result) => {
190
+ if (!result.success) setLocalError(result.error);
191
+ refresh();
192
+ setMode('idle');
193
+ });
194
+ }
195
+ }
196
+ }, { isActive: mode === 'feature-status' });
197
+
198
+ // Toggle feature expansion
199
+ const handleToggleFeature = (featureId: string) => {
200
+ const next = new Set(expandedFeatures);
201
+ if (next.has(featureId)) {
202
+ next.delete(featureId);
203
+ // Clamp selectedIndex if it was on a child task
204
+ const featureIndex = rows.findIndex(r => r.type === 'feature' && r.feature.id === featureId);
205
+ if (featureIndex >= 0 && selectedIndex > featureIndex) {
206
+ const nextFeatureIndex = rows.findIndex((r, i) => i > featureIndex && r.type === 'feature');
207
+ if (nextFeatureIndex === -1 || selectedIndex < nextFeatureIndex) {
208
+ onSelectedIndexChange(featureIndex);
209
+ }
210
+ }
211
+ } else {
212
+ next.add(featureId);
213
+ }
214
+ onExpandedFeaturesChange(next);
215
+ };
216
+
217
+ // Toggle group expansion (for status groups)
218
+ const handleToggleGroup = (groupId: string) => {
219
+ const next = new Set(expandedGroups);
220
+ if (next.has(groupId)) {
221
+ next.delete(groupId);
222
+ } else {
223
+ next.add(groupId);
224
+ }
225
+ onExpandedGroupsChange(next);
226
+ };
227
+
228
+ // Reset selectedIndex to 0 only when viewMode actually changes (not on mount)
229
+ const prevViewMode = useRef(viewMode);
230
+ useEffect(() => {
231
+ if (prevViewMode.current !== viewMode) {
232
+ onSelectedIndexChange(0);
233
+ prevViewMode.current = viewMode;
234
+ }
235
+ }, [viewMode, onSelectedIndexChange]);
236
+
237
+ if (loading) {
238
+ return <Box padding={1}><Text>Loading project...</Text></Box>;
239
+ }
240
+
241
+ if (error) {
242
+ return <Box padding={1}><Text color={theme.colors.danger}>Error: {error}</Text></Box>;
243
+ }
244
+
245
+ if (!project) {
246
+ return <Box padding={1}><Text>Project not found</Text></Box>;
247
+ }
248
+
249
+ // Clamp selectedIndex if data changed
250
+ const clampedSelectedIndex = Math.min(selectedIndex, Math.max(0, rows.length - 1));
251
+ const effectiveSelectedIndex = rows.length > 0 ? clampedSelectedIndex : 0;
252
+
253
+ return (
254
+ <Box flexDirection="column" padding={1}>
255
+ {/* Project Header */}
256
+ <Box marginBottom={1}>
257
+ <Text bold>{project.name}</Text>
258
+ <Text> </Text>
259
+ <StatusBadge status={project.status} />
260
+ <Text dimColor> — {taskCounts.completed}/{taskCounts.total} tasks completed</Text>
261
+ </Box>
262
+
263
+ {/* View Mode Chips */}
264
+ <Box marginBottom={1}>
265
+ <ViewModeChips
266
+ modes={[
267
+ { key: 'features', label: 'Features' },
268
+ { key: 'status', label: 'Status' },
269
+ { key: 'feature-status', label: 'Feature Status' },
270
+ ]}
271
+ activeMode={viewMode}
272
+ onModeChange={(mode) => onViewModeChange(mode as 'features' | 'status' | 'feature-status')}
273
+ />
274
+ </Box>
275
+
276
+ {rows.length === 0 ? (
277
+ <EmptyState message="No features or tasks yet." hint="Press n to create a feature." />
278
+ ) : (
279
+ <TreeView
280
+ rows={rows}
281
+ selectedIndex={effectiveSelectedIndex}
282
+ onSelectedIndexChange={onSelectedIndexChange}
283
+ onToggleFeature={handleToggleFeature}
284
+ onToggleGroup={handleToggleGroup}
285
+ onSelectTask={onSelectTask}
286
+ onBack={onBack}
287
+ isActive={mode === 'idle'}
288
+ />
289
+ )}
290
+
291
+ {localError ? <ErrorMessage message={localError} onDismiss={() => setLocalError(null)} /> : null}
292
+
293
+ {mode === 'create-feature' ? (
294
+ <FormDialog
295
+ title="Create Feature"
296
+ fields={[
297
+ { key: 'name', label: 'Name', required: true },
298
+ { key: 'summary', label: 'Summary', required: true },
299
+ { key: 'description', label: 'Description' },
300
+ { key: 'priority', label: 'Priority (HIGH/MEDIUM/LOW)', value: 'MEDIUM', required: true },
301
+ ]}
302
+ onCancel={() => setMode('idle')}
303
+ onSubmit={(values) => {
304
+ adapter.createFeature({
305
+ projectId,
306
+ name: values.name ?? '',
307
+ summary: values.summary ?? '',
308
+ description: values.description || undefined,
309
+ priority: ((values.priority ?? 'MEDIUM') as Priority),
310
+ }).then((result) => {
311
+ if (!result.success) setLocalError(result.error);
312
+ refresh();
313
+ setMode('idle');
314
+ });
315
+ }}
316
+ />
317
+ ) : null}
318
+
319
+ {mode === 'edit-feature' ? (
320
+ (() => {
321
+ const currentRow = rows[selectedIndex];
322
+ const featureId =
323
+ currentRow?.type === 'feature'
324
+ ? currentRow.feature.id
325
+ : currentRow?.type === 'group'
326
+ ? currentRow.featureId
327
+ : undefined;
328
+ const feature = featureId ? features.find((f) => f.id === featureId) : undefined;
329
+ if (!feature) return null;
330
+ return (
331
+ <FormDialog
332
+ title="Edit Feature"
333
+ fields={[
334
+ { key: 'name', label: 'Name', required: true, value: feature.name },
335
+ { key: 'summary', label: 'Summary', required: true, value: feature.summary },
336
+ { key: 'description', label: 'Description', value: feature.description ?? '' },
337
+ { key: 'priority', label: 'Priority (HIGH/MEDIUM/LOW)', required: true, value: feature.priority },
338
+ ]}
339
+ onCancel={() => setMode('idle')}
340
+ onSubmit={(values) => {
341
+ adapter.updateFeature(feature.id, {
342
+ name: values.name ?? '',
343
+ summary: values.summary ?? '',
344
+ description: values.description || undefined,
345
+ priority: ((values.priority ?? feature.priority) as Priority),
346
+ version: feature.version,
347
+ }).then((result) => {
348
+ if (!result.success) setLocalError(result.error);
349
+ refresh();
350
+ setMode('idle');
351
+ });
352
+ }}
353
+ />
354
+ );
355
+ })()
356
+ ) : null}
357
+
358
+ {mode === 'delete-feature' ? (
359
+ (() => {
360
+ const currentRow = rows[selectedIndex];
361
+ const featureId =
362
+ currentRow?.type === 'feature'
363
+ ? currentRow.feature.id
364
+ : currentRow?.type === 'group'
365
+ ? currentRow.featureId
366
+ : undefined;
367
+ const feature = featureId ? features.find((f) => f.id === featureId) : undefined;
368
+ if (!feature) return null;
369
+ return (
370
+ <ConfirmDialog
371
+ title="Delete Feature"
372
+ message={`Delete "${feature.name}"?`}
373
+ onCancel={() => setMode('idle')}
374
+ onConfirm={() => {
375
+ adapter.deleteFeature(feature.id).then((result) => {
376
+ if (!result.success) setLocalError(result.error);
377
+ refresh();
378
+ setMode('idle');
379
+ });
380
+ }}
381
+ />
382
+ );
383
+ })()
384
+ ) : null}
385
+
386
+ {mode === 'create-task' ? (
387
+ <FormDialog
388
+ title="Create Task"
389
+ fields={[
390
+ { key: 'title', label: 'Title', required: true },
391
+ { key: 'summary', label: 'Summary', required: true },
392
+ { key: 'description', label: 'Description' },
393
+ { key: 'priority', label: 'Priority (HIGH/MEDIUM/LOW)', required: true, value: 'MEDIUM' },
394
+ { key: 'complexity', label: 'Complexity (1-10)', required: true, value: '3' },
395
+ ]}
396
+ onCancel={() => setMode('idle')}
397
+ onSubmit={(values) => {
398
+ const currentRow = rows[selectedIndex];
399
+ const featureId =
400
+ currentRow?.type === 'feature'
401
+ ? currentRow.feature.id
402
+ : currentRow?.type === 'group'
403
+ ? currentRow.featureId
404
+ : currentRow?.type === 'task'
405
+ ? currentRow.task.featureId
406
+ : undefined;
407
+ adapter.createTask({
408
+ projectId,
409
+ featureId,
410
+ title: values.title ?? '',
411
+ summary: values.summary ?? '',
412
+ description: values.description || undefined,
413
+ priority: ((values.priority ?? 'MEDIUM') as Priority),
414
+ complexity: Number.parseInt(values.complexity ?? '3', 10) || 3,
415
+ }).then((result) => {
416
+ if (!result.success) setLocalError(result.error);
417
+ refresh();
418
+ setMode('idle');
419
+ });
420
+ }}
421
+ />
422
+ ) : null}
423
+
424
+ {mode === 'edit-task' ? (
425
+ (() => {
426
+ const currentRow = rows[selectedIndex];
427
+ if (!currentRow || currentRow.type !== 'task') return null;
428
+ const task = currentRow.task;
429
+ return (
430
+ <FormDialog
431
+ title="Edit Task"
432
+ fields={[
433
+ { key: 'title', label: 'Title', required: true, value: task.title },
434
+ { key: 'summary', label: 'Summary', required: true, value: task.summary },
435
+ { key: 'description', label: 'Description', value: task.description ?? '' },
436
+ { key: 'priority', label: 'Priority (HIGH/MEDIUM/LOW)', required: true, value: task.priority },
437
+ { key: 'complexity', label: 'Complexity (1-10)', required: true, value: String(task.complexity) },
438
+ ]}
439
+ onCancel={() => setMode('idle')}
440
+ onSubmit={(values) => {
441
+ adapter.updateTask(task.id, {
442
+ title: values.title ?? '',
443
+ summary: values.summary ?? '',
444
+ description: values.description || undefined,
445
+ priority: ((values.priority ?? task.priority) as Priority),
446
+ complexity: Number.parseInt(values.complexity ?? String(task.complexity), 10) || task.complexity,
447
+ version: task.version,
448
+ }).then((result) => {
449
+ if (!result.success) setLocalError(result.error);
450
+ refresh();
451
+ setMode('idle');
452
+ });
453
+ }}
454
+ />
455
+ );
456
+ })()
457
+ ) : null}
458
+
459
+ {mode === 'delete-task' ? (
460
+ (() => {
461
+ const currentRow = rows[selectedIndex];
462
+ if (!currentRow || currentRow.type !== 'task') return null;
463
+ const task = currentRow.task;
464
+ return (
465
+ <ConfirmDialog
466
+ title="Delete Task"
467
+ message={`Delete "${task.title}"?`}
468
+ onCancel={() => setMode('idle')}
469
+ onConfirm={() => {
470
+ adapter.deleteTask(task.id).then((result) => {
471
+ if (!result.success) setLocalError(result.error);
472
+ refresh();
473
+ setMode('idle');
474
+ });
475
+ }}
476
+ />
477
+ );
478
+ })()
479
+ ) : null}
480
+
481
+ {mode === 'feature-status' ? (
482
+ <Box flexDirection="column" borderStyle="round" borderColor={theme.colors.highlight} paddingX={1} marginTop={1}>
483
+ <Text bold>Set Feature Status</Text>
484
+ {featureTransitions.length === 0 ? (
485
+ <Text dimColor>No transitions available</Text>
486
+ ) : (
487
+ featureTransitions.map((status, idx) => (
488
+ <Text key={status} inverse={idx === featureTransitionIndex}>
489
+ {idx === featureTransitionIndex ? '>' : ' '} {status}
490
+ </Text>
491
+ ))
492
+ )}
493
+ <Text dimColor>Enter apply • Esc cancel</Text>
494
+ </Box>
495
+ ) : null}
496
+ </Box>
497
+ );
498
+ }