magus-cli 7.3.0 → 7.4.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 (130) hide show
  1. package/package.json +4 -4
  2. package/scripts/build-binaries.ts +4 -2
  3. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  4. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  5. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  6. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  7. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  8. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  9. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  10. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  11. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  12. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  15. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  16. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  17. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  18. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  19. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  20. package/src/__tests__/blind/child-run.ts +114 -0
  21. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  22. package/src/__tests__/component-badge.test.ts +20 -36
  23. package/src/__tests__/footer-hints.test.ts +1 -1
  24. package/src/__tests__/manifest.test.ts +25 -17
  25. package/src/__tests__/marketplace-badge.test.ts +4 -4
  26. package/src/__tests__/models-screen-state.test.ts +36 -13
  27. package/src/__tests__/npm-package-name-api.test.ts +111 -0
  28. package/src/__tests__/npm-package-name.test.ts +280 -0
  29. package/src/__tests__/scope-squares.test.tsx +28 -114
  30. package/src/__tests__/shutdown.test.ts +578 -0
  31. package/src/__tests__/styles-manager.test.ts +4 -4
  32. package/src/__tests__/styles-screen-state.test.ts +26 -12
  33. package/src/__tests__/tabbar-layout.test.ts +44 -32
  34. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  35. package/src/__tests__/tool-probe.test.ts +47 -22
  36. package/src/__tests__/upgrade-spawn.test.ts +355 -0
  37. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  38. package/src/cli/ansi.ts +2 -2
  39. package/src/cli/bootstrap.ts +33 -2
  40. package/src/cli/doctor.ts +105 -14
  41. package/src/cli/install.ts +8 -2
  42. package/src/cli/profile.ts +18 -1
  43. package/src/cli/router.ts +31 -18
  44. package/src/cli/update-view.ts +1 -1
  45. package/src/cli/upgrade.ts +37 -9
  46. package/src/data/cli-tools.ts +13 -2
  47. package/src/data/npm-package.ts +15 -0
  48. package/src/services/claude-settings.ts +151 -73
  49. package/src/services/cli-tool-commands.ts +15 -5
  50. package/src/services/cli-tool-version.ts +101 -0
  51. package/src/services/hook-registration.ts +23 -6
  52. package/src/services/manifest.ts +117 -52
  53. package/src/services/plugin-manager.ts +19 -5
  54. package/src/services/profile-adopt.ts +7 -2
  55. package/src/services/profile-startup.ts +87 -7
  56. package/src/services/profiles.ts +36 -23
  57. package/src/services/settings-file.ts +158 -4
  58. package/src/services/settings-manager.ts +19 -5
  59. package/src/services/skills-manager.ts +46 -0
  60. package/src/services/tool-probe.ts +272 -45
  61. package/src/services/toolchain.ts +39 -0
  62. package/src/services/update-engine.ts +5 -0
  63. package/src/services/update-plan.ts +9 -0
  64. package/src/services/version-check.ts +20 -10
  65. package/src/tui.tsx +48 -21
  66. package/src/ui/App.tsx +58 -197
  67. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  68. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  69. package/src/ui/adapters/settingsAdapter.ts +0 -21
  70. package/src/ui/adapters/skillsAdapter.ts +27 -8
  71. package/src/ui/adapters/stylesAdapter.ts +15 -11
  72. package/src/ui/components/CategoryHeader.tsx +7 -1
  73. package/src/ui/components/EmptyFilterState.tsx +2 -2
  74. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  75. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  76. package/src/ui/components/TabBar.tsx +93 -21
  77. package/src/ui/components/layout/FooterHints.tsx +6 -27
  78. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  79. package/src/ui/components/layout/index.ts +1 -5
  80. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  81. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  82. package/src/ui/components/modals/MessageModal.tsx +3 -15
  83. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  84. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  85. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  86. package/src/ui/components/primitives/MetaText.tsx +1 -1
  87. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  88. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  89. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  90. package/src/ui/components/primitives/index.ts +4 -3
  91. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  92. package/src/ui/input/KeyRouter.tsx +180 -0
  93. package/src/ui/input/key-dispatch.ts +149 -0
  94. package/src/ui/keymap.ts +898 -0
  95. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  96. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  97. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  98. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  99. package/src/ui/renderers/modelRenderers.tsx +12 -13
  100. package/src/ui/renderers/modelVisuals.tsx +0 -2
  101. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  102. package/src/ui/renderers/profileRenderers.tsx +32 -37
  103. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  104. package/src/ui/renderers/skillRenderers.tsx +237 -190
  105. package/src/ui/renderers/styleRenderers.tsx +13 -2
  106. package/src/ui/runtime/shutdown.ts +268 -0
  107. package/src/ui/screens/AliasScreen.tsx +118 -100
  108. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  109. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  110. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  111. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  112. package/src/ui/screens/McpScreen.tsx +47 -33
  113. package/src/ui/screens/ModelsScreen.tsx +59 -104
  114. package/src/ui/screens/PluginsScreen.tsx +123 -127
  115. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  116. package/src/ui/screens/SkillsScreen.tsx +184 -204
  117. package/src/ui/screens/StylesScreen.tsx +104 -157
  118. package/src/ui/state/AppContext.tsx +6 -0
  119. package/src/ui/state/DimensionsContext.tsx +1 -10
  120. package/src/ui/state/reducer.ts +5 -8
  121. package/src/ui/state/types.ts +11 -9
  122. package/src/ui/text-width.ts +341 -0
  123. package/src/ui/theme-mode.ts +34 -71
  124. package/src/ui/theme.ts +50 -28
  125. package/src/utils/request-gate.ts +37 -0
  126. package/src/ui/components/ScopeIndicator.tsx +0 -63
  127. package/src/ui/components/SearchInput.tsx +0 -57
  128. package/src/ui/hooks/index.ts +0 -2
  129. package/src/ui/hooks/useKeyboard.ts +0 -24
  130. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -1,6 +1,7 @@
