@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,335 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import type { FeatureBoardColumn } from '../../ui/lib/types';
4
+ import { KanbanColumn } from './kanban-column';
5
+ import { ColumnFilterBar } from './column-filter-bar';
6
+ import { FEATURE_KANBAN_STATUSES } from '../../ui/hooks/use-feature-kanban';
7
+ import { useTheme } from '../../ui/context/theme-context';
8
+
9
+ const MAX_VISIBLE_COLUMNS = 3;
10
+
11
+ interface KanbanBoardProps {
12
+ columns: FeatureBoardColumn[];
13
+ activeColumnIndex: number;
14
+ selectedFeatureIndex: number;
15
+ expandedFeatureId: string | null;
16
+ selectedTaskIndex: number;
17
+ onColumnChange: (index: number) => void;
18
+ onFeatureChange: (index: number) => void;
19
+ onExpandFeature: (featureId: string | null) => void;
20
+ onTaskChange: (index: number) => void;
21
+ onSelectTask: (taskId: string) => void;
22
+ onMoveFeature?: (featureId: string, newStatus: string) => void;
23
+ isActive?: boolean;
24
+ availableHeight?: number;
25
+ availableWidth?: number;
26
+ // Filter props
27
+ activeStatuses: Set<string>;
28
+ isFilterMode: boolean;
29
+ filterCursorIndex: number;
30
+ onToggleStatus: (status: string) => void;
31
+ onFilterCursorChange: (index: number) => void;
32
+ onFilterModeChange: (isFilterMode: boolean) => void;
33
+ }
34
+
35
+ export function KanbanBoard({
36
+ columns,
37
+ activeColumnIndex,
38
+ selectedFeatureIndex,
39
+ expandedFeatureId,
40
+ selectedTaskIndex,
41
+ onColumnChange,
42
+ onFeatureChange,
43
+ onExpandFeature,
44
+ onTaskChange,
45
+ onSelectTask,
46
+ onMoveFeature,
47
+ isActive = true,
48
+ availableHeight,
49
+ availableWidth,
50
+ activeStatuses,
51
+ isFilterMode,
52
+ filterCursorIndex,
53
+ onToggleStatus,
54
+ onFilterCursorChange,
55
+ onFilterModeChange,
56
+ }: KanbanBoardProps) {
57
+ const [isMoveMode, setIsMoveMode] = useState(false);
58
+ const { theme } = useTheme();
59
+
60
+ const isTaskMode = expandedFeatureId !== null;
61
+
62
+ // Compute viewport window (3 columns max)
63
+ const totalCols = columns.length;
64
+ const visibleCount = Math.min(MAX_VISIBLE_COLUMNS, totalCols);
65
+
66
+ let viewportStart = 0;
67
+ if (totalCols > MAX_VISIBLE_COLUMNS) {
68
+ // Center activeColumnIndex in viewport when possible
69
+ viewportStart = Math.max(0, activeColumnIndex - Math.floor(visibleCount / 2));
70
+ viewportStart = Math.min(viewportStart, totalCols - visibleCount);
71
+ }
72
+ const viewportEnd = viewportStart + visibleCount;
73
+
74
+ const hiddenLeft = viewportStart;
75
+ const hiddenRight = totalCols - viewportEnd;
76
+
77
+ // Dynamic column width
78
+ const termWidth = availableWidth ?? 120;
79
+ const columnWidth = Math.floor((termWidth - 4) / Math.min(MAX_VISIBLE_COLUMNS, Math.max(1, totalCols)));
80
+
81
+ useInput(
82
+ (input, key) => {
83
+ if (!isActive || columns.length === 0) return;
84
+
85
+ // Filter mode handling
86
+ if (isFilterMode) {
87
+ if (key.escape) {
88
+ onFilterModeChange(false);
89
+ return;
90
+ }
91
+ if (input === 'h' || key.leftArrow) {
92
+ const newIdx = (filterCursorIndex - 1 + FEATURE_KANBAN_STATUSES.length) % FEATURE_KANBAN_STATUSES.length;
93
+ onFilterCursorChange(newIdx);
94
+ return;
95
+ }
96
+ if (input === 'l' || key.rightArrow) {
97
+ const newIdx = (filterCursorIndex + 1) % FEATURE_KANBAN_STATUSES.length;
98
+ onFilterCursorChange(newIdx);
99
+ return;
100
+ }
101
+ if (input === ' ') {
102
+ const status = FEATURE_KANBAN_STATUSES[filterCursorIndex];
103
+ if (status) {
104
+ onToggleStatus(status.status);
105
+ }
106
+ return;
107
+ }
108
+ return; // Absorb all other keys while in filter mode
109
+ }
110
+
111
+ const activeColumn = columns[activeColumnIndex];
112
+
113
+ // Move mode handling
114
+ if (isMoveMode) {
115
+ const activeFeature = activeColumn?.features[selectedFeatureIndex];
116
+
117
+ if (key.escape) {
118
+ setIsMoveMode(false);
119
+ return;
120
+ }
121
+
122
+ if (!onMoveFeature || !activeFeature) {
123
+ setIsMoveMode(false);
124
+ return;
125
+ }
126
+
127
+ // Move feature left
128
+ if (input === 'h' || key.leftArrow) {
129
+ if (activeColumnIndex > 0) {
130
+ const targetColumn = columns[activeColumnIndex - 1];
131
+ if (targetColumn) {
132
+ onMoveFeature(activeFeature.id, targetColumn.status);
133
+ setIsMoveMode(false);
134
+ }
135
+ }
136
+ return;
137
+ }
138
+
139
+ // Move feature right
140
+ if (input === 'l' || key.rightArrow) {
141
+ if (activeColumnIndex < columns.length - 1) {
142
+ const targetColumn = columns[activeColumnIndex + 1];
143
+ if (targetColumn) {
144
+ onMoveFeature(activeFeature.id, targetColumn.status);
145
+ setIsMoveMode(false);
146
+ }
147
+ }
148
+ return;
149
+ }
150
+
151
+ return;
152
+ }
153
+
154
+ // ---- Task mode (expanded feature) ----
155
+ if (isTaskMode) {
156
+ const expandedFeature = activeColumn?.features.find(
157
+ (f) => f.id === expandedFeatureId
158
+ );
159
+ if (!expandedFeature) return;
160
+
161
+ const taskCount = expandedFeature.tasks.length;
162
+
163
+ // Collapse back to feature mode
164
+ if (key.escape || (input === 'h' && !key.leftArrow)) {
165
+ onExpandFeature(null);
166
+ onTaskChange(-1);
167
+ return;
168
+ }
169
+
170
+ // Navigate tasks
171
+ if (input === 'j' || key.downArrow) {
172
+ if (taskCount > 0) {
173
+ const newIndex = selectedTaskIndex >= taskCount - 1 ? 0 : selectedTaskIndex + 1;
174
+ onTaskChange(newIndex);
175
+ }
176
+ return;
177
+ }
178
+
179
+ if (input === 'k' || key.upArrow) {
180
+ if (taskCount > 0) {
181
+ const newIndex = selectedTaskIndex <= 0 ? taskCount - 1 : selectedTaskIndex - 1;
182
+ onTaskChange(newIndex);
183
+ }
184
+ return;
185
+ }
186
+
187
+ // Select task → open TaskDetail
188
+ if (key.return) {
189
+ const task = expandedFeature.tasks[selectedTaskIndex];
190
+ if (task) {
191
+ onSelectTask(task.id);
192
+ }
193
+ return;
194
+ }
195
+
196
+ return;
197
+ }
198
+
199
+ // ---- Feature mode (default) ----
200
+ if (!activeColumn) return;
201
+
202
+ // Enter filter mode
203
+ if (input === 'f') {
204
+ onFilterModeChange(true);
205
+ return;
206
+ }
207
+
208
+ // Enter move mode
209
+ if (input === 'm') {
210
+ if (activeColumn.features.length > 0 && selectedFeatureIndex >= 0) {
211
+ setIsMoveMode(true);
212
+ }
213
+ return;
214
+ }
215
+
216
+ // Column navigation
217
+ if (input === 'h' || key.leftArrow) {
218
+ const newIndex = (activeColumnIndex - 1 + columns.length) % columns.length;
219
+ onColumnChange(newIndex);
220
+ const newColumn = columns[newIndex];
221
+ if (newColumn && newColumn.features.length > 0) {
222
+ onFeatureChange(0);
223
+ } else {
224
+ onFeatureChange(-1);
225
+ }
226
+ return;
227
+ }
228
+
229
+ if (input === 'l' || key.rightArrow) {
230
+ const newIndex = (activeColumnIndex + 1) % columns.length;
231
+ onColumnChange(newIndex);
232
+ const newColumn = columns[newIndex];
233
+ if (newColumn && newColumn.features.length > 0) {
234
+ onFeatureChange(0);
235
+ } else {
236
+ onFeatureChange(-1);
237
+ }
238
+ return;
239
+ }
240
+
241
+ // Feature navigation within column
242
+ if (activeColumn.features.length === 0) return;
243
+
244
+ if (input === 'j' || key.downArrow) {
245
+ const maxIndex = activeColumn.features.length - 1;
246
+ const newIndex = selectedFeatureIndex >= maxIndex ? 0 : selectedFeatureIndex + 1;
247
+ onFeatureChange(newIndex);
248
+ return;
249
+ }
250
+
251
+ if (input === 'k' || key.upArrow) {
252
+ const maxIndex = activeColumn.features.length - 1;
253
+ const newIndex = selectedFeatureIndex <= 0 ? maxIndex : selectedFeatureIndex - 1;
254
+ onFeatureChange(newIndex);
255
+ return;
256
+ }
257
+
258
+ // Expand feature (Enter)
259
+ if (key.return) {
260
+ const feature = activeColumn.features[selectedFeatureIndex];
261
+ if (feature) {
262
+ onExpandFeature(feature.id);
263
+ onTaskChange(feature.tasks.length > 0 ? 0 : -1);
264
+ }
265
+ return;
266
+ }
267
+ },
268
+ { isActive }
269
+ );
270
+
271
+ const visibleColumns = columns.slice(viewportStart, viewportEnd);
272
+
273
+ return (
274
+ <Box flexDirection="column">
275
+ {/* Filter chip bar */}
276
+ {isFilterMode && (
277
+ <ColumnFilterBar
278
+ allStatuses={FEATURE_KANBAN_STATUSES}
279
+ activeStatuses={activeStatuses}
280
+ isFilterMode={isFilterMode}
281
+ filterCursorIndex={filterCursorIndex}
282
+ />
283
+ )}
284
+
285
+ {/* Scroll indicators + Columns */}
286
+ <Box flexDirection="row" alignItems="center">
287
+ {/* Left scroll indicator */}
288
+ {hiddenLeft > 0 ? (
289
+ <Box width={3} justifyContent="center">
290
+ <Text color={theme.colors.muted}>{'← '}{hiddenLeft}</Text>
291
+ </Box>
292
+ ) : totalCols > MAX_VISIBLE_COLUMNS ? (
293
+ <Box width={3} />
294
+ ) : null}
295
+
296
+ {/* Visible columns */}
297
+ {visibleColumns.map((column, index) => {
298
+ const actualIndex = viewportStart + index;
299
+ const isColumnActive = isActive && actualIndex === activeColumnIndex;
300
+
301
+ return (
302
+ <KanbanColumn
303
+ key={column.id}
304
+ column={column}
305
+ isActiveColumn={isColumnActive}
306
+ selectedFeatureIndex={isColumnActive ? selectedFeatureIndex : -1}
307
+ expandedFeatureId={isColumnActive ? expandedFeatureId : null}
308
+ selectedTaskIndex={isColumnActive ? selectedTaskIndex : -1}
309
+ availableHeight={availableHeight}
310
+ columnWidth={columnWidth}
311
+ />
312
+ );
313
+ })}
314
+
315
+ {/* Right scroll indicator */}
316
+ {hiddenRight > 0 ? (
317
+ <Box width={3} justifyContent="center">
318
+ <Text color={theme.colors.muted}>{hiddenRight}{' →'}</Text>
319
+ </Box>
320
+ ) : totalCols > MAX_VISIBLE_COLUMNS ? (
321
+ <Box width={3} />
322
+ ) : null}
323
+ </Box>
324
+
325
+ {/* Move mode indicator */}
326
+ {isMoveMode && (
327
+ <Box marginTop={1} borderStyle="round" borderColor="yellow" paddingX={1}>
328
+ <Text color="yellow" bold>
329
+ MOVE: ←/→ to move, Esc cancel
330
+ </Text>
331
+ </Box>
332
+ )}
333
+ </Box>
334
+ );
335
+ }
@@ -0,0 +1,70 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import type { BoardTask } from '../../ui/lib/types';
4
+ import { PriorityBadge } from './priority-badge';
5
+
6
+ export interface KanbanCardProps {
7
+ task: BoardTask;
8
+ isSelected: boolean;
9
+ compact?: boolean;
10
+ }
11
+
12
+ /**
13
+ * KanbanCard Component
14
+ *
15
+ * Displays a single task as a card in the Kanban board.
16
+ *
17
+ * Normal mode:
18
+ * ┌──────────────────────┐
19
+ * │ Task title here... │
20
+ * │ ●●○ HIGH #tag1 │
21
+ * └──────────────────────┘
22
+ *
23
+ * Compact mode:
24
+ * │ Task title... ●●○ │
25
+ */
26
+ export function KanbanCard({ task, isSelected, compact = false }: KanbanCardProps) {
27
+ const truncateText = (text: string, maxLength: number): string => {
28
+ if (text.length <= maxLength) return text;
29
+ return text.slice(0, maxLength - 3) + '...';
30
+ };
31
+
32
+ const featureLabel = task.featureName ?? '—';
33
+
34
+ if (compact) {
35
+ // Compact mode: single line with title and priority dots
36
+ const title = truncateText(task.title, 30);
37
+ return (
38
+ <Box paddingX={1}>
39
+ <Text bold={isSelected}>
40
+ {title} <PriorityBadge priority={task.priority} />
41
+ </Text>
42
+ </Box>
43
+ );
44
+ }
45
+
46
+ // Normal mode: bordered card with title, priority, and tag
47
+ // Card fills column width; content area = column content (26) - card borders (2) - card padding (2) = 22
48
+ const title = truncateText(task.title, 22);
49
+
50
+ return (
51
+ <Box
52
+ borderStyle="single"
53
+ paddingX={1}
54
+ flexDirection="column"
55
+ >
56
+ <Text bold={isSelected}>
57
+ {title}
58
+ </Text>
59
+ <Box gap={1}>
60
+ <PriorityBadge priority={task.priority} />
61
+ <Text bold={isSelected} dimColor>
62
+ {task.priority}
63
+ </Text>
64
+ <Text bold={isSelected} dimColor>
65
+ [{featureLabel}]
66
+ </Text>
67
+ </Box>
68
+ </Box>
69
+ );
70
+ }
@@ -0,0 +1,173 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import type { FeatureBoardColumn } from '../../ui/lib/types';
4
+ import { FeatureKanbanCard, getFeatureCardHeight } from './feature-kanban-card';
5
+ import { getStatusColor } from '../../ui/lib/colors';
6
+ import { useTheme } from '../../ui/context/theme-context';
7
+
8
+ interface KanbanColumnProps {
9
+ column: FeatureBoardColumn;
10
+ isActiveColumn: boolean;
11
+ selectedFeatureIndex: number;
12
+ expandedFeatureId: string | null;
13
+ selectedTaskIndex: number;
14
+ availableHeight?: number;
15
+ columnWidth?: number;
16
+ }
17
+
18
+ /**
19
+ * KanbanColumn Component
20
+ *
21
+ * Displays a single column in the feature-based Kanban board.
22
+ * Uses variable-height scrolling based on actual card line counts.
23
+ */
24
+ export function KanbanColumn({
25
+ column,
26
+ isActiveColumn,
27
+ selectedFeatureIndex,
28
+ expandedFeatureId,
29
+ selectedTaskIndex,
30
+ availableHeight,
31
+ columnWidth: columnWidthProp,
32
+ }: KanbanColumnProps) {
33
+ const { theme } = useTheme();
34
+ const statusColor = getStatusColor(column.status, theme);
35
+ const featureCount = column.features.length;
36
+ const effectiveHeight = availableHeight ?? 30;
37
+ const COLUMN_WIDTH = columnWidthProp ?? 40;
38
+
39
+ // Chrome overhead: column border (2) + padding (2) + header (2) + gap (1) = ~7
40
+ const columnChromeLines = 7;
41
+ const maxContentLines = effectiveHeight - columnChromeLines;
42
+ const maxTaskHeight = Math.max(2, Math.floor((effectiveHeight - columnChromeLines) / 3));
43
+
44
+ // Compute heights for all cards
45
+ const cardHeights = column.features.map((feature) =>
46
+ getFeatureCardHeight(
47
+ feature,
48
+ feature.id === expandedFeatureId,
49
+ maxTaskHeight,
50
+ COLUMN_WIDTH - 4 // inner content width (column border + padding)
51
+ ) + 1 // +1 for gap between cards
52
+ );
53
+
54
+ // Variable-height sliding window
55
+ let windowStart = 0;
56
+ let windowEnd = featureCount;
57
+
58
+ if (featureCount > 0 && selectedFeatureIndex >= 0) {
59
+ // Try to fit as many cards as possible around the selected one
60
+ // First, find a window that includes the selected feature
61
+ let totalLines = 0;
62
+
63
+ // Start from selected and expand outward
64
+ windowStart = selectedFeatureIndex;
65
+ windowEnd = selectedFeatureIndex + 1;
66
+ totalLines = cardHeights[selectedFeatureIndex] ?? 0;
67
+
68
+ // Expand upward and downward alternately
69
+ let expandUp = true;
70
+ while (true) {
71
+ if (expandUp && windowStart > 0) {
72
+ const nextHeight = cardHeights[windowStart - 1] ?? 0;
73
+ if (totalLines + nextHeight <= maxContentLines) {
74
+ windowStart--;
75
+ totalLines += nextHeight;
76
+ expandUp = false;
77
+ continue;
78
+ }
79
+ }
80
+ if (!expandUp && windowEnd < featureCount) {
81
+ const nextHeight = cardHeights[windowEnd] ?? 0;
82
+ if (totalLines + nextHeight <= maxContentLines) {
83
+ windowEnd++;
84
+ totalLines += nextHeight;
85
+ expandUp = true;
86
+ continue;
87
+ }
88
+ }
89
+ // Try the other direction if current didn't work
90
+ if (expandUp && windowEnd < featureCount) {
91
+ const nextHeight = cardHeights[windowEnd] ?? 0;
92
+ if (totalLines + nextHeight <= maxContentLines) {
93
+ windowEnd++;
94
+ totalLines += nextHeight;
95
+ continue;
96
+ }
97
+ }
98
+ if (!expandUp && windowStart > 0) {
99
+ const nextHeight = cardHeights[windowStart - 1] ?? 0;
100
+ if (totalLines + nextHeight <= maxContentLines) {
101
+ windowStart--;
102
+ totalLines += nextHeight;
103
+ continue;
104
+ }
105
+ }
106
+ break;
107
+ }
108
+ }
109
+
110
+ const visibleFeatures = column.features.slice(windowStart, windowEnd);
111
+ const hasFeaturesAbove = windowStart > 0;
112
+ const hasFeaturesBelow = windowEnd < featureCount;
113
+
114
+ return (
115
+ <Box
116
+ flexDirection="column"
117
+ borderStyle={isActiveColumn ? 'bold' : 'round'}
118
+ borderColor={isActiveColumn ? theme.colors.accent : theme.colors.border}
119
+ width={COLUMN_WIDTH}
120
+ height={effectiveHeight}
121
+ paddingX={1}
122
+ paddingY={1}
123
+ >
124
+ {/* Column header */}
125
+ <Box marginBottom={1}>
126
+ <Text color={statusColor} bold={isActiveColumn}>
127
+ {column.title} ({featureCount})
128
+ </Text>
129
+ </Box>
130
+
131
+ {/* Feature list */}
132
+ {featureCount === 0 ? (
133
+ <Box justifyContent="center" paddingY={2}>
134
+ <Text dimColor>No features</Text>
135
+ </Box>
136
+ ) : (
137
+ <Box flexDirection="column" gap={1}>
138
+ {/* Top scroll indicator */}
139
+ {hasFeaturesAbove && (
140
+ <Box justifyContent="center">
141
+ <Text dimColor>↑ {windowStart} more</Text>
142
+ </Box>
143
+ )}
144
+
145
+ {/* Visible features */}
146
+ {visibleFeatures.map((feature, index) => {
147
+ const actualIndex = windowStart + index;
148
+ const isFeatureSelected = isActiveColumn && actualIndex === selectedFeatureIndex;
149
+
150
+ return (
151
+ <FeatureKanbanCard
152
+ key={feature.id}
153
+ feature={feature}
154
+ isSelected={isFeatureSelected}
155
+ isExpanded={feature.id === expandedFeatureId}
156
+ selectedTaskIndex={isFeatureSelected ? selectedTaskIndex : -1}
157
+ maxTaskHeight={maxTaskHeight}
158
+ columnWidth={COLUMN_WIDTH - 4}
159
+ />
160
+ );
161
+ })}
162
+
163
+ {/* Bottom scroll indicator */}
164
+ {hasFeaturesBelow && (
165
+ <Box justifyContent="center">
166
+ <Text dimColor>↓ {featureCount - windowEnd} more</Text>
167
+ </Box>
168
+ )}
169
+ </Box>
170
+ )}
171
+ </Box>
172
+ );
173
+ }
@@ -0,0 +1,16 @@
1
+ import React from 'react';
2
+ import { Text } from 'ink';
3
+ import { getPriorityColor, getPriorityDots } from '../../ui/lib/colors';
4
+ import { useTheme } from '../../ui/context/theme-context';
5
+
6
+ interface PriorityBadgeProps {
7
+ priority: string;
8
+ isSelected?: boolean;
9
+ }
10
+
11
+ export function PriorityBadge({ priority }: PriorityBadgeProps) {
12
+ const { theme } = useTheme();
13
+ const color = getPriorityColor(priority as any, theme);
14
+ const dots = getPriorityDots(priority as any);
15
+ return <Text color={color}>{dots}</Text>;
16
+ }
@@ -0,0 +1,96 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import type { Section } from 'task-orchestrator-bun/src/domain/types';
4
+ import { useTheme } from '../../ui/context/theme-context';
5
+
6
+ export interface SectionListProps {
7
+ sections: Section[];
8
+ selectedIndex: number;
9
+ onSelectedIndexChange: (index: number) => void;
10
+ isActive?: boolean;
11
+ }
12
+
13
+ export function SectionList({
14
+ sections,
15
+ selectedIndex,
16
+ onSelectedIndexChange,
17
+ isActive = true,
18
+ }: SectionListProps) {
19
+ const { theme } = useTheme();
20
+ const [expandedSections, setExpandedSections] = useState<Set<string>>(
21
+ new Set(sections.map(s => s.id))
22
+ );
23
+
24
+ useInput((input, key) => {
25
+ if (!isActive || sections.length === 0) return;
26
+
27
+ // Navigation: j/down or k/up
28
+ if (input === 'j' || key.downArrow) {
29
+ const nextIndex = (selectedIndex + 1) % sections.length;
30
+ onSelectedIndexChange(nextIndex);
31
+ } else if (input === 'k' || key.upArrow) {
32
+ const prevIndex = (selectedIndex - 1 + sections.length) % sections.length;
33
+ onSelectedIndexChange(prevIndex);
34
+ } else if ((key.return || input === ' ') && sections[selectedIndex]) {
35
+ // Toggle expansion: Enter or Space
36
+ const section = sections[selectedIndex];
37
+ setExpandedSections(prev => {
38
+ const next = new Set(prev);
39
+ if (next.has(section.id)) {
40
+ next.delete(section.id);
41
+ } else {
42
+ next.add(section.id);
43
+ }
44
+ return next;
45
+ });
46
+ }
47
+ });
48
+
49
+ if (sections.length === 0) {
50
+ return (
51
+ <Box>
52
+ <Text dimColor>No sections available</Text>
53
+ </Box>
54
+ );
55
+ }
56
+
57
+ return (
58
+ <Box flexDirection="column">
59
+ {sections.map((section, index) => {
60
+ const isSelected = index === selectedIndex;
61
+ const isExpanded = expandedSections.has(section.id);
62
+ const expandIcon = isExpanded ? '▼' : '▶';
63
+
64
+ return (
65
+ <Box key={section.id} flexDirection="column">
66
+ {/* Section Header */}
67
+ <Box>
68
+ <Text color={isSelected ? theme.colors.highlight : undefined}>
69
+ {isSelected ? '▎' : ' '}
70
+ </Text>
71
+ <Text bold={isSelected} dimColor={!isExpanded}>
72
+ {expandIcon} {section.title}
73
+ </Text>
74
+ </Box>
75
+
76
+ {/* Section Content (when expanded) */}
77
+ {isExpanded && (
78
+ <Box marginLeft={2} flexDirection="column">
79
+ {section.usageDescription && (
80
+ <Box marginBottom={1}>
81
+ <Text italic dimColor>
82
+ {section.usageDescription}
83
+ </Text>
84
+ </Box>
85
+ )}
86
+ <Box>
87
+ <Text>{section.content}</Text>
88
+ </Box>
89
+ </Box>
90
+ )}
91
+ </Box>
92
+ );
93
+ })}
94
+ </Box>
95
+ );
96
+ }