claudeup 4.17.0 → 4.19.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 (86) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/alias-adopt.test.ts +364 -0
  3. package/src/__tests__/alias-parser.test.ts +409 -0
  4. package/src/__tests__/alias-shell-writer.test.ts +668 -0
  5. package/src/__tests__/alias-store.test.ts +163 -0
  6. package/src/__tests__/gitignore-fixer.test.ts +64 -1
  7. package/src/__tests__/gitignore-prerun.test.ts +2 -2
  8. package/src/__tests__/gitignore-service.test.ts +42 -0
  9. package/src/__tests__/marketplaces.test.ts +40 -0
  10. package/src/__tests__/plugin-manager-fallback.test.ts +120 -0
  11. package/src/__tests__/plugin-setup.test.ts +111 -0
  12. package/src/__tests__/useGitignoreModal.test.ts +2 -2
  13. package/src/data/alias-flags.js +205 -0
  14. package/src/data/alias-flags.ts +301 -0
  15. package/src/data/gitignore-reasons.js +97 -0
  16. package/src/data/gitignore-reasons.ts +103 -0
  17. package/src/data/marketplaces.js +5 -3
  18. package/src/data/marketplaces.ts +5 -4
  19. package/src/services/alias-settings.js +51 -0
  20. package/src/services/alias-settings.ts +63 -0
  21. package/src/services/alias-shell-writer.js +1018 -0
  22. package/src/services/alias-shell-writer.ts +1247 -0
  23. package/src/services/alias-store.js +129 -0
  24. package/src/services/alias-store.ts +172 -0
  25. package/src/services/gitignore-fixer.js +70 -10
  26. package/src/services/gitignore-fixer.ts +76 -9
  27. package/src/services/gitignore-prerun.js +3 -3
  28. package/src/services/gitignore-prerun.ts +3 -3
  29. package/src/services/gitignore-service.js +20 -2
  30. package/src/services/gitignore-service.ts +23 -1
  31. package/src/services/marketplace-fetcher.js +96 -0
  32. package/src/services/marketplace-fetcher.ts +137 -0
  33. package/src/services/plugin-manager.js +6 -59
  34. package/src/services/plugin-manager.ts +16 -91
  35. package/src/services/plugin-setup.js +59 -4
  36. package/src/services/plugin-setup.ts +61 -4
  37. package/src/services/skillsmp-client.js +29 -9
  38. package/src/services/skillsmp-client.ts +38 -8
  39. package/src/types/gitignore.ts +1 -1
  40. package/src/ui/App.js +26 -11
  41. package/src/ui/App.tsx +25 -10
  42. package/src/ui/components/FlagDetailEditor.js +0 -0
  43. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  44. package/src/ui/components/TabBar.js +2 -1
  45. package/src/ui/components/TabBar.tsx +2 -1
  46. package/src/ui/components/layout/FooterHints.js +29 -0
  47. package/src/ui/components/layout/FooterHints.tsx +52 -0
  48. package/src/ui/components/layout/ScreenLayout.js +2 -1
  49. package/src/ui/components/layout/ScreenLayout.tsx +12 -3
  50. package/src/ui/components/layout/index.js +1 -0
  51. package/src/ui/components/layout/index.ts +5 -0
  52. package/src/ui/components/modals/ConfirmModal.js +1 -1
  53. package/src/ui/components/modals/ConfirmModal.tsx +1 -1
  54. package/src/ui/components/modals/SelectModal.js +8 -1
  55. package/src/ui/components/modals/SelectModal.tsx +12 -1
  56. package/src/ui/hooks/useGitignoreModal.js +7 -8
  57. package/src/ui/hooks/useGitignoreModal.ts +8 -9
  58. package/src/ui/renderers/gitignoreRenderers.js +36 -23
  59. package/src/ui/renderers/gitignoreRenderers.tsx +50 -41
  60. package/src/ui/screens/AliasScreen.js +1111 -0
  61. package/src/ui/screens/AliasScreen.tsx +1534 -0
  62. package/src/ui/screens/CliToolsScreen.js +6 -1
  63. package/src/ui/screens/CliToolsScreen.tsx +6 -1
  64. package/src/ui/screens/EnvVarsScreen.js +6 -1
  65. package/src/ui/screens/EnvVarsScreen.tsx +6 -1
  66. package/src/ui/screens/GitignoreScreen.js +189 -88
  67. package/src/ui/screens/GitignoreScreen.tsx +312 -132
  68. package/src/ui/screens/McpRegistryScreen.js +13 -2
  69. package/src/ui/screens/McpRegistryScreen.tsx +13 -2
  70. package/src/ui/screens/McpScreen.js +6 -1
  71. package/src/ui/screens/McpScreen.tsx +6 -1
  72. package/src/ui/screens/ModelSelectorScreen.js +8 -2
  73. package/src/ui/screens/ModelSelectorScreen.tsx +8 -2
  74. package/src/ui/screens/PluginsScreen.js +17 -3
  75. package/src/ui/screens/PluginsScreen.tsx +16 -3
  76. package/src/ui/screens/ProfilesScreen.js +8 -1
  77. package/src/ui/screens/ProfilesScreen.tsx +8 -1
  78. package/src/ui/screens/SkillsScreen.js +21 -4
  79. package/src/ui/screens/SkillsScreen.tsx +39 -5
  80. package/src/ui/screens/StatusLineScreen.js +7 -1
  81. package/src/ui/screens/StatusLineScreen.tsx +7 -1
  82. package/src/ui/screens/index.js +1 -0
  83. package/src/ui/screens/index.ts +1 -0
  84. package/src/ui/state/reducer.js +5 -1
  85. package/src/ui/state/reducer.ts +6 -1
  86. package/src/ui/state/types.ts +12 -2