1
1
  import React, { useEffect, useCallback, useState, useRef } from "react";
2
2
  import { ScreenLayout } from "../components/layout/index.js";
3
- import { useKeyboard } from "../hooks/useKeyboard.js";
3
+ import { useScreenKeys, useSearchInput } from "../input/KeyRouter.js";
4
+ import { moveDirection } from "../keymap.js";
4
5
  import { useApp, useModal, useNavigation } from "../state/AppContext.js";
5
6
  import { useDimensions } from "../state/DimensionsContext.js";
6
7
 
@@ -8,6 +9,7 @@ import { addMcpServer, setAllowMcp } from "../../services/claude-settings.js";
8
9
  import { formatDate, searchMcpServers } from "../../services/mcp-registry.js";
9
10
  import type { McpRegistryServer, McpServerConfig } from "../../types/index.js";
10
11
  import { ScrollableList } from "../components/ScrollableList.js";
12
+ import { rowLead } from "../components/primitives/index.js";
11
13
  import { theme } from "../theme.js";
12
14
 
13
15
  /**
@@ -82,9 +84,7 @@ export function McpRegistryScreen() {
82
84
  const [searchQuery, setSearchQuery] = useState(mcpRegistry.searchQuery || "");
83
85
 
84
86
  const isSearchActive =
85
- state.isSearching &&
86
- state.currentRoute.screen === "mcp-registry" &&
87
- !state.modal;
87
+ state.isSearching && state.currentRoute.screen === "mcp-registry";
88
88
 
89
89
  const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
90
90
 
@@ -132,67 +132,48 @@ export function McpRegistryScreen() {
132
132
  };
133
133
  }, [loadServers]);
134
134
 
135
- // Keyboard handling
136
- useKeyboard((event) => {
137
- // Handle search mode
138
- if (isSearchActive) {
139
- if (event.name === "escape") {
140
- dispatch({ type: "SET_SEARCHING", isSearching: false });
141
- } else if (event.name === "enter") {
142
- // Exit search mode and stay on list for navigation
143
- dispatch({ type: "SET_SEARCHING", isSearching: false });
144
- } else if (event.name === "up") {
145
- // Allow navigation while searching
146
- const newIndex = Math.max(0, mcpRegistry.selectedIndex - 1);
147
- dispatch({ type: "MCPREGISTRY_SELECT", index: newIndex });
148
- } else if (event.name === "down") {
149
- // Allow navigation while searching
150
- const newIndex = Math.min(
151
- Math.max(0, servers.length - 1),
152
- mcpRegistry.selectedIndex + 1,
153
- );
154
- dispatch({ type: "MCPREGISTRY_SELECT", index: newIndex });
155
- } else if (event.name === "backspace" || event.name === "delete") {
156
- const newQuery = searchQuery.slice(0, -1);
157
- setSearchQuery(newQuery);
158
- dispatch({ type: "MCPREGISTRY_SEARCH", query: newQuery });
159
- debouncedSearch(newQuery);
160
- } else if (event.name.length === 1 && !event.ctrl && !event.meta) {
161
- const newQuery = searchQuery + event.name;
162
- setSearchQuery(newQuery);
163
- dispatch({ type: "MCPREGISTRY_SEARCH", query: newQuery });
164
- debouncedSearch(newQuery);
165
- }
166
- return;
167
- }
135
+ // ── Keys ──────────────────────────────────────────────────────────────────
168
136
 
169
- if (state.modal) return;
137
+ /** A new query: the list reloads, debounced, and the cursor starts over. */
138
+ const setQuery = (query: string) => {
139
+ setSearchQuery(query);
140
+ dispatch({ type: "MCPREGISTRY_SEARCH", query });
141
+ dispatch({ type: "MCPREGISTRY_SELECT", index: 0 });
142
+ debouncedSearch(query);
143
+ };
170
144
 
