@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,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
+ }