claudeup 3.7.2 → 3.8.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 (44) 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/services/plugin-manager.js +2 -0
  5. package/src/services/plugin-manager.ts +3 -0
  6. package/src/services/profiles.js +161 -0
  7. package/src/services/profiles.ts +225 -0
  8. package/src/services/settings-manager.js +108 -0
  9. package/src/services/settings-manager.ts +140 -0
  10. package/src/types/index.ts +34 -0
  11. package/src/ui/App.js +17 -18
  12. package/src/ui/App.tsx +21 -23
  13. package/src/ui/components/TabBar.js +8 -8
  14. package/src/ui/components/TabBar.tsx +14 -19
  15. package/src/ui/components/layout/ScreenLayout.js +8 -14
  16. package/src/ui/components/layout/ScreenLayout.tsx +51 -58
  17. package/src/ui/components/modals/ModalContainer.js +43 -11
  18. package/src/ui/components/modals/ModalContainer.tsx +44 -12
  19. package/src/ui/components/modals/SelectModal.js +4 -18
  20. package/src/ui/components/modals/SelectModal.tsx +10 -21
  21. package/src/ui/screens/CliToolsScreen.js +2 -2
  22. package/src/ui/screens/CliToolsScreen.tsx +8 -8
  23. package/src/ui/screens/EnvVarsScreen.js +248 -116
  24. package/src/ui/screens/EnvVarsScreen.tsx +419 -184
  25. package/src/ui/screens/McpRegistryScreen.tsx +18 -6
  26. package/src/ui/screens/McpScreen.js +1 -1
  27. package/src/ui/screens/McpScreen.tsx +15 -5
  28. package/src/ui/screens/ModelSelectorScreen.js +3 -5
  29. package/src/ui/screens/ModelSelectorScreen.tsx +12 -16
  30. package/src/ui/screens/PluginsScreen.js +154 -66
  31. package/src/ui/screens/PluginsScreen.tsx +280 -97
  32. package/src/ui/screens/ProfilesScreen.js +255 -0
  33. package/src/ui/screens/ProfilesScreen.tsx +487 -0
  34. package/src/ui/screens/StatusLineScreen.js +2 -2
  35. package/src/ui/screens/StatusLineScreen.tsx +10 -12
  36. package/src/ui/screens/index.js +2 -2
  37. package/src/ui/screens/index.ts +2 -2
  38. package/src/ui/state/AppContext.js +2 -1
  39. package/src/ui/state/AppContext.tsx +2 -0
  40. package/src/ui/state/reducer.js +63 -19
  41. package/src/ui/state/reducer.ts +68 -19
  42. package/src/ui/state/types.ts +33 -14
  43. package/src/utils/clipboard.js +56 -0
  44. package/src/utils/clipboard.ts +58 -0
@@ -73,6 +73,15 @@ export interface MarketplaceSource {
73
73
  autoUpdate?: boolean; // Enable auto-update for this marketplace (default: true for new marketplaces)
74
74
  }
75
75
 
76
+ export interface ClaudeHookEntry {
77
+ type: string;
78
+ command: string;
79
+ }
80
+
81
+ export interface ClaudeHookGroup {
82
+ hooks: ClaudeHookEntry[];
83
+ }
84
+
76
85
  export interface ClaudeSettings {
77
86
  enabledMcpServers?: Record<string, boolean>;
78
87
  mcpServers?: Record<string, McpServerConfig>;
@@ -80,6 +89,7 @@ export interface ClaudeSettings {
80
89
  extraKnownMarketplaces?: Record<string, MarketplaceSource>;
81
90
  installedPluginVersions?: Record<string, string>;
82
91
  statusLine?: string;
92
+ hooks?: Record<string, ClaudeHookGroup[]>;
83
93
  }
84
94
 
85
95
  export interface McpServerConfig {
@@ -139,3 +149,27 @@ export interface InstalledPluginsRegistry {
139
149
  version: number;
140
150
  plugins: Record<string, InstalledPluginEntry[]>;
141
151
  }
152
+
153
+ // ─── Plugin Profile Types ──────────────────────────────────────────────────────
154
+
155
+ export interface Profile {
156
+ name: string;
157
+ plugins: Record<string, boolean>;
158
+ createdAt: string;
159
+ updatedAt: string;
160
+ }
161
+
162
+ export interface ProfilesFile {
163
+ version: number;
164
+ profiles: Record<string, Profile>;
165
+ }
166
+
167
+ /** A profile with its id and scope, as used in UI lists */
168
+ export interface ProfileEntry {
169
+ id: string;
170
+ name: string;
171
+ plugins: Record<string, boolean>;
172
+ createdAt: string;
173
+ updatedAt: string;
174
+ scope: "user" | "project";
175
+ }
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, } 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,14 @@ 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
37
  default:
38
38
  return _jsx(PluginsScreen, {});
39
39
  }
