loop-task 1.4.3 → 1.4.5

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/dist/board/App.js CHANGED
@@ -310,7 +310,9 @@ export function App(props) {
310
310
  refreshTasks,
311
311
  onViewTasks: () => { void refreshTasks(); push("task-list"); },
312
312
  onViewProjects: () => push("projects"),
313
+ onViewLoops: () => replace("board"),
313
314
  onAddLoop: () => { setEditTarget(null); push("create"); },
315
+ onAddTask: () => { setEditTask(null); push("task-create"); },
314
316
  onSelectProject: () => setProjectsModalOpen(true),
315
317
  });
316
318
  useTaskKeybindings({
@@ -330,6 +332,9 @@ export function App(props) {
330
332
  onTaskAction: handleTaskAction,
331
333
  onCancel: cancelTaskList,
332
334
  onCreateTask: handleCreateTask,
335
+ onEnterHeader: (direction) => {
336
+ setFocusedPanel(direction === "right" ? "header-tasks" : "header-new");
337
+ },
333
338
  selectable: stack.includes("create") || stack.includes("task-edit"),
334
339
  });
335
340
  const counts = {
@@ -345,7 +350,9 @@ export function App(props) {
345
350
  }
346
351
  else {
347
352
  push("projects");
348
- } } }), 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; } })) : (_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) {
353
+ } } }), 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) => {
354
+ setFocusedPanel(direction === "right" ? "header-tasks" : "header-new");
355
+ } })) : (_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) {
349
356
  setEditTarget(loop);
350
357
  push("create");
351
358
  } } }), _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, 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 })] }));
@@ -7,6 +7,7 @@ import { t } from "../../i18n/index.js";
7
7
  import { commandLine } from "../format.js";
8
8
  import { createLoop, updateLoop, listTasks } from "../daemon.js";
9
9
  import { useHoverState } from "../hooks/useHoverState.js";
10
+ import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
10
11
  import { HOVER_BG } from "../../config/constants.js";
11
12
  export const TASK_MODE_INLINE = "inline";
12
13
  export const TASK_MODE_EXISTING = "existing";
@@ -47,6 +48,12 @@ export function CreateView(props) {
47
48
  const [error, setError] = useState("");
48
49
  const [isSubmitting, setIsSubmitting] = useState(false);
49
50
  const [selectedTaskName, setSelectedTaskName] = useState(props.selectedTaskName ?? null);
51
+ const inputRef = useRef(null);
52
+ useInputShortcuts(() => {
53
+ if (focusIndex >= saveIndex)
54
+ return null;
55
+ return inputRef.current;
56
+ });
50
57
  useState(() => {
51
58
  if (values.taskId && !selectedTaskName) {
52
59
  void listTasks().then((tasks) => {
@@ -92,6 +99,31 @@ export function CreateView(props) {
92
99
  return 0;
93
100
  return next;
94
101
  });
102
+ key.preventDefault();
103
+ return;
104
+ }
105
+ if (key.name === "up" || key.name === "down") {
106
+ const field = filteredFields[focusIndex];
107
+ if (field === "project" && projectOptions.length > 1) {
108
+ const currentIdx = projectOptions.findIndex((p) => p.value === valuesRef.current.project);
109
+ const nextIdx = key.name === "down"
110
+ ? (currentIdx + 1) % projectOptions.length
111
+ : (currentIdx - 1 + projectOptions.length) % projectOptions.length;
112
+ const next = projectOptions[nextIdx];
113
+ if (next)
114
+ updateValues({ ...valuesRef.current, project: next.value });
115
+ key.preventDefault();
116
+ return;
117
+ }
118
+ setFocusIndex((i) => {
119
+ const next = key.name === "up" ? i - 1 : i + 1;
120
+ if (next < 0)
121
+ return cancelIndex;
122
+ if (next > cancelIndex)
123
+ return 0;
124
+ return next;
125
+ });
126
+ key.preventDefault();
95
127
  return;
96
128
  }
97
129
  if (key.name === "return" || key.name === "enter" || key.name === " ") {
@@ -99,35 +131,27 @@ export function CreateView(props) {
99
131
  if (field === "taskMode") {
100
132
  const next = valuesRef.current.taskMode === TASK_MODE_INLINE ? TASK_MODE_EXISTING : TASK_MODE_INLINE;
101
133
  updateValues({ ...valuesRef.current, taskMode: next });
134
+ key.preventDefault();
102
135
  return;
103
136
  }
104
137
  if (field === "runNow") {
105
138
  const next = valuesRef.current.runNow === "y" ? "n" : "y";
106
139
  updateValues({ ...valuesRef.current, runNow: next });
140
+ key.preventDefault();
107
141
  return;
108
142
  }
109
143
  if (focusIndex === chooseTaskIdx && !isInline) {
110
144
  props.onChooseTask();
145
+ key.preventDefault();
111
146
  return;
112
147
  }
113
148
  if (focusIndex === saveIndex) {
114
149
  void submit(valuesRef.current);
150
+ key.preventDefault();
115
151
  }
116
152
  else if (focusIndex === cancelIndex) {
117
153
  props.onCancel();
118
- }
119
- }
120
- if (key.name === "up" || key.name === "down") {
121
- const field = filteredFields[focusIndex];
122
- if (field === "project" && projectOptions.length > 1) {
123
- const currentIdx = projectOptions.findIndex((p) => p.value === valuesRef.current.project);
124
- const nextIdx = key.name === "down"
125
- ? (currentIdx + 1) % projectOptions.length
126
- : (currentIdx - 1 + projectOptions.length) % projectOptions.length;
127
- const next = projectOptions[nextIdx];
128
- if (next)
129
- updateValues({ ...valuesRef.current, project: next.value });
130
- return;
154
+ key.preventDefault();
131
155
  }
132
156
  }
133
157
  });
@@ -254,7 +278,7 @@ export function CreateView(props) {
254
278
  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) => {
255
279
  const leftField = filteredFields[row * 2];
256
280
  const rightField = row * 2 + 1 < filteredFields.length ? filteredFields[row * 2 + 1] : null;
257
- 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, 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, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
281
+ 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));
258
282
  }) }), _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] }));
