@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.
- package/README.md +78 -0
- package/package.json +54 -0
- package/src/tui/app.tsx +308 -0
- package/src/tui/components/column-filter-bar.tsx +52 -0
- package/src/tui/components/confirm-dialog.tsx +45 -0
- package/src/tui/components/dependency-list.tsx +115 -0
- package/src/tui/components/empty-state.tsx +28 -0
- package/src/tui/components/entity-table.tsx +120 -0
- package/src/tui/components/error-message.tsx +41 -0
- package/src/tui/components/feature-kanban-card.tsx +216 -0
- package/src/tui/components/footer.tsx +34 -0
- package/src/tui/components/form-dialog.tsx +338 -0
- package/src/tui/components/header.tsx +54 -0
- package/src/tui/components/index.ts +16 -0
- package/src/tui/components/kanban-board.tsx +335 -0
- package/src/tui/components/kanban-card.tsx +70 -0
- package/src/tui/components/kanban-column.tsx +173 -0
- package/src/tui/components/priority-badge.tsx +16 -0
- package/src/tui/components/section-list.tsx +96 -0
- package/src/tui/components/status-actions.tsx +87 -0
- package/src/tui/components/status-badge.tsx +22 -0
- package/src/tui/components/tree-view.tsx +295 -0
- package/src/tui/components/view-mode-chips.tsx +23 -0
- package/src/tui/index.tsx +33 -0
- package/src/tui/screens/dashboard.tsx +248 -0
- package/src/tui/screens/feature-detail.tsx +312 -0
- package/src/tui/screens/index.ts +6 -0
- package/src/tui/screens/kanban-view.tsx +251 -0
- package/src/tui/screens/project-detail.tsx +305 -0
- package/src/tui/screens/project-view.tsx +498 -0
- package/src/tui/screens/search.tsx +257 -0
- package/src/tui/screens/task-detail.tsx +294 -0
- package/src/ui/adapters/direct.ts +429 -0
- package/src/ui/adapters/index.ts +14 -0
- package/src/ui/adapters/types.ts +269 -0
- package/src/ui/context/adapter-context.tsx +31 -0
- package/src/ui/context/theme-context.tsx +43 -0
- package/src/ui/hooks/index.ts +20 -0
- package/src/ui/hooks/use-data.ts +919 -0
- package/src/ui/hooks/use-debounce.ts +37 -0
- package/src/ui/hooks/use-feature-kanban.ts +151 -0
- package/src/ui/hooks/use-kanban.ts +96 -0
- package/src/ui/hooks/use-navigation.tsx +94 -0
- package/src/ui/index.ts +73 -0
- package/src/ui/lib/colors.ts +79 -0
- package/src/ui/lib/format.ts +114 -0
- package/src/ui/lib/types.ts +157 -0
- package/src/ui/themes/dark.ts +63 -0
- package/src/ui/themes/light.ts +63 -0
- 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
|
+
}
|