claudeup 4.15.1 → 4.17.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 (41) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/gitignore-detector.test.ts +156 -0
  3. package/src/__tests__/gitignore-fixer.test.ts +197 -0
  4. package/src/__tests__/gitignore-prerun.test.ts +75 -0
  5. package/src/__tests__/gitignore-resolver.test.ts +166 -0
  6. package/src/__tests__/gitignore-service.test.ts +93 -0
  7. package/src/__tests__/useGitignoreModal.test.ts +71 -0
  8. package/src/data/gitignore-defaults.js +24 -0
  9. package/src/data/gitignore-defaults.ts +26 -0
  10. package/src/data/gitignore-templates.js +21 -0
  11. package/src/data/gitignore-templates.ts +25 -0
  12. package/src/data/marketplaces.js +17 -1
  13. package/src/data/marketplaces.ts +16 -1
  14. package/src/prerunner/index.js +17 -0
  15. package/src/prerunner/index.ts +20 -0
  16. package/src/services/gitignore-detector.js +155 -0
  17. package/src/services/gitignore-detector.ts +157 -0
  18. package/src/services/gitignore-fixer.js +171 -0
  19. package/src/services/gitignore-fixer.ts +198 -0
  20. package/src/services/gitignore-prerun.js +46 -0
  21. package/src/services/gitignore-prerun.ts +59 -0
  22. package/src/services/gitignore-resolver.js +143 -0
  23. package/src/services/gitignore-resolver.ts +190 -0
  24. package/src/services/gitignore-service.js +99 -0
  25. package/src/services/gitignore-service.ts +142 -0
  26. package/src/types/gitignore.js +6 -0
  27. package/src/types/gitignore.ts +68 -0
  28. package/src/types/index.ts +1 -0
  29. package/src/ui/App.js +47 -3
  30. package/src/ui/App.tsx +51 -2
  31. package/src/ui/components/TabBar.js +1 -0
  32. package/src/ui/components/TabBar.tsx +1 -0
  33. package/src/ui/hooks/useGitignoreModal.js +75 -0
  34. package/src/ui/hooks/useGitignoreModal.ts +97 -0
  35. package/src/ui/renderers/gitignoreRenderers.js +33 -0
  36. package/src/ui/renderers/gitignoreRenderers.tsx +70 -0
  37. package/src/ui/screens/GitignoreScreen.js +227 -0
  38. package/src/ui/screens/GitignoreScreen.tsx +364 -0
  39. package/src/ui/screens/index.js +1 -0
  40. package/src/ui/screens/index.ts +1 -0
  41. package/src/ui/state/types.ts +4 -2
package/src/ui/App.js CHANGED
@@ -5,7 +5,10 @@ import fs from "node:fs";
5
5
  import { AppProvider, useApp, useNavigation, useModal, } from "./state/AppContext.js";
6
6
  import { DimensionsProvider, useDimensions, } from "./state/DimensionsContext.js";
7
7
  import { ModalContainer } from "./components/modals/index.js";
8
- import { PluginsScreen, McpScreen, McpRegistryScreen, SettingsScreen, CliToolsScreen, ModelSelectorScreen, ProfilesScreen, SkillsScreen, } from "./screens/index.js";
8
+ import { PluginsScreen, McpScreen, McpRegistryScreen, SettingsScreen, CliToolsScreen, ModelSelectorScreen, ProfilesScreen, SkillsScreen, GitignoreScreen, } from "./screens/index.js";
9
+ import { checkGitignore } from "../services/gitignore-prerun.js";
10
+ import { loadGitignoreState, applyAllSafeFixes, } from "../services/gitignore-service.js";
11
+ import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
9
12
  import { repairAllMarketplaces } from "../services/local-marketplace.js";
10
13
  import { migrateMarketplaceRename, recoverMarketplaceSettings, } from "../services/claude-settings.js";
11
14
  import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