259
283
  }
260
284
  function HoverButton(props) {
@@ -264,7 +288,7 @@ function HoverButton(props) {
264
288
  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 }) }) }));
265
289
  }
266
290
  function FormRow(props) {
267
- const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, examples, taskModeOptions, runNowOptions, projectOptions, selectedTaskName, fields, onChooseTask, style } = props;
291
+ const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, examples, taskModeOptions, runNowOptions, projectOptions, selectedTaskName, fields, onChooseTask, inputRef, style } = props;
268
292
  const { isHovered, hoverProps } = useHoverState();
269
293
  const isFocused = focusIndex === index;
270
294
  const isToggleField = field === "taskMode" || field === "runNow";
@@ -280,7 +304,7 @@ function FormRow(props) {
280
304
  : t("board.chooseTask") }) })) : isProjectField ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, flexDirection: "row", backgroundColor: "#0b0b0b", alignItems: "center", paddingLeft: 1 }, onMouseDown: () => setFocusIndex(index), children: projectOptions.map((opt) => {
281
305
  const isActive = values.project === opt.value;
282
306
  return (_jsx("box", { onMouseDown: () => updateValues({ ...valuesRef.current, project: opt.value }), style: { backgroundColor: isActive ? "#1e3a8a" : undefined, paddingLeft: 1, paddingRight: 1, marginRight: 1 }, children: _jsx("text", { fg: isActive ? "#ffffff" : "#9ca3af", children: `● ${opt.name}` }) }, opt.value));
283
- }) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { 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: () => {
307
+ }) })) : (_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: () => {
284
308
  if (index < fields.length - 1) {
285
309
  setFocusIndex(index + 1);
286
310
  }
@@ -5,40 +5,43 @@ export function HelpModal(props) {
5
5
  const { view } = props;
6
6
  const { width } = useTerminalDimensions();
7
7
  const loopRows = [
8
+ ["tab", "switch panels"],
9
+ ["up/down", "navigate list items"],
10
+ ["enter", t("board.helpEdit")],
8
11
  ["e", "edit loop"],
9
12
  ["d/del", "delete loop"],
10
13
  ["c", "clone loop"],
11
14
  ["f", "force run"],
12
15
  ["p", "pause/play (contextual)"],
13
16
  ["s", "stop loop (resets schedule)"],
14
- [t("board.helpKeyEnter"), t("board.helpEdit")],
15
17
  ["n", t("board.helpCreate")],
16
18
  ["t", t("board.helpCreateTask")],
17
19
  ["o", t("board.helpCycleSort")],
18
20
  ["x", "cycle status filter"],
19
21
  ["r", "project filter"],
20
- ["←/→", t("board.helpSwitchPanel")],
21
22
  ["/", t("board.helpSearch")],
22
23
  ["h", t("board.helpToggleHelp")],
23
24
  ["esc", t("board.helpQuit")],
24
25
  ];
25
26
  const taskRows = [
27
+ ["tab", "switch panels"],
28
+ ["up/down", "navigate tasks"],
29
+ ["enter", "focus actions panel"],
26
30
  ["e", "edit task"],
27
31
  ["d/del", "delete task"],
28
32
  ["s", "select task (when creating/editing a loop)"],
29
- ["enter", "focus actions panel"],
30
33
  ["n", t("board.helpCreateTask")],
31
- ["←/→", t("board.helpSwitchPanel")],
32
34
  ["/", t("board.helpSearch")],
33
35
  ["h", t("board.helpToggleHelp")],
34
36
  ["esc", t("board.helpQuit")],
35
37
  ];
36
38
  const projectRows = [
39
+ ["tab", "switch panel (list/actions)"],
40
+ ["up/down", "navigate projects"],
37
41
  ["enter", "focus actions"],
38
42
  ["n", "new project"],
39
43
  ["e", "edit project"],
40
44
  ["d", "delete project"],
41
- ["←/→", "switch panel (list/actions)"],
42
45
  ["h", t("board.helpToggleHelp")],
43
46
  ["esc", t("board.helpQuit")],
44
47
  ];
@@ -6,12 +6,31 @@ import { formatRunDuration, formatRunTime } from "../format.js";
6
6
  import { streamRunLog } from "../daemon.js";
7
7
  import { LOG_LINES_MAX } from "../../config/constants.js";
8
8
  import { copyToClipboard } from "../../shared/clipboard.js";
9
+ function colorizeLine(line) {
10
+ if (/^\[Run #/.test(line)) {
11
+ return _jsx("text", { fg: "#38bdf8", children: _jsx("strong", { children: line }) });
12
+ }
13
+ if (/^--- Chain:/.test(line)) {
14
+ return _jsx("text", { fg: "#a78bfa", children: _jsx("strong", { children: line }) });
15
+ }
16
+ if (/^\[exit 0/.test(line)) {
17
+ return _jsx("text", { fg: "#4ade80", children: line });
18
+ }
19
+ if (/^\[exit /.test(line)) {
20
+ return _jsx("text", { fg: "#f87171", children: line });
21
+ }
22
+ if (/^\[error\]/.test(line)) {
23
+ return _jsx("text", { fg: "#f87171", children: line });
24
+ }
25
+ return _jsx("text", { fg: "#e5e7eb", children: line });
26
+ }
9
27
  export function LogModal(props) {
10
28
  const { loopId, run, logLines: staticLines, loading } = props;
11
29
  const { width, height } = useTerminalDimensions();
12
30
  const isRunning = run.status === "running";
13
31
  const [streamLines, setStreamLines] = useState([]);
14
32
  const socketRef = useRef(null);
33
+ const scrollRef = useRef(null);
15
34
  useEffect(() => {
16
35
  if (!isRunning || !loopId)
17
36
  return;
@@ -28,13 +47,18 @@ export function LogModal(props) {
28
47
  }
29
48
  };
30
49
  }, [isRunning, loopId, run.runNumber]);
50
+ const logLines = isRunning ? streamLines : staticLines;
51
+ useEffect(() => {
52
+ if (logLines.length > 0) {
53
+ scrollRef.current?.scrollChildIntoView(`log-line-${logLines.length - 1}`);
54
+ }
55
+ }, [logLines]);
31
56
  useKeyboard((key) => {
32
57
  if (key.ctrl && key.name === "c") {
33
- const all = (isRunning ? streamLines : staticLines).join("\n");
58
+ const all = logLines.join("\n");
34
59
  copyToClipboard(all);
35
60
  }
36
61
  });
37
- const logLines = isRunning ? streamLines : staticLines;
38
62
  const success = isRunning ? true : run.exitCode === 0;
39
63
  const icon = isRunning ? "⟳" : success ? "✓" : "✗";
40
64
  const iconColor = isRunning ? "#facc15" : success ? "#4ade80" : "#f87171";
@@ -54,5 +78,5 @@ export function LogModal(props) {
54
78
  width: Math.min(width - 4, 100),
55
79
  height: Math.min(height - 4, 30),
56
80
  backgroundColor: "#111827",
57
- }, children: [_jsx("scrollbox", { style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: loading && !isRunning ? (_jsx("text", { fg: "#9ca3af", children: t("board.logModalLoading") })) : logLines.length === 0 ? (_jsx("text", { fg: "#9ca3af", children: t("board.logModalEmpty") })) : (logLines.map((line, i) => _jsx("text", { children: line }, i))) }), _jsxs("box", { style: { flexDirection: "row", justifyContent: "space-between", backgroundColor: "#111827" }, children: [_jsxs("text", { children: [_jsx("span", { fg: iconColor, children: icon }), " ", isRunning ? (_jsx("span", { fg: "#facc15", children: t("board.logModalRunning") })) : (_jsxs(_Fragment, { children: [_jsx("span", { fg: "#9ca3af", children: formatRunDuration(run.duration) }), " ", _jsxs("span", { fg: success ? "#4ade80" : "#f87171", children: ["exit ", run.exitCode] })] }))] }), _jsx("text", { fg: "#6b7280", children: t("board.logModalEscClose") })] })] }) }));
81
+ }, children: [_jsx("scrollbox", { ref: scrollRef, style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: loading && !isRunning ? (_jsx("text", { fg: "#9ca3af", children: t("board.logModalLoading") })) : logLines.length === 0 ? (_jsx("text", { fg: "#9ca3af", children: t("board.logModalEmpty") })) : (logLines.map((line, i) => (_jsx("box", { id: `log-line-${i}`, children: colorizeLine(line) }, i)))) }), _jsxs("box", { style: { flexDirection: "row", justifyContent: "space-between", backgroundColor: "#111827" }, children: [_jsxs("text", { children: [_jsx("span", { fg: iconColor, children: icon }), " ", isRunning ? (_jsx("span", { fg: "#facc15", children: t("board.logModalRunning") })) : (_jsxs(_Fragment, { children: [_jsx("span", { fg: "#9ca3af", children: formatRunDuration(run.duration) }), " ", _jsxs("span", { fg: success ? "#4ade80" : "#f87171", children: ["exit ", run.exitCode] })] }))] }), _jsx("text", { fg: "#6b7280", children: t("board.logModalEscClose") })] })] }) }));
58
82
  }