@@ -7,10 +7,28 @@
7
7
  * - GitHub Tree API with ETag caching
8
8
  * - Recommended skills list
9
9
  */
10
- // TODO: Update to production URL after Firebase deploy
10
+ // Deployed `skills` Gen2 Cloud Function (project claudish-6da10, us-central1).
11
+ // Firebase keeps this cloudfunctions.net alias stable for the function.
11
12
  const SKILLS_API_BASE = process.env.SKILLS_API_URL || "https://us-central1-claudish-6da10.cloudfunctions.net/skills";
12
13
  /**
13
- * Search skills across all sources (SkillsMP keyword + AI search)
14
+ * Thrown when the skills API is unreachable or returns a non-OK status.
15
+ * Lets callers distinguish "search service is down" from "no skills matched",
16
+ * which a bare `[]` return cannot express.
17
+ */
18
+ export class SkillsApiError extends Error {
19
+ status;
20
+ constructor(message, status) {
21
+ super(message);
22
+ this.status = status;
23
+ this.name = "SkillsApiError";
24
+ }
25
+ }
26
+ /**
27
+ * Search skills across all sources (SkillsMP keyword + AI search).
28
+ *
29
+ * Throws {@link SkillsApiError} on transport/HTTP failure so the caller can
30
+ * surface a distinct "search unavailable" state. An empty array means the
31
+ * query genuinely matched nothing.
14
32
  */
15
33
  export async function searchSkills(query, options) {
16
34
  const params = new URLSearchParams({ q: query });
@@ -18,18 +36,20 @@ export async function searchSkills(query, options) {
18
36
  params.set("page", String(options.page));
19
37
  if (options?.limit)
20
38
  params.set("limit", String(options.limit));
39
+ let res;
21
40
  try {
22
- const res = await fetch(`${SKILLS_API_BASE}/search?${params}`, {
41
+ res = await fetch(`${SKILLS_API_BASE}/search?${params}`, {
23
42
  signal: AbortSignal.timeout(12000),
24
43
  });
25
- if (!res.ok)
26
- return [];
27
- const data = (await res.json());
28
- return data.skills || [];
29
44
  }
30
- catch {
31
- return [];
45
+ catch (cause) {
46
+ throw new SkillsApiError(`Skills search request failed: ${cause instanceof Error ? cause.message : String(cause)}`);
47
+ }
48
+ if (!res.ok) {
49
+ throw new SkillsApiError(`Skills search returned HTTP ${res.status}`, res.status);
32
50
  }
51
+ const data = (await res.json());
52
+ return data.skills || [];
33
53
  }
34
54
  /**
35
55
  * Fetch skills from a specific GitHub repo (via our proxy)
@@ -8,7 +8,8 @@
8
8
  * - Recommended skills list
9
9
  */
10
10
 
11
- // TODO: Update to production URL after Firebase deploy
11
+ // Deployed `skills` Gen2 Cloud Function (project claudish-6da10, us-central1).
12
+ // Firebase keeps this cloudfunctions.net alias stable for the function.
12
13
  const SKILLS_API_BASE =
13
14
  process.env.SKILLS_API_URL || "https://us-central1-claudish-6da10.cloudfunctions.net/skills";
14
15
 
@@ -31,7 +32,26 @@ export interface RepoSkillResult {
31
32
  }
32
33
 
33
34
  /**
34
- * Search skills across all sources (SkillsMP keyword + AI search)
35
+ * Thrown when the skills API is unreachable or returns a non-OK status.
36
+ * Lets callers distinguish "search service is down" from "no skills matched",
37
+ * which a bare `[]` return cannot express.
38
+ */
39
+ export class SkillsApiError extends Error {
40
+ constructor(
41
+ message: string,
42
+ readonly status?: number,
43
+ ) {
44
+ super(message);
45
+ this.name = "SkillsApiError";
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Search skills across all sources (SkillsMP keyword + AI search).
51
+ *
52
+ * Throws {@link SkillsApiError} on transport/HTTP failure so the caller can
53
+ * surface a distinct "search unavailable" state. An empty array means the
54
+ * query genuinely matched nothing.
35
55
  */
36
56
  export async function searchSkills(
37
57
  query: string,
@@ -41,16 +61,26 @@ export async function searchSkills(
41
61
  if (options?.page) params.set("page", String(options.page));
42
62
  if (options?.limit) params.set("limit", String(options.limit));
43
63
 
64
+ let res: Response;
44
65
  try {
45
- const res = await fetch(`${SKILLS_API_BASE}/search?${params}`, {
66
+ res = await fetch(`${SKILLS_API_BASE}/search?${params}`, {
46
67
  signal: AbortSignal.timeout(12000),
47
68
  });
48
- if (!res.ok) return [];
49
- const data = (await res.json()) as { skills: SkillSearchResult[] };
50
- return data.skills || [];
51
- } catch {
52
- return [];
69
+ } catch (cause) {
70
+ throw new SkillsApiError(
71
+ `Skills search request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
72
+ );
53
73
  }
74
+
75
+ if (!res.ok) {
76
+ throw new SkillsApiError(
77
+ `Skills search returned HTTP ${res.status}`,
78
+ res.status,
79
+ );
80
+ }
81
+
82
+ const data = (await res.json()) as { skills?: SkillSearchResult[] };
83
+ return data.skills || [];
54
84
  }
55
85
 
56
86
  /**
@@ -6,7 +6,7 @@
6
6
 
7
7
  export type RuleAction = "ignore" | "track";
8
8
 
9
- export type Tier = "builtin" | "global" | "project";
9
+ export type Tier = "builtin" | "global" | "project" | "profile";
10
10
 
11
11
  /** A user-authored manifest, deserialized from JSON. */
12
12
  export interface GitignoreManifest {
package/src/ui/App.js CHANGED
@@ -5,7 +5,7 @@ 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, GitignoreScreen, } from "./screens/index.js";
8
+ import { PluginsScreen, McpScreen, McpRegistryScreen, SettingsScreen, CliToolsScreen, ModelSelectorScreen, ProfilesScreen, SkillsScreen, GitignoreScreen, AliasScreen, } from "./screens/index.js";
9
9
  import { checkGitignore } from "../services/gitignore-prerun.js";
10
10
  import { loadGitignoreState, applyAllSafeFixes, } from "../services/gitignore-service.js";
11
11
  import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
@@ -45,13 +45,15 @@ function Router() {
45
45
  return _jsx(SkillsScreen, {});
46
46
  case "gitignore":
47
47
  return _jsx(GitignoreScreen, {});
48
+ case "alias":
49
+ return _jsx(AliasScreen, {});
48
50
  default:
49
51
  return _jsx(PluginsScreen, {});
50
52
  }
51
53
  }
52
54
  /**
53
55
  * GlobalKeyHandler Component
54
- * Handles global keyboard shortcuts (1-5, Tab, Escape, q, ?, Shift+D)
56
+ * Handles global keyboard shortcuts (1-8, Tab, Escape, q, Ctrl+C, ?, Shift+D)
55
57
  * Does not render anything (returns null)
56
58
  */
57
59
  function GlobalKeyHandler({ onDebugToggle, onExit, }) {
@@ -86,7 +88,7 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
86
88
  // Don't handle keys when modal is open or searching
87
89
  if (state.modal || state.isSearching)
88
90
  return;
89
- // Global navigation shortcuts (1-7) - include mcp-registry as it's a sub-screen of mcp
91
+ // Global navigation shortcuts (1-8) - include mcp-registry as it's a sub-screen of mcp
90
92
  const isTopLevel = [
91
93
  "plugins",
92
94
  "mcp",
@@ -97,6 +99,7 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
97
99
  "profiles",
98
100
  "skills",
99
101
  "gitignore",
102
+ "alias",
100
103
  ].includes(state.currentRoute.screen);
101
104
  if (isTopLevel) {
102
105
  if (input === "1")
@@ -113,6 +116,8 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
113
116
  navigateToScreen("cli-tools");
114
117
  else if (input === "7")
115
118
  navigateToScreen("gitignore");
119
+ else if (input === "8")
120
+ navigateToScreen("alias");
116
121
  // Tab navigation cycling
117
122
  if (key.tab) {
118
123
  const screens = [
@@ -123,6 +128,7 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
123
128
  "profiles",
124
129
  "cli-tools",
125
130
  "gitignore",
131
+ "alias",
126
132
  ];
127
133
  const currentIndex = screens.indexOf(state.currentRoute.screen);
128
134
  if (currentIndex !== -1) {
@@ -133,18 +139,26 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
133
139
  }
134
140
  }
135
141
  }
136
- // Escape/q to go back or exit
137
- if (key.escape || input === "q") {
142
+ // q or Ctrl+C quits from anywhere — no need to bounce to home first.
143
+ if (input === "q" || (key.ctrl && input === "c")) {
144
+ onExit();
145
+ return;
146
+ }
147
+ // Escape goes back: registry → mcp, any other tab → plugins home,
148
+ // already-home → exit. But when a screen has a focused sub-view open
149
+ // (e.g. the Alias flag-detail editor), that sub-view owns Escape and
150
+ // backs out to itself — don't let the global handler jump to home.
151
+ // Scoped to the Escape branch ONLY so q/Ctrl+C still quit from anywhere.
152
+ if (key.escape) {
153
+ if (state.detailActive)
154
+ return;
138
155
  if (state.currentRoute.screen === "plugins") {
139
- // On home screen, exit immediately
140
156
  onExit();
141
157
  }
142
158
  else if (state.currentRoute.screen === "mcp-registry") {
143
- // Go back to MCP from registry
144
159
  navigateToScreen("mcp");
145
160
  }
146
161
  else {
147
- // Go back to plugins (home)
148
162
  navigateToScreen("plugins");
149
163
  }
150
164
  }
@@ -153,12 +167,13 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
153
167
  modal.message("claudeup Help", `Navigation
154
168
  ↑/↓ or j/k Move selection
155
169
  Enter Select / Toggle
156
- Escape or q Back / Quit
170
+ Escape Back (to home)
171
+ q / Ctrl+C Quit (from anywhere)
157
172
  ? This help
158
173
 
159
174
  Quick Navigation
160
- 1 Plugins 4 Settings 7 Gitignore
161
- 2 Skills 5 Profiles
175
+ 1 Plugins 4 Settings 7 Git State
176
+ 2 Skills 5 Profiles 8 Alias
162
177
  3 MCP Servers 6 CLI Tools
163
178
 
164
179
  Plugin Actions
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
  }
@@ -86,7 +89,7 @@ function Router() {
86
89
 
87
90
  /**
88
91
  * GlobalKeyHandler Component
89
- * Handles global keyboard shortcuts (1-5, Tab, Escape, q, ?, Shift+D)
92
+ * Handles global keyboard shortcuts (1-8, Tab, Escape, q, Ctrl+C, ?, Shift+D)
90
93
  * Does not render anything (returns null)
91
94
  */
92
95
  function GlobalKeyHandler({
@@ -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,
@@ -174,16 +180,24 @@ function GlobalKeyHandler({
174
180
  }
175
181
  }
176
182
 
177
- // Escape/q to go back or exit
178
- if (key.escape || input === "q") {
183
+ // q or Ctrl+C quits from anywhere — no need to bounce to home first.
184
+ if (input === "q" || (key.ctrl && input === "c")) {
185
+ onExit();
186
+ return;
187
+ }
188
+
189
+ // Escape goes back: registry → mcp, any other tab → plugins home,
190
+ // already-home → exit. But when a screen has a focused sub-view open
191
+ // (e.g. the Alias flag-detail editor), that sub-view owns Escape and
192
+ // backs out to itself — don't let the global handler jump to home.
193
+ // Scoped to the Escape branch ONLY so q/Ctrl+C still quit from anywhere.
194
+ if (key.escape) {
195
+ if (state.detailActive) return;
179
196
  if (state.currentRoute.screen === "plugins") {
180
- // On home screen, exit immediately
181
197
  onExit();
182
198
  } else if (state.currentRoute.screen === "mcp-registry") {
183
- // Go back to MCP from registry
184
199
  navigateToScreen("mcp");
185
200
  } else {
186
- // Go back to plugins (home)
187
201
  navigateToScreen("plugins");
188
202
  }
189
203
  }
@@ -195,12 +209,13 @@ function GlobalKeyHandler({
195
209
  `Navigation
196
210
  ↑/↓ or j/k Move selection
197
211
  Enter Select / Toggle
198
- Escape or q Back / Quit
212
+ Escape Back (to home)
213
+ q / Ctrl+C Quit (from anywhere)
199
214
  ? This help
200
215
 
201
216
  Quick Navigation
202
- 1 Plugins 4 Settings 7 Gitignore
203
- 2 Skills 5 Profiles
217
+ 1 Plugins 4 Settings 7 Git State
218
+ 2 Skills 5 Profiles 8 Alias
204
219
  3 MCP Servers 6 CLI Tools
205
220
 
206
221
  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";
@@ -9,6 +9,6 @@ export function ConfirmModal({ title, message, onConfirm, onCancel, }) {
9
9
  onCancel();
10
10
  }
11
11
  });
12
- return (_jsxs("box", { flexDirection: "column", border: true, borderStyle: "rounded", borderColor: "#525252", backgroundColor: "#1C1C1E", paddingLeft: 3, paddingRight: 3, paddingTop: 1, paddingBottom: 1, width: 60, 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 }) }), _jsxs("box", { marginTop: 1, children: [_jsx("text", { fg: "#71717A", children: "Press " }), _jsx("text", { fg: "#EDEDED", children: "Y" }), _jsx("text", { fg: "#71717A", children: " to confirm \u2022 " }), _jsx("text", { fg: "#EDEDED", children: "N" }), _jsx("text", { fg: "#71717A", children: " to cancel" })] })] }));
12
+ return (_jsxs("box", { flexDirection: "column", border: true, borderStyle: "rounded", borderColor: "#525252", backgroundColor: "#1C1C1E", paddingLeft: 3, paddingRight: 3, paddingTop: 1, paddingBottom: 1, width: 60, 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 }) }), _jsxs("box", { marginTop: 1, flexDirection: "row", children: [_jsx("text", { fg: "#71717A", children: "Press " }), _jsx("text", { fg: "#EDEDED", children: "Y" }), _jsx("text", { fg: "#71717A", children: " to confirm \u2022 " }), _jsx("text", { fg: "#EDEDED", children: "N" }), _jsx("text", { fg: "#71717A", children: " to cancel" })] })] }));
13
13
  }
14
14
  export default ConfirmModal;
@@ -47,7 +47,7 @@ export function ConfirmModal({
47
47
  <box marginBottom={1}>
48
48
  <text fg="#A1A1AA">{message}</text>
49
49
  </box>
50
- <box marginTop={1}>
50
+ <box marginTop={1} flexDirection="row">
51
51
  <text fg="#71717A">Press </text>
52
52
  <text fg="#EDEDED">Y</text>
53
53
  <text fg="#71717A"> to confirm • </text>
@@ -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")