@@ -40,6 +43,8 @@ function Router() {
40
43
  return _jsx(ProfilesScreen, {});
41
44
  case "skills":
42
45
  return _jsx(SkillsScreen, {});
46
+ case "gitignore":
47
+ return _jsx(GitignoreScreen, {});
43
48
  default:
44
49
  return _jsx(PluginsScreen, {});
45
50
  }
@@ -81,7 +86,7 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
81
86
  // Don't handle keys when modal is open or searching
82
87
  if (state.modal || state.isSearching)
83
88
  return;
84
- // Global navigation shortcuts (1-5) - include mcp-registry as it's a sub-screen of mcp
89
+ // Global navigation shortcuts (1-7) - include mcp-registry as it's a sub-screen of mcp
85
90
  const isTopLevel = [
86
91
  "plugins",
87
92
  "mcp",
@@ -91,6 +96,7 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
91
96
  "model-selector",
92
97
  "profiles",
93
98
  "skills",
99
+ "gitignore",
94
100
  ].includes(state.currentRoute.screen);
95
101
  if (isTopLevel) {
96
102
  if (input === "1")
@@ -105,6 +111,8 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
105
111
  navigateToScreen("profiles");
106
112
  else if (input === "6")
107
113
  navigateToScreen("cli-tools");
114
+ else if (input === "7")
115
+ navigateToScreen("gitignore");
108
116
  // Tab navigation cycling
109
117
  if (key.tab) {
110
118
  const screens = [
@@ -114,6 +122,7 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
114
122
  "settings",
115
123
  "profiles",
116
124
  "cli-tools",
125
+ "gitignore",
117
126
  ];
118
127
  const currentIndex = screens.indexOf(state.currentRoute.screen);
119
128
  if (currentIndex !== -1) {
@@ -148,7 +157,7 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
148
157
  ? This help
149
158
 
150
159
  Quick Navigation
151
- 1 Plugins 4 Settings
160
+ 1 Plugins 4 Settings 7 Gitignore
152
161
  2 Skills 5 Profiles
153
162
  3 MCP Servers 6 CLI Tools
154
163
 
@@ -193,6 +202,8 @@ function AppContent({ onExit }) {
193
202
  const [recoveryReport, setRecoveryReport] = useState(null);
194
203
  const [mismatchData, setMismatchData] = useState(null);
195
204
  const mismatchModal = useMismatchModal();
205
+ const [gitignoreData, setGitignoreData] = useState(null);
206
+ const gitignoreModal = useGitignoreModal();
196
207
  // Check for updates on startup (non-blocking)
197
208
  useEffect(() => {
198
209
  checkForUpdates()
@@ -213,6 +224,23 @@ function AppContent({ onExit }) {
213
224
  mismatchModal.show(mismatchData);
214
225
  setMismatchData(null);
215
226
  }, [mismatchData, mismatchModal]);
227
+ // Show gitignore modal — wait for the modal slot to be free so we don't
228
+ // stomp on the mismatch modal that may have just been shown.
229
+ useEffect(() => {
230
+ if (!gitignoreData)
231
+ return;
232
+ if (state.modal)
233
+ return;
234
+ gitignoreModal.show({
235
+ violationCount: gitignoreData.violationCount,
236
+ safeCount: gitignoreData.safeCount,
237
+ onFixSafe: async () => {
238
+ const s = await loadGitignoreState(state.projectPath);
239
+ await applyAllSafeFixes(state.projectPath, s.violations);
240
+ },
241
+ });
242
+ setGitignoreData(null);
243
+ }, [gitignoreData, gitignoreModal, state.modal, state.projectPath]);
216
244
  // Auto-refresh marketplaces on startup
217
245
  useEffect(() => {
218
246
  const noRefresh = process.argv.includes("--no-refresh");
@@ -277,6 +305,22 @@ function AppContent({ onExit }) {
277
305
  }
278
306
  })
279
307
  .catch(() => { }); // non-fatal
308
+ // Check for gitignore manifest violations and surface a popup if any.
309
+ // Loads twice (once for count, once when user picks "fix safe") because
310
+ // the second load happens after edits — keeps detection cheap on startup.
311
+ (async () => {
312
+ try {
313
+ const result = await checkGitignore(process.cwd());
314
+ if (result.violationCount > 0) {
315
+ const s = await loadGitignoreState(process.cwd());
316
+ const safeCount = s.violations.filter((v) => v.severity === "safe").length;
317
+ setGitignoreData({ violationCount: result.violationCount, safeCount });
318
+ }
319
+ }
320
+ catch {
321
+ // non-fatal
322
+ }
323
+ })();
280
324
  repairAllMarketplaces()
281
325
  .then(async () => {
282
326
  dispatch({ type: "HIDE_PROGRESS" });
package/src/ui/App.tsx CHANGED
@@ -21,8 +21,15 @@ import {
21
21
  ModelSelectorScreen,
22
22
  ProfilesScreen,
23
23
  SkillsScreen,
24
+ GitignoreScreen,
24
25
  } from "./screens/index.js";
25
26
  import type { Screen } from "./state/types.js";
27
+ import { checkGitignore } from "../services/gitignore-prerun.js";
28
+ import {
29
+ loadGitignoreState,
30
+ applyAllSafeFixes,
31
+ } from "../services/gitignore-service.js";
32
+ import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
26
33
  import { repairAllMarketplaces } from "../services/local-marketplace.js";
27
34
  import {
28
35
  migrateMarketplaceRename,
@@ -70,6 +77,8 @@ function Router() {
70
77
  return <ProfilesScreen />;
71
78
  case "skills":
72
79
  return <SkillsScreen />;
80
+ case "gitignore":
81
+ return <GitignoreScreen />;
73
82
  default:
74
83
  return <PluginsScreen />;
75
84
  }
@@ -120,7 +129,7 @@ function GlobalKeyHandler({
120
129
  // Don't handle keys when modal is open or searching
121
130
  if (state.modal || state.isSearching) return;
122
131
 
123
- // Global navigation shortcuts (1-5) - include mcp-registry as it's a sub-screen of mcp
132
+ // Global navigation shortcuts (1-7) - include mcp-registry as it's a sub-screen of mcp
124
133
  const isTopLevel = [
125
134
  "plugins",
126
135
  "mcp",
@@ -130,6 +139,7 @@ function GlobalKeyHandler({
130
139
  "model-selector",
131
140
  "profiles",
132
141
  "skills",
142
+ "gitignore",
133
143
  ].includes(state.currentRoute.screen);
134
144
 
135
145
  if (isTopLevel) {
@@ -139,6 +149,7 @@ function GlobalKeyHandler({
139
149
  else if (input === "4") navigateToScreen("settings");
140
150
  else if (input === "5") navigateToScreen("profiles");
141
151
  else if (input === "6") navigateToScreen("cli-tools");
152
+ else if (input === "7") navigateToScreen("gitignore");
142
153
 
143
154
  // Tab navigation cycling
144
155
  if (key.tab) {
@@ -149,6 +160,7 @@ function GlobalKeyHandler({
149
160
  "settings",
150
161
  "profiles",
151
162
  "cli-tools",
163
+ "gitignore",
152
164
  ];
153
165
  const currentIndex = screens.indexOf(
154
166
  state.currentRoute.screen as Screen,
@@ -187,7 +199,7 @@ function GlobalKeyHandler({
187
199
  ? This help
188
200
 
189
201
  Quick Navigation
190
- 1 Plugins 4 Settings
202
+ 1 Plugins 4 Settings 7 Gitignore
191
203
  2 Skills 5 Profiles
192
204
  3 MCP Servers 6 CLI Tools
193
205
 
@@ -319,6 +331,11 @@ function AppContent({ onExit }: AppContentProps) {
319
331
  const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
320
332
  const [mismatchData, setMismatchData] = useState<VersionMismatchInfo[] | null>(null);
321
333
  const mismatchModal = useMismatchModal();
334
+ const [gitignoreData, setGitignoreData] = useState<{
335
+ violationCount: number;
336
+ safeCount: number;
337
+ } | null>(null);
338
+ const gitignoreModal = useGitignoreModal();
322
339
 
323
340
  // Check for updates on startup (non-blocking)
324
341
  useEffect(() => {
@@ -341,6 +358,22 @@ function AppContent({ onExit }: AppContentProps) {
341
358
  setMismatchData(null);
342
359
  }, [mismatchData, mismatchModal]);
343
360
 
361
+ // Show gitignore modal — wait for the modal slot to be free so we don't
362
+ // stomp on the mismatch modal that may have just been shown.
363
+ useEffect(() => {
364
+ if (!gitignoreData) return;
365
+ if (state.modal) return;
366
+ gitignoreModal.show({
367
+ violationCount: gitignoreData.violationCount,
368
+ safeCount: gitignoreData.safeCount,
369
+ onFixSafe: async () => {
370
+ const s = await loadGitignoreState(state.projectPath);
371
+ await applyAllSafeFixes(state.projectPath, s.violations);
372
+ },
373
+ });
374
+ setGitignoreData(null);
375
+ }, [gitignoreData, gitignoreModal, state.modal, state.projectPath]);
376
+
344
377
  // Auto-refresh marketplaces on startup
345
378
  useEffect(() => {
346
379
  const noRefresh = process.argv.includes("--no-refresh");
@@ -409,6 +442,22 @@ function AppContent({ onExit }: AppContentProps) {
409
442
  })
410
443
  .catch(() => {}); // non-fatal
411
444
 
445
+ // Check for gitignore manifest violations and surface a popup if any.
446
+ // Loads twice (once for count, once when user picks "fix safe") because
447
+ // the second load happens after edits — keeps detection cheap on startup.
448
+ (async () => {
449
+ try {
450
+ const result = await checkGitignore(process.cwd());
451
+ if (result.violationCount > 0) {
452
+ const s = await loadGitignoreState(process.cwd());
453
+ const safeCount = s.violations.filter((v) => v.severity === "safe").length;
454
+ setGitignoreData({ violationCount: result.violationCount, safeCount });
455
+ }
456
+ } catch {
457
+ // non-fatal
458
+ }
459
+ })();
460
+
412
461
  repairAllMarketplaces()
413
462
  .then(async () => {
414
463
  dispatch({ type: "HIDE_PROGRESS" });
@@ -6,6 +6,7 @@ const TABS = [
6
6
  { key: "4", label: "Settings", screen: "settings" },
7
7
  { key: "5", label: "Profiles", screen: "profiles" },
8
8
  { key: "6", label: "CLI", screen: "cli-tools" },
9
+ { key: "7", label: "Gitignore", screen: "gitignore" },
9
10
  ];
10
11
  export function TabBar({ currentScreen }) {
11
12
  return (_jsx("box", { flexDirection: "row", gap: 0, children: TABS.map((tab, index) => {
@@ -14,6 +14,7 @@ const TABS: Tab[] = [
14
14
  { key: "4", label: "Settings", screen: "settings" },
15
15
  { key: "5", label: "Profiles", screen: "profiles" },
16
16
  { key: "6", label: "CLI", screen: "cli-tools" },
17
+ { key: "7", label: "Gitignore", screen: "gitignore" },
17
18
  ];
18
19
 
19
20
  interface TabBarProps {
@@ -0,0 +1,75 @@
1
+ import { useCallback } from "react";
2
+ import { useApp, useNavigation } from "../state/AppContext.js";
3
+ export function buildGitignoreModal(args) {
4
+ const { violationCount, safeCount, onFixSafe, onOpenTab, onDismiss } = args;
5
+ const options = [
6
+ {
7
+ label: safeCount > 0
8
+ ? `Append ${safeCount} safe entr${safeCount === 1 ? "y" : "ies"} to .gitignore`
9
+ : "(no safe-fixable entries)",
10
+ value: "fix-safe",
11
+ },
12
+ {
13
+ label: "Open Gitignore tab to review",
14
+ value: "open-tab",
15
+ },
16
+ {
17
+ label: "Dismiss",
18
+ value: "dismiss",
19
+ },
20
+ ];
21
+ return {
22
+ type: "select",
23
+ title: "Gitignore violations detected",
24
+ message: `Found ${violationCount} issue(s) where the resolved gitignore manifest ` +
25
+ `disagrees with your working tree.\n\n` +
26
+ `Safe fixes append to .gitignore (no index mutation). Destructive fixes ` +
27
+ `(\`git rm --cached\`, removing .gitignore lines) must be applied one at ` +
28
+ `a time from the Gitignore tab.`,
29
+ options,
30
+ onSelect: (value) => {
31
+ if (value === "fix-safe")
32
+ onFixSafe();
33
+ else if (value === "open-tab")
34
+ onOpenTab();
35
+ else
36
+ onDismiss();
37
+ },
38
+ onCancel: onDismiss,
39
+ };
40
+ }
41
+ export function useGitignoreModal() {
42
+ const { dispatch } = useApp();
43
+ const { navigateToScreen } = useNavigation();
44
+ const show = useCallback((args) => {
45
+ if (args.violationCount === 0)
46
+ return;
47
+ const dismiss = () => dispatch({ type: "HIDE_MODAL" });
48
+ const modal = buildGitignoreModal({
49
+ violationCount: args.violationCount,
50
+ safeCount: args.safeCount,
51
+ onFixSafe: () => {
52
+ dispatch({ type: "HIDE_MODAL" });
53
+ void Promise.resolve(args.onFixSafe()).catch((err) => {
54
+ dispatch({
55
+ type: "SHOW_MODAL",
56
+ modal: {
57
+ type: "message",
58
+ title: "Safe fixes failed",
59
+ message: err instanceof Error ? err.message : String(err),
60
+ variant: "error",
61
+ onDismiss: dismiss,
62
+ },
63
+ });
64
+ });
65
+ },
66
+ onOpenTab: () => {
67
+ dispatch({ type: "HIDE_MODAL" });
68
+ navigateToScreen("gitignore");
69
+ },
70
+ onDismiss: dismiss,
71
+ });
72
+ dispatch({ type: "SHOW_MODAL", modal });
73
+ }, [dispatch, navigateToScreen]);
74
+ return { show };
75
+ }
@@ -0,0 +1,97 @@
1
+ import { useCallback } from "react";
2
+ import { useApp, useNavigation } from "../state/AppContext.js";
3
+ import type { ModalState } from "../state/types.js";
4
+
5
+ /**
6
+ * Startup gitignore-violation modal. Mirrors `useMismatchModal` so the
7
+ * two flows feel identical to the user — claudeup launches, detects
8
+ * a problem, asks "fix safe entries / open the tab / dismiss".
9
+ *
10
+ * Pure builder is exported separately for tests.
11
+ */
12
+
13
+ export interface BuildModalArgs {
14
+ violationCount: number;
15
+ safeCount: number;
16
+ onFixSafe: () => void;
17
+ onOpenTab: () => void;
18
+ onDismiss: () => void;
19
+ }
20
+
21
+ export function buildGitignoreModal(args: BuildModalArgs): ModalState {
22
+ const { violationCount, safeCount, onFixSafe, onOpenTab, onDismiss } = args;
23
+ const options = [
24
+ {
25
+ label:
26
+ safeCount > 0
27
+ ? `Append ${safeCount} safe entr${safeCount === 1 ? "y" : "ies"} to .gitignore`
28
+ : "(no safe-fixable entries)",
29
+ value: "fix-safe",
30
+ },
31
+ {
32
+ label: "Open Gitignore tab to review",
33
+ value: "open-tab",
34
+ },
35
+ {
36
+ label: "Dismiss",
37
+ value: "dismiss",
38
+ },
39
+ ];
40
+ return {
41
+ type: "select",
42
+ title: "Gitignore violations detected",
43
+ message:
44
+ `Found ${violationCount} issue(s) where the resolved gitignore manifest ` +
45
+ `disagrees with your working tree.\n\n` +
46
+ `Safe fixes append to .gitignore (no index mutation). Destructive fixes ` +
47
+ `(\`git rm --cached\`, removing .gitignore lines) must be applied one at ` +
48
+ `a time from the Gitignore tab.`,
49
+ options,
50
+ onSelect: (value: string) => {
51
+ if (value === "fix-safe") onFixSafe();
52
+ else if (value === "open-tab") onOpenTab();
53
+ else onDismiss();
54
+ },
55
+ onCancel: onDismiss,
56
+ };
57
+ }
58
+
59
+ export function useGitignoreModal() {
60
+ const { dispatch } = useApp();
61
+ const { navigateToScreen } = useNavigation();
62
+
63
+ const show = useCallback(
64
+ (args: { violationCount: number; safeCount: number; onFixSafe: () => Promise<void> | void }) => {
65
+ if (args.violationCount === 0) return;
66
+ const dismiss = () => dispatch({ type: "HIDE_MODAL" });
67
+ const modal = buildGitignoreModal({
68
+ violationCount: args.violationCount,
69
+ safeCount: args.safeCount,
70
+ onFixSafe: () => {
71
+ dispatch({ type: "HIDE_MODAL" });
72
+ void Promise.resolve(args.onFixSafe()).catch((err) => {
73
+ dispatch({
74
+ type: "SHOW_MODAL",
75
+ modal: {
76
+ type: "message",
77
+ title: "Safe fixes failed",
78
+ message: err instanceof Error ? err.message : String(err),
79
+ variant: "error",
80
+ onDismiss: dismiss,
81
+ },
82
+ });
83
+ });
84
+ },
85
+ onOpenTab: () => {
86
+ dispatch({ type: "HIDE_MODAL" });
87
+ navigateToScreen("gitignore");
88
+ },
89
+ onDismiss: dismiss,
90
+ });
91
+ dispatch({ type: "SHOW_MODAL", modal });
92
+ },
93
+ [dispatch, navigateToScreen],
94
+ );
95
+
96
+ return { show };
97
+ }
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ const SELECTION_BG = "#7e57c2";
3
+ const SELECTION_FG = "white";
4
+ // Layout: " X Verb path" — 2 (gutter) + 2 (status) + 7 (verb) + 4 (gap)
5
+ // = 15 prefix chars. On a 38-col panel inner width that leaves 23 chars for
6
+ // the path; truncate at 22 to keep 1 col safety margin (OpenTUI <text>
7
+ // reserves a second line if the rendered string equals the inner width).
8
+ const MAX_PATH_LEN = 22;
9
+ function truncatePath(p) {
10
+ return p.length > MAX_PATH_LEN ? p.slice(0, MAX_PATH_LEN - 1) + "…" : p;
11
+ }
12
+ export function renderRuleRow(row, isSelected) {
13
+ const { rule, violation } = row;
14
+ const path = truncatePath(rule.pattern);
15
+ const key = `${rule.action}:${rule.pattern}`;
16
+ // Layout: status-glyph (2) + verb (7) + 2 spaces + path
17
+ // OK rule: ✓ Ignore path
18
+ // Violated:! Track path
19
+ const verb = rule.action === "ignore" ? "Ignore " : "Track ";
20
+ if (!violation) {
21
+ // Clean rule — muted, just confirmation
22
+ if (isSelected) {
23
+ return (_jsx("text", { bg: SELECTION_BG, fg: SELECTION_FG, children: `▶ ✓ ${verb} ${path}` }, key));
24
+ }
25
+ return (_jsxs("text", { children: [_jsx("span", { fg: "green", children: ` ✓ ` }), _jsx("span", { fg: "gray", children: `${verb} ${path}` })] }, key));
26
+ }
27
+ // Violated rule — color encodes severity of the fix
28
+ const sevColor = violation.severity === "safe" ? "yellow" : "red";
29
+ if (isSelected) {
30
+ return (_jsx("text", { bg: SELECTION_BG, fg: SELECTION_FG, children: `▶ ! ${verb} ${path}` }, key));
31
+ }
32
+ return (_jsxs("text", { children: [_jsx("span", { fg: sevColor, children: ` ! ` }), _jsx("span", { fg: "white", children: `${verb} ${path}` })] }, key));
33
+ }
@@ -0,0 +1,70 @@
1
+ import React from "react";
2
+ import type { ResolvedRule, Violation } from "../../types/gitignore.js";
3
+
4
+ /**
5
+ * A unified row for the Gitignore screen — represents one resolved rule,
6
+ * optionally annotated with the violation it has against the working tree.
7
+ */
8
+ export interface RuleRow {
9
+ rule: ResolvedRule;
10
+ violation?: Violation;
11
+ }
12
+
13
+ const SELECTION_BG = "#7e57c2";
14
+ const SELECTION_FG = "white";
15
+
16
+ // Layout: " X Verb path" — 2 (gutter) + 2 (status) + 7 (verb) + 4 (gap)
17
+ // = 15 prefix chars. On a 38-col panel inner width that leaves 23 chars for
18
+ // the path; truncate at 22 to keep 1 col safety margin (OpenTUI <text>
19
+ // reserves a second line if the rendered string equals the inner width).
20
+ const MAX_PATH_LEN = 22;
21
+
22
+ function truncatePath(p: string): string {
23
+ return p.length > MAX_PATH_LEN ? p.slice(0, MAX_PATH_LEN - 1) + "…" : p;
24
+ }
25
+
26
+ export function renderRuleRow(row: RuleRow, isSelected: boolean): React.ReactNode {
27
+ const { rule, violation } = row;
28
+ const path = truncatePath(rule.pattern);
29
+ const key = `${rule.action}:${rule.pattern}`;
30
+
31
+ // Layout: status-glyph (2) + verb (7) + 2 spaces + path
32
+ // OK rule: ✓ Ignore path
33
+ // Violated:! Track path
34
+
35
+ const verb = rule.action === "ignore" ? "Ignore " : "Track ";
36
+
37
+ if (!violation) {
38
+ // Clean rule — muted, just confirmation
39
+ if (isSelected) {
40
+ return (
41
+ <text key={key} bg={SELECTION_BG} fg={SELECTION_FG}>
42
+ {`▶ ✓ ${verb} ${path}`}
43
+ </text>
44
+ );
45
+ }
46
+ return (
47
+ <text key={key}>
48
+ <span fg="green">{` ✓ `}</span>
49
+ <span fg="gray">{`${verb} ${path}`}</span>
50
+ </text>
51
+ );
52
+ }
53
+
54
+ // Violated rule — color encodes severity of the fix
55
+ const sevColor = violation.severity === "safe" ? "yellow" : "red";
56
+
57
+ if (isSelected) {
58
+ return (
59
+ <text key={key} bg={SELECTION_BG} fg={SELECTION_FG}>
60
+ {`▶ ! ${verb} ${path}`}
61
+ </text>
62
+ );
63
+ }
64
+ return (
65
+ <text key={key}>
66
+ <span fg={sevColor}>{` ! `}</span>
67
+ <span fg="white">{`${verb} ${path}`}</span>
68
+ </text>
69
+ );
70
+ }