171
- // Start search with /
172
- if (event.name === "/") {
173
- dispatch({ type: "SET_SEARCHING", isSearching: true });
174
- return;
175
- }
145
+ // The search box, while it has focus: digits, j/k, q and ? are typed here.
146
+ useSearchInput({
147
+ focused: isSearchActive,
148
+ query: searchQuery,
149
+ append: (char) => setQuery(searchQuery + char),
150
+ erase: () => setQuery(searchQuery.slice(0, -1)),
151
+ clear: () => setQuery(""),
152
+ blur: () => dispatch({ type: "SET_SEARCHING", isSearching: false }),
153
+ });
176
154
 
177
- // Navigation
178
- if (event.name === "up" || event.name === "k") {
179
- const newIndex = Math.max(0, mcpRegistry.selectedIndex - 1);
180
- dispatch({ type: "MCPREGISTRY_SELECT", index: newIndex });
181
- } else if (event.name === "down" || event.name === "j") {
182
- const newIndex = Math.min(
183
- Math.max(0, servers.length - 1),
184
- mcpRegistry.selectedIndex + 1,
185
- );
186
- dispatch({ type: "MCPREGISTRY_SELECT", index: newIndex });
187
- } else if (event.name === "l") {
188
- navigateToScreen("mcp");
189
- } else if (event.name === "r" && event.shift) {
190
- // `name` is always the unshifted key, so `=== "R"` never matched and
191
- // refresh silently did nothing.
192
- loadServers(searchQuery);
193
- } else if (event.name === "enter") {
194
- handleInstall();
195
- }
155
+ useScreenKeys("mcp-registry", {
156
+ install: () => {
157
+ void handleInstall();
158
+ },
159
+ search: () => dispatch({ type: "SET_SEARCHING", isSearching: true }),
160
+ local: () => navigateToScreen("mcp"),
161
+ reload: () => {
162
+ void loadServers(searchQuery);
163
+ },
164
+ // Only while a query is set; otherwise Esc is the global back to MCP.
165
+ "clear-filter": () => {
166
+ if (!searchQuery) return false;
167
+ setQuery("");
168
+ return true;
169
+ },
170
+ move: (key) => {
171
+ const index = mcpRegistry.selectedIndex + moveDirection(key);
172
+ dispatch({
173
+ type: "MCPREGISTRY_SELECT",
174
+ index: Math.max(0, Math.min(servers.length - 1, index)),
175
+ });
176
+ },
196
177
  });
197
178
 
