@plainconceptsplatform/loop-task 2.6.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 (202) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +534 -0
  3. package/dist/app/App.js +132 -0
  4. package/dist/app/BoardLayout.js +5 -0
  5. package/dist/app/index.js +48 -0
  6. package/dist/app/providers/index.js +1 -0
  7. package/dist/app/router/index.js +16 -0
  8. package/dist/app/types.js +1 -0
  9. package/dist/cli/import-validator.js +152 -0
  10. package/dist/cli/import-writer.js +81 -0
  11. package/dist/cli.js +573 -0
  12. package/dist/client/cli-format.js +7 -0
  13. package/dist/client/commands.js +162 -0
  14. package/dist/client/ipc.js +96 -0
  15. package/dist/client/project-commands.js +164 -0
  16. package/dist/core/command/command-runner.js +300 -0
  17. package/dist/core/command/process-tree.js +73 -0
  18. package/dist/core/command/resolve-cwd.js +8 -0
  19. package/dist/core/command/stdout-capture-transform.js +73 -0
  20. package/dist/core/context/context-parser.js +65 -0
  21. package/dist/core/context/log-context.js +3 -0
  22. package/dist/core/context/template.js +20 -0
  23. package/dist/core/context/validate-context.js +19 -0
  24. package/dist/core/foreground/index.js +81 -0
  25. package/dist/core/logging/bounded-log-reader.js +368 -0
  26. package/dist/core/logging/log-follower.js +26 -0
  27. package/dist/core/logging/log-parser.js +29 -0
  28. package/dist/core/logging/log-rotator.js +34 -0
  29. package/dist/core/logging/rotating-log-stream.js +137 -0
  30. package/dist/core/loop/chain-executor.js +171 -0
  31. package/dist/core/loop/delay-utils.js +65 -0
  32. package/dist/core/loop/loop-controller.js +240 -0
  33. package/dist/core/loop/loop-runner.js +194 -0
  34. package/dist/core/loop/run-executor.js +144 -0
  35. package/dist/core/loop/types.js +1 -0
  36. package/dist/core/scheduling/index.js +16 -0
  37. package/dist/daemon/daemon-log.js +11 -0
  38. package/dist/daemon/diagnostics.js +85 -0
  39. package/dist/daemon/http/helpers.js +81 -0
  40. package/dist/daemon/http/openapi.js +119 -0
  41. package/dist/daemon/http/route-loops.js +311 -0
  42. package/dist/daemon/http/route-misc.js +60 -0
  43. package/dist/daemon/http/route-projects.js +55 -0
  44. package/dist/daemon/http/route-tasks.js +141 -0
  45. package/dist/daemon/http/routes.js +19 -0
  46. package/dist/daemon/http/server.js +82 -0
  47. package/dist/daemon/http/sse.js +36 -0
  48. package/dist/daemon/index.js +189 -0
  49. package/dist/daemon/ipc/send.js +5 -0
  50. package/dist/daemon/managers/loop-entry.js +27 -0
  51. package/dist/daemon/managers/loop-manager.js +346 -0
  52. package/dist/daemon/managers/loop-options.js +17 -0
  53. package/dist/daemon/managers/loop-serialization.js +53 -0
  54. package/dist/daemon/managers/project-manager.js +170 -0
  55. package/dist/daemon/managers/task-manager.js +94 -0
  56. package/dist/daemon/mcp/index.js +2 -0
  57. package/dist/daemon/mcp/openapi-sync.js +50 -0
  58. package/dist/daemon/mcp/server.js +167 -0
  59. package/dist/daemon/mcp/tools.js +382 -0
  60. package/dist/daemon/recipe/deferred-reload.js +32 -0
  61. package/dist/daemon/recipe/diagram-reader.js +26 -0
  62. package/dist/daemon/recipe/file-writer.js +52 -0
  63. package/dist/daemon/recipe/id-remapper.js +46 -0
  64. package/dist/daemon/recipe/runtime-state.js +23 -0
  65. package/dist/daemon/recipe/scanner.js +165 -0
  66. package/dist/daemon/recipe/task-store.js +33 -0
  67. package/dist/daemon/recipe/validator.js +35 -0
  68. package/dist/daemon/server/handlers/diagnostics-handlers.js +7 -0
  69. package/dist/daemon/server/handlers/index.js +101 -0
  70. package/dist/daemon/server/handlers/log-handlers.js +129 -0
  71. package/dist/daemon/server/handlers/loop-handlers.js +64 -0
  72. package/dist/daemon/server/handlers/project-handlers.js +42 -0
  73. package/dist/daemon/server/handlers/settings-handlers.js +8 -0
  74. package/dist/daemon/server/handlers/task-handlers.js +20 -0
  75. package/dist/daemon/server/handlers/telemetry-handlers.js +5 -0
  76. package/dist/daemon/server/index.js +117 -0
  77. package/dist/daemon/settings-manager.js +73 -0
  78. package/dist/daemon/spawner/index.js +204 -0
  79. package/dist/daemon/state/index.js +209 -0
  80. package/dist/daemon/telemetry/agent-integrations/agent-integration.js +1 -0
  81. package/dist/daemon/telemetry/agent-integrations/claude-code-integration.js +55 -0
  82. package/dist/daemon/telemetry/agent-integrations/detect-agent-integration.js +20 -0
  83. package/dist/daemon/telemetry/agent-integrations/index.js +3 -0
  84. package/dist/daemon/telemetry/agent-integrations/opencode-integration.js +72 -0
  85. package/dist/daemon/telemetry/index.js +6 -0
  86. package/dist/daemon/telemetry/noop-telemetry-adapter.js +53 -0
  87. package/dist/daemon/telemetry/open-telemetry-adapter.js +464 -0
  88. package/dist/daemon/telemetry/telemetry-manager.js +147 -0
  89. package/dist/daemon/telemetry/telemetry-redaction.js +67 -0
  90. package/dist/daemon/telemetry/telemetry-types.js +45 -0
  91. package/dist/daemon/telemetry/telemetry.js +1 -0
  92. package/dist/daemon/watcher/index.js +250 -0
  93. package/dist/duration.js +24 -0
  94. package/dist/entities/loops/filters.js +109 -0
  95. package/dist/entities/loops/index.js +1 -0
  96. package/dist/entities/projects/filters.js +69 -0
  97. package/dist/entities/projects/index.js +1 -0
  98. package/dist/entities/tasks/filters.js +41 -0
  99. package/dist/entities/tasks/index.js +1 -0
  100. package/dist/entry.js +16 -0
  101. package/dist/esm-loader.js +39 -0
  102. package/dist/features/chain-editor/ChainEditor.js +116 -0
  103. package/dist/features/chain-editor/mermaidToAscii.js +375 -0
  104. package/dist/features/chain-editor/renderChainDiagram.js +106 -0
  105. package/dist/features/code-editor/CodeEditorModal.js +84 -0
  106. package/dist/features/code-editor/CodeEditorPreview.js +14 -0
  107. package/dist/features/code-editor/useEditorKeyboard.js +150 -0
  108. package/dist/features/code-editor/useModalDimensions.js +9 -0
  109. package/dist/features/commands/commands.js +132 -0
  110. package/dist/features/commands/useCommandHandlers.js +280 -0
  111. package/dist/features/commands/useContextualActions.js +78 -0
  112. package/dist/features/commands/useGlobalShortcuts.js +154 -0
  113. package/dist/features/forms/FormRouter.js +46 -0
  114. package/dist/features/overlays/ContextHelpModal.js +8 -0
  115. package/dist/features/overlays/DiagramModal.js +45 -0
  116. package/dist/features/overlays/ExportModal.js +51 -0
  117. package/dist/features/overlays/HelpGuideModal.js +23 -0
  118. package/dist/features/overlays/HelpModal.js +59 -0
  119. package/dist/features/overlays/OverlayStack.js +10 -0
  120. package/dist/features/overlays/ProjectsModal.js +42 -0
  121. package/dist/features/overlays/TaskPickerModal.js +62 -0
  122. package/dist/features/overlays/WelcomeScreen.js +74 -0
  123. package/dist/features/overlays/useOverlayStack.js +55 -0
  124. package/dist/features/state/useAppState.js +151 -0
  125. package/dist/logger.js +25 -0
  126. package/dist/loop-config.js +152 -0
  127. package/dist/shared/clipboard.js +111 -0
  128. package/dist/shared/config/constants.js +104 -0
  129. package/dist/shared/config/paths.js +67 -0
  130. package/dist/shared/container/index.js +24 -0
  131. package/dist/shared/fs-utils.js +44 -0
  132. package/dist/shared/hooks/useBreakpoint.js +11 -0
  133. package/dist/shared/hooks/useDaemonSettings.js +41 -0
  134. package/dist/shared/hooks/useInject.js +6 -0
  135. package/dist/shared/hooks/useLogStream.js +37 -0
  136. package/dist/shared/hooks/useLoopFormValidation.js +131 -0
  137. package/dist/shared/hooks/useLoopPolling.js +30 -0
  138. package/dist/shared/hooks/useMouseScroll.js +56 -0
  139. package/dist/shared/hooks/useUndoRedo.js +77 -0
  140. package/dist/shared/i18n/en.json +709 -0
  141. package/dist/shared/i18n/index.js +11 -0
  142. package/dist/shared/providers/InversifyProvider.js +6 -0
  143. package/dist/shared/services/export-service.js +43 -0
  144. package/dist/shared/services/log-service.js +28 -0
  145. package/dist/shared/services/loop-service.js +78 -0
  146. package/dist/shared/services/project-service.js +39 -0
  147. package/dist/shared/services/settings-service.js +102 -0
  148. package/dist/shared/services/task-service.js +41 -0
  149. package/dist/shared/services/types.js +8 -0
  150. package/dist/shared/sleep.js +17 -0
  151. package/dist/shared/tail.js +4 -0
  152. package/dist/shared/ui/Button.js +18 -0
  153. package/dist/shared/ui/DebugPanel.js +9 -0
  154. package/dist/shared/ui/FocusableButton.js +18 -0
  155. package/dist/shared/ui/FocusableInput.js +72 -0
  156. package/dist/shared/ui/FocusableList.js +48 -0
  157. package/dist/shared/ui/Modal.js +13 -0
  158. package/dist/shared/ui/SelectModal.js +78 -0
  159. package/dist/shared/ui/Toast.js +37 -0
  160. package/dist/shared/ui/format.js +117 -0
  161. package/dist/shared/ui/hooks/useHoverState.js +11 -0
  162. package/dist/shared/ui/state.js +9 -0
  163. package/dist/shared/ui/theme.js +96 -0
  164. package/dist/shared/utils/log-lines.js +13 -0
  165. package/dist/shared/utils/paste.js +37 -0
  166. package/dist/shared/utils/syntax.js +128 -0
  167. package/dist/shared/utils/validation.js +56 -0
  168. package/dist/types.js +1 -0
  169. package/dist/visual-evidence/capture.js +51 -0
  170. package/dist/visual-evidence/cli.js +85 -0
  171. package/dist/visual-evidence/evidence-required.js +71 -0
  172. package/dist/visual-evidence/index.js +10 -0
  173. package/dist/visual-evidence/launch.js +61 -0
  174. package/dist/visual-evidence/manifest.js +32 -0
  175. package/dist/visual-evidence/openspec-resolver.js +45 -0
  176. package/dist/visual-evidence/publish.js +126 -0
  177. package/dist/visual-evidence/run.js +121 -0
  178. package/dist/visual-evidence/scenario-registry.js +132 -0
  179. package/dist/widgets/command-input/CommandDropdown.js +69 -0
  180. package/dist/widgets/command-input/CommandInput.js +133 -0
  181. package/dist/widgets/command-input/HintBar.js +9 -0
  182. package/dist/widgets/command-input/InputModes.js +72 -0
  183. package/dist/widgets/commands-browser/CommandsBrowserModal.js +85 -0
  184. package/dist/widgets/header/Header.js +54 -0
  185. package/dist/widgets/header/TabBar.js +20 -0
  186. package/dist/widgets/left-panel/LeftPanel.js +34 -0
  187. package/dist/widgets/left-panel/Navigator.js +107 -0
  188. package/dist/widgets/left-panel/ProjectsPage.js +76 -0
  189. package/dist/widgets/left-panel/ProjectsPageParts.js +34 -0
  190. package/dist/widgets/left-panel/TaskBrowser.js +132 -0
  191. package/dist/widgets/log-modal/LogModal.js +211 -0
  192. package/dist/widgets/loop-form/CreateForm.js +82 -0
  193. package/dist/widgets/loop-form/TextField.js +9 -0
  194. package/dist/widgets/loop-form/WizardForm.js +199 -0
  195. package/dist/widgets/loop-form/useCreateSteps.js +135 -0
  196. package/dist/widgets/loop-form/useHandleComplete.js +94 -0
  197. package/dist/widgets/project-form/ProjectForm.js +83 -0
  198. package/dist/widgets/right-panel/Inspector.js +41 -0
  199. package/dist/widgets/right-panel/RightPanel.js +38 -0
  200. package/dist/widgets/right-panel/RunHistory.js +187 -0
  201. package/dist/widgets/task-form/TaskForm.js +272 -0
  202. package/package.json +119 -0