@@ -80,10 +80,10 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
80
80
  "plugins",
81
81
  "mcp",
82
82
  "mcp-registry",
83
- "statusline",
84
- "env-vars",
83
+ "settings",
85
84
  "cli-tools",
86
85
  "model-selector",
86
+ "profiles",
87
87
  ].includes(state.currentRoute.screen);
88
88
  if (isTopLevel) {
89
89
  if (input === "1")
@@ -91,19 +91,19 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
91
91
  else if (input === "2")
92
92
  navigateToScreen("mcp");
93
93
  else if (input === "3")
94
- navigateToScreen("statusline");
94
+ navigateToScreen("settings");
95
95
  else if (input === "4")
96
- navigateToScreen("env-vars");
97
- else if (input === "5")
98
96
  navigateToScreen("cli-tools");
97
+ else if (input === "5")
98
+ navigateToScreen("profiles");
99
99
  // Tab navigation cycling
100
100
  if (key.tab) {
101
101
  const screens = [
102
102
  "plugins",
103
103
  "mcp",
104
- "statusline",
105
- "env-vars",
104
+ "settings",
106
105
  "cli-tools",
106
+ "profiles",
107
107
  ];
108
108
  const currentIndex = screens.indexOf(state.currentRoute.screen);
109
109
  if (currentIndex !== -1) {
@@ -138,9 +138,9 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
138
138
  ? This help
139
139
 
140
140
  Quick Navigation
141
- 1 Plugins 4 Env Vars
142
- 2 MCP Servers 5 CLI Tools
143
- 3 Status Line
141
+ 1 Plugins 4 CLI Tools
142
+ 2 MCP Servers 5 Profiles
143
+ 3 Settings
144
144
 
145
145
  Plugin Actions
146
146
  u Update d Uninstall
@@ -157,7 +157,7 @@ MCP Servers
157
157
  * UpdateBanner Component
158
158
  * Shows version update notification
159
159
  */
160
- function UpdateBanner({ result, }) {
160
+ function UpdateBanner({ result }) {
161
161
  if (!result.updateAvailable)
162
162
  return null;
163
163
  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 +183,7 @@ function AppContentInner({ showDebug, onDebugToggle, updateInfo, onExit, }) {
183
183
  state: { message: "Scanning marketplaces..." },
184
184
  });
185
185
  // Migrate old marketplace names → magus (idempotent), then repair plugin.json files
186
- migrateMarketplaceRename()
187
- .catch(() => { }); // non-blocking, best-effort
186
+ migrateMarketplaceRename().catch(() => { }); // non-blocking, best-effort
188
187
  repairAllMarketplaces()
189
188
  .then(async () => {
190
189
  dispatch({ type: "HIDE_PROGRESS" });
package/src/ui/App.tsx CHANGED
@@ -16,10 +16,10 @@ 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
23
  } from "./screens/index.js";
24
24
  import type { Screen } from "./state/types.js";
25
25
  import { repairAllMarketplaces } from "../services/local-marketplace.js";
@@ -49,14 +49,14 @@ function Router() {
49
49
  return <McpScreen />;
50
50
  case "mcp-registry":
51
51
  return <McpRegistryScreen />;
52
- case "statusline":
53
- return <StatusLineScreen />;
54
- case "env-vars":
55
- return <EnvVarsScreen />;
52
+ case "settings":
53
+ return <SettingsScreen />;
56
54
  case "cli-tools":
57
55
  return <CliToolsScreen />;
58
56
  case "model-selector":
59
57
  return <ModelSelectorScreen />;
58
+ case "profiles":
59
+ return <ProfilesScreen />;
60
60
  default:
61
61
  return <PluginsScreen />;
62
62
  }
@@ -112,27 +112,27 @@ function GlobalKeyHandler({
112
112
  "plugins",
113
113
  "mcp",
114
114
  "mcp-registry",
115
- "statusline",
116
- "env-vars",
115
+ "settings",
117
116
  "cli-tools",
118
117
  "model-selector",
118
+ "profiles",
119
119
  ].includes(state.currentRoute.screen);
120
120
 
121
121
  if (isTopLevel) {
122
122
  if (input === "1") navigateToScreen("plugins");
123
123
  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");
124
+ else if (input === "3") navigateToScreen("settings");
125
+ else if (input === "4") navigateToScreen("cli-tools");
126
+ else if (input === "5") navigateToScreen("profiles");
127
127
 
128
128
  // Tab navigation cycling
129
129
  if (key.tab) {
130
130
  const screens: Screen[] = [
131
131
  "plugins",
132
132
  "mcp",
133
- "statusline",
134
- "env-vars",
133
+ "settings",
135
134
  "cli-tools",
135
+ "profiles",
136
136
  ];
137
137
  const currentIndex = screens.indexOf(
138
138
  state.currentRoute.screen as Screen,
@@ -171,9 +171,9 @@ function GlobalKeyHandler({
171
171
  ? This help
172
172
 
173
173
  Quick Navigation
174
- 1 Plugins 4 Env Vars
175
- 2 MCP Servers 5 CLI Tools
176
- 3 Status Line
174
+ 1 Plugins 4 CLI Tools
175
+ 2 MCP Servers 5 Profiles
176
+ 3 Settings
177
177
 
178
178
  Plugin Actions
179
179
  u Update d Uninstall
@@ -194,13 +194,11 @@ MCP Servers
194
194
  * UpdateBanner Component
195
195
  * Shows version update notification
196
196
  */
197
- function UpdateBanner({
198
- result,
199
- }: { result: VersionCheckResult }) {
197
+ function UpdateBanner({ result }: { result: VersionCheckResult }) {
200
198
  if (!result.updateAvailable) return null;
201
199
 
202
200
  return (
203
- <box paddingLeft={1} paddingRight={1} >
201
+ <box paddingLeft={1} paddingRight={1}>
204
202
  <text bg="yellow" fg="black">
205
203
  <strong> UPDATE </strong>
206
204
  </text>
@@ -266,8 +264,7 @@ function AppContentInner({
266
264
  });
267
265
 
268
266
  // Migrate old marketplace names → magus (idempotent), then repair plugin.json files
269
- migrateMarketplaceRename()
270
- .catch(() => {}); // non-blocking, best-effort
267
+ migrateMarketplaceRename().catch(() => {}); // non-blocking, best-effort
271
268
 
272
269
  repairAllMarketplaces()
273
270
  .then(async () => {
@@ -295,7 +292,8 @@ function AppContentInner({
295
292
  <box
296
293
  flexDirection="column"
297
294
  height={dimensions.contentHeight}
298
- paddingLeft={1} paddingRight={1}
295
+ paddingLeft={1}
296
+ paddingRight={1}
299
297
  >
300
298
  <Router />
301
299
  </box>
@@ -1,13 +1,13 @@
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
9
  ];
10
- export function TabBar({ currentScreen, onTabChange, }) {
10
+ export function TabBar({ currentScreen, onTabChange }) {
11
11
  // Handle number key shortcuts (1-5)
12
12
  useKeyboardHandler((input, key) => {
13
13
  if (!onTabChange)
@@ -32,7 +32,7 @@ export function TabBar({ currentScreen, onTabChange, }) {
32
32
  return (_jsx("box", { flexDirection: "row", gap: 0, children: TABS.map((tab, index) => {
33
33
  const isSelected = tab.screen === currentScreen;
34
34
  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));
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
36
  }) }));
37
37
  }
38
38
  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,11 @@ 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
17
  ];
18
18
 
19
19
  interface TabBarProps {
@@ -21,10 +21,7 @@ interface TabBarProps {
21
21
  onTabChange?: (screen: Screen) => void;
22
22
  }
23
23
 
24
- export function TabBar({
25
- currentScreen,
26
- onTabChange,
27
- }: TabBarProps) {
24
+ export function TabBar({ currentScreen, onTabChange }: TabBarProps) {
28
25
  // Handle number key shortcuts (1-5)
29
26
  useKeyboardHandler((input, key) => {
30
27
  if (!onTabChange) return;
@@ -61,23 +58,21 @@ export function TabBar({
61
58
  <box>
62
59
  <text bg="#7e57c2" fg="white">
63
60
  <strong>
64
- {' '}
65
- {tab.key}:{tab.label}{' '}
61
+ {" "}
62
+ {tab.key}:{tab.label}{" "}
66
63
  </strong>
67
64
  </text>
68
65
  </box>
69
66
  ) : (
70
67
  <box>
71
68
  <text fg="gray">
72
- {' '}
73
- {tab.key}:{tab.label}{' '}
69
+ {" "}
70
+ {tab.key}:{tab.label}{" "}
74
71
  </text>
75
72
  </box>
76
73
  )}
77
74
  {/* Separator */}
78
- {!isLast && (
79
- <text fg="#666666">│</text>
80
- )}
75
+ {!isLast && <text fg="#666666">│</text>}
81
76
  </box>
82
77
  );
83
78
  })}
@@ -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":