loop-task 1.4.7 → 1.5.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 +115 -0
- package/dist/board/App.js +57 -18
- package/dist/board/components/ActionButtons.js +12 -12
- package/dist/board/components/ContextHelpModal.js +22 -0
- package/dist/board/components/CreateForm.js +27 -75
- package/dist/board/components/CreateProjectModal.js +8 -17
- package/dist/board/components/EditProjectModal.js +8 -17
- package/dist/board/components/ProjectsPage.js +43 -36
- package/dist/board/components/SearchSelect.js +99 -0
- package/dist/board/components/TaskForm.js +14 -38
- package/dist/board/focus-context.js +98 -0
- package/dist/board/hooks/useBoardKeybindings.js +5 -47
- package/dist/board/hooks/useTabNav.js +60 -0
- package/dist/board/hooks/useTaskKeybindings.js +8 -30
- package/dist/config/constants.js +2 -0
- package/dist/core/command-runner.js +33 -3
- package/dist/core/context-parser.js +55 -0
- package/dist/core/loop-controller.js +20 -2
- package/dist/core/template.js +3 -0
- package/dist/i18n/en.json +8 -1
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
+
import { useTabNav } from "../hooks/useTabNav.js";
|
|
3
4
|
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
4
5
|
import { t } from "../../i18n/index.js";
|
|
5
6
|
import { updateProject } from "../daemon.js";
|
|
@@ -14,15 +15,8 @@ export function EditProjectModal(props) {
|
|
|
14
15
|
});
|
|
15
16
|
const [error, setError] = useState("");
|
|
16
17
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
17
|
-
const
|
|
18
|
+
const { focusedItem: focusField, setFocusIndex } = useTabNav(["name", "color", "save", "cancel"]);
|
|
18
19
|
useKeyboard((key) => {
|
|
19
|
-
if (key.name === "tab") {
|
|
20
|
-
const order = ["name", "color", "save", "cancel"];
|
|
21
|
-
const idx = order.indexOf(focusField);
|
|
22
|
-
const next = key.shift ? order[(idx - 1 + order.length) % order.length] : order[(idx + 1) % order.length];
|
|
23
|
-
setFocusField(next ?? "name");
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
20
|
if (key.name === "escape") {
|
|
27
21
|
onCancel();
|
|
28
22
|
return;
|
|
@@ -36,14 +30,11 @@ export function EditProjectModal(props) {
|
|
|
36
30
|
return;
|
|
37
31
|
}
|
|
38
32
|
if (focusField === "color") {
|
|
39
|
-
if (key.name === "left") {
|
|
40
|
-
const idx = PROJECT_COLOR_KEYS.indexOf(selectedColorKey);
|
|
41
|
-
setSelectedColorKey(PROJECT_COLOR_KEYS[(idx - 1 + PROJECT_COLOR_KEYS.length) % PROJECT_COLOR_KEYS.length] ?? selectedColorKey);
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
if (key.name === "right") {
|
|
33
|
+
if (key.name === "left" || key.name === "right") {
|
|
45
34
|
const idx = PROJECT_COLOR_KEYS.indexOf(selectedColorKey);
|
|
46
|
-
|
|
35
|
+
const dir = key.name === "left" ? -1 : 1;
|
|
36
|
+
setSelectedColorKey(PROJECT_COLOR_KEYS[(idx + dir + PROJECT_COLOR_KEYS.length) % PROJECT_COLOR_KEYS.length] ?? selectedColorKey);
|
|
37
|
+
key.preventDefault();
|
|
47
38
|
return;
|
|
48
39
|
}
|
|
49
40
|
}
|
|
@@ -83,9 +74,9 @@ export function EditProjectModal(props) {
|
|
|
83
74
|
padding: 1,
|
|
84
75
|
minWidth: Math.min(44, width - 4),
|
|
85
76
|
backgroundColor: "#111827",
|
|
86
|
-
}, children: [_jsx("text", { fg: focusField === "name" ? "#38bdf8" : "#e5e7eb", children: t("project.labelName") }), _jsx("text", { fg: "#6b7280", children: t("project.hintName") }), _jsx("box", { border: true, borderColor: focusField === "name" ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b", marginBottom: 1 }, children: _jsx("input", { focused: focusField === "name", value: name, placeholder: project.name, onInput: (value) => setName(value)
|
|
77
|
+
}, children: [_jsx("text", { fg: focusField === "name" ? "#38bdf8" : "#e5e7eb", children: t("project.labelName") }), _jsx("text", { fg: "#6b7280", children: t("project.hintName") }), _jsx("box", { border: true, borderColor: focusField === "name" ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b", marginBottom: 1 }, children: _jsx("input", { focused: focusField === "name", value: name, placeholder: project.name, onInput: (value) => setName(value) }) }), _jsx("text", { fg: focusField === "color" ? "#38bdf8" : "#e5e7eb", children: t("project.labelColor") }), _jsx("text", { fg: "#6b7280", children: t("project.hintColor") }), _jsx("box", { border: true, borderColor: focusField === "color" ? "#38bdf8" : undefined, style: { height: 3, flexDirection: "row", backgroundColor: "#0b0b0b", alignItems: "center", marginBottom: 1 }, children: PROJECT_COLOR_KEYS.map((colorKey) => {
|
|
87
78
|
const isActive = selectedColorKey === colorKey;
|
|
88
|
-
return (_jsx("box", { onMouseDown: () => {
|
|
79
|
+
return (_jsx("box", { onMouseDown: () => { setFocusIndex(1); setSelectedColorKey(colorKey); }, style: {
|
|
89
80
|
backgroundColor: isActive ? "#1e3a8a" : undefined,
|
|
90
81
|
paddingLeft: 1,
|
|
91
82
|
paddingRight: 1,
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
|
|
2
|
-
import { useState } from "react";
|
|
2
|
+
import { useState, useEffect, useRef } from "react";
|
|
3
3
|
import { useKeyboard } from "@opentui/react";
|
|
4
4
|
import { t } from "../../i18n/index.js";
|
|
5
5
|
import { useHoverState } from "../hooks/useHoverState.js";
|
|
6
|
+
import { useTabNav } from "../hooks/useTabNav.js";
|
|
6
7
|
import { HOVER_BG, ENTITY_COLORS } from "../../config/constants.js";
|
|
7
8
|
import { CreateProjectModal } from "./CreateProjectModal.js";
|
|
8
9
|
import { EditProjectModal } from "./EditProjectModal.js";
|
|
9
10
|
import { DeleteProjectConfirm } from "./DeleteProjectConfirm.js";
|
|
10
|
-
const PROJECT_ACTION_COUNT = 2;
|
|
11
11
|
function ProjectActionButton(props) {
|
|
12
12
|
const { isHovered, hoverProps } = useHoverState();
|
|
13
13
|
const bg = props.selected ? "#1e3a8a" : isHovered ? HOVER_BG : undefined;
|
|
@@ -15,11 +15,25 @@ function ProjectActionButton(props) {
|
|
|
15
15
|
return (_jsx("box", { onMouseDown: props.onMouseDown, style: { backgroundColor: bg, paddingLeft: 1, paddingRight: 1, marginRight: 1 }, ...hoverProps, children: _jsx("text", { fg: fg, children: _jsx("strong", { children: props.label }) }) }));
|
|
16
16
|
}
|
|
17
17
|
export function ProjectsPage(props) {
|
|
18
|
-
const { projects, loops, onClose, onRefresh, onOpenCreate, onEnterHeader } = props;
|
|
18
|
+
const { projects, loops, headerFocused, onClose, onRefresh, onOpenCreate, onEnterHeader } = props;
|
|
19
19
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
20
20
|
const [subModal, setSubModal] = useState("none");
|
|
21
|
-
const
|
|
22
|
-
const
|
|
21
|
+
const navItems = ["list", "edit", "delete"];
|
|
22
|
+
const { setFocusIndex, focusedItem, isFocused } = useTabNav(navItems, {
|
|
23
|
+
onCycleOut: (dir) => {
|
|
24
|
+
if (dir === "right")
|
|
25
|
+
onEnterHeader?.("right");
|
|
26
|
+
else
|
|
27
|
+
onEnterHeader?.("left");
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const prevHeaderFocused = useRef(headerFocused);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (prevHeaderFocused.current && !headerFocused) {
|
|
33
|
+
setFocusIndex(0);
|
|
34
|
+
}
|
|
35
|
+
prevHeaderFocused.current = headerFocused;
|
|
36
|
+
}, [headerFocused]);
|
|
23
37
|
// Expose create trigger to parent via callback
|
|
24
38
|
useState(() => { onOpenCreate?.(() => setSubModal("create")); });
|
|
25
39
|
const clampedIndex = Math.min(selectedIndex, Math.max(0, projects.length - 1));
|
|
@@ -36,44 +50,37 @@ export function ProjectsPage(props) {
|
|
|
36
50
|
useKeyboard((key) => {
|
|
37
51
|
if (subModal !== "none")
|
|
38
52
|
return;
|
|
39
|
-
if (
|
|
40
|
-
if (focusedPanel === "list")
|
|
41
|
-
setSelectedIndex((i) => Math.max(0, i - 1));
|
|
42
|
-
else
|
|
43
|
-
setSelectedAction((a) => Math.max(0, a - 1));
|
|
44
|
-
key.preventDefault();
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
if (key.name === "down") {
|
|
48
|
-
if (focusedPanel === "list")
|
|
49
|
-
setSelectedIndex((i) => Math.min(projects.length - 1, i + 1));
|
|
50
|
-
else
|
|
51
|
-
setSelectedAction((a) => Math.min(PROJECT_ACTION_COUNT - 1, a + 1));
|
|
52
|
-
key.preventDefault();
|
|
53
|
+
if (headerFocused)
|
|
53
54
|
return;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const direction = key.shift ? "left" : "right";
|
|
57
|
-
if (focusedPanel === "list" && direction === "left") {
|
|
58
|
-
onEnterHeader?.("left");
|
|
55
|
+
if (key.name === "up" || key.name === "down") {
|
|
56
|
+
if (focusedItem !== "list") {
|
|
59
57
|
key.preventDefault();
|
|
60
58
|
return;
|
|
61
59
|
}
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
key.preventDefault();
|
|
65
|
-
return;
|
|
60
|
+
if (key.name === "up") {
|
|
61
|
+
setSelectedIndex((i) => Math.max(0, i - 1));
|
|
66
62
|
}
|
|
67
|
-
|
|
63
|
+
else {
|
|
64
|
+
setSelectedIndex((i) => Math.min(projects.length - 1, i + 1));
|
|
65
|
+
}
|
|
66
|
+
key.preventDefault();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (key.name === "left" || key.name === "right") {
|
|
68
70
|
key.preventDefault();
|
|
69
71
|
return;
|
|
70
72
|
}
|
|
71
73
|
if (key.name === "return" || key.name === "enter") {
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
+
if (focusedItem === "list") {
|
|
75
|
+
setFocusIndex(1);
|
|
74
76
|
}
|
|
75
|
-
else {
|
|
76
|
-
|
|
77
|
+
else if (focusedItem === "edit") {
|
|
78
|
+
if (selectedProject && !selectedProject.isSystem)
|
|
79
|
+
setSubModal("edit");
|
|
80
|
+
}
|
|
81
|
+
else if (focusedItem === "delete") {
|
|
82
|
+
if (selectedProject && !selectedProject.isSystem)
|
|
83
|
+
setSubModal("delete");
|
|
77
84
|
}
|
|
78
85
|
key.preventDefault();
|
|
79
86
|
return;
|
|
@@ -102,12 +109,12 @@ export function ProjectsPage(props) {
|
|
|
102
109
|
{ key: "edit", label: t("project.editProjectLabel") },
|
|
103
110
|
{ key: "delete", label: t("project.deleteProjectLabel") },
|
|
104
111
|
];
|
|
105
|
-
return (_jsxs("box", { style: { flexDirection: "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsxs("box", { title: t("project.projectsTitle"), border: true, borderColor:
|
|
112
|
+
return (_jsxs("box", { style: { flexDirection: "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsxs("box", { title: t("project.projectsTitle"), border: true, borderColor: focusedItem === "list" ? ENTITY_COLORS.project : "#1e3a2a", style: { width: "40%", flexDirection: "column", backgroundColor: "#0b0b0b", flexShrink: 0 }, children: [_jsxs("text", { fg: "#9ca3af", children: [t("project.keyNewHint"), " | ", t("project.keyEditHint"), " | ", t("project.keyDeleteHint")] }), projects.map((project, index) => {
|
|
106
113
|
const isSelected = index === clampedIndex;
|
|
107
114
|
const bg = isSelected ? "#1e3a8a" : undefined;
|
|
108
115
|
const count = loopCount(project.id);
|
|
109
|
-
return (_jsx("box", { backgroundColor: bg, onMouseDown: () => { setSelectedIndex(index);
|
|
110
|
-
})] }), _jsxs("box", { style: { flexGrow: 1, flexDirection: "column", backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx("box", { title: t("board.inspectorTitle"), border: true, style: { flexGrow: 1, flexDirection: "column", backgroundColor: "#0b0b0b", padding: 1 }, children: selectedProject ? (_jsxs(_Fragment, { children: [_jsxs("text", { children: [_jsx("span", { fg: selectedProject.color, children: "\u25CF" }), ` `, _jsx("strong", { children: selectedProject.name })] }), _jsx("text", { fg: "#6b7280", children: t("project.loopCount", { count: String(loopCount(selectedProject.id)) }) }), selectedProject.isSystem ? (_jsx("text", { fg: "#9ca3af", children: t("project.systemLabel") })) : null] })) : (_jsx("text", { fg: "#9ca3af", children: t("project.noLoops") })) }), _jsx("box", { border: true, borderColor:
|
|
116
|
+
return (_jsx("box", { backgroundColor: bg, onMouseDown: () => { setSelectedIndex(index); setFocusIndex(0); }, style: { height: 1 }, children: _jsxs("text", { children: [isSelected ? "› " : " ", _jsx("span", { fg: project.color, children: "\u25CF" }), ` ${project.name} `, _jsx("span", { fg: "#6b7280", children: `(${count})` })] }) }, project.id));
|
|
117
|
+
})] }), _jsxs("box", { style: { flexGrow: 1, flexDirection: "column", backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx("box", { title: t("board.inspectorTitle"), border: true, style: { flexGrow: 1, flexDirection: "column", backgroundColor: "#0b0b0b", padding: 1 }, children: selectedProject ? (_jsxs(_Fragment, { children: [_jsxs("text", { children: [_jsx("span", { fg: selectedProject.color, children: "\u25CF" }), ` `, _jsx("strong", { children: selectedProject.name })] }), _jsx("text", { fg: "#6b7280", children: t("project.loopCount", { count: String(loopCount(selectedProject.id)) }) }), selectedProject.isSystem ? (_jsx("text", { fg: "#9ca3af", children: t("project.systemLabel") })) : null] })) : (_jsx("text", { fg: "#9ca3af", children: t("project.noLoops") })) }), _jsx("box", { border: true, borderColor: focusedItem === "edit" || focusedItem === "delete" ? ENTITY_COLORS.project : undefined, style: { flexDirection: "row", height: 3, flexShrink: 0, paddingLeft: 1, backgroundColor: "#0b0b0b", alignItems: "center", justifyContent: selectedProject?.isSystem ? "center" : "flex-start" }, children: selectedProject && !selectedProject.isSystem ? (actions.map((action, i) => (_jsx(ProjectActionButton, { label: action.label, selected: isFocused(action.key), onMouseDown: () => { setFocusIndex(i + 1); runAction(i); } }, action.key)))) : (_jsx("text", { fg: "#9ca3af", children: t("project.systemLabel") })) })] }), subModal === "create" ? (_jsx(CreateProjectModal, { onDone: async (project) => {
|
|
111
118
|
setSubModal("none");
|
|
112
119
|
await onRefresh();
|
|
113
120
|
const newIndex = projects.findIndex((p) => p.id === project.id);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
2
|
+
import { useRef, useState, useMemo } from "react";
|
|
3
|
+
import { useKeyboard } from "@opentui/react";
|
|
4
|
+
import { t } from "../../i18n/index.js";
|
|
5
|
+
import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
|
|
6
|
+
import { SEARCH_SELECT_HEIGHT } from "../../config/constants.js";
|
|
7
|
+
export function SearchSelect(props) {
|
|
8
|
+
const { options, value, onChange, focused, height } = props;
|
|
9
|
+
const placeholder = props.placeholder ?? t("board.searchSelectPlaceholder");
|
|
10
|
+
const maxHeight = height ?? SEARCH_SELECT_HEIGHT;
|
|
11
|
+
const inputRef = useRef(null);
|
|
12
|
+
const [filter, setFilter] = useState("");
|
|
13
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
14
|
+
useInputShortcuts(() => focused ? inputRef.current : null);
|
|
15
|
+
const filtered = useMemo(() => {
|
|
16
|
+
if (!filter)
|
|
17
|
+
return options;
|
|
18
|
+
const q = filter.toLowerCase();
|
|
19
|
+
return options.filter((o) => o.name.toLowerCase().includes(q) || o.value.toLowerCase().includes(q));
|
|
20
|
+
}, [options, filter]);
|
|
21
|
+
const currentIdx = filtered.findIndex((o) => o.value === value);
|
|
22
|
+
const safeSelected = currentIdx >= 0 ? currentIdx : selectedIndex;
|
|
23
|
+
const clampedSelected = Math.min(safeSelected, Math.max(0, filtered.length - 1));
|
|
24
|
+
useKeyboard((key) => {
|
|
25
|
+
if (!focused)
|
|
26
|
+
return;
|
|
27
|
+
const name = key.name;
|
|
28
|
+
if (name === "up" || name === "k") {
|
|
29
|
+
if (filtered.length > 0) {
|
|
30
|
+
setSelectedIndex((i) => i <= 0 ? filtered.length - 1 : i - 1);
|
|
31
|
+
}
|
|
32
|
+
key.preventDefault();
|
|
33
|
+
key.stopPropagation();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (name === "down" || name === "j") {
|
|
37
|
+
if (filtered.length > 0) {
|
|
38
|
+
setSelectedIndex((i) => i >= filtered.length - 1 ? 0 : i + 1);
|
|
39
|
+
}
|
|
40
|
+
key.preventDefault();
|
|
41
|
+
key.stopPropagation();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (name === "return" || name === "enter") {
|
|
45
|
+
const option = filtered[clampedSelected];
|
|
46
|
+
if (option) {
|
|
47
|
+
onChange(option.value);
|
|
48
|
+
}
|
|
49
|
+
key.preventDefault();
|
|
50
|
+
key.stopPropagation();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (name === "escape") {
|
|
54
|
+
if (filter) {
|
|
55
|
+
setFilter("");
|
|
56
|
+
setSelectedIndex(0);
|
|
57
|
+
}
|
|
58
|
+
key.preventDefault();
|
|
59
|
+
key.stopPropagation();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (name === "backspace") {
|
|
63
|
+
setFilter((f) => f.slice(0, -1));
|
|
64
|
+
setSelectedIndex(0);
|
|
65
|
+
key.preventDefault();
|
|
66
|
+
key.stopPropagation();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (key.ctrl)
|
|
70
|
+
return;
|
|
71
|
+
if (name && name.length === 1 && /[a-z0-9 _\-./]/i.test(name)) {
|
|
72
|
+
setFilter((f) => f + name);
|
|
73
|
+
setSelectedIndex(0);
|
|
74
|
+
key.preventDefault();
|
|
75
|
+
key.stopPropagation();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (key.sequence && key.sequence.length === 1 && /[a-z0-9 _\-./]/i.test(key.sequence)) {
|
|
79
|
+
setFilter((f) => f + key.sequence);
|
|
80
|
+
setSelectedIndex(0);
|
|
81
|
+
key.preventDefault();
|
|
82
|
+
key.stopPropagation();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
const listHeight = Math.min(filtered.length, maxHeight);
|
|
87
|
+
return (_jsxs("box", { border: true, borderColor: focused ? "#38bdf8" : undefined, style: { flexDirection: "column", backgroundColor: "#0b0b0b" }, children: [_jsxs("box", { style: { height: 3, flexDirection: "row", alignItems: "center" }, children: [_jsx("text", { fg: "#6b7280", children: " / " }), _jsx("input", { ref: inputRef, focused: focused, value: filter, placeholder: placeholder, onInput: (v) => {
|
|
88
|
+
setFilter(v);
|
|
89
|
+
setSelectedIndex(0);
|
|
90
|
+
} })] }), _jsx("box", { style: { flexDirection: "column", height: listHeight }, children: filtered.map((option, i) => {
|
|
91
|
+
const isSelected = i === clampedSelected;
|
|
92
|
+
const isActive = option.value === value;
|
|
93
|
+
const prefix = isSelected ? "› " : " ";
|
|
94
|
+
const colorIndicator = option.color ? `\u25cf ` : "";
|
|
95
|
+
const bg = isSelected ? "#1e3a8a" : undefined;
|
|
96
|
+
const fg = isSelected ? "#ffffff" : isActive ? "#38bdf8" : "#9ca3af";
|
|
97
|
+
return (_jsx("box", { style: { flexDirection: "row", backgroundColor: bg }, children: _jsx("text", { fg: fg, children: `${prefix}${colorIndicator}${option.name}` }) }, option.value));
|
|
98
|
+
}) })] }));
|
|
99
|
+
}
|
|
@@ -6,7 +6,9 @@ import { t } from "../../i18n/index.js";
|
|
|
6
6
|
import { createTask, updateTask, listTasks } from "../daemon.js";
|
|
7
7
|
import { useHoverState } from "../hooks/useHoverState.js";
|
|
8
8
|
import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
|
|
9
|
+
import { useTabNav } from "../hooks/useTabNav.js";
|
|
9
10
|
import { HOVER_BG } from "../../config/constants.js";
|
|
11
|
+
import { SearchSelect } from "./SearchSelect.js";
|
|
10
12
|
const taskFields = ["name", "command", "onSuccessTaskId", "onFailureTaskId"];
|
|
11
13
|
function taskInitialValues(task) {
|
|
12
14
|
if (!task) {
|
|
@@ -22,20 +24,20 @@ function taskInitialValues(task) {
|
|
|
22
24
|
export function TaskForm(props) {
|
|
23
25
|
const [values, setValues] = useState(taskInitialValues(props.editTask));
|
|
24
26
|
const valuesRef = useRef(values);
|
|
25
|
-
const [focusIndex, setFocusIndex] = useState(0);
|
|
26
27
|
const [error, setError] = useState("");
|
|
27
28
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
28
29
|
const [allTasks, setAllTasks] = useState([]);
|
|
29
30
|
const inputRef = useRef(null);
|
|
30
31
|
const { width: termWidth } = useTerminalDimensions();
|
|
31
32
|
const btnWidth = Math.max(10, Math.min(14, Math.floor(termWidth / 6)));
|
|
33
|
+
const navItems = [...taskFields, "save", "cancel"];
|
|
34
|
+
const { setFocusIndex, focusedItem, isFocused } = useTabNav(navItems);
|
|
32
35
|
useInputShortcuts(() => {
|
|
33
|
-
if (
|
|
34
|
-
return
|
|
35
|
-
|
|
36
|
+
if (focusedItem != null && focusedItem !== "save" && focusedItem !== "cancel") {
|
|
37
|
+
return inputRef.current;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
36
40
|
});
|
|
37
|
-
const saveIndex = taskFields.length;
|
|
38
|
-
const cancelIndex = taskFields.length + 1;
|
|
39
41
|
function updateValues(next) {
|
|
40
42
|
valuesRef.current = next;
|
|
41
43
|
setValues(next);
|
|
@@ -44,36 +46,12 @@ export function TaskForm(props) {
|
|
|
44
46
|
void listTasks().then(setAllTasks).catch(() => { });
|
|
45
47
|
});
|
|
46
48
|
useKeyboard((key) => {
|
|
47
|
-
if (key.name === "tab") {
|
|
48
|
-
setFocusIndex((i) => {
|
|
49
|
-
const next = key.shift ? i - 1 : i + 1;
|
|
50
|
-
if (next < 0)
|
|
51
|
-
return cancelIndex;
|
|
52
|
-
if (next > cancelIndex)
|
|
53
|
-
return 0;
|
|
54
|
-
return next;
|
|
55
|
-
});
|
|
56
|
-
key.preventDefault();
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
if (key.name === "up" || key.name === "down") {
|
|
60
|
-
setFocusIndex((i) => {
|
|
61
|
-
const next = key.name === "up" ? i - 1 : i + 1;
|
|
62
|
-
if (next < 0)
|
|
63
|
-
return cancelIndex;
|
|
64
|
-
if (next > cancelIndex)
|
|
65
|
-
return 0;
|
|
66
|
-
return next;
|
|
67
|
-
});
|
|
68
|
-
key.preventDefault();
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
49
|
if (key.name === "return" || key.name === "enter") {
|
|
72
|
-
if (
|
|
50
|
+
if (focusedItem === "save") {
|
|
73
51
|
void submit(valuesRef.current);
|
|
74
52
|
key.preventDefault();
|
|
75
53
|
}
|
|
76
|
-
else if (
|
|
54
|
+
else if (focusedItem === "cancel") {
|
|
77
55
|
props.onCancel();
|
|
78
56
|
key.preventDefault();
|
|
79
57
|
}
|
|
@@ -147,16 +125,14 @@ export function TaskForm(props) {
|
|
|
147
125
|
return (_jsxs("box", { title: title, border: true, style: { flexDirection: "column", flexGrow: 1, padding: 1, backgroundColor: "#0b0b0b" }, children: [_jsx("box", { style: { flexDirection: "column", flexGrow: 1 }, children: Array.from({ length: Math.ceil(taskFields.length / 2) }, (_, row) => {
|
|
148
126
|
const leftField = taskFields[row * 2];
|
|
149
127
|
const rightField = row * 2 + 1 < taskFields.length ? taskFields[row * 2 + 1] : null;
|
|
150
|
-
return (_jsxs("box", { style: { flexDirection: "row", marginBottom: 1 }, children: [_jsx(TaskFormRow, { field: leftField, index: row * 2,
|
|
151
|
-
}) }), _jsxs("box", { style: { flexDirection: "row", height: 3, marginBottom: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(HoverButton, { label: isSubmitting ? t("board.saving") : props.mode === "edit" ? t("board.save") : t("board.create"), onMouseDown: () => void submit(valuesRef.current), selected:
|
|
128
|
+
return (_jsxs("box", { style: { flexDirection: "row", marginBottom: 1 }, children: [_jsx(TaskFormRow, { field: leftField, index: row * 2, focused: isFocused(leftField), values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, chainOptions: chainOptions, inputRef: inputRef, style: { width: "50%", paddingRight: 1 } }), rightField ? (_jsx(TaskFormRow, { field: rightField, index: row * 2 + 1, focused: isFocused(rightField), values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, chainOptions: chainOptions, inputRef: inputRef, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
|
|
129
|
+
}) }), _jsxs("box", { style: { flexDirection: "row", height: 3, marginBottom: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(HoverButton, { label: isSubmitting ? t("board.saving") : props.mode === "edit" ? t("board.save") : t("board.create"), onMouseDown: () => void submit(valuesRef.current), selected: isFocused("save"), width: btnWidth, marginRight: 1 }), _jsx(HoverButton, { label: t("board.cancel"), onMouseDown: props.onCancel, selected: isFocused("cancel"), width: btnWidth })] }), _jsx("text", { fg: "#9ca3af", children: t("board.formNav") }), error ? _jsx("text", { fg: "#f87171", children: error }) : null] }));
|
|
152
130
|
}
|
|
153
131
|
function TaskFormRow(props) {
|
|
154
|
-
const { field, index,
|
|
155
|
-
const isFocused = focusIndex === index;
|
|
132
|
+
const { field, index, focused, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, chainOptions, inputRef, style } = props;
|
|
156
133
|
const isSelect = field === "onSuccessTaskId" || field === "onFailureTaskId";
|
|
157
134
|
const selectOpts = isSelect ? chainOptions : [];
|
|
158
|
-
|
|
159
|
-
return (_jsxs("box", { style: { flexDirection: "column", ...style }, children: [_jsx("text", { fg: isFocused ? "#38bdf8" : "#e5e7eb", children: labels[field] }), _jsx("text", { fg: "#6b7280", children: hints[field] }), isSelect ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: Math.min(selectOpts.length + 2, 6), backgroundColor: "#0b0b0b" }, children: _jsx("select", { focused: isFocused, options: selectOpts, selectedIndex: Math.max(0, selectedIdx), showDescription: false, style: { flexGrow: 1 }, onChange: (_i, option) => updateValues({ ...valuesRef.current, [field]: option?.value ?? "" }) }) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { ref: inputRef, focused: isFocused, value: values[field], placeholder: field === "command" ? t("board.exampleCommand") : "", onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
|
|
135
|
+
return (_jsxs("box", { style: { flexDirection: "column", ...style }, children: [_jsx("text", { fg: focused ? "#38bdf8" : "#e5e7eb", children: labels[field] }), _jsx("text", { fg: "#6b7280", children: hints[field] }), isSelect ? (_jsx(SearchSelect, { options: selectOpts, value: values[field], onChange: (v) => updateValues({ ...valuesRef.current, [field]: v }), focused: focused })) : (_jsx("box", { border: true, borderColor: focused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { ref: inputRef, focused: focused, value: values[field], placeholder: field === "command" ? t("board.exampleCommand") : "", onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
|
|
160
136
|
if (index < taskFields.length - 1) {
|
|
161
137
|
setFocusIndex(index + 1);
|
|
162
138
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { jsx as _jsx } from "@opentui/react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useState, useCallback, useRef, useEffect } from "react";
|
|
3
|
+
import { useKeyboard } from "@opentui/react";
|
|
4
|
+
const FocusContext = createContext(null);
|
|
5
|
+
export function useFocusContext() {
|
|
6
|
+
return useContext(FocusContext);
|
|
7
|
+
}
|
|
8
|
+
export function FocusProvider(props) {
|
|
9
|
+
const itemsRef = useRef(new Map());
|
|
10
|
+
const [focusedOrder, setFocusedOrderState] = useState(null);
|
|
11
|
+
const [active, setActive] = useState(true);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (focusedOrder === null && itemsRef.current.size > 0) {
|
|
14
|
+
const minOrder = Math.min(...itemsRef.current.keys());
|
|
15
|
+
setFocusedOrderState(minOrder);
|
|
16
|
+
}
|
|
17
|
+
}, [itemsRef.current.size]);
|
|
18
|
+
const registerItem = useCallback((order, id) => {
|
|
19
|
+
itemsRef.current.set(order, { order, id });
|
|
20
|
+
if (focusedOrder === null) {
|
|
21
|
+
setFocusedOrderState(order);
|
|
22
|
+
}
|
|
23
|
+
}, [focusedOrder]);
|
|
24
|
+
const unregisterItem = useCallback((id) => {
|
|
25
|
+
for (const [order, entry] of itemsRef.current) {
|
|
26
|
+
if (entry.id === id) {
|
|
27
|
+
itemsRef.current.delete(order);
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}, []);
|
|
32
|
+
const setFocusedOrder = useCallback((order) => {
|
|
33
|
+
setFocusedOrderState(order);
|
|
34
|
+
}, []);
|
|
35
|
+
const isFocused = useCallback((order) => {
|
|
36
|
+
return focusedOrder === order;
|
|
37
|
+
}, [focusedOrder]);
|
|
38
|
+
const focusNext = useCallback(() => {
|
|
39
|
+
const orders = Array.from(itemsRef.current.keys()).sort((a, b) => a - b);
|
|
40
|
+
if (orders.length === 0)
|
|
41
|
+
return;
|
|
42
|
+
const currentIdx = orders.indexOf(focusedOrder ?? orders[0]);
|
|
43
|
+
const nextIdx = (currentIdx + 1) % orders.length;
|
|
44
|
+
setFocusedOrderState(orders[nextIdx]);
|
|
45
|
+
}, [focusedOrder]);
|
|
46
|
+
const focusPrev = useCallback(() => {
|
|
47
|
+
const orders = Array.from(itemsRef.current.keys()).sort((a, b) => a - b);
|
|
48
|
+
if (orders.length === 0)
|
|
49
|
+
return;
|
|
50
|
+
const currentIdx = orders.indexOf(focusedOrder ?? orders[0]);
|
|
51
|
+
const prevIdx = (currentIdx - 1 + orders.length) % orders.length;
|
|
52
|
+
setFocusedOrderState(orders[prevIdx]);
|
|
53
|
+
}, [focusedOrder]);
|
|
54
|
+
useKeyboard((key) => {
|
|
55
|
+
if (!active)
|
|
56
|
+
return;
|
|
57
|
+
if (key.name === "tab") {
|
|
58
|
+
if (key.shift) {
|
|
59
|
+
focusPrev();
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
focusNext();
|
|
63
|
+
}
|
|
64
|
+
key.preventDefault();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
const value = {
|
|
68
|
+
registerItem,
|
|
69
|
+
unregisterItem,
|
|
70
|
+
focusedOrder,
|
|
71
|
+
setFocusedOrder,
|
|
72
|
+
isFocused,
|
|
73
|
+
focusNext,
|
|
74
|
+
focusPrev,
|
|
75
|
+
active,
|
|
76
|
+
};
|
|
77
|
+
return _jsx(FocusContext.Provider, { value: value, children: props.children });
|
|
78
|
+
}
|
|
79
|
+
export function useFocusable(order, id) {
|
|
80
|
+
const ctx = useContext(FocusContext);
|
|
81
|
+
const registered = useRef(false);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!ctx)
|
|
84
|
+
return;
|
|
85
|
+
ctx.registerItem(order, id);
|
|
86
|
+
registered.current = true;
|
|
87
|
+
return () => {
|
|
88
|
+
ctx.unregisterItem(id);
|
|
89
|
+
};
|
|
90
|
+
}, [ctx, order, id]);
|
|
91
|
+
if (!ctx) {
|
|
92
|
+
return { isFocused: false, setFocused: () => { } };
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
isFocused: ctx.isFocused(order),
|
|
96
|
+
setFocused: () => ctx.setFocusedOrder(order),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -118,14 +118,14 @@ const panelHandlers = {
|
|
|
118
118
|
},
|
|
119
119
|
"header-tasks": (key, p) => {
|
|
120
120
|
if (key === "return" || key === "enter") {
|
|
121
|
-
p.
|
|
121
|
+
p.onViewProjects?.();
|
|
122
122
|
return true;
|
|
123
123
|
}
|
|
124
124
|
return false;
|
|
125
125
|
},
|
|
126
126
|
"header-projects": (key, p) => {
|
|
127
127
|
if (key === "return" || key === "enter") {
|
|
128
|
-
p.
|
|
128
|
+
p.onViewTasks?.();
|
|
129
129
|
return true;
|
|
130
130
|
}
|
|
131
131
|
return false;
|
|
@@ -250,20 +250,6 @@ export function useBoardKeybindings(params) {
|
|
|
250
250
|
key.preventDefault();
|
|
251
251
|
return;
|
|
252
252
|
}
|
|
253
|
-
if (name === "tab" && view !== "board") {
|
|
254
|
-
const HEADER_PANELS = ["header-tasks", "header-projects", "header-new"];
|
|
255
|
-
const isHeader = HEADER_PANELS.includes(focusedPanel);
|
|
256
|
-
const direction = key.shift ? "left" : "right";
|
|
257
|
-
if (isHeader) {
|
|
258
|
-
const idx = HEADER_PANELS.indexOf(focusedPanel);
|
|
259
|
-
const nextIdx = direction === "right"
|
|
260
|
-
? (idx + 1) % HEADER_PANELS.length
|
|
261
|
-
: (idx - 1 + HEADER_PANELS.length) % HEADER_PANELS.length;
|
|
262
|
-
setFocusedPanel(HEADER_PANELS[nextIdx]);
|
|
263
|
-
key.preventDefault();
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
253
|
if (view !== "board" && (name === "return" || name === "enter")) {
|
|
268
254
|
if (view === "task-list") {
|
|
269
255
|
if (focusedPanel === "header-tasks") {
|
|
@@ -294,7 +280,7 @@ export function useBoardKeybindings(params) {
|
|
|
294
280
|
return;
|
|
295
281
|
}
|
|
296
282
|
if (focusedPanel === "header-new") {
|
|
297
|
-
|
|
283
|
+
onAddLoop?.();
|
|
298
284
|
key.preventDefault();
|
|
299
285
|
return;
|
|
300
286
|
}
|
|
@@ -319,42 +305,14 @@ export function useBoardKeybindings(params) {
|
|
|
319
305
|
}
|
|
320
306
|
if (view !== "board")
|
|
321
307
|
return;
|
|
322
|
-
if (name === "tab") {
|
|
323
|
-
const direction = key.shift ? "left" : "right";
|
|
324
|
-
if (focusedPanel === "actions") {
|
|
325
|
-
const actionCount = selected ? getActionCount(selected.status) : 0;
|
|
326
|
-
if (direction === "left" && selectedAction === 0) {
|
|
327
|
-
setFocusedPanel((p) => nextPanel(p, "left"));
|
|
328
|
-
}
|
|
329
|
-
else if (direction === "right" && selectedAction === actionCount - 1) {
|
|
330
|
-
setFocusedPanel((p) => nextPanel(p, "right"));
|
|
331
|
-
}
|
|
332
|
-
else {
|
|
333
|
-
setSelectedAction((i) => direction === "right"
|
|
334
|
-
? Math.min(actionCount - 1, i + 1)
|
|
335
|
-
: Math.max(0, i - 1));
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
else {
|
|
339
|
-
const next = nextPanel(focusedPanel, direction);
|
|
340
|
-
if (next === "actions") {
|
|
341
|
-
setFocusedPanel("actions");
|
|
342
|
-
setSelectedAction(0);
|
|
343
|
-
}
|
|
344
|
-
else {
|
|
345
|
-
setFocusedPanel(next);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
key.preventDefault();
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
308
|
const globalHandler = GLOBAL_KEYS[name];
|
|
352
309
|
if (globalHandler) {
|
|
353
310
|
globalHandler({ destroyLogSocket, onQuit, setHelpOpen, setEditTarget, setEditTask, push, onAction });
|
|
354
311
|
key.preventDefault();
|
|
355
312
|
return;
|
|
356
313
|
}
|
|
357
|
-
const
|
|
314
|
+
const isActionPanel = typeof focusedPanel === "string" && focusedPanel.startsWith("action-");
|
|
315
|
+
const panelHandler = isActionPanel ? panelHandlers["actions"] : panelHandlers[focusedPanel];
|
|
358
316
|
if (panelHandler?.(name, {
|
|
359
317
|
setSearchActive, setFilters, setSort, setEditTarget, push,
|
|
360
318
|
setSelectedIndex, setSelectedRunIndex, setFocusedPanel, selectedRunCount, selected,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import { useKeyboard } from "@opentui/react";
|
|
3
|
+
export function useTabNav(items, options) {
|
|
4
|
+
const initial = options?.initialIndex ?? 0;
|
|
5
|
+
const [focusIndex, setFocusIndex] = useState(Math.min(initial, Math.max(0, items.length - 1)));
|
|
6
|
+
const [enabled, setEnabled] = useState(true);
|
|
7
|
+
const focusIndexRef = useRef(focusIndex);
|
|
8
|
+
focusIndexRef.current = focusIndex;
|
|
9
|
+
const itemsRef = useRef(items);
|
|
10
|
+
itemsRef.current = items;
|
|
11
|
+
const onCycleOutRef = useRef(options?.onCycleOut);
|
|
12
|
+
onCycleOutRef.current = options?.onCycleOut;
|
|
13
|
+
const enabledRef = useRef(enabled);
|
|
14
|
+
enabledRef.current = enabled;
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
setFocusIndex((i) => Math.min(i, Math.max(0, items.length - 1)));
|
|
17
|
+
}, [items.length]);
|
|
18
|
+
useKeyboard((key) => {
|
|
19
|
+
if (!enabledRef.current)
|
|
20
|
+
return;
|
|
21
|
+
if (key.name !== "tab")
|
|
22
|
+
return;
|
|
23
|
+
const currentItems = itemsRef.current;
|
|
24
|
+
const currentIdx = focusIndexRef.current;
|
|
25
|
+
const lastIndex = currentItems.length - 1;
|
|
26
|
+
if (lastIndex < 0)
|
|
27
|
+
return;
|
|
28
|
+
const direction = key.shift ? "left" : "right";
|
|
29
|
+
if (direction === "right") {
|
|
30
|
+
if (currentIdx >= lastIndex) {
|
|
31
|
+
if (onCycleOutRef.current) {
|
|
32
|
+
onCycleOutRef.current("right");
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
setFocusIndex(0);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
setFocusIndex(currentIdx + 1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
if (currentIdx <= 0) {
|
|
44
|
+
if (onCycleOutRef.current) {
|
|
45
|
+
onCycleOutRef.current("left");
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
setFocusIndex(lastIndex);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
setFocusIndex(currentIdx - 1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
key.preventDefault();
|
|
56
|
+
});
|
|
57
|
+
const focusedItem = items[focusIndex];
|
|
58
|
+
const isFocused = useCallback((item) => item === focusedItem, [focusedItem]);
|
|
59
|
+
return { focusIndex, setFocusIndex, focusedItem, isFocused, enabled, setEnabled };
|
|
60
|
+
}
|