198
179
  const handleInstall = async () => {
@@ -254,13 +235,13 @@ export function McpRegistryScreen() {
254
235
  <box marginTop={1}>
255
236
  <text fg={theme.colors.text}>{selectedServer.short_description}</text>
256
237
  </box>
257
- <box marginTop={1}>
238
+ <box flexDirection="row" marginTop={1}>
258
239
  <text fg={theme.colors.text}>
259
240
  <strong>Version: </strong>
260
241
  </text>
261
- <text fg={theme.colors.success}>{versionDisplay}</text>
242
+ <text fg={theme.colors.info}>{versionDisplay}</text>
262
243
  </box>
263
- <box>
244
+ <box flexDirection="row">
264
245
  <text fg={theme.colors.text}>
265
246
  <strong>Published: </strong>
266
247
  </text>
@@ -283,7 +264,7 @@ export function McpRegistryScreen() {
283
264
  </box>
284
265
  )}
285
266
  <box marginTop={1}>
286
- <text fg={theme.colors.success}>Press Enter to install</text>
267
+ <text fg={theme.colors.muted}>Press Enter to install</text>
287
268
  </box>
288
269
  </box>
289
270
  );
@@ -296,37 +277,22 @@ export function McpRegistryScreen() {
296
277
  ) => {
297
278
  const version = server.version ? ` v${server.version}` : "";
298
279
  return isSelected ? (
299
- <text bg={theme.colors.accent} fg={theme.hints.fg}>
300
- {" "}
280
+ <text bg={theme.selection.bg} fg={theme.selection.fg}>
281
+ {rowLead(true)}
301
282
  {server.name}
302
283
  {version}{" "}
303
284
  </text>
304
285
  ) : (
305
286
  <text fg={theme.colors.text}>
287
+ <span>{rowLead(false)}</span>
306
288
  <span>
307
289
  <strong>{server.name}</strong>
308
290
  </span>
309
- <span fg={theme.colors.success}>{version}</span>
291
+ <span fg={theme.colors.info}>{version}</span>
310
292
  </text>
311
293
  );
312
294
  };
313
295
 
314
- // Footer hints
315
- const footerHints = isSearchActive
316
- ? [
317
- { keys: ["type"], label: "search" },
318
- { keys: ["↑", "↓"], label: "nav" },
319
- { keys: ["Enter"], label: "done" },
320
- { keys: ["Esc"], label: "cancel" },
321
- ]
322
- : [
323
- { keys: ["↑", "↓"], label: "nav" },
324
- { keys: ["Enter"], label: "install" },
325
- { keys: ["/"], label: "search" },
326
- { keys: ["R"], label: "refresh" },
327
- { keys: ["l"], label: "local" },
328
- ];
329
-
330
296
  // Status for search placeholder
331
297
  const searchPlaceholder = `${servers.length} servers │ / to search`;
332
298
 
@@ -340,7 +306,7 @@ export function McpRegistryScreen() {
340
306
  query: searchQuery,
341
307
  placeholder: searchPlaceholder,
342
308
  }}
