claudeup 4.16.0 → 4.18.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 (77) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/alias-parser.test.ts +317 -0
  3. package/src/__tests__/alias-shell-writer.test.ts +661 -0
  4. package/src/__tests__/alias-store.test.ts +86 -0
  5. package/src/__tests__/gitignore-fixer.test.ts +64 -1
  6. package/src/__tests__/gitignore-prerun.test.ts +2 -2
  7. package/src/__tests__/gitignore-service.test.ts +42 -0
  8. package/src/__tests__/marketplaces.test.ts +40 -0
  9. package/src/__tests__/plugin-manager-fallback.test.ts +120 -0
  10. package/src/__tests__/useGitignoreModal.test.ts +2 -2
  11. package/src/data/alias-flags.js +196 -0
  12. package/src/data/alias-flags.ts +291 -0
  13. package/src/data/gitignore-reasons.js +97 -0
  14. package/src/data/gitignore-reasons.ts +103 -0
  15. package/src/data/marketplaces.js +19 -1
  16. package/src/data/marketplaces.ts +17 -1
  17. package/src/services/alias-settings.js +51 -0
  18. package/src/services/alias-settings.ts +63 -0
  19. package/src/services/alias-shell-writer.js +764 -0
  20. package/src/services/alias-shell-writer.ts +873 -0
  21. package/src/services/alias-store.js +77 -0
  22. package/src/services/alias-store.ts +112 -0
  23. package/src/services/gitignore-fixer.js +70 -10
  24. package/src/services/gitignore-fixer.ts +76 -9
  25. package/src/services/gitignore-prerun.js +3 -3
  26. package/src/services/gitignore-prerun.ts +3 -3
  27. package/src/services/gitignore-service.js +20 -2
  28. package/src/services/gitignore-service.ts +23 -1
  29. package/src/services/marketplace-fetcher.js +96 -0
  30. package/src/services/marketplace-fetcher.ts +137 -0
  31. package/src/services/plugin-manager.js +6 -59
  32. package/src/services/plugin-manager.ts +16 -91
  33. package/src/services/skillsmp-client.js +29 -9
  34. package/src/services/skillsmp-client.ts +38 -8
  35. package/src/types/gitignore.ts +1 -1
  36. package/src/types/index.ts +1 -0
  37. package/src/ui/App.js +10 -4
  38. package/src/ui/App.tsx +9 -3
  39. package/src/ui/components/TabBar.js +2 -1
  40. package/src/ui/components/TabBar.tsx +2 -1
  41. package/src/ui/components/layout/FooterHints.js +29 -0
  42. package/src/ui/components/layout/FooterHints.tsx +52 -0
  43. package/src/ui/components/layout/ScreenLayout.js +2 -1
  44. package/src/ui/components/layout/ScreenLayout.tsx +12 -3
  45. package/src/ui/components/layout/index.js +1 -0
  46. package/src/ui/components/layout/index.ts +5 -0
  47. package/src/ui/components/modals/SelectModal.js +8 -1
  48. package/src/ui/components/modals/SelectModal.tsx +12 -1
  49. package/src/ui/hooks/useGitignoreModal.js +7 -8
  50. package/src/ui/hooks/useGitignoreModal.ts +8 -9
  51. package/src/ui/renderers/gitignoreRenderers.js +36 -23
  52. package/src/ui/renderers/gitignoreRenderers.tsx +50 -41
  53. package/src/ui/screens/AliasScreen.js +1008 -0
  54. package/src/ui/screens/AliasScreen.tsx +1402 -0
  55. package/src/ui/screens/CliToolsScreen.js +6 -1
  56. package/src/ui/screens/CliToolsScreen.tsx +6 -1
  57. package/src/ui/screens/EnvVarsScreen.js +6 -1
  58. package/src/ui/screens/EnvVarsScreen.tsx +6 -1
  59. package/src/ui/screens/GitignoreScreen.js +189 -88
  60. package/src/ui/screens/GitignoreScreen.tsx +312 -132
  61. package/src/ui/screens/McpRegistryScreen.js +13 -2
  62. package/src/ui/screens/McpRegistryScreen.tsx +13 -2
  63. package/src/ui/screens/McpScreen.js +6 -1
  64. package/src/ui/screens/McpScreen.tsx +6 -1
  65. package/src/ui/screens/ModelSelectorScreen.js +8 -2
  66. package/src/ui/screens/ModelSelectorScreen.tsx +8 -2
  67. package/src/ui/screens/PluginsScreen.js +13 -2
  68. package/src/ui/screens/PluginsScreen.tsx +13 -2
  69. package/src/ui/screens/ProfilesScreen.js +8 -1
  70. package/src/ui/screens/ProfilesScreen.tsx +8 -1
  71. package/src/ui/screens/SkillsScreen.js +21 -4
  72. package/src/ui/screens/SkillsScreen.tsx +39 -5
  73. package/src/ui/screens/StatusLineScreen.js +7 -1
  74. package/src/ui/screens/StatusLineScreen.tsx +7 -1
  75. package/src/ui/screens/index.js +1 -0
  76. package/src/ui/screens/index.ts +1 -0
  77. package/src/ui/state/types.ts +4 -2
