loop-task 1.3.0 → 1.4.0

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.
Files changed (43) hide show
  1. package/README.md +250 -195
  2. package/dist/board/App.js +225 -87
  3. package/dist/board/components/ActionButtons.js +28 -8
  4. package/dist/board/components/CreateForm.js +224 -70
  5. package/dist/board/components/CreateProjectModal.js +104 -0
  6. package/dist/board/components/DeleteProjectConfirm.js +53 -0
  7. package/dist/board/components/EditProjectModal.js +106 -0
  8. package/dist/board/components/FilterBar.js +5 -4
  9. package/dist/board/components/Footer.js +19 -6
  10. package/dist/board/components/Header.js +33 -3
  11. package/dist/board/components/HelpModal.js +11 -10
  12. package/dist/board/components/Inspector.js +1 -1
  13. package/dist/board/components/LogModal.js +39 -8
  14. package/dist/board/components/Navigator.js +30 -14
  15. package/dist/board/components/ProjectsModal.js +70 -0
  16. package/dist/board/components/ProjectsPage.js +105 -0
  17. package/dist/board/components/RunHistory.js +48 -9
  18. package/dist/board/components/TaskBrowser.js +95 -0
  19. package/dist/board/components/TaskFilterBar.js +6 -0
  20. package/dist/board/components/TaskForm.js +177 -0
  21. package/dist/board/daemon.js +56 -0
  22. package/dist/board/format.js +19 -12
  23. package/dist/board/hooks/useBoardKeybindings.js +264 -156
  24. package/dist/board/hooks/useTaskKeybindings.js +114 -0
  25. package/dist/board/router.js +16 -0
  26. package/dist/board/state.js +18 -14
  27. package/dist/cli.js +61 -6
  28. package/dist/client/commands.js +157 -1
  29. package/dist/config/constants.js +15 -0
  30. package/dist/config/paths.js +6 -0
  31. package/dist/core/command-runner.js +2 -0
  32. package/dist/core/foreground-loop.js +4 -1
  33. package/dist/core/loop-controller.js +210 -32
  34. package/dist/daemon/index.js +5 -2
  35. package/dist/daemon/manager.js +113 -34
  36. package/dist/daemon/projects.js +104 -0
  37. package/dist/daemon/server.js +177 -6
  38. package/dist/daemon/state.js +32 -1
  39. package/dist/daemon/task-manager.js +59 -0
  40. package/dist/i18n/en.json +139 -20
  41. package/dist/loop-config.js +13 -4
  42. package/dist/shared/clipboard.js +19 -0
  43. package/package.json +1 -1
@@ -1,15 +1,16 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
2
2
  import { t } from "../../i18n/index.js";
3
3
  import { useHoverState } from "../hooks/useHoverState.js";
4
4
  import { HOVER_BG } from "../../config/constants.js";
5
5
  export function FilterBar(props) {
6
- const { filters, sort, searchActive, focusedPanel, onSearch, onStatusCycle, onSortCycle, onNewLoop } = props;
6
+ const { filters, sort, searchActive, focusedPanel, onStatusCycle, onSortCycle, onSelectProject, currentProjectName } = props;
7
7
  const statusDisplay = filters.status === "waiting" ? "waiting" : filters.status;
8
- return (_jsxs("box", { style: { flexDirection: "row", height: 3, paddingLeft: 1, paddingRight: 1, backgroundColor: "#0b0b0b" }, children: [_jsx("box", { title: t("board.searchTitle"), border: true, borderColor: focusedPanel === "search" ? "#38bdf8" : undefined, style: { flexGrow: 2, height: 3, marginRight: 1, paddingLeft: 1, backgroundColor: focusedPanel === "search" ? "#1e2a4a" : "#0b0b0b" }, children: searchActive ? (_jsx("input", { focused: true, placeholder: t("board.searchPlaceholder"), onInput: onSearch })) : (_jsx("text", { fg: filters.query ? "#e5e7eb" : "#6b7280", children: filters.query || t("board.searchEmpty") })) }), _jsx(ClickableBadge, { title: t("board.statusFilterTitle"), text: statusDisplay, textColor: "#38bdf8", focused: focusedPanel === "status", onMouseDown: onStatusCycle, marginRight: 1 }), _jsx(ClickableBadge, { title: t("board.sortTitle"), text: sort, textColor: "#a3e635", focused: focusedPanel === "sort", onMouseDown: onSortCycle, marginRight: 1 }), _jsx(ClickableBadge, { title: t("board.newLoopTitle"), text: t("board.newLoopLabel"), textColor: "#4ade80", focused: focusedPanel === "new", onMouseDown: onNewLoop, narrow: true })] }));
8
+ return (_jsxs("box", { style: { flexDirection: "row", height: 3, paddingLeft: 1, paddingRight: 1, backgroundColor: "#0b0b0b" }, children: [_jsx("box", { title: t("board.searchTitle"), border: true, borderColor: focusedPanel === "search" ? "#38bdf8" : undefined, style: { width: "25%", height: 3, marginRight: 1, paddingLeft: 1, backgroundColor: focusedPanel === "search" ? "#1e2a4a" : "#0b0b0b" }, children: searchActive ? (_jsxs("text", { fg: filters.query ? "#e5e7eb" : "#6b7280", children: [filters.query || t("board.searchPlaceholder"), "\u258E"] })) : (_jsx("text", { fg: filters.query ? "#e5e7eb" : "#6b7280", children: filters.query || t("board.searchEmpty") })) }), onSelectProject ? (_jsx(ClickableBadge, { title: t("project.filterTitle"), text: currentProjectName ?? "Default", textColor: "#f59e0b", focused: focusedPanel === "project-filter", onMouseDown: onSelectProject, marginRight: 1 })) : null, _jsx(ClickableBadge, { title: t("board.statusFilterTitle"), text: statusDisplay, textColor: "#38bdf8", focused: focusedPanel === "status", onMouseDown: onStatusCycle, marginRight: 1 }), _jsx(ClickableBadge, { title: t("board.sortTitle"), text: sort, textColor: "#a3e635", focused: focusedPanel === "sort", onMouseDown: onSortCycle })] }));
9
9
  }
