loop-task 1.5.5 → 2.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 (105) hide show
  1. package/README.md +141 -21
  2. package/dist/board/App.js +1 -1
  3. package/dist/board/components/ActionButtons.js +1 -1
  4. package/dist/board/components/BoardButton.js +1 -1
  5. package/dist/board/components/ConfirmModal.js +1 -1
  6. package/dist/board/components/ContextHelpModal.js +1 -1
  7. package/dist/board/components/CreateForm.js +1 -1
  8. package/dist/board/components/CreateProjectModal.js +1 -1
  9. package/dist/board/components/DeleteProjectConfirm.js +1 -1
  10. package/dist/board/components/DetailView.js +1 -1
  11. package/dist/board/components/EditProjectModal.js +1 -1
  12. package/dist/board/components/FilterBar.js +1 -1
  13. package/dist/board/components/Footer.js +1 -1
  14. package/dist/board/components/Header.js +1 -1
  15. package/dist/board/components/HelpModal.js +1 -1
  16. package/dist/board/components/Inspector.js +1 -1
  17. package/dist/board/components/LogModal.js +1 -1
  18. package/dist/board/components/Navigator.js +1 -1
  19. package/dist/board/components/ProjectsModal.js +1 -1
  20. package/dist/board/components/ProjectsPage.js +1 -1
  21. package/dist/board/components/RunHistory.js +1 -1
  22. package/dist/board/components/SearchBox.js +1 -1
  23. package/dist/board/components/SearchSelect.js +1 -1
  24. package/dist/board/components/TaskBrowser.js +1 -1
  25. package/dist/board/components/TaskFilterBar.js +1 -1
  26. package/dist/board/components/TaskForm.js +1 -1
  27. package/dist/board/components/Timeline.js +1 -1
  28. package/dist/board/focus-context.js +1 -1
  29. package/dist/board/format.js +15 -6
  30. package/dist/board/index.js +1 -1
  31. package/dist/board/toast.js +1 -1
  32. package/dist/cli/import-validator.js +151 -0
  33. package/dist/cli/import-writer.js +77 -0
  34. package/dist/cli.js +111 -17
  35. package/dist/config/constants.js +52 -0
  36. package/dist/daemon/file-watcher.js +171 -0
  37. package/dist/daemon/http-server.js +605 -0
  38. package/dist/daemon/index.js +36 -6
  39. package/dist/daemon/manager.js +94 -13
  40. package/dist/daemon/projects.js +10 -0
  41. package/dist/daemon/server.js +21 -0
  42. package/dist/daemon/task-manager.js +7 -0
  43. package/dist/esm-loader.js +4 -1
  44. package/dist/hooks/useLoopFormValidation.js +140 -0
  45. package/dist/i18n/en.json +237 -17
  46. package/dist/shared/fs-utils.js +34 -1
  47. package/dist/tui/app.js +675 -151
  48. package/dist/tui/commands.js +114 -0
  49. package/dist/tui/components/ActionButtons.js +49 -0
  50. package/dist/tui/components/Button.js +18 -0
  51. package/dist/tui/components/ChainEditor.js +112 -0
  52. package/dist/tui/components/CommandBuilderField.js +143 -0
  53. package/dist/tui/components/CommandEditorModal.js +159 -0
  54. package/dist/tui/components/CommandInput.js +303 -0
  55. package/dist/tui/components/CommandsBrowserModal.js +77 -0
  56. package/dist/tui/components/ConfirmModal.js +9 -0
  57. package/dist/tui/components/ContextHelpModal.js +8 -0
  58. package/dist/tui/components/CreateForm.js +223 -0
  59. package/dist/tui/components/DebugPanel.js +8 -0
  60. package/dist/tui/components/ExportModal.js +37 -0
  61. package/dist/tui/components/FilterBar.js +13 -0
  62. package/dist/tui/components/FocusableButton.js +16 -0
  63. package/dist/tui/components/FocusableInput.js +69 -0
  64. package/dist/tui/components/FocusableList.js +46 -0
  65. package/dist/tui/components/FocusableSearchSelect.js +82 -0
  66. package/dist/tui/components/Footer.js +42 -0
  67. package/dist/tui/components/Header.js +40 -0
  68. package/dist/tui/components/HelpGuideModal.js +21 -0
  69. package/dist/tui/components/HelpModal.js +59 -0
  70. package/dist/tui/components/InlineCommandEditor.js +143 -0
  71. package/dist/tui/components/Inspector.js +31 -0
  72. package/dist/tui/components/LeftPanel.js +33 -0
  73. package/dist/tui/components/LogModal.js +121 -0
  74. package/dist/tui/components/Modal.js +11 -0
  75. package/dist/tui/components/Navigator.js +68 -0
  76. package/dist/tui/components/PatchEditForm.js +78 -0
  77. package/dist/tui/components/ProjectForm.js +64 -0
  78. package/dist/tui/components/ProjectsModal.js +40 -0
  79. package/dist/tui/components/ProjectsPage.js +99 -0
  80. package/dist/tui/components/RightPanel.js +34 -0
  81. package/dist/tui/components/RunHistory.js +108 -0
  82. package/dist/tui/components/SearchSelect.js +67 -0
  83. package/dist/tui/components/SelectModal.js +76 -0
  84. package/dist/tui/components/TabBar.js +20 -0
  85. package/dist/tui/components/TaskBrowser.js +96 -0
  86. package/dist/tui/components/TaskFilterBar.js +9 -0
  87. package/dist/tui/components/TaskForm.js +134 -0
  88. package/dist/tui/components/TaskPickerModal.js +60 -0
  89. package/dist/tui/components/Toast.js +37 -0
  90. package/dist/tui/components/WelcomeScreen.js +70 -0
  91. package/dist/tui/components/WizardForm.js +178 -0
  92. package/dist/tui/daemon.js +142 -0
  93. package/dist/tui/format.js +111 -0
  94. package/dist/tui/hooks/useBreakpoint.js +7 -0
  95. package/dist/tui/hooks/useHoverState.js +11 -0
  96. package/dist/tui/hooks/useLogStream.js +32 -0
  97. package/dist/tui/hooks/useLoopPolling.js +28 -0
  98. package/dist/tui/index.js +24 -0
  99. package/dist/tui/router.js +16 -0
  100. package/dist/tui/state.js +175 -0
  101. package/dist/tui/theme.js +96 -0
  102. package/dist/tui/types.js +1 -0
  103. package/dist/tui/utils/paste.js +10 -0
  104. package/dist/tui/utils/validation.js +56 -0
  105. package/package.json +13 -8
@@ -0,0 +1,108 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { ScrollList } from "ink-scroll-list";
4
+ import { darkTheme as theme } from "../theme.js";
5
+ import { formatRunTime, formatRunDuration, formatFileSize } from "../format.js";
6
+ import { t } from "../../i18n/index.js";
7
+ function runIcon(run) {
8
+ if (run.status === "running")
9
+ return "\u21bb";
10
+ return run.exitCode === 0 ? "\u2713" : "\u2717";
11
+ }
12
+ function runIconColor(run) {
13
+ if (run.status === "running")
14
+ return theme.accent.loop;
15
+ return run.exitCode === 0 ? theme.semantic.success : theme.semantic.danger;
16
+ }
17
+ const SPARK_CHARS = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
18
+ function sparkline(durations) {
19
+ if (durations.length === 0)
20
+ return "";
21
+ const max = Math.max(...durations, 1);
22
+ const min = Math.min(...durations, 0);
23
+ const range = max - min || 1;
24
+ return durations.map((d) => {
25
+ const level = Math.round(((d - min) / range) * (SPARK_CHARS.length - 1));
26
+ return SPARK_CHARS[level] ?? SPARK_CHARS[0];
27
+ }).join("");
28
+ }
29
+ function computeTrends(runs) {
30
+ if (runs.length === 0) {
31
+ return { sparkline: "", avgDuration: 0, successStreak: 0, failureStreak: 0, lastFailureAgo: null };
32
+ }
33
+ const recent = runs.slice(-20);
34
+ const durations = recent.map((r) => r.duration).filter((d) => d > 0);
35
+ const avgDuration = durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : 0;
36
+ let successStreak = 0;
37
+ let failureStreak = 0;
38
+ for (let i = runs.length - 1; i >= 0; i--) {
39
+ const r = runs[i];
40
+ if (r.exitCode === 0 && failureStreak === 0)
41
+ successStreak++;
42
+ else if (r.exitCode !== 0 && successStreak === 0)
43
+ failureStreak++;
44
+ else
45
+ break;
46
+ }
47
+ let lastFailureAgo = null;
48
+ for (let i = runs.length - 1; i >= 0; i--) {
49
+ if (runs[i].exitCode !== 0) {
50
+ const diff = Date.now() - new Date(runs[i].startedAt).getTime();
51
+ const hours = Math.floor(diff / 3600000);
52
+ const mins = Math.floor(diff / 60000);
53
+ lastFailureAgo = hours > 0 ? `${hours}h ago` : `${mins}m ago`;
54
+ break;
55
+ }
56
+ }
57
+ return {
58
+ sparkline: sparkline(durations),
59
+ avgDuration: Math.round(avgDuration),
60
+ successStreak,
61
+ failureStreak,
62
+ lastFailureAgo,
63
+ };
64
+ }
65
+ const LIMIT = 15;
66
+ export function RunHistory(props) {
67
+ const { loop, selectedRunIndex, onSelectRun, onOpenRun, isFocused, navActive = true } = props;
68
+ const runs = loop?.runHistory ?? [];
69
+ const reversed = [...runs].reverse();
70
+ const n = reversed.length;
71
+ useInput((input, key) => {
72
+ if (n === 0)
73
+ return;
74
+ if (key.upArrow || input === "k") {
75
+ const next = selectedRunIndex <= 0 ? n - 1 : selectedRunIndex - 1;
76
+ onSelectRun(next);
77
+ return;
78
+ }
79
+ if (key.downArrow || input === "j") {
80
+ const next = selectedRunIndex >= n - 1 ? 0 : selectedRunIndex + 1;
81
+ onSelectRun(next);
82
+ return;
83
+ }
84
+ if (key.ctrl && key.return) {
85
+ const run = reversed[selectedRunIndex];
86
+ if (run)
87
+ onOpenRun(run);
88
+ return;
89
+ }
90
+ }, { isActive: isFocused && navActive });
91
+ const title = isFocused ? t("board.runHistoryTitleHint") : t("board.runHistoryTitle");
92
+ const trends = computeTrends(runs);
93
+ function renderRun(run, isSelected) {
94
+ const fg = isSelected ? theme.text.inverse : theme.text.primary;
95
+ const time = formatRunTime(run.startedAt);
96
+ const duration = formatRunDuration(run.duration);
97
+ const size = formatFileSize(run.logSize);
98
+ const icon = runIcon(run);
99
+ const iconColor = runIconColor(run);
100
+ const chain = run.chainName ?? "";
101
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { color: fg, children: time.padEnd(10) }), _jsxs(Text, { color: isSelected ? theme.text.inverse : iconColor, children: [icon, " "] }), _jsx(Text, { color: fg, children: duration.padEnd(10) }), _jsx(Text, { color: fg, children: size.padEnd(8) }), _jsx(Text, { color: fg, children: chain })] }));
102
+ }
103
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { paddingLeft: 1, paddingRight: 1, justifyContent: "space-between", children: [_jsx(Text, { color: theme.text.muted, children: title }), (trends.failureStreak > 0 || trends.lastFailureAgo) ? (_jsxs(Text, { color: theme.semantic.warning, children: [trends.failureStreak > 0 ? t("board.runHistoryFailStreak", { count: String(trends.failureStreak) }) : "", trends.failureStreak > 0 && trends.lastFailureAgo ? " " : "", trends.lastFailureAgo ? t("board.runHistoryLastFail", { ago: trends.lastFailureAgo }) : ""] })) : null] }), reversed.length === 0 ? (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.runHistoryEmpty") }) })) : (_jsxs(Box, { flexDirection: "column", children: [trends.sparkline ? (_jsxs(Box, { paddingLeft: 1, marginBottom: 0, children: [_jsx(Text, { color: theme.text.muted, children: "Durations: " }), _jsx(Text, { color: theme.accent.loop, children: trends.sparkline }), _jsxs(Text, { color: theme.text.muted, children: [" avg:", formatRunDuration(trends.avgDuration), " "] }), trends.successStreak > 0 ? (_jsxs(Text, { color: theme.semantic.success, children: ["streak:", trends.successStreak, " ok"] })) : null] })) : null, _jsxs(Box, { paddingLeft: 1, children: [_jsx(Text, { color: theme.text.muted, children: t("board.runHistoryTime").padEnd(10) }), _jsx(Text, { color: theme.text.muted, children: " " }), _jsx(Text, { color: theme.text.muted, children: t("board.runHistoryDuration").padEnd(10) }), _jsx(Text, { color: theme.text.muted, children: t("board.runHistorySize").padEnd(8) }), _jsx(Text, { color: theme.text.muted, children: t("board.runHistoryDesc") })] }), _jsx(ScrollList, { selectedIndex: selectedRunIndex, height: LIMIT, children: reversed.map((run, i) => {
104
+ const isSelected = i === selectedRunIndex;
105
+ const indicator = isSelected ? "\u203a " : " ";
106
+ return (_jsxs(Box, { backgroundColor: isSelected ? (isFocused && navActive ? theme.bg.active : isFocused ? theme.bg.hover : undefined) : undefined, children: [_jsx(Text, { color: isSelected ? theme.text.inverse : theme.text.primary, children: indicator }), renderRun(run, isSelected)] }, i));
107
+ }) })] }))] }));
108
+ }
@@ -0,0 +1,67 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useMemo, useEffect } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { darkTheme as theme } from "../theme.js";
5
+ export function SearchSelect(props) {
6
+ const { options, value, onChange, focused } = props;
7
+ const placeholder = props.placeholder ?? "type to filter...";
8
+ const [filter, setFilter] = useState("");
9
+ const [selectedIndex, setSelectedIndex] = useState(0);
10
+ const filtered = useMemo(() => {
11
+ if (!filter)
12
+ return options;
13
+ const q = filter.toLowerCase();
14
+ return options.filter((o) => o.name.toLowerCase().includes(q) || o.value.toLowerCase().includes(q));
15
+ }, [options, filter]);
16
+ const currentIdx = filtered.findIndex((o) => o.value === value);
17
+ const clampedSelected = Math.min(currentIdx >= 0 && !filter ? currentIdx : selectedIndex, Math.max(0, filtered.length - 1));
18
+ useEffect(() => {
19
+ if (focused) {
20
+ setFilter("");
21
+ setSelectedIndex(currentIdx >= 0 ? currentIdx : 0);
22
+ }
23
+ }, [focused]);
24
+ useInput((input, key) => {
25
+ if (!focused)
26
+ return;
27
+ if (key.upArrow) {
28
+ setSelectedIndex((prev) => prev <= 0 ? filtered.length - 1 : prev - 1);
29
+ return;
30
+ }
31
+ if (key.downArrow) {
32
+ setSelectedIndex((prev) => prev >= filtered.length - 1 ? 0 : prev + 1);
33
+ return;
34
+ }
35
+ if (key.return) {
36
+ const option = filtered[clampedSelected];
37
+ if (option) {
38
+ onChange(option.value);
39
+ }
40
+ return;
41
+ }
42
+ if (key.escape) {
43
+ setFilter("");
44
+ setSelectedIndex(0);
45
+ return;
46
+ }
47
+ if (key.backspace || key.delete) {
48
+ setFilter((f) => f.slice(0, -1));
49
+ setSelectedIndex(0);
50
+ return;
51
+ }
52
+ if (input && !key.ctrl && !key.meta && input.length === 1 && /[a-z0-9 _\-./]/i.test(input)) {
53
+ setFilter((f) => f + input);
54
+ setSelectedIndex(0);
55
+ return;
56
+ }
57
+ });
58
+ const listHeight = Math.min(filtered.length, 6);
59
+ return (_jsxs(Box, { borderStyle: "single", borderColor: focused ? theme.accent.focus : theme.border.default, backgroundColor: focused ? theme.bg.input : theme.bg.surface, flexDirection: "column", children: [_jsxs(Box, { paddingLeft: 1, children: [_jsx(Text, { color: theme.text.muted, children: "/ " }), _jsx(Text, { color: filter ? theme.text.primary : theme.text.muted, children: filter || placeholder })] }), filtered.length > 0 ? (_jsx(Box, { flexDirection: "column", height: listHeight, children: filtered.map((option, i) => {
60
+ const isSelected = i === clampedSelected;
61
+ const isActive = option.value === value;
62
+ const prefix = isSelected ? "\u203a " : " ";
63
+ const bg = isSelected ? theme.bg.active : undefined;
64
+ const fg = isSelected ? theme.text.inverse : isActive ? theme.accent.focus : theme.text.secondary;
65
+ return (_jsx(Box, { backgroundColor: bg, children: _jsxs(Text, { color: fg, children: [prefix, option.name] }) }, option.value));
66
+ }) })) : null] }));
67
+ }
@@ -0,0 +1,76 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useMemo, useCallback } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { darkTheme as theme } from "../theme.js";
5
+ import { t } from "../../i18n/index.js";
6
+ const MAX_VISIBLE = 10;
7
+ /**
8
+ * Filterable modal picker: type to narrow, up/down to navigate, enter to
9
+ * select, esc to cancel. The single interaction pattern for every enumerated
10
+ * field in the board — a fixed set of two options and a list of dozens both
11
+ * use this, so users only ever learn one gesture for "change this value".
12
+ */
13
+ export function SelectModal(props) {
14
+ const { title, options, initialValue, onSelect, onClose } = props;
15
+ const accent = props.accentColor ?? theme.accent.brand;
16
+ const [query, setQuery] = useState("");
17
+ const [cursor, setCursor] = useState(() => {
18
+ const idx = options.findIndex((o) => o.value === initialValue);
19
+ return idx >= 0 ? idx : 0;
20
+ });
21
+ const filtered = useMemo(() => {
22
+ const q = query.trim().toLowerCase();
23
+ if (!q)
24
+ return options;
25
+ return options.filter((o) => o.label.toLowerCase().includes(q));
26
+ }, [options, query]);
27
+ const clampedCursor = filtered.length > 0 ? cursor % filtered.length : 0;
28
+ const selectAt = useCallback((idx) => {
29
+ const option = filtered[idx];
30
+ if (option)
31
+ onSelect(option);
32
+ }, [filtered, onSelect]);
33
+ useInput((input, key) => {
34
+ if (key.escape) {
35
+ onClose();
36
+ return;
37
+ }
38
+ if (key.return) {
39
+ selectAt(clampedCursor);
40
+ return;
41
+ }
42
+ if (key.upArrow) {
43
+ setCursor((prev) => (prev <= 0 ? Math.max(0, filtered.length - 1) : prev - 1));
44
+ return;
45
+ }
46
+ if (key.downArrow) {
47
+ setCursor((prev) => (prev >= filtered.length - 1 ? 0 : prev + 1));
48
+ return;
49
+ }
50
+ if (key.backspace || key.delete) {
51
+ setQuery((prev) => prev.slice(0, -1));
52
+ setCursor(0);
53
+ return;
54
+ }
55
+ if (key.ctrl)
56
+ return;
57
+ if (input.length === 1 && input >= " " && input <= "~") {
58
+ setQuery((prev) => prev + input);
59
+ setCursor(0);
60
+ }
61
+ });
62
+ const start = Math.max(0, clampedCursor - Math.floor(MAX_VISIBLE / 2));
63
+ const end = Math.min(filtered.length, start + MAX_VISIBLE);
64
+ const visible = filtered.slice(start, end);
65
+ return (_jsx(Box, { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", justifyContent: "center", alignItems: "center", children: _jsxs(Box, { width: 50, height: Math.min(20, visible.length + 7), flexDirection: "column", backgroundColor: theme.bg.elevated, borderStyle: "round", borderColor: accent, paddingX: 1, paddingY: 0, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: accent, bold: true, children: title }), _jsx(Text, { color: theme.text.muted, children: "esc" })] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Text, { color: theme.text.muted, children: "› " }), _jsx(Text, { color: query ? theme.text.primary : theme.text.muted, children: query || t("selectModal.searchPlaceholder") }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(Box, { flexDirection: "column", marginTop: 0, children: visible.length === 0 ? (_jsx(Text, { color: theme.text.muted, children: t("selectModal.empty") })) : (visible.map((option, visIdx) => {
66
+ const realIdx = start + visIdx;
67
+ const isSelected = realIdx === clampedCursor;
68
+ const isCurrent = option.value === initialValue;
69
+ return (_jsxs(Box, { backgroundColor: isSelected ? theme.bg.active : undefined, children: [_jsx(Text, { color: isSelected ? theme.text.inverse : theme.text.muted, children: isSelected ? "❯ " : " " }), _jsx(Text, { color: isSelected ? theme.text.inverse : isCurrent ? accent : theme.text.primary, children: option.label })] }, option.value));
70
+ })) }), _jsxs(Box, { marginTop: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.text.muted, children: [filtered.length, "/", options.length] }), _jsx(Text, { color: theme.text.muted, children: t("selectModal.hint") })] })] }) }));
71
+ }
72
+ /** Bordered field showing the current value, matching TextField's focus styling. */
73
+ export function SelectValueField(props) {
74
+ const { label, placeholder, isActive } = props;
75
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsx(Box, { borderStyle: "single", borderColor: isActive ? theme.accent.brand : theme.border.dim, backgroundColor: isActive ? theme.bg.input : undefined, paddingLeft: 1, overflow: "hidden", width: "100%", children: label ? (_jsx(Text, { color: theme.text.primary, children: label })) : (_jsx(Text, { color: theme.text.muted, children: placeholder })) }), isActive ? (_jsxs(Box, { marginTop: 0, children: [_jsx(Text, { color: theme.accent.brand, children: "› " }), _jsx(Text, { color: theme.text.muted, children: t("selectModal.fieldHint", { action: "enter" }) })] })) : null] }));
76
+ }
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { darkTheme as theme } from "../theme.js";
4
+ const TAB_DEFS = [
5
+ { key: "loops", label: "Loops", color: theme.accent.loop },
6
+ { key: "tasks", label: "Tasks", color: theme.accent.task },
7
+ { key: "projects", label: "Projects", color: theme.accent.project },
8
+ ];
9
+ export function tabColor(tab) {
10
+ return TAB_DEFS.find((t) => t.key === tab)?.color ?? theme.accent.brand;
11
+ }
12
+ export function TabBar(props) {
13
+ const { activeTab, counts } = props;
14
+ return (_jsx(Box, { gap: 1, children: TAB_DEFS.map((tab) => {
15
+ const isActive = tab.key === activeTab;
16
+ const count = counts?.[tab.key];
17
+ const label = count !== undefined ? `${tab.label} ${count}` : tab.label;
18
+ return (_jsx(Box, { children: _jsx(Text, { color: isActive ? theme.text.inverse : theme.text.muted, backgroundColor: isActive ? tab.color : undefined, bold: isActive, children: ` ${label} ` }) }, tab.key));
19
+ }) }));
20
+ }
@@ -0,0 +1,96 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { ScrollList } from "ink-scroll-list";
5
+ import { darkTheme as theme } from "../theme.js";
6
+ import { commandLine } from "../format.js";
7
+ import { t } from "../../i18n/index.js";
8
+ import { FocusableButton } from "./FocusableButton.js";
9
+ const NAME_WIDTH = 24;
10
+ const COMMAND_WIDTH = 32;
11
+ const LIMIT = 15;
12
+ export function TaskNavigator(props) {
13
+ const { visible, total, selectedIndex, isFocused, navActive = true, allTasks, onSelect, onActivate } = props;
14
+ const title = t("board.taskBrowserTitle", {
15
+ visible: String(visible.length),
16
+ total: String(total),
17
+ });
18
+ useInput((input, key) => {
19
+ if (key.upArrow || input === "k") {
20
+ onSelect(Math.max(selectedIndex - 1, 0));
21
+ }
22
+ else if (key.downArrow || input === "j") {
23
+ onSelect(Math.min(selectedIndex + 1, visible.length - 1));
24
+ }
25
+ else if (key.ctrl && key.return) {
26
+ onActivate(selectedIndex);
27
+ }
28
+ }, { isActive: isFocused && navActive });
29
+ function resolveName(id) {
30
+ if (id === null)
31
+ return null;
32
+ return allTasks.find((t) => t.id === id)?.name ?? id;
33
+ }
34
+ function chainsLabel(task) {
35
+ const hasSuccess = task.onSuccessTaskId !== null;
36
+ const hasFailure = task.onFailureTaskId !== null;
37
+ if (!hasSuccess && !hasFailure)
38
+ return t("board.taskChainsNone");
39
+ const successName = hasSuccess ? resolveName(task.onSuccessTaskId) : null;
40
+ const failureName = hasFailure ? resolveName(task.onFailureTaskId) : null;
41
+ return t("board.taskChainsFormat", {
42
+ success: hasSuccess ? `\u2713${successName}` : "-",
43
+ failure: hasFailure ? `\u2192${failureName}` : "-",
44
+ });
45
+ }
46
+ function renderTask(task, index) {
47
+ const isSelected = index === selectedIndex;
48
+ const name = task.name.length > NAME_WIDTH
49
+ ? task.name.slice(0, NAME_WIDTH - 3) + "..."
50
+ : task.name.padEnd(NAME_WIDTH);
51
+ const cmd = commandLine(task.command, task.commandArgs);
52
+ const cmdDisplay = cmd.length > COMMAND_WIDTH
53
+ ? cmd.slice(0, COMMAND_WIDTH - 3) + "..."
54
+ : cmd.padEnd(COMMAND_WIDTH);
55
+ const chains = chainsLabel(task);
56
+ const fg = isSelected ? theme.text.inverse : theme.text.primary;
57
+ return (_jsxs(Box, { backgroundColor: isSelected ? (isFocused && navActive ? theme.bg.activeTask : isFocused ? theme.bg.hover : undefined) : undefined, children: [_jsx(Text, { color: fg, children: name }), _jsx(Text, { color: fg, children: cmdDisplay }), _jsx(Text, { color: fg, children: chains })] }));
58
+ }
59
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: title }) }), visible.length === 0 ? (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.taskBrowserEmpty") }) })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { paddingLeft: 1, children: [_jsx(Text, { color: theme.text.muted, children: " " }), _jsx(Text, { color: theme.text.muted, children: t("board.taskHeaderName").padEnd(NAME_WIDTH) }), _jsx(Text, { color: theme.text.muted, children: t("board.taskHeaderCommand").padEnd(COMMAND_WIDTH) }), _jsx(Text, { color: theme.text.muted, children: t("board.taskHeaderChains") })] }), _jsx(Box, { paddingLeft: 1, children: _jsx(ScrollList, { height: LIMIT, selectedIndex: selectedIndex, scrollAlignment: "auto", children: visible.map((task, i) => (_jsx(React.Fragment, { children: renderTask(task, i) }, task.id))) }) })] }))] }));
60
+ }
61
+ const FIELD_LABEL_WIDTH = 13;
62
+ function InspectorField(props) {
63
+ return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.text.secondary, children: props.label.padEnd(FIELD_LABEL_WIDTH) }), _jsx(Text, { color: theme.text.primary, children: props.children })] }));
64
+ }
65
+ export function TaskInspector(props) {
66
+ const { task, allTasks } = props;
67
+ if (!task) {
68
+ return (_jsxs(Box, { borderStyle: "single", borderColor: theme.border.default, flexDirection: "column", children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.inspectorTitle") }) }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.inspectorEmpty") }) })] }));
69
+ }
70
+ const cmd = commandLine(task.command, task.commandArgs);
71
+ const resolveName = (id) => {
72
+ if (id === null)
73
+ return null;
74
+ return allTasks.find((t) => t.id === id)?.name ?? id;
75
+ };
76
+ const onSuccess = task.onSuccessTaskId ? resolveName(task.onSuccessTaskId) ?? task.onSuccessTaskId : t("board.taskNone");
77
+ const onFailure = task.onFailureTaskId ? resolveName(task.onFailureTaskId) ?? task.onFailureTaskId : t("board.taskNone");
78
+ return (_jsxs(Box, { borderStyle: "single", borderColor: theme.border.default, flexDirection: "column", children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.inspectorTitle") }) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsx(InspectorField, { label: t("board.fieldId"), children: _jsx(Text, { color: theme.text.primary, children: task.id }) }), _jsx(InspectorField, { label: t("board.taskLabelName") + ": ", children: _jsx(Text, { color: theme.text.primary, children: task.name }) }), _jsx(InspectorField, { label: t("board.taskLabelCommand") + ": ", children: _jsx(Text, { color: theme.text.primary, children: cmd }) }), _jsx(InspectorField, { label: t("board.taskLabelOnSuccess") + ": ", children: _jsx(Text, { color: theme.text.primary, children: onSuccess }) }), _jsx(InspectorField, { label: t("board.taskLabelOnFailure") + ": ", children: _jsx(Text, { color: theme.text.primary, children: onFailure }) })] })] }));
79
+ }
80
+ export function TaskActionButtons(props) {
81
+ const { task, selectable, onAction } = props;
82
+ const actions = selectable
83
+ ? [
84
+ { key: "select", label: t("board.taskActionSelect") },
85
+ { key: "edit", label: t("board.taskActionEdit") },
86
+ { key: "delete", label: t("board.taskActionDelete") },
87
+ ]
88
+ : [
89
+ { key: "edit", label: t("board.taskActionEdit") },
90
+ { key: "delete", label: t("board.taskActionDelete") },
91
+ ];
92
+ if (!task) {
93
+ return (_jsx(Box, { borderStyle: "single", borderColor: theme.border.default, flexDirection: "row", height: 3, backgroundColor: theme.bg.surface, children: _jsx(Text, { color: theme.text.muted, children: t("board.noActions") }) }));
94
+ }
95
+ return (_jsx(Box, { borderStyle: "single", borderColor: theme.border.default, flexDirection: "row", height: 3, backgroundColor: theme.bg.surface, children: actions.map((action) => (_jsx(FocusableButton, { label: action.label, color: action.key === "delete" ? theme.semantic.danger : theme.text.secondary, variant: action.key === "delete" ? "danger" : "default", onPress: () => onAction(action.key) }, action.key))) }));
96
+ }
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { darkTheme as theme } from "../theme.js";
4
+ import { FocusableInput } from "./FocusableInput.js";
5
+ import { t } from "../../i18n/index.js";
6
+ export function TaskFilterBar(props) {
7
+ const { query, onQueryChange, onSearchDismiss } = props;
8
+ return (_jsxs(Box, { height: 3, children: [_jsx(Box, { flexGrow: 1, children: _jsx(FocusableInput, { value: query, onChange: onQueryChange, onSubmit: onSearchDismiss, placeholder: t("board.searchPlaceholder") }) }), _jsx(Box, { paddingLeft: 1, justifyContent: "flex-end", children: _jsx(Text, { color: theme.text.muted, children: t("board.searchEmpty") }) })] }));
9
+ }
@@ -0,0 +1,134 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useCallback, useState, useRef } from "react";
3
+ import { WizardForm } from "./WizardForm.js";
4
+ import { SelectModal, SelectValueField } from "./SelectModal.js";
5
+ import { InlineCommandEditor } from "./InlineCommandEditor.js";
6
+ import { createTask, updateTask, listTasks } from "../daemon.js";
7
+ import crypto from "node:crypto";
8
+ import { t } from "../../i18n/index.js";
9
+ // ── Utility ─────────────────────────────────────────────────────────
10
+ function parseArgs(cmd) {
11
+ const tokens = [];
12
+ const regex = /"([^"]*)"|'([^']*)'|(\S+)/g;
13
+ let match;
14
+ while ((match = regex.exec(cmd)) !== null) {
15
+ tokens.push(match[1] ?? match[2] ?? match[3] ?? "");
16
+ }
17
+ return tokens.slice(1);
18
+ }
19
+ function joinCommand(task) {
20
+ return [task.command, ...task.commandArgs].join(" ");
21
+ }
22
+ // ── Component ───────────────────────────────────────────────────────
23
+ export function TaskForm(props) {
24
+ const { mode, editTask, onCancel, onDone } = props;
25
+ const [tasks, setTasks] = useState([]);
26
+ const [commandValue, setCommandValue] = useState(editTask ? (editTask.commandRaw ?? joinCommand(editTask)) : "");
27
+ useEffect(() => {
28
+ listTasks().then(setTasks).catch(() => setTasks([]));
29
+ }, []);
30
+ const chainOptions = useMemo(() => [t("wizard.chainNone"), ...tasks.map((task) => task.name)], [tasks]);
31
+ const chainSelectOptions = useMemo(() => chainOptions.map((v) => ({ value: v, label: v })), [chainOptions]);
32
+ const [openChainField, setOpenChainField] = useState(null);
33
+ const chainFieldsRef = useRef({});
34
+ const resolveChainId = useCallback((val) => {
35
+ if (!val || val === t("wizard.chainNone"))
36
+ return null;
37
+ const found = tasks.find((task) => task.name === val || task.id === val);
38
+ return found?.id ?? val;
39
+ }, [tasks]);
40
+ const resolveChainName = useCallback((id) => {
41
+ if (!id)
42
+ return t("wizard.chainNone");
43
+ const found = tasks.find((task) => task.id === id);
44
+ return found?.name ?? id;
45
+ }, [tasks]);
46
+ const steps = useMemo(() => {
47
+ const list = [
48
+ {
49
+ key: "name",
50
+ prompt: t("wizard.taskNamePrompt"),
51
+ hint: t("wizard.taskNameHint"),
52
+ required: true,
53
+ inputType: "text",
54
+ defaultValue: editTask?.name ?? undefined,
55
+ },
56
+ {
57
+ key: "command",
58
+ prompt: t("wizard.taskCommandPrompt"),
59
+ hint: t("wizard.commandHint"),
60
+ required: true,
61
+ inputType: "text",
62
+ defaultValue: commandValue || undefined,
63
+ renderCustom: ({ isActive, onChange }) => (_jsx(InlineCommandEditor, { value: commandValue, hint: t("wizard.commandHint"), isActive: isActive, onChange: (v) => {
64
+ setCommandValue(v);
65
+ onChange(v);
66
+ } })),
67
+ },
68
+ {
69
+ key: "onSuccess",
70
+ prompt: t("wizard.onSuccessPrompt"),
71
+ hint: t("board.taskHintOnSuccess"),
72
+ required: false,
73
+ defaultValue: editTask ? resolveChainName(editTask.onSuccessTaskId) : undefined,
74
+ onActivate: () => setOpenChainField("onSuccess"),
75
+ renderCustom: ({ value, isActive, onChange, onAdvance }) => {
76
+ chainFieldsRef.current.onSuccess = { value, onChange, onAdvance };
77
+ return (_jsx(SelectValueField, { label: value || null, placeholder: t("wizard.onSuccessPrompt"), isActive: isActive }));
78
+ },
79
+ },
80
+ {
81
+ key: "onFailure",
82
+ prompt: t("wizard.onFailurePrompt"),
83
+ hint: t("board.taskHintOnFailure"),
84
+ required: false,
85
+ defaultValue: editTask ? resolveChainName(editTask.onFailureTaskId) : undefined,
86
+ onActivate: () => setOpenChainField("onFailure"),
87
+ renderCustom: ({ value, isActive, onChange, onAdvance }) => {
88
+ chainFieldsRef.current.onFailure = { value, onChange, onAdvance };
89
+ return (_jsx(SelectValueField, { label: value || null, placeholder: t("wizard.onFailurePrompt"), isActive: isActive }));
90
+ },
91
+ },
92
+ ];
93
+ return list;
94
+ }, [commandValue, chainOptions, editTask, resolveChainName]);
95
+ const handleComplete = useCallback((values) => {
96
+ const name = values.name?.trim() ?? "";
97
+ const rawCommand = commandValue
98
+ .split("\n")
99
+ .map((l) => l.trim())
100
+ .filter(Boolean)
101
+ .join(" ");
102
+ if (!name || !rawCommand)
103
+ return;
104
+ const onSuccessTaskId = resolveChainId(values.onSuccess ?? "");
105
+ const onFailureTaskId = resolveChainId(values.onFailure ?? "");
106
+ const payload = {
107
+ name,
108
+ command: rawCommand.split(" ")[0] ?? "",
109
+ commandArgs: parseArgs(rawCommand),
110
+ commandRaw: commandValue,
111
+ onSuccessTaskId,
112
+ onFailureTaskId,
113
+ };
114
+ if (mode === "edit" && editTask) {
115
+ updateTask(editTask.id, payload)
116
+ .then(() => onDone(true, editTask.id))
117
+ .catch(() => { });
118
+ }
119
+ else {
120
+ const id = crypto.randomUUID().slice(0, 8);
121
+ createTask({ id, ...payload })
122
+ .then((result) => onDone(false, result.id))
123
+ .catch(() => { });
124
+ }
125
+ }, [commandValue, resolveChainId, mode, editTask, onDone]);
126
+ const title = mode === "edit"
127
+ ? t("board.taskEditTitle")
128
+ : t("board.taskCreateTitle");
129
+ return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: title, steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: openChainField !== null }), openChainField ? (_jsx(SelectModal, { title: openChainField === "onSuccess" ? t("wizard.onSuccessPrompt") : t("wizard.onFailurePrompt"), options: chainSelectOptions, initialValue: chainFieldsRef.current[openChainField]?.value, onSelect: (option) => {
130
+ chainFieldsRef.current[openChainField]?.onChange(option.value);
131
+ chainFieldsRef.current[openChainField]?.onAdvance();
132
+ setOpenChainField(null);
133
+ }, onClose: () => setOpenChainField(null) })) : null] }));
134
+ }
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useMemo, useCallback } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { darkTheme as theme } from "../theme.js";
5
+ import { t } from "../../i18n/index.js";
6
+ const MAX_VISIBLE = 10;
7
+ export function TaskPickerModal(props) {
8
+ const [query, setQuery] = useState("");
9
+ const [cursor, setCursor] = useState(0);
10
+ const filtered = useMemo(() => {
11
+ const q = query.trim().toLowerCase();
12
+ if (!q)
13
+ return props.tasks;
14
+ return props.tasks.filter((task) => task.name.toLowerCase().includes(q) ||
15
+ task.command.toLowerCase().includes(q));
16
+ }, [props.tasks, query]);
17
+ const clampedCursor = filtered.length > 0 ? cursor % filtered.length : 0;
18
+ const selectAt = useCallback((idx) => {
19
+ const task = filtered[idx];
20
+ if (task)
21
+ props.onSelect(task);
22
+ }, [filtered, props]);
23
+ useInput((input, key) => {
24
+ if (key.escape) {
25
+ props.onClose();
26
+ return;
27
+ }
28
+ if (key.return) {
29
+ selectAt(clampedCursor);
30
+ return;
31
+ }
32
+ if (key.upArrow) {
33
+ setCursor((prev) => prev <= 0 ? Math.max(0, filtered.length - 1) : prev - 1);
34
+ return;
35
+ }
36
+ if (key.downArrow) {
37
+ setCursor((prev) => prev >= filtered.length - 1 ? 0 : prev + 1);
38
+ return;
39
+ }
40
+ if (key.backspace || key.delete) {
41
+ setQuery((prev) => prev.slice(0, -1));
42
+ setCursor(0);
43
+ return;
44
+ }
45
+ if (key.ctrl)
46
+ return;
47
+ if (input.length === 1 && input >= " " && input <= "~") {
48
+ setQuery((prev) => prev + input);
49
+ setCursor(0);
50
+ }
51
+ });
52
+ const start = Math.max(0, clampedCursor - Math.floor(MAX_VISIBLE / 2));
53
+ const end = Math.min(filtered.length, start + MAX_VISIBLE);
54
+ const visible = filtered.slice(start, end);
55
+ return (_jsx(Box, { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", justifyContent: "center", alignItems: "center", children: _jsxs(Box, { width: 60, height: Math.min(20, visible.length + 8), flexDirection: "column", backgroundColor: theme.bg.elevated, borderStyle: "round", borderColor: theme.accent.task, paddingX: 1, paddingY: 0, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: theme.accent.task, bold: true, children: t("taskPicker.title") }), _jsx(Text, { color: theme.text.muted, children: "esc" })] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Text, { color: theme.text.muted, children: "\u203a " }), _jsx(Text, { color: query ? theme.text.primary : theme.text.muted, children: query || t("taskPicker.searchPlaceholder") }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(Box, { flexDirection: "column", marginTop: 0, children: visible.length === 0 ? (_jsx(Text, { color: theme.text.muted, children: t("taskPicker.empty") })) : (visible.map((task, visIdx) => {
56
+ const realIdx = start + visIdx;
57
+ const isSelected = realIdx === clampedCursor;
58
+ return (_jsxs(Box, { backgroundColor: isSelected ? theme.bg.active : undefined, children: [_jsx(Text, { color: isSelected ? theme.text.inverse : theme.text.muted, children: isSelected ? "\u276f " : " " }), _jsx(Text, { color: isSelected ? theme.text.inverse : theme.text.primary, children: task.name }), _jsx(Text, { color: isSelected ? theme.text.inverse : theme.text.muted, children: " " + task.command })] }, task.id));
59
+ })) }), _jsxs(Box, { marginTop: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.text.muted, children: [filtered.length, "/", props.tasks.length] }), _jsx(Text, { color: theme.text.muted, children: t("taskPicker.hint") })] })] }) }));
60
+ }
@@ -0,0 +1,37 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState, useCallback } from "react";
3
+ import { Box, Text } from "ink";
4
+ import { darkTheme as theme } from "../theme.js";
5
+ const TOAST_TIMEOUT_MS = 3500;
6
+ const TOAST_MAX = 4;
7
+ function toastColor(kind) {
8
+ switch (kind) {
9
+ case "success": return theme.semantic.success;
10
+ case "error": return theme.semantic.danger;
11
+ case "info": return theme.accent.loop;
12
+ }
13
+ }
14
+ function toastIcon(kind) {
15
+ switch (kind) {
16
+ case "success": return "\u2713";
17
+ case "error": return "\u2717";
18
+ case "info": return "\u2139";
19
+ }
20
+ }
21
+ export function useToasts() {
22
+ const [toasts, setToasts] = useState([]);
23
+ const nextId = useState(() => ({ value: 0 }))[0];
24
+ const push = useCallback((kind, message) => {
25
+ const id = ++nextId.value;
26
+ setToasts((prev) => [...prev, { id, kind, message }].slice(-TOAST_MAX));
27
+ setTimeout(() => {
28
+ setToasts((prev) => prev.filter((t) => t.id !== id));
29
+ }, TOAST_TIMEOUT_MS);
30
+ }, [nextId]);
31
+ return { toasts, push };
32
+ }
33
+ export function ToastStack(props) {
34
+ if (props.toasts.length === 0)
35
+ return null;
36
+ return (_jsx(Box, { position: "absolute", bottom: 0, right: 0, flexDirection: "column", alignItems: "flex-end", children: props.toasts.map((toast) => (_jsxs(Box, { backgroundColor: theme.bg.elevated, paddingLeft: 1, paddingRight: 1, children: [_jsxs(Text, { color: toastColor(toast.kind), bold: true, children: [toastIcon(toast.kind), " "] }), _jsx(Text, { color: theme.text.primary, children: toast.message })] }, toast.id))) }));
37
+ }