@@ -0,0 +1,133 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useCallback } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { useAutocompleteState, } from "ink-combobox";
5
+ import { darkTheme as theme } from "../../shared/ui/theme.js";
6
+ import { t } from "../../shared/i18n/index.js";
7
+ import { buildCommands, rankCommands } from "../../features/commands/commands.js";
8
+ import { COMMAND_INPUT_DROPDOWN_MAX_VISIBLE, COMMAND_INPUT_HEIGHT, } from "../../shared/config/constants.js";
9
+ import { sanitizePaste } from "../../shared/utils/paste.js";
10
+ import { CommandDropdown, renderInputLine } from "./CommandDropdown.js";
11
+ import { HintBar, KeyHint } from "./HintBar.js";
12
+ import { ConfirmMode, SearchMode } from "./InputModes.js";
13
+ export { sanitizePaste } from "../../shared/utils/paste.js";
14
+ function CommandMode({ context, onCommand, onCopy, onPanelAction, disabled, navOwner, onInputStateChange, }) {
15
+ const commands = useMemo(() => buildCommands(context), [context]);
16
+ const options = useMemo(() => commands.map((cmd) => ({ label: cmd.label, value: cmd.value })), [commands]);
17
+ const { state, dispatch } = useAutocompleteState({
18
+ options,
19
+ visibleOptionCount: COMMAND_INPUT_DROPDOWN_MAX_VISIBLE,
20
+ onSelect: onCommand,
21
+ });
22
+ const rankedFiltered = useMemo(() => {
23
+ const fo = state.filteredOptions;
24
+ if (fo.length === 0 || state.inputValue.length === 0)
25
+ return fo;
26
+ const byValue = new Map();
27
+ for (const m of fo)
28
+ byValue.set(m.option.value, m);
29
+ const ranked = rankCommands(state.inputValue, fo.map((m) => ({ label: m.option.label, value: m.option.value })));
30
+ return ranked
31
+ .map((opt) => byValue.get(opt.value))
32
+ .filter((m) => m !== undefined);
33
+ }, [state.filteredOptions, state.inputValue]);
34
+ useEffect(() => {
35
+ onInputStateChange?.(state.inputValue.length > 0, state.isOpen);
36
+ }, [state.inputValue, state.isOpen, onInputStateChange]);
37
+ const insertText = useCallback((text) => {
38
+ for (const ch of text)
39
+ dispatch({ type: "INSERT_TEXT", text: ch });
40
+ }, [dispatch]);
41
+ const clearInput = useCallback(() => {
42
+ dispatch({ type: "MOVE_CURSOR_END" });
43
+ for (let i = 0; i < state.inputValue.length; i++)
44
+ dispatch({ type: "DELETE_BACKWARD" });
45
+ }, [dispatch, state.inputValue.length]);
46
+ useInput((input, key) => {
47
+ if (input.includes("\x1b[200~")) {
48
+ insertText(sanitizePaste(input));
49
+ return;
50
+ }
51
+ // Swallow SGR mouse sequences — handled by useMouseScroll
52
+ if (input.includes("[<")) {
53
+ return;
54
+ }
55
+ if ((key.ctrl && input === "u") || input === "\x15") {
56
+ clearInput();
57
+ return;
58
+ }
59
+ if (key.ctrl)
60
+ return;
61
+ if (input.length > 1 && (input.includes("\r") || input.includes("\n")))
62
+ return;
63
+ if (navOwner === "panel" && state.inputValue.length === 0 && !state.isOpen) {
64
+ if (input === "j" || input === "k" || key.upArrow || key.downArrow) {
65
+ return;
66
+ }
67
+ }
68
+ if (onCopy && input === "c" && !state.isOpen) {
69
+ onCopy();
70
+ return;
71
+ }
72
+ if (key.escape) {
73
+ dispatch({ type: "CLOSE" });
74
+ if (state.inputValue.length > 0) {
75
+ clearInput();
76
+ }
77
+ return;
78
+ }
79
+ if (key.return) {
80
+ if (state.isOpen && rankedFiltered.length > 0 && state.focusedIndex < rankedFiltered.length) {
81
+ const focused = rankedFiltered[state.focusedIndex];
82
+ dispatch({ type: "CLOSE" });
83
+ for (let i = 0; i <= state.inputValue.length; i++) {
84
+ dispatch({ type: "DELETE_BACKWARD" });
85
+ }
86
+ onCommand(focused.option.value);
87
+ }
88
+ else if (state.inputValue.length === 0) {
89
+ onPanelAction?.();
90
+ }
91
+ return;
92
+ }
93
+ if (key.tab) {
94
+ dispatch({ type: "ACCEPT" });
95
+ return;
96
+ }
97
+ if (key.downArrow) {
98
+ dispatch({ type: "FOCUS_NEXT" });
99
+ return;
100
+ }
101
+ if (key.upArrow) {
102
+ dispatch({ type: "FOCUS_PREV" });
103
+ return;
104
+ }
105
+ if (key.backspace || key.delete) {
106
+ dispatch({ type: "DELETE_BACKWARD" });
107
+ return;
108
+ }
109
+ if (key.leftArrow) {
110
+ dispatch({ type: "MOVE_CURSOR_LEFT" });
111
+ return;
112
+ }
113
+ if (key.rightArrow) {
114
+ dispatch({ type: "MOVE_CURSOR_RIGHT" });
115
+ return;
116
+ }
117
+ if (input.length > 1 && !key.meta) {
118
+ insertText(sanitizePaste(input));
119
+ return;
120
+ }
121
+ if (input.length === 1 && !key.ctrl && !key.meta && !key.return && !key.tab && !key.escape && input >= " " && input <= "~") {
122
+ dispatch({ type: "INSERT_TEXT", text: input });
123
+ }
124
+ }, { isActive: !disabled });
125
+ const isEmpty = state.inputValue.length === 0;
126
+ const cursor = "\x1b[7m \x1b[27m";
127
+ const inputContent = isEmpty ? cursor : renderInputLine(state.inputValue, state.cursorOffset);
128
+ return (_jsxs(_Fragment, { children: [_jsx(CommandDropdown, { state: state, rankedFiltered: rankedFiltered }), _jsxs(Box, { children: [_jsx(Text, { color: theme.accent.brand, children: "│ " }), isEmpty ? (_jsx(Text, { color: theme.text.muted, children: t("cmdInput.placeholder") })) : (_jsx(Text, { children: inputContent }))] }), _jsx(HintBar, { leftHint: _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7 " }), _jsx(KeyHint, { keyLabel: "esc", action: "cancel" })] }), rightHint: _jsxs(Box, { children: [_jsx(KeyHint, { keyLabel: "enter", action: "edit/logs" }), _jsx(KeyHint, { keyLabel: "ctrl+u", action: "clear" }), _jsx(KeyHint, { keyLabel: "c", action: "copy" }), _jsx(KeyHint, { keyLabel: "tab", action: "panels" }), _jsx(KeyHint, { keyLabel: "ctrl+\u2190\u2192", action: "tabs" }), _jsx(KeyHint, { keyLabel: "ctrl+p", action: "commands" })] }) })] }));
129
+ }
130
+ export function CommandInput(props) {
131
+ const { context, onCommand, confirmState, searchState, searchValue, onSearchChange, onSearchSubmit, onSearchCancel, onConfirmYes, onConfirmCancel, onCopy, onPanelAction, navOwner, onInputStateChange, } = props;
132
+ return (_jsx(Box, { flexDirection: "column", height: COMMAND_INPUT_HEIGHT, borderStyle: "single", borderColor: theme.border.dim, paddingY: 1, children: searchState?.active ? (_jsx(SearchMode, { value: searchValue, onSearchChange: onSearchChange, onSearchSubmit: onSearchSubmit, onSearchCancel: onSearchCancel, disabled: props.disabled })) : confirmState === null ? (_jsx(CommandMode, { context: context, onCommand: onCommand, onCopy: onCopy, onPanelAction: onPanelAction, disabled: props.disabled, navOwner: navOwner, onInputStateChange: onInputStateChange })) : (_jsx(ConfirmMode, { confirmState: confirmState, onConfirmYes: onConfirmYes, onConfirmCancel: onConfirmCancel, disabled: props.disabled })) }));
133
+ }
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { darkTheme as theme } from "../../shared/ui/theme.js";
4
+ export function HintBar({ leftHint, rightHint, }) {
5
+ return (_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsx(Box, { children: leftHint }), _jsx(Box, { children: rightHint })] }));
6
+ }
7
+ export function KeyHint({ keyLabel, action }) {
8
+ return (_jsxs(Box, { marginRight: 2, children: [_jsx(Text, { bold: true, color: theme.text.primary, children: keyLabel }), _jsx(Text, { color: theme.text.muted, children: " " + action })] }));
9
+ }
@@ -0,0 +1,72 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { darkTheme as theme } from "../../shared/ui/theme.js";
5
+ import { t } from "../../shared/i18n/index.js";
6
+ import { HintBar, KeyHint } from "./HintBar.js";
7
+ export function ConfirmMode({ confirmState, onConfirmYes, onConfirmCancel, disabled, }) {
8
+ const [value, setValue] = useState("");
9
+ useInput((input, key) => {
10
+ if (input.includes("[<"))
11
+ return;
12
+ if (key.ctrl)
13
+ return;
14
+ if (input.length > 1 && (input.includes("\r") || input.includes("\n")))
15
+ return;
16
+ if (key.escape) {
17
+ setValue("");
18
+ onConfirmCancel();
19
+ return;
20
+ }
21
+ if (key.return) {
22
+ if (value.toLowerCase() === "yes") {
23
+ setValue("");
24
+ onConfirmYes();
25
+ }
26
+ else {
27
+ setValue("");
28
+ onConfirmCancel();
29
+ }
30
+ return;
31
+ }
32
+ if (key.backspace || key.delete) {
33
+ setValue((v) => v.slice(0, -1));
34
+ return;
35
+ }
36
+ if (input && !key.ctrl && !key.meta && input.length === 1) {
37
+ setValue((v) => v + input);
38
+ return;
39
+ }
40
+ }, { isActive: !disabled });
41
+ const cursor = "\x1b[7m \x1b[27m";
42
+ return (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.semantic.danger, children: "│ " }), _jsx(Text, { color: theme.text.muted, children: confirmState.prompt + " " }), value.length > 0 ? (_jsx(Text, { children: value + cursor })) : (_jsx(Text, { children: cursor }))] }), _jsx(HintBar, { leftHint: _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7 " }), _jsx(KeyHint, { keyLabel: "esc", action: "cancel" })] }), rightHint: _jsx(KeyHint, { keyLabel: "enter", action: "confirm" }) })] }));
43
+ }
44
+ export function SearchMode({ value, onSearchChange, onSearchSubmit, onSearchCancel, disabled, }) {
45
+ useInput((input, key) => {
46
+ if (input.includes("[<"))
47
+ return;
48
+ if (key.ctrl)
49
+ return;
50
+ if (input.length > 1 && (input.includes("\r") || input.includes("\n")))
51
+ return;
52
+ if (key.escape) {
53
+ onSearchCancel();
54
+ return;
55
+ }
56
+ if (key.return) {
57
+ onSearchSubmit();
58
+ return;
59
+ }
60
+ if (key.backspace || key.delete) {
61
+ onSearchChange(value.slice(0, -1));
62
+ return;
63
+ }
64
+ if (input && !key.ctrl && !key.meta && input.length === 1) {
65
+ onSearchChange(value + input);
66
+ return;
67
+ }
68
+ }, { isActive: !disabled });
69
+ const placeholder = t("cmdInput.searchPlaceholder");
70
+ const cursor = "\x1b[7m \x1b[27m";
71
+ return (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent.project, children: "│ " }), value.length > 0 ? (_jsx(Text, { children: value + cursor })) : (_jsx(Text, { color: theme.text.muted, children: placeholder }))] }), _jsx(HintBar, { leftHint: _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7 " }), _jsx(KeyHint, { keyLabel: "esc", action: "cancel" })] }), rightHint: _jsx(KeyHint, { keyLabel: "enter", action: "apply" }) })] }));
72
+ }
@@ -0,0 +1,85 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useMemo } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { darkTheme as theme } from "../../shared/ui/theme.js";
5
+ import { t } from "../../shared/i18n/index.js";
6
+ import { buildTabCommands } from "../../features/commands/commands.js";
7
+ import { useMouseScroll } from "../../shared/hooks/useMouseScroll.js";
8
+ const CATEGORY_ORDER = ["global", "filters", "loop", "task", "project"];
9
+ function categoryLabel(cat) {
10
+ switch (cat) {
11
+ case "global": return t("cmdsBrowser.groupGlobal");
12
+ case "filters": return t("cmdsBrowser.groupFilters");
13
+ case "loop": return t("cmdsBrowser.groupLoop");
14
+ case "task": return t("cmdsBrowser.groupTask");
15
+ case "project": return t("cmdsBrowser.groupProject");
16
+ }
17
+ }
18
+ const MAX_VISIBLE = 16;
19
+ export function CommandsBrowserModal(props) {
20
+ const allCommands = useMemo(() => buildTabCommands(props.context), [props.context]);
21
+ const flatItems = useMemo(() => {
22
+ const items = [];
23
+ for (const cat of CATEGORY_ORDER) {
24
+ const cmds = allCommands.filter((c) => c.category === cat).sort((a, b) => a.label.localeCompare(b.label));
25
+ if (cmds.length === 0)
26
+ continue;
27
+ items.push({ command: { label: categoryLabel(cat), value: `__header_${cat}`, hint: "", tier: "global", category: cat }, isHeader: true, category: cat });
28
+ for (const cmd of cmds) {
29
+ items.push({ command: cmd, isHeader: false, category: cat });
30
+ }
31
+ }
32
+ return items;
33
+ }, [allCommands]);
34
+ const selectableIndices = useMemo(() => {
35
+ return flatItems
36
+ .map((item, i) => ({ item, i }))
37
+ .filter(({ item }) => !item.isHeader)
38
+ .map(({ i }) => i);
39
+ }, [flatItems]);
40
+ const [cursor, setCursor] = useState(0);
41
+ const currentFlatIdx = selectableIndices[cursor] ?? 0;
42
+ useInput((input, key) => {
43
+ if (input.includes("[<")) {
44
+ return;
45
+ }
46
+ if (key.escape) {
47
+ props.onClose();
48
+ return;
49
+ }
50
+ if (key.upArrow) {
51
+ setCursor((prev) => prev <= 0 ? Math.max(0, selectableIndices.length - 1) : prev - 1);
52
+ return;
53
+ }
54
+ if (key.downArrow) {
55
+ setCursor((prev) => prev >= selectableIndices.length - 1 ? 0 : prev + 1);
56
+ return;
57
+ }
58
+ if (key.return) {
59
+ const flatIdx = selectableIndices[cursor];
60
+ if (flatIdx !== undefined) {
61
+ const item = flatItems[flatIdx];
62
+ if (item && !item.isHeader) {
63
+ props.onExecute(item.command.value);
64
+ }
65
+ }
66
+ return;
67
+ }
68
+ });
69
+ useMouseScroll({
70
+ onScrollUp: () => setCursor((prev) => prev <= 0 ? Math.max(0, selectableIndices.length - 1) : prev - 1),
71
+ onScrollDown: () => setCursor((prev) => prev >= selectableIndices.length - 1 ? 0 : prev + 1),
72
+ });
73
+ const windowStart = Math.max(0, currentFlatIdx - Math.floor(MAX_VISIBLE / 2));
74
+ const windowEnd = Math.min(flatItems.length, windowStart + MAX_VISIBLE);
75
+ const visibleItems = flatItems.slice(windowStart, windowEnd);
76
+ return (_jsx(Box, { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", justifyContent: "center", alignItems: "center", children: _jsxs(Box, { width: 56, flexDirection: "column", backgroundColor: theme.bg.elevated, borderStyle: "round", borderColor: theme.border.dim, paddingX: 1, paddingY: 0, children: [_jsxs(Box, { justifyContent: "space-between", paddingY: 0, children: [_jsx(Text, { bold: true, color: theme.text.primary, children: t("cmdsBrowser.title") }), _jsx(Text, { color: theme.text.muted, children: "esc" })] }), _jsx(Box, { flexDirection: "column", children: visibleItems.map((item, visIdx) => {
77
+ const realIdx = windowStart + visIdx;
78
+ const isSelected = realIdx === currentFlatIdx;
79
+ if (item.isHeader) {
80
+ return (_jsx(Box, { marginTop: realIdx === 0 ? 0 : 1, children: _jsx(Text, { color: theme.accent.brand, bold: true, children: item.command.label }) }, item.command.value));
81
+ }
82
+ const shortcut = item.command.shortcut ?? "";
83
+ return (_jsxs(Box, { backgroundColor: isSelected ? theme.bg.active : undefined, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? theme.text.inverse : theme.text.primary, children: " " + item.command.label }), item.command.hint.length > 0 ? (_jsx(Text, { color: isSelected ? theme.text.inverse : theme.text.muted, children: " " + item.command.hint.slice(0, 20) })) : null] }), shortcut.length > 0 ? (_jsx(Text, { color: isSelected ? theme.text.inverse : theme.text.muted, children: shortcut })) : null] }, `${item.command.value}-${realIdx}`));
84
+ }) }), _jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(Text, { color: theme.text.muted, children: t("cmdsBrowser.hint") }) })] }) }));
85
+ }
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import { Box, Text, useStdout } from "ink";
4
+ import { darkTheme as theme } from "../../shared/ui/theme.js";
5
+ import { HEADER_COMPACT_WIDTH } from "../../shared/config/constants.js";
6
+ import { t } from "../../shared/i18n/index.js";
7
+ import { TabBar } from "./TabBar.js";
8
+ const VERSION = "v" + (process.env.npm_package_version ?? "dev");
9
+ function daemonSymbol(status) {
10
+ switch (status) {
11
+ case "connected": return "\u25CF";
12
+ case "starting": return "\u25CB";
13
+ case "error": return "\u2717";
14
+ }
15
+ }
16
+ function daemonColor(status) {
17
+ switch (status) {
18
+ case "connected": return theme.semantic.success;
19
+ case "starting": return theme.semantic.warning;
20
+ case "error": return theme.semantic.danger;
21
+ }
22
+ }
23
+ function daemonText(status) {
24
+ switch (status) {
25
+ case "connected": return "connected";
26
+ case "starting": return "starting";
27
+ case "error": return "offline";
28
+ }
29
+ }
30
+ function ServiceStatus({ label, enabled, online }) {
31
+ // Unknown until the daemon is reachable and reports the setting.
32
+ const known = online && enabled !== undefined;
33
+ const on = known && enabled === true;
34
+ const color = !known
35
+ ? theme.text.muted
36
+ : on
37
+ ? theme.semantic.success
38
+ : theme.semantic.danger;
39
+ const symbol = !known ? "\u25CB" : on ? "\u25CF" : "\u25CB";
40
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.text.muted, children: label }), _jsx(Text, { color: color, children: symbol })] }));
41
+ }
42
+ export function Header(props) {
43
+ const { stdout } = useStdout();
44
+ const width = stdout?.columns ?? 80;
45
+ const compact = width < HEADER_COMPACT_WIDTH;
46
+ const online = props.daemonStatus === "connected";
47
+ const entries = [
48
+ { label: t("board.runningLabel"), value: props.counts.running, color: theme.semantic.success },
49
+ { label: t("board.waitingLabel"), value: props.counts.waiting, color: theme.accent.loop },
50
+ { label: t("board.pausedLabel"), value: props.counts.paused, color: theme.semantic.warning },
51
+ { label: t("board.idleLabel"), value: props.counts.idle, color: theme.semantic.idle },
52
+ ];
53
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: theme.accent.brand, bold: true, children: t("board.appName") }), _jsx(Text, { color: theme.text.muted, children: VERSION }), _jsx(Text, { color: daemonColor(props.daemonStatus), children: daemonSymbol(props.daemonStatus) }), _jsx(Text, { color: theme.text.secondary, children: daemonText(props.daemonStatus) }), _jsx(ServiceStatus, { label: "api", enabled: props.httpApiEnabled, online: online }), _jsx(ServiceStatus, { label: "mcp", enabled: props.mcpApiEnabled, online: online }), _jsx(ServiceStatus, { label: "otel", enabled: props.telemetryEnabled, online: online }), !compact && entries.map((e) => e.value > 0 ? (_jsxs(React.Fragment, { children: [_jsx(Text, { color: theme.text.muted, children: e.label }), _jsx(Text, { color: e.color, children: e.value })] }, e.label)) : null)] }), _jsx(TabBar, { activeTab: props.activeTab, onTabChange: props.onTabChange, counts: props.tabCounts })] }), _jsx(Box, { children: _jsx(Text, { color: theme.border.default, children: "\u2500".repeat(width) }) })] }));
54
+ }
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { darkTheme as theme } from "../../shared/ui/theme.js";
4
+ const TAB_DEFS = [
5
+ { key: "loops", label: "Loops", color: theme.accent.loop },
6
+ { key: "tasks", label: "Tasks", color: theme.accent.task },
7
+ { key: "projects", label: "Projects", color: theme.accent.project },
8
+ ];
9
+ export function tabColor(tab) {
10
+ return TAB_DEFS.find((t) => t.key === tab)?.color ?? theme.accent.brand;
11
+ }
12
+ export function TabBar(props) {
13
+ const { activeTab, counts } = props;
14
+ return (_jsx(Box, { gap: 1, children: TAB_DEFS.map((tab) => {
15
+ const isActive = tab.key === activeTab;
16
+ const count = counts?.[tab.key];
17
+ const label = count !== undefined ? `${tab.label} ${count}` : tab.label;
18
+ return (_jsx(Box, { children: _jsx(Text, { color: isActive ? theme.text.inverse : theme.text.muted, backgroundColor: isActive ? tab.color : undefined, bold: isActive, children: ` ${label} ` }) }, tab.key));
19
+ }) }));
20
+ }
@@ -0,0 +1,34 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import { Box, Text } from "ink";
4
+ import { darkTheme as theme, tabAccentColor } from "../../shared/ui/theme.js";
5
+ import { t } from "../../shared/i18n/index.js";
6
+ import { Navigator } from "./Navigator.js";
7
+ import { TaskNavigator } from "./TaskBrowser.js";
8
+ import { FocusableList } from "../../shared/ui/FocusableList.js";
9
+ function Chip(props) {
10
+ const { label, value, highlighted } = props;
11
+ return (_jsxs(Text, { color: highlighted ? theme.text.primary : theme.text.muted, children: [label, " ", value] }));
12
+ }
13
+ export function LeftPanel(props) {
14
+ const { isFocused, navActive = true, activeTab, query, loops, selectedIndex, filters, sort, breakpoint, projects, onSelect, onActivate, tasks, taskSelectedIndex, onTaskSelect, onTaskActivate, currentProjectName, projectFilters, projectSelectedIndex = 0, onProjectSelect, onProjectActivate, projectLoops, } = props;
15
+ const accentColor = tabAccentColor(activeTab);
16
+ const borderColor = isFocused ? accentColor : theme.border.default;
17
+ const hasFilter = query.length > 0;
18
+ const loopCountFor = (projectId) => (projectLoops ?? loops).filter((l) => l.projectId === projectId).length;
19
+ const panelWidth = breakpoint === "wide" ? "60%" : "100%";
20
+ return (_jsxs(Box, { flexDirection: "column", width: panelWidth, flexShrink: 0, borderStyle: "single", borderColor: borderColor, children: [_jsxs(Box, { paddingLeft: 1, gap: 1, children: [hasFilter ? (_jsx(Text, { color: accentColor, children: t("board.chipFilter", { query }) })) : null, activeTab === "loops" ? (_jsxs(_Fragment, { children: [_jsx(Chip, { label: t("board.chipProject"), value: currentProjectName ?? t("board.chipAll"), highlighted: currentProjectName != null }), _jsx(Text, { color: theme.text.muted, children: "\u00B7" }), _jsx(Chip, { label: t("board.chipStatus"), value: filters.status, highlighted: filters.status !== "all" }), _jsx(Text, { color: theme.text.muted, children: "\u00B7" }), _jsx(Chip, { label: t("board.chipSort"), value: sort, highlighted: sort !== "description" })] })) : null, activeTab === "projects" && projectFilters ? (_jsxs(_Fragment, { children: [projectFilters.query.length > 0 ? (_jsx(Text, { color: accentColor, children: t("board.chipSearch", { query: projectFilters.query }) })) : null, _jsx(Chip, { label: t("board.chipLoops"), value: projectFilters.hasLoops, highlighted: projectFilters.hasLoops !== "all" }), _jsx(Text, { color: theme.text.muted, children: "\u00B7" }), _jsx(Chip, { label: t("board.chipType"), value: projectFilters.isSystem, highlighted: projectFilters.isSystem !== "all" }), _jsx(Text, { color: theme.text.muted, children: "\u00B7" }), _jsx(Chip, { label: t("board.chipSort"), value: projectFilters.sort, highlighted: projectFilters.sort !== "name" })] })) : null] }), activeTab === "loops" ? (_jsx(Navigator, { visible: loops, total: loops.length, selectedIndex: selectedIndex, breakpoint: breakpoint, projects: projects, onSelect: onSelect, onActivate: onActivate, isFocused: isFocused, navActive: navActive })) : activeTab === "tasks" ? (_jsx(TaskNavigator, { visible: tasks, total: tasks.length, selectedIndex: taskSelectedIndex, query: query, onSelect: onTaskSelect, onActivate: onTaskActivate, isFocused: isFocused, navActive: navActive, allTasks: tasks, projects: projects })) : (_jsx(ProjectNavigator, { projects: projects, loops: projectLoops ?? loops, selectedIndex: projectSelectedIndex, onSelect: onProjectSelect ?? (() => { }), onActivate: onProjectActivate ?? (() => { }), isFocused: isFocused, navActive: navActive, loopCountFor: loopCountFor }))] }));
21
+ }
22
+ function ProjectNavigator(props) {
23
+ const { projects, selectedIndex, onSelect, onActivate, isFocused, navActive = true, loopCountFor } = props;
24
+ if (projects.length === 0) {
25
+ return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.text.muted, children: t("project.noLoops") }) }));
26
+ }
27
+ return (_jsx(FocusableList, { items: projects, selectedIndex: selectedIndex, isFocused: isFocused, navActive: navActive, limit: 15, onSelect: onSelect, onActivate: onActivate, renderItem: (project, isSelected) => {
28
+ const count = loopCountFor(project.id);
29
+ const fg = isSelected ? theme.text.inverse : theme.text.primary;
30
+ const countFg = isSelected ? theme.text.inverse : theme.text.muted;
31
+ const createdFg = isSelected ? theme.text.inverse : theme.text.muted;
32
+ return (_jsxs(React.Fragment, { children: [_jsx(Text, { color: project.color, children: "\u25CF" }), _jsxs(Text, { color: fg, children: [" ", project.name.padEnd(18)] }), _jsxs(Text, { color: countFg, children: [String(count).padStart(3), " "] }), _jsx(Text, { color: createdFg, children: project.createdAt.slice(0, 10) })] }));
33
+ } }));
34
+ }
@@ -0,0 +1,107 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import { ScrollList } from "ink-scroll-list";
5
+ import { darkTheme as theme, statusColor } from "../../shared/ui/theme.js";
6
+ import { describeLoop, sinceLabel, statusLabel, timingLabel, truncate } from "../../shared/ui/format.js";
7
+ import { t } from "../../shared/i18n/index.js";
8
+ import { useMouseScroll } from "../../shared/hooks/useMouseScroll.js";
9
+ const DESC_WIDTH_WIDE = 32;
10
+ const DESC_WIDTH_COMPACT = 20;
11
+ const DESC_WIDTH_MINIMAL = 14;
12
+ const SINCE_WIDTH = 13;
13
+ const RUNS_WIDTH = 4;
14
+ const STATUS_WIDTH = 8;
15
+ const COL_GAP = 1;
16
+ const LIMIT = 15;
17
+ function descWidth(bp) {
18
+ if (bp === "wide")
19
+ return DESC_WIDTH_WIDE;
20
+ if (bp === "compact")
21
+ return DESC_WIDTH_COMPACT;
22
+ return DESC_WIDTH_MINIMAL;
23
+ }
24
+ export function Navigator(props) {
25
+ const { visible, total, selectedIndex, projects, onSelect, onActivate, isFocused, navActive = true, breakpoint = "wide" } = props;
26
+ const n = visible.length;
27
+ const dw = descWidth(breakpoint);
28
+ const showSince = breakpoint === "wide";
29
+ const showTiming = breakpoint !== "minimal";
30
+ useInput((input, key) => {
31
+ if (n === 0)
32
+ return;
33
+ // Swallow SGR mouse sequences so they don't interfere
34
+ if (input.includes("[<")) {
35
+ return;
36
+ }
37
+ if (key.upArrow || input === "k") {
38
+ const next = selectedIndex <= 0 ? n - 1 : selectedIndex - 1;
39
+ onSelect(next);
40
+ return;
41
+ }
42
+ if (key.downArrow || input === "j") {
43
+ const next = selectedIndex >= n - 1 ? 0 : selectedIndex + 1;
44
+ onSelect(next);
45
+ return;
46
+ }
47
+ if (key.ctrl && key.return) {
48
+ onActivate(selectedIndex);
49
+ return;
50
+ }
51
+ }, { isActive: isFocused && navActive });
52
+ useMouseScroll({
53
+ onScrollUp: () => {
54
+ if (n === 0 || !(isFocused && navActive))
55
+ return;
56
+ const next = selectedIndex <= 0 ? n - 1 : selectedIndex - 1;
57
+ onSelect(next);
58
+ },
59
+ onScrollDown: () => {
60
+ if (n === 0 || !(isFocused && navActive))
61
+ return;
62
+ const next = selectedIndex >= n - 1 ? 0 : selectedIndex + 1;
63
+ onSelect(next);
64
+ },
65
+ isActive: isFocused && navActive,
66
+ });
67
+ const title = t("board.navigatorCount", {
68
+ visible: String(visible.length),
69
+ total: String(total),
70
+ });
71
+ function projectColor(loop) {
72
+ const proj = projects.find((p) => p.id === loop.projectId);
73
+ return proj?.color ?? theme.text.muted;
74
+ }
75
+ function projectName(loop) {
76
+ const proj = projects.find((p) => p.id === loop.projectId);
77
+ return proj?.name ?? "Default";
78
+ }
79
+ function isFailed(loop) {
80
+ return loop.lastExitCode !== null && loop.lastExitCode !== 0;
81
+ }
82
+ function renderLoop(loop, isSelected) {
83
+ const rawDesc = describeLoop(loop);
84
+ const prefix = breakpoint === "minimal" ? "" : `${projectName(loop)}: `;
85
+ const desc = truncate(`${prefix}${rawDesc}`, dw);
86
+ const since = sinceLabel(loop);
87
+ const timing = timingLabel(loop);
88
+ const failed = isFailed(loop);
89
+ const sColor = failed ? theme.semantic.danger : statusColor(loop.status);
90
+ const sLabel = statusLabel(loop.status);
91
+ const recipeBadge = loop.isRecipe ? "R " : "";
92
+ const statusText = `${recipeBadge}${sLabel}`;
93
+ const fg = isSelected ? theme.text.inverse : theme.text.primary;
94
+ const dotChar = failed ? "\u2717 " : "\u25cf ";
95
+ const dotColor = failed
96
+ ? theme.semantic.danger
97
+ : isSelected
98
+ ? theme.text.inverse
99
+ : projectColor(loop);
100
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { color: dotColor, children: dotChar }), _jsx(Text, { color: fg, children: desc.padEnd(dw + COL_GAP) }), showSince ? _jsx(Text, { color: fg, children: since.padEnd(SINCE_WIDTH + COL_GAP) }) : null, _jsx(Text, { color: fg, children: String(loop.runCount).padStart(RUNS_WIDTH) + " ".repeat(COL_GAP) }), _jsx(Text, { color: isSelected ? theme.text.inverse : sColor, children: statusText.padEnd(STATUS_WIDTH + 1 + COL_GAP) }), showTiming ? _jsx(Text, { color: fg, children: timing }) : null, loop.status === "running" ? (_jsxs(Text, { color: theme.semantic.success, children: [" ", _jsx(Spinner, { type: "dots" })] })) : null] }));
101
+ }
102
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: title }) }), visible.length === 0 ? (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.noMatch") }) })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { paddingLeft: 1, children: [_jsx(Text, { color: theme.text.muted, children: " " }), _jsx(Text, { color: theme.text.muted, children: " " }), _jsx(Text, { color: theme.text.muted, children: t("board.headerDescription").padEnd(dw + COL_GAP) }), showSince ? _jsx(Text, { color: theme.text.muted, children: t("board.headerSince").padEnd(SINCE_WIDTH + COL_GAP) }) : null, _jsx(Text, { color: theme.text.muted, children: t("board.headerRuns").padStart(RUNS_WIDTH) + " ".repeat(COL_GAP) }), _jsx(Text, { color: theme.text.muted, children: t("board.headerStatus").padEnd(STATUS_WIDTH + COL_GAP) }), showTiming ? _jsx(Text, { color: theme.text.muted, children: t("board.headerTiming") }) : null] }), _jsx(Box, { paddingLeft: 1, children: _jsx(ScrollList, { selectedIndex: selectedIndex, height: LIMIT, children: visible.map((loop, i) => {
103
+ const isSelected = i === selectedIndex;
104
+ const indicator = isSelected ? "\u203a " : " ";
105
+ return (_jsxs(Box, { backgroundColor: isSelected ? (isFocused && navActive ? theme.bg.active : isFocused ? theme.bg.hover : undefined) : undefined, children: [_jsx(Text, { color: isSelected ? theme.text.inverse : theme.text.primary, children: indicator }), renderLoop(loop, isSelected)] }, i));
106
+ }) }) })] }))] }));
107
+ }
@@ -0,0 +1,76 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { darkTheme as theme } from "../../shared/ui/theme.js";
5
+ import { FocusableButton } from "../../shared/ui/FocusableButton.js";
6
+ import { t } from "../../shared/i18n/index.js";
7
+ import { useInject } from "../../shared/hooks/useInject.js";
8
+ import { TYPES } from "../../shared/services/types.js";
9
+ import { DeleteConfirm, ListFocusWrapper, loopCountFor } from "./ProjectsPageParts.js";
10
+ export function ProjectsPage(props) {
11
+ const [selectedIndex, setSelectedIndex] = useState(0);
12
+ const [subModal, setSubModal] = useState("none");
13
+ const projectService = useInject(TYPES.ProjectService);
14
+ const defaultProject = props.projects.find((p) => p.isDefault) ?? null;
15
+ const defaultProjectId = defaultProject?.id ?? null;
16
+ const knownProjectIds = new Set(props.projects.map((p) => p.id));
17
+ const selected = props.projects[selectedIndex];
18
+ const loopCount = selected
19
+ ? loopCountFor(props.loops, selected.id, defaultProjectId, knownProjectIds)
20
+ : 0;
21
+ useInput((input, key) => {
22
+ if (subModal !== "none")
23
+ return;
24
+ if (input.includes("[<"))
25
+ return;
26
+ if (key.escape) {
27
+ props.onClose();
28
+ return;
29
+ }
30
+ if (input === "n") {
31
+ if (props.onNavigateCreate) {
32
+ props.onNavigateCreate();
33
+ }
34
+ return;
35
+ }
36
+ if (input === "e" && selected) {
37
+ if (props.onNavigateEdit) {
38
+ props.onNavigateEdit(selected);
39
+ }
40
+ return;
41
+ }
42
+ if (input === "d" && selected) {
43
+ setSubModal("delete");
44
+ return;
45
+ }
46
+ });
47
+ async function handleDelete() {
48
+ if (!selected)
49
+ return;
50
+ try {
51
+ await projectService.delete(selected.id);
52
+ props.onToast(t("project.toastDeleted", { name: selected.name }));
53
+ await props.onRefresh();
54
+ setSubModal("none");
55
+ setSelectedIndex(0);
56
+ }
57
+ catch (e) {
58
+ props.onToast(e.message);
59
+ }
60
+ }
61
+ if (subModal === "delete" && selected) {
62
+ return (_jsx(DeleteConfirm, { project: selected, loopCount: loopCount, onConfirm: handleDelete, onCancel: () => setSubModal("none") }));
63
+ }
64
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Box, { children: [_jsxs(Box, { width: "40%", flexDirection: "column", children: [_jsx(Box, { borderStyle: "single", borderColor: theme.accent.project, paddingX: 1, children: _jsx(Text, { color: theme.accent.project, bold: true, children: t("project.projectsTitle") }) }), props.projects.length === 0 ? (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.text.muted, children: t("project.noLoops") }) })) : (_jsx(ListFocusWrapper, { projects: props.projects, loops: props.loops, defaultProjectId: defaultProjectId, knownProjectIds: knownProjectIds, selectedIndex: selectedIndex, onSelect: setSelectedIndex, onActivate: (idx) => {
65
+ setSelectedIndex(idx);
66
+ if (props.onNavigateEdit) {
67
+ const p = props.projects[idx];
68
+ if (p)
69
+ props.onNavigateEdit(p);
70
+ }
71
+ }, children: null }))] }), _jsx(Box, { flexGrow: 1, flexDirection: "column", marginLeft: 1, children: selected ? (_jsxs(React.Fragment, { children: [_jsxs(Box, { borderStyle: "single", borderColor: theme.accent.brand, padding: 1, flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: selected.color, bold: true, children: "\u25CF" }), _jsx(Text, { color: theme.text.primary, bold: true, children: " " + selected.name })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "ID: " }), _jsx(Text, { color: theme.text.secondary, children: selected.id })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "Created: " }), _jsx(Text, { color: theme.text.secondary, children: selected.createdAt })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "Loops: " }), _jsx(Text, { color: theme.text.secondary, children: t("project.loopCount", { count: loopCount }) })] }), selected.directory ? (_jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: t("project.fieldDirectory") }), _jsx(Text, { color: theme.text.secondary, children: selected.directory })] })) : null, selected.directory ? (_jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "Dir: " }), _jsx(Text, { color: theme.text.secondary, children: selected.directory })] })) : null, selected.isSystem && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.semantic.warning, children: t("project.systemLabel") }) }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(FocusableButton, { label: `${t("project.editProjectLabel")} (${t("project.keyEditHint")})`, color: theme.accent.brand, onPress: () => {
72
+ if (props.onNavigateEdit && selected) {
73
+ props.onNavigateEdit(selected);
74
+ }
75
+ } }), _jsx(FocusableButton, { label: `${t("project.deleteProjectLabel")} (${t("project.keyDeleteHint")})`, color: theme.semantic.danger, variant: "danger", onPress: () => setSubModal("delete") })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.text.muted, children: `${t("project.keyNewHint")} | ${t("project.keyEditHint")} | ${t("project.keyDeleteHint")}` }) })] })] })) : (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.text.muted, children: t("project.noLoops") }) })) })] }) }));
76
+ }
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import { Box, Text, useFocus } from "ink";
4
+ import { darkTheme as theme } from "../../shared/ui/theme.js";
5
+ import { FocusableList } from "../../shared/ui/FocusableList.js";
6
+ import { FocusableButton } from "../../shared/ui/FocusableButton.js";
7
+ import { Modal } from "../../shared/ui/Modal.js";
8
+ import { t } from "../../shared/i18n/index.js";
9
+ export function loopCountFor(loops, projectId, defaultProjectId, knownProjectIds) {
10
+ const direct = loops.filter((l) => l.projectId === projectId).length;
11
+ if (projectId === defaultProjectId) {
12
+ const orphans = loops.filter((l) => !knownProjectIds.has(l.projectId)).length;
13
+ return direct + orphans;
14
+ }
15
+ return direct;
16
+ }
17
+ export function DeleteConfirm(props) {
18
+ return (_jsxs(Modal, { title: t("project.deleteTitle"), onClose: props.onCancel, width: 50, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.text.primary, children: t("project.deleteConfirm", { name: props.project.name }) }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.semantic.warning, children: t("project.deleteWarning", { count: props.loopCount }) }) }), _jsxs(Box, { marginTop: 1, children: [_jsx(FocusableButton, { label: t("board.yes"), color: theme.semantic.danger, onPress: props.onConfirm, variant: "danger" }), _jsx(FocusableButton, { label: t("board.no"), color: theme.text.secondary, onPress: props.onCancel })] })] }));
19
+ }
20
+ export function ListFocusWrapper(props) {
21
+ const { isFocused } = useFocus();
22
+ if (props.projects.length === 0) {
23
+ return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.text.muted, children: t("project.noLoops") }) }));
24
+ }
25
+ const nameWidth = 20;
26
+ const loopsWidth = 12;
27
+ const headerSeparator = " \u00B7 ";
28
+ return (_jsxs(React.Fragment, { children: [_jsxs(Box, { paddingX: 1, marginBottom: 0, children: [_jsx(Text, { color: theme.text.muted, children: " ".concat(t("project.headerName").padEnd(nameWidth)) }), _jsxs(Text, { color: theme.text.muted, children: [headerSeparator, String(t("project.headerLoops")).padEnd(loopsWidth)] }), _jsxs(Text, { color: theme.text.muted, children: [headerSeparator, t("project.headerCreated")] })] }), _jsx(FocusableList, { items: props.projects, selectedIndex: props.selectedIndex, isFocused: isFocused, limit: 10, onSelect: props.onSelect, onActivate: props.onActivate, renderItem: (project, isSelected) => {
29
+ const count = loopCountFor(props.loops, project.id, props.defaultProjectId, props.knownProjectIds);
30
+ const fg = isSelected ? theme.text.inverse : theme.text.primary;
31
+ const countFg = isSelected ? theme.text.inverse : theme.text.muted;
32
+ return (_jsxs(React.Fragment, { children: [_jsx(Text, { color: project.color, children: "\u25CF" }), _jsxs(Text, { color: fg, children: [" ", project.name.padEnd(nameWidth - 1)] }), _jsxs(Text, { color: countFg, children: [headerSeparator, String(count).padEnd(loopsWidth)] }), _jsxs(Text, { color: countFg, children: [headerSeparator, project.createdAt] })] }));
33
+ } }), props.children] }));
34
+ }