loop-task 1.5.0 → 1.5.2

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,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,27 @@ 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 focusedItemRef = useRef(focusedItem);
31
+ focusedItemRef.current = focusedItem;
32
+ const prevHeaderFocused = useRef(headerFocused);
33
+ useEffect(() => {
34
+ if (prevHeaderFocused.current && !headerFocused) {
35
+ setFocusIndex(0);
36
+ }
37
+ prevHeaderFocused.current = headerFocused;
38
+ }, [headerFocused]);
23
39
  // Expose create trigger to parent via callback
24
40
  useState(() => { onOpenCreate?.(() => setSubModal("create")); });
25
41
  const clampedIndex = Math.min(selectedIndex, Math.max(0, projects.length - 1));
@@ -36,44 +52,38 @@ export function ProjectsPage(props) {
36
52
  useKeyboard((key) => {
37
53
  if (subModal !== "none")
38
54
  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();
55
+ if (headerFocused)
53
56
  return;
54
- }
55
- if (key.name === "tab") {
56
- const direction = key.shift ? "left" : "right";
57
- if (focusedPanel === "list" && direction === "left") {
58
- onEnterHeader?.("left");
57
+ const fi = focusedItemRef.current;
58
+ if (key.name === "up" || key.name === "down") {
59
+ if (fi !== "list") {
59
60
  key.preventDefault();
60
61
  return;
61
62
  }
62
- if (focusedPanel === "actions" && direction === "right") {
63
- onEnterHeader?.("right");
64
- key.preventDefault();
65
- return;
63
+ if (key.name === "up") {
64
+ setSelectedIndex((i) => Math.max(0, i - 1));
66
65
  }
67
- setFocusedPanel((p) => (p === "list" ? "actions" : "list"));
66
+ else {
67
+ setSelectedIndex((i) => Math.min(projects.length - 1, i + 1));
68
+ }
69
+ key.preventDefault();
70
+ return;
71
+ }
72
+ if (key.name === "left" || key.name === "right") {
68
73
  key.preventDefault();
69
74
  return;
70
75
  }
71
76
  if (key.name === "return" || key.name === "enter") {
72
- if (focusedPanel === "list") {
73
- setFocusedPanel("actions");
77
+ if (fi === "list") {
78
+ setFocusIndex(1);
74
79
  }
75
- else {
76
- runAction(selectedAction);
80
+ else if (fi === "edit") {
81
+ if (selectedProject && !selectedProject.isSystem)
82
+ setSubModal("edit");
83
+ }
84
+ else if (fi === "delete") {
85
+ if (selectedProject && !selectedProject.isSystem)
86
+ setSubModal("delete");
77
87
  }
78
88
  key.preventDefault();
79
89
  return;
@@ -102,12 +112,12 @@ export function ProjectsPage(props) {
102
112
  { key: "edit", label: t("project.editProjectLabel") },
103
113
  { key: "delete", label: t("project.deleteProjectLabel") },
104
114
  ];
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) => {
115
+ 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
116
  const isSelected = index === clampedIndex;
107
117
  const bg = isSelected ? "#1e3a8a" : undefined;
108
118
  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) => {
119
+ 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));
120
+ })] }), _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
121
  setSubModal("none");
112
122
  await onRefresh();
113
123
  const newIndex = projects.findIndex((p) => p.id === project.id);
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
- import { useRef, useState, useMemo } from "react";
2
+ import { useRef, useState, useMemo, useEffect } from "react";
3
3
  import { useKeyboard } from "@opentui/react";
4
4
  import { t } from "../../i18n/index.js";
5
5
  import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
@@ -11,7 +11,7 @@ export function SearchSelect(props) {
11
11
  const inputRef = useRef(null);
12
12
  const [filter, setFilter] = useState("");
13
13
  const [selectedIndex, setSelectedIndex] = useState(0);
14
- useInputShortcuts(() => focused ? inputRef.current : null);
14
+ const [userNavigated, setUserNavigated] = useState(false);
15
15
  const filtered = useMemo(() => {
16
16
  if (!filter)
17
17
  return options;
@@ -19,41 +19,74 @@ export function SearchSelect(props) {
19
19
  return options.filter((o) => o.name.toLowerCase().includes(q) || o.value.toLowerCase().includes(q));
20
20
  }, [options, filter]);
21
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));
22
+ const displaySelected = userNavigated ? selectedIndex : (currentIdx >= 0 ? currentIdx : 0);
23
+ const clampedSelected = Math.min(displaySelected, Math.max(0, filtered.length - 1));
24
+ const filteredRef = useRef(filtered);
25
+ filteredRef.current = filtered;
26
+ const selectedIdxRef = useRef(clampedSelected);
27
+ selectedIdxRef.current = clampedSelected;
28
+ const focusedRef = useRef(focused);
29
+ focusedRef.current = focused;
30
+ const onChangeRef = useRef(onChange);
31
+ onChangeRef.current = onChange;
32
+ const filterRef = useRef(filter);
33
+ filterRef.current = filter;
34
+ useEffect(() => {
35
+ if (focused) {
36
+ setUserNavigated(false);
37
+ setSelectedIndex(currentIdx >= 0 ? currentIdx : 0);
38
+ }
39
+ else {
40
+ setFilter("");
41
+ setUserNavigated(false);
42
+ }
43
+ }, [focused]);
44
+ useInputShortcuts(() => focusedRef.current ? inputRef.current : null);
24
45
  useKeyboard((key) => {
25
- if (!focused)
46
+ if (!focusedRef.current)
26
47
  return;
27
48
  const name = key.name;
28
49
  if (name === "up" || name === "k") {
29
- if (filtered.length > 0) {
30
- setSelectedIndex((i) => i <= 0 ? filtered.length - 1 : i - 1);
50
+ const list = filteredRef.current;
51
+ if (list.length > 0) {
52
+ const cur = selectedIdxRef.current;
53
+ const next = cur <= 0 ? list.length - 1 : cur - 1;
54
+ setSelectedIndex(next);
55
+ setUserNavigated(true);
31
56
  }
32
57
  key.preventDefault();
33
58
  key.stopPropagation();
34
59
  return;
35
60
  }
36
61
  if (name === "down" || name === "j") {
37
- if (filtered.length > 0) {
38
- setSelectedIndex((i) => i >= filtered.length - 1 ? 0 : i + 1);
62
+ const list = filteredRef.current;
63
+ if (list.length > 0) {
64
+ const cur = selectedIdxRef.current;
65
+ const next = cur >= list.length - 1 ? 0 : cur + 1;
66
+ setSelectedIndex(next);
67
+ setUserNavigated(true);
39
68
  }
40
69
  key.preventDefault();
41
70
  key.stopPropagation();
42
71
  return;
43
72
  }
44
73
  if (name === "return" || name === "enter") {
45
- const option = filtered[clampedSelected];
74
+ const list = filteredRef.current;
75
+ const option = list[selectedIdxRef.current];
46
76
  if (option) {
47
- onChange(option.value);
77
+ onChangeRef.current(option.value);
48
78
  }
49
79
  key.preventDefault();
50
80
  key.stopPropagation();
51
81
  return;
52
82
  }
53
83
  if (name === "escape") {
54
- if (filter) {
84
+ if (filterRef.current) {
55
85
  setFilter("");
56
86
  setSelectedIndex(0);
87
+ setUserNavigated(false);
88
+ if (inputRef.current)
89
+ inputRef.current.value = "";
57
90
  }
58
91
  key.preventDefault();
59
92
  key.stopPropagation();
@@ -62,32 +95,31 @@ export function SearchSelect(props) {
62
95
  if (name === "backspace") {
63
96
  setFilter((f) => f.slice(0, -1));
64
97
  setSelectedIndex(0);
98
+ setUserNavigated(false);
65
99
  key.preventDefault();
66
100
  key.stopPropagation();
67
101
  return;
68
102
  }
69
103
  if (key.ctrl)
70
104
  return;
105
+ let char = null;
71
106
  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;
107
+ char = name;
108
+ }
109
+ else if (key.sequence && key.sequence.length === 1 && /[a-z0-9 _\-./]/i.test(key.sequence)) {
110
+ char = key.sequence;
77
111
  }
78
- if (key.sequence && key.sequence.length === 1 && /[a-z0-9 _\-./]/i.test(key.sequence)) {
79
- setFilter((f) => f + key.sequence);
112
+ if (char) {
113
+ setFilter((f) => f + char);
80
114
  setSelectedIndex(0);
115
+ setUserNavigated(false);
81
116
  key.preventDefault();
82
117
  key.stopPropagation();
83
118
  return;
84
119
  }
85
120
  });
86
121
  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) => {
122
+ return (_jsxs("box", { border: true, borderColor: focused ? "#38bdf8" : undefined, style: { flexDirection: "column", backgroundColor: focused ? "#0f172a" : "#0b0b0b" }, children: [_jsxs("box", { style: { height: 3, flexDirection: "row", alignItems: "center", paddingLeft: 1 }, children: [_jsx("text", { fg: "#6b7280", children: "/ " }), _jsx("text", { fg: filter ? "#e5e7eb" : "#6b7280", children: filter || placeholder })] }), _jsx("box", { style: { flexDirection: "column", height: listHeight }, children: filtered.map((option, i) => {
91
123
  const isSelected = i === clampedSelected;
92
124
  const isActive = option.value === value;
93
125
  const prefix = isSelected ? "› " : " ";
@@ -5,6 +5,7 @@ import { t } from "../../i18n/index.js";
5
5
  import { commandLine, truncate } from "../format.js";
6
6
  import { useHoverState } from "../hooks/useHoverState.js";
7
7
  import { HOVER_BG, ENTITY_COLORS } from "../../config/constants.js";
8
+ import { copyToClipboard } from "../../shared/clipboard.js";
8
9
  function fit(text, width) {
9
10
  if (width <= 0)
10
11
  return "";
@@ -67,12 +68,13 @@ function TaskNavRow(props) {
67
68
  return (_jsx("box", { id: id, onMouseDown: handleClick, backgroundColor: bg, ...hoverProps, children: _jsxs("text", { fg: fg, children: [isSelected ? "›" : " ", " ", fit(name, nameW), " ", fit(truncCmd, cmdW), " ", fit(chains, chainsW)] }) }));
68
69
  }
69
70
  export function TaskInspector(props) {
70
- const { task } = props;
71
+ const { task, onCopy } = props;
71
72
  if (!task) {
72
73
  return (_jsx("box", { title: t("board.inspectorTitle"), border: true, style: { backgroundColor: "#0b0b0b" }, children: _jsx("text", { fg: "#9ca3af", children: t("board.inspectorEmpty") }) }));
73
74
  }
74
75
  const cmd = commandLine(task.command, task.commandArgs);
75
- return (_jsxs("box", { title: t("board.inspectorTitle"), border: true, style: { flexDirection: "column", backgroundColor: "#0b0b0b" }, children: [_jsxs("text", { children: [_jsx("strong", { children: t("board.fieldId") }), " ", task.id] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelName") }), " ", task.name] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldCommand") }), " ", cmd] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelOnSuccess") }), " ", task.onSuccessTaskId ?? t("board.taskNone")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelOnFailure") }), " ", task.onFailureTaskId ?? t("board.taskNone")] })] }));
76
+ const { isHovered, hoverProps } = useHoverState();
77
+ return (_jsxs("box", { title: t("board.inspectorTitle"), border: true, style: { flexDirection: "column", backgroundColor: "#0b0b0b" }, children: [_jsxs("text", { children: [_jsx("strong", { children: t("board.fieldId") }), " ", task.id] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelName") }), " ", task.name] }), _jsxs("box", { style: { flexDirection: "row" }, children: [_jsxs("text", { children: [_jsx("strong", { children: t("board.fieldCommand") }), " ", cmd] }), _jsx("box", { border: true, borderColor: isHovered ? "#38bdf8" : "#374151", onMouseDown: () => { copyToClipboard(cmd); onCopy?.(cmd); }, style: { paddingLeft: 1, paddingRight: 1, marginLeft: 1, backgroundColor: isHovered ? HOVER_BG : "#0b0b0b" }, ...hoverProps, children: _jsx("text", { fg: isHovered ? "#38bdf8" : "#6b7280", children: "\u2398" }) })] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelOnSuccess") }), " ", task.onSuccessTaskId ?? t("board.taskNone")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelOnFailure") }), " ", task.onFailureTaskId ?? t("board.taskNone")] })] }));
76
78
  }
77
79
  export function TaskActionButtons(props) {
78
80
  const { task, focused, selectedAction, selectable = true, onAction } = props;
@@ -6,6 +6,8 @@ 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";
10
+ import { copyToClipboard } from "../../shared/clipboard.js";
9
11
  import { HOVER_BG } from "../../config/constants.js";
10
12
  import { SearchSelect } from "./SearchSelect.js";
11
13
  const taskFields = ["name", "command", "onSuccessTaskId", "onFailureTaskId"];
@@ -23,20 +25,23 @@ function taskInitialValues(task) {
23
25
  export function TaskForm(props) {
24
26
  const [values, setValues] = useState(taskInitialValues(props.editTask));
25
27
  const valuesRef = useRef(values);
26
- const [focusIndex, setFocusIndex] = useState(0);
27
28
  const [error, setError] = useState("");
28
29
  const [isSubmitting, setIsSubmitting] = useState(false);
29
30
  const [allTasks, setAllTasks] = useState([]);
30
31
  const inputRef = useRef(null);
31
32
  const { width: termWidth } = useTerminalDimensions();
32
33
  const btnWidth = Math.max(10, Math.min(14, Math.floor(termWidth / 6)));
34
+ const navItems = [...taskFields, "save", "cancel"];
35
+ const { setFocusIndex, focusedItem, isFocused } = useTabNav(navItems);
36
+ const focusedItemRef = useRef(focusedItem);
37
+ focusedItemRef.current = focusedItem;
33
38
  useInputShortcuts(() => {
34
- if (focusIndex >= saveIndex)
35
- return null;
36
- return inputRef.current;
39
+ const fi = focusedItemRef.current;
40
+ if (fi != null && fi !== "save" && fi !== "cancel" && fi !== "onSuccessTaskId" && fi !== "onFailureTaskId") {
41
+ return inputRef.current;
42
+ }
43
+ return null;
37
44
  });
38
- const saveIndex = taskFields.length;
39
- const cancelIndex = taskFields.length + 1;
40
45
  function updateValues(next) {
41
46
  valuesRef.current = next;
42
47
  setValues(next);
@@ -45,39 +50,13 @@ export function TaskForm(props) {
45
50
  void listTasks().then(setAllTasks).catch(() => { });
46
51
  });
47
52
  useKeyboard((key) => {
48
- if (key.name === "tab") {
49
- setFocusIndex((i) => {
50
- const next = key.shift ? i - 1 : i + 1;
51
- if (next < 0)
52
- return cancelIndex;
53
- if (next > cancelIndex)
54
- return 0;
55
- return next;
56
- });
57
- key.preventDefault();
58
- return;
59
- }
60
- if (key.name === "up" || key.name === "down") {
61
- const field = taskFields[focusIndex];
62
- if (field === "onSuccessTaskId" || field === "onFailureTaskId")
63
- return;
64
- setFocusIndex((i) => {
65
- const next = key.name === "up" ? i - 1 : i + 1;
66
- if (next < 0)
67
- return cancelIndex;
68
- if (next > cancelIndex)
69
- return 0;
70
- return next;
71
- });
72
- key.preventDefault();
73
- return;
74
- }
75
53
  if (key.name === "return" || key.name === "enter") {
76
- if (focusIndex === saveIndex) {
54
+ const fi = focusedItemRef.current;
55
+ if (fi === "save") {
77
56
  void submit(valuesRef.current);
78
57
  key.preventDefault();
79
58
  }
80
- else if (focusIndex === cancelIndex) {
59
+ else if (fi === "cancel") {
81
60
  props.onCancel();
82
61
  key.preventDefault();
83
62
  }
@@ -151,22 +130,22 @@ export function TaskForm(props) {
151
130
  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) => {
152
131
  const leftField = taskFields[row * 2];
153
132
  const rightField = row * 2 + 1 < taskFields.length ? taskFields[row * 2 + 1] : null;
154
- 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));
155
- }) }), _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] }));
133
+ 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, onCopy: props.onCopy, 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, onCopy: props.onCopy, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
134
+ }) }), _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] }));
156
135
  }
157
136
  function TaskFormRow(props) {
158
- const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, chainOptions, inputRef, style } = props;
159
- const isFocused = focusIndex === index;
137
+ const { field, index, focused, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, chainOptions, inputRef, onCopy, style } = props;
160
138
  const isSelect = field === "onSuccessTaskId" || field === "onFailureTaskId";
161
139
  const selectOpts = isSelect ? chainOptions : [];
162
- 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(SearchSelect, { options: selectOpts, value: values[field], onChange: (v) => updateValues({ ...valuesRef.current, [field]: v }), focused: isFocused })) : (_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: () => {
163
- if (index < taskFields.length - 1) {
164
- setFocusIndex(index + 1);
165
- }
166
- else {
167
- void submit(valuesRef.current);
168
- }
169
- } }) }))] }));
140
+ const copyHover = useHoverState();
141
+ 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 })) : (_jsxs("box", { style: { flexDirection: "row" }, children: [_jsx("box", { border: true, borderColor: focused ? "#38bdf8" : undefined, style: { height: 3, width: field === "command" ? "92%" : "100%", 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: () => {
142
+ if (index < taskFields.length - 1) {
143
+ setFocusIndex(index + 1);
144
+ }
145
+ else {
146
+ void submit(valuesRef.current);
147
+ }
148
+ } }) }), field === "command" ? (_jsx("box", { border: true, borderColor: copyHover.isHovered ? "#38bdf8" : "#374151", onMouseDown: () => { copyToClipboard(valuesRef.current.command); onCopy?.(valuesRef.current.command); }, style: { width: "8%", height: 3, justifyContent: "center", alignItems: "center", backgroundColor: copyHover.isHovered ? HOVER_BG : "#0b0b0b" }, ...copyHover.hoverProps, children: _jsx("text", { fg: copyHover.isHovered ? "#38bdf8" : "#6b7280", children: "\u2398" }) })) : null] }))] }));
170
149
  }
171
150
  function HoverButton(props) {
172
151
  const { isHovered, hoverProps } = useHoverState();
@@ -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,