@@ -15,7 +15,7 @@ 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 } = props;
18
+ const { projects, loops, onClose, onRefresh, onOpenCreate, onEnterHeader } = props;
19
19
  const [selectedIndex, setSelectedIndex] = useState(0);
20
20
  const [subModal, setSubModal] = useState("none");
21
21
  const [focusedPanel, setFocusedPanel] = useState("list");
@@ -41,6 +41,7 @@ export function ProjectsPage(props) {
41
41
  setSelectedIndex((i) => Math.max(0, i - 1));
42
42
  else
43
43
  setSelectedAction((a) => Math.max(0, a - 1));
44
+ key.preventDefault();
44
45
  return;
45
46
  }
46
47
  if (key.name === "down") {
@@ -48,10 +49,23 @@ export function ProjectsPage(props) {
48
49
  setSelectedIndex((i) => Math.min(projects.length - 1, i + 1));
49
50
  else
50
51
  setSelectedAction((a) => Math.min(PROJECT_ACTION_COUNT - 1, a + 1));
52
+ key.preventDefault();
51
53
  return;
52
54
  }
53
- if (key.name === "left" || key.name === "right") {
55
+ if (key.name === "tab") {
56
+ const direction = key.shift ? "left" : "right";
57
+ if (focusedPanel === "list" && direction === "left") {
58
+ onEnterHeader?.("left");
59
+ key.preventDefault();
60
+ return;
61
+ }
62
+ if (focusedPanel === "actions" && direction === "right") {
63
+ onEnterHeader?.("right");
64
+ key.preventDefault();
65
+ return;
66
+ }
54
67
  setFocusedPanel((p) => (p === "list" ? "actions" : "list"));
68
+ key.preventDefault();
55
69
  return;
56
70
  }
57
71
  if (key.name === "return" || key.name === "enter") {
@@ -61,22 +75,27 @@ export function ProjectsPage(props) {
61
75
  else {
62
76
  runAction(selectedAction);
63
77
  }
78
+ key.preventDefault();
64
79
  return;
65
80
  }
66
81
  if (key.name === "n") {
67
82
  setSubModal("create");
83
+ key.preventDefault();
68
84
  return;
69
85
  }
70
86
  if (key.name === "e" && selectedProject && !selectedProject.isSystem) {
71
87
  setSubModal("edit");
88
+ key.preventDefault();
72
89
  return;
73
90
  }
74
91
  if (key.name === "d" && selectedProject && !selectedProject.isSystem) {
75
92
  setSubModal("delete");
93
+ key.preventDefault();
76
94
  return;
77
95
  }
78
96
  if (key.name === "escape") {
79
97
  onClose();
98
+ key.preventDefault();
80
99
  }
81
100
  });
82
101
  const actions = [
@@ -1,6 +1,10 @@
1
1
  import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import { useRef } from "react";
2
3
  import { t } from "../../i18n/index.js";
4
+ import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
3
5
  export function SearchBox(props) {
4
6
  const { query, searchActive, focused, onQueryChange, onActivate, onDismiss } = props;
5
- return (_jsx("box", { title: t("board.searchTitle"), border: true, borderColor: focused ? "#38bdf8" : undefined, style: { width: "25%", height: 3, marginRight: 1, paddingLeft: 1, backgroundColor: focused ? "#1e2a4a" : "#0b0b0b" }, children: searchActive ? (_jsx("input", { focused: true, value: query, placeholder: t("board.searchPlaceholder"), onInput: (value) => onQueryChange(value), onSubmit: () => onDismiss() })) : (_jsx("text", { fg: query ? "#e5e7eb" : "#6b7280", onMouseDown: onActivate, children: query || t("board.searchEmpty") })) }));
7
+ const inputRef = useRef(null);
8
+ useInputShortcuts(() => searchActive ? inputRef.current : null);
9
+ return (_jsx("box", { title: t("board.searchTitle"), border: true, borderColor: focused ? "#38bdf8" : undefined, style: { width: "25%", height: 3, marginRight: 1, paddingLeft: 1, backgroundColor: focused ? "#1e2a4a" : "#0b0b0b" }, children: searchActive ? (_jsx("input", { ref: inputRef, focused: true, value: query, placeholder: t("board.searchPlaceholder"), onInput: (value) => onQueryChange(value), onSubmit: () => onDismiss() })) : (_jsx("text", { fg: query ? "#e5e7eb" : "#6b7280", onMouseDown: onActivate, children: query || t("board.searchEmpty") })) }));
6
10
  }
@@ -5,6 +5,7 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
5
5
  import { t } from "../../i18n/index.js";
6
6
  import { createTask, updateTask, listTasks } from "../daemon.js";
7
7
  import { useHoverState } from "../hooks/useHoverState.js";
8
+ import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
8
9
  import { HOVER_BG } from "../../config/constants.js";
9
10
  const taskFields = ["name", "command", "onSuccessTaskId", "onFailureTaskId"];
10
11
  function taskInitialValues(task) {
@@ -25,8 +26,14 @@ export function TaskForm(props) {
25
26
  const [error, setError] = useState("");
26
27
  const [isSubmitting, setIsSubmitting] = useState(false);
27
28
  const [allTasks, setAllTasks] = useState([]);
29
+ const inputRef = useRef(null);
28
30
  const { width: termWidth } = useTerminalDimensions();
29
31
  const btnWidth = Math.max(10, Math.min(14, Math.floor(termWidth / 6)));
32
+ useInputShortcuts(() => {
33
+ if (focusIndex >= saveIndex)
34
+ return null;
35
+ return inputRef.current;
36
+ });
30
37
  const saveIndex = taskFields.length;
31
38
  const cancelIndex = taskFields.length + 1;
32
39
  function updateValues(next) {
@@ -46,14 +53,29 @@ export function TaskForm(props) {
46
53
  return 0;
47
54
  return next;
48
55
  });
56
+ key.preventDefault();
57
+ return;
58
+ }
59
+ if (key.name === "up" || key.name === "down") {
60
+ setFocusIndex((i) => {
61
+ const next = key.name === "up" ? i - 1 : i + 1;
62
+ if (next < 0)
63
+ return cancelIndex;
64
+ if (next > cancelIndex)
65
+ return 0;
66
+ return next;
67
+ });
68
+ key.preventDefault();
49
69
  return;
50
70
  }
51
71
  if (key.name === "return" || key.name === "enter") {
52
72
  if (focusIndex === saveIndex) {
53
73
  void submit(valuesRef.current);
74
+ key.preventDefault();
54
75
  }
55
76
  else if (focusIndex === cancelIndex) {
56
77
  props.onCancel();
78
+ key.preventDefault();
57
79
  }
58
80
  }
59
81
  });
@@ -125,16 +147,16 @@ export function TaskForm(props) {
125
147
  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) => {
126
148
  const leftField = taskFields[row * 2];
127
149
  const rightField = row * 2 + 1 < taskFields.length ? taskFields[row * 2 + 1] : null;
128
- 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, 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, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
150
+ return (_jsxs("box", { style: { flexDirection: "row", marginBottom: 1 }, children: [_jsx(TaskFormRow, { field: leftField, index: row * 2, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, chainOptions: chainOptions, inputRef: inputRef, style: { width: "50%", paddingRight: 1 } }), rightField ? (_jsx(TaskFormRow, { field: rightField, index: row * 2 + 1, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, chainOptions: chainOptions, inputRef: inputRef, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
129
151
  }) }), _jsxs("box", { style: { flexDirection: "row", height: 3, marginBottom: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(HoverButton, { label: isSubmitting ? t("board.saving") : props.mode === "edit" ? t("board.save") : t("board.create"), onMouseDown: () => void submit(valuesRef.current), selected: focusIndex === saveIndex, width: btnWidth, marginRight: 1 }), _jsx(HoverButton, { label: t("board.cancel"), onMouseDown: props.onCancel, selected: focusIndex === cancelIndex, width: btnWidth })] }), _jsx("text", { fg: "#9ca3af", children: t("board.formNav") }), error ? _jsx("text", { fg: "#f87171", children: error }) : null] }));
130
152
  }
