@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,257 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
3
|
+
import { useSearch } from '../../ui/hooks/use-data';
|
|
4
|
+
import { useDebounce } from '../../ui/hooks/use-debounce';
|
|
5
|
+
import { EmptyState } from '../components/empty-state';
|
|
6
|
+
import { ErrorMessage } from '../components/error-message';
|
|
7
|
+
import { useTheme } from '../../ui/context/theme-context';
|
|
8
|
+
|
|
9
|
+
interface SearchScreenProps {
|
|
10
|
+
onOpenProject: (projectId: string) => void;
|
|
11
|
+
onOpenFeature: (featureId: string) => void;
|
|
12
|
+
onOpenTask: (taskId: string) => void;
|
|
13
|
+
onBack: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type SearchItem =
|
|
17
|
+
| { kind: 'project'; id: string; title: string; subtitle: string }
|
|
18
|
+
| { kind: 'feature'; id: string; title: string; subtitle: string }
|
|
19
|
+
| { kind: 'task'; id: string; title: string; subtitle: string };
|
|
20
|
+
|
|
21
|
+
function countWrappedLines(text: string, width: number): number {
|
|
22
|
+
const safeWidth = Math.max(1, width);
|
|
23
|
+
return text.split('\n').reduce((sum, line) => {
|
|
24
|
+
const len = Array.from(line).length;
|
|
25
|
+
return sum + Math.max(1, Math.ceil(len / safeWidth));
|
|
26
|
+
}, 0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function SearchScreen({
|
|
30
|
+
onOpenProject,
|
|
31
|
+
onOpenFeature,
|
|
32
|
+
onOpenTask,
|
|
33
|
+
onBack,
|
|
34
|
+
}: SearchScreenProps) {
|
|
35
|
+
const { stdout } = useStdout();
|
|
36
|
+
const { theme } = useTheme();
|
|
37
|
+
const [query, setQuery] = useState('');
|
|
38
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
39
|
+
const debouncedQuery = useDebounce(query, 300);
|
|
40
|
+
const { results, loading, error } = useSearch(debouncedQuery);
|
|
41
|
+
const [dismissedError, setDismissedError] = useState(false);
|
|
42
|
+
|
|
43
|
+
const items = useMemo<SearchItem[]>(() => {
|
|
44
|
+
if (!results) return [];
|
|
45
|
+
return [
|
|
46
|
+
...results.projects.map((p) => ({
|
|
47
|
+
kind: 'project' as const,
|
|
48
|
+
id: p.id,
|
|
49
|
+
title: p.name,
|
|
50
|
+
subtitle: p.summary,
|
|
51
|
+
})),
|
|
52
|
+
...results.features.map((f) => ({
|
|
53
|
+
kind: 'feature' as const,
|
|
54
|
+
id: f.id,
|
|
55
|
+
title: f.name,
|
|
56
|
+
subtitle: f.summary,
|
|
57
|
+
})),
|
|
58
|
+
...results.tasks.map((t) => ({
|
|
59
|
+
kind: 'task' as const,
|
|
60
|
+
id: t.id,
|
|
61
|
+
title: t.title,
|
|
62
|
+
subtitle: t.summary,
|
|
63
|
+
})),
|
|
64
|
+
];
|
|
65
|
+
}, [results]);
|
|
66
|
+
|
|
67
|
+
useInput((input, key) => {
|
|
68
|
+
if (key.escape || key.leftArrow) {
|
|
69
|
+
onBack();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (key.backspace || key.delete) {
|
|
74
|
+
setQuery((prev) => prev.slice(0, -1));
|
|
75
|
+
setSelectedIndex(0);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (key.downArrow) {
|
|
80
|
+
if (items.length > 0) setSelectedIndex((prev) => (prev + 1) % items.length);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (key.upArrow) {
|
|
85
|
+
if (items.length > 0) setSelectedIndex((prev) => (prev - 1 + items.length) % items.length);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if ((key.return || key.rightArrow) && items[selectedIndex]) {
|
|
90
|
+
const selected = items[selectedIndex];
|
|
91
|
+
if (!selected) return;
|
|
92
|
+
if (selected.kind === 'project') onOpenProject(selected.id);
|
|
93
|
+
if (selected.kind === 'feature') onOpenFeature(selected.id);
|
|
94
|
+
if (selected.kind === 'task') onOpenTask(selected.id);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (input && input.length === 1 && !key.ctrl && !key.meta) {
|
|
99
|
+
setQuery((prev) => `${prev}${input}`);
|
|
100
|
+
setSelectedIndex(0);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const clampedIndex = Math.min(selectedIndex, Math.max(0, items.length - 1));
|
|
105
|
+
const terminalRows = stdout?.rows ?? 24;
|
|
106
|
+
const terminalCols = stdout?.columns ?? 80;
|
|
107
|
+
const contentWidth = Math.max(20, terminalCols - 4);
|
|
108
|
+
const markerWidth = 2;
|
|
109
|
+
const cardWidth = Math.max(16, contentWidth - markerWidth);
|
|
110
|
+
const cardInnerWidth = Math.max(8, cardWidth - 4); // border + paddingX
|
|
111
|
+
const isQueryEmpty = debouncedQuery.trim().length === 0;
|
|
112
|
+
const hasNoResults = !loading && !isQueryEmpty && items.length === 0;
|
|
113
|
+
const shouldShowResults = !isQueryEmpty && items.length > 0;
|
|
114
|
+
|
|
115
|
+
const queryLabel = `Query: ${query || ' '}`;
|
|
116
|
+
const hintLabel = 'Type to search • ↑/↓ move • Enter open • Esc/← back';
|
|
117
|
+
|
|
118
|
+
// Sticky top area rows with dynamic wrapping.
|
|
119
|
+
const titleRows = 1;
|
|
120
|
+
const queryRows = countWrappedLines(queryLabel, contentWidth);
|
|
121
|
+
const hintRows = countWrappedLines(hintLabel, contentWidth);
|
|
122
|
+
const loadingRows = loading ? 1 : 0;
|
|
123
|
+
const errorRows = !dismissedError && error
|
|
124
|
+
? (
|
|
125
|
+
2 + // border top + bottom
|
|
126
|
+
2 + // marginY = 1 => top + bottom
|
|
127
|
+
countWrappedLines(`! ${error}`, Math.max(8, contentWidth - 4)) // border + paddingX reduce width
|
|
128
|
+
)
|
|
129
|
+
: 0;
|
|
130
|
+
const chromeRows = titleRows + queryRows + hintRows + loadingRows + errorRows + 1;
|
|
131
|
+
|
|
132
|
+
// Reserve lines for top/bottom "more" indicators and extra layout slack.
|
|
133
|
+
// Ink + terminal row accounting can differ slightly with borders/wrapping, so keep a cushion.
|
|
134
|
+
const layoutSlackRows = 3;
|
|
135
|
+
const maxResultLines = Math.max(3, terminalRows - chromeRows - 2 - layoutSlackRows);
|
|
136
|
+
|
|
137
|
+
const itemHeights = (shouldShowResults ? items : []).map((item) => {
|
|
138
|
+
const kindLabel = item.kind.toUpperCase();
|
|
139
|
+
const title = item.title;
|
|
140
|
+
const subtitle = item.subtitle ?? '';
|
|
141
|
+
// Card chrome: top/bottom border + type + title + subtitle
|
|
142
|
+
return (
|
|
143
|
+
2 +
|
|
144
|
+
countWrappedLines(kindLabel, cardInnerWidth) +
|
|
145
|
+
countWrappedLines(title, cardInnerWidth) +
|
|
146
|
+
countWrappedLines(subtitle, cardInnerWidth)
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
let windowStart = 0;
|
|
151
|
+
let windowEnd = 0;
|
|
152
|
+
if (shouldShowResults) {
|
|
153
|
+
windowStart = clampedIndex;
|
|
154
|
+
let used = itemHeights[clampedIndex] ?? 1;
|
|
155
|
+
|
|
156
|
+
// Include as many entries above selection as possible.
|
|
157
|
+
while (windowStart > 0) {
|
|
158
|
+
const prevHeight = itemHeights[windowStart - 1] ?? 1;
|
|
159
|
+
if (used + prevHeight > maxResultLines) break;
|
|
160
|
+
windowStart -= 1;
|
|
161
|
+
used += prevHeight;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Fill downward from windowStart.
|
|
165
|
+
windowEnd = windowStart;
|
|
166
|
+
used = 0;
|
|
167
|
+
while (windowEnd < items.length) {
|
|
168
|
+
const nextHeight = itemHeights[windowEnd] ?? 1;
|
|
169
|
+
if (used + nextHeight > maxResultLines && windowEnd > windowStart) break;
|
|
170
|
+
if (used + nextHeight > maxResultLines && windowEnd === windowStart) {
|
|
171
|
+
// Keep at least one item visible even in very short terminals.
|
|
172
|
+
windowEnd += 1;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
used += nextHeight;
|
|
176
|
+
windowEnd += 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const visibleItems = items.slice(windowStart, windowEnd);
|
|
181
|
+
const hasItemsAbove = shouldShowResults && windowStart > 0;
|
|
182
|
+
const hasItemsBelow = shouldShowResults && windowEnd < items.length;
|
|
183
|
+
const itemsAboveCount = windowStart;
|
|
184
|
+
const itemsBelowCount = items.length - windowEnd;
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<Box flexDirection="column" paddingX={1}>
|
|
188
|
+
<Text bold>Search</Text>
|
|
189
|
+
<Text>
|
|
190
|
+
Query: <Text inverse>{query || ' '}</Text>
|
|
191
|
+
</Text>
|
|
192
|
+
<Text dimColor>{hintLabel}</Text>
|
|
193
|
+
|
|
194
|
+
{!dismissedError && error ? (
|
|
195
|
+
<ErrorMessage message={error} onDismiss={() => setDismissedError(true)} />
|
|
196
|
+
) : null}
|
|
197
|
+
|
|
198
|
+
{loading ? <Text>Searching...</Text> : null}
|
|
199
|
+
|
|
200
|
+
{!loading && isQueryEmpty ? (
|
|
201
|
+
<EmptyState message="Start typing to search projects, features, and tasks." />
|
|
202
|
+
) : null}
|
|
203
|
+
|
|
204
|
+
{!loading && !isQueryEmpty && items.length === 0 ? (
|
|
205
|
+
<EmptyState message="No results found." hint="Try a broader query." />
|
|
206
|
+
) : null}
|
|
207
|
+
|
|
208
|
+
{hasItemsAbove ? (
|
|
209
|
+
<Text dimColor>↑ {itemsAboveCount} more</Text>
|
|
210
|
+
) : null}
|
|
211
|
+
|
|
212
|
+
{shouldShowResults
|
|
213
|
+
? visibleItems.map((item, index) => {
|
|
214
|
+
const actualIndex = windowStart + index;
|
|
215
|
+
const isSelected = actualIndex === clampedIndex;
|
|
216
|
+
return (
|
|
217
|
+
<Box
|
|
218
|
+
key={`${item.kind}-${item.id}`}
|
|
219
|
+
width={contentWidth}
|
|
220
|
+
flexDirection="row"
|
|
221
|
+
>
|
|
222
|
+
<Box width={markerWidth}>
|
|
223
|
+
<Text color={isSelected ? theme.colors.highlight : theme.colors.muted}>
|
|
224
|
+
{isSelected ? '▎' : ' '}
|
|
225
|
+
</Text>
|
|
226
|
+
</Box>
|
|
227
|
+
<Box
|
|
228
|
+
borderStyle={isSelected ? 'double' : 'round'}
|
|
229
|
+
borderColor={isSelected ? theme.colors.highlight : theme.colors.border}
|
|
230
|
+
paddingX={1}
|
|
231
|
+
width={cardWidth}
|
|
232
|
+
>
|
|
233
|
+
<Box flexDirection="column">
|
|
234
|
+
<Text color={isSelected ? theme.colors.highlight : (
|
|
235
|
+
item.kind === 'project' ? theme.colors.accent :
|
|
236
|
+
item.kind === 'feature' ? theme.colors.warning :
|
|
237
|
+
theme.colors.success
|
|
238
|
+
)} bold>
|
|
239
|
+
{item.kind.toUpperCase()}
|
|
240
|
+
</Text>
|
|
241
|
+
<Text bold={isSelected}>
|
|
242
|
+
{item.title}
|
|
243
|
+
</Text>
|
|
244
|
+
<Text dimColor={!isSelected}>{item.subtitle}</Text>
|
|
245
|
+
</Box>
|
|
246
|
+
</Box>
|
|
247
|
+
</Box>
|
|
248
|
+
);
|
|
249
|
+
})
|
|
250
|
+
: null}
|
|
251
|
+
|
|
252
|
+
{hasItemsBelow ? (
|
|
253
|
+
<Text dimColor>↓ {itemsBelowCount} more</Text>
|
|
254
|
+
) : null}
|
|
255
|
+
</Box>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { useTask } from '../../ui/hooks/use-data';
|
|
4
|
+
import { useAdapter } from '../../ui/context/adapter-context';
|
|
5
|
+
import { useTheme } from '../../ui/context/theme-context';
|
|
6
|
+
import { StatusBadge } from '../components/status-badge';
|
|
7
|
+
import { PriorityBadge } from '../components/priority-badge';
|
|
8
|
+
import { SectionList } from '../components/section-list';
|
|
9
|
+
import { DependencyList } from '../components/dependency-list';
|
|
10
|
+
import { StatusActions } from '../components/status-actions';
|
|
11
|
+
import { timeAgo } from '../../ui/lib/format';
|
|
12
|
+
import type { TaskStatus, Priority } from 'task-orchestrator-bun/src/domain/types';
|
|
13
|
+
import { FormDialog } from '../components/form-dialog';
|
|
14
|
+
import { ConfirmDialog } from '../components/confirm-dialog';
|
|
15
|
+
import { ErrorMessage } from '../components/error-message';
|
|
16
|
+
|
|
17
|
+
interface TaskDetailProps {
|
|
18
|
+
taskId: string;
|
|
19
|
+
onSelectTask: (taskId: string) => void;
|
|
20
|
+
onBack: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ActivePanel = 'sections' | 'dependencies' | 'status';
|
|
24
|
+
|
|
25
|
+
export function TaskDetail({ taskId, onSelectTask, onBack }: TaskDetailProps) {
|
|
26
|
+
const { task, sections, dependencies, loading, error, refresh } = useTask(taskId);
|
|
27
|
+
const { adapter } = useAdapter();
|
|
28
|
+
const { theme } = useTheme();
|
|
29
|
+
const [activePanel, setActivePanel] = useState<ActivePanel>('sections');
|
|
30
|
+
const [allowedTransitions, setAllowedTransitions] = useState<string[]>([]);
|
|
31
|
+
const [statusError, setStatusError] = useState<string | null>(null);
|
|
32
|
+
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
|
33
|
+
const [selectedSectionIndex, setSelectedSectionIndex] = useState(0);
|
|
34
|
+
const [mode, setMode] = useState<'idle' | 'edit' | 'delete'>('idle');
|
|
35
|
+
|
|
36
|
+
// Fetch allowed transitions when task loads
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (task) {
|
|
39
|
+
adapter.getAllowedTransitions('TASK', task.status).then(result => {
|
|
40
|
+
if (result.success) {
|
|
41
|
+
setAllowedTransitions(result.data);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}, [adapter, task]);
|
|
46
|
+
|
|
47
|
+
// Set initial panel based on available content
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (sections.length === 0 && activePanel === 'sections') {
|
|
50
|
+
setActivePanel('dependencies');
|
|
51
|
+
}
|
|
52
|
+
}, [sections, activePanel]);
|
|
53
|
+
|
|
54
|
+
// Handle keyboard navigation
|
|
55
|
+
useInput((input, key) => {
|
|
56
|
+
if (mode !== 'idle') return;
|
|
57
|
+
if (key.escape || input === 'h' || key.leftArrow) {
|
|
58
|
+
onBack();
|
|
59
|
+
}
|
|
60
|
+
if (input === 'r') {
|
|
61
|
+
refresh();
|
|
62
|
+
}
|
|
63
|
+
if (key.tab) {
|
|
64
|
+
// Cycle through panels (skip sections if none exist)
|
|
65
|
+
setActivePanel(current => {
|
|
66
|
+
if (current === 'sections') return 'dependencies';
|
|
67
|
+
if (current === 'dependencies') return 'status';
|
|
68
|
+
// From status, go to sections only if they exist
|
|
69
|
+
return sections.length > 0 ? 'sections' : 'dependencies';
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (input === 'e' && task) {
|
|
73
|
+
setMode('edit');
|
|
74
|
+
}
|
|
75
|
+
if (input === 'd' && task) {
|
|
76
|
+
setMode('delete');
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Handle status change
|
|
81
|
+
const handleStatusChange = async (newStatus: string) => {
|
|
82
|
+
if (!task) return;
|
|
83
|
+
|
|
84
|
+
setIsUpdatingStatus(true);
|
|
85
|
+
setStatusError(null);
|
|
86
|
+
|
|
87
|
+
const result = await adapter.setTaskStatus(taskId, newStatus as TaskStatus, task.version);
|
|
88
|
+
|
|
89
|
+
if (result.success) {
|
|
90
|
+
// Refresh task data to get updated version
|
|
91
|
+
await refresh();
|
|
92
|
+
} else {
|
|
93
|
+
setStatusError(result.error);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setIsUpdatingStatus(false);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (loading) {
|
|
100
|
+
return (
|
|
101
|
+
<Box padding={1}>
|
|
102
|
+
<Text>Loading task...</Text>
|
|
103
|
+
</Box>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (error) {
|
|
108
|
+
return (
|
|
109
|
+
<Box padding={1}>
|
|
110
|
+
<Text color={theme.colors.danger}>Error: {error}</Text>
|
|
111
|
+
</Box>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!task) {
|
|
116
|
+
return (
|
|
117
|
+
<Box padding={1}>
|
|
118
|
+
<Text>Task not found</Text>
|
|
119
|
+
</Box>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<Box flexDirection="column" padding={1}>
|
|
125
|
+
{/* Task Header */}
|
|
126
|
+
<Box marginBottom={1}>
|
|
127
|
+
<Text bold>{task.title}</Text>
|
|
128
|
+
<Text> </Text>
|
|
129
|
+
<StatusBadge status={task.status} />
|
|
130
|
+
</Box>
|
|
131
|
+
|
|
132
|
+
{/* Divider */}
|
|
133
|
+
<Box marginY={0}>
|
|
134
|
+
<Text dimColor>{'─'.repeat(40)}</Text>
|
|
135
|
+
</Box>
|
|
136
|
+
|
|
137
|
+
{/* Task Metadata */}
|
|
138
|
+
<Box marginBottom={1}>
|
|
139
|
+
<Text>Priority: </Text>
|
|
140
|
+
<PriorityBadge priority={task.priority} />
|
|
141
|
+
<Text> Modified: </Text>
|
|
142
|
+
<Text dimColor>{timeAgo(new Date(task.modifiedAt))}</Text>
|
|
143
|
+
</Box>
|
|
144
|
+
|
|
145
|
+
{/* Divider */}
|
|
146
|
+
{task.summary && (
|
|
147
|
+
<Box marginY={0}>
|
|
148
|
+
<Text dimColor>{'─'.repeat(40)}</Text>
|
|
149
|
+
</Box>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{/* Task Details (summary) */}
|
|
153
|
+
{task.summary && (
|
|
154
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
155
|
+
<Text bold>Details</Text>
|
|
156
|
+
<Box marginLeft={1}>
|
|
157
|
+
<Text wrap="wrap">{task.summary}</Text>
|
|
158
|
+
</Box>
|
|
159
|
+
</Box>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{/* Divider */}
|
|
163
|
+
{task.description && (
|
|
164
|
+
<Box marginY={0}>
|
|
165
|
+
<Text dimColor>{'─'.repeat(40)}</Text>
|
|
166
|
+
</Box>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{/* Task Description */}
|
|
170
|
+
{task.description && (
|
|
171
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
172
|
+
<Text bold>Description</Text>
|
|
173
|
+
<Box marginLeft={1}>
|
|
174
|
+
<Text wrap="wrap">{task.description}</Text>
|
|
175
|
+
</Box>
|
|
176
|
+
</Box>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{/* Divider */}
|
|
180
|
+
<Box marginY={0}>
|
|
181
|
+
<Text dimColor>{'─'.repeat(40)}</Text>
|
|
182
|
+
</Box>
|
|
183
|
+
|
|
184
|
+
{/* Sections Panel - only show if there are sections */}
|
|
185
|
+
{sections.length > 0 && (
|
|
186
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
187
|
+
<Box marginBottom={0}>
|
|
188
|
+
<Text bold={activePanel === 'sections'} dimColor={activePanel !== 'sections'}>
|
|
189
|
+
Sections
|
|
190
|
+
</Text>
|
|
191
|
+
</Box>
|
|
192
|
+
<SectionList
|
|
193
|
+
sections={sections}
|
|
194
|
+
selectedIndex={selectedSectionIndex}
|
|
195
|
+
onSelectedIndexChange={setSelectedSectionIndex}
|
|
196
|
+
isActive={activePanel === 'sections'}
|
|
197
|
+
/>
|
|
198
|
+
</Box>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
{/* Dependencies Panel */}
|
|
202
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
203
|
+
<Box marginBottom={0}>
|
|
204
|
+
<Text bold={activePanel === 'dependencies'} dimColor={activePanel !== 'dependencies'}>
|
|
205
|
+
Dependencies
|
|
206
|
+
</Text>
|
|
207
|
+
</Box>
|
|
208
|
+
<DependencyList
|
|
209
|
+
dependencies={dependencies}
|
|
210
|
+
isActive={activePanel === 'dependencies'}
|
|
211
|
+
onSelectTask={onSelectTask}
|
|
212
|
+
/>
|
|
213
|
+
</Box>
|
|
214
|
+
|
|
215
|
+
{/* Status Panel */}
|
|
216
|
+
<Box flexDirection="column">
|
|
217
|
+
<Box marginBottom={0}>
|
|
218
|
+
<Text bold={activePanel === 'status'} dimColor={activePanel !== 'status'}>
|
|
219
|
+
Status
|
|
220
|
+
</Text>
|
|
221
|
+
</Box>
|
|
222
|
+
<StatusActions
|
|
223
|
+
currentStatus={task.status}
|
|
224
|
+
allowedTransitions={allowedTransitions}
|
|
225
|
+
isActive={activePanel === 'status'}
|
|
226
|
+
loading={isUpdatingStatus}
|
|
227
|
+
onTransition={handleStatusChange}
|
|
228
|
+
/>
|
|
229
|
+
{statusError && (
|
|
230
|
+
<Box marginTop={1}>
|
|
231
|
+
<Text color={theme.colors.danger}>Error: {statusError}</Text>
|
|
232
|
+
</Box>
|
|
233
|
+
)}
|
|
234
|
+
</Box>
|
|
235
|
+
|
|
236
|
+
{/* Help Footer */}
|
|
237
|
+
<Box marginTop={1}>
|
|
238
|
+
<Text dimColor>
|
|
239
|
+
ESC/h: Back | Tab: Switch Panel | r: Refresh | e: Edit | d: Delete
|
|
240
|
+
</Text>
|
|
241
|
+
</Box>
|
|
242
|
+
|
|
243
|
+
{statusError ? <ErrorMessage message={statusError} onDismiss={() => setStatusError(null)} /> : null}
|
|
244
|
+
|
|
245
|
+
{mode === 'edit' ? (
|
|
246
|
+
<FormDialog
|
|
247
|
+
title="Edit Task"
|
|
248
|
+
fields={[
|
|
249
|
+
{ key: 'title', label: 'Title', required: true, value: task.title },
|
|
250
|
+
{ key: 'summary', label: 'Summary', required: true, value: task.summary },
|
|
251
|
+
{ key: 'description', label: 'Description', value: task.description ?? '' },
|
|
252
|
+
{ key: 'priority', label: 'Priority (HIGH/MEDIUM/LOW)', required: true, value: task.priority },
|
|
253
|
+
{ key: 'complexity', label: 'Complexity (1-10)', required: true, value: String(task.complexity) },
|
|
254
|
+
]}
|
|
255
|
+
onCancel={() => setMode('idle')}
|
|
256
|
+
onSubmit={(values) => {
|
|
257
|
+
adapter.updateTask(task.id, {
|
|
258
|
+
title: values.title ?? '',
|
|
259
|
+
summary: values.summary ?? '',
|
|
260
|
+
description: values.description || undefined,
|
|
261
|
+
priority: ((values.priority ?? task.priority) as Priority),
|
|
262
|
+
complexity: Number.parseInt(values.complexity ?? String(task.complexity), 10) || task.complexity,
|
|
263
|
+
version: task.version,
|
|
264
|
+
}).then((result) => {
|
|
265
|
+
if (!result.success) {
|
|
266
|
+
setStatusError(result.error);
|
|
267
|
+
}
|
|
268
|
+
refresh();
|
|
269
|
+
setMode('idle');
|
|
270
|
+
});
|
|
271
|
+
}}
|
|
272
|
+
/>
|
|
273
|
+
) : null}
|
|
274
|
+
|
|
275
|
+
{mode === 'delete' ? (
|
|
276
|
+
<ConfirmDialog
|
|
277
|
+
title="Delete Task"
|
|
278
|
+
message={`Delete "${task.title}"?`}
|
|
279
|
+
onCancel={() => setMode('idle')}
|
|
280
|
+
onConfirm={() => {
|
|
281
|
+
adapter.deleteTask(task.id).then((result) => {
|
|
282
|
+
if (!result.success) {
|
|
283
|
+
setStatusError(result.error);
|
|
284
|
+
setMode('idle');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
onBack();
|
|
288
|
+
});
|
|
289
|
+
}}
|
|
290
|
+
/>
|
|
291
|
+
) : null}
|
|
292
|
+
</Box>
|
|
293
|
+
);
|
|
294
|
+
}
|