@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,338 @@
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { useTheme } from '../../ui/context/theme-context';
4
+
5
+ export interface FormField {
6
+ key: string;
7
+ label: string;
8
+ value?: string;
9
+ required?: boolean;
10
+ }
11
+
12
+ interface FormDialogProps {
13
+ title: string;
14
+ description?: string;
15
+ fields: FormField[];
16
+ onSubmit: (values: Record<string, string>) => void;
17
+ onCancel: () => void;
18
+ isActive?: boolean;
19
+ }
20
+
21
+ function clamp(value: number, min: number, max: number): number {
22
+ return Math.min(max, Math.max(min, value));
23
+ }
24
+
25
+ function normalizeNewlines(value: string): string {
26
+ return value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
27
+ }
28
+
29
+ function sanitizeInputValue(value: string): string {
30
+ const normalized = normalizeNewlines(value);
31
+ // Keep printable chars + newlines; drop other control bytes that can corrupt TUI layout.
32
+ return normalized.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
33
+ }
34
+
35
+ function toChars(value: string): string[] {
36
+ return Array.from(value);
37
+ }
38
+
39
+ function deleteWordBeforeCursor(value: string, cursor: number): { value: string; cursor: number } {
40
+ const chars = toChars(value);
41
+ let start = clamp(cursor, 0, chars.length);
42
+
43
+ while (start > 0 && /\s/.test(chars[start - 1] ?? '')) {
44
+ start -= 1;
45
+ }
46
+ while (start > 0 && !/\s/.test(chars[start - 1] ?? '')) {
47
+ start -= 1;
48
+ }
49
+
50
+ const next = chars.slice(0, start).concat(chars.slice(clamp(cursor, 0, chars.length))).join('');
51
+ return { value: next, cursor: start };
52
+ }
53
+
54
+ function getCursorLineAndColumn(value: string, cursor: number): { line: number; column: number } {
55
+ const chars = toChars(value);
56
+ const clamped = clamp(cursor, 0, chars.length);
57
+ let line = 0;
58
+ let column = 0;
59
+ for (let i = 0; i < clamped; i += 1) {
60
+ if (chars[i] === '\n') {
61
+ line += 1;
62
+ column = 0;
63
+ } else {
64
+ column += 1;
65
+ }
66
+ }
67
+ return { line, column };
68
+ }
69
+
70
+ export function FormDialog({
71
+ title,
72
+ description,
73
+ fields,
74
+ onSubmit,
75
+ onCancel,
76
+ isActive = true,
77
+ }: FormDialogProps) {
78
+ const { theme } = useTheme();
79
+
80
+ const initialValues = useMemo(() => {
81
+ const values: Record<string, string> = {};
82
+ for (const field of fields) {
83
+ values[field.key] = field.value ?? '';
84
+ }
85
+ return values;
86
+ }, [fields]);
87
+
88
+ const [values, setValues] = useState<Record<string, string>>(initialValues);
89
+ const [activeIndex, setActiveIndex] = useState(0);
90
+ const [cursorPositions, setCursorPositions] = useState<Record<string, number>>(() => {
91
+ const positions: Record<string, number> = {};
92
+ for (const field of fields) {
93
+ positions[field.key] = toChars(field.value ?? '').length;
94
+ }
95
+ return positions;
96
+ });
97
+ const [showCursor, setShowCursor] = useState(true);
98
+
99
+ const activeField = fields[activeIndex];
100
+
101
+ useEffect(() => {
102
+ if (!isActive || !activeField) {
103
+ setShowCursor(true);
104
+ return;
105
+ }
106
+ const timer = setInterval(() => {
107
+ setShowCursor((prev) => !prev);
108
+ }, 500);
109
+ return () => clearInterval(timer);
110
+ }, [isActive, activeField]);
111
+
112
+ useInput((input, key) => {
113
+ if (!isActive) return;
114
+ if (!activeField) return;
115
+
116
+ if (key.escape) {
117
+ onCancel();
118
+ return;
119
+ }
120
+
121
+ const currentValue = normalizeNewlines(values[activeField.key] ?? '');
122
+ const chars = toChars(currentValue);
123
+ const currentCursor = clamp(cursorPositions[activeField.key] ?? chars.length, 0, chars.length);
124
+ setShowCursor(true);
125
+
126
+ if (key.leftArrow) {
127
+ setCursorPositions((prev) => ({
128
+ ...prev,
129
+ [activeField.key]: Math.max(0, currentCursor - 1),
130
+ }));
131
+ return;
132
+ }
133
+
134
+ if (key.rightArrow) {
135
+ setCursorPositions((prev) => ({
136
+ ...prev,
137
+ [activeField.key]: Math.min(chars.length, currentCursor + 1),
138
+ }));
139
+ return;
140
+ }
141
+
142
+ if ((key.backspace || key.delete) && key.meta) {
143
+ const next = deleteWordBeforeCursor(currentValue, currentCursor);
144
+ setValues((prev) => ({
145
+ ...prev,
146
+ [activeField.key]: sanitizeInputValue(next.value),
147
+ }));
148
+ setCursorPositions((prev) => ({
149
+ ...prev,
150
+ [activeField.key]: next.cursor,
151
+ }));
152
+ return;
153
+ }
154
+
155
+ if (key.backspace) {
156
+ if (currentCursor === 0) return;
157
+ const nextChars = chars.slice(0, currentCursor - 1).concat(chars.slice(currentCursor));
158
+ setValues((prev) => ({
159
+ ...prev,
160
+ [activeField.key]: sanitizeInputValue(nextChars.join('')),
161
+ }));
162
+ setCursorPositions((prev) => ({
163
+ ...prev,
164
+ [activeField.key]: currentCursor - 1,
165
+ }));
166
+ return;
167
+ }
168
+
169
+ if (key.delete) {
170
+ if (currentCursor === 0) return;
171
+ const nextChars = chars.slice(0, currentCursor - 1).concat(chars.slice(currentCursor));
172
+ setValues((prev) => ({
173
+ ...prev,
174
+ [activeField.key]: sanitizeInputValue(nextChars.join('')),
175
+ }));
176
+ setCursorPositions((prev) => ({
177
+ ...prev,
178
+ [activeField.key]: currentCursor - 1,
179
+ }));
180
+ return;
181
+ }
182
+
183
+ if (key.return && key.shift) {
184
+ const nextChars = chars.slice(0, currentCursor).concat('\n', chars.slice(currentCursor));
185
+ setValues((prev) => ({
186
+ ...prev,
187
+ [activeField.key]: sanitizeInputValue(nextChars.join('')),
188
+ }));
189
+ setCursorPositions((prev) => ({
190
+ ...prev,
191
+ [activeField.key]: currentCursor + 1,
192
+ }));
193
+ return;
194
+ }
195
+
196
+ if (key.return || key.tab) {
197
+ if (activeIndex < fields.length - 1) {
198
+ const nextIndex = activeIndex + 1;
199
+ const nextField = fields[nextIndex];
200
+ setActiveIndex(nextIndex);
201
+ if (nextField) {
202
+ const nextValue = values[nextField.key] ?? '';
203
+ setCursorPositions((prev) => ({
204
+ ...prev,
205
+ [nextField.key]: toChars(nextValue).length,
206
+ }));
207
+ }
208
+ return;
209
+ }
210
+
211
+ const hasMissingRequired = fields.some((field) => field.required && !values[field.key]?.trim());
212
+ if (!hasMissingRequired) {
213
+ onSubmit(values);
214
+ }
215
+ return;
216
+ }
217
+
218
+ if (key.upArrow) {
219
+ const nextIndex = Math.max(0, activeIndex - 1);
220
+ const nextField = fields[nextIndex];
221
+ setActiveIndex(nextIndex);
222
+ if (nextField) {
223
+ const nextValue = values[nextField.key] ?? '';
224
+ setCursorPositions((prev) => ({
225
+ ...prev,
226
+ [nextField.key]: toChars(nextValue).length,
227
+ }));
228
+ }
229
+ return;
230
+ }
231
+
232
+ if (key.downArrow) {
233
+ const nextIndex = Math.min(fields.length - 1, activeIndex + 1);
234
+ const nextField = fields[nextIndex];
235
+ setActiveIndex(nextIndex);
236
+ if (nextField) {
237
+ const nextValue = values[nextField.key] ?? '';
238
+ setCursorPositions((prev) => ({
239
+ ...prev,
240
+ [nextField.key]: toChars(nextValue).length,
241
+ }));
242
+ }
243
+ return;
244
+ }
245
+
246
+ if (input && input.length === 1 && !key.ctrl && !key.meta) {
247
+ // Ignore control characters that can break terminal layout rendering.
248
+ if (/[\p{C}]/u.test(input)) return;
249
+ const nextChars = chars.slice(0, currentCursor).concat(input, chars.slice(currentCursor));
250
+ setValues((prev) => ({
251
+ ...prev,
252
+ [activeField.key]: sanitizeInputValue(nextChars.join('')),
253
+ }));
254
+ setCursorPositions((prev) => ({
255
+ ...prev,
256
+ [activeField.key]: currentCursor + 1,
257
+ }));
258
+ }
259
+ }, { isActive });
260
+
261
+ return (
262
+ <Box flexDirection="column" borderStyle="round" borderColor={theme.colors.highlight} paddingX={1} paddingY={0} marginY={1}>
263
+ <Text bold>{title}</Text>
264
+ {description ? <Text dimColor>{description}</Text> : null}
265
+ {fields.map((field, index) => {
266
+ const isActiveField = index === activeIndex;
267
+ const value = sanitizeInputValue(values[field.key] ?? '');
268
+ const valueChars = toChars(value);
269
+ const cursor = clamp(cursorPositions[field.key] ?? valueChars.length, 0, valueChars.length);
270
+ const isMissing = field.required && value.trim().length === 0;
271
+ const renderedLines = value.split('\n');
272
+ const cursorPos = getCursorLineAndColumn(value, cursor);
273
+ return (
274
+ <Box key={field.key} flexDirection="column" marginTop={1}>
275
+ <Text bold={isActiveField}>
276
+ {isActiveField ? '>' : ' '} {field.label}
277
+ {isMissing ? <Text color={theme.colors.warning}> *</Text> : null}
278
+ </Text>
279
+ <Box
280
+ borderStyle={isActiveField ? 'double' : 'round'}
281
+ borderColor={isActiveField ? theme.colors.highlight : theme.colors.border}
282
+ paddingX={1}
283
+ >
284
+ {isActiveField
285
+ ? (
286
+ <Box flexDirection="column">
287
+ {renderedLines.map((line, lineIndex) => {
288
+ if (!showCursor || lineIndex !== cursorPos.line) {
289
+ return (
290
+ <Text key={`${field.key}-line-${lineIndex}`}>{line || ' '}</Text>
291
+ );
292
+ }
293
+
294
+ const lineChars = toChars(line);
295
+ const column = clamp(cursorPos.column, 0, lineChars.length);
296
+
297
+ // Keep rendered width stable: never append an extra cursor glyph.
298
+ if (lineChars.length === 0) {
299
+ return (
300
+ <Text key={`${field.key}-line-${lineIndex}`}>
301
+ <Text color={theme.colors.accent} inverse> </Text>
302
+ </Text>
303
+ );
304
+ }
305
+
306
+ if (column >= lineChars.length) {
307
+ const before = lineChars.slice(0, lineChars.length - 1).join('');
308
+ const last = lineChars[lineChars.length - 1] ?? ' ';
309
+ return (
310
+ <Text key={`${field.key}-line-${lineIndex}`}>
311
+ {before}
312
+ <Text color={theme.colors.accent} inverse>{last}</Text>
313
+ </Text>
314
+ );
315
+ }
316
+
317
+ const before = lineChars.slice(0, column).join('');
318
+ const current = lineChars[column] ?? ' ';
319
+ const after = lineChars.slice(column + 1).join('');
320
+ return (
321
+ <Text key={`${field.key}-line-${lineIndex}`}>
322
+ {before}
323
+ <Text color={theme.colors.accent} inverse>{current}</Text>
324
+ {after}
325
+ </Text>
326
+ );
327
+ })}
328
+ </Box>
329
+ )
330
+ : <Text>{value || '—'}</Text>}
331
+ </Box>
332
+ </Box>
333
+ );
334
+ })}
335
+ <Text dimColor>Type to edit • ←/→ cursor • Shift+Enter newline • Option+Delete delete word • Delete/Backspace delete char • Enter/Tab next • Esc cancel</Text>
336
+ </Box>
337
+ );
338
+ }
@@ -0,0 +1,54 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { useTheme } from '../../ui/context/theme-context';
4
+
5
+ export interface HeaderProps {
6
+ title?: string;
7
+ breadcrumbs?: string[];
8
+ }
9
+
10
+ export const Header: React.FC<HeaderProps> = ({ title = 'Task Orchestrator', breadcrumbs }) => {
11
+ const { theme } = useTheme();
12
+
13
+ return (
14
+ <Box flexDirection="column">
15
+ <Box
16
+ justifyContent="space-between"
17
+ paddingX={1}
18
+ >
19
+ <Box gap={1}>
20
+ <Text bold color={theme.colors.accent}>
21
+ {title}
22
+ </Text>
23
+ {breadcrumbs && breadcrumbs.length > 0 && (
24
+ <Text>
25
+ {breadcrumbs.map((crumb, index) => {
26
+ const isLast = index === breadcrumbs.length - 1;
27
+ return (
28
+ <React.Fragment key={index}>
29
+ {index > 0 && <Text dimColor> › </Text>}
30
+ <Text bold={isLast} dimColor={!isLast}>
31
+ {crumb}
32
+ </Text>
33
+ </React.Fragment>
34
+ );
35
+ })}
36
+ </Text>
37
+ )}
38
+ </Box>
39
+ <Text dimColor>
40
+ q:quit
41
+ </Text>
42
+ </Box>
43
+ <Box
44
+ borderStyle="single"
45
+ borderBottom
46
+ borderTop={false}
47
+ borderLeft={false}
48
+ borderRight={false}
49
+ >
50
+ <Text> </Text>
51
+ </Box>
52
+ </Box>
53
+ );
54
+ };
@@ -0,0 +1,16 @@
1
+ export { Header } from './header';
2
+ export { Footer } from './footer';
3
+ export { StatusBadge } from './status-badge';
4
+ export { EntityTable } from './entity-table';
5
+ export { PriorityBadge } from './priority-badge';
6
+ export { TreeView, type TreeRow } from './tree-view';
7
+ export { SectionList, type SectionListProps } from './section-list';
8
+ export { StatusActions } from './status-actions';
9
+ export { DependencyList } from './dependency-list';
10
+ export { KanbanCard, type KanbanCardProps } from './kanban-card';
11
+ export { KanbanColumn } from './kanban-column';
12
+ export { KanbanBoard } from './kanban-board';
13
+ export { ConfirmDialog } from './confirm-dialog';
14
+ export { ErrorMessage } from './error-message';
15
+ export { EmptyState } from './empty-state';
16
+ export { FormDialog, type FormField } from './form-dialog';