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.
@@ -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 [focusField, setFocusField] = useState("name");
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
- setSelectedColorKey(PROJECT_COLOR_KEYS[(idx + 1) % PROJECT_COLOR_KEYS.length] ?? selectedColorKey);
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), onSubmit: () => setFocusField("color") }) }), _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) => {
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: () => { setFocusField("color"); setSelectedColorKey(colorKey); }, style: {
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 [focusedPanel, setFocusedPanel] = useState("list");
22
- const [selectedAction, setSelectedAction] = useState(0);
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 (key.name === "up") {
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
- if (key.name === "tab") {
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 (focusedPanel === "actions" && direction === "right") {
63
- onEnterHeader?.("right");
64
- key.preventDefault();
65
- return;
60
+ if (key.name === "up") {
61
+ setSelectedIndex((i) => Math.max(0, i - 1));
66
62
  }
67
- setFocusedPanel((p) => (p === "list" ? "actions" : "list"));
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 (focusedPanel === "list") {
73
- setFocusedPanel("actions");
74
+ if (focusedItem === "list") {
75
+ setFocusIndex(1);
74
76
  }
75
- else {
76
- runAction(selectedAction);
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: focusedPanel === "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) => {
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); setFocusedPanel("list"); }, 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));
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: focusedPanel === "actions" ? 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: focusedPanel === "actions" && selectedAction === i, onMouseDown: () => { setFocusedPanel("actions"); runAction(i); } }, action.key)))) : (_jsx("text", { fg: "#9ca3af", children: t("project.systemLabel") })) })] }), subModal === "create" ? (_jsx(CreateProjectModal, { onDone: async (project) => {
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 (focusIndex >= saveIndex)
34
- return null;
35
- return inputRef.current;
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 (focusIndex === saveIndex) {
50
+ if (focusedItem === "save") {
73
51
  void submit(valuesRef.current);
74
52
  key.preventDefault();
75
53
  }
76
- else if (focusIndex === cancelIndex) {
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, focusIndex: focusIndex, 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, focusIndex: focusIndex, 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));
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: focusIndex === saveIndex, width: btnWidth, marginRight: 1 }), _jsx(HoverButton, { label: t("board.cancel"), onMouseDown: props.onCancel, selected: focusIndex === cancelIndex, width: btnWidth })] }), _jsx("text", { fg: "#9ca3af", children: t("board.formNav") }), error ? _jsx("text", { fg: "#f87171", children: error }) : null] }));
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, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, chainOptions, inputRef, style } = props;
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
- const selectedIdx = isSelect ? selectOpts.findIndex((o) => o.value === values[field]) : 0;
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.onViewTasks?.();
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.onViewProjects?.();
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
- onViewProjects?.();
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 panelHandler = panelHandlers[focusedPanel];
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
+ }