@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,120 @@
1
+ import React from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { useTheme } from '../../ui/context/theme-context';
4
+
5
+ export interface Column<T> {
6
+ key: keyof T | string;
7
+ label: string;
8
+ width?: number;
9
+ render?: (value: unknown, row: T, context?: { isSelected: boolean }) => React.ReactNode;
10
+ }
11
+
12
+ export interface EntityTableProps<T> {
13
+ columns: Column<T>[];
14
+ data: T[];
15
+ selectedIndex: number;
16
+ onSelectedIndexChange: (index: number) => void;
17
+ onSelect?: (row: T) => void;
18
+ onBack?: () => void;
19
+ isActive?: boolean;
20
+ getRowKey?: (row: T, index: number) => string;
21
+ }
22
+
23
+ export function EntityTable<T>({
24
+ columns,
25
+ data,
26
+ selectedIndex,
27
+ onSelectedIndexChange,
28
+ onSelect,
29
+ onBack,
30
+ isActive = true,
31
+ getRowKey,
32
+ }: EntityTableProps<T>) {
33
+ const { theme } = useTheme();
34
+
35
+ useInput((input, key) => {
36
+ if (!isActive || data.length === 0) return;
37
+
38
+ if (input === 'j' || key.downArrow) {
39
+ const nextIndex = (selectedIndex + 1) % data.length;
40
+ onSelectedIndexChange(nextIndex);
41
+ } else if (input === 'k' || key.upArrow) {
42
+ const prevIndex = (selectedIndex - 1 + data.length) % data.length;
43
+ onSelectedIndexChange(prevIndex);
44
+ } else if ((key.return || input === 'l' || key.rightArrow) && onSelect) {
45
+ const row = data[selectedIndex];
46
+ if (row !== undefined) {
47
+ onSelect(row);
48
+ }
49
+ } else if (input === 'h' || key.leftArrow) {
50
+ onBack?.();
51
+ }
52
+ }, { isActive });
53
+
54
+ const renderCellValue = (column: Column<T>, row: T, isSelected: boolean): React.ReactNode => {
55
+ const value = row[column.key as keyof T];
56
+
57
+ if (column.render) {
58
+ return column.render(value, row, { isSelected });
59
+ }
60
+
61
+ if (value === null || value === undefined) {
62
+ return '';
63
+ }
64
+
65
+ return String(value);
66
+ };
67
+
68
+ const getDefaultRowKey = (row: T, index: number): string => {
69
+ // Try to use 'id' field if available
70
+ if (row && typeof row === 'object' && 'id' in row) {
71
+ return String(row.id);
72
+ }
73
+ return `row-${index}`;
74
+ };
75
+
76
+ const rowKeyFn = getRowKey || getDefaultRowKey;
77
+
78
+ return (
79
+ <Box flexDirection="column">
80
+ {/* Header Row */}
81
+ <Box>
82
+ {/* Gutter space for selection indicator */}
83
+ <Box width={2} marginRight={1}>
84
+ <Text> </Text>
85
+ </Box>
86
+ {columns.map((column) => (
87
+ <Box key={String(column.key)} width={column.width} marginRight={1}>
88
+ <Text bold dimColor>
89
+ {column.label}
90
+ </Text>
91
+ </Box>
92
+ ))}
93
+ </Box>
94
+
95
+ {/* Data Rows */}
96
+ {data.map((row, rowIndex) => {
97
+ const isSelected = rowIndex === selectedIndex;
98
+ const rowKey = rowKeyFn(row, rowIndex);
99
+
100
+ return (
101
+ <Box key={rowKey}>
102
+ {/* Selection gutter */}
103
+ <Box width={2} marginRight={1}>
104
+ <Text color={isSelected ? theme.colors.highlight : undefined}>
105
+ {isSelected ? '▎' : ' '}
106
+ </Text>
107
+ </Box>
108
+ {columns.map((column) => (
109
+ <Box key={`${rowKey}-${String(column.key)}`} width={column.width} marginRight={1}>
110
+ <Text bold={isSelected}>
111
+ {renderCellValue(column, row, isSelected)}
112
+ </Text>
113
+ </Box>
114
+ ))}
115
+ </Box>
116
+ );
117
+ })}
118
+ </Box>
119
+ );
120
+ }
@@ -0,0 +1,41 @@
1
+ import React, { useEffect } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { useTheme } from '../../ui/context/theme-context';
4
+
5
+ interface ErrorMessageProps {
6
+ message: string;
7
+ onDismiss?: () => void;
8
+ timeoutMs?: number;
9
+ isActive?: boolean;
10
+ }
11
+
12
+ export function ErrorMessage({
13
+ message,
14
+ onDismiss,
15
+ timeoutMs = 4000,
16
+ isActive = true,
17
+ }: ErrorMessageProps) {
18
+ const { theme } = useTheme();
19
+
20
+ useEffect(() => {
21
+ if (!onDismiss || timeoutMs <= 0) return;
22
+ const timer = setTimeout(onDismiss, timeoutMs);
23
+ return () => clearTimeout(timer);
24
+ }, [onDismiss, timeoutMs, message]);
25
+
26
+ useInput((input, key) => {
27
+ if (!isActive) return;
28
+ if (key.escape || input === 'x') {
29
+ onDismiss?.();
30
+ }
31
+ }, { isActive });
32
+
33
+ return (
34
+ <Box borderStyle="round" borderColor={theme.colors.danger} paddingX={1} marginY={1}>
35
+ <Text color={theme.colors.danger}>
36
+ ! {message}
37
+ </Text>
38
+ </Box>
39
+ );
40
+ }
41
+
@@ -0,0 +1,216 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import type { BoardFeature } from '../../ui/lib/types';
4
+ import type { Task } from 'task-orchestrator-bun/src/domain/types';
5
+ import { PriorityBadge } from './priority-badge';
6
+ import { StatusBadge } from './status-badge';
7
+ import { useTheme } from '../../ui/context/theme-context';
8
+
9
+ export interface FeatureKanbanCardProps {
10
+ feature: BoardFeature;
11
+ isSelected: boolean;
12
+ isExpanded: boolean;
13
+ selectedTaskIndex: number;
14
+ maxTaskHeight: number;
15
+ columnWidth: number;
16
+ }
17
+
18
+ /**
19
+ * Calculate the rendered line count for a feature card.
20
+ * Used by the column to compute variable-height scroll windows.
21
+ */
22
+ export function getFeatureCardHeight(
23
+ feature: BoardFeature,
24
+ isExpanded: boolean,
25
+ maxTaskHeight: number,
26
+ columnWidth: number
27
+ ): number {
28
+ // Content width inside the card border (border 2 + padding 2 = 4)
29
+ const contentWidth = columnWidth - 4;
30
+
31
+ // Collapsed: border-top + name lines + status line + border-bottom
32
+ const nameLines = Math.ceil(feature.name.length / contentWidth) || 1;
33
+ const collapsedHeight = 2 + nameLines + 1; // borders + name + status row
34
+
35
+ if (!isExpanded) return collapsedHeight;
36
+
37
+ // Expanded: collapsed chrome + separator + task rows
38
+ const tasks = feature.tasks;
39
+ if (tasks.length === 0) return collapsedHeight;
40
+
41
+ let taskLines = 1; // separator line
42
+ const visibleCount = Math.min(tasks.length, maxTaskHeight);
43
+ const hasAbove = false; // initially scroll starts at 0
44
+ const hasBelow = tasks.length > visibleCount;
45
+
46
+ if (hasAbove) taskLines += 1;
47
+ if (hasBelow) taskLines += 1;
48
+
49
+ for (let i = 0; i < visibleCount; i++) {
50
+ const task = tasks[i]!;
51
+ const taskTitleLines = Math.ceil(task.title.length / (contentWidth - 4)) || 1; // indent for tree chrome
52
+ taskLines += taskTitleLines + 1; // title lines + status line
53
+ }
54
+
55
+ // Account for gap={1} between task rows
56
+ if (visibleCount > 1) {
57
+ taskLines += visibleCount - 1;
58
+ }
59
+
60
+ return collapsedHeight + taskLines;
61
+ }
62
+
63
+ /**
64
+ * FeatureKanbanCard Component
65
+ *
66
+ * Collapsed:
67
+ * ┌──────────────────────────────────┐
68
+ * │ Feature Name Wraps If Needed │
69
+ * │ ● In Development ●●● 3/8 │
70
+ * └──────────────────────────────────┘
71
+ *
72
+ * Expanded (tasks visible, internal scroll):
73
+ * ┌──────────────────────────────────┐
74
+ * │ Feature Name │
75
+ * │ ● In Development ●●● 3/8 │
76
+ * │ ───────────────────────────── │
77
+ * │ ↑ 2 more │
78
+ * │ ▎├─ Selected task title wraps │
79
+ * │ ● Pending ●●○ │
80
+ * │ └─ Another task │
81
+ * │ ● Blocked ●○○ │
82
+ * │ ↓ 3 more │
83
+ * └──────────────────────────────────┘
84
+ */
85
+ export function FeatureKanbanCard({
86
+ feature,
87
+ isSelected,
88
+ isExpanded,
89
+ selectedTaskIndex,
90
+ maxTaskHeight,
91
+ columnWidth,
92
+ }: FeatureKanbanCardProps) {
93
+ const { theme } = useTheme();
94
+ const contentWidth = columnWidth - 4; // border + padding
95
+ const { total, completed } = feature.taskCounts;
96
+
97
+ // Task internal scroll
98
+ const tasks = feature.tasks;
99
+ const taskCount = tasks.length;
100
+
101
+ let windowStart = 0;
102
+ let windowEnd = Math.min(taskCount, maxTaskHeight);
103
+
104
+ if (isExpanded && taskCount > maxTaskHeight && selectedTaskIndex >= 0) {
105
+ const halfWindow = Math.floor(maxTaskHeight / 2);
106
+ windowStart = Math.max(0, selectedTaskIndex - halfWindow);
107
+ windowEnd = Math.min(taskCount, windowStart + maxTaskHeight);
108
+ if (windowEnd === taskCount) {
109
+ windowStart = Math.max(0, windowEnd - maxTaskHeight);
110
+ }
111
+ }
112
+
113
+ const visibleTasks = isExpanded ? tasks.slice(windowStart, windowEnd) : [];
114
+ const hasTasksAbove = windowStart > 0;
115
+ const hasTasksBelow = windowEnd < taskCount;
116
+
117
+ return (
118
+ <Box
119
+ borderStyle={isSelected ? 'double' : 'single'}
120
+ borderColor={isSelected ? theme.colors.accent : theme.colors.border}
121
+ flexDirection="column"
122
+ paddingX={1}
123
+ width={columnWidth}
124
+ >
125
+ {/* Feature name - full wrap, no truncation */}
126
+ <Text bold={isSelected} wrap="wrap">
127
+ {feature.name}
128
+ </Text>
129
+
130
+ {/* Status + priority + task count */}
131
+ <Box gap={1}>
132
+ <StatusBadge status={feature.status} />
133
+ <PriorityBadge priority={feature.priority} />
134
+ <Text dimColor>{completed}/{total}</Text>
135
+ </Box>
136
+
137
+ {/* Expanded: show tasks */}
138
+ {isExpanded && taskCount > 0 && (
139
+ <Box flexDirection="column">
140
+ {/* Separator */}
141
+ <Text dimColor>{'─'.repeat(Math.max(1, contentWidth - 2))}</Text>
142
+
143
+ {/* Scroll-up indicator */}
144
+ {hasTasksAbove && (
145
+ <Text dimColor> ↑ {windowStart} more</Text>
146
+ )}
147
+
148
+ {/* Visible tasks */}
149
+ <Box flexDirection="column" gap={1}>
150
+ {visibleTasks.map((task, index) => {
151
+ const actualIndex = windowStart + index;
152
+ const isTaskSelected = actualIndex === selectedTaskIndex;
153
+ const isLast = actualIndex === taskCount - 1;
154
+ const treeChar = isLast ? '└─' : '├─';
155
+
156
+ return (
157
+ <TaskRow
158
+ key={task.id}
159
+ task={task}
160
+ isSelected={isTaskSelected}
161
+ treeChar={treeChar}
162
+ accentColor={theme.colors.accent}
163
+ mutedColor={theme.colors.muted}
164
+ />
165
+ );
166
+ })}
167
+ </Box>
168
+
169
+ {/* Scroll-down indicator */}
170
+ {hasTasksBelow && (
171
+ <Text dimColor> ↓ {taskCount - windowEnd} more</Text>
172
+ )}
173
+ </Box>
174
+ )}
175
+
176
+ {/* Expanded with no tasks */}
177
+ {isExpanded && taskCount === 0 && (
178
+ <Box flexDirection="column">
179
+ <Text dimColor>{'─'.repeat(Math.max(1, contentWidth - 2))}</Text>
180
+ <Text dimColor> No tasks</Text>
181
+ </Box>
182
+ )}
183
+ </Box>
184
+ );
185
+ }
186
+
187
+ function TaskRow({
188
+ task,
189
+ isSelected,
190
+ treeChar,
191
+ accentColor,
192
+ mutedColor,
193
+ }: {
194
+ task: Task;
195
+ isSelected: boolean;
196
+ treeChar: string;
197
+ accentColor: string;
198
+ mutedColor: string;
199
+ }) {
200
+ return (
201
+ <Box flexDirection="column">
202
+ <Box>
203
+ {isSelected && <Text color={accentColor}>▎</Text>}
204
+ <Text color={mutedColor}>{treeChar} </Text>
205
+ <Text bold={isSelected} wrap="wrap">
206
+ {task.title}
207
+ </Text>
208
+ </Box>
209
+ <Box paddingLeft={isSelected ? 4 : 3}>
210
+ <StatusBadge status={task.status} />
211
+ <Text> </Text>
212
+ <PriorityBadge priority={task.priority} />
213
+ </Box>
214
+ </Box>
215
+ );
216
+ }
@@ -0,0 +1,34 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { useTheme } from '../../ui/context/theme-context';
4
+
5
+ export interface FooterProps {
6
+ shortcuts: Array<{ key: string; label: string }>;
7
+ }
8
+
9
+ export const Footer: React.FC<FooterProps> = ({ shortcuts }) => {
10
+ const { theme } = useTheme();
11
+
12
+ return (
13
+ <Box flexDirection="column">
14
+ <Box
15
+ borderStyle="single"
16
+ borderTop
17
+ borderBottom={false}
18
+ borderLeft={false}
19
+ borderRight={false}
20
+ >
21
+ <Text> </Text>
22
+ </Box>
23
+ <Box paddingX={1} flexWrap="wrap">
24
+ {shortcuts.map((shortcut, i) => (
25
+ <Box key={`${shortcut.key}-${shortcut.label}`}>
26
+ {i > 0 ? <Text dimColor> · </Text> : null}
27
+ <Text color={theme.colors.accent} bold>{shortcut.key}</Text>
28
+ <Text> {shortcut.label}</Text>
29
+ </Box>
30
+ ))}
31
+ </Box>
32
+ </Box>
33
+ );
34
+ };