loop-task 1.5.0 → 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 +52 -9
- package/dist/board/App.js +54 -18
- package/dist/board/components/CreateForm.js +26 -63
- 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/TaskForm.js +13 -40
- 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 +3 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -290,29 +290,28 @@ gh issue edit {{number}} --add-label "refining" --remove-label "to refine"
|
|
|
290
290
|
|
|
291
291
|
interpolated: `gh issue edit 123 --add-label "refining" --remove-label "to refine"`
|
|
292
292
|
|
|
293
|
-
**Task 3** (chain, onSuccess): Rewrite with AI
|
|
293
|
+
**Task 3** (chain, onSuccess): Rewrite with AI (edits the issue directly)
|
|
294
294
|
|
|
295
295
|
```bash
|
|
296
|
-
opencode run "Rewrite this GitHub issue as a detailed user story using project context and
|
|
296
|
+
opencode run "Rewrite this GitHub issue as a detailed user story using project context. Update the issue title and body directly using gh issue edit. Issue number: {{number}} Original title: {{title}} Original body: {{body}}" --model "opencode/big-pickle"
|
|
297
297
|
```
|
|
298
298
|
|
|
299
|
-
|
|
300
|
-
context: `{ number: 123, title: "As a user, I want to log in securely", body: "## Acceptance Criteria\n- ..." }`
|
|
299
|
+
interpolated: `opencode run "Rewrite this GitHub issue as a detailed user story using project context. Update the issue title and body directly using gh issue edit. Issue number: 123 Original title: Fix login Original body: It doesn't work" --model "opencode/big-pickle"`
|
|
301
300
|
|
|
302
|
-
**Task 4** (chain, onSuccess):
|
|
301
|
+
**Task 4** (chain, onSuccess): Relabel as ready to implement
|
|
303
302
|
|
|
304
303
|
```bash
|
|
305
|
-
gh issue edit {{number}} --
|
|
304
|
+
gh issue edit {{number}} --remove-label "refining" --add-label "to implement"
|
|
306
305
|
```
|
|
307
306
|
|
|
308
|
-
interpolated: `gh issue edit 123 --
|
|
307
|
+
interpolated: `gh issue edit 123 --remove-label "refining" --add-label "to implement"`
|
|
309
308
|
|
|
310
309
|
### How it works
|
|
311
310
|
|
|
312
311
|
1. Task 1 queries the issue and emits a JSON object with `number`, `title`, and `body` via `--jq`. The primary task cannot use `{{key}}` interpolation because the chain context is empty when it runs.
|
|
313
312
|
2. Task 2 receives `{{number}}` interpolated from task 1's context. It relabels the issue from "to refine" to "refining" - no re-query needed.
|
|
314
|
-
3. Task 3 runs opencode, which finds the issue by the "refining" label and rewrites it
|
|
315
|
-
4. Task 4 receives `{{number}}` (still 123 from task 1)
|
|
313
|
+
3. Task 3 runs opencode, which finds the issue by the "refining" label and rewrites it in place using `gh issue edit`. The AI agent edits the issue directly - no need to parse its stdout as JSON.
|
|
314
|
+
4. Task 4 receives `{{number}}` (still 123 from task 1) and relabels the issue as "to implement" - no re-query needed.
|
|
316
315
|
|
|
317
316
|
### Wrapping values with --jq
|
|
318
317
|
|
|
@@ -324,6 +323,50 @@ gh issue list --label "to refine" --json number,title --jq '{number: .[0].number
|
|
|
324
323
|
|
|
325
324
|
This stores `{ "number": 123, "title": "Fix login" }` in context instead of overwriting `output`.
|
|
326
325
|
|
|
326
|
+
### Example: Issue Implementation Chain
|
|
327
|
+
|
|
328
|
+
A four-task chain that finds an issue to implement, marks it in-progress, runs an AI agent to implement it, then closes it - all without re-querying:
|
|
329
|
+
|
|
330
|
+
**Task 1** (primary): Find an issue to implement (or exit if one is already in progress)
|
|
331
|
+
|
|
332
|
+
```bash
|
|
333
|
+
gh issue list --label "implementing" --limit 1 --json number --jq 'length == 0' | grep -q true && gh issue list --label "to implement" --limit 1 --json number,title,body --jq '{number: .[0].number, title: .[0].title, body: .[0].body}'
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
stdout: `{"number":456,"title":"Add dark mode toggle","body":"Users want a dark theme"}`
|
|
337
|
+
context: `{ number: 456, title: "Add dark mode toggle", body: "Users want a dark theme" }`
|
|
338
|
+
|
|
339
|
+
If an issue with the "implementing" label already exists, `length == 0` returns `false`, `grep -q true` fails, and the `&&` short-circuits - the chain does not fire. The loop waits for the next iteration.
|
|
340
|
+
|
|
341
|
+
**Task 2** (chain, onSuccess): Mark as in-progress
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
gh issue edit {{number}} --add-label "implementing" --remove-label "to implement"
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
interpolated: `gh issue edit 456 --add-label "implementing" --remove-label "to implement"`
|
|
348
|
+
|
|
349
|
+
**Task 3** (chain, onSuccess): Implement with AI agent
|
|
350
|
+
|
|
351
|
+
```bash
|
|
352
|
+
git fetch origin && git checkout main && git reset --hard origin/main && opencode run "Implement this GitHub issue using /ob-autopilot and return only JSON with fields title and body after implementation is completed, merged to main, pushed to origin and the issue has been referenced in GitHub. Issue title: {{title}} Issue body: {{body}}" --model "opencode/big-pickle"
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
interpolated: `git fetch origin && git checkout main && git reset --hard origin/main && opencode run "Implement this GitHub issue using /ob-autopilot ... Issue title: Add dark mode toggle Issue body: Users want a dark theme" --model "opencode/big-pickle"`
|
|
356
|
+
|
|
357
|
+
stdout: `{"title":"Add dark mode toggle","body":"Implemented dark mode toggle with CSS variables..."}`
|
|
358
|
+
context: `{ number: 456, title: "Add dark mode toggle", body: "Implemented dark mode toggle..." }`
|
|
359
|
+
|
|
360
|
+
**Task 4** (chain, onSuccess): Verify sync and close the issue
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
git push && git fetch origin && [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] && gh issue edit {{number}} --remove-label "implementing" && gh issue close {{number}}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
interpolated: `git push && git fetch origin && [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] && gh issue edit 456 --remove-label "implementing" && gh issue close 456`
|
|
367
|
+
|
|
368
|
+
The `git rev-parse` check ensures local and remote are in sync before closing - if the push failed or remote is ahead, the command fails and the issue stays open.
|
|
369
|
+
|
|
327
370
|
## Development
|
|
328
371
|
|
|
329
372
|
Requires [Bun](https://bun.sh) >= 1.2 for package management and the board, and [Node.js](https://nodejs.org) >= 20 for the CLI and daemon.
|
package/dist/board/App.js
CHANGED
|
@@ -12,7 +12,7 @@ import { FilterBar } from "./components/FilterBar.js";
|
|
|
12
12
|
import { Navigator } from "./components/Navigator.js";
|
|
13
13
|
import { Inspector } from "./components/Inspector.js";
|
|
14
14
|
import { RunHistory } from "./components/RunHistory.js";
|
|
15
|
-
import { ActionButtons } from "./components/ActionButtons.js";
|
|
15
|
+
import { ActionButtons, getActionKeys } from "./components/ActionButtons.js";
|
|
16
16
|
import { HelpModal } from "./components/HelpModal.js";
|
|
17
17
|
import { ContextHelpModal } from "./components/ContextHelpModal.js";
|
|
18
18
|
import { Footer } from "./components/Footer.js";
|
|
@@ -28,6 +28,7 @@ import { fetchRunLog, deleteLoop, pauseLoop, resumeLoop, stopLoop, playLoop, tri
|
|
|
28
28
|
import { useBreakpoint } from "./hooks/useBreakpoint.js";
|
|
29
29
|
import { useRouter } from "./router.js";
|
|
30
30
|
import { POLL_MS } from "../config/constants.js";
|
|
31
|
+
import { useTabNav } from "./hooks/useTabNav.js";
|
|
31
32
|
const VIEW_TO_MODE = {
|
|
32
33
|
create: "create",
|
|
33
34
|
"task-create": "task",
|
|
@@ -68,14 +69,12 @@ export function App(props) {
|
|
|
68
69
|
const [pendingTaskSelection, setPendingTaskSelection] = useState(null);
|
|
69
70
|
const [selectedRunIndex, setSelectedRunIndex] = useState(0);
|
|
70
71
|
const [selectedAction, setSelectedAction] = useState(0);
|
|
71
|
-
const [focusedPanel, setFocusedPanel] = useState("loops");
|
|
72
72
|
const [logModalRun, setLogModalRun] = useState(null);
|
|
73
73
|
const [logModalLines, setLogModalLines] = useState([]);
|
|
74
74
|
const [logModalLoading, setLogModalLoading] = useState(false);
|
|
75
75
|
const [tasks, setTasks] = useState([]);
|
|
76
76
|
const [taskSelectedIndex, setTaskSelectedIndex] = useState(0);
|
|
77
77
|
const [taskSelectedAction, setTaskSelectedAction] = useState(0);
|
|
78
|
-
const [taskFocusedPanel, setTaskFocusedPanel] = useState("tasks");
|
|
79
78
|
const [taskSearchActive, setTaskSearchActive] = useState(false);
|
|
80
79
|
const [taskQuery, setTaskQuery] = useState("");
|
|
81
80
|
const [projects, setProjects] = useState([]);
|
|
@@ -282,6 +281,30 @@ export function App(props) {
|
|
|
282
281
|
pop();
|
|
283
282
|
pushToast("success", updated ? t("board.toastTaskUpdated", { id }) : t("board.toastTaskCreated", { id }));
|
|
284
283
|
};
|
|
284
|
+
const FORM_VIEWS = ["create", "task-create", "task-edit"];
|
|
285
|
+
const isFormView = FORM_VIEWS.includes(view);
|
|
286
|
+
const actionKeys = selected ? getActionKeys(selected.status) : [];
|
|
287
|
+
const actionItems = actionKeys.map((k) => `action-${k}`);
|
|
288
|
+
const boardItems = ["search", "project-filter", "status", "sort", "header-1", "header-2", "header-3", "navigator", "run-history", ...actionItems];
|
|
289
|
+
const taskItems = ["task-search", "header-1", "header-2", "header-3", "task-list", ...(["select", "edit", "delete"].map((k) => `task-action-${k}`))];
|
|
290
|
+
const projectItems = ["header-1", "header-2", "header-3"];
|
|
291
|
+
const navItems = isFormView ? [] : view === "task-list" ? taskItems : view === "projects" ? projectItems : boardItems;
|
|
292
|
+
const { focusedItem: navFocused, setFocusIndex: setNavIndex, enabled: navEnabled } = useTabNav(navItems);
|
|
293
|
+
const HEADER_MAP = { "header-1": "header-tasks", "header-2": "header-projects", "header-3": "header-new" };
|
|
294
|
+
const isHeaderFocused = navFocused === "header-1" || navFocused === "header-2" || navFocused === "header-3";
|
|
295
|
+
const derivedFocusedPanel = isHeaderFocused ? HEADER_MAP[navFocused] : navFocused ?? "loops";
|
|
296
|
+
const actionIdx = navFocused?.startsWith("action-") ? actionKeys.indexOf(navFocused.slice("action-".length)) : -1;
|
|
297
|
+
const derivedSelectedAction = actionIdx >= 0 ? actionIdx : selectedAction;
|
|
298
|
+
const taskActionIdx = navFocused?.startsWith("task-action-") ? ["select", "edit", "delete"].indexOf(navFocused.slice("task-action-".length)) : -1;
|
|
299
|
+
const derivedTaskAction = taskActionIdx >= 0 ? taskActionIdx : taskSelectedAction;
|
|
300
|
+
const taskPanelMap = {
|
|
301
|
+
"task-search": "search",
|
|
302
|
+
"task-list": "tasks",
|
|
303
|
+
"task-action-select": "actions",
|
|
304
|
+
"task-action-edit": "actions",
|
|
305
|
+
"task-action-delete": "actions",
|
|
306
|
+
};
|
|
307
|
+
const derivedTaskPanel = (navFocused ? taskPanelMap[navFocused] : undefined) ?? "tasks";
|
|
285
308
|
useBoardKeybindings({
|
|
286
309
|
confirm,
|
|
287
310
|
confirmChoice,
|
|
@@ -309,9 +332,13 @@ export function App(props) {
|
|
|
309
332
|
selectedRunIndex,
|
|
310
333
|
setSelectedRunIndex,
|
|
311
334
|
selectedRunCount: selected?.runHistory?.length ?? 0,
|
|
312
|
-
focusedPanel,
|
|
313
|
-
setFocusedPanel
|
|
314
|
-
|
|
335
|
+
focusedPanel: derivedFocusedPanel,
|
|
336
|
+
setFocusedPanel: ((p) => {
|
|
337
|
+
const navIdx = navItems.indexOf(p);
|
|
338
|
+
if (navIdx >= 0)
|
|
339
|
+
setNavIndex(navIdx);
|
|
340
|
+
}),
|
|
341
|
+
selectedAction: derivedSelectedAction,
|
|
315
342
|
setSelectedAction,
|
|
316
343
|
onAction: handleAction,
|
|
317
344
|
onOpenRunLog: handleOpenRunLog,
|
|
@@ -329,10 +356,23 @@ export function App(props) {
|
|
|
329
356
|
tasks: filteredTasks,
|
|
330
357
|
taskSelectedIndex,
|
|
331
358
|
setTaskSelectedIndex,
|
|
332
|
-
taskSelectedAction,
|
|
333
|
-
setTaskSelectedAction
|
|
334
|
-
|
|
335
|
-
|
|
359
|
+
taskSelectedAction: derivedTaskAction,
|
|
360
|
+
setTaskSelectedAction: ((updater) => {
|
|
361
|
+
const nextIdx = typeof updater === "function" ? updater(derivedTaskAction) : updater;
|
|
362
|
+
const taskActions = ["select", "edit", "delete"];
|
|
363
|
+
const actionKey = taskActions[nextIdx];
|
|
364
|
+
if (actionKey) {
|
|
365
|
+
const navIdx = navItems.indexOf(`task-action-${actionKey}`);
|
|
366
|
+
if (navIdx >= 0)
|
|
367
|
+
setNavIndex(navIdx);
|
|
368
|
+
}
|
|
369
|
+
}),
|
|
370
|
+
taskFocusedPanel: derivedTaskPanel,
|
|
371
|
+
setTaskFocusedPanel: ((p) => {
|
|
372
|
+
const navIdx = navItems.indexOf(p === "search" ? "task-search" : p === "actions" ? "task-actions" : "task-list");
|
|
373
|
+
if (navIdx >= 0)
|
|
374
|
+
setNavIndex(navIdx);
|
|
375
|
+
}),
|
|
336
376
|
taskSearchActive,
|
|
337
377
|
setTaskSearchActive,
|
|
338
378
|
taskQuery,
|
|
@@ -340,10 +380,8 @@ export function App(props) {
|
|
|
340
380
|
onTaskAction: handleTaskAction,
|
|
341
381
|
onCancel: cancelTaskList,
|
|
342
382
|
onCreateTask: handleCreateTask,
|
|
343
|
-
onEnterHeader: (direction) => {
|
|
344
|
-
setFocusedPanel(direction === "right" ? "header-tasks" : "header-new");
|
|
345
|
-
},
|
|
346
383
|
onToggleContextHelp: () => setContextHelpOpen((v) => !v),
|
|
384
|
+
headerFocused: isHeaderFocused,
|
|
347
385
|
selectable: stack.includes("create") || stack.includes("task-edit"),
|
|
348
386
|
});
|
|
349
387
|
const counts = {
|
|
@@ -354,16 +392,14 @@ export function App(props) {
|
|
|
354
392
|
idle: loops.filter((l) => l.status === "idle").length,
|
|
355
393
|
};
|
|
356
394
|
const mode = resolveMode(confirm, searchActive || taskSearchActive, helpOpen, view);
|
|
357
|
-
return (_jsxs("box", { style: { flexDirection: "column", width: "100%", height: "100%", backgroundColor: "#0b0b0b" }, children: [_jsx(Header, { daemonStatus: daemonStatus, counts: counts, view: view, focusedPanel:
|
|
395
|
+
return (_jsxs("box", { style: { flexDirection: "column", width: "100%", height: "100%", backgroundColor: "#0b0b0b" }, children: [_jsx(Header, { daemonStatus: daemonStatus, counts: counts, view: view, focusedPanel: derivedFocusedPanel, onViewLoops: () => replace("board"), onViewTasks: () => { void refreshTasks(); push("task-list"); }, onViewProjects: () => push("projects"), onAddLoop: () => { setEditTarget(null); push("create"); }, onAddTask: () => { setEditTask(null); push("task-create"); }, onAddProject: () => { if (view === "projects") {
|
|
358
396
|
createProjectTriggerRef.current?.();
|
|
359
397
|
}
|
|
360
398
|
else {
|
|
361
399
|
push("projects");
|
|
362
|
-
} } }), view === "board" ? (_jsx(FilterBar, { filters: filters, sort: sort, searchActive: searchActive, focusedPanel:
|
|
363
|
-
setFocusedPanel(direction === "right" ? "header-tasks" : "header-new");
|
|
364
|
-
} })) : (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(Navigator, { visible: visible, total: loops.length, selectedIndex: clampedIndex, filters: filters, sort: sort, breakpoint: breakpoint, focused: focusedPanel === "loops", projects: projects, onSelect: (index) => { setSelectedIndex(index); setFocusedPanel("loops"); }, onActivate: (index) => { setSelectedIndex(index); const loop = visible[index]; if (loop) {
|
|
400
|
+
} } }), view === "board" ? (_jsx(FilterBar, { filters: filters, sort: sort, searchActive: searchActive, focusedPanel: derivedFocusedPanel, onStatusCycle: () => setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => setSort(cycleSortMode(sort)), onSelectProject: () => setProjectsModalOpen(true), currentProjectName: currentProjectId === "all" ? t("project.showAll") : (projects.find(p => p.id === currentProjectId)?.name ?? "Default"), onQueryChange: (value) => setFilters((prev) => ({ ...prev, query: value })), onSearchActivate: () => setSearchActive(true), onSearchDismiss: () => { setSearchActive(false); } })) : view === "task-list" ? (_jsx(TaskFilterBar, { query: taskQuery, searchActive: taskSearchActive, focusedPanel: derivedTaskPanel, onQueryChange: setTaskQuery, onSearchActivate: () => setTaskSearchActive(true), onSearchDismiss: () => { setTaskSearchActive(false); } })) : null, _jsx("box", { style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: view === "create" ? (_jsx(CreateView, { mode: editTarget && !cloneMode ? "edit" : "create", editId: editTarget && !cloneMode ? editTarget.id : null, initial: createInitialValues(editTarget, currentProjectId), selectedTaskId: pendingTaskSelection?.id ?? null, selectedTaskName: pendingTaskSelection?.name ?? null, projects: projects, currentProjectId: currentProjectId, onCancel: cancelCreate, onDone: onCreateDone, onChooseTask: handleChooseTask })) : TASK_FORM_VIEWS.has(view) ? (_jsx(TaskForm, { mode: view === "task-edit" ? "edit" : "create", editTask: editTask, onCancel: cancelTask, onDone: onTaskDone })) : view === "task-list" ? (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(TaskNavigator, { visible: filteredTasks, total: tasks.length, selectedIndex: taskClampedIndex, focused: derivedTaskPanel === "tasks", query: taskQuery, onSelect: (index) => { setTaskSelectedIndex(index); }, onActivate: (index) => { setTaskSelectedIndex(index); setEditTask(filteredTasks[index] ?? null); push("task-edit"); } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(TaskInspector, { task: selectedTask }, `ti-${selectedTask?.id}`), _jsx(TaskActionButtons, { task: selectedTask, focused: taskActionIdx >= 0, selectedAction: derivedTaskAction, selectable: stack.includes("create") || stack.includes("task-edit"), onAction: handleTaskAction }, `tab-${selectedTask?.id}`)] })] })) : view === "projects" ? (_jsx(ProjectsPage, { projects: projects, loops: loops, headerFocused: isHeaderFocused, onClose: () => pop(), onRefresh: refreshProjects, onOpenCreate: (trigger) => { createProjectTriggerRef.current = trigger; }, onEnterHeader: () => { } })) : (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(Navigator, { visible: visible, total: loops.length, selectedIndex: clampedIndex, filters: filters, sort: sort, breakpoint: breakpoint, focused: derivedFocusedPanel === "loops" || derivedFocusedPanel === "navigator", projects: projects, onSelect: (index) => setSelectedIndex(index), onActivate: (index) => { setSelectedIndex(index); const loop = visible[index]; if (loop) {
|
|
365
401
|
setEditTarget(loop);
|
|
366
402
|
push("create");
|
|
367
|
-
} } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(Inspector, { loop: selected }), _jsx(RunHistory, { loop: selected, selectedRunIndex: selectedRunIndex, focused:
|
|
403
|
+
} } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(Inspector, { loop: selected }), _jsx(RunHistory, { loop: selected, selectedRunIndex: selectedRunIndex, focused: derivedFocusedPanel === "runs" || derivedFocusedPanel === "run-history", onSelectRun: (index) => setSelectedRunIndex(index), onOpenRun: handleOpenRunLog }), _jsx(ActionButtons, { loop: selected, focused: actionIdx >= 0, selectedAction: derivedSelectedAction, onAction: handleAction })] })] })) }, viewKey(view, editTarget, editTask)), _jsx(Footer, { mode: mode }), confirm ? (_jsx(ConfirmModal, { message: confirm.message, choice: confirmChoice, onYes: () => { const action = confirm.action; setConfirm(null); void action(); }, onNo: () => setConfirm(null) })) : null, helpOpen ? _jsx(HelpModal, { view: view }) : null, contextHelpOpen ? _jsx(ContextHelpModal, { onClose: () => setContextHelpOpen(false) }) : null, logModalRun ? (_jsx(LogModal, { loopId: selectedId, run: logModalRun, logLines: logModalLines, loading: logModalLoading, onClose: () => setLogModalRun(null) })) : null, projectsModalOpen ? (_jsx(ProjectsModal, { projects: projects, loops: loops, currentProjectId: currentProjectId, onSelect: (id) => { setCurrentProjectId(id); setProjectsModalOpen(false); }, onClose: () => setProjectsModalOpen(false) })) : null, _jsx(ToastStack, { toasts: toasts })] }));
|
|
368
404
|
}
|
|
369
405
|
const TASK_FORM_VIEWS = new Set(["task-create", "task-edit"]);
|
|
@@ -8,6 +8,7 @@ import { commandLine } from "../format.js";
|
|
|
8
8
|
import { createLoop, updateLoop, listTasks } from "../daemon.js";
|
|
9
9
|
import { useHoverState } from "../hooks/useHoverState.js";
|
|
10
10
|
import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
|
|
11
|
+
import { useTabNav } from "../hooks/useTabNav.js";
|
|
11
12
|
import { SearchSelect } from "./SearchSelect.js";
|
|
12
13
|
import { HOVER_BG } from "../../config/constants.js";
|
|
13
14
|
export const TASK_MODE_INLINE = "inline";
|
|
@@ -45,25 +46,10 @@ export function CreateView(props) {
|
|
|
45
46
|
project: props.initial.project ?? props.currentProjectId ?? "default",
|
|
46
47
|
});
|
|
47
48
|
const valuesRef = useRef(values);
|
|
48
|
-
const [focusIndex, setFocusIndex] = useState(0);
|
|
49
49
|
const [error, setError] = useState("");
|
|
50
50
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
51
51
|
const [selectedTaskName, setSelectedTaskName] = useState(props.selectedTaskName ?? null);
|
|
52
52
|
const inputRef = useRef(null);
|
|
53
|
-
useInputShortcuts(() => {
|
|
54
|
-
if (focusIndex >= saveIndex)
|
|
55
|
-
return null;
|
|
56
|
-
return inputRef.current;
|
|
57
|
-
});
|
|
58
|
-
useState(() => {
|
|
59
|
-
if (values.taskId && !selectedTaskName) {
|
|
60
|
-
void listTasks().then((tasks) => {
|
|
61
|
-
const found = tasks.find((t) => t.id === values.taskId);
|
|
62
|
-
if (found)
|
|
63
|
-
setSelectedTaskName(found.name);
|
|
64
|
-
}).catch(() => { });
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
53
|
const { width: termWidth } = useTerminalDimensions();
|
|
68
54
|
const btnWidth = Math.max(10, Math.min(14, Math.floor(termWidth / 6)));
|
|
69
55
|
const isInline = values.taskMode === TASK_MODE_INLINE;
|
|
@@ -77,9 +63,22 @@ export function CreateView(props) {
|
|
|
77
63
|
return !isInline;
|
|
78
64
|
return true;
|
|
79
65
|
});
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
66
|
+
const navItems = [...filteredFields, "save", "cancel"];
|
|
67
|
+
const { setFocusIndex, focusedItem, isFocused } = useTabNav(navItems);
|
|
68
|
+
useInputShortcuts(() => {
|
|
69
|
+
if (focusedItem === "save" || focusedItem === "cancel")
|
|
70
|
+
return null;
|
|
71
|
+
return inputRef.current;
|
|
72
|
+
});
|
|
73
|
+
useState(() => {
|
|
74
|
+
if (values.taskId && !selectedTaskName) {
|
|
75
|
+
void listTasks().then((tasks) => {
|
|
76
|
+
const found = tasks.find((t) => t.id === values.taskId);
|
|
77
|
+
if (found)
|
|
78
|
+
setSelectedTaskName(found.name);
|
|
79
|
+
}).catch(() => { });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
83
82
|
function updateValues(next) {
|
|
84
83
|
valuesRef.current = next;
|
|
85
84
|
setValues(next);
|
|
@@ -91,57 +90,29 @@ export function CreateView(props) {
|
|
|
91
90
|
setSelectedTaskName(props.selectedTaskName ?? props.selectedTaskId);
|
|
92
91
|
}
|
|
93
92
|
useKeyboard((key) => {
|
|
94
|
-
if (key.name === "tab") {
|
|
95
|
-
setFocusIndex((i) => {
|
|
96
|
-
const next = key.shift ? i - 1 : i + 1;
|
|
97
|
-
if (next < 0)
|
|
98
|
-
return cancelIndex;
|
|
99
|
-
if (next > cancelIndex)
|
|
100
|
-
return 0;
|
|
101
|
-
return next;
|
|
102
|
-
});
|
|
103
|
-
key.preventDefault();
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
if (key.name === "up" || key.name === "down") {
|
|
107
|
-
const field = filteredFields[focusIndex];
|
|
108
|
-
if (field === "project")
|
|
109
|
-
return;
|
|
110
|
-
setFocusIndex((i) => {
|
|
111
|
-
const next = key.name === "up" ? i - 1 : i + 1;
|
|
112
|
-
if (next < 0)
|
|
113
|
-
return cancelIndex;
|
|
114
|
-
if (next > cancelIndex)
|
|
115
|
-
return 0;
|
|
116
|
-
return next;
|
|
117
|
-
});
|
|
118
|
-
key.preventDefault();
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
93
|
if (key.name === "return" || key.name === "enter" || key.name === " ") {
|
|
122
|
-
|
|
123
|
-
if (field === "taskMode") {
|
|
94
|
+
if (focusedItem === "taskMode") {
|
|
124
95
|
const next = valuesRef.current.taskMode === TASK_MODE_INLINE ? TASK_MODE_EXISTING : TASK_MODE_INLINE;
|
|
125
96
|
updateValues({ ...valuesRef.current, taskMode: next });
|
|
126
97
|
key.preventDefault();
|
|
127
98
|
return;
|
|
128
99
|
}
|
|
129
|
-
if (
|
|
100
|
+
if (focusedItem === "runNow") {
|
|
130
101
|
const next = valuesRef.current.runNow === "y" ? "n" : "y";
|
|
131
102
|
updateValues({ ...valuesRef.current, runNow: next });
|
|
132
103
|
key.preventDefault();
|
|
133
104
|
return;
|
|
134
105
|
}
|
|
135
|
-
if (
|
|
106
|
+
if (focusedItem === "taskId" && !isInline) {
|
|
136
107
|
props.onChooseTask();
|
|
137
108
|
key.preventDefault();
|
|
138
109
|
return;
|
|
139
110
|
}
|
|
140
|
-
if (
|
|
111
|
+
if (focusedItem === "save") {
|
|
141
112
|
void submit(valuesRef.current);
|
|
142
113
|
key.preventDefault();
|
|
143
114
|
}
|
|
144
|
-
else if (
|
|
115
|
+
else if (focusedItem === "cancel") {
|
|
145
116
|
props.onCancel();
|
|
146
117
|
key.preventDefault();
|
|
147
118
|
}
|
|
@@ -270,8 +241,8 @@ export function CreateView(props) {
|
|
|
270
241
|
return (_jsxs("box", { title: title, border: true, style: { flexDirection: "column", flexGrow: 1, padding: 1, backgroundColor: "#0b0b0b" }, children: [_jsxs("box", { style: { flexDirection: "column", marginBottom: 1 }, children: [_jsx("text", { fg: "#9ca3af", children: t("board.exampleHeading") }), _jsx("text", { fg: "#d1d5db", children: t("board.exampleFull") })] }), _jsx("box", { style: { flexDirection: "column", flexGrow: 1 }, children: Array.from({ length: Math.ceil(filteredFields.length / 2) }, (_, row) => {
|
|
271
242
|
const leftField = filteredFields[row * 2];
|
|
272
243
|
const rightField = row * 2 + 1 < filteredFields.length ? filteredFields[row * 2 + 1] : null;
|
|
273
|
-
return (_jsxs("box", { style: { flexDirection: "row", marginBottom: 1 }, children: [_jsx(FormRow, { field: leftField, index: row * 2,
|
|
274
|
-
}) }), _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:
|
|
244
|
+
return (_jsxs("box", { style: { flexDirection: "row", marginBottom: 1 }, children: [_jsx(FormRow, { field: leftField, index: row * 2, isFocused: isFocused(leftField), values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, examples: examples, taskModeOptions: taskModeOptions, runNowOptions: runNowOptions, projectOptions: projectOptions, selectedTaskName: selectedTaskName, onChooseTask: props.onChooseTask, inputRef: inputRef, style: { width: "50%", paddingRight: 1 } }), rightField ? (_jsx(FormRow, { field: rightField, index: row * 2 + 1, isFocused: isFocused(rightField), values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, examples: examples, taskModeOptions: taskModeOptions, runNowOptions: runNowOptions, projectOptions: projectOptions, selectedTaskName: selectedTaskName, onChooseTask: props.onChooseTask, inputRef: inputRef, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
|
|
245
|
+
}) }), _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] }));
|
|
275
246
|
}
|
|
276
247
|
function HoverButton(props) {
|
|
277
248
|
const { isHovered, hoverProps } = useHoverState();
|
|
@@ -280,9 +251,8 @@ function HoverButton(props) {
|
|
|
280
251
|
return (_jsx("box", { border: true, onMouseDown: props.onMouseDown, borderColor: borderColor, style: { width: props.width, justifyContent: "center", alignItems: "center", marginRight: props.marginRight, backgroundColor: bg }, ...hoverProps, children: _jsx("text", { fg: "#e5e7eb", children: _jsx("strong", { children: props.label }) }) }));
|
|
281
252
|
}
|
|
282
253
|
function FormRow(props) {
|
|
283
|
-
const { field, index,
|
|
254
|
+
const { field, index, isFocused, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, examples, taskModeOptions, runNowOptions, projectOptions, selectedTaskName, onChooseTask, inputRef, style } = props;
|
|
284
255
|
const { isHovered, hoverProps } = useHoverState();
|
|
285
|
-
const isFocused = focusIndex === index;
|
|
286
256
|
const isToggleField = field === "taskMode" || field === "runNow";
|
|
287
257
|
const isTaskButton = field === "taskId";
|
|
288
258
|
const isProjectField = field === "project";
|
|
@@ -293,12 +263,5 @@ function FormRow(props) {
|
|
|
293
263
|
return (_jsx("box", { onMouseDown: () => updateValues({ ...valuesRef.current, [field]: opt.value }), style: { backgroundColor: isActive ? "#1e3a8a" : undefined, paddingLeft: 1, paddingRight: 1, marginRight: 1 }, ...hoverProps, children: _jsx("text", { fg: isActive ? "#ffffff" : isHovered ? "#e5e7eb" : "#9ca3af", children: _jsx("strong", { children: opt.name }) }) }, opt.value));
|
|
294
264
|
}) })) : isTaskButton ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, flexDirection: "row", backgroundColor: "#0b0b0b", alignItems: "center", paddingLeft: 1 }, onMouseDown: () => { setFocusIndex(index); onChooseTask(); }, children: _jsx("text", { fg: values.taskId ? "#4ade80" : "#9ca3af", children: values.taskId
|
|
295
265
|
? t("board.selectedTask", { name: selectedTaskName ?? values.taskId })
|
|
296
|
-
: t("board.chooseTask") }) })) : isProjectField ? (_jsx(SearchSelect, { options: projectOptions, value: values.project, onChange: (v) => updateValues({ ...valuesRef.current, project: 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: examples[field] ? t("board.placeholderExample", { example: examples[field] }) : t("board.placeholderBlank"), onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
|
|
297
|
-
if (index < fields.length - 1) {
|
|
298
|
-
setFocusIndex(index + 1);
|
|
299
|
-
}
|
|
300
|
-
else {
|
|
301
|
-
void submit(valuesRef.current);
|
|
302
|
-
}
|
|
303
|
-
} }) }))] }));
|
|
266
|
+
: t("board.chooseTask") }) })) : isProjectField ? (_jsx(SearchSelect, { options: projectOptions, value: values.project, onChange: (v) => updateValues({ ...valuesRef.current, project: 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: examples[field] ? t("board.placeholderExample", { example: examples[field] }) : t("board.placeholderBlank"), onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => { void submit(valuesRef.current); } }) }))] }));
|
|
304
267
|
}
|
|
@@ -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 { createProject } from "../daemon.js";
|
|
@@ -12,15 +13,8 @@ export function CreateProjectModal(props) {
|
|
|
12
13
|
const [selectedColorKey, setSelectedColorKey] = useState(defaultColorKey);
|
|
13
14
|
const [error, setError] = useState("");
|
|
14
15
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
15
|
-
const
|
|
16
|
+
const { focusedItem: focusField, setFocusIndex } = useTabNav(["name", "color", "save", "cancel"]);
|
|
16
17
|
useKeyboard((key) => {
|
|
17
|
-
if (key.name === "tab") {
|
|
18
|
-
const order = ["name", "color", "save", "cancel"];
|
|
19
|
-
const idx = order.indexOf(focusField);
|
|
20
|
-
const next = key.shift ? order[(idx - 1 + order.length) % order.length] : order[(idx + 1) % order.length];
|
|
21
|
-
setFocusField(next ?? "name");
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
18
|
if (key.name === "escape") {
|
|
25
19
|
onCancel();
|
|
26
20
|
return;
|
|
@@ -34,14 +28,11 @@ export function CreateProjectModal(props) {
|
|
|
34
28
|
return;
|
|
35
29
|
}
|
|
36
30
|
if (focusField === "color") {
|
|
37
|
-
if (key.name === "left") {
|
|
38
|
-
const idx = PROJECT_COLOR_KEYS.indexOf(selectedColorKey);
|
|
39
|
-
setSelectedColorKey(PROJECT_COLOR_KEYS[(idx - 1 + PROJECT_COLOR_KEYS.length) % PROJECT_COLOR_KEYS.length] ?? selectedColorKey);
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
if (key.name === "right") {
|
|
31
|
+
if (key.name === "left" || key.name === "right") {
|
|
43
32
|
const idx = PROJECT_COLOR_KEYS.indexOf(selectedColorKey);
|
|
44
|
-
|
|
33
|
+
const dir = key.name === "left" ? -1 : 1;
|
|
34
|
+
setSelectedColorKey(PROJECT_COLOR_KEYS[(idx + dir + PROJECT_COLOR_KEYS.length) % PROJECT_COLOR_KEYS.length] ?? selectedColorKey);
|
|
35
|
+
key.preventDefault();
|
|
45
36
|
return;
|
|
46
37
|
}
|
|
47
38
|
}
|
|
@@ -81,9 +72,9 @@ export function CreateProjectModal(props) {
|
|
|
81
72
|
padding: 1,
|
|
82
73
|
minWidth: Math.min(44, width - 4),
|
|
83
74
|
backgroundColor: "#111827",
|
|
84
|
-
}, 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: "My Project", onInput: (value) => setName(value)
|
|
75
|
+
}, 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: "My Project", 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) => {
|
|
85
76
|
const isActive = selectedColorKey === colorKey;
|
|
86
|
-
return (_jsx("box", { onMouseDown: () => {
|
|
77
|
+
return (_jsx("box", { onMouseDown: () => { setFocusIndex(1); setSelectedColorKey(colorKey); }, style: {
|
|
87
78
|
backgroundColor: isActive ? "#1e3a8a" : undefined,
|
|
88
79
|
paddingLeft: 1,
|
|
89
80
|
paddingRight: 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);
|
|
@@ -6,6 +6,7 @@ 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";
|
|
10
11
|
import { SearchSelect } from "./SearchSelect.js";
|
|
11
12
|
const taskFields = ["name", "command", "onSuccessTaskId", "onFailureTaskId"];
|
|
@@ -23,20 +24,20 @@ function taskInitialValues(task) {
|
|
|
23
24
|
export function TaskForm(props) {
|
|
24
25
|
const [values, setValues] = useState(taskInitialValues(props.editTask));
|
|
25
26
|
const valuesRef = useRef(values);
|
|
26
|
-
const [focusIndex, setFocusIndex] = useState(0);
|
|
27
27
|
const [error, setError] = useState("");
|
|
28
28
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
29
29
|
const [allTasks, setAllTasks] = useState([]);
|
|
30
30
|
const inputRef = useRef(null);
|
|
31
31
|
const { width: termWidth } = useTerminalDimensions();
|
|
32
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);
|
|
33
35
|
useInputShortcuts(() => {
|
|
34
|
-
if (
|
|
35
|
-
return
|
|
36
|
-
|
|
36
|
+
if (focusedItem != null && focusedItem !== "save" && focusedItem !== "cancel") {
|
|
37
|
+
return inputRef.current;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
37
40
|
});
|
|
38
|
-
const saveIndex = taskFields.length;
|
|
39
|
-
const cancelIndex = taskFields.length + 1;
|
|
40
41
|
function updateValues(next) {
|
|
41
42
|
valuesRef.current = next;
|
|
42
43
|
setValues(next);
|
|
@@ -45,39 +46,12 @@ export function TaskForm(props) {
|
|
|
45
46
|
void listTasks().then(setAllTasks).catch(() => { });
|
|
46
47
|
});
|
|
47
48
|
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
49
|
if (key.name === "return" || key.name === "enter") {
|
|
76
|
-
if (
|
|
50
|
+
if (focusedItem === "save") {
|
|
77
51
|
void submit(valuesRef.current);
|
|
78
52
|
key.preventDefault();
|
|
79
53
|
}
|
|
80
|
-
else if (
|
|
54
|
+
else if (focusedItem === "cancel") {
|
|
81
55
|
props.onCancel();
|
|
82
56
|
key.preventDefault();
|
|
83
57
|
}
|
|
@@ -151,15 +125,14 @@ export function TaskForm(props) {
|
|
|
151
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) => {
|
|
152
126
|
const leftField = taskFields[row * 2];
|
|
153
127
|
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,
|
|
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:
|
|
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] }));
|
|
156
130
|
}
|
|
157
131
|
function TaskFormRow(props) {
|
|
158
|
-
const { field, index,
|
|
159
|
-
const isFocused = focusIndex === index;
|
|
132
|
+
const { field, index, focused, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, chainOptions, inputRef, style } = props;
|
|
160
133
|
const isSelect = field === "onSuccessTaskId" || field === "onFailureTaskId";
|
|
161
134
|
const selectOpts = isSelect ? chainOptions : [];
|
|
162
|
-
return (_jsxs("box", { style: { flexDirection: "column", ...style }, children: [_jsx("text", { fg:
|
|
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: () => {
|
|
163
136
|
if (index < taskFields.length - 1) {
|
|
164
137
|
setFocusIndex(index + 1);
|
|
165
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
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useKeyboard } from "@opentui/react";
|
|
2
|
-
import { nextTaskPanel } from "../components/TaskBrowser.js";
|
|
3
2
|
export function useTaskKeybindings(params) {
|
|
4
|
-
const { confirm, view, tasks, taskSelectedIndex, setTaskSelectedIndex, taskSelectedAction, setTaskSelectedAction, taskFocusedPanel, setTaskFocusedPanel, taskSearchActive, setTaskSearchActive, taskQuery, setTaskQuery, onTaskAction, onCancel, onCreateTask,
|
|
3
|
+
const { confirm, view, tasks, taskSelectedIndex, setTaskSelectedIndex, taskSelectedAction, setTaskSelectedAction, taskFocusedPanel, setTaskFocusedPanel, taskSearchActive, setTaskSearchActive, taskQuery, setTaskQuery, onTaskAction, onCancel, onCreateTask, onToggleContextHelp, headerFocused = false, selectable = true, } = params;
|
|
5
4
|
const taskActions = selectable
|
|
6
5
|
? ["select", "edit", "delete"]
|
|
7
6
|
: ["edit", "delete"];
|
|
@@ -9,6 +8,8 @@ export function useTaskKeybindings(params) {
|
|
|
9
8
|
useKeyboard((key) => {
|
|
10
9
|
if (view !== "task-list")
|
|
11
10
|
return;
|
|
11
|
+
if (headerFocused)
|
|
12
|
+
return;
|
|
12
13
|
const name = key.name;
|
|
13
14
|
if (confirm)
|
|
14
15
|
return;
|
|
@@ -51,34 +52,6 @@ export function useTaskKeybindings(params) {
|
|
|
51
52
|
key.preventDefault();
|
|
52
53
|
return;
|
|
53
54
|
}
|
|
54
|
-
if (name === "tab") {
|
|
55
|
-
const direction = key.shift ? "left" : "right";
|
|
56
|
-
if (taskFocusedPanel === "actions") {
|
|
57
|
-
if (direction === "left" && taskSelectedAction === 0) {
|
|
58
|
-
setTaskFocusedPanel((p) => nextTaskPanel(p, "left"));
|
|
59
|
-
}
|
|
60
|
-
else if (direction === "right" && taskSelectedAction === taskActionCount - 1) {
|
|
61
|
-
onEnterHeader("right");
|
|
62
|
-
key.preventDefault();
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
else {
|
|
66
|
-
setTaskSelectedAction((i) => direction === "right"
|
|
67
|
-
? Math.min(taskActionCount - 1, i + 1)
|
|
68
|
-
: Math.max(0, i - 1));
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
else if (taskFocusedPanel === "search" && direction === "left") {
|
|
72
|
-
onEnterHeader("left");
|
|
73
|
-
key.preventDefault();
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
else {
|
|
77
|
-
setTaskFocusedPanel((p) => nextTaskPanel(p, direction));
|
|
78
|
-
}
|
|
79
|
-
key.preventDefault();
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
55
|
if (taskFocusedPanel === "tasks") {
|
|
83
56
|
if (name === "up" || name === "k") {
|
|
84
57
|
setTaskSelectedIndex((i) => Math.max(0, i - 1));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loop-task",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Loop engineering toolkit. Run any command on a cadence, in the background, managed from a terminal board. Schedule tests, builds, syncs, or agent prompts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|