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 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 return only JSON with fields title and body." --model "opencode/big-pickle"
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
- stdout: `{"title":"As a user, I want to log in securely","body":"## Acceptance Criteria\n- Login form validates email\n- ..."}`
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): Apply the rewrite and relabel
301
+ **Task 4** (chain, onSuccess): Relabel as ready to implement
303
302
 
304
303
  ```bash
305
- gh issue edit {{number}} --title "{{title}}" --body "{{body}}" --remove-label "refining" --add-label "to implement"
304
+ gh issue edit {{number}} --remove-label "refining" --add-label "to implement"
306
305
  ```
307
306
 
308
- interpolated: `gh issue edit 123 --title "As a user, I want to log in securely" --body "## Acceptance Criteria\n- ..." --remove-label "refining" --add-label "to implement"`
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. It outputs a new JSON object with updated `title` and `body`. Since it uses the same key names, the context is updated with the new values (merge with last-writer-wins).
315
- 4. Task 4 receives `{{number}}` (still 123 from task 1), `{{title}}` and `{{body}}` (now the rewritten versions from task 3). It applies the edits and relabels the issue as "to implement" - no re-query needed.
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
- selectedAction,
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
- taskFocusedPanel,
335
- setTaskFocusedPanel,
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: focusedPanel, 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") {
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: focusedPanel, 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); setFocusedPanel("loops"); } })) : view === "task-list" ? (_jsx(TaskFilterBar, { query: taskQuery, searchActive: taskSearchActive, focusedPanel: taskFocusedPanel, onQueryChange: setTaskQuery, onSearchActivate: () => setTaskSearchActive(true), onSearchDismiss: () => { setTaskSearchActive(false); setTaskFocusedPanel("tasks"); } })) : 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: taskFocusedPanel === "tasks", query: taskQuery, onSelect: (index) => { setTaskSelectedIndex(index); setTaskFocusedPanel("tasks"); }, 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: taskFocusedPanel === "actions", selectedAction: taskSelectedAction, selectable: stack.includes("create") || stack.includes("task-edit"), onAction: handleTaskAction }, `tab-${selectedTask?.id}`)] })] })) : view === "projects" ? (_jsx(ProjectsPage, { projects: projects, loops: loops, onClose: () => pop(), onRefresh: refreshProjects, onOpenCreate: (trigger) => { createProjectTriggerRef.current = trigger; }, onEnterHeader: (direction) => {
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: focusedPanel === "runs", onSelectRun: (index) => { setSelectedRunIndex(index); setFocusedPanel("runs"); }, onOpenRun: handleOpenRunLog }), _jsx(ActionButtons, { loop: selected, focused: focusedPanel === "actions", selectedAction: selectedAction, 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 })] }));
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 chooseTaskIdx = filteredFields.indexOf("taskId");
81
- const saveIndex = filteredFields.length;
82
- const cancelIndex = filteredFields.length + 1;
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
- const field = filteredFields[focusIndex];
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 (field === "runNow") {
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 (focusIndex === chooseTaskIdx && !isInline) {
106
+ if (focusedItem === "taskId" && !isInline) {
136
107
  props.onChooseTask();
137
108
  key.preventDefault();
138
109
  return;
139
110
  }
140
- if (focusIndex === saveIndex) {
111
+ if (focusedItem === "save") {
141
112
  void submit(valuesRef.current);
142
113
  key.preventDefault();
143
114
  }
144
- else if (focusIndex === cancelIndex) {
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, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, examples: examples, taskModeOptions: taskModeOptions, runNowOptions: runNowOptions, projectOptions: projectOptions, selectedTaskName: selectedTaskName, fields: filteredFields, onChooseTask: props.onChooseTask, inputRef: inputRef, style: { width: "50%", paddingRight: 1 } }), rightField ? (_jsx(FormRow, { field: rightField, index: row * 2 + 1, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, examples: examples, taskModeOptions: taskModeOptions, runNowOptions: runNowOptions, projectOptions: projectOptions, selectedTaskName: selectedTaskName, fields: filteredFields, onChooseTask: props.onChooseTask, inputRef: inputRef, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
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: 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] }));
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, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, examples, taskModeOptions, runNowOptions, projectOptions, selectedTaskName, fields, onChooseTask, inputRef, style } = props;
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 [focusField, setFocusField] = useState("name");
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
- setSelectedColorKey(PROJECT_COLOR_KEYS[(idx + 1) % PROJECT_COLOR_KEYS.length] ?? selectedColorKey);
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), onSubmit: () => setFocusField("color") }) }), _jsx("text", { fg: focusField === "color" ? "#38bdf8" : "#e5e7eb", children: t("project.labelColor") }), _jsx("text", { fg: "#6b7280", children: t("project.hintColor") }), _jsx("box", { border: true, borderColor: focusField === "color" ? "#38bdf8" : undefined, style: { height: 3, flexDirection: "row", backgroundColor: "#0b0b0b", alignItems: "center", marginBottom: 1 }, children: PROJECT_COLOR_KEYS.map((colorKey) => {
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: () => { setFocusField("color"); setSelectedColorKey(colorKey); }, style: {
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 [focusField, setFocusField] = useState("name");
18
+ const { focusedItem: focusField, setFocusIndex } = useTabNav(["name", "color", "save", "cancel"]);
18
19
  useKeyboard((key) => {
19
- if (key.name === "tab") {
20
- const order = ["name", "color", "save", "cancel"];
21
- const idx = order.indexOf(focusField);
22
- const next = key.shift ? order[(idx - 1 + order.length) % order.length] : order[(idx + 1) % order.length];
23
- setFocusField(next ?? "name");
24
- return;
25
- }
26
20
  if (key.name === "escape") {
27
21
  onCancel();
28
22
  return;
@@ -36,14 +30,11 @@ export function EditProjectModal(props) {
36
30
  return;
37
31
  }
38
32
  if (focusField === "color") {
39
- if (key.name === "left") {
40
- const idx = PROJECT_COLOR_KEYS.indexOf(selectedColorKey);
41
- setSelectedColorKey(PROJECT_COLOR_KEYS[(idx - 1 + PROJECT_COLOR_KEYS.length) % PROJECT_COLOR_KEYS.length] ?? selectedColorKey);
42
- return;
43
- }
44
- if (key.name === "right") {
33
+ if (key.name === "left" || key.name === "right") {
45
34
  const idx = PROJECT_COLOR_KEYS.indexOf(selectedColorKey);
46
- setSelectedColorKey(PROJECT_COLOR_KEYS[(idx + 1) % PROJECT_COLOR_KEYS.length] ?? selectedColorKey);
35
+ const dir = key.name === "left" ? -1 : 1;
36
+ setSelectedColorKey(PROJECT_COLOR_KEYS[(idx + dir + PROJECT_COLOR_KEYS.length) % PROJECT_COLOR_KEYS.length] ?? selectedColorKey);
37
+ key.preventDefault();
47
38
  return;
48
39
  }
49
40
  }
@@ -83,9 +74,9 @@ export function EditProjectModal(props) {
83
74
  padding: 1,
84
75
  minWidth: Math.min(44, width - 4),
85
76
  backgroundColor: "#111827",
86
- }, children: [_jsx("text", { fg: focusField === "name" ? "#38bdf8" : "#e5e7eb", children: t("project.labelName") }), _jsx("text", { fg: "#6b7280", children: t("project.hintName") }), _jsx("box", { border: true, borderColor: focusField === "name" ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b", marginBottom: 1 }, children: _jsx("input", { focused: focusField === "name", value: name, placeholder: project.name, onInput: (value) => setName(value), onSubmit: () => setFocusField("color") }) }), _jsx("text", { fg: focusField === "color" ? "#38bdf8" : "#e5e7eb", children: t("project.labelColor") }), _jsx("text", { fg: "#6b7280", children: t("project.hintColor") }), _jsx("box", { border: true, borderColor: focusField === "color" ? "#38bdf8" : undefined, style: { height: 3, flexDirection: "row", backgroundColor: "#0b0b0b", alignItems: "center", marginBottom: 1 }, children: PROJECT_COLOR_KEYS.map((colorKey) => {
77
+ }, children: [_jsx("text", { fg: focusField === "name" ? "#38bdf8" : "#e5e7eb", children: t("project.labelName") }), _jsx("text", { fg: "#6b7280", children: t("project.hintName") }), _jsx("box", { border: true, borderColor: focusField === "name" ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b", marginBottom: 1 }, children: _jsx("input", { focused: focusField === "name", value: name, placeholder: project.name, onInput: (value) => setName(value) }) }), _jsx("text", { fg: focusField === "color" ? "#38bdf8" : "#e5e7eb", children: t("project.labelColor") }), _jsx("text", { fg: "#6b7280", children: t("project.hintColor") }), _jsx("box", { border: true, borderColor: focusField === "color" ? "#38bdf8" : undefined, style: { height: 3, flexDirection: "row", backgroundColor: "#0b0b0b", alignItems: "center", marginBottom: 1 }, children: PROJECT_COLOR_KEYS.map((colorKey) => {
87
78
  const isActive = selectedColorKey === colorKey;
88
- return (_jsx("box", { onMouseDown: () => { setFocusField("color"); setSelectedColorKey(colorKey); }, style: {
79
+ return (_jsx("box", { onMouseDown: () => { setFocusIndex(1); setSelectedColorKey(colorKey); }, style: {
89
80
  backgroundColor: isActive ? "#1e3a8a" : undefined,
90
81
  paddingLeft: 1,
91
82
  paddingRight: 1,
@@ -1,13 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
2
- import { useState } from "react";
2
+ import { useState, useEffect, useRef } from "react";
3
3
  import { useKeyboard } from "@opentui/react";
4
4
  import { t } from "../../i18n/index.js";
5
5
  import { useHoverState } from "../hooks/useHoverState.js";
6
+ import { useTabNav } from "../hooks/useTabNav.js";
6
7
  import { HOVER_BG, ENTITY_COLORS } from "../../config/constants.js";
7
8
  import { CreateProjectModal } from "./CreateProjectModal.js";
8
9
  import { EditProjectModal } from "./EditProjectModal.js";
9
10
  import { DeleteProjectConfirm } from "./DeleteProjectConfirm.js";
10
- const PROJECT_ACTION_COUNT = 2;
11
11
  function ProjectActionButton(props) {
12
12
  const { isHovered, hoverProps } = useHoverState();
13
13
  const bg = props.selected ? "#1e3a8a" : isHovered ? HOVER_BG : undefined;
@@ -15,11 +15,25 @@ function ProjectActionButton(props) {
15
15
  return (_jsx("box", { onMouseDown: props.onMouseDown, style: { backgroundColor: bg, paddingLeft: 1, paddingRight: 1, marginRight: 1 }, ...hoverProps, children: _jsx("text", { fg: fg, children: _jsx("strong", { children: props.label }) }) }));
16
16
  }
17
17
  export function ProjectsPage(props) {
18
- const { projects, loops, onClose, onRefresh, onOpenCreate, onEnterHeader } = props;
18
+ const { projects, loops, headerFocused, onClose, onRefresh, onOpenCreate, onEnterHeader } = props;
19
19
  const [selectedIndex, setSelectedIndex] = useState(0);
20
20
  const [subModal, setSubModal] = useState("none");
21
- const [focusedPanel, setFocusedPanel] = useState("list");
22
- const [selectedAction, setSelectedAction] = useState(0);
21
+ const navItems = ["list", "edit", "delete"];
22
+ const { setFocusIndex, focusedItem, isFocused } = useTabNav(navItems, {
23
+ onCycleOut: (dir) => {
24
+ if (dir === "right")
25
+ onEnterHeader?.("right");
26
+ else
27
+ onEnterHeader?.("left");
28
+ },
29
+ });
30
+ const prevHeaderFocused = useRef(headerFocused);
31
+ useEffect(() => {
32
+ if (prevHeaderFocused.current && !headerFocused) {
33
+ setFocusIndex(0);
34
+ }
35
+ prevHeaderFocused.current = headerFocused;
36
+ }, [headerFocused]);
23
37
  // Expose create trigger to parent via callback
24
38
  useState(() => { onOpenCreate?.(() => setSubModal("create")); });
25
39
  const clampedIndex = Math.min(selectedIndex, Math.max(0, projects.length - 1));
@@ -36,44 +50,37 @@ export function ProjectsPage(props) {
36
50
  useKeyboard((key) => {
37
51
  if (subModal !== "none")
38
52
  return;
39
- if (key.name === "up") {
40
- if (focusedPanel === "list")
41
- setSelectedIndex((i) => Math.max(0, i - 1));
42
- else
43
- setSelectedAction((a) => Math.max(0, a - 1));
44
- key.preventDefault();
45
- return;
46
- }
47
- if (key.name === "down") {
48
- if (focusedPanel === "list")
49
- setSelectedIndex((i) => Math.min(projects.length - 1, i + 1));
50
- else
51
- setSelectedAction((a) => Math.min(PROJECT_ACTION_COUNT - 1, a + 1));
52
- key.preventDefault();
53
+ if (headerFocused)
53
54
  return;
54
- }
55
- if (key.name === "tab") {
56
- const direction = key.shift ? "left" : "right";
57
- if (focusedPanel === "list" && direction === "left") {
58
- onEnterHeader?.("left");
55
+ if (key.name === "up" || key.name === "down") {
56
+ if (focusedItem !== "list") {
59
57
  key.preventDefault();
60
58
  return;
61
59
  }
62
- if (focusedPanel === "actions" && direction === "right") {
63
- onEnterHeader?.("right");
64
- key.preventDefault();
65
- return;
60
+ if (key.name === "up") {
61
+ setSelectedIndex((i) => Math.max(0, i - 1));
66
62
  }
67
- setFocusedPanel((p) => (p === "list" ? "actions" : "list"));
63
+ else {
64
+ setSelectedIndex((i) => Math.min(projects.length - 1, i + 1));
65
+ }
66
+ key.preventDefault();
67
+ return;
68
+ }
69
+ if (key.name === "left" || key.name === "right") {
68
70
  key.preventDefault();
69
71
  return;
70
72
  }
71
73
  if (key.name === "return" || key.name === "enter") {
72
- if (focusedPanel === "list") {
73
- setFocusedPanel("actions");
74
+ if (focusedItem === "list") {
75
+ setFocusIndex(1);
74
76
  }
75
- else {
76
- runAction(selectedAction);
77
+ else if (focusedItem === "edit") {
78
+ if (selectedProject && !selectedProject.isSystem)
79
+ setSubModal("edit");
80
+ }
81
+ else if (focusedItem === "delete") {
82
+ if (selectedProject && !selectedProject.isSystem)
83
+ setSubModal("delete");
77
84
  }
78
85
  key.preventDefault();
79
86
  return;
@@ -102,12 +109,12 @@ export function ProjectsPage(props) {
102
109
  { key: "edit", label: t("project.editProjectLabel") },
103
110
  { key: "delete", label: t("project.deleteProjectLabel") },
104
111
  ];
105
- return (_jsxs("box", { style: { flexDirection: "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsxs("box", { title: t("project.projectsTitle"), border: true, borderColor: focusedPanel === "list" ? ENTITY_COLORS.project : "#1e3a2a", style: { width: "40%", flexDirection: "column", backgroundColor: "#0b0b0b", flexShrink: 0 }, children: [_jsxs("text", { fg: "#9ca3af", children: [t("project.keyNewHint"), " | ", t("project.keyEditHint"), " | ", t("project.keyDeleteHint")] }), projects.map((project, index) => {
112
+ return (_jsxs("box", { style: { flexDirection: "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsxs("box", { title: t("project.projectsTitle"), border: true, borderColor: focusedItem === "list" ? ENTITY_COLORS.project : "#1e3a2a", style: { width: "40%", flexDirection: "column", backgroundColor: "#0b0b0b", flexShrink: 0 }, children: [_jsxs("text", { fg: "#9ca3af", children: [t("project.keyNewHint"), " | ", t("project.keyEditHint"), " | ", t("project.keyDeleteHint")] }), projects.map((project, index) => {
106
113
  const isSelected = index === clampedIndex;
107
114
  const bg = isSelected ? "#1e3a8a" : undefined;
108
115
  const count = loopCount(project.id);
109
- return (_jsx("box", { backgroundColor: bg, onMouseDown: () => { setSelectedIndex(index); setFocusedPanel("list"); }, style: { height: 1 }, children: _jsxs("text", { children: [isSelected ? "› " : " ", _jsx("span", { fg: project.color, children: "\u25CF" }), ` ${project.name} `, _jsx("span", { fg: "#6b7280", children: `(${count})` })] }) }, project.id));
110
- })] }), _jsxs("box", { style: { flexGrow: 1, flexDirection: "column", backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx("box", { title: t("board.inspectorTitle"), border: true, style: { flexGrow: 1, flexDirection: "column", backgroundColor: "#0b0b0b", padding: 1 }, children: selectedProject ? (_jsxs(_Fragment, { children: [_jsxs("text", { children: [_jsx("span", { fg: selectedProject.color, children: "\u25CF" }), ` `, _jsx("strong", { children: selectedProject.name })] }), _jsx("text", { fg: "#6b7280", children: t("project.loopCount", { count: String(loopCount(selectedProject.id)) }) }), selectedProject.isSystem ? (_jsx("text", { fg: "#9ca3af", children: t("project.systemLabel") })) : null] })) : (_jsx("text", { fg: "#9ca3af", children: t("project.noLoops") })) }), _jsx("box", { border: true, borderColor: focusedPanel === "actions" ? ENTITY_COLORS.project : undefined, style: { flexDirection: "row", height: 3, flexShrink: 0, paddingLeft: 1, backgroundColor: "#0b0b0b", alignItems: "center", justifyContent: selectedProject?.isSystem ? "center" : "flex-start" }, children: selectedProject && !selectedProject.isSystem ? (actions.map((action, i) => (_jsx(ProjectActionButton, { label: action.label, selected: focusedPanel === "actions" && selectedAction === i, onMouseDown: () => { setFocusedPanel("actions"); runAction(i); } }, action.key)))) : (_jsx("text", { fg: "#9ca3af", children: t("project.systemLabel") })) })] }), subModal === "create" ? (_jsx(CreateProjectModal, { onDone: async (project) => {
116
+ return (_jsx("box", { backgroundColor: bg, onMouseDown: () => { setSelectedIndex(index); setFocusIndex(0); }, style: { height: 1 }, children: _jsxs("text", { children: [isSelected ? "› " : " ", _jsx("span", { fg: project.color, children: "\u25CF" }), ` ${project.name} `, _jsx("span", { fg: "#6b7280", children: `(${count})` })] }) }, project.id));
117
+ })] }), _jsxs("box", { style: { flexGrow: 1, flexDirection: "column", backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx("box", { title: t("board.inspectorTitle"), border: true, style: { flexGrow: 1, flexDirection: "column", backgroundColor: "#0b0b0b", padding: 1 }, children: selectedProject ? (_jsxs(_Fragment, { children: [_jsxs("text", { children: [_jsx("span", { fg: selectedProject.color, children: "\u25CF" }), ` `, _jsx("strong", { children: selectedProject.name })] }), _jsx("text", { fg: "#6b7280", children: t("project.loopCount", { count: String(loopCount(selectedProject.id)) }) }), selectedProject.isSystem ? (_jsx("text", { fg: "#9ca3af", children: t("project.systemLabel") })) : null] })) : (_jsx("text", { fg: "#9ca3af", children: t("project.noLoops") })) }), _jsx("box", { border: true, borderColor: focusedItem === "edit" || focusedItem === "delete" ? ENTITY_COLORS.project : undefined, style: { flexDirection: "row", height: 3, flexShrink: 0, paddingLeft: 1, backgroundColor: "#0b0b0b", alignItems: "center", justifyContent: selectedProject?.isSystem ? "center" : "flex-start" }, children: selectedProject && !selectedProject.isSystem ? (actions.map((action, i) => (_jsx(ProjectActionButton, { label: action.label, selected: isFocused(action.key), onMouseDown: () => { setFocusIndex(i + 1); runAction(i); } }, action.key)))) : (_jsx("text", { fg: "#9ca3af", children: t("project.systemLabel") })) })] }), subModal === "create" ? (_jsx(CreateProjectModal, { onDone: async (project) => {
111
118
  setSubModal("none");
112
119
  await onRefresh();
113
120
  const newIndex = projects.findIndex((p) => p.id === project.id);
@@ -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 (focusIndex >= saveIndex)
35
- return null;
36
- return inputRef.current;
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 (focusIndex === saveIndex) {
50
+ if (focusedItem === "save") {
77
51
  void submit(valuesRef.current);
78
52
  key.preventDefault();
79
53
  }
80
- else if (focusIndex === cancelIndex) {
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, 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] }));
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, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, chainOptions, inputRef, style } = props;
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: 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: () => {
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.onViewTasks?.();
121
+ p.onViewProjects?.();
122
122
  return true;
123
123
  }
124
124
  return false;
125
125
  },
126
126
  "header-projects": (key, p) => {
127
127
  if (key === "return" || key === "enter") {
128
- p.onViewProjects?.();
128
+ p.onViewTasks?.();
129
129
  return true;
130
130
  }
131
131
  return false;
@@ -250,20 +250,6 @@ export function useBoardKeybindings(params) {
250
250
  key.preventDefault();
251
251
  return;
252
252
  }
253
- if (name === "tab" && view !== "board") {
254
- const HEADER_PANELS = ["header-tasks", "header-projects", "header-new"];
255
- const isHeader = HEADER_PANELS.includes(focusedPanel);
256
- const direction = key.shift ? "left" : "right";
257
- if (isHeader) {
258
- const idx = HEADER_PANELS.indexOf(focusedPanel);
259
- const nextIdx = direction === "right"
260
- ? (idx + 1) % HEADER_PANELS.length
261
- : (idx - 1 + HEADER_PANELS.length) % HEADER_PANELS.length;
262
- setFocusedPanel(HEADER_PANELS[nextIdx]);
263
- key.preventDefault();
264
- return;
265
- }
266
- }
267
253
  if (view !== "board" && (name === "return" || name === "enter")) {
268
254
  if (view === "task-list") {
269
255
  if (focusedPanel === "header-tasks") {
@@ -294,7 +280,7 @@ export function useBoardKeybindings(params) {
294
280
  return;
295
281
  }
296
282
  if (focusedPanel === "header-new") {
297
- onViewProjects?.();
283
+ onAddLoop?.();
298
284
  key.preventDefault();
299
285
  return;
300
286
  }
@@ -319,42 +305,14 @@ export function useBoardKeybindings(params) {
319
305
  }
320
306
  if (view !== "board")
321
307
  return;
322
- if (name === "tab") {
323
- const direction = key.shift ? "left" : "right";
324
- if (focusedPanel === "actions") {
325
- const actionCount = selected ? getActionCount(selected.status) : 0;
326
- if (direction === "left" && selectedAction === 0) {
327
- setFocusedPanel((p) => nextPanel(p, "left"));
328
- }
329
- else if (direction === "right" && selectedAction === actionCount - 1) {
330
- setFocusedPanel((p) => nextPanel(p, "right"));
331
- }
332
- else {
333
- setSelectedAction((i) => direction === "right"
334
- ? Math.min(actionCount - 1, i + 1)
335
- : Math.max(0, i - 1));
336
- }
337
- }
338
- else {
339
- const next = nextPanel(focusedPanel, direction);
340
- if (next === "actions") {
341
- setFocusedPanel("actions");
342
- setSelectedAction(0);
343
- }
344
- else {
345
- setFocusedPanel(next);
346
- }
347
- }
348
- key.preventDefault();
349
- return;
350
- }
351
308
  const globalHandler = GLOBAL_KEYS[name];
352
309
  if (globalHandler) {
353
310
  globalHandler({ destroyLogSocket, onQuit, setHelpOpen, setEditTarget, setEditTask, push, onAction });
354
311
  key.preventDefault();
355
312
  return;
356
313
  }
357
- const panelHandler = panelHandlers[focusedPanel];
314
+ const isActionPanel = typeof focusedPanel === "string" && focusedPanel.startsWith("action-");
315
+ const panelHandler = isActionPanel ? panelHandlers["actions"] : panelHandlers[focusedPanel];
358
316
  if (panelHandler?.(name, {
359
317
  setSearchActive, setFilters, setSort, setEditTarget, push,
360
318
  setSelectedIndex, setSelectedRunIndex, setFocusedPanel, selectedRunCount, selected,
@@ -0,0 +1,60 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ export function useTabNav(items, options) {
4
+ const initial = options?.initialIndex ?? 0;
5
+ const [focusIndex, setFocusIndex] = useState(Math.min(initial, Math.max(0, items.length - 1)));
6
+ const [enabled, setEnabled] = useState(true);
7
+ const focusIndexRef = useRef(focusIndex);
8
+ focusIndexRef.current = focusIndex;
9
+ const itemsRef = useRef(items);
10
+ itemsRef.current = items;
11
+ const onCycleOutRef = useRef(options?.onCycleOut);
12
+ onCycleOutRef.current = options?.onCycleOut;
13
+ const enabledRef = useRef(enabled);
14
+ enabledRef.current = enabled;
15
+ useEffect(() => {
16
+ setFocusIndex((i) => Math.min(i, Math.max(0, items.length - 1)));
17
+ }, [items.length]);
18
+ useKeyboard((key) => {
19
+ if (!enabledRef.current)
20
+ return;
21
+ if (key.name !== "tab")
22
+ return;
23
+ const currentItems = itemsRef.current;
24
+ const currentIdx = focusIndexRef.current;
25
+ const lastIndex = currentItems.length - 1;
26
+ if (lastIndex < 0)
27
+ return;
28
+ const direction = key.shift ? "left" : "right";
29
+ if (direction === "right") {
30
+ if (currentIdx >= lastIndex) {
31
+ if (onCycleOutRef.current) {
32
+ onCycleOutRef.current("right");
33
+ }
34
+ else {
35
+ setFocusIndex(0);
36
+ }
37
+ }
38
+ else {
39
+ setFocusIndex(currentIdx + 1);
40
+ }
41
+ }
42
+ else {
43
+ if (currentIdx <= 0) {
44
+ if (onCycleOutRef.current) {
45
+ onCycleOutRef.current("left");
46
+ }
47
+ else {
48
+ setFocusIndex(lastIndex);
49
+ }
50
+ }
51
+ else {
52
+ setFocusIndex(currentIdx - 1);
53
+ }
54
+ }
55
+ key.preventDefault();
56
+ });
57
+ const focusedItem = items[focusIndex];
58
+ const isFocused = useCallback((item) => item === focusedItem, [focusedItem]);
59
+ return { focusIndex, setFocusIndex, focusedItem, isFocused, enabled, setEnabled };
60
+ }
@@ -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, onEnterHeader, onToggleContextHelp, selectable = true, } = params;
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.0",
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": {