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
@@ -217,7 +217,12 @@ export function McpScreen() {
217
217
  title="claudeup MCP Servers"
218
218
  subtitle={subtitle}
219
219
  currentScreen="mcp"
220
- footerHints="↑↓:nav │ Enter:toggle │ /:search │ r:registry"
220
+ footerHints={[
221
+ { keys: ["↑", "↓"], label: "nav" },
222
+ { keys: ["Enter"], label: "toggle" },
223
+ { keys: ["/"], label: "search" },
224
+ { keys: ["r"], label: "registry" },
225
+ ]}
221
226
  listPanel={
222
227
  <ScrollableList
223
228
  items={allListItems}
@@ -5,6 +5,7 @@ import { useDimensions } from "../state/DimensionsContext.js";
5
5
  import { fuzzyFilter, highlightMatches } from "../../utils/fuzzy-search.js";
6
6
  import { useKeyboard } from "../hooks/useKeyboard.js";
7
7
  import { ScrollableList } from "../components/ScrollableList.js";
8
+ import { FooterHints } from "../components/layout/index.js";
8
9
  const RECENT_MODELS = [
9
10
  {
10
11
  id: "recent-1",
@@ -278,13 +279,18 @@ export function ModelSelectorScreen() {
278
279
  return (_jsxs("box", { overflow: "hidden", children: [_jsx(Label, {}), _jsx("box", { flexGrow: 1 }), item.provider && _jsx("text", { fg: "gray", children: item.provider })] }));
279
280
  }
280
281
  };
281
- const footerHints = "↑↓ choose • tab toggle type • enter choose • esc exit";
282
+ const footerHints = [
283
+ { keys: ["↑", "↓"], label: "choose" },
284
+ { keys: ["tab"], label: "toggle type" },
285
+ { keys: ["enter"], label: "choose" },
286
+ { keys: ["esc"], label: "exit" },
287
+ ];
282
288
  // Available height calculation
283
289
  // Header: 1 line (title) + 1 line (search) + 1 line (separator) = 3 lines?
284
290
  // Title line: Switch Model /// (o) Large
285
291
  // Cursor line: > c
286
292
  // Separator: handled by list? or explicit?
287
293
  const listHeight = Math.max(5, dimensions.contentHeight - 5);
288
- return (_jsxs("box", { flexDirection: "column", height: dimensions.contentHeight, children: [_jsx("box", { flexDirection: "row", border: true, borderStyle: "single", borderColor: "#7e57c2", paddingLeft: 1, paddingRight: 1, children: _jsxs("box", { flexDirection: "column", flexGrow: 1, children: [_jsx("box", { flexDirection: "row", justifyContent: "space-between", children: _jsxs("box", { children: [_jsx("text", { fg: "#7e57c2", children: "Switch Model " }), _jsxs("text", { fg: modelSelector.taskSize === "large" ? "white" : "gray", children: [modelSelector.taskSize === "large" ? "◎" : "○", " Large Task", " "] }), _jsxs("text", { fg: modelSelector.taskSize === "small" ? "white" : "gray", children: [modelSelector.taskSize === "small" ? "◎" : "○", " Small Task"] })] }) }), _jsxs("box", { flexDirection: "row", marginTop: 1, children: [_jsx("text", { fg: "green", children: "> " }), _jsx("text", { children: modelSelector.searchQuery }), _jsx("text", { bg: "gray", fg: "black", children: " " })] })] }) }), _jsx("box", { flexGrow: 1, paddingLeft: 1, paddingRight: 1, children: _jsx(ScrollableList, { items: filteredItems, selectedIndex: modelSelector.selectedIndex, renderItem: renderItem, maxHeight: listHeight, getKey: (item) => item.id }) }), _jsx("box", { height: 1, children: _jsx("text", { fg: "#888888", children: footerHints }) })] }));
294
+ return (_jsxs("box", { flexDirection: "column", height: dimensions.contentHeight, children: [_jsx("box", { flexDirection: "row", border: true, borderStyle: "single", borderColor: "#7e57c2", paddingLeft: 1, paddingRight: 1, children: _jsxs("box", { flexDirection: "column", flexGrow: 1, children: [_jsx("box", { flexDirection: "row", justifyContent: "space-between", children: _jsxs("box", { children: [_jsx("text", { fg: "#7e57c2", children: "Switch Model " }), _jsxs("text", { fg: modelSelector.taskSize === "large" ? "white" : "gray", children: [modelSelector.taskSize === "large" ? "◎" : "○", " Large Task", " "] }), _jsxs("text", { fg: modelSelector.taskSize === "small" ? "white" : "gray", children: [modelSelector.taskSize === "small" ? "◎" : "○", " Small Task"] })] }) }), _jsxs("box", { flexDirection: "row", marginTop: 1, children: [_jsx("text", { fg: "green", children: "> " }), _jsx("text", { children: modelSelector.searchQuery }), _jsx("text", { bg: "gray", fg: "black", children: " " })] })] }) }), _jsx("box", { flexGrow: 1, paddingLeft: 1, paddingRight: 1, children: _jsx(ScrollableList, { items: filteredItems, selectedIndex: modelSelector.selectedIndex, renderItem: renderItem, maxHeight: listHeight, getKey: (item) => item.id }) }), _jsx("box", { height: 1, children: _jsx(FooterHints, { hints: footerHints }) })] }));
289
295
  }
290
296
  export default ModelSelectorScreen;
@@ -5,6 +5,7 @@ import { useDimensions } from "../state/DimensionsContext.js";
5
5
  import { fuzzyFilter, highlightMatches } from "../../utils/fuzzy-search.js";
6
6
  import { useKeyboard } from "../hooks/useKeyboard.js";
7
7
  import { ScrollableList } from "../components/ScrollableList.js";
8
+ import { FooterHints } from "../components/layout/index.js";
8
9
 
9
10
  interface ModelItem {
10
11
  id: string;
@@ -372,7 +373,12 @@ export function ModelSelectorScreen() {
372
373
  }
373
374
  };
374
375
 
375
- const footerHints = "↑↓ choose • tab toggle type • enter choose • esc exit";
376
+ const footerHints = [
377
+ { keys: ["↑", "↓"], label: "choose" },
378
+ { keys: ["tab"], label: "toggle type" },
379
+ { keys: ["enter"], label: "choose" },
380
+ { keys: ["esc"], label: "exit" },
381
+ ];
376
382
  // Available height calculation
377
383
  // Header: 1 line (title) + 1 line (search) + 1 line (separator) = 3 lines?
378
384
  // Title line: Switch Model /// (o) Large
@@ -429,7 +435,7 @@ export function ModelSelectorScreen() {
429
435
  </box>
430
436
 
431
437
  <box height={1}>
432
- <text fg="#888888">{footerHints}</text>
438
+ <FooterHints hints={footerHints} />
433
439
  </box>
434
440
  </box>
435
441
  );
@@ -374,7 +374,8 @@ export function PluginsScreen() {
374
374
  const hasMissing = (missing.pip?.length || 0) +
375
375
  (missing.brew?.length || 0) +
376
376
  (missing.npm?.length || 0) +
377
- (missing.cargo?.length || 0) >
377
+ (missing.cargo?.length || 0) +
378
+ (missing.go?.length || 0) >
378
379
  0;
379
380
  if (!hasMissing)
380
381
  return;
@@ -387,6 +388,8 @@ export function PluginsScreen() {
387
388
  parts.push(`npm: ${missing.npm.join(", ")}`);
388
389
  if (missing.cargo?.length)
389
390
  parts.push(`cargo: ${missing.cargo.join(", ")}`);
391
+ if (missing.go?.length)
392
+ parts.push(`go: ${missing.go.join(", ")}`);
390
393
  const wantInstall = await modal.confirm("Install Dependencies?", `This plugin needs system dependencies:\n\n${parts.join("\n")}\n\nInstall now?`);
391
394
  if (!wantInstall)
392
395
  return;
@@ -735,8 +738,19 @@ export function PluginsScreen() {
735
738
  }
736
739
  const selectedItem = selectableItems[pluginsState.selectedIndex];
737
740
  const footerHints = isSearchActive
738
- ? "type to filter │ Enter:done │ Esc:clear"
739
- : "u/p/l:toggle │ U:update a:all │ m:mismatches │ s:profile │ /:search";
741
+ ? [
742
+ { keys: ["type"], label: "filter" },
743
+ { keys: ["Enter"], label: "done" },
744
+ { keys: ["Esc"], label: "clear" },
745
+ ]
746
+ : [
747
+ { keys: ["u", "/", "p", "/", "l"], label: "toggle" },
748
+ { keys: ["U"], label: "update" },
749
+ { keys: ["a"], label: "all" },
750
+ { keys: ["m"], label: "mismatches" },
751
+ { keys: ["s"], label: "profile" },
752
+ { keys: ["/"], label: "search" },
753
+ ];
740
754
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
741
755
  const plugins = pluginsState.plugins.status === "success" ? pluginsState.plugins.data : [];
742
756
  const installedCount = plugins.filter((p) => p.enabled).length;
@@ -486,7 +486,8 @@ export function PluginsScreen() {
486
486
  (missing.pip?.length || 0) +
487
487
  (missing.brew?.length || 0) +
488
488
  (missing.npm?.length || 0) +
489
- (missing.cargo?.length || 0) >
489
+ (missing.cargo?.length || 0) +
490
+ (missing.go?.length || 0) >
490
491
  0;
491
492
 
492
493
  if (!hasMissing) return;
@@ -497,6 +498,7 @@ export function PluginsScreen() {
497
498
  if (missing.npm?.length) parts.push(`npm: ${missing.npm.join(", ")}`);
498
499
  if (missing.cargo?.length)
499
500
  parts.push(`cargo: ${missing.cargo.join(", ")}`);
501
+ if (missing.go?.length) parts.push(`go: ${missing.go.join(", ")}`);
500
502
 
501
503
  const wantInstall = await modal.confirm(
502
504
  "Install Dependencies?",
@@ -937,8 +939,19 @@ export function PluginsScreen() {
937
939
  const selectedItem = selectableItems[pluginsState.selectedIndex];
938
940
 
939
941
  const footerHints = isSearchActive
940
- ? "type to filter │ Enter:done │ Esc:clear"
941
- : "u/p/l:toggle │ U:update a:all │ m:mismatches │ s:profile │ /:search";
942
+ ? [
943
+ { keys: ["type"], label: "filter" },
944
+ { keys: ["Enter"], label: "done" },
945
+ { keys: ["Esc"], label: "clear" },
946
+ ]
947
+ : [
948
+ { keys: ["u", "/", "p", "/", "l"], label: "toggle" },
949
+ { keys: ["U"], label: "update" },
950
+ { keys: ["a"], label: "all" },
951
+ { keys: ["m"], label: "mismatches" },
952
+ { keys: ["s"], label: "profile" },
953
+ { keys: ["/"], label: "search" },
954
+ ];
942
955
 
943
956
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
944
957
  const plugins: PluginInfo[] =
@@ -286,6 +286,13 @@ export function ProfilesScreen() {
286
286
  const errorMessage = profilesState.profiles.status === "error"
287
287
  ? profilesState.profiles.error.message
288
288
  : undefined;
289
- return (_jsx(ScreenLayout, { title: "claudeup Plugin Profiles", currentScreen: "profiles", statusLine: statusContent, footerHints: "\u2191\u2193:nav \u2502 Enter/a:apply \u2502 r:rename \u2502 d:delete \u2502 c:copy \u2502 i:import", listPanel: _jsx(ScrollableList, { items: allItems, selectedIndex: effectiveIndex, renderItem: renderProfileRow, maxHeight: dimensions.listPanelHeight }), detailPanel: renderProfileDetail(allItems[effectiveIndex], loadingStatus, errorMessage) }));
289
+ return (_jsx(ScreenLayout, { title: "claudeup Plugin Profiles", currentScreen: "profiles", statusLine: statusContent, footerHints: [
290
+ { keys: ["↑", "↓"], label: "nav" },
291
+ { keys: ["Enter", "/", "a"], label: "apply" },
292
+ { keys: ["r"], label: "rename" },
293
+ { keys: ["d"], label: "delete" },
294
+ { keys: ["c"], label: "copy" },
295
+ { keys: ["i"], label: "import" },
296
+ ], listPanel: _jsx(ScrollableList, { items: allItems, selectedIndex: effectiveIndex, renderItem: renderProfileRow, maxHeight: dimensions.listPanelHeight }), detailPanel: renderProfileDetail(allItems[effectiveIndex], loadingStatus, errorMessage) }));
290
297
  }
291
298
  export default ProfilesScreen;
@@ -393,7 +393,14 @@ export function ProfilesScreen() {
393
393
  title="claudeup Plugin Profiles"
394
394
  currentScreen="profiles"
395
395
  statusLine={statusContent}
396
- footerHints="↑↓:nav │ Enter/a:apply │ r:rename │ d:delete │ c:copy │ i:import"
396
+ footerHints={[
397
+ { keys: ["↑", "↓"], label: "nav" },
398
+ { keys: ["Enter", "/", "a"], label: "apply" },
399
+ { keys: ["r"], label: "rename" },
400
+ { keys: ["d"], label: "delete" },
401
+ { keys: ["c"], label: "copy" },
402
+ { keys: ["i"], label: "import" },
403
+ ]}
397
404
  listPanel={
398
405
  <ScrollableList
399
406
  items={allItems}
@@ -42,6 +42,7 @@ export function SkillsScreen() {
42
42
  // ── Remote search (debounced, cached) ─────────────────────────────────────
43
43
  const [searchResults, setSearchResults] = useState([]);
44
44
  const [isSearchLoading, setIsSearchLoading] = useState(false);
45
+ const [searchError, setSearchError] = useState(null);
45
46
  const searchTimerRef = useRef(null);
46
47
  const searchCacheRef = useRef(new Map());
47
48
  useEffect(() => {
@@ -49,15 +50,18 @@ export function SkillsScreen() {
49
50
  if (query.length < 2) {
50
51
  setSearchResults([]);
51
52
  setIsSearchLoading(false);
53
+ setSearchError(null);
52
54
  return;
53
55
  }
54
56
  const cached = searchCacheRef.current.get(query);
55
57
  if (cached) {
56
58
  setSearchResults(cached);
57
59
  setIsSearchLoading(false);
60
+ setSearchError(null);
58
61
  return;
59
62
  }
60
63
  setIsSearchLoading(true);
64
+ setSearchError(null);
61
65
  if (searchTimerRef.current)
62
66
  clearTimeout(searchTimerRef.current);
63
67
  searchTimerRef.current = setTimeout(async () => {
@@ -86,9 +90,13 @@ export function SkillsScreen() {
86
90
  });
87
91
  searchCacheRef.current.set(query, mapped);
88
92
  setSearchResults(mapped);
93
+ setSearchError(null);
89
94
  }
90
- catch {
95
+ catch (error) {
96
+ // A thrown error means the search service is unreachable — distinct
97
+ // from a successful search that returned zero matches.
91
98
  setSearchResults([]);
99
+ setSearchError(error instanceof Error ? error.message : "Search service unavailable");
92
100
  }
93
101
  setIsSearchLoading(false);
94
102
  }, 400);
@@ -517,7 +525,7 @@ export function SkillsScreen() {
517
525
  const skills = skillsState.skills.status === "success" ? skillsState.skills.data : [];
518
526
  const installedCount = skills.filter((s) => s.installed).length;
519
527
  const query = skillsState.searchQuery.trim();
520
- const statusContent = statusMsg ? (_jsx("text", { children: _jsx("span", { fg: statusMsg.tone === "success" ? "green" : "red", children: statusMsg.text }) })) : (_jsxs("text", { children: [_jsx("span", { fg: "gray", children: "Skills: " }), _jsxs("span", { fg: "cyan", children: [installedCount, " installed"] }), query.length >= 2 && isSearchLoading && (_jsx("span", { fg: "yellow", children: " \u2502 searching..." })), query.length >= 2 && !isSearchLoading && searchResults.length > 0 && (_jsxs("span", { fg: "green", children: [" \u2502 ", searchResults.length, " found"] })), !query && _jsx("span", { fg: "gray", children: " \u2502 89K+ searchable" })] }));
528
+ const statusContent = statusMsg ? (_jsx("text", { children: _jsx("span", { fg: statusMsg.tone === "success" ? "green" : "red", children: statusMsg.text }) })) : (_jsxs("text", { children: [_jsx("span", { fg: "gray", children: "Skills: " }), _jsxs("span", { fg: "cyan", children: [installedCount, " installed"] }), query.length >= 2 && isSearchLoading && (_jsx("span", { fg: "yellow", children: " \u2502 searching..." })), query.length >= 2 && !isSearchLoading && !searchError && searchResults.length > 0 && (_jsxs("span", { fg: "green", children: [" \u2502 ", searchResults.length, " found"] })), query.length >= 2 && !isSearchLoading && searchError && (_jsx("span", { fg: "red", children: " \u2502 search unavailable" })), !query && _jsx("span", { fg: "gray", children: " \u2502 89K+ searchable" })] }));
521
529
  // ── Render ────────────────────────────────────────────────────────────────
522
530
  return (_jsx(ScreenLayout, { title: "claudeup Skills", currentScreen: "skills", statusLine: statusContent, search: skillsState.searchQuery || isSearchActive
523
531
  ? {
@@ -526,7 +534,16 @@ export function SkillsScreen() {
526
534
  placeholder: "type to search",
527
535
  }
528
536
  : undefined, footerHints: isSearchActive
529
- ? "type to filter │ Enter:done │ Esc:clear"
530
- : "u:user │ p:project │ o:open │ /:search", listPanel: _jsxs("box", { flexDirection: "column", children: [_jsx(ScrollableList, { items: allItems, selectedIndex: skillsState.selectedIndex, renderItem: renderSkillRow, maxHeight: dimensions.listPanelHeight, getKey: (item, index) => `${index}:${item.id}` }), !query && skillsState.skills.status === "loading" && (_jsx("box", { marginTop: 2, paddingLeft: 2, children: _jsx("text", { fg: "yellow", children: "Loading popular skills..." }) })), query.length >= 2 && isSearchLoading && (_jsx("box", { marginTop: 2, paddingLeft: 2, children: _jsxs("text", { fg: "yellow", children: ["Searching for \"", skillsState.searchQuery, "\"..."] }) })), query.length >= 2 && !isSearchLoading && searchResults.length === 0 && (_jsx(EmptyFilterState, { query: skillsState.searchQuery, entityName: "skills" }))] }), detailPanel: renderSkillDetail(selectedItem) }));
537
+ ? [
538
+ { keys: ["type"], label: "filter" },
539
+ { keys: ["Enter"], label: "done" },
540
+ { keys: ["Esc"], label: "clear" },
541
+ ]
542
+ : [
543
+ { keys: ["u"], label: "user" },
544
+ { keys: ["p"], label: "project" },
545
+ { keys: ["o"], label: "open" },
546
+ { keys: ["/"], label: "search" },
547
+ ], listPanel: _jsxs("box", { flexDirection: "column", children: [_jsx(ScrollableList, { items: allItems, selectedIndex: skillsState.selectedIndex, renderItem: renderSkillRow, maxHeight: dimensions.listPanelHeight, getKey: (item, index) => `${index}:${item.id}` }), !query && skillsState.skills.status === "loading" && (_jsx("box", { marginTop: 2, paddingLeft: 2, children: _jsx("text", { fg: "yellow", children: "Loading popular skills..." }) })), query.length >= 2 && isSearchLoading && (_jsx("box", { marginTop: 2, paddingLeft: 2, children: _jsxs("text", { fg: "yellow", children: ["Searching for \"", skillsState.searchQuery, "\"..."] }) })), query.length >= 2 && !isSearchLoading && searchError && (_jsxs("box", { flexDirection: "column", marginTop: 2, paddingLeft: 2, paddingRight: 2, children: [_jsx("text", { fg: "red", children: "Skill search is unavailable right now." }), _jsx("box", { marginTop: 1, children: _jsx("text", { fg: "gray", children: "The search service could not be reached" }) }), _jsxs("text", { fg: "gray", children: ["(", searchError, "). Check your connection and retry."] }), _jsx("box", { marginTop: 1, children: _jsx("text", { fg: "gray", children: "Press Esc to clear the search." }) })] })), query.length >= 2 && !isSearchLoading && !searchError && searchResults.length === 0 && (_jsx(EmptyFilterState, { query: skillsState.searchQuery, entityName: "skills" }))] }), detailPanel: renderSkillDetail(selectedItem) }));
531
548
  }
532
549
  export default SkillsScreen;
@@ -60,6 +60,7 @@ export function SkillsScreen() {
60
60
 
61
61
  const [searchResults, setSearchResults] = useState<SkillInfo[]>([]);
62
62
  const [isSearchLoading, setIsSearchLoading] = useState(false);
63
+ const [searchError, setSearchError] = useState<string | null>(null);
63
64
  const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
64
65
  const searchCacheRef = useRef<Map<string, SkillInfo[]>>(new Map());
65
66
 
@@ -68,6 +69,7 @@ export function SkillsScreen() {
68
69
  if (query.length < 2) {
69
70
  setSearchResults([]);
70
71
  setIsSearchLoading(false);
72
+ setSearchError(null);
71
73
  return;
72
74
  }
73
75
 
@@ -75,10 +77,12 @@ export function SkillsScreen() {
75
77
  if (cached) {
76
78
  setSearchResults(cached);
77
79
  setIsSearchLoading(false);
80
+ setSearchError(null);
78
81
  return;
79
82
  }
80
83
 
81
84
  setIsSearchLoading(true);
85
+ setSearchError(null);
82
86
 
83
87
  if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
84
88
  searchTimerRef.current = setTimeout(async () => {
@@ -107,8 +111,14 @@ export function SkillsScreen() {
107
111
  });
108
112
  searchCacheRef.current.set(query, mapped);
109
113
  setSearchResults(mapped);
110
- } catch {
114
+ setSearchError(null);
115
+ } catch (error) {
116
+ // A thrown error means the search service is unreachable — distinct
117
+ // from a successful search that returned zero matches.
111
118
  setSearchResults([]);
119
+ setSearchError(
120
+ error instanceof Error ? error.message : "Search service unavailable",
121
+ );
112
122
  }
113
123
  setIsSearchLoading(false);
114
124
  }, 400);
@@ -583,9 +593,12 @@ export function SkillsScreen() {
583
593
  {query.length >= 2 && isSearchLoading && (
584
594
  <span fg="yellow"> │ searching...</span>
585
595
  )}
586
- {query.length >= 2 && !isSearchLoading && searchResults.length > 0 && (
596
+ {query.length >= 2 && !isSearchLoading && !searchError && searchResults.length > 0 && (
587
597
  <span fg="green"> │ {searchResults.length} found</span>
588
598
  )}
599
+ {query.length >= 2 && !isSearchLoading && searchError && (
600
+ <span fg="red"> │ search unavailable</span>
601
+ )}
589
602
  {!query && <span fg="gray"> │ 89K+ searchable</span>}
590
603
  </text>
591
604
  );
@@ -608,8 +621,17 @@ export function SkillsScreen() {
608
621
  }
609
622
  footerHints={
610
623
  isSearchActive
611
- ? "type to filter │ Enter:done │ Esc:clear"
612
- : "u:user │ p:project o:open │ /:search"
624
+ ? [
625
+ { keys: ["type"], label: "filter" },
626
+ { keys: ["Enter"], label: "done" },
627
+ { keys: ["Esc"], label: "clear" },
628
+ ]
629
+ : [
630
+ { keys: ["u"], label: "user" },
631
+ { keys: ["p"], label: "project" },
632
+ { keys: ["o"], label: "open" },
633
+ { keys: ["/"], label: "search" },
634
+ ]
613
635
  }
614
636
  listPanel={
615
637
  <box flexDirection="column">
@@ -630,7 +652,19 @@ export function SkillsScreen() {
630
652
  <text fg="yellow">Searching for "{skillsState.searchQuery}"...</text>
631
653
  </box>
632
654
  )}
633
- {query.length >= 2 && !isSearchLoading && searchResults.length === 0 && (
655
+ {query.length >= 2 && !isSearchLoading && searchError && (
656
+ <box flexDirection="column" marginTop={2} paddingLeft={2} paddingRight={2}>
657
+ <text fg="red">Skill search is unavailable right now.</text>
658
+ <box marginTop={1}>
659
+ <text fg="gray">The search service could not be reached</text>
660
+ </box>
661
+ <text fg="gray">({searchError}). Check your connection and retry.</text>
662
+ <box marginTop={1}>
663
+ <text fg="gray">Press Esc to clear the search.</text>
664
+ </box>
665
+ </box>
666
+ )}
667
+ {query.length >= 2 && !isSearchLoading && !searchError && searchResults.length === 0 && (
634
668
  <EmptyFilterState query={skillsState.searchQuery} entityName="skills" />
635
669
  )}
636
670
  </box>
@@ -195,6 +195,12 @@ export function StatusLineScreen() {
195
195
  const statusContent = (_jsxs(_Fragment, { children: [_jsx("text", { fg: "gray", children: "Scope: " }), _jsx("text", { fg: "cyan", children: scopeLabel }), _jsx("text", { fg: "gray", children: " \u2502 Current: " }), _jsx("text", { fg: "green", children: currentValue
196
196
  ? currentValue.slice(0, 35) + (currentValue.length > 35 ? "..." : "")
197
197
  : "(not set)" })] }));
198
- return (_jsx(ScreenLayout, { title: "claudeup Status Line", currentScreen: "statusline", statusLine: statusContent, footerHints: "\u2191\u2193:nav \u2502 Enter:apply \u2502 p:project \u2502 g:global \u2502 r:reset", listPanel: _jsx(ScrollableList, { items: listItems, selectedIndex: statusLine.selectedIndex, renderItem: renderListItem, maxHeight: dimensions.listPanelHeight }), detailPanel: renderPreview() }));
198
+ return (_jsx(ScreenLayout, { title: "claudeup Status Line", currentScreen: "statusline", statusLine: statusContent, footerHints: [
199
+ { keys: ["↑", "↓"], label: "nav" },
200
+ { keys: ["Enter"], label: "apply" },
201
+ { keys: ["p"], label: "project" },
202
+ { keys: ["g"], label: "global" },
203
+ { keys: ["r"], label: "reset" },
204
+ ], listPanel: _jsx(ScrollableList, { items: listItems, selectedIndex: statusLine.selectedIndex, renderItem: renderListItem, maxHeight: dimensions.listPanelHeight }), detailPanel: renderPreview() }));
199
205
  }
200
206
  export default StatusLineScreen;
@@ -393,7 +393,13 @@ export function StatusLineScreen() {
393
393
  title="claudeup Status Line"
394
394
  currentScreen={"statusline" as never}
395
395
  statusLine={statusContent}
396
- footerHints="↑↓:nav │ Enter:apply │ p:project │ g:global │ r:reset"
396
+ footerHints={[
397
+ { keys: ["↑", "↓"], label: "nav" },
398
+ { keys: ["Enter"], label: "apply" },
399
+ { keys: ["p"], label: "project" },
400
+ { keys: ["g"], label: "global" },
401
+ { keys: ["r"], label: "reset" },
402
+ ]}
397
403
  listPanel={
398
404
  <ScrollableList
399
405
  items={listItems}
@@ -7,3 +7,4 @@ export { ModelSelectorScreen } from "./ModelSelectorScreen.js";
7
7
  export { ProfilesScreen } from "./ProfilesScreen.js";
8
8
  export { SkillsScreen } from "./SkillsScreen.js";
9
9
  export { GitignoreScreen } from "./GitignoreScreen.js";
10
+ export { AliasScreen } from "./AliasScreen.js";
@@ -7,3 +7,4 @@ export { ModelSelectorScreen } from "./ModelSelectorScreen.js";
7
7
  export { ProfilesScreen } from "./ProfilesScreen.js";
8
8
  export { SkillsScreen } from "./SkillsScreen.js";
9
9
  export { GitignoreScreen } from "./GitignoreScreen.js";
10
+ export { AliasScreen } from "./AliasScreen.js";
@@ -7,6 +7,7 @@ export const initialState = {
7
7
  modal: null,
8
8
  progress: null,
9
9
  isSearching: false,
10
+ detailActive: false,
10
11
  // Refresh counter for triggering data refetch
11
12
  dataRefreshVersion: 0,
12
13
  // Screen-specific state
@@ -68,8 +69,9 @@ export function appReducer(state, action) {
68
69
  return {
69
70
  ...state,
70
71
  currentRoute: action.route,
71
- // Clear search state when navigating away
72
+ // Clear transient sub-view state when navigating away
72
73
  isSearching: false,
74
+ detailActive: false,
73
75
  };
74
76
  // =========================================================================
75
77
  // Plugins Screen
@@ -532,6 +534,8 @@ export function appReducer(state, action) {
532
534
  // =========================================================================
533
535
  case "SET_SEARCHING":
534
536
  return { ...state, isSearching: action.isSearching };
537
+ case "SET_DETAIL_ACTIVE":
538
+ return { ...state, detailActive: action.detailActive };
535
539
  // =========================================================================
536
540
  // Data Refresh
537
541
  // =========================================================================
@@ -11,6 +11,7 @@ export const initialState: AppState = {
11
11
  modal: null,
12
12
  progress: null,
13
13
  isSearching: false,
14
+ detailActive: false,
14
15
 
15
16
  // Refresh counter for triggering data refetch
16
17
  dataRefreshVersion: 0,
@@ -83,8 +84,9 @@ export function appReducer(state: AppState, action: AppAction): AppState {
83
84
  return {
84
85
  ...state,
85
86
  currentRoute: action.route,
86
- // Clear search state when navigating away
87
+ // Clear transient sub-view state when navigating away
87
88
  isSearching: false,
89
+ detailActive: false,
88
90
  };
89
91
 
90
92
  // =========================================================================
@@ -603,6 +605,9 @@ export function appReducer(state: AppState, action: AppAction): AppState {
603
605
  case "SET_SEARCHING":
604
606
  return { ...state, isSearching: action.isSearching };
605
607
 
608
+ case "SET_DETAIL_ACTIVE":
609
+ return { ...state, detailActive: action.detailActive };
610
+
606
611
  // =========================================================================
607
612
  // Data Refresh
608
613
  // =========================================================================
@@ -21,7 +21,8 @@ export type Screen =
21
21
  | "model-selector"
22
22
  | "profiles"
23
23
  | "skills"
24
- | "gitignore";
24
+ | "gitignore"
25
+ | "alias";
25
26
 
26
27
  export type Route =
27
28
  | { screen: "plugins" }
@@ -32,7 +33,8 @@ export type Route =
32
33
  | { screen: "model-selector" }
33
34
  | { screen: "profiles" }
34
35
  | { screen: "skills" }
35
- | { screen: "gitignore" };
36
+ | { screen: "gitignore" }
37
+ | { screen: "alias" };
36
38
 
37
39
  // ============================================================================
38
40
  // Async Data Types
@@ -205,6 +207,11 @@ export interface AppState {
205
207
  modal: ModalState | null;
206
208
  progress: ProgressState | null;
207
209
  isSearching: boolean;
210
+ // True while a screen has a focused sub-view open that owns Escape (e.g.
211
+ // the Alias tab's flag-detail editor). The global key handler skips its
212
+ // Escape→navigate branch so Escape backs out of the sub-view instead of
213
+ // jumping to the home tab. Mirrors how `modal`/`isSearching` gate keys.
214
+ detailActive: boolean;
208
215
 
209
216
  // Refresh counter - incremented when marketplace sync completes
210
217
  // Screens can watch this to know when to refetch data
@@ -305,6 +312,9 @@ export type AppAction =
305
312
  // Search state (blocks global key handlers)
306
313
  | { type: "SET_SEARCHING"; isSearching: boolean }
307
314
 
315
+ // Detail sub-view state (gates global Escape→navigate)
316
+ | { type: "SET_DETAIL_ACTIVE"; detailActive: boolean }
317
+
308
318
  // Profiles screen
309
319
  | { type: "PROFILES_SELECT"; index: number }
310
320
  | { type: "PROFILES_DATA_LOADING" }