131
153
  function TaskFormRow(props) {
132
- const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, chainOptions, style } = props;
154
+ const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, chainOptions, inputRef, style } = props;
133
155
  const isFocused = focusIndex === index;
134
156
  const isSelect = field === "onSuccessTaskId" || field === "onFailureTaskId";
135
157
  const selectOpts = isSelect ? chainOptions : [];
136
158
  const selectedIdx = isSelect ? selectOpts.findIndex((o) => o.value === values[field]) : 0;
137
- return (_jsxs("box", { style: { flexDirection: "column", ...style }, children: [_jsx("text", { fg: isFocused ? "#38bdf8" : "#e5e7eb", children: labels[field] }), _jsx("text", { fg: "#6b7280", children: hints[field] }), isSelect ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: Math.min(selectOpts.length + 2, 6), backgroundColor: "#0b0b0b" }, children: _jsx("select", { focused: isFocused, options: selectOpts, selectedIndex: Math.max(0, selectedIdx), showDescription: false, style: { flexGrow: 1 }, onChange: (_i, option) => updateValues({ ...valuesRef.current, [field]: option?.value ?? "" }) }) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { focused: isFocused, value: values[field], placeholder: field === "command" ? t("board.exampleCommand") : "", onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
159
+ return (_jsxs("box", { style: { flexDirection: "column", ...style }, children: [_jsx("text", { fg: isFocused ? "#38bdf8" : "#e5e7eb", children: labels[field] }), _jsx("text", { fg: "#6b7280", children: hints[field] }), isSelect ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: Math.min(selectOpts.length + 2, 6), backgroundColor: "#0b0b0b" }, children: _jsx("select", { focused: isFocused, options: selectOpts, selectedIndex: Math.max(0, selectedIdx), showDescription: false, style: { flexGrow: 1 }, onChange: (_i, option) => updateValues({ ...valuesRef.current, [field]: option?.value ?? "" }) }) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { ref: inputRef, focused: isFocused, value: values[field], placeholder: field === "command" ? t("board.exampleCommand") : "", onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
138
160
  if (index < taskFields.length - 1) {
139
161
  setFocusIndex(index + 1);
140
162
  }
@@ -195,26 +195,30 @@ const BOARD_SHORTCUTS = {
195
195
  r: (p) => p.onSelectProject?.(),
196
196
  };
197
197
  export function useBoardKeybindings(params) {
198
- const { confirm, confirmChoice, setConfirm, setConfirmChoice, helpOpen, setHelpOpen, searchActive, setSearchActive, view, push, pop, setEditTarget, setEditTask, selected, visibleCount, setSelectedIndex, setFilters, setSort, onQuit, destroyLogSocket, logModalRun, setLogModalRun, logModalLines, selectedRunIndex, setSelectedRunIndex, selectedRunCount, focusedPanel, setFocusedPanel, selectedAction, setSelectedAction, onAction, onOpenRunLog, refreshTasks, onViewTasks, onViewProjects, onAddLoop, onSelectProject, } = params;
198
+ const { confirm, confirmChoice, setConfirm, setConfirmChoice, helpOpen, setHelpOpen, searchActive, setSearchActive, view, push, pop, setEditTarget, setEditTask, selected, visibleCount, setSelectedIndex, setFilters, setSort, onQuit, destroyLogSocket, logModalRun, setLogModalRun, logModalLines, selectedRunIndex, setSelectedRunIndex, selectedRunCount, focusedPanel, setFocusedPanel, selectedAction, setSelectedAction, onAction, onOpenRunLog, refreshTasks, onViewTasks, onViewProjects, onViewLoops, onAddLoop, onAddTask, onSelectProject, } = params;
199
199
  useKeyboard((key) => {
200
200
  const name = key.name;
201
201
  if (confirm) {
202
202
  CONFIRM_KEYS[name]?.({ confirm, confirmChoice, setConfirm, setConfirmChoice });
203
+ key.preventDefault();
203
204
  return;
204
205
  }
205
206
  if (logModalRun) {
206
207
  if (name === "escape" || name === "q") {
207
208
  OVERLAY_DISMISS.log({ setLogModalRun, setHelpOpen, setSearchActive, setFocusedPanel }, name);
209
+ key.preventDefault();
208
210
  return;
209
211
  }
210
212
  if (key.ctrl && name === "c") {
211
213
  copyToClipboard(logModalLines.join("\n"));
214
+ key.preventDefault();
212
215
  return;
213
216
  }
214
217
  return;
215
218
  }
216
219
  if (helpOpen && (name === "h" || name === "escape")) {
217
220
  OVERLAY_DISMISS.help({ setLogModalRun, setHelpOpen, setSearchActive, setFocusedPanel }, name);
221
+ key.preventDefault();
218
222
  return;
219
223
  }
220
224
  if (helpOpen)
@@ -223,27 +227,116 @@ export function useBoardKeybindings(params) {
223
227
  if (name === "escape") {
224
228
  setSearchActive(false);
225
229
  setFocusedPanel("loops");
230
+ key.preventDefault();
226
231
  return;
227
232
  }
233
+ if (name === "up" || name === "k" || name === "down" || name === "j") {
234
+ const handler = panelHandlers["loops"];
235
+ if (handler) {
236
+ handler(name, {
237
+ setSearchActive, setFilters, setSort, setEditTarget, push,
238
+ setSelectedIndex, setSelectedRunIndex, setFocusedPanel, selectedRunCount, selected,
239
+ selectedRunIndex, setSelectedAction, selectedAction, onAction, onOpenRunLog,
240
+ refreshTasks, onViewTasks, onViewProjects, onAddLoop,
241
+ });
242
+ key.preventDefault();
243
+ }
244
+ return;
245
+ }
246
+ return;
247
+ }
248
+ if (view !== "board" && name === "escape") {
249
+ VIEW_ESCAPE[view]?.({ setEditTarget, setEditTask, onBack: pop });
250
+ key.preventDefault();
228
251
  return;
229
252
  }
230
- if (name === "left" || name === "right") {
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
+ if (view !== "board" && (name === "return" || name === "enter")) {
268
+ if (view === "task-list") {
269
+ if (focusedPanel === "header-tasks") {
270
+ onViewProjects?.();
271
+ key.preventDefault();
272
+ return;
273
+ }
274
+ if (focusedPanel === "header-projects") {
275
+ onViewLoops?.();
276
+ key.preventDefault();
277
+ return;
278
+ }
279
+ if (focusedPanel === "header-new") {
280
+ onAddTask?.();
281
+ key.preventDefault();
282
+ return;
283
+ }
284
+ }
285
+ else if (view === "projects") {
286
+ if (focusedPanel === "header-tasks") {
287
+ onViewLoops?.();
288
+ key.preventDefault();
289
+ return;
290
+ }
291
+ if (focusedPanel === "header-projects") {
292
+ onViewTasks?.();
293
+ key.preventDefault();
294
+ return;
295
+ }
296
+ if (focusedPanel === "header-new") {
297
+ onViewProjects?.();
298
+ key.preventDefault();
299
+ return;
300
+ }
301
+ }
302
+ else if (view === "create" || view === "task-create" || view === "task-edit") {
303
+ if (focusedPanel === "header-tasks") {
304
+ onViewProjects?.();
305
+ key.preventDefault();
306
+ return;
307
+ }
308
+ if (focusedPanel === "header-projects") {
309
+ onViewTasks?.();
310
+ key.preventDefault();
311
+ return;
312
+ }
313
+ if (focusedPanel === "header-new") {
314
+ onAddLoop?.();
315
+ key.preventDefault();
316
+ return;
317
+ }
318
+ }
319
+ }
320
+ if (view !== "board")
321
+ return;
322
+ if (name === "tab") {
323
+ const direction = key.shift ? "left" : "right";
231
324
  if (focusedPanel === "actions") {
232
325
  const actionCount = selected ? getActionCount(selected.status) : 0;
233
- if (name === "left" && selectedAction === 0) {
326
+ if (direction === "left" && selectedAction === 0) {
234
327
  setFocusedPanel((p) => nextPanel(p, "left"));
235
328
  }
236
- else if (name === "right" && selectedAction === actionCount - 1) {
329
+ else if (direction === "right" && selectedAction === actionCount - 1) {
237
330
  setFocusedPanel((p) => nextPanel(p, "right"));
238
331
  }
239
332
  else {
240
- setSelectedAction((i) => name === "right"
333
+ setSelectedAction((i) => direction === "right"
241
334
  ? Math.min(actionCount - 1, i + 1)
242
335
  : Math.max(0, i - 1));
243
336
  }
244
337
  }
245
338
  else {
246
- const next = nextPanel(focusedPanel, name === "right" ? "right" : "left");
339
+ const next = nextPanel(focusedPanel, direction);
247
340
  if (next === "actions") {
248
341
  setFocusedPanel("actions");
249
342
  setSelectedAction(0);
@@ -252,17 +345,13 @@ export function useBoardKeybindings(params) {
252
345
  setFocusedPanel(next);
253
346
  }
254
347
  }
348
+ key.preventDefault();
255
349
  return;
256
350
  }
257
- if (view !== "board" && name === "escape") {
258
- VIEW_ESCAPE[view]?.({ setEditTarget, setEditTask, onBack: pop });
259
- return;
260
- }
261
- if (view !== "board")
262
- return;
263
351
  const globalHandler = GLOBAL_KEYS[name];
264
352
  if (globalHandler) {
265
353
  globalHandler({ destroyLogSocket, onQuit, setHelpOpen, setEditTarget, setEditTask, push, onAction });
354
+ key.preventDefault();
266
355
  return;
267
356
  }
268
357
  const panelHandler = panelHandlers[focusedPanel];
@@ -271,8 +360,10 @@ export function useBoardKeybindings(params) {
271
360
  setSelectedIndex, setSelectedRunIndex, setFocusedPanel, selectedRunCount, selected,
272
361
  selectedRunIndex, setSelectedAction, selectedAction, onAction, onOpenRunLog,
273
362
  refreshTasks, onViewTasks, onViewProjects, onAddLoop,
274
- }))
363
+ })) {
364
+ key.preventDefault();
275
365
  return;
366
+ }
276
367
  const shortcut = BOARD_SHORTCUTS[name];
277
368
  if (shortcut) {
278
369
  shortcut({
@@ -281,6 +372,7 @@ export function useBoardKeybindings(params) {
281
372
  selectedRunIndex, setSelectedAction, selectedAction, onAction, onOpenRunLog,
282
373
  onSelectProject,
283
374
  });
375
+ key.preventDefault();
284
376
  }
285
377
  });
286
378
  }
@@ -0,0 +1,42 @@
1
+ import { useKeyboard } from "@opentui/react";
2
+ import { copyToClipboard, readFromClipboard } from "../../shared/clipboard.js";
3
+ export function useInputShortcuts(getActiveInput) {
4
+ useKeyboard((key) => {
5
+ if (!key.ctrl)
6
+ return;
7
+ const input = getActiveInput();
8
+ if (!input)
9
+ return;
10
+ const name = key.name;
11
+ if (name === "c") {
12
+ const text = input.getSelectedText();
13
+ if (text) {
14
+ copyToClipboard(text);
15
+ }
16
+ key.preventDefault();
17
+ return;
18
+ }
19
+ if (name === "x") {
20
+ const text = input.getSelectedText();
21
+ if (text) {
22
+ copyToClipboard(text);
23
+ input.deleteSelection();
24
+ }
25
+ key.preventDefault();
26
+ return;
27
+ }
28
+ if (name === "v") {
29
+ const text = readFromClipboard();
30
+ if (text) {
31
+ input.insertText(text);
32
+ }
33
+ key.preventDefault();
34
+ return;
35
+ }
36
+ if (name === "a") {
37
+ input.selectAll();
38
+ key.preventDefault();
39
+ return;
40
+ }
41
+ });
42
+ }
@@ -1,7 +1,7 @@
1
1
  import { useKeyboard } from "@opentui/react";
2
2
  import { nextTaskPanel } from "../components/TaskBrowser.js";
3
3
  export function useTaskKeybindings(params) {
4
- const { confirm, view, tasks, taskSelectedIndex, setTaskSelectedIndex, taskSelectedAction, setTaskSelectedAction, taskFocusedPanel, setTaskFocusedPanel, taskSearchActive, setTaskSearchActive, taskQuery, setTaskQuery, onTaskAction, onCancel, onCreateTask, selectable = true, } = params;
4
+ const { confirm, view, tasks, taskSelectedIndex, setTaskSelectedIndex, taskSelectedAction, setTaskSelectedAction, taskFocusedPanel, setTaskFocusedPanel, taskSearchActive, setTaskSearchActive, taskQuery, setTaskQuery, onTaskAction, onCancel, onCreateTask, onEnterHeader, selectable = true, } = params;
5
5
  const taskActions = selectable
6
6
  ? ["select", "edit", "delete"]
7
7
  : ["edit", "delete"];
@@ -16,94 +16,125 @@ export function useTaskKeybindings(params) {
16
16
  if (name === "escape") {
17
17
  setTaskSearchActive(false);
18
18
  setTaskFocusedPanel("tasks");
19
+ key.preventDefault();
20
+ return;
21
+ }
22
+ if (name === "up" || name === "k") {
23
+ setTaskSelectedIndex((i) => Math.max(0, i - 1));
24
+ key.preventDefault();
25
+ return;
26
+ }
27
+ if (name === "down" || name === "j") {
28
+ setTaskSelectedIndex((i) => Math.min(tasks.length - 1, i + 1));
29
+ key.preventDefault();
19
30
  return;
20
31
  }
21
32
  return;
22
33
  }
23
34
  if (name === "escape") {
24
35
  onCancel();
36
+ key.preventDefault();
25
37
  return;
26
38
  }
27
39
  if (name === "n") {
28
40
  onCreateTask();
41
+ key.preventDefault();
29
42
  return;
30
43
  }
31
44
  if (name === "/" && taskFocusedPanel === "tasks") {
32
45
  setTaskSearchActive(true);
46
+ key.preventDefault();
33
47
  return;
34
48
  }
35
49
  if (name === "tab") {
36
- setTaskFocusedPanel((p) => nextTaskPanel(p, "right"));
37
- return;
38
- }
39
- if (name === "left" || name === "right") {
50
+ const direction = key.shift ? "left" : "right";
40
51
  if (taskFocusedPanel === "actions") {
41
- if (name === "left" && taskSelectedAction === 0) {
52
+ if (direction === "left" && taskSelectedAction === 0) {
42
53
  setTaskFocusedPanel((p) => nextTaskPanel(p, "left"));
43
54
  }
44
- else if (name === "right" && taskSelectedAction === taskActionCount - 1) {
45
- setTaskFocusedPanel((p) => nextTaskPanel(p, "right"));
55
+ else if (direction === "right" && taskSelectedAction === taskActionCount - 1) {
56
+ onEnterHeader("right");
57
+ key.preventDefault();
58
+ return;
46
59
  }
47
60
  else {
48
- setTaskSelectedAction((i) => name === "right"
61
+ setTaskSelectedAction((i) => direction === "right"
49
62
  ? Math.min(taskActionCount - 1, i + 1)
50
63
  : Math.max(0, i - 1));
51
64
  }
52
65
  }
66
+ else if (taskFocusedPanel === "search" && direction === "left") {
67
+ onEnterHeader("left");
68
+ key.preventDefault();
69
+ return;
70
+ }
53
71
  else {
54
- setTaskFocusedPanel((p) => nextTaskPanel(p, name === "right" ? "right" : "left"));
72
+ setTaskFocusedPanel((p) => nextTaskPanel(p, direction));
55
73
  }
74
+ key.preventDefault();
56
75
  return;
57
76
  }
58
77
  if (taskFocusedPanel === "tasks") {
59
78
  if (name === "up" || name === "k") {
60
79
  setTaskSelectedIndex((i) => Math.max(0, i - 1));
80
+ key.preventDefault();
61
81
  return;
62
82
  }
63
83
  if (name === "down" || name === "j") {
64
84
  setTaskSelectedIndex((i) => Math.min(tasks.length - 1, i + 1));
85
+ key.preventDefault();
65
86
  return;
66
87
  }
67
88
  if (name === "return" || name === "enter") {
68
89
  setTaskFocusedPanel("actions");
90
+ key.preventDefault();
69
91
  return;
70
92
  }
71
93
  if (name === "e" && tasks[taskSelectedIndex]) {
72
94
  onTaskAction("edit");
95
+ key.preventDefault();
73
96
  return;
74
97
  }
75
98
  if (name === "d" && tasks[taskSelectedIndex]) {
76
99
  onTaskAction("delete");
100
+ key.preventDefault();
77
101
  return;
78
102
  }
79
103
  if (name === "delete" && tasks[taskSelectedIndex]) {
80
104
  onTaskAction("delete");
105
+ key.preventDefault();
81
106
  return;
82
107
  }
83
108
  if (selectable && name === "s" && tasks[taskSelectedIndex]) {
84
109
  onTaskAction("select");
110
+ key.preventDefault();
85
111
  return;
86
112
  }
87
113
  }
88
114
  if (taskFocusedPanel === "actions") {
89
115
  if (name === "up" || name === "k") {
90
116
  setTaskSelectedAction((i) => Math.max(0, i - 1));
117
+ key.preventDefault();
91
118
  return;
92
119
  }
93
120
  if (name === "down" || name === "j") {
94
121
  setTaskSelectedAction((i) => Math.min(taskActionCount - 1, i + 1));
122
+ key.preventDefault();
95
123
  return;
96
124
  }
97
125
  if (name === "return" || name === "enter") {
98
126
  onTaskAction(taskActions[taskSelectedAction] ?? "select");
127
+ key.preventDefault();
99
128
  return;
100
129
  }
101
130
  if (name === "d") {
102
131
  onTaskAction("delete");
132
+ key.preventDefault();
103
133
  return;
104
134
  }
105
135
  if (selectable && name === "s") {
106
136
  onTaskAction("select");
137
+ key.preventDefault();
107
138
  return;
108
139
  }
109
140
  }
@@ -5,6 +5,7 @@ import { sleep } from "../shared/sleep.js";
5
5
  import { SLEEP_CHUNK_MS } from "../config/constants.js";
6
6
  import { executeCommand } from "./command-runner.js";
7
7
  import { rotateLogIfNeeded } from "./log-rotator.js";
8
+ import { t } from "../i18n/index.js";
8
9
  export class LoopController extends EventEmitter {
9
10
  abortController;
10
11
  runAbortController = null;
@@ -344,6 +345,9 @@ export class LoopController extends EventEmitter {
344
345
  const chainTask = this.taskResolver(currentTargetId);
345
346
  if (!chainTask)
346
347
  break;
348
+ if (this.logStream) {
349
+ this.logStream.write(t("loop.chainHeader", { name: chainTask.name }));
350
+ }
347
351
  const chainStartedAt = new Date().toISOString();
348
352
  const chainOffset = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size : 0;
349
353
  this.runHistory.push({
@@ -1,11 +1,13 @@
1
1
  import { LoopManager } from "./manager.js";
2
2
  import { TaskManager } from "./task-manager.js";
3
3
  import { IpcServer } from "./server.js";
4
- import { writeDaemonPid, removeDaemonPid, writeDaemonSignature, removeDaemonSignature, computeCodeSignature, } from "./state.js";
4
+ import { writeDaemonPid, removeDaemonPid, writeDaemonSignature, removeDaemonSignature, computeCodeSignature, migrateTasksToJson, migrateLoopsToJson, } from "./state.js";
5
5
  import { t } from "../i18n/index.js";
6
6
  import { daemonLog } from "./daemon-log.js";
7
7
  async function main() {
8
8
  const taskManager = new TaskManager();
9
+ migrateLoopsToJson();
10
+ migrateTasksToJson();
9
11
  taskManager.init();
10
12
  const manager = new LoopManager(taskManager);
11
13
  const server = new IpcServer(manager, taskManager);
@@ -1,7 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import { LoopController } from "../core/loop-controller.js";
3
3
  import { ProjectManager } from "./projects.js";
4
- import { saveLoop, loadAllLoops, deleteLoop as deleteLoopState, getLogPath, migrateLoopsToJson, migrateTasksToJson, } from "./state.js";
4
+ import { saveLoop, loadAllLoops, deleteLoop as deleteLoopState, getLogPath, } from "./state.js";
5
5
  import { daemonLog } from "./daemon-log.js";
6
6
  export class LoopManager {
7
7
  loops = new Map();
@@ -16,9 +16,6 @@ export class LoopManager {
16
16
  init() {
17
17
  // Initialize projects first
18
18
  this.projectManager.init();
19
- // Migrate from individual files to consolidated JSON
20
- migrateLoopsToJson();
21
- migrateTasksToJson();
22
19
  const saved = loadAllLoops();
23
20
  let restarted = 0;
24
21
  let migrated = 0;
@@ -257,8 +257,10 @@ export class IpcServer {
257
257
  send(socket, { type: "error", message: t("errors.loopNotFound", { id }) });
258
258
  return;
259
259
  }
260
- const record = meta.runHistory.find((r) => r.runNumber === runNumber);
261
- if (!record) {
260
+ const records = meta.runHistory
261
+ .filter((r) => r.runNumber === runNumber)
262
+ .sort((a, b) => a.logOffset - b.logOffset);
263
+ if (records.length === 0) {
262
264
  send(socket, { type: "ok", data: "" });
263
265
  return;
264
266
  }
@@ -267,12 +269,12 @@ export class IpcServer {
267
269
  send(socket, { type: "ok", data: "" });
268
270
  return;
269
271
  }
270
- const offset = record.logOffset;
272
+ const firstOffset = records[0].logOffset;
271
273
  const stat = fs.statSync(logPath);
272
- if (stat.size > offset) {
274
+ if (stat.size > firstOffset) {
273
275
  const fd = fs.openSync(logPath, "r");
274
- const buf = Buffer.alloc(stat.size - offset);
275
- fs.readSync(fd, buf, 0, buf.length, offset);
276
+ const buf = Buffer.alloc(stat.size - firstOffset);
277
+ fs.readSync(fd, buf, 0, buf.length, firstOffset);
276
278
  fs.closeSync(fd);
277
279
  for (const line of buf.toString().split("\n")) {
278
280
  if (line) {
@@ -280,7 +282,8 @@ export class IpcServer {
280
282
  }
281
283
  }
282
284
  }
283
- if (record.status === "completed") {
285
+ const allCompleted = records.every((r) => r.status === "completed");
286
+ if (allCompleted) {
284
287
  send(socket, { type: "end" });
285
288
  return;
286
289
  }
package/dist/i18n/en.json CHANGED
@@ -65,6 +65,7 @@
65
65
  "cli.projectAmbiguous": "Multiple projects named \"{name}\" - use the id instead",
66
66
  "cli.projectInvalidColor": "Invalid color \"{color}\". Valid names: {valid}, or a #rrggbb hex",
67
67
  "loop.runHeader": "\n[Run #{runNumber} - {timestamp}]\n",
68
+ "loop.chainHeader": "\n--- Chain: {name} ---\n",
68
69
  "loop.exitMarker": "[exit {code} · {duration}]\n",
69
70
  "loop.cwdMissingLog": "[error] working directory does not exist: {cwd}\n",
70
71
  "loop.cwdMissing": "Working directory does not exist: {cwd}",
@@ -17,3 +17,25 @@ export function copyToClipboard(text) {
17
17
  }
18
18
  }
19
19
  }
20
+ export function readFromClipboard() {
21
+ const os = platform();
22
+ try {
23
+ if (os === "win32") {
24
+ return execFileSync("powershell", ["-NoProfile", "-Command", "Get-Clipboard"], { encoding: "utf-8" }).replace(/\r?\n$/, "");
25
+ }
26
+ else if (os === "darwin") {
27
+ return execFileSync("pbpaste", { encoding: "utf-8" });
28
+ }
29
+ else {
30
+ try {
31
+ return execFileSync("xclip", ["-selection", "clipboard", "-o"], { encoding: "utf-8" });
32
+ }
33
+ catch {
34
+ return execFileSync("xsel", ["--clipboard", "--output"], { encoding: "utf-8" });
35
+ }
36
+ }
37
+ }
38
+ catch {
39
+ return "";
40
+ }
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loop-task",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
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": {