343
- footerHints={footerHints}
309
+ footer={{ hide: searchQuery ? [] : ["clear-filter"] }}
344
310
  listPanel={
345
311
  isLoading ? (
346
312
  <text fg={theme.colors.muted}>Loading...</text>
@@ -1,6 +1,7 @@
1
1
  import React, { useEffect, useCallback, useMemo } from "react";
2
2
  import { ScreenLayout } from "../components/layout/index.js";
3
- import { useKeyboard } from "../hooks/useKeyboard.js";
3
+ import { useScreenKeys } from "../input/KeyRouter.js";
4
+ import { moveDirection } from "../keymap.js";
4
5
  import { useApp, useModal, useNavigation } from "../state/AppContext.js";
5
6
  import { useDimensions } from "../state/DimensionsContext.js";
6
7
 
@@ -18,6 +19,7 @@ import {
18
19
  import { fetchCuratedMcpServers } from "../../services/mcp-registry.js";
19
20
  import type { McpServer, McpServerConfig } from "../../types/index.js";
20
21
  import { ScrollableList } from "../components/ScrollableList.js";
22
+ import { ErrorNotice } from "../components/primitives/index.js";
21
23
  import {
22
24
  type McpListItem,
23
25
  renderMcpDetail,
@@ -92,26 +94,32 @@ export function McpScreen() {
92
94
  return items;
93
95
  }, [mcp.servers, mcp.installedServers]);
94
96
 
95
- useKeyboard((event) => {
96
- if (state.isSearching || state.modal) return;
97
-
98
- if (event.name === "/") {
99
- dispatch({ type: "SET_SEARCHING", isSearching: true });
100
- navigateToScreen("mcp-registry");
101
- return;
102
- }
103
-
104
- if (event.name === "up" || event.name === "k") {
105
- const newIndex = Math.max(0, mcp.selectedIndex - 1);
106
- dispatch({ type: "MCP_SELECT", index: newIndex });
107
- } else if (event.name === "down" || event.name === "j") {
108
- const newIndex = Math.min(allListItems.length - 1, mcp.selectedIndex + 1);
109
- dispatch({ type: "MCP_SELECT", index: newIndex });
110
- } else if (event.name === "r") {
97
+ useScreenKeys("mcp", {
98
+ // Guarded: `installMcpServer` throws on a catalogue entry with no URL or
99
+ // command, and a rejection nobody catches ends the app.
100
+ toggle: () => {
101
+ handleSelect().catch((error: unknown) =>
102
+ modal.message(
103
+ "Error",
104
+ error instanceof Error ? error.message : String(error),
105
+ "error",
106
+ ),
107
+ );
108
+ },
109
+ // Navigate FIRST: a navigation closes any search box, so opening the
110
+ // registry's box has to come after it.
111
+ search: () => {
111
112
  navigateToScreen("mcp-registry");
112
- } else if (event.name === "enter") {
113
- handleSelect();
114
- }
113
+ dispatch({ type: "SET_SEARCHING", isSearching: true });
114
+ },
115
+ registry: () => navigateToScreen("mcp-registry"),
116
+ move: (key) => {
117
+ const index = mcp.selectedIndex + moveDirection(key);
118
+ dispatch({
119
+ type: "MCP_SELECT",
120
+ index: Math.max(0, Math.min(allListItems.length - 1, index)),
121
+ });
122
+ },
115
123
  });
116
124
 
117
125
  const handleSelect = async () => {
@@ -227,26 +235,32 @@ export function McpScreen() {
227
235
  const enabledCount = Object.values(mcp.installedServers).filter(
228
236
  (v) => v === true,
229
237
  ).length;
230
- const subtitle = `${enabledCount} enabled │ ${installedCount} configured │ / to search`;
238
+ // A failed read is not "0 configured": the counts are only a fact once the
239
+ // configuration has actually been read.
240
+ const loadError = mcp.servers.status === "error" ? mcp.servers.error : null;
241
+ const subtitle = loadError
242
+ ? "configuration unreadable"
243
+ : `${enabledCount} enabled │ ${installedCount} configured │ / to search`;
231
244
 
232
245
  return (
233
246
  <ScreenLayout
234
247
  title="magus MCP Servers"
235
248
  subtitle={subtitle}
236
249
  currentScreen="mcp"
237
- footerHints={[
238
- { keys: ["↑", "↓"], label: "nav" },
239
- { keys: ["Enter"], label: "toggle" },
240
- { keys: ["/"], label: "search" },
241
- { keys: ["r"], label: "registry" },
242
- ]}
243
250
  listPanel={
244
- <ScrollableList
245
- items={allListItems}
246
- selectedIndex={mcp.selectedIndex}
247
- renderItem={renderMcpRow}
248
- maxHeight={dimensions.listPanelHeight}
249
- />
251
+ loadError ? (
252
+ <ErrorNotice
253
+ title="Could not load MCP servers."
254
+ detail={loadError.message}
255
+ />
256
+ ) : (
257
+ <ScrollableList
258
+ items={allListItems}
259
+ selectedIndex={mcp.selectedIndex}
260
+ renderItem={renderMcpRow}
261
+ maxHeight={dimensions.listPanelHeight}
262
+ />
263
+ )
250
264
  }
251
265
  detailPanel={renderMcpDetail(selectedItem, isLoading)}
252
266
  />
@@ -15,7 +15,8 @@ import {
15
15
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
16
16
  import { ScrollableList } from "../components/ScrollableList.js";
17
17
  import { ScreenLayout } from "../components/layout/index.js";
18
- import { useKeyboard } from "../hooks/useKeyboard.js";
18
+ import { useScreenKeys, useSearchInput } from "../input/KeyRouter.js";
19
+ import { moveDirection } from "../keymap.js";
19
20
  import {
20
21
  fitPresetList,
21
22
  renderModelDetail,
@@ -33,7 +34,7 @@ export function ModelsScreen() {
33
34
  const dimensions = useDimensions();
34
35
 
35
36
  const isSearchActive =
36
- state.isSearching && state.currentRoute.screen === "models" && !state.modal;
37
+ state.isSearching && state.currentRoute.screen === "models";
37
38
 
38
39
  // ── Data ──────────────────────────────────────────────────────────────────
39
40
 
@@ -287,94 +288,61 @@ export function ModelsScreen() {
287
288
  [modelsState.selectedIndex, allItems, dispatch],
288
289
  );
289
290
 
290
- useKeyboard((event) => {
291
- if (state.modal) return;
292
-
293
- // The previous message belongs to the previous ACTION, so clearing it here
294
- // covers every action key in one place. Navigation is excluded: a digit or
295
- // Tab is handled by the global handler, and clearing on it wiped the
296
- // message on the way OUT of the tab, losing it on a round trip.
297
- const isNavigation =
298
- /^[0-9]$/.test(event.name ?? "") || event.name === "tab";
299
- if (modelsState.status && !isNavigation) {
300
- dispatch({ type: "MODELS_STATUS_CLEAR" });
301
- }
302
-
303
- const hasQuery = modelsState.searchQuery.length > 0;
304
-
305
- if (event.name === "escape") {
306
- if (hasQuery || isSearchActive) {
307
- dispatch({ type: "MODELS_SET_SEARCH", query: "" });
308
- dispatch({ type: "SET_SEARCHING", isSearching: false });
309
- dispatch({ type: "MODELS_SELECT", index: 0 });
310
- }
311
- return;
312
- }
313
-
314
- if (event.name === "backspace" || event.name === "delete") {
315
- if (hasQuery) {
316
- dispatch({ type: "MODELS_SEARCH_BACKSPACE" });
317
- // Driven by what was there BEFORE this keystroke — the one thing the
318
- // closure knows reliably.
319
- if (modelsState.searchQuery.length <= 1) {
320
- dispatch({ type: "SET_SEARCHING", isSearching: false });
321
- }
322
- }
323
- return;
324
- }
325
-
326
- if (event.name === "up") {
327
- if (isSearchActive)
328
- dispatch({ type: "SET_SEARCHING", isSearching: false });
329
- move(-1);
330
- return;
331
- }
332
- if (event.name === "down") {
333
- if (isSearchActive)
334
- dispatch({ type: "SET_SEARCHING", isSearching: false });
335
- move(1);
336
- return;
337
- }
338
-
339
- if (event.name === "return" || event.name === "enter") {
340
- if (isSearchActive) {
341
- dispatch({ type: "SET_SEARCHING", isSearching: false });
342
- return;
343
- }
344
- // Enter and `a` are the same action here. There is nothing to "open" on
345
- // this screen — a preset row has one thing it can do — so binding Enter
346
- // to anything else would leave the most obvious key doing nothing.
347
- handleApply();
348
- return;
349
- }
350
-
351
- // While typing a filter, letters are text — not commands.
352
- if (isSearchActive) {
353
- if (
354
- event.name &&
355
- event.name.length === 1 &&
356
- !event.ctrl &&
357
- !event.meta &&
358
- !/[0-9]/.test(event.name)
359
- ) {
360
- dispatch({ type: "MODELS_SEARCH_APPEND", char: event.name });
361
- }
362
- return;
363
- }
291
+ // The search box, while it has focus: digits, j/k, q and ? are typed here.
292
+ useSearchInput({
293
+ focused: isSearchActive,
294
+ query: modelsState.searchQuery,
295
+ append: (char) => dispatch({ type: "MODELS_SEARCH_APPEND", char }),
296
+ erase: () => dispatch({ type: "MODELS_SEARCH_BACKSPACE" }),
297
+ clear: () => {
298
+ dispatch({ type: "MODELS_SET_SEARCH", query: "" });
299
+ dispatch({ type: "MODELS_SELECT", index: 0 });
300
+ },
301
+ blur: () => dispatch({ type: "SET_SEARCHING", isSearching: false }),
302
+ });
364
303
 
365
- if (event.name === "k") {
366
- move(-1);
367
- } else if (event.name === "j") {
368
- move(1);
369
- } else if (event.name === "a") {
370
- handleApply();
371
- } else if (event.name === "c") {
372
- handleClear();
373
- } else if (event.name === "r") {
374
- fetchData();
375
- } else if (event.name === "/") {
304
+ /**
305
+ * The previous message belongs to the previous ACTION, so every key this
306
+ * screen acts on clears it first. Keys another layer takes — a digit or Tab
307
+ * leaving the tab, text typed into the search box — leave it alone, so a
308
+ * round trip through another tab keeps it.
309
+ */
310
+ const clearStatus = () => {
311
+ if (modelsState.status) dispatch({ type: "MODELS_STATUS_CLEAR" });
312
+ };
313
+
314
+ useScreenKeys("models", {
315
+ // Enter and `a` are the same action here. There is nothing to "open" on
316
+ // this screen — a preset row has one thing it can do — so binding Enter
317
+ // to anything else would leave the most obvious key doing nothing.
318
+ apply: () => {
319
+ clearStatus();
320
+ void handleApply();
321
+ },
322
+ off: () => {
323
+ clearStatus();
324
+ void handleClear();
325
+ },
326
+ reload: () => {
327
+ clearStatus();
328
+ void fetchData();
329
+ },
330
+ search: () => {
331
+ clearStatus();
376
332
  dispatch({ type: "SET_SEARCHING", isSearching: true });
377
- }
333
+ },
334
+ // Only while a committed filter is set; otherwise Esc is the global back.
335
+ "clear-filter": () => {
336
+ if (!modelsState.searchQuery) return false;
337
+ clearStatus();
338
+ dispatch({ type: "MODELS_SET_SEARCH", query: "" });
339
+ dispatch({ type: "MODELS_SELECT", index: 0 });
340
+ return true;
341
+ },
342
+ move: (key) => {
343
+ clearStatus();
344
+ move(moveDirection(key));
345
+ },
378
346
  });
379
347
 
380
348
  // ── Chrome ────────────────────────────────────────────────────────────────
@@ -413,7 +381,7 @@ export function ModelsScreen() {
413
381
  {routingState ?? "reading…"}
414
382
  </span>
415
383
  {modelsState.isApplying ? (
416
- <span fg={theme.colors.warning}> │ writing…</span>
384
+ <span fg={theme.colors.muted}> │ writing…</span>
417
385
  ) : null}
418
386
  </text>
419
387
  );
@@ -440,20 +408,7 @@ export function ModelsScreen() {
440
408
  }
441
409
  : undefined
442
410
  }
443
- footerHints={
444
- isSearchActive
445
- ? [
446
- { keys: ["type"], label: "filter" },
447
- { keys: ["Enter"], label: "done" },
448
- { keys: ["Esc"], label: "clear" },
449
- ]
450
- : [
451
- { keys: ["Enter", "a"], label: "apply" },
452
- { keys: ["c"], label: "off" },
453
- { keys: ["r"], label: "reload" },
454
- { keys: ["/"], label: "filter" },
455
- ]
456
- }
411
+ footer={{ hide: modelsState.searchQuery ? [] : ["clear-filter"] }}
457
412
  listPanel={(listWidth) => {
458
413
  // ONE layout for every row, measured from the widest of them.
459
414
  // Rows used to fit themselves, so at 120×30 the selected row
@@ -464,7 +419,7 @@ export function ModelsScreen() {
464
419
  <box flexDirection="column">
465
420
  {modelsState.data.status === "loading" && !snapshot && (
466
421
  <box paddingLeft={2}>
467
- <text fg={theme.colors.warning}>Reading the routing…</text>
422
+ <text fg={theme.colors.muted}>Reading the routing…</text>
468
423
  </box>
469
424
  )}
470
425