package/src/ui/App.tsx CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  ProfilesScreen,
23
23
  SkillsScreen,
24
24
  GitignoreScreen,
25
+ AliasScreen,
25
26
  } from "./screens/index.js";
26
27
  import type { Screen } from "./state/types.js";
27
28
  import { checkGitignore } from "../services/gitignore-prerun.js";
@@ -79,6 +80,8 @@ function Router() {
79
80
  return <SkillsScreen />;
80
81
  case "gitignore":
81
82
  return <GitignoreScreen />;
83
+ case "alias":
84
+ return <AliasScreen />;
82
85
  default:
83
86
  return <PluginsScreen />;
84
87
  }
@@ -129,7 +132,7 @@ function GlobalKeyHandler({
129
132
  // Don't handle keys when modal is open or searching
130
133
  if (state.modal || state.isSearching) return;
131
134
 
132
- // Global navigation shortcuts (1-7) - include mcp-registry as it's a sub-screen of mcp
135
+ // Global navigation shortcuts (1-8) - include mcp-registry as it's a sub-screen of mcp
133
136
  const isTopLevel = [
134
137
  "plugins",
135
138
  "mcp",
@@ -140,6 +143,7 @@ function GlobalKeyHandler({
140
143
  "profiles",
141
144
  "skills",
142
145
  "gitignore",
146
+ "alias",
143
147
  ].includes(state.currentRoute.screen);
144
148
 
145
149
  if (isTopLevel) {
@@ -150,6 +154,7 @@ function GlobalKeyHandler({
150
154
  else if (input === "5") navigateToScreen("profiles");
151
155
  else if (input === "6") navigateToScreen("cli-tools");
152
156
  else if (input === "7") navigateToScreen("gitignore");
157
+ else if (input === "8") navigateToScreen("alias");
153
158
 
154
159
  // Tab navigation cycling
155
160
  if (key.tab) {
@@ -161,6 +166,7 @@ function GlobalKeyHandler({
161
166
  "profiles",
162
167
  "cli-tools",
163
168
  "gitignore",
169
+ "alias",
164
170
  ];
165
171
  const currentIndex = screens.indexOf(
166
172
  state.currentRoute.screen as Screen,
@@ -199,8 +205,8 @@ function GlobalKeyHandler({
199
205
  ? This help
200
206
 
201
207
  Quick Navigation
202
- 1 Plugins 4 Settings 7 Gitignore
203
- 2 Skills 5 Profiles
208
+ 1 Plugins 4 Settings 7 Git State
209
+ 2 Skills 5 Profiles 8 Alias
204
210
  3 MCP Servers 6 CLI Tools
205
211
 
206
212
  Plugin Actions
@@ -6,7 +6,8 @@ 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
+ { key: "7", label: "Git State", screen: "gitignore" },
10
+ { key: "8", label: "Alias", screen: "alias" },
10
11
  ];
11
12
  export function TabBar({ currentScreen }) {
12
13
  return (_jsx("box", { flexDirection: "row", gap: 0, children: TABS.map((tab, index) => {
@@ -14,7 +14,8 @@ 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
+ { key: "7", label: "Git State", screen: "gitignore" },
18
+ { key: "8", label: "Alias", screen: "alias" },
18
19
  ];
19
20
 
20
21
  interface TabBarProps {
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ const KEY_BG = "#3f3f46";
3
+ const KEY_FG = "#fafafa";
4
+ const LABEL_FG = "gray";
5
+ const GAP = " ";
6
+ /**
7
+ * Render a row of footer hints as key badges + labels:
8
+ *
9
+ * [↑↓] move [U] update [/] search
10
+ *
11
+ * Each key is shown in a chip so it reads as a pressable key, not prose.
12
+ * Shared across screens so every footer looks the same.
13
+ */
14
+ export function FooterHints({ hints }) {
15
+ const nodes = [];
16
+ hints.forEach((hint, i) => {
17
+ if (i > 0) {
18
+ nodes.push(_jsx("span", { fg: LABEL_FG, children: GAP }, `gap-${i}`));
19
+ }
20
+ nodes.push(_jsx("span", { bg: KEY_BG, fg: KEY_FG, children: ` ${hint.keys.join("")} ` }, `k-${i}`));
21
+ nodes.push(_jsx("span", { fg: LABEL_FG, children: ` ${hint.label}` }, `l-${i}`));
22
+ });
23
+ return _jsx("text", { children: nodes });
24
+ }
25
+ /** Convenience for callers that already have a node: same as <FooterHints>. */
26
+ export function renderFooterHints(hints) {
27
+ return _jsx(FooterHints, { hints: hints });
28
+ }
29
+ export default FooterHints;
@@ -0,0 +1,52 @@
1
+ import React from "react";
2
+
3
+ /** One footer hint: a key (or key group) and the action it performs. */
4
+ export interface FooterHint {
5
+ /** Key(s) for this action, e.g. ["↑", "↓"] or ["U"]. Joined for display. */
6
+ keys: string[];
7
+ label: string;
8
+ }
9
+
10
+ const KEY_BG = "#3f3f46";
11
+ const KEY_FG = "#fafafa";
12
+ const LABEL_FG = "gray";
13
+ const GAP = " ";
14
+
15
+ /**
16
+ * Render a row of footer hints as key badges + labels:
17
+ *
18
+ * [↑↓] move [U] update [/] search
19
+ *
20
+ * Each key is shown in a chip so it reads as a pressable key, not prose.
21
+ * Shared across screens so every footer looks the same.
22
+ */
23
+ export function FooterHints({ hints }: { hints: FooterHint[] }): React.ReactNode {
24
+ const nodes: React.ReactNode[] = [];
25
+ hints.forEach((hint, i) => {
26
+ if (i > 0) {
27
+ nodes.push(
28
+ <span key={`gap-${i}`} fg={LABEL_FG}>
29
+ {GAP}
30
+ </span>,
31
+ );
32
+ }
33
+ nodes.push(
34
+ <span key={`k-${i}`} bg={KEY_BG} fg={KEY_FG}>
35
+ {` ${hint.keys.join("")} `}
36
+ </span>,
37
+ );
38
+ nodes.push(
39
+ <span key={`l-${i}`} fg={LABEL_FG}>
40
+ {` ${hint.label}`}
41
+ </span>,
42
+ );
43
+ });
44
+ return <text>{nodes}</text>;
45
+ }
46
+
47
+ /** Convenience for callers that already have a node: same as <FooterHints>. */
48
+ export function renderFooterHints(hints: FooterHint[]): React.ReactNode {
49
+ return <FooterHints hints={hints} />;
50
+ }
51
+
52
+ export default FooterHints;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
2
  import { useDimensions } from "../../state/DimensionsContext.js";
3
3
  import { TabBar } from "../TabBar.js";
4
+ import { FooterHints } from "./FooterHints.js";
4
5
  const HEADER_COLOR = "#7e57c2";
5
6
  export function ScreenLayout({ title, subtitle, currentScreen, search, statusLine, footerHints, listPanel, detailPanel, }) {
6
7
  const dimensions = useDimensions();
@@ -10,6 +11,6 @@ export function ScreenLayout({ title, subtitle, currentScreen, search, statusLin
10
11
  const fixedHeight = 6 + (hasSearchBar ? 1 : 0);
11
12
  const panelHeight = Math.max(5, dimensions.contentHeight - fixedHeight);
12
13
  const lineWidth = Math.max(10, dimensions.terminalWidth - 4);
13
- return (_jsxs("box", { flexDirection: "column", height: dimensions.contentHeight, children: [_jsx("box", { height: 1, paddingLeft: 1, paddingRight: 1, children: _jsx("text", { fg: "#333333", children: "─".repeat(lineWidth) }) }), _jsx("box", { height: 1, paddingLeft: 1, paddingRight: 1, children: _jsx(TabBar, { currentScreen: currentScreen }) }), _jsx("box", { height: 1, paddingLeft: 1, paddingRight: 1, children: _jsx("text", { fg: "#333333", children: "─".repeat(lineWidth) }) }), _jsxs("box", { height: 1, paddingLeft: 1, paddingRight: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsx("text", { fg: HEADER_COLOR, children: _jsx("strong", { children: title }) }), subtitle && _jsx("text", { fg: "gray", children: subtitle }), !subtitle && statusLine ? statusLine : null] }), hasSearchBar && (_jsx("box", { height: 1, paddingLeft: 1, paddingRight: 1, children: search.isActive ? (_jsxs("text", { children: [_jsx("span", { fg: "green", children: "Filter: " }), _jsx("span", { fg: "white", children: search.query }), _jsx("span", { bg: "white", fg: "black", children: " " })] })) : (_jsxs("text", { children: [_jsx("span", { fg: "green", children: "Filter: " }), _jsx("span", { fg: "yellow", children: search.query }), _jsx("span", { fg: "gray", children: " (Esc to clear)" })] })) })), _jsx("box", { height: 1, paddingLeft: 1, paddingRight: 1, children: _jsx("text", { fg: "#444444", children: "─".repeat(lineWidth) }) }), _jsxs("box", { flexDirection: "row", height: panelHeight, children: [_jsx("box", { flexDirection: "column", width: "49%", height: panelHeight, paddingRight: 1, children: listPanel }), _jsx("box", { flexDirection: "column", width: 1, height: panelHeight, children: _jsx("text", { fg: "#444444", children: "│".repeat(panelHeight) }) }), _jsx("box", { width: "50%", height: panelHeight, paddingLeft: 1, children: _jsx("scrollbox", { height: panelHeight, scrollY: true, scrollX: false, children: _jsx("box", { flexDirection: "column", children: detailPanel }) }) })] }), _jsx("box", { height: 1, paddingLeft: 1, children: _jsx("text", { fg: "gray", children: footerHints }) })] }));
14
+ return (_jsxs("box", { flexDirection: "column", height: dimensions.contentHeight, children: [_jsx("box", { height: 1, paddingLeft: 1, paddingRight: 1, children: _jsx("text", { fg: "#333333", children: "─".repeat(lineWidth) }) }), _jsx("box", { height: 1, paddingLeft: 1, paddingRight: 1, children: _jsx(TabBar, { currentScreen: currentScreen }) }), _jsx("box", { height: 1, paddingLeft: 1, paddingRight: 1, children: _jsx("text", { fg: "#333333", children: "─".repeat(lineWidth) }) }), _jsxs("box", { height: 1, paddingLeft: 1, paddingRight: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsx("text", { fg: HEADER_COLOR, children: _jsx("strong", { children: title }) }), subtitle && _jsx("text", { fg: "gray", children: subtitle }), !subtitle && statusLine ? statusLine : null] }), hasSearchBar && (_jsx("box", { height: 1, paddingLeft: 1, paddingRight: 1, children: search.isActive ? (_jsxs("text", { children: [_jsx("span", { fg: "green", children: "Filter: " }), _jsx("span", { fg: "white", children: search.query }), _jsx("span", { bg: "white", fg: "black", children: " " })] })) : (_jsxs("text", { children: [_jsx("span", { fg: "green", children: "Filter: " }), _jsx("span", { fg: "yellow", children: search.query }), _jsx("span", { fg: "gray", children: " (Esc to clear)" })] })) })), _jsx("box", { height: 1, paddingLeft: 1, paddingRight: 1, children: _jsx("text", { fg: "#444444", children: "─".repeat(lineWidth) }) }), _jsxs("box", { flexDirection: "row", height: panelHeight, children: [_jsx("box", { flexDirection: "column", width: "49%", height: panelHeight, paddingRight: 1, children: listPanel }), _jsx("box", { flexDirection: "column", width: 1, height: panelHeight, children: _jsx("text", { fg: "#444444", children: "│".repeat(panelHeight) }) }), _jsx("box", { width: "50%", height: panelHeight, paddingLeft: 1, children: _jsx("scrollbox", { height: panelHeight, scrollY: true, scrollX: false, children: _jsx("box", { flexDirection: "column", children: detailPanel }) }) })] }), _jsx("box", { height: 1, paddingLeft: 1, children: Array.isArray(footerHints) ? (_jsx(FooterHints, { hints: footerHints })) : typeof footerHints === "string" ? (_jsx("text", { fg: "gray", children: footerHints })) : (footerHints) })] }));
14
15
  }
15
16
  export default ScreenLayout;
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import { useDimensions } from "../../state/DimensionsContext.js";
3
3
  import { TabBar } from "../TabBar.js";
4
4
  import type { Screen } from "../../state/types.js";
5
+ import { FooterHints, type FooterHint } from "./FooterHints.js";
5
6
 
6
7
  interface ScreenLayoutProps {
7
8
  /** Screen title (e.g., "claudeup Plugins") */
@@ -21,8 +22,10 @@ interface ScreenLayoutProps {
21
22
  };
22
23
  /** Status line content (for screens without search) - shown in second row */
23
24
  statusLine?: React.ReactNode;
24
- /** Footer hints (left side) */
25
- footerHints: string;
25
+ /** Footer hints (left side). Pass a FooterHint[] to render key badges
26
+ * (the standard look). A string or ReactNode is still accepted for
27
+ * free-form footers. */
28
+ footerHints: FooterHint[] | string | React.ReactNode;
26
29
  /** Left panel content */
27
30
  listPanel: React.ReactNode;
28
31
  /** Right panel content (detail view) */
@@ -134,7 +137,13 @@ export function ScreenLayout({
134
137
 
135
138
  {/* Footer */}
136
139
  <box height={1} paddingLeft={1}>
137
- <text fg="gray">{footerHints}</text>
140
+ {Array.isArray(footerHints) ? (
141
+ <FooterHints hints={footerHints as FooterHint[]} />
142
+ ) : typeof footerHints === "string" ? (
143
+ <text fg="gray">{footerHints}</text>
144
+ ) : (
145
+ footerHints
146
+ )}
138
147
  </box>
139
148
  </box>
140
149
  );
@@ -2,3 +2,4 @@ export { Panel } from "./Panel.js";
2
2
  export { ScopeTabs } from "./ScopeTabs.js";
3
3
  export { ProgressBar } from "./ProgressBar.js";
4
4
  export { ScreenLayout } from "./ScreenLayout.js";
5
+ export { FooterHints, renderFooterHints, } from "./FooterHints.js";
@@ -2,3 +2,8 @@ export { Panel } from "./Panel.js";
2
2
  export { ScopeTabs } from "./ScopeTabs.js";
3
3
  export { ProgressBar } from "./ProgressBar.js";
4
4
  export { ScreenLayout } from "./ScreenLayout.js";
5
+ export {
6
+ FooterHints,
7
+ renderFooterHints,
8
+ type FooterHint,
9
+ } from "./FooterHints.js";
@@ -1,9 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useDimensions } from "../../state/DimensionsContext.js";
2
3
  export function SelectModal({ title, message, options, defaultIndex, onSelect: _onSelect, onCancel: _onCancel, }) {
3
4
  // Keyboard handling is done by ModalContainer
4
5
  // defaultIndex is the live selectedIndex from ModalContainer state
5
6
  const selectedIndex = defaultIndex ?? 0;
6
- return (_jsxs("box", { flexDirection: "column", border: true, borderStyle: "rounded", borderColor: "#525252", backgroundColor: "#1C1C1E", paddingLeft: 3, paddingRight: 3, paddingTop: 1, paddingBottom: 1, width: 50, children: [_jsx("box", { marginBottom: 1, children: _jsx("text", { fg: "#EDEDED", children: _jsx("strong", { children: title }) }) }), _jsx("box", { marginBottom: 1, children: _jsx("text", { fg: "#A1A1AA", children: message }) }), _jsx("box", { flexDirection: "column", paddingLeft: 1, children: options.map((option, idx) => {
7
+ // Size to terminal width: 60% of available, clamped to [50, 90].
8
+ // Previously hardcoded at 50, which forced descriptions and long item
9
+ // labels (e.g. plugin specs) to wrap mid-sentence and produce orphan
10
+ // punctuation lines.
11
+ const dimensions = useDimensions();
12
+ const width = Math.max(50, Math.min(90, Math.floor(dimensions.terminalWidth * 0.6)));
13
+ return (_jsxs("box", { flexDirection: "column", border: true, borderStyle: "rounded", borderColor: "#525252", backgroundColor: "#1C1C1E", paddingLeft: 3, paddingRight: 3, paddingTop: 1, paddingBottom: 1, width: width, children: [_jsx("box", { marginBottom: 1, children: _jsx("text", { fg: "#EDEDED", children: _jsx("strong", { children: title }) }) }), _jsx("box", { marginBottom: 1, children: _jsx("text", { fg: "#A1A1AA", children: message }) }), _jsx("box", { flexDirection: "column", paddingLeft: 1, children: options.map((option, idx) => {
7
14
  const isSelected = idx === selectedIndex;
8
15
  return (_jsxs("text", { fg: isSelected ? "#F4F4F5" : "#A1A1AA", children: [_jsx("span", { fg: isSelected ? "#F4F4F5" : "#71717A", children: isSelected ? "❯ " : " " }), isSelected ? _jsx("strong", { children: option.label }) : option.label] }, option.value));
9
16
  }) }), _jsx("box", { marginTop: 1, children: _jsx("text", { fg: "#71717A", children: "\u2191\u2193 Select \u2022 \u21B5 Confirm \u2022 Esc Cancel" }) })] }));
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import type { SelectOption } from "../../state/types.js";
3
+ import { useDimensions } from "../../state/DimensionsContext.js";
3
4
 
4
5
  interface SelectModalProps {
5
6
  /** Modal title */
@@ -28,6 +29,16 @@ export function SelectModal({
28
29
  // defaultIndex is the live selectedIndex from ModalContainer state
29
30
  const selectedIndex = defaultIndex ?? 0;
30
31
 
32
+ // Size to terminal width: 60% of available, clamped to [50, 90].
33
+ // Previously hardcoded at 50, which forced descriptions and long item
34
+ // labels (e.g. plugin specs) to wrap mid-sentence and produce orphan
35
+ // punctuation lines.
36
+ const dimensions = useDimensions();
37
+ const width = Math.max(
38
+ 50,
39
+ Math.min(90, Math.floor(dimensions.terminalWidth * 0.6)),
40
+ );
41
+
31
42
  return (
32
43
  <box
33
44
  flexDirection="column"
@@ -39,7 +50,7 @@ export function SelectModal({
39
50
  paddingRight={3}
40
51
  paddingTop={1}
41
52
  paddingBottom={1}
42
- width={50}
53
+ width={width}
43
54
  >
44
55
  <box marginBottom={1}>
45
56
  <text fg="#EDEDED">
@@ -5,12 +5,12 @@ export function buildGitignoreModal(args) {
5
5
  const options = [
6
6
  {
7
7
  label: safeCount > 0
8
- ? `Append ${safeCount} safe entr${safeCount === 1 ? "y" : "ies"} to .gitignore`
9
- : "(no safe-fixable entries)",
8
+ ? `Add ${safeCount} missing .gitignore entr${safeCount === 1 ? "y" : "ies"}`
9
+ : "(no missing .gitignore entries)",
10
10
  value: "fix-safe",
11
11
  },
12
12
  {
13
- label: "Open Gitignore tab to review",
13
+ label: "Open Git State tab to review",
14
14
  value: "open-tab",
15
15
  },
16
16
  {
@@ -21,11 +21,10 @@ export function buildGitignoreModal(args) {
21
21
  return {
22
22
  type: "select",
23
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.`,
24
+ message: `Found ${violationCount} managed gitignore item(s) that do not match ` +
25
+ `the current repo state.\n\n` +
26
+ `Missing .gitignore entries can be added now. Items that change git ` +
27
+ `tracking are reviewed and fixed from the Gitignore tab.`,
29
28
  options,
30
29
  onSelect: (value) => {
31
30
  if (value === "fix-safe")
@@ -5,7 +5,7 @@ import type { ModalState } from "../state/types.js";
5
5
  /**
6
6
  * Startup gitignore-violation modal. Mirrors `useMismatchModal` so the
7
7
  * two flows feel identical to the user — claudeup launches, detects
8
- * a problem, asks "fix safe entries / open the tab / dismiss".
8
+ * a problem, asks "add missing ignores / open the tab / dismiss".
9
9
  *
10
10
  * Pure builder is exported separately for tests.
11
11
  */
@@ -24,12 +24,12 @@ export function buildGitignoreModal(args: BuildModalArgs): ModalState {
24
24
  {
25
25
  label:
26
26
  safeCount > 0
27
- ? `Append ${safeCount} safe entr${safeCount === 1 ? "y" : "ies"} to .gitignore`
28
- : "(no safe-fixable entries)",
27
+ ? `Add ${safeCount} missing .gitignore entr${safeCount === 1 ? "y" : "ies"}`
28
+ : "(no missing .gitignore entries)",
29
29
  value: "fix-safe",
30
30
  },
31
31
  {
32
- label: "Open Gitignore tab to review",
32
+ label: "Open Git State tab to review",
33
33
  value: "open-tab",
34
34
  },
35
35
  {
@@ -41,11 +41,10 @@ export function buildGitignoreModal(args: BuildModalArgs): ModalState {
41
41
  type: "select",
42
42
  title: "Gitignore violations detected",
43
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.`,
44
+ `Found ${violationCount} managed gitignore item(s) that do not match ` +
45
+ `the current repo state.\n\n` +
46
+ `Missing .gitignore entries can be added now. Items that change git ` +
47
+ `tracking are reviewed and fixed from the Gitignore tab.`,
49
48
  options,
50
49
  onSelect: (value: string) => {
51
50
  if (value === "fix-safe") onFixSafe();
@@ -1,33 +1,46 @@
1
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).
2
+ // Two color channels that never collide:
3
+ // • Badge color = direction (where the item ends up): green = tracked by git,
4
+ // reddish = ignored/dropped from git. Same on clean AND violation rows.
5
+ // Filename color = severity: dim gray when fine, red when it's a violation.
6
+ // No row background tints red is reserved entirely for "needs attention".
7
+ const TRACK_FG = "#5fd787"; // green item ends up tracked by git
8
+ const IGNORE_FG = "#d78787"; // reddish — item ends up ignored / dropped
9
+ const VIOLATION_PATH_FG = "#ff5f5f"; // bright red filename = look here
10
+ const CLEAN_PATH_FG = "gray";
11
+ const SELECTED_BG = "#2a2a3a"; // faint slate highlight for the cursor row
12
+ // Violation badges are solid chips so the required direction reads at a glance:
13
+ // green chip = will be tracked, red chip = will be ignored/dropped.
14
+ const TRACK_CHIP_BG = "#1f5132";
15
+ const IGNORE_CHIP_BG = "#5a2323";
16
+ const CHIP_FG = "white";
17
+ // The only marker is the cursor: ▶ on the selected row, blank otherwise.
18
+ // Action (track/ignore) is read from the badge word and, for clean items,
19
+ // from the group the row sits under — so no per-row dot is needed.
20
+ const MARKER_SELECTED = "▶";
21
+ const MARKER_NONE = " ";
22
+ // Layout: " M action path" — keep the prefix compact so the narrow terminal
23
+ // case still has room for the path without wrapping.
8
24
  const MAX_PATH_LEN = 22;
9
25
  function truncatePath(p) {
10
26
  return p.length > MAX_PATH_LEN ? p.slice(0, MAX_PATH_LEN - 1) + "…" : p;
11
27
  }
12
28
  export function renderRuleRow(row, isSelected) {
13
- const { rule, violation } = row;
29
+ const { rule, violations } = row;
14
30
  const path = truncatePath(rule.pattern);
15
31
  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));
32
+ // Badge text padded to a fixed width so paths line up in a column.
33
+ const badge = rule.action === "ignore" ? "ignore" : "track ";
34
+ const badgeFg = rule.action === "ignore" ? IGNORE_FG : TRACK_FG;
35
+ const suffix = violations.length > 1 ? ` +${violations.length - 1}` : "";
36
+ const rowBg = isSelected ? SELECTED_BG : undefined;
37
+ const marker = isSelected ? MARKER_SELECTED : MARKER_NONE;
38
+ const isViolation = violations.length > 0;
39
+ if (isViolation) {
40
+ // Solid badge chip + red filename. The chip color is the fix direction.
41
+ const chipBg = rule.action === "ignore" ? IGNORE_CHIP_BG : TRACK_CHIP_BG;
42
+ return (_jsxs("text", { bg: rowBg, children: [_jsx("span", { fg: VIOLATION_PATH_FG, children: ` ${marker} ` }), _jsx("span", { bg: chipBg, fg: CHIP_FG, children: ` ${badge} ` }), _jsx("span", { fg: VIOLATION_PATH_FG, children: ` ${path}${suffix}` })] }, key));
26
43
  }
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));
44
+ // Clean row: flat colored badge word, dim path, no chip.
45
+ return (_jsxs("text", { bg: rowBg, children: [_jsx("span", { fg: badgeFg, children: ` ${marker} ` }), _jsx("span", { fg: badgeFg, children: badge }), _jsx("span", { fg: CLEAN_PATH_FG, children: ` ${path}` })] }, key));
33
46
  }
@@ -3,68 +3,77 @@ import type { ResolvedRule, Violation } from "../../types/gitignore.js";
3
3
 
4
4
  /**
5
5
  * A unified row for the Gitignore screen — represents one resolved rule,
6
- * optionally annotated with the violation it has against the working tree.
6
+ * annotated with every violation it has against the working tree.
7
7
  */
8
8
  export interface RuleRow {
9
9
  rule: ResolvedRule;
10
- violation?: Violation;
10
+ violations: Violation[];
11
11
  }
12
12
 
13
- const SELECTION_BG = "#7e57c2";
14
- const SELECTION_FG = "white";
13
+ // Two color channels that never collide:
14
+ // • Badge color = direction (where the item ends up): green = tracked by git,
15
+ // reddish = ignored/dropped from git. Same on clean AND violation rows.
16
+ // • Filename color = severity: dim gray when fine, red when it's a violation.
17
+ // No row background tints — red is reserved entirely for "needs attention".
18
+ const TRACK_FG = "#5fd787"; // green — item ends up tracked by git
19
+ const IGNORE_FG = "#d78787"; // reddish — item ends up ignored / dropped
20
+ const VIOLATION_PATH_FG = "#ff5f5f"; // bright red filename = look here
21
+ const CLEAN_PATH_FG = "gray";
22
+ const SELECTED_BG = "#2a2a3a"; // faint slate highlight for the cursor row
15
23
 
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).
24
+ // Violation badges are solid chips so the required direction reads at a glance:
25
+ // green chip = will be tracked, red chip = will be ignored/dropped.
26
+ const TRACK_CHIP_BG = "#1f5132";
27
+ const IGNORE_CHIP_BG = "#5a2323";
28
+ const CHIP_FG = "white";
29
+
30
+ // The only marker is the cursor: ▶ on the selected row, blank otherwise.
31
+ // Action (track/ignore) is read from the badge word and, for clean items,
32
+ // from the group the row sits under — so no per-row dot is needed.
33
+ const MARKER_SELECTED = "▶";
34
+ const MARKER_NONE = " ";
35
+
36
+ // Layout: " M action path" — keep the prefix compact so the narrow terminal
37
+ // case still has room for the path without wrapping.
20
38
  const MAX_PATH_LEN = 22;
21
39
 
22
40
  function truncatePath(p: string): string {
23
41
  return p.length > MAX_PATH_LEN ? p.slice(0, MAX_PATH_LEN - 1) + "…" : p;
24
42
  }
25
43
 
26
- export function renderRuleRow(row: RuleRow, isSelected: boolean): React.ReactNode {
27
- const { rule, violation } = row;
44
+ export function renderRuleRow(
45
+ row: RuleRow,
46
+ isSelected: boolean,
47
+ ): React.ReactNode {
48
+ const { rule, violations } = row;
28
49
  const path = truncatePath(rule.pattern);
29
50
  const key = `${rule.action}:${rule.pattern}`;
51
+ // Badge text padded to a fixed width so paths line up in a column.
52
+ const badge = rule.action === "ignore" ? "ignore" : "track ";
53
+ const badgeFg = rule.action === "ignore" ? IGNORE_FG : TRACK_FG;
54
+ const suffix = violations.length > 1 ? ` +${violations.length - 1}` : "";
55
+ const rowBg = isSelected ? SELECTED_BG : undefined;
56
+ const marker = isSelected ? MARKER_SELECTED : MARKER_NONE;
57
+ const isViolation = violations.length > 0;
30
58
 
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
- }
59
+ if (isViolation) {
60
+ // Solid badge chip + red filename. The chip color is the fix direction.
61
+ const chipBg = rule.action === "ignore" ? IGNORE_CHIP_BG : TRACK_CHIP_BG;
46
62
  return (
47
- <text key={key}>
48
- <span fg="green">{` `}</span>
49
- <span fg="gray">{`${verb} ${path}`}</span>
63
+ <text key={key} bg={rowBg}>
64
+ <span fg={VIOLATION_PATH_FG}>{` ${marker} `}</span>
65
+ <span bg={chipBg} fg={CHIP_FG}>{` ${badge} `}</span>
66
+ <span fg={VIOLATION_PATH_FG}>{` ${path}${suffix}`}</span>
50
67
  </text>
51
68
  );
52
69
  }
53
70
 
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
- }
71
+ // Clean row: flat colored badge word, dim path, no chip.
64
72
  return (
65
- <text key={key}>
66
- <span fg={sevColor}>{` ! `}</span>
67
- <span fg="white">{`${verb} ${path}`}</span>
73
+ <text key={key} bg={rowBg}>
74
+ <span fg={badgeFg}>{` ${marker} `}</span>
75
+ <span fg={badgeFg}>{badge}</span>
76
+ <span fg={CLEAN_PATH_FG}>{` ${path}`}</span>
68
77
  </text>
69
78
  );
70
79
  }