10
10
  function ClickableBadge(props) {
11
11
  const { isHovered, hoverProps } = useHoverState();
12
12
  const bg = props.focused ? "#1e2a4a" : isHovered ? HOVER_BG : "#0b0b0b";
13
13
  const borderColor = props.focused ? "#38bdf8" : undefined;
14
- return (_jsx("box", { title: props.title, border: true, borderColor: borderColor, onMouseDown: props.onMouseDown, style: { flexGrow: props.narrow ? 0 : 1, height: 3, marginRight: props.marginRight, paddingLeft: 1, backgroundColor: bg }, ...hoverProps, children: _jsx("text", { fg: props.textColor, children: props.text }) }));
14
+ const titleProp = props.title ? { title: props.title } : {};
15
+ return (_jsx("box", { ...titleProp, border: true, borderColor: borderColor, onMouseDown: props.onMouseDown, style: { flexGrow: 1, height: 3, marginRight: props.marginRight, paddingLeft: 1, backgroundColor: bg }, ...hoverProps, children: _jsx("text", { fg: props.textColor, children: props.text }) }));
15
16
  }
@@ -1,21 +1,23 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
2
  import { t } from "../../i18n/index.js";
3
+ import { ENTITY_COLORS } from "../../config/constants.js";
3
4
  export function Footer(props) {
4
5
  const { mode } = props;
5
6
  const badge = {
6
- normal: { label: t("board.badgeNormal"), bg: "#4ade80" },
7
+ normal: { label: t("board.badgeNormal"), bg: ENTITY_COLORS.loop },
7
8
  search: { label: t("board.badgeSearch"), bg: "#38bdf8" },
8
- create: { label: t("board.badgeCreate"), bg: "#a3e635" },
9
+ create: { label: t("board.badgeCreate"), bg: ENTITY_COLORS.loop },
10
+ task: { label: t("board.badgeTask"), bg: ENTITY_COLORS.task },
9
11
  help: { label: t("board.badgeHelp"), bg: "#facc15" },
10
12
  confirm: { label: t("board.badgeConfirm"), bg: "#f87171" },
13
+ projects: { label: t("project.projectsLabel"), bg: ENTITY_COLORS.project },
11
14
  };
12
15
  const hints = {
13
16
  normal: [
14
17
  ["←/→", t("board.hintSwitchPanel")],
15
- [t("board.hintKeyEnter"), t("board.hintOpenRun")],
16
- [t("board.hintKeySlash"), t("board.hintSearch")],
17
- [t("board.hintKeyH"), t("board.hintHelp")],
18
- [t("board.hintKeyEsc"), t("board.hintQuit")],
18
+ ["/", t("board.hintSearch")],
19
+ ["h", t("board.hintHelp")],
20
+ ["esc", t("board.hintQuit")],
19
21
  ],
20
22
  search: [
21
23
  [t("board.hintKeyEnter"), t("board.hintApply")],
@@ -26,6 +28,11 @@ export function Footer(props) {
26
28
  [t("board.hintKeyEnter"), t("board.hintCreate")],
27
29
  [t("board.hintKeyEsc"), t("board.hintCancel")],
28
30
  ],
31
+ task: [
32
+ [t("board.hintKeyTab"), t("board.hintNextField")],
33
+ [t("board.hintKeyEnter"), t("board.hintCreate")],
34
+ [t("board.hintKeyEsc"), t("board.hintCancel")],
35
+ ],
29
36
  help: [[t("board.hintKeyHelpEsc"), t("board.hintBack")]],
30
37
  confirm: [
31
38
  [t("board.hintKeyArrows"), t("board.hintChoose")],
@@ -33,6 +40,12 @@ export function Footer(props) {
33
40
  [t("board.hintKeyYN"), t("board.hintYesNo")],
34
41
  [t("board.hintKeyEsc"), t("board.hintCancel")],
35
42
  ],
43
+ projects: [
44
+ ["n", t("project.keyNewHint")],
45
+ ["e", t("project.keyEditHint")],
46
+ ["d", t("project.keyDeleteHint")],
47
+ [t("board.hintKeyEsc"), t("board.hintBack")],
48
+ ],
36
49
  };
37
50
  const current = badge[mode];
38
51
  return (_jsxs("box", { style: { flexDirection: "row", height: 1 }, children: [_jsx("box", { style: { backgroundColor: current.bg, paddingLeft: 1, paddingRight: 1 }, children: _jsx("text", { fg: "#0b0b0b", children: _jsx("strong", { children: current.label }) }) }), _jsx("box", { style: { flexGrow: 1, paddingLeft: 1, flexDirection: "row" }, children: _jsx("text", { children: hints[mode].map(([k, a], i) => (_jsxs("span", { children: [i > 0 ? _jsx("span", { fg: "#374151", children: " " }) : null, _jsx("span", { fg: "#38bdf8", children: k }), _jsxs("span", { fg: "#6b7280", children: [":", a] })] }, k))) }) })] }));
@@ -1,9 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
2
2
  import { useTerminalDimensions } from "@opentui/react";
3
- import { HEADER_COMPACT_WIDTH } from "../../config/constants.js";
3
+ import { HEADER_COMPACT_WIDTH, ENTITY_COLORS } from "../../config/constants.js";
4
4
  import { t } from "../../i18n/index.js";
5
+ import { useHoverState } from "../hooks/useHoverState.js";
6
+ import { HOVER_BG } from "../../config/constants.js";
7
+ function ActionButton(props) {
8
+ const { isHovered, hoverProps } = useHoverState();
9
+ const bg = props.focused ? "#1e2a4a" : isHovered ? HOVER_BG : undefined;
10
+ return (_jsx("box", { onMouseDown: props.onMouseDown, style: { flexGrow: 0, paddingLeft: 1, paddingRight: 1, marginLeft: 1, backgroundColor: bg }, ...hoverProps, children: _jsx("text", { fg: props.textColor, children: props.label }) }));
11
+ }
5
12
  export function Header(props) {
6
- const { daemonStatus, counts } = props;
13
+ const { daemonStatus, counts, view, focusedPanel, onViewLoops, onViewTasks, onViewProjects, onAddLoop, onAddTask, onAddProject } = props;
7
14
  const { width } = useTerminalDimensions();
8
15
  const compact = width < HEADER_COMPACT_WIDTH;
9
16
  const color = daemonStatus === "connected"
@@ -12,5 +19,28 @@ export function Header(props) {
12
19
  ? "#f87171"
13
20
  : "#facc15";
14
21
  const symbol = daemonStatus === "connected" ? "✓" : daemonStatus === "error" ? "✗" : "…";
15
- return (_jsxs("box", { style: { flexDirection: "column" }, children: [_jsx("box", { style: { flexDirection: "row", paddingLeft: 1, paddingRight: 1 }, children: _jsxs("text", { children: [_jsx("strong", { fg: "#a3e635", children: t("board.appName") }), compact ? null : _jsx("span", { fg: "#6b7280", children: t("board.appTagline") })] }) }), _jsx("box", { style: { flexDirection: "row", paddingLeft: 1, paddingRight: 1 }, children: _jsxs("text", { children: [_jsx("span", { fg: "#6b7280", children: t("board.daemonLabel") }), _jsxs("span", { fg: color, children: [symbol, " ", daemonStatus] }), compact ? null : (_jsxs(_Fragment, { children: [_jsx("span", { fg: "#6b7280", children: t("board.loopsLabel") }), _jsx("span", { fg: "#e5e7eb", children: counts.total }), _jsx("span", { fg: "#6b7280", children: t("board.runningLabel") }), _jsx("span", { fg: "#4ade80", children: counts.running }), _jsx("span", { fg: "#6b7280", children: t("board.waitingLabel") }), _jsx("span", { fg: "#38bdf8", children: counts.waiting }), _jsx("span", { fg: "#6b7280", children: t("board.pausedLabel") }), _jsx("span", { fg: "#facc15", children: counts.paused })] }))] }) }), _jsx("box", { style: { height: 1, paddingLeft: 1, paddingRight: 1 }, children: _jsx("text", { fg: "#374151", children: "─".repeat(Math.max(0, width - 2)) }) })] }));
22
+ let buttons;
23
+ if (view === "projects") {
24
+ buttons = [
25
+ { label: t("board.viewLoopsLabel"), color: ENTITY_COLORS.loop, panel: "header-tasks", action: onViewLoops },
26
+ { label: t("board.viewTasksLabel"), color: ENTITY_COLORS.task, panel: "header-projects", action: onViewTasks },
27
+ { label: t("project.newProjectLabel"), color: ENTITY_COLORS.project, panel: "header-new", action: onAddProject },
28
+ ];
29
+ }
30
+ else if (view === "task-list") {
31
+ buttons = [
32
+ { label: t("project.manageLabel"), color: ENTITY_COLORS.project, panel: "header-tasks", action: onViewProjects },
33
+ { label: t("board.viewLoopsLabel"), color: ENTITY_COLORS.loop, panel: "header-projects", action: onViewLoops },
34
+ { label: t("board.taskActionNew"), color: ENTITY_COLORS.task, panel: "header-new", action: onAddTask },
35
+ ];
36
+ }
37
+ else {
38
+ // board (default)
39
+ buttons = [
40
+ { label: t("project.manageLabel"), color: ENTITY_COLORS.project, panel: "header-tasks", action: onViewProjects },
41
+ { label: t("board.viewTasksLabel"), color: ENTITY_COLORS.task, panel: "header-projects", action: onViewTasks },
42
+ { label: t("board.newLoopLabel"), color: ENTITY_COLORS.loop, panel: "header-new", action: onAddLoop },
43
+ ];
44
+ }
45
+ return (_jsxs("box", { style: { flexDirection: "column" }, children: [_jsx("box", { style: { flexDirection: "row", paddingLeft: 1, paddingRight: 1 }, children: _jsxs("text", { children: [_jsx("strong", { fg: "#a3e635", children: t("board.appName") }), compact ? null : _jsx("span", { fg: "#6b7280", children: t("board.appTagline") })] }) }), _jsxs("box", { style: { flexDirection: "row", paddingLeft: 1, paddingRight: 1, alignItems: "center" }, children: [_jsx("box", { style: { flexGrow: 1 }, children: _jsxs("text", { children: [_jsx("span", { fg: "#6b7280", children: t("board.daemonLabel") }), _jsxs("span", { fg: color, children: [symbol, " ", daemonStatus] }), compact ? null : (_jsxs(_Fragment, { children: [_jsx("span", { fg: "#6b7280", children: t("board.loopsLabel") }), _jsx("span", { fg: "#e5e7eb", children: counts.total }), _jsx("span", { fg: "#6b7280", children: t("board.runningLabel") }), _jsx("span", { fg: "#4ade80", children: counts.running }), _jsx("span", { fg: "#6b7280", children: t("board.waitingLabel") }), _jsx("span", { fg: "#38bdf8", children: counts.waiting }), _jsx("span", { fg: "#6b7280", children: t("board.pausedLabel") }), _jsx("span", { fg: "#facc15", children: counts.paused }), _jsx("span", { fg: "#6b7280", children: t("board.idleLabel") }), _jsx("span", { fg: "#fb923c", children: counts.idle })] }))] }) }), buttons.map((btn) => btn.action ? (_jsx(ActionButton, { label: btn.label, textColor: btn.color, focused: focusedPanel === btn.panel, onMouseDown: btn.action }, btn.panel)) : null)] }), _jsx("box", { style: { height: 1, paddingLeft: 1, paddingRight: 1 }, children: _jsx("text", { fg: "#374151", children: "─".repeat(Math.max(0, width - 2)) }) })] }));
16
46
  }
@@ -4,18 +4,19 @@ import { t } from "../../i18n/index.js";
4
4
  export function HelpModal() {
5
5
  const { width } = useTerminalDimensions();
6
6
  const rows = [
7
- [t("board.helpKeyMove"), t("board.helpMoveSelection")],
7
+ ["e", "edit loop"],
8
+ ["d/del", "delete loop"],
9
+ ["f", "force run"],
10
+ ["p", "pause/play (contextual)"],
11
+ ["s", "stop loop (resets schedule)"],
8
12
  [t("board.helpKeyEnter"), t("board.helpEdit")],
9
- [t("board.helpKeyN"), t("board.helpCreate")],
10
- ["p", t("board.helpPauseResume")],
11
- ["r", t("board.helpForceRun")],
12
- ["del", t("board.helpDelete")],
13
+ ["n", t("board.helpCreate")],
14
+ ["t", t("board.helpCreateTask")],
15
+ ["o", t("board.helpCycleSort")],
13
16
  ["←/→", t("board.helpSwitchPanel")],
14
- [t("board.helpKeySlash"), t("board.helpSearch")],
15
- [t("board.helpKeyF"), t("board.helpCycleFilter")],
16
- [t("board.helpKeyS"), t("board.helpCycleSort")],
17
- [t("board.helpKeyH"), t("board.helpToggleHelp")],
18
- [t("board.helpKeyEsc"), t("board.helpQuit")],
17
+ ["/", t("board.helpSearch")],
18
+ ["h", t("board.helpToggleHelp")],
19
+ ["esc", t("board.helpQuit")],
19
20
  ];
20
21
  return (_jsx("box", { style: {
21
22
  position: "absolute",
@@ -8,5 +8,5 @@ export function Inspector(props) {
8
8
  }
9
9
  const cmd = commandLine(loop.command, loop.commandArgs);
10
10
  const maxRuns = loop.maxRuns !== null ? String(loop.maxRuns) : t("board.unlimited");
11
- return (_jsxs("box", { title: t("board.inspectorTitle"), border: true, style: { flexDirection: "column", backgroundColor: "#0b0b0b" }, children: [_jsxs("text", { children: [_jsx("strong", { children: t("board.fieldId") }), " ", loop.id] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldDesc") }), " ", describeLoop(loop)] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldCommand") }), " ", cmd] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldDir") }), " ", loop.cwd || t("board.inherit")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldInterval") }), " ", loop.intervalHuman] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldStatus") }), " ", _jsx("span", { fg: statusColor(loop.status), children: statusLabel(loop.status) })] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldRuns") }), " ", loop.runCount, " / ", maxRuns] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldLastRun") }), " ", timeAgo(loop.lastRunAt)] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldLastExit") }), " ", loop.lastExitCode !== null ? String(loop.lastExitCode) : t("format.dash")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldNextRun") }), " ", loop.nextRunAt ? timeAgo(loop.nextRunAt) : t("format.dash")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldPid") }), " ", loop.pid] })] }));
11
+ return (_jsxs("box", { title: t("board.inspectorTitle"), border: true, style: { flexDirection: "column", backgroundColor: "#0b0b0b" }, children: [_jsxs("text", { children: [_jsx("strong", { children: t("board.fieldId") }), " ", loop.id] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldDesc") }), " ", describeLoop(loop)] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldCommand") }), " ", cmd] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldTask") }), " ", loop.taskId] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldDir") }), " ", loop.cwd || t("board.inherit")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldInterval") }), " ", loop.intervalHuman] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldStatus") }), " ", _jsx("span", { fg: statusColor(loop.status), children: statusLabel(loop.status) })] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldRuns") }), " ", loop.runCount, " / ", maxRuns] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldLastRun") }), " ", timeAgo(loop.lastRunAt)] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldLastExit") }), " ", loop.lastExitCode !== null ? String(loop.lastExitCode) : t("format.dash")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldNextRun") }), " ", loop.nextRunAt ? timeAgo(loop.nextRunAt) : t("format.dash")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldPid") }), " ", loop.pid] })] }));
12
12
  }
@@ -1,13 +1,44 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
- import { useTerminalDimensions } from "@opentui/react";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
3
4
  import { t } from "../../i18n/index.js";
4
5
  import { formatRunDuration, formatRunTime } from "../format.js";
6
+ import { streamRunLog } from "../daemon.js";
7
+ import { LOG_LINES_MAX } from "../../config/constants.js";
8
+ import { copyToClipboard } from "../../shared/clipboard.js";
5
9
  export function LogModal(props) {
6
- const { run, logLines, loading } = props;
10
+ const { loopId, run, logLines: staticLines, loading } = props;
7
11
  const { width, height } = useTerminalDimensions();
8
- const success = run.exitCode === 0;
9
- const icon = success ? "✓" : "✗";
10
- const iconColor = success ? "#4ade80" : "#f87171";
12
+ const isRunning = run.status === "running";
13
+ const [streamLines, setStreamLines] = useState([]);
14
+ const socketRef = useRef(null);
15
+ useEffect(() => {
16
+ if (!isRunning || !loopId)
17
+ return;
18
+ setStreamLines([t("board.logWaiting")]);
19
+ const socket = streamRunLog(loopId, run.runNumber, (line) => setStreamLines((prev) => {
20
+ const next = prev[0] === t("board.logWaiting") ? [] : prev;
21
+ return [...next, line].slice(-LOG_LINES_MAX);
22
+ }), () => { }, () => { });
23
+ socketRef.current = socket;
24
+ return () => {
25
+ socket.destroy();
26
+ if (socketRef.current === socket) {
27
+ socketRef.current = null;
28
+ }
29
+ };
30
+ }, [isRunning, loopId, run.runNumber]);
31
+ useKeyboard((key) => {
32
+ if (key.ctrl && key.name === "c") {
33
+ const all = (isRunning ? streamLines : staticLines).join("\n");
34
+ copyToClipboard(all);
35
+ }
36
+ });
37
+ const logLines = isRunning ? streamLines : staticLines;
38
+ const success = isRunning ? true : run.exitCode === 0;
39
+ const icon = isRunning ? "⟳" : success ? "✓" : "✗";
40
+ const iconColor = isRunning ? "#facc15" : success ? "#4ade80" : "#f87171";
41
+ const chainLabel = run.chainName ? ` → ${run.chainName}` : "";
11
42
  return (_jsx("box", { style: {
12
43
  position: "absolute",
13
44
  top: 0,
@@ -17,11 +48,11 @@ export function LogModal(props) {
17
48
  justifyContent: "center",
18
49
  alignItems: "center",
19
50
  zIndex: 100,
20
- }, children: _jsxs("box", { title: t("board.logModalTitle", { icon, time: formatRunTime(run.startedAt) }), border: true, style: {
51
+ }, children: _jsxs("box", { title: `${t("board.logModalTitle", { icon, time: formatRunTime(run.startedAt) })}${chainLabel}`, border: true, style: {
21
52
  flexDirection: "column",
22
53
  minWidth: Math.min(60, width - 4),
23
54
  width: Math.min(width - 4, 100),
24
55
  height: Math.min(height - 4, 30),
25
56
  backgroundColor: "#111827",
26
- }, children: [_jsx("scrollbox", { style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: loading ? (_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 }), " ", _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") })] })] }) }));
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") })] })] }) }));
27
58
  }
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from "react";
3
3
  import { useTerminalDimensions } from "@opentui/react";
4
4
  import { t } from "../../i18n/index.js";
5
- import { describeLoop, statusColor, statusLabel, timingLabel, truncate } from "../format.js";
5
+ import { describeLoop, sinceLabel, statusColor, statusLabel, timingLabel, truncate } from "../format.js";
6
6
  import { useHoverState } from "../hooks/useHoverState.js";
7
- import { HOVER_BG } from "../../config/constants.js";
7
+ import { HOVER_BG, ENTITY_COLORS } from "../../config/constants.js";
8
8
  function fit(text, width) {
9
9
  if (width <= 0)
10
10
  return "";
@@ -13,26 +13,37 @@ function fit(text, width) {
13
13
  return truncate(text, width);
14
14
  }
15
15
  export function Navigator(props) {
16
- const { visible, total, selectedIndex, filters, sort, breakpoint, focused, onSelect, onActivate } = props;
16
+ const { visible, total, selectedIndex, filters, sort, breakpoint, focused, projects, onSelect, onActivate } = props;
17
17
  const { width, height } = useTerminalDimensions();
18
18
  const scrollRef = useRef(null);
19
19
  const [, setTick] = useState(0);
20
- const panelWidth = width * (breakpoint === "narrow" ? 1 : 0.55) - 4;
20
+ const panelWidth = width * (breakpoint === "narrow" ? 1 : 0.605) - 4;
21
21
  const panelHeight = height - (breakpoint === "narrow" ? 10 : 7);
22
22
  const statusW = 8;
23
- const exitW = 4;
23
+ const exitW = 2;
24
24
  const runsW = 5;
25
- const fixedOverhead = 2 + statusW + 1 + 1 + exitW + 1 + runsW;
26
- const timingW = Math.max(6, Math.min(12, Math.floor((panelWidth - fixedOverhead) * 0.3)));
27
- const descW = Math.max(4, panelWidth - fixedOverhead - timingW);
25
+ const skpW = 4;
26
+ const bulletW = 2;
27
+ const nonVar = 2 + 1 + statusW + 1 + 1 + 1 + exitW + 1 + 1 + runsW + 1 + skpW;
28
+ const avail = Math.floor(panelWidth) - nonVar - bulletW;
29
+ const descW = Math.min(22, Math.max(6, Math.round(avail * 0.30)));
30
+ const sinceW = Math.min(14, Math.max(8, Math.round(avail * 0.35)));
31
+ const timingW = Math.max(6, avail - descW - sinceW);
28
32
  const header = " " +
33
+ fit("", bulletW) +
34
+ fit(t("board.headerDescription"), descW) +
35
+ " " +
29
36
  fit(t("board.headerStatus"), statusW) +
30
37
  " " +
31
- fit(t("board.headerDescription"), descW) +
38
+ fit(t("board.headerSince"), sinceW) +
32
39
  " " +
33
40
  fit(t("board.headerTiming"), timingW) +
34
41
  " " +
35
- fit(t("board.headerExitRuns"), exitW + 1 + runsW);
42
+ fit("EX", exitW) +
43
+ " " +
44
+ "#" + fit(t("board.headerRuns"), runsW) +
45
+ " " +
46
+ fit(t("board.headerSkipped"), skpW);
36
47
  useEffect(() => {
37
48
  const id = `nav-row-${selectedIndex}`;
38
49
  scrollRef.current?.scrollChildIntoView(id);
@@ -43,14 +54,16 @@ export function Navigator(props) {
43
54
  }, 1000);
44
55
  return () => clearInterval(timer);
45
56
  }, []);
46
- return (_jsxs("box", { title: t("board.navigatorTitle", { visible: visible.length, total, sort, status: filters.status }), border: true, borderColor: focused ? "#38bdf8" : undefined, style: { width: breakpoint === "narrow" ? "100%" : "55%", flexShrink: 0, flexDirection: "column", backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx("text", { fg: "#6b7280", children: header }), visible.length === 0 ? (_jsx("text", { fg: "#9ca3af", children: t("board.noMatch") })) : (_jsx("scrollbox", { ref: scrollRef, style: { flexGrow: 1, maxHeight: panelHeight, backgroundColor: "#0b0b0b" }, children: visible.map((loop, index) => {
57
+ return (_jsxs("box", { title: t("board.navigatorTitle", { visible: visible.length, total, sort, status: filters.status }), border: true, borderColor: focused ? ENTITY_COLORS.loop : "#1e3a4a", style: { width: breakpoint === "narrow" ? "100%" : "60.5%", flexShrink: 0, flexDirection: "column", backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx("text", { fg: "#6b7280", style: { height: 1, overflow: "hidden" }, children: header }), visible.length === 0 ? (_jsx("text", { fg: "#9ca3af", children: t("board.noMatch") })) : (_jsx("scrollbox", { ref: scrollRef, style: { flexGrow: 1, maxHeight: panelHeight, backgroundColor: "#0b0b0b" }, children: visible.map((loop, index) => {
47
58
  const isSelected = index === selectedIndex;
48
59
  const exit = loop.lastExitCode === null ? "-" : String(loop.lastExitCode);
49
- return (_jsx(NavigatorRow, { id: `nav-row-${index}`, loop: loop, index: index, isSelected: isSelected, focused: focused, exit: exit, statusW: statusW, descW: descW, timingW: timingW, exitW: exitW, runsW: runsW, onSelect: onSelect, onActivate: onActivate }, loop.id));
60
+ const proj = projects?.find((p) => p.id === (loop.projectId ?? "default"));
61
+ const bulletColor = proj?.color ?? "#ffffff";
62
+ return (_jsx(NavigatorRow, { id: `nav-row-${index}`, loop: loop, index: index, isSelected: isSelected, focused: focused, exit: exit, statusW: statusW, sinceW: sinceW, descW: descW, timingW: timingW, exitW: exitW, runsW: runsW, skpW: skpW, bulletColor: bulletColor, onSelect: onSelect, onActivate: onActivate }, loop.id));
50
63
  }) }))] }));
51
64
  }
52
65
  function NavigatorRow(props) {
53
- const { id, loop, index, isSelected, focused, exit, statusW, descW, timingW, exitW, runsW, onSelect, onActivate } = props;
66
+ const { id, loop, index, isSelected, focused, exit, statusW, sinceW, descW, timingW, exitW, runsW, skpW, bulletColor, onSelect, onActivate } = props;
54
67
  const { isHovered, hoverProps } = useHoverState();
55
68
  const bg = isSelected ? (focused ? "#1e3a8a" : "#1e2a4a") : isHovered ? HOVER_BG : undefined;
56
69
  const lastClickRef = useRef(0);
@@ -64,5 +77,8 @@ function NavigatorRow(props) {
64
77
  }
65
78
  lastClickRef.current = now;
66
79
  }
67
- return (_jsx("box", { id: id, onMouseDown: handleClick, backgroundColor: bg, ...hoverProps, children: _jsxs("text", { children: [isSelected ? "›" : " ", " ", _jsx("span", { fg: statusColor(loop.status), children: fit(statusLabel(loop.status), statusW) }), " ", fit(truncate(describeLoop(loop), descW), descW), " ", fit(timingLabel(loop), timingW), " ", fit(exit, exitW), " #", String(loop.runCount).padStart(runsW)] }) }));
80
+ const descText = fit(truncate(describeLoop(loop), descW), descW);
81
+ const afterDesc = ` ${fit(statusLabel(loop.status), statusW)} ${fit(sinceLabel(loop), sinceW)} ${fit(timingLabel(loop), timingW)} ${fit(exit, exitW)} #${String(loop.runCount).padStart(runsW)} ${fit(loop.skippedCount > 0 ? String(loop.skippedCount) : "-", skpW)}`;
82
+ const statusFg = statusColor(loop.status);
83
+ return (_jsx("box", { id: id, onMouseDown: handleClick, backgroundColor: bg, style: { height: 1, overflow: "hidden" }, ...hoverProps, children: _jsxs("text", { fg: "#e5e7eb", children: [isSelected ? "› " : " ", _jsx("span", { fg: bulletColor, children: "● " }), descText, " ", _jsx("span", { fg: statusFg, children: afterDesc.slice(1, 1 + statusW) }), afterDesc.slice(1 + statusW)] }) }));
68
84
  }
@@ -0,0 +1,70 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
4
+ import { t } from "../../i18n/index.js";
5
+ const ALL_ID = "all";
6
+ export function ProjectsModal(props) {
7
+ const { projects, loops, currentProjectId, onSelect, onClose } = props;
8
+ const { width } = useTerminalDimensions();
9
+ const [query, setQuery] = useState("");
10
+ // "Show All" synthetic entry always first, then real projects sorted alphabetically
11
+ const sorted = [...projects].sort((a, b) => a.name.localeCompare(b.name));
12
+ const allEntry = { id: ALL_ID, name: t("project.showAll"), color: "#6b7280", isSystem: false, isDefault: false, createdAt: "" };
13
+ const entries = [allEntry, ...sorted];
14
+ const filtered = query
15
+ ? entries.filter((p) => p.id === ALL_ID || p.name.toLowerCase().startsWith(query.toLowerCase()))
16
+ : entries;
17
+ const initialIndex = Math.max(0, filtered.findIndex((p) => p.id === currentProjectId));
18
+ const [selectedIndex, setSelectedIndex] = useState(initialIndex);
19
+ const clampedIndex = Math.min(selectedIndex, Math.max(0, filtered.length - 1));
20
+ useKeyboard((key) => {
21
+ if (key.name === "up") {
22
+ setSelectedIndex((i) => Math.max(0, i - 1));
23
+ return;
24
+ }
25
+ if (key.name === "down") {
26
+ setSelectedIndex((i) => Math.min(filtered.length - 1, i + 1));
27
+ return;
28
+ }
29
+ if (key.name === "return" || key.name === "enter") {
30
+ const entry = filtered[clampedIndex];
31
+ if (entry)
32
+ onSelect(entry.id);
33
+ return;
34
+ }
35
+ if (key.name === "escape") {
36
+ onClose();
37
+ return;
38
+ }
39
+ if (key.name === "backspace") {
40
+ setQuery((q) => q.slice(0, -1));
41
+ return;
42
+ }
43
+ if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
44
+ setQuery((q) => q + key.sequence);
45
+ setSelectedIndex(0);
46
+ }
47
+ });
48
+ return (_jsx("box", { style: {
49
+ position: "absolute",
50
+ top: 0,
51
+ left: 0,
52
+ width: "100%",
53
+ height: "100%",
54
+ justifyContent: "center",
55
+ alignItems: "center",
56
+ zIndex: 100,
57
+ }, children: _jsxs("box", { title: t("project.selectProject"), border: true, style: {
58
+ flexDirection: "column",
59
+ padding: 1,
60
+ minWidth: Math.min(50, width - 4),
61
+ backgroundColor: "#111827",
62
+ }, children: [_jsx("box", { border: true, style: { height: 3, marginBottom: 1, backgroundColor: "#0b0b0b" }, children: _jsxs("text", { fg: query ? "#e5e7eb" : "#6b7280", children: [query || "type to filter...", "\u258E"] }) }), filtered.length === 0 ? (_jsx("text", { fg: "#9ca3af", children: "No projects match" })) : (filtered.map((entry, index) => {
63
+ const isSelected = index === clampedIndex;
64
+ const bg = isSelected ? "#1e3a8a" : undefined;
65
+ const loopCount = entry.id === ALL_ID
66
+ ? loops.length
67
+ : loops.filter((l) => (l.projectId ?? "default") === entry.id).length;
68
+ return (_jsx("box", { backgroundColor: bg, onMouseDown: () => onSelect(entry.id), style: { height: 1 }, children: _jsxs("text", { children: [isSelected ? "› " : " ", _jsx("span", { fg: entry.color, children: "\u25CF" }), ` ${entry.name} `, _jsx("span", { fg: "#6b7280", children: `(${loopCount})` })] }) }, entry.id));
69
+ }))] }) }));
70
+ }
@@ -0,0 +1,105 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { useKeyboard } from "@opentui/react";
4
+ import { t } from "../../i18n/index.js";
5
+ import { useHoverState } from "../hooks/useHoverState.js";
6
+ import { HOVER_BG, ENTITY_COLORS } from "../../config/constants.js";
7
+ import { CreateProjectModal } from "./CreateProjectModal.js";
8
+ import { EditProjectModal } from "./EditProjectModal.js";
9
+ import { DeleteProjectConfirm } from "./DeleteProjectConfirm.js";
10
+ const PROJECT_ACTION_COUNT = 2;
11
+ function ProjectActionButton(props) {
12
+ const { isHovered, hoverProps } = useHoverState();
13
+ const bg = props.selected ? "#1e3a8a" : isHovered ? HOVER_BG : undefined;
14
+ const fg = props.selected ? "#ffffff" : isHovered ? "#e5e7eb" : "#9ca3af";
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
+ }
17
+ export function ProjectsPage(props) {
18
+ const { projects, loops, onClose, onRefresh, onOpenCreate } = props;
19
+ const [selectedIndex, setSelectedIndex] = useState(0);
20
+ const [subModal, setSubModal] = useState("none");
21
+ const [focusedPanel, setFocusedPanel] = useState("list");
22
+ const [selectedAction, setSelectedAction] = useState(0);
23
+ // Expose create trigger to parent via callback
24
+ useState(() => { onOpenCreate?.(() => setSubModal("create")); });
25
+ const clampedIndex = Math.min(selectedIndex, Math.max(0, projects.length - 1));
26
+ const selectedProject = projects[clampedIndex] ?? null;
27
+ const loopCount = (projectId) => loops.filter((l) => (l.projectId ?? "default") === projectId).length;
28
+ function runAction(actionIndex) {
29
+ if (!selectedProject || selectedProject.isSystem)
30
+ return;
31
+ if (actionIndex === 0)
32
+ setSubModal("edit");
33
+ else if (actionIndex === 1)
34
+ setSubModal("delete");
35
+ }
36
+ useKeyboard((key) => {
37
+ if (subModal !== "none")
38
+ 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
+ return;
45
+ }
46
+ if (key.name === "down") {
47
+ if (focusedPanel === "list")
48
+ setSelectedIndex((i) => Math.min(projects.length - 1, i + 1));
49
+ else
50
+ setSelectedAction((a) => Math.min(PROJECT_ACTION_COUNT - 1, a + 1));
51
+ return;
52
+ }
53
+ if (key.name === "left" || key.name === "right") {
54
+ setFocusedPanel((p) => (p === "list" ? "actions" : "list"));
55
+ return;
56
+ }
57
+ if (key.name === "return" || key.name === "enter") {
58
+ if (focusedPanel === "list") {
59
+ setFocusedPanel("actions");
60
+ }
61
+ else {
62
+ runAction(selectedAction);
63
+ }
64
+ return;
65
+ }
66
+ if (key.name === "n") {
67
+ setSubModal("create");
68
+ return;
69
+ }
70
+ if (key.name === "e" && selectedProject && !selectedProject.isSystem) {
71
+ setSubModal("edit");
72
+ return;
73
+ }
74
+ if (key.name === "d" && selectedProject && !selectedProject.isSystem) {
75
+ setSubModal("delete");
76
+ return;
77
+ }
78
+ if (key.name === "escape") {
79
+ onClose();
80
+ }
81
+ });
82
+ const actions = [
83
+ { key: "edit", label: t("project.editProjectLabel") },
84
+ { key: "delete", label: t("project.deleteProjectLabel") },
85
+ ];
86
+ 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) => {
87
+ const isSelected = index === clampedIndex;
88
+ const bg = isSelected ? "#1e3a8a" : undefined;
89
+ const count = loopCount(project.id);
90
+ 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));
91
+ })] }), _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) => {
92
+ setSubModal("none");
93
+ await onRefresh();
94
+ const newIndex = projects.findIndex((p) => p.id === project.id);
95
+ if (newIndex >= 0)
96
+ setSelectedIndex(newIndex);
97
+ }, onCancel: () => setSubModal("none") })) : null, subModal === "edit" && selectedProject ? (_jsx(EditProjectModal, { project: selectedProject, onDone: async () => {
98
+ setSubModal("none");
99
+ await onRefresh();
100
+ }, onCancel: () => setSubModal("none") })) : null, subModal === "delete" && selectedProject ? (_jsx(DeleteProjectConfirm, { project: selectedProject, loopCount: loopCount(selectedProject.id), onDone: async () => {
101
+ setSubModal("none");
102
+ setSelectedIndex((i) => Math.max(0, i - 1));
103
+ await onRefresh();
104
+ }, onCancel: () => setSubModal("none") })) : null] }));
105
+ }
@@ -1,10 +1,46 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
- import { useEffect, useRef } from "react";
2
+ import { useEffect, useRef, useMemo } from "react";
3
3
  import { useTerminalDimensions } from "@opentui/react";
4
4
  import { t } from "../../i18n/index.js";
5
5
  import { formatFileSize, formatRunDuration, formatRunTime } from "../format.js";
6
6
  import { useHoverState } from "../hooks/useHoverState.js";
7
7
  import { HOVER_BG } from "../../config/constants.js";
8
+ function groupRuns(runs) {
9
+ const groups = [];
10
+ let i = 0;
11
+ while (i < runs.length) {
12
+ const current = runs[i];
13
+ if (current.chainGroupId) {
14
+ const group = runs.filter((r) => r.chainGroupId === current.chainGroupId);
15
+ const first = group[0];
16
+ const allSuccess = group.every((r) => r.status === "completed" && r.exitCode === 0);
17
+ const isRunning = group.some((r) => r.status === "running");
18
+ groups.push({
19
+ runs: group,
20
+ startedAt: first.startedAt,
21
+ totalDuration: group.reduce((s, r) => s + r.duration, 0),
22
+ totalLogSize: group.reduce((s, r) => s + r.logSize, 0),
23
+ allSuccess,
24
+ isRunning,
25
+ chainName: group.find((r) => r.chainName)?.chainName ?? null,
26
+ });
27
+ i += group.length;
28
+ }
29
+ else {
30
+ groups.push({
31
+ runs: [current],
32
+ startedAt: current.startedAt,
33
+ totalDuration: current.duration,
34
+ totalLogSize: current.logSize,
35
+ allSuccess: current.status === "completed" && current.exitCode === 0,
36
+ isRunning: current.status === "running",
37
+ chainName: null,
38
+ });
39
+ i++;
40
+ }
41
+ }
42
+ return groups;
43
+ }
8
44
  function fit(text, width) {
9
45
  if (width <= 0)
10
46
  return "";
@@ -17,6 +53,7 @@ export function RunHistory(props) {
17
53
  const { height } = useTerminalDimensions();
18
54
  const scrollRef = useRef(null);
19
55
  const runs = loop?.runHistory ?? [];
56
+ const grouped = useMemo(() => groupRuns(runs), [runs]);
20
57
  useEffect(() => {
21
58
  const id = `run-row-${selectedRunIndex}`;
22
59
  scrollRef.current?.scrollChildIntoView(id);
@@ -31,14 +68,16 @@ export function RunHistory(props) {
31
68
  fit(t("board.runHistoryDuration"), durW) +
32
69
  " " +
33
70
  fit(t("board.runHistorySize"), sizeW);
34
- return (_jsxs("box", { title: t("board.runHistoryTitle"), border: true, borderColor: focused ? "#38bdf8" : undefined, style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx("text", { fg: "#6b7280", children: header }), runs.length === 0 ? (_jsx("text", { fg: "#9ca3af", children: t("board.runHistoryEmpty") })) : (_jsx("scrollbox", { ref: scrollRef, style: { flexGrow: 1, maxHeight: panelHeight, backgroundColor: "#0b0b0b" }, children: [...runs].reverse().map((run, revIndex) => (_jsx(RunRow, { id: `run-row-${revIndex}`, run: run, isSelected: revIndex === selectedRunIndex, focused: focused, timeW: timeW, durW: durW, sizeW: sizeW, onSelect: onSelectRun, onOpen: onOpenRun }, run.runNumber))) }, `runs-${loop?.id}-${runs.length}`))] }));
71
+ return (_jsxs("box", { title: t("board.runHistoryTitleHint"), border: true, borderColor: focused ? "#38bdf8" : undefined, style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx("text", { fg: "#6b7280", children: header }), grouped.length === 0 ? (_jsx("text", { fg: "#9ca3af", children: t("board.runHistoryEmpty") })) : (_jsx("scrollbox", { ref: scrollRef, style: { flexGrow: 1, maxHeight: panelHeight, backgroundColor: "#0b0b0b" }, children: [...grouped].reverse().map((group, revIndex) => (_jsx(GroupRow, { id: `run-row-${revIndex}`, group: group, isSelected: focused && revIndex === selectedRunIndex, timeW: timeW, durW: durW, sizeW: sizeW, onOpen: () => onOpenRun(group.runs[0]) }, group.runs[0].runNumber + (group.chainName ?? "")))) }, `runs-${loop?.id}-${runs.length}`))] }));
35
72
  }
36
- function RunRow(props) {
37
- const { id, run, isSelected, focused, timeW, durW, sizeW, onOpen } = props;
73
+ function GroupRow(props) {
74
+ const { id, group, isSelected, timeW, durW, sizeW, onOpen } = props;
38
75
  const { isHovered, hoverProps } = useHoverState();
39
- const bg = isSelected ? (focused ? "#1e3a8a" : "#1e2a4a") : isHovered ? HOVER_BG : undefined;
40
- const success = run.exitCode === 0;
41
- const icon = success ? "" : "";
42
- const iconColor = success ? "#4ade80" : "#f87171";
43
- return (_jsx("box", { id: id, onMouseDown: () => onOpen(run), backgroundColor: bg, ...hoverProps, children: _jsxs("text", { children: [" ", _jsx("span", { fg: "#9ca3af", children: fit(formatRunTime(run.startedAt), timeW) }), " ", _jsx("span", { fg: iconColor, children: icon }), " ", fit(formatRunDuration(run.duration), durW), " ", fit(formatFileSize(run.logSize), sizeW)] }) }));
76
+ const bg = isSelected ? "#1e3a8a" : isHovered ? HOVER_BG : undefined;
77
+ const icon = group.isRunning ? "⟳" : group.allSuccess ? "✓" : "✗";
78
+ const iconColor = group.isRunning ? "#facc15" : group.allSuccess ? "#4ade80" : "#f87171";
79
+ const label = group.chainName
80
+ ? `→ ${group.chainName}`
81
+ : "";
82
+ return (_jsx("box", { id: id, onMouseDown: onOpen, backgroundColor: bg, ...hoverProps, children: _jsxs("text", { children: [" ", _jsx("span", { fg: "#9ca3af", children: fit(formatRunTime(group.startedAt), timeW) }), " ", _jsx("span", { fg: iconColor, children: icon }), " ", label ? _jsx("span", { fg: "#a78bfa", children: fit(label, 12) }) : null, " ", fit(group.isRunning ? "…" : formatRunDuration(group.totalDuration), durW), " ", fit(formatFileSize(group.totalLogSize), sizeW)] }) }));
44
83
  }