claudeup 3.7.2 → 3.9.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 (52) hide show
  1. package/package.json +1 -1
  2. package/src/data/settings-catalog.js +612 -0
  3. package/src/data/settings-catalog.ts +689 -0
  4. package/src/data/skill-repos.js +86 -0
  5. package/src/data/skill-repos.ts +97 -0
  6. package/src/services/plugin-manager.js +2 -0
  7. package/src/services/plugin-manager.ts +3 -0
  8. package/src/services/profiles.js +161 -0
  9. package/src/services/profiles.ts +225 -0
  10. package/src/services/settings-manager.js +108 -0
  11. package/src/services/settings-manager.ts +140 -0
  12. package/src/services/skills-manager.js +239 -0
  13. package/src/services/skills-manager.ts +328 -0
  14. package/src/services/skillsmp-client.js +67 -0
  15. package/src/services/skillsmp-client.ts +89 -0
  16. package/src/types/index.ts +101 -1
  17. package/src/ui/App.js +23 -18
  18. package/src/ui/App.tsx +27 -23
  19. package/src/ui/components/TabBar.js +9 -8
  20. package/src/ui/components/TabBar.tsx +15 -19
  21. package/src/ui/components/layout/ScreenLayout.js +8 -14
  22. package/src/ui/components/layout/ScreenLayout.tsx +51 -58
  23. package/src/ui/components/modals/ModalContainer.js +43 -11
  24. package/src/ui/components/modals/ModalContainer.tsx +44 -12
  25. package/src/ui/components/modals/SelectModal.js +4 -18
  26. package/src/ui/components/modals/SelectModal.tsx +10 -21
  27. package/src/ui/screens/CliToolsScreen.js +2 -2
  28. package/src/ui/screens/CliToolsScreen.tsx +8 -8
  29. package/src/ui/screens/EnvVarsScreen.js +248 -116
  30. package/src/ui/screens/EnvVarsScreen.tsx +419 -184
  31. package/src/ui/screens/McpRegistryScreen.tsx +18 -6
  32. package/src/ui/screens/McpScreen.js +1 -1
  33. package/src/ui/screens/McpScreen.tsx +15 -5
  34. package/src/ui/screens/ModelSelectorScreen.js +3 -5
  35. package/src/ui/screens/ModelSelectorScreen.tsx +12 -16
  36. package/src/ui/screens/PluginsScreen.js +154 -66
  37. package/src/ui/screens/PluginsScreen.tsx +280 -97
  38. package/src/ui/screens/ProfilesScreen.js +255 -0
  39. package/src/ui/screens/ProfilesScreen.tsx +487 -0
  40. package/src/ui/screens/SkillsScreen.js +325 -0
  41. package/src/ui/screens/SkillsScreen.tsx +574 -0
  42. package/src/ui/screens/StatusLineScreen.js +2 -2
  43. package/src/ui/screens/StatusLineScreen.tsx +10 -12
  44. package/src/ui/screens/index.js +3 -2
  45. package/src/ui/screens/index.ts +3 -2
  46. package/src/ui/state/AppContext.js +2 -1
  47. package/src/ui/state/AppContext.tsx +2 -0
  48. package/src/ui/state/reducer.js +151 -19
  49. package/src/ui/state/reducer.ts +167 -19
  50. package/src/ui/state/types.ts +58 -14
  51. package/src/utils/clipboard.js +56 -0
  52. package/src/utils/clipboard.ts +58 -0
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, StatusLineScreen, EnvVarsScreen, CliToolsScreen, ModelSelectorScreen, } from "./screens/index.js";
8
+ import { PluginsScreen, McpScreen, McpRegistryScreen, SettingsScreen, CliToolsScreen, ModelSelectorScreen, ProfilesScreen, SkillsScreen, } from "./screens/index.js";
9
9
  import { repairAllMarketplaces } from "../services/local-marketplace.js";
10
10
  import { migrateMarketplaceRename } from "../services/claude-settings.js";
11
11
  import { checkForUpdates, getCurrentVersion, } from "../services/version-check.js";
@@ -26,14 +26,16 @@ function Router() {
26
26
  return _jsx(McpScreen, {});
27
27
  case "mcp-registry":
28
28
  return _jsx(McpRegistryScreen, {});
29
- case "statusline":
30
- return _jsx(StatusLineScreen, {});
31
- case "env-vars":
32
- return _jsx(EnvVarsScreen, {});
29
+ case "settings":
30
+ return _jsx(SettingsScreen, {});
33
31
  case "cli-tools":
34
32
  return _jsx(CliToolsScreen, {});
35
33
  case "model-selector":
36
34
  return _jsx(ModelSelectorScreen, {});
35
+ case "profiles":
36
+ return _jsx(ProfilesScreen, {});
37
+ case "skills":
38
+ return _jsx(SkillsScreen, {});
37
39
  default:
38
40
  return _jsx(PluginsScreen, {});
39
41
  }
@@ -80,10 +82,11 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
80
82
  "plugins",
81
83
  "mcp",
82
84
  "mcp-registry",
83
- "statusline",
84
- "env-vars",
85
+ "settings",
85
86
  "cli-tools",
86
87
  "model-selector",
88
+ "profiles",
89
+ "skills",
87
90
  ].includes(state.currentRoute.screen);
88
91
  if (isTopLevel) {
89
92
  if (input === "1")
@@ -91,19 +94,22 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
91
94
  else if (input === "2")
92
95
  navigateToScreen("mcp");
93
96
  else if (input === "3")
94
- navigateToScreen("statusline");
97
+ navigateToScreen("settings");
95
98
  else if (input === "4")
96
- navigateToScreen("env-vars");
97
- else if (input === "5")
98
99
  navigateToScreen("cli-tools");
100
+ else if (input === "5")
101
+ navigateToScreen("profiles");
102
+ else if (input === "6")
103
+ navigateToScreen("skills");
99
104
  // Tab navigation cycling
100
105
  if (key.tab) {
101
106
  const screens = [
102
107
  "plugins",
103
108
  "mcp",
104
- "statusline",
105
- "env-vars",
109
+ "settings",
106
110
  "cli-tools",
111
+ "profiles",
112
+ "skills",
107
113
  ];
108
114
  const currentIndex = screens.indexOf(state.currentRoute.screen);
109
115
  if (currentIndex !== -1) {
@@ -138,9 +144,9 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
138
144
  ? This help
139
145
 
140
146
  Quick Navigation
141
- 1 Plugins 4 Env Vars
142
- 2 MCP Servers 5 CLI Tools
143
- 3 Status Line
147
+ 1 Plugins 4 CLI Tools
148
+ 2 MCP Servers 5 Profiles
149
+ 3 Settings 6 Skills
144
150
 
145
151
  Plugin Actions
146
152
  u Update d Uninstall
@@ -157,7 +163,7 @@ MCP Servers
157
163
  * UpdateBanner Component
158
164
  * Shows version update notification
159
165
  */
160
- function UpdateBanner({ result, }) {
166
+ function UpdateBanner({ result }) {
161
167
  if (!result.updateAvailable)
162
168
  return null;
163
169
  return (_jsxs("box", { paddingLeft: 1, paddingRight: 1, children: [_jsx("text", { bg: "yellow", fg: "black", children: _jsx("strong", { children: " UPDATE " }) }), _jsxs("text", { fg: "yellow", children: [" ", "v", result.currentVersion, " \u2192 v", result.latestVersion] }), _jsx("text", { fg: "gray", children: " Run: " }), _jsx("text", { fg: "cyan", children: "npm i -g claudeup" })] }));
@@ -183,8 +189,7 @@ function AppContentInner({ showDebug, onDebugToggle, updateInfo, onExit, }) {
183
189
  state: { message: "Scanning marketplaces..." },
184
190
  });
185
191
  // Migrate old marketplace names → magus (idempotent), then repair plugin.json files
186
- migrateMarketplaceRename()
187
- .catch(() => { }); // non-blocking, best-effort
192
+ migrateMarketplaceRename().catch(() => { }); // non-blocking, best-effort
188
193
  repairAllMarketplaces()
189
194
  .then(async () => {
190
195
  dispatch({ type: "HIDE_PROGRESS" });
package/src/ui/App.tsx CHANGED
@@ -16,10 +16,11 @@ import {
16
16
  PluginsScreen,
17
17
  McpScreen,
18
18
  McpRegistryScreen,
19
- StatusLineScreen,
20
- EnvVarsScreen,
19
+ SettingsScreen,
21
20
  CliToolsScreen,
22
21
  ModelSelectorScreen,
22
+ ProfilesScreen,
23
+ SkillsScreen,
23
24
  } from "./screens/index.js";
24
25
  import type { Screen } from "./state/types.js";
25
26
  import { repairAllMarketplaces } from "../services/local-marketplace.js";
@@ -49,14 +50,16 @@ function Router() {
49
50
  return <McpScreen />;
50
51
  case "mcp-registry":
51
52
  return <McpRegistryScreen />;
52
- case "statusline":
53
- return <StatusLineScreen />;
54
- case "env-vars":
55
- return <EnvVarsScreen />;
53
+ case "settings":
54
+ return <SettingsScreen />;
56
55
  case "cli-tools":
57
56
  return <CliToolsScreen />;
58
57
  case "model-selector":
59
58
  return <ModelSelectorScreen />;
59
+ case "profiles":
60
+ return <ProfilesScreen />;
61
+ case "skills":
62
+ return <SkillsScreen />;
60
63
  default:
61
64
  return <PluginsScreen />;
62
65
  }
@@ -112,27 +115,30 @@ function GlobalKeyHandler({
112
115
  "plugins",
113
116
  "mcp",
114
117
  "mcp-registry",
115
- "statusline",
116
- "env-vars",
118
+ "settings",
117
119
  "cli-tools",
118
120
  "model-selector",
121
+ "profiles",
122
+ "skills",
119
123
  ].includes(state.currentRoute.screen);
120
124
 
121
125
  if (isTopLevel) {
122
126
  if (input === "1") navigateToScreen("plugins");
123
127
  else if (input === "2") navigateToScreen("mcp");
124
- else if (input === "3") navigateToScreen("statusline");
125
- else if (input === "4") navigateToScreen("env-vars");
126
- else if (input === "5") navigateToScreen("cli-tools");
128
+ else if (input === "3") navigateToScreen("settings");
129
+ else if (input === "4") navigateToScreen("cli-tools");
130
+ else if (input === "5") navigateToScreen("profiles");
131
+ else if (input === "6") navigateToScreen("skills");
127
132
 
128
133
  // Tab navigation cycling
129
134
  if (key.tab) {
130
135
  const screens: Screen[] = [
131
136
  "plugins",
132
137
  "mcp",
133
- "statusline",
134
- "env-vars",
138
+ "settings",
135
139
  "cli-tools",
140
+ "profiles",
141
+ "skills",
136
142
  ];
137
143
  const currentIndex = screens.indexOf(
138
144
  state.currentRoute.screen as Screen,
@@ -171,9 +177,9 @@ function GlobalKeyHandler({
171
177
  ? This help
172
178
 
173
179
  Quick Navigation
174
- 1 Plugins 4 Env Vars
175
- 2 MCP Servers 5 CLI Tools
176
- 3 Status Line
180
+ 1 Plugins 4 CLI Tools
181
+ 2 MCP Servers 5 Profiles
182
+ 3 Settings 6 Skills
177
183
 
178
184
  Plugin Actions
179
185
  u Update d Uninstall
@@ -194,13 +200,11 @@ MCP Servers
194
200
  * UpdateBanner Component
195
201
  * Shows version update notification
196
202
  */
197
- function UpdateBanner({
198
- result,
199
- }: { result: VersionCheckResult }) {
203
+ function UpdateBanner({ result }: { result: VersionCheckResult }) {
200
204
  if (!result.updateAvailable) return null;
201
205
 
202
206
  return (
203
- <box paddingLeft={1} paddingRight={1} >
207
+ <box paddingLeft={1} paddingRight={1}>
204
208
  <text bg="yellow" fg="black">
205
209
  <strong> UPDATE </strong>
206
210
  </text>
@@ -266,8 +270,7 @@ function AppContentInner({
266
270
  });
267
271
 
268
272
  // Migrate old marketplace names → magus (idempotent), then repair plugin.json files
269
- migrateMarketplaceRename()
270
- .catch(() => {}); // non-blocking, best-effort
273
+ migrateMarketplaceRename().catch(() => {}); // non-blocking, best-effort
271
274
 
272
275
  repairAllMarketplaces()
273
276
  .then(async () => {
@@ -295,7 +298,8 @@ function AppContentInner({
295
298
  <box
296
299
  flexDirection="column"
297
300
  height={dimensions.contentHeight}
298
- paddingLeft={1} paddingRight={1}
301
+ paddingLeft={1}
302
+ paddingRight={1}
299
303
  >
300
304
  <Router />
301
305
  </box>
@@ -1,13 +1,14 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
2
- import { useKeyboardHandler } from '../hooks/useKeyboardHandler';
2
+ import { useKeyboardHandler } from "../hooks/useKeyboardHandler";
3
3
  const TABS = [
4
- { key: '1', label: 'Plugins', screen: 'plugins' },
5
- { key: '2', label: 'MCP', screen: 'mcp' },
6
- { key: '3', label: 'Status', screen: 'statusline' },
7
- { key: '4', label: 'Env', screen: 'env-vars' },
8
- { key: '5', label: 'CLI', screen: 'cli-tools' },
4
+ { key: "1", label: "Plugins", screen: "plugins" },
5
+ { key: "2", label: "MCP", screen: "mcp" },
6
+ { key: "3", label: "Settings", screen: "settings" },
7
+ { key: "4", label: "CLI", screen: "cli-tools" },
8
+ { key: "5", label: "Profiles", screen: "profiles" },
9
+ { key: "6", label: "Skills", screen: "skills" },
9
10
  ];
10
- export function TabBar({ currentScreen, onTabChange, }) {
11
+ export function TabBar({ currentScreen, onTabChange }) {
11
12
  // Handle number key shortcuts (1-5)
12
13
  useKeyboardHandler((input, key) => {
13
14
  if (!onTabChange)
@@ -32,7 +33,7 @@ export function TabBar({ currentScreen, onTabChange, }) {
32
33
  return (_jsx("box", { flexDirection: "row", gap: 0, children: TABS.map((tab, index) => {
33
34
  const isSelected = tab.screen === currentScreen;
34
35
  const isLast = index === TABS.length - 1;
35
- return (_jsxs("box", { flexDirection: "row", children: [isSelected ? (_jsx("box", { children: _jsx("text", { bg: "#7e57c2", fg: "white", children: _jsxs("strong", { children: [' ', tab.key, ":", tab.label, ' '] }) }) })) : (_jsx("box", { children: _jsxs("text", { fg: "gray", children: [' ', tab.key, ":", tab.label, ' '] }) })), !isLast && (_jsx("text", { fg: "#666666", children: "\u2502" }))] }, tab.key));
36
+ return (_jsxs("box", { flexDirection: "row", children: [isSelected ? (_jsx("box", { children: _jsx("text", { bg: "#7e57c2", fg: "white", children: _jsxs("strong", { children: [" ", tab.key, ":", tab.label, " "] }) }) })) : (_jsx("box", { children: _jsxs("text", { fg: "gray", children: [" ", tab.key, ":", tab.label, " "] }) })), !isLast && _jsx("text", { fg: "#666666", children: "\u2502" })] }, tab.key));
36
37
  }) }));
37
38
  }
38
39
  export default TabBar;
@@ -1,6 +1,6 @@
1
- import React from 'react';
2
- import { useKeyboardHandler } from '../hooks/useKeyboardHandler';
3
- import type { Screen } from '../state/types.js';
1
+ import React from "react";
2
+ import { useKeyboardHandler } from "../hooks/useKeyboardHandler";
3
+ import type { Screen } from "../state/types.js";
4
4
 
5
5
  interface Tab {
6
6
  key: string;
@@ -9,11 +9,12 @@ interface Tab {
9
9
  }
10
10
 
11
11
  const TABS: Tab[] = [
12
- { key: '1', label: 'Plugins', screen: 'plugins' },
13
- { key: '2', label: 'MCP', screen: 'mcp' },
14
- { key: '3', label: 'Status', screen: 'statusline' },
15
- { key: '4', label: 'Env', screen: 'env-vars' },
16
- { key: '5', label: 'CLI', screen: 'cli-tools' },
12
+ { key: "1", label: "Plugins", screen: "plugins" },
13
+ { key: "2", label: "MCP", screen: "mcp" },
14
+ { key: "3", label: "Settings", screen: "settings" },
15
+ { key: "4", label: "CLI", screen: "cli-tools" },
16
+ { key: "5", label: "Profiles", screen: "profiles" },
17
+ { key: "6", label: "Skills", screen: "skills" },
17
18
  ];
18
19
 
19
20
  interface TabBarProps {
@@ -21,10 +22,7 @@ interface TabBarProps {
21
22
  onTabChange?: (screen: Screen) => void;
22
23
  }
23
24
 
24
- export function TabBar({
25
- currentScreen,
26
- onTabChange,
27
- }: TabBarProps) {
25
+ export function TabBar({ currentScreen, onTabChange }: TabBarProps) {
28
26
  // Handle number key shortcuts (1-5)
29
27
  useKeyboardHandler((input, key) => {
30
28
  if (!onTabChange) return;
@@ -61,23 +59,21 @@ export function TabBar({
61
59
  <box>
62
60
  <text bg="#7e57c2" fg="white">
63
61
  <strong>
64
- {' '}
65
- {tab.key}:{tab.label}{' '}
62
+ {" "}
63
+ {tab.key}:{tab.label}{" "}
66
64
  </strong>
67
65
  </text>
68
66
  </box>
69
67
  ) : (
70
68
  <box>
71
69
  <text fg="gray">
72
- {' '}
73
- {tab.key}:{tab.label}{' '}
70
+ {" "}
71
+ {tab.key}:{tab.label}{" "}
74
72
  </text>
75
73
  </box>
76
74
  )}
77
75
  {/* Separator */}
78
- {!isLast && (
79
- <text fg="#666666">│</text>
80
- )}
76
+ {!isLast && <text fg="#666666">│</text>}
81
77
  </box>
82
78
  );
83
79
  })}
@@ -1,21 +1,15 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
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
4
  const HEADER_COLOR = "#7e57c2";
5
5
  export function ScreenLayout({ title, subtitle, currentScreen, search, statusLine, footerHints, listPanel, detailPanel, }) {
6
6
  const dimensions = useDimensions();
7
- // Calculate panel heights
8
- // Header: 4 lines (border + title + status/search + border)
9
- // Footer: 2 lines (border-top + content)
10
- const headerHeight = 4;
11
- const footerHeight = 2;
12
- const panelHeight = Math.max(5, dimensions.contentHeight - headerHeight - footerHeight);
13
- return (_jsxs("box", { flexDirection: "column", height: dimensions.contentHeight, children: [_jsxs("box", { flexDirection: "column", border: true, borderStyle: "single", borderColor: HEADER_COLOR, padding: 1, marginBottom: 0, children: [_jsxs("box", { flexDirection: "row", justifyContent: "space-between", children: [_jsx("text", { fg: HEADER_COLOR, children: _jsx("strong", { children: title }) }), subtitle && _jsx("text", { fg: "gray", children: subtitle })] }), _jsx("box", { flexDirection: "row", marginTop: 0, children: search ? (
14
- // Search mode
15
- _jsxs(_Fragment, { children: [_jsx("text", { fg: "green", children: "> " }), search.isActive ? (_jsxs(_Fragment, { children: [_jsx("text", { fg: "white", children: search.query }), _jsx("text", { bg: "white", fg: "black", children: " " })] })) : (_jsx("text", { fg: "gray", children: search.query || search.placeholder || "/" }))] })) : statusLine ? (
16
- // Custom status line
17
- statusLine) : (
18
- // Default empty status
19
- _jsx("text", { fg: "gray", children: "\u2500" })) })] }), _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", { flexDirection: "column", width: "50%", height: panelHeight, paddingLeft: 1, children: detailPanel })] }), _jsxs("box", { height: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsx("text", { fg: "gray", children: footerHints }), _jsx(TabBar, { currentScreen: currentScreen })] })] }));
7
+ const hasSearchBar = search && (search.isActive || search.query);
8
+ // Fixed chrome: top line + tabs + line + header + separator + footer = 6
9
+ // Search bar adds 1 when active
10
+ const fixedHeight = 6 + (hasSearchBar ? 1 : 0);
11
+ const panelHeight = Math.max(5, dimensions.contentHeight - fixedHeight);
12
+ 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", { flexDirection: "column", width: "50%", height: panelHeight, paddingLeft: 1, children: detailPanel })] }), _jsx("box", { height: 1, paddingLeft: 1, children: _jsx("text", { fg: "gray", children: footerHints }) })] }));
20
14
  }
21
15
  export default ScreenLayout;
@@ -43,60 +43,62 @@ export function ScreenLayout({
43
43
  }: ScreenLayoutProps) {
44
44
  const dimensions = useDimensions();
45
45
 
46
- // Calculate panel heights
47
- // Header: 4 lines (border + title + status/search + border)
48
- // Footer: 2 lines (border-top + content)
49
- const headerHeight = 4;
50
- const footerHeight = 2;
51
- const panelHeight = Math.max(
52
- 5,
53
- dimensions.contentHeight - headerHeight - footerHeight,
54
- );
46
+ const hasSearchBar = search && (search.isActive || search.query);
47
+
48
+ // Fixed chrome: top line + tabs + line + header + separator + footer = 6
49
+ // Search bar adds 1 when active
50
+ const fixedHeight = 6 + (hasSearchBar ? 1 : 0);
51
+ const panelHeight = Math.max(5, dimensions.contentHeight - fixedHeight);
52
+ const lineWidth = Math.max(10, dimensions.terminalWidth - 4);
55
53
 
56
54
  return (
57
55
  <box flexDirection="column" height={dimensions.contentHeight}>
58
- {/* Header */}
59
- <box
60
- flexDirection="column"
61
- border
62
- borderStyle="single"
63
- borderColor={HEADER_COLOR}
64
- padding={1}
65
- marginBottom={0}
66
- >
67
- {/* Title row */}
68
- <box flexDirection="row" justifyContent="space-between">
69
- <text fg={HEADER_COLOR}>
70
- <strong>{title}</strong>
71
- </text>
72
- {subtitle && <text fg="gray">{subtitle}</text>}
73
- </box>
56
+ {/* Line above tabs */}
57
+ <box height={1} paddingLeft={1} paddingRight={1}>
58
+ <text fg="#333333">{"─".repeat(lineWidth)}</text>
59
+ </box>
60
+
61
+ {/* Tab bar */}
62
+ <box height={1} paddingLeft={1} paddingRight={1}>
63
+ <TabBar currentScreen={currentScreen} />
64
+ </box>
65
+
66
+ {/* Line below tabs */}
67
+ <box height={1} paddingLeft={1} paddingRight={1}>
68
+ <text fg="#333333">{"─".repeat(lineWidth)}</text>
69
+ </box>
74
70
 
75
- {/* Status/Search row - always present */}
76
- <box flexDirection="row" marginTop={0}>
77
- {search ? (
78
- // Search mode
79
- <>
80
- <text fg="green">{"> "}</text>
81
- {search.isActive ? (
82
- <>
83
- <text fg="white">{search.query}</text>
84
- <text bg="white" fg="black"> </text>
85
- </>
86
- ) : (
87
- <text fg="gray">
88
- {search.query || search.placeholder || "/"}
89
- </text>
90
- )}
91
- </>
92
- ) : statusLine ? (
93
- // Custom status line
94
- statusLine
71
+ {/* Header title on left, subtitle/status on right */}
72
+ <box height={1} paddingLeft={1} paddingRight={1} flexDirection="row" justifyContent="space-between">
73
+ <text fg={HEADER_COLOR}>
74
+ <strong>{title}</strong>
75
+ </text>
76
+ {subtitle && <text fg="gray">{subtitle}</text>}
77
+ {!subtitle && statusLine ? statusLine : null}
78
+ </box>
79
+
80
+ {/* Search bar — own line, only when active */}
81
+ {hasSearchBar && (
82
+ <box height={1} paddingLeft={1} paddingRight={1}>
83
+ {search.isActive ? (
84
+ <text>
85
+ <span fg="green">{"Filter: "}</span>
86
+ <span fg="white">{search.query}</span>
87
+ <span bg="white" fg="black"> </span>
88
+ </text>
95
89
  ) : (
96
- // Default empty status
97
- <text fg="gray">─</text>
90
+ <text>
91
+ <span fg="green">{"Filter: "}</span>
92
+ <span fg="yellow">{search.query}</span>
93
+ <span fg="gray"> (Esc to clear)</span>
94
+ </text>
98
95
  )}
99
96
  </box>
97
+ )}
98
+
99
+ {/* Separator below header */}
100
+ <box height={1} paddingLeft={1} paddingRight={1}>
101
+ <text fg="#444444">{"─".repeat(lineWidth)}</text>
100
102
  </box>
101
103
 
102
104
  {/* Main content area */}
@@ -112,11 +114,7 @@ export function ScreenLayout({
112
114
  </box>
113
115
 
114
116
  {/* Vertical separator */}
115
- <box
116
- flexDirection="column"
117
- width={1}
118
- height={panelHeight}
119
- >
117
+ <box flexDirection="column" width={1} height={panelHeight}>
120
118
  <text fg="#444444">{"│".repeat(panelHeight)}</text>
121
119
  </box>
122
120
 
@@ -132,13 +130,8 @@ export function ScreenLayout({
132
130
  </box>
133
131
 
134
132
  {/* Footer */}
135
- <box
136
- height={1}
137
- flexDirection="row"
138
- justifyContent="space-between"
139
- >
133
+ <box height={1} paddingLeft={1}>
140
134
  <text fg="gray">{footerHints}</text>
141
- <TabBar currentScreen={currentScreen} />
142
135
  </box>
143
136
  </box>
144
137
  );
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import React, { useState } from "react";
2
3
  import { useApp } from "../../state/AppContext.js";
3
4
  import { useKeyboard } from "../../hooks/useKeyboard.js";
4
5
  import { ConfirmModal } from "./ConfirmModal.js";
@@ -8,25 +9,56 @@ import { MessageModal } from "./MessageModal.js";
8
9
  import { LoadingModal } from "./LoadingModal.js";
9
10
  /**
10
11
  * Container that renders the active modal as an overlay
11
- * Handles global Escape key to close modals
12
+ * Handles ALL keyboard events when a modal is open to avoid
13
+ * conflicts with multiple useKeyboard hooks in child components
12
14
  */
13
15
  export function ModalContainer() {
14
16
  const { state } = useApp();
15
17
  const { modal } = state;
16
- // Handle Escape key to close modal (except loading)
18
+ // Track select modal index here (lifted from SelectModal)
19
+ const [selectIndex, setSelectIndex] = useState(0);
20
+ // Reset select index when modal changes
21
+ const modalRef = React.useRef(modal);
22
+ if (modal !== modalRef.current) {
23
+ modalRef.current = modal;
24
+ if (modal?.type === "select") {
25
+ setSelectIndex(modal.defaultIndex ?? 0);
26
+ }
27
+ }
28
+ // Handle ALL keyboard events for modals
17
29
  useKeyboard((key) => {
18
- if (key.name === "escape" && modal && modal.type !== "loading") {
19
- // Loading modal cannot be dismissed with Escape
20
- if (modal.type === "confirm") {
30
+ if (!modal)
31
+ return;
32
+ if (modal.type === "loading")
33
+ return;
34
+ // Escape — close any modal
35
+ if (key.name === "escape" || key.name === "q") {
36
+ if (modal.type === "confirm")
21
37
  modal.onCancel();
22
- }
23
- else if (modal.type === "input") {
38
+ else if (modal.type === "input")
24
39
  modal.onCancel();
25
- }
26
- else if (modal.type === "select") {
40
+ else if (modal.type === "select")
27
41
  modal.onCancel();
42
+ else if (modal.type === "message")
43
+ modal.onDismiss();
44
+ return;
45
+ }
46
+ // Select modal — handle navigation and selection
47
+ if (modal.type === "select") {
48
+ if (key.name === "return" || key.name === "enter") {
49
+ modal.onSelect(modal.options[selectIndex].value);
50
+ }
51
+ else if (key.name === "up" || key.name === "k") {
52
+ setSelectIndex((prev) => Math.max(0, prev - 1));
28
53
  }
29
- else if (modal.type === "message") {
54
+ else if (key.name === "down" || key.name === "j") {
55
+ setSelectIndex((prev) => Math.min(modal.options.length - 1, prev + 1));
56
+ }
57
+ return;
58
+ }
59
+ // Message modal — Enter to dismiss
60
+ if (modal.type === "message") {
61
+ if (key.name === "return" || key.name === "enter") {
30
62
  modal.onDismiss();
31
63
  }
32
64
  }
@@ -41,7 +73,7 @@ export function ModalContainer() {
41
73
  case "input":
42
74
  return (_jsx(InputModal, { title: modal.title, label: modal.label, defaultValue: modal.defaultValue, onSubmit: modal.onSubmit, onCancel: modal.onCancel }));
43
75
  case "select":
44
- return (_jsx(SelectModal, { title: modal.title, message: modal.message, options: modal.options, onSelect: modal.onSelect, onCancel: modal.onCancel }));
76
+ return (_jsx(SelectModal, { title: modal.title, message: modal.message, options: modal.options, defaultIndex: selectIndex, onSelect: modal.onSelect, onCancel: modal.onCancel }));
45
77
  case "message":
46
78
  return (_jsx(MessageModal, { title: modal.title, message: modal.message, variant: modal.variant, onDismiss: modal.onDismiss }));
47
79
  case "loading":