loop-task 1.5.5 → 2.0.1
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 +127 -21
- package/dist/board/App.js +1 -1
- package/dist/board/components/ActionButtons.js +1 -1
- package/dist/board/components/BoardButton.js +1 -1
- package/dist/board/components/ConfirmModal.js +1 -1
- package/dist/board/components/ContextHelpModal.js +1 -1
- package/dist/board/components/CreateForm.js +1 -1
- package/dist/board/components/CreateProjectModal.js +1 -1
- package/dist/board/components/DeleteProjectConfirm.js +1 -1
- package/dist/board/components/DetailView.js +1 -1
- package/dist/board/components/EditProjectModal.js +1 -1
- package/dist/board/components/FilterBar.js +1 -1
- package/dist/board/components/Footer.js +1 -1
- package/dist/board/components/Header.js +1 -1
- package/dist/board/components/HelpModal.js +1 -1
- package/dist/board/components/Inspector.js +1 -1
- package/dist/board/components/LogModal.js +1 -1
- package/dist/board/components/Navigator.js +1 -1
- package/dist/board/components/ProjectsModal.js +1 -1
- package/dist/board/components/ProjectsPage.js +1 -1
- package/dist/board/components/RunHistory.js +1 -1
- package/dist/board/components/SearchBox.js +1 -1
- package/dist/board/components/SearchSelect.js +1 -1
- package/dist/board/components/TaskBrowser.js +1 -1
- package/dist/board/components/TaskFilterBar.js +1 -1
- package/dist/board/components/TaskForm.js +1 -1
- package/dist/board/components/Timeline.js +1 -1
- package/dist/board/focus-context.js +1 -1
- package/dist/board/format.js +15 -6
- package/dist/board/index.js +1 -1
- package/dist/board/toast.js +1 -1
- package/dist/cli/import-validator.js +151 -0
- package/dist/cli/import-writer.js +77 -0
- package/dist/cli.js +111 -17
- package/dist/config/constants.js +52 -0
- package/dist/daemon/file-watcher.js +171 -0
- package/dist/daemon/http-server.js +605 -0
- package/dist/daemon/index.js +36 -6
- package/dist/daemon/manager.js +94 -13
- package/dist/daemon/projects.js +10 -0
- package/dist/daemon/server.js +21 -0
- package/dist/daemon/task-manager.js +7 -0
- package/dist/esm-loader.js +4 -1
- package/dist/hooks/useLoopFormValidation.js +140 -0
- package/dist/i18n/en.json +237 -17
- package/dist/shared/fs-utils.js +34 -1
- package/dist/tui/app.js +675 -151
- package/dist/tui/commands.js +114 -0
- package/dist/tui/components/ActionButtons.js +49 -0
- package/dist/tui/components/Button.js +18 -0
- package/dist/tui/components/ChainEditor.js +112 -0
- package/dist/tui/components/CommandBuilderField.js +143 -0
- package/dist/tui/components/CommandEditorModal.js +159 -0
- package/dist/tui/components/CommandInput.js +303 -0
- package/dist/tui/components/CommandsBrowserModal.js +77 -0
- package/dist/tui/components/ConfirmModal.js +9 -0
- package/dist/tui/components/ContextHelpModal.js +8 -0
- package/dist/tui/components/CreateForm.js +223 -0
- package/dist/tui/components/DebugPanel.js +8 -0
- package/dist/tui/components/ExportModal.js +37 -0
- package/dist/tui/components/FilterBar.js +13 -0
- package/dist/tui/components/FocusableButton.js +16 -0
- package/dist/tui/components/FocusableInput.js +69 -0
- package/dist/tui/components/FocusableList.js +46 -0
- package/dist/tui/components/FocusableSearchSelect.js +82 -0
- package/dist/tui/components/Footer.js +42 -0
- package/dist/tui/components/Header.js +40 -0
- package/dist/tui/components/HelpGuideModal.js +21 -0
- package/dist/tui/components/HelpModal.js +59 -0
- package/dist/tui/components/InlineCommandEditor.js +143 -0
- package/dist/tui/components/Inspector.js +31 -0
- package/dist/tui/components/LeftPanel.js +33 -0
- package/dist/tui/components/LogModal.js +121 -0
- package/dist/tui/components/Modal.js +11 -0
- package/dist/tui/components/Navigator.js +68 -0
- package/dist/tui/components/PatchEditForm.js +78 -0
- package/dist/tui/components/ProjectForm.js +64 -0
- package/dist/tui/components/ProjectsModal.js +40 -0
- package/dist/tui/components/ProjectsPage.js +99 -0
- package/dist/tui/components/RightPanel.js +34 -0
- package/dist/tui/components/RunHistory.js +108 -0
- package/dist/tui/components/SearchSelect.js +67 -0
- package/dist/tui/components/SelectModal.js +76 -0
- package/dist/tui/components/TabBar.js +20 -0
- package/dist/tui/components/TaskBrowser.js +96 -0
- package/dist/tui/components/TaskFilterBar.js +9 -0
- package/dist/tui/components/TaskForm.js +134 -0
- package/dist/tui/components/TaskPickerModal.js +60 -0
- package/dist/tui/components/Toast.js +37 -0
- package/dist/tui/components/WelcomeScreen.js +70 -0
- package/dist/tui/components/WizardForm.js +178 -0
- package/dist/tui/daemon.js +142 -0
- package/dist/tui/format.js +111 -0
- package/dist/tui/hooks/useBreakpoint.js +7 -0
- package/dist/tui/hooks/useHoverState.js +11 -0
- package/dist/tui/hooks/useLogStream.js +32 -0
- package/dist/tui/hooks/useLoopPolling.js +28 -0
- package/dist/tui/index.js +24 -0
- package/dist/tui/router.js +16 -0
- package/dist/tui/state.js +175 -0
- package/dist/tui/theme.js +96 -0
- package/dist/tui/types.js +1 -0
- package/dist/tui/utils/paste.js +10 -0
- package/dist/tui/utils/validation.js +56 -0
- 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
|
+
}
|