magus-cli 7.3.1 → 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 (123) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  3. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  4. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  5. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  6. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  7. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  8. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  9. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  10. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  11. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  12. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  13. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  14. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  15. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  16. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  17. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  18. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  19. package/src/__tests__/blind/child-run.ts +114 -0
  20. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  21. package/src/__tests__/component-badge.test.ts +20 -36
  22. package/src/__tests__/footer-hints.test.ts +1 -1
  23. package/src/__tests__/manifest.test.ts +25 -17
  24. package/src/__tests__/marketplace-badge.test.ts +4 -4
  25. package/src/__tests__/models-screen-state.test.ts +36 -13
  26. package/src/__tests__/scope-squares.test.tsx +28 -114
  27. package/src/__tests__/shutdown.test.ts +578 -0
  28. package/src/__tests__/styles-manager.test.ts +4 -4
  29. package/src/__tests__/styles-screen-state.test.ts +26 -12
  30. package/src/__tests__/tabbar-layout.test.ts +44 -32
  31. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  32. package/src/__tests__/tool-probe.test.ts +47 -22
  33. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  34. package/src/cli/ansi.ts +2 -2
  35. package/src/cli/bootstrap.ts +33 -2
  36. package/src/cli/doctor.ts +105 -14
  37. package/src/cli/install.ts +8 -2
  38. package/src/cli/profile.ts +18 -1
  39. package/src/cli/router.ts +31 -18
  40. package/src/cli/update-view.ts +1 -1
  41. package/src/data/cli-tools.ts +9 -1
  42. package/src/services/claude-settings.ts +151 -73
  43. package/src/services/cli-tool-commands.ts +15 -5
  44. package/src/services/cli-tool-version.ts +101 -0
  45. package/src/services/hook-registration.ts +23 -6
  46. package/src/services/manifest.ts +117 -52
  47. package/src/services/plugin-manager.ts +19 -5
  48. package/src/services/profile-adopt.ts +7 -2
  49. package/src/services/profile-startup.ts +87 -7
  50. package/src/services/profiles.ts +36 -23
  51. package/src/services/settings-file.ts +158 -4
  52. package/src/services/settings-manager.ts +19 -5
  53. package/src/services/skills-manager.ts +46 -0
  54. package/src/services/tool-probe.ts +272 -45
  55. package/src/services/toolchain.ts +39 -0
  56. package/src/services/update-engine.ts +5 -0
  57. package/src/services/update-plan.ts +9 -0
  58. package/src/tui.tsx +48 -21
  59. package/src/ui/App.tsx +58 -197
  60. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  61. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  62. package/src/ui/adapters/settingsAdapter.ts +0 -21
  63. package/src/ui/adapters/skillsAdapter.ts +27 -8
  64. package/src/ui/adapters/stylesAdapter.ts +15 -11
  65. package/src/ui/components/CategoryHeader.tsx +7 -1
  66. package/src/ui/components/EmptyFilterState.tsx +2 -2
  67. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  68. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  69. package/src/ui/components/TabBar.tsx +93 -21
  70. package/src/ui/components/layout/FooterHints.tsx +6 -27
  71. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  72. package/src/ui/components/layout/index.ts +1 -5
  73. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  74. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  75. package/src/ui/components/modals/MessageModal.tsx +3 -15
  76. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  77. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  78. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  79. package/src/ui/components/primitives/MetaText.tsx +1 -1
  80. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  81. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  82. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  83. package/src/ui/components/primitives/index.ts +4 -3
  84. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  85. package/src/ui/input/KeyRouter.tsx +180 -0
  86. package/src/ui/input/key-dispatch.ts +149 -0
  87. package/src/ui/keymap.ts +898 -0
  88. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  89. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  90. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  91. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  92. package/src/ui/renderers/modelRenderers.tsx +12 -13
  93. package/src/ui/renderers/modelVisuals.tsx +0 -2
  94. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  95. package/src/ui/renderers/profileRenderers.tsx +32 -37
  96. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  97. package/src/ui/renderers/skillRenderers.tsx +237 -190
  98. package/src/ui/renderers/styleRenderers.tsx +13 -2
  99. package/src/ui/runtime/shutdown.ts +268 -0
  100. package/src/ui/screens/AliasScreen.tsx +118 -100
  101. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  102. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  103. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  104. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  105. package/src/ui/screens/McpScreen.tsx +47 -33
  106. package/src/ui/screens/ModelsScreen.tsx +59 -104
  107. package/src/ui/screens/PluginsScreen.tsx +123 -127
  108. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  109. package/src/ui/screens/SkillsScreen.tsx +184 -204
  110. package/src/ui/screens/StylesScreen.tsx +104 -157
  111. package/src/ui/state/AppContext.tsx +6 -0
  112. package/src/ui/state/DimensionsContext.tsx +1 -10
  113. package/src/ui/state/reducer.ts +5 -8
  114. package/src/ui/state/types.ts +11 -9
  115. package/src/ui/text-width.ts +341 -0
  116. package/src/ui/theme-mode.ts +34 -71
  117. package/src/ui/theme.ts +50 -28
  118. package/src/utils/request-gate.ts +37 -0
  119. package/src/ui/components/ScopeIndicator.tsx +0 -63
  120. package/src/ui/components/SearchInput.tsx +0 -57
  121. package/src/ui/hooks/index.ts +0 -2
  122. package/src/ui/hooks/useKeyboard.ts +0 -24
  123. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -41,7 +41,8 @@ import {
41
41
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
42
42
  import { ScrollableList } from "../components/ScrollableList.js";
43
43
  import { ScreenLayout } from "../components/layout/index.js";
44
- import { useKeyboard } from "../hooks/useKeyboard.js";
44
+ import { useScreenKeys, useSearchInput } from "../input/KeyRouter.js";
45
+ import { type BindingId, moveDirection } from "../keymap.js";
45
46
  import {
46
47
  renderStyleDetail,
47
48
  renderStyleRow,
@@ -57,7 +58,7 @@ export function StylesScreen() {
57
58
  const modal = useModal();
58
59
 
59
60
  const isSearchActive =
60
- state.isSearching && state.currentRoute.screen === "styles" && !state.modal;
61
+ state.isSearching && state.currentRoute.screen === "styles";
61
62
 
62
63
  // ── Data ──────────────────────────────────────────────────────────────────
63
64
 
@@ -748,120 +749,93 @@ export function StylesScreen() {
748
749
  [stylesState.selectedIndex, allItems, dispatch],
749
750
  );
750
751
 
751
- useKeyboard((event) => {
752
- if (state.modal) return;
753
-
754
- // The previous message belongs to the previous ACTION, so clearing it here
755
- // covers every action key in one place and replaces the 5-second timer
756
- // that used to erase messages while they were still being read.
757
- //
758
- // Navigation is excluded. A digit or Tab is handled by the global
759
- // handler, not by this screen — and clearing on it wiped the message on
760
- // the way OUT of the tab, so a round trip lost it even though the state
761
- // now survives the unmount.
762
- const isNavigation =
763
- /^[0-9]$/.test(event.name ?? "") || event.name === "tab";
764
- if (stylesState.status && !isNavigation) {
765
- dispatch({ type: "STYLES_STATUS_CLEAR" });
766
- }
767
-
768
- const hasQuery = stylesState.searchQuery.length > 0;
769
-
770
- if (event.name === "escape") {
771
- if (hasQuery || isSearchActive) {
772
- dispatch({ type: "STYLES_SET_SEARCH", query: "" });
773
- dispatch({ type: "SET_SEARCHING", isSearching: false });
774
- dispatch({ type: "STYLES_SELECT", index: 0 });
775
- }
776
- return;
777
- }
778
-
779
- if (event.name === "backspace" || event.name === "delete") {
780
- if (hasQuery) {
781
- dispatch({ type: "STYLES_SEARCH_BACKSPACE" });
782
- // Leaving search mode is driven by what was there BEFORE this
783
- // keystroke, which is the one thing the closure does know reliably.
784
- if (stylesState.searchQuery.length <= 1) {
785
- dispatch({ type: "SET_SEARCHING", isSearching: false });
786
- }
787
- }
788
- return;
789
- }
752
+ // The search box, while it has focus: digits, j/k, q and ? are typed here.
753
+ useSearchInput({
754
+ focused: isSearchActive,
755
+ query: stylesState.searchQuery,
756
+ append: (char) => dispatch({ type: "STYLES_SEARCH_APPEND", char }),
757
+ erase: () => dispatch({ type: "STYLES_SEARCH_BACKSPACE" }),
758
+ clear: () => {
759
+ dispatch({ type: "STYLES_SET_SEARCH", query: "" });
760
+ dispatch({ type: "STYLES_SELECT", index: 0 });
761
+ },
762
+ blur: () => dispatch({ type: "SET_SEARCHING", isSearching: false }),
763
+ });
790
764
 
791
- if (event.name === "up") {
792
- if (isSearchActive)
793
- dispatch({ type: "SET_SEARCHING", isSearching: false });
794
- move(-1);
795
- return;
796
- }
797
- if (event.name === "down") {
798
- if (isSearchActive)
799
- dispatch({ type: "SET_SEARCHING", isSearching: false });
800
- move(1);
801
- return;
802
- }
765
+ /**
766
+ * The previous message belongs to the previous ACTION, so every key this
767
+ * screen acts on clears it first. Keys another layer takes — a digit or Tab
768
+ * leaving the tab, text typed into the search box — leave it alone, so a
769
+ * round trip through another tab keeps it.
770
+ */
771
+ const clearStatus = () => {
772
+ if (stylesState.status) dispatch({ type: "STYLES_STATUS_CLEAR" });
773
+ };
803
774
 
804
- if (event.name === "space" || event.name === " ") {
775
+ useScreenKeys("styles", {
776
+ toggle: () => {
777
+ clearStatus();
805
778
  handleToggle();
806
- return;
807
- }
808
-
809
- if (event.name === "return" || event.name === "enter") {
810
- if (isSearchActive) {
811
- dispatch({ type: "SET_SEARCHING", isSearching: false });
812
- return;
813
- }
814
- // Enter opens the style for editing; Space is the ticking key. Enter
815
- // reads as "go into this thing", and ticking already has a key that
816
- // means exactly that and nothing else.
817
- handleOpen();
818
- return;
819
- }
820
-
821
- // While typing a filter, letters are text — not commands.
822
- if (isSearchActive) {
823
- if (
824
- event.name &&
825
- event.name.length === 1 &&
826
- !event.ctrl &&
827
- !event.meta &&
828
- !/[0-9]/.test(event.name)
829
- ) {
830
- dispatch({ type: "STYLES_SEARCH_APPEND", char: event.name });
831
- }
832
- return;
833
- }
834
-
835
- // Shift+U is tested BEFORE plain u. OpenTUI reports it as
836
- // `{name: "u", shift: true}`, so a lowercase branch above this one would
837
- // swallow it — the exact bug `uppercase-keybindings.test.ts` exists for.
838
- if (event.name === "u" && event.shift) {
839
- handleCheckUpdates(true);
840
- } else if (event.name === "u") {
841
- handleCheckUpdates(false);
842
- } else if (event.name === "k") {
843
- move(-1);
844
- } else if (event.name === "j") {
845
- move(1);
846
- } else if (event.name === "a") {
847
- handleApply();
848
- } else if (event.name === "c") {
849
- handleClear();
850
- } else if (event.name === "x") {
779
+ },
780
+ apply: () => {
781
+ clearStatus();
782
+ void handleApply();
783
+ },
784
+ fill: () => {
785
+ clearStatus();
786
+ void handleFillTemplate();
787
+ },
788
+ clear: () => {
789
+ clearStatus();
790
+ void handleClear();
791
+ },
792
+ // Enter opens the style for editing; Space is the ticking key. Enter
793
+ // reads as "go into this thing", and ticking already has a key that
794
+ // means exactly that and nothing else.
795
+ edit: () => {
796
+ clearStatus();
797
+ void handleOpen();
798
+ },
799
+ fetch: () => {
800
+ clearStatus();
801
+ void handleFetch();
802
+ },
803
+ new: () => {
804
+ clearStatus();
805
+ void handleNewTeamStyle();
806
+ },
807
+ check: () => {
808
+ clearStatus();
809
+ void handleCheckUpdates(false);
810
+ },
811
+ "check-all": () => {
812
+ clearStatus();
813
+ void handleCheckUpdates(true);
814
+ },
815
+ reset: () => {
816
+ clearStatus();
851
817
  handleReset();
852
- } else if (event.name === "n") {
853
- handleNewTeamStyle();
854
- } else if (event.name === "e") {
855
- handleOpen();
856
- } else if (event.name === "f") {
857
- handleFetch();
858
- } else if (event.name === "t") {
859
- handleFillTemplate();
860
- } else if (event.name === "r") {
861
- fetchData();
862
- } else if (event.name === "/") {
818
+ },
819
+ refresh: () => {
820
+ clearStatus();
821
+ void fetchData();
822
+ },
823
+ search: () => {
824
+ clearStatus();
863
825
  dispatch({ type: "SET_SEARCHING", isSearching: true });
864
- }
826
+ },
827
+ // Only while a committed filter is set; otherwise Esc is the global back.
828
+ "clear-filter": () => {
829
+ if (!stylesState.searchQuery) return false;
830
+ clearStatus();
831
+ dispatch({ type: "STYLES_SET_SEARCH", query: "" });
832
+ dispatch({ type: "STYLES_SELECT", index: 0 });
833
+ return true;
834
+ },
835
+ move: (key) => {
836
+ clearStatus();
837
+ move(moveDirection(key));
838
+ },
865
839
  });
866
840
 
867
841
  // ── Status line ───────────────────────────────────────────────────────────
@@ -887,7 +861,7 @@ export function StylesScreen() {
887
861
  {statusMsg.text}
888
862
  </text>
889
863
  ) : isFilling ? (
890
- <text fg={theme.colors.warning}>
864
+ <text fg={theme.colors.muted}>
891
865
  Reading the codebase to fill a template… this can take a few minutes
892
866
  </text>
893
867
  ) : undefined;
@@ -910,9 +884,9 @@ export function StylesScreen() {
910
884
  <span fg={theme.colors.info}>{` │ profile ${snapshot.profile}`}</span>
911
885
  ) : null}
912
886
  {isApplying ? (
913
- <span fg={theme.colors.warning}> │ applying…</span>
887
+ <span fg={theme.colors.muted}> │ applying…</span>
914
888
  ) : isFetching ? (
915
- <span fg={theme.colors.warning}> │ talking to GitHub…</span>
889
+ <span fg={theme.colors.muted}> │ talking to GitHub…</span>
916
890
  ) : errors.length > 0 ? (
917
891
  <span fg={theme.colors.danger}> │ selection invalid</span>
918
892
  ) : syncState === "not-applied" ? (
@@ -948,41 +922,26 @@ export function StylesScreen() {
948
922
  selectedItem?.kind === "style" &&
949
923
  selectedItem.source.kind === "imported" &&
950
924
  selectedItem.source.origin === "community";
951
- const selectedHasUpdate =
952
- selectedItem?.kind === "style" &&
953
- selectedItem.upstream?.state === "update-available";
954
925
 
955
926
  /**
956
- * Row-sensitive, and it has to REPLACE rather than append.
957
- *
958
- * The tab bar already degrades at about 94 columns, so a footer that only
959
- * grows pushes itself off the screen on a split pane. `n` and `e` stay bound
960
- * on a community row; they are simply not the keys that row is about.
927
+ * Row-sensitive: the footer advertises the keys this row is about. `n` and
928
+ * `e` stay bound on a community row; they are simply not what that row is
929
+ * for. Everything hidden here still works and is still listed in help.
961
930
  */
962
- const rowHints = selectedIsOffer
963
- ? [
964
- { keys: ["Space"], label: "fetch" },
965
- { keys: ["a"], label: "apply" },
966
- ]
931
+ const rowHidden: BindingId<"styles">[] = selectedIsOffer
932
+ ? ["edit", "new", "fill", "check", "check-all", "reset"]
967
933
  : selectedIsCommunity
968
- ? [
969
- { keys: ["Space"], label: "toggle" },
970
- { keys: ["a"], label: "apply" },
971
- {
972
- keys: ["f"],
973
- label: selectedHasUpdate ? "accept update" : "re-fetch",
974
- },
975
- { keys: ["u"], label: "check" },
976
- ]
934
+ ? ["new", "edit", "fill"]
977
935
  : [
978
- { keys: ["Space"], label: "toggle" },
979
- { keys: ["a"], label: "apply" },
980
- { keys: ["n"], label: "new" },
981
- { keys: ["e"], label: "edit" },
982
- ...(selectedIsTemplate
983
- ? [{ keys: ["t"], label: "fill from codebase" }]
984
- : []),
936
+ "fetch",
937
+ "check",
938
+ "check-all",
939
+ ...(selectedIsTemplate ? [] : ["fill" as const]),
985
940
  ];
941
+ const hiddenHints: BindingId<"styles">[] = [
942
+ ...rowHidden,
943
+ ...(stylesState.searchQuery ? [] : ["clear-filter" as const]),
944
+ ];
986
945
 
987
946
  return (
988
947
  <ScreenLayout
@@ -999,24 +958,12 @@ export function StylesScreen() {
999
958
  }
1000
959
  : undefined
1001
960
  }
1002
- footerHints={
1003
- isSearchActive
1004
- ? [
1005
- { keys: ["type"], label: "filter" },
1006
- { keys: ["Enter"], label: "done" },
1007
- { keys: ["Esc"], label: "clear" },
1008
- ]
1009
- : [
1010
- ...rowHints,
1011
- { keys: ["c"], label: "clear" },
1012
- { keys: ["/"], label: "filter" },
1013
- ]
1014
- }
961
+ footer={{ hide: hiddenHints }}
1015
962
  listPanel={(listWidth) => (
1016
963
  <box flexDirection="column">
1017
964
  {stylesState.snapshot.status === "loading" && (
1018
965
  <box paddingLeft={2}>
1019
- <text fg={theme.colors.warning}>Reading styles…</text>
966
+ <text fg={theme.colors.muted}>Reading styles…</text>
1020
967
  </box>
1021
968
  )}
1022
969
 
@@ -7,6 +7,7 @@ import type {
7
7
  ModalState,
8
8
  ProgressState,
9
9
  Route,
10
+ Screen,
10
11
  } from "./types.js";
11
12
 
12
13
  // ============================================================================
@@ -204,6 +205,11 @@ export function useModal() {
204
205
  },
205
206
  });
206
207
  },
208
+
209
+ /** The key help overlay, with `screen`'s keys first. Esc or `?` closes it. */
210
+ help: (screen: Screen) => {
211
+ dispatch({ type: "SHOW_MODAL", modal: { type: "help", screen } });
212
+ },
207
213
  };
208
214
  }
209
215
 
@@ -27,8 +27,6 @@ interface DimensionsProviderProps {
27
27
  children: React.ReactNode;
28
28
  /** Whether progress bar is visible */
29
29
  showProgress?: boolean;
30
- /** Whether debug bar is visible */
31
- showDebug?: boolean;
32
30
  /** Whether update banner is visible */
33
31
  showUpdateBanner?: boolean;
34
32
  /** Number of transient banners (recovery, mismatch) currently visible */
@@ -39,7 +37,6 @@ function calculateDimensions(
39
37
  columns: number,
40
38
  rows: number,
41
39
  showProgress: boolean,
42
- showDebug: boolean,
43
40
  showUpdateBanner: boolean,
44
41
  transientBannerCount: number,
45
42
  ): Dimensions {
@@ -49,7 +46,6 @@ function calculateDimensions(
49
46
  // Calculate total available height for ScreenLayout (includes its header, panels, footer)
50
47
  let contentHeight = terminalHeight - APP_MARGINS;
51
48
  if (showProgress) contentHeight -= PROGRESS_HEIGHT;
52
- if (showDebug) contentHeight -= 1;
53
49
  if (showUpdateBanner) contentHeight -= 1;
54
50
  contentHeight -= transientBannerCount;
55
51
  contentHeight = Math.max(10, contentHeight); // Minimum 10 lines for full layout
@@ -77,7 +73,6 @@ function calculateDimensions(
77
73
  export function DimensionsProvider({
78
74
  children,
79
75
  showProgress = false,
80
- showDebug = false,
81
76
  showUpdateBanner = false,
82
77
  transientBannerCount = 0,
83
78
  }: DimensionsProviderProps) {
@@ -88,7 +83,6 @@ export function DimensionsProvider({
88
83
  renderer.width,
89
84
  renderer.height,
90
85
  showProgress,
91
- showDebug,
92
86
  showUpdateBanner,
93
87
  transientBannerCount,
94
88
  ),
@@ -101,21 +95,19 @@ export function DimensionsProvider({
101
95
  width,
102
96
  height,
103
97
  showProgress,
104
- showDebug,
105
98
  showUpdateBanner,
106
99
  transientBannerCount,
107
100
  ),
108
101
  );
109
102
  });
110
103
 
111
- // Update when showProgress/showDebug/showUpdateBanner/transientBannerCount changes
104
+ // Update when showProgress/showUpdateBanner/transientBannerCount changes
112
105
  useEffect(() => {
113
106
  setDimensions(
114
107
  calculateDimensions(
115
108
  renderer.width,
116
109
  renderer.height,
117
110
  showProgress,
118
- showDebug,
119
111
  showUpdateBanner,
120
112
  transientBannerCount,
121
113
  ),
@@ -124,7 +116,6 @@ export function DimensionsProvider({
124
116
  renderer.width,
125
117
  renderer.height,
126
118
  showProgress,
127
- showDebug,
128
119
  showUpdateBanner,
129
120
  transientBannerCount,
130
121
  ]);
@@ -12,7 +12,6 @@ export const initialState: AppState = {
12
12
  modal: null,
13
13
  progress: null,
14
14
  isSearching: false,
15
- detailActive: false,
16
15
 
17
16
  // Refresh counter for triggering data refetch
18
17
  dataRefreshVersion: 0,
@@ -104,9 +103,8 @@ export function appReducer(state: AppState, action: AppAction): AppState {
104
103
  return {
105
104
  ...state,
106
105
  currentRoute: action.route,
107
- // Clear transient sub-view state when navigating away
106
+ // A search box belongs to the screen it was opened on.
108
107
  isSearching: false,
109
- detailActive: false,
110
108
  };
111
109
 
112
110
  // =========================================================================
@@ -771,11 +769,13 @@ export function appReducer(state: AppState, action: AppAction): AppState {
771
769
  // =========================================================================
772
770
  // Modals
773
771
  // =========================================================================
772
+ // The modal key layer shields everything below it, so opening a dialog
773
+ // leaves a focused search box focused for when it closes.
774
774
  case "SHOW_MODAL":
775
- return { ...state, modal: action.modal, isSearching: true };
775
+ return { ...state, modal: action.modal };
776
776
 
777
777
  case "HIDE_MODAL":
778
- return { ...state, modal: null, isSearching: false };
778
+ return { ...state, modal: null };
779
779
 
780
780
  // =========================================================================
781
781
  // Progress
@@ -795,9 +795,6 @@ export function appReducer(state: AppState, action: AppAction): AppState {
795
795
  case "SET_SEARCHING":
796
796
  return { ...state, isSearching: action.isSearching };
797
797
 
798
- case "SET_DETAIL_ACTIVE":
799
- return { ...state, detailActive: action.detailActive };
800
-
801
798
  // =========================================================================
802
799
  // Data Refresh
803
800
  // =========================================================================
@@ -131,6 +131,11 @@ export type ModalState =
131
131
  | {
132
132
  type: "loading";
133
133
  message: string;
134
+ }
135
+ | {
136
+ /** The key help overlay, opened with `?`, listing `screen` first. */
137
+ type: "help";
138
+ screen: Screen;
134
139
  };
135
140
 
136
141
  // ============================================================================
@@ -348,12 +353,12 @@ export interface AppState {
348
353
  // Global UI state
349
354
  modal: ModalState | null;
350
355
  progress: ProgressState | null;
356
+ /**
357
+ * The current screen's search box has focus. It decides what the box draws
358
+ * and whether the screen's input key layer is active; key routing itself
359
+ * reads no flag — see ui/input/key-dispatch.ts.
360
+ */
351
361
  isSearching: boolean;
352
- // True while a screen has a focused sub-view open that owns Escape (e.g.
353
- // the Alias tab's flag-detail editor). The global key handler skips its
354
- // Escape→navigate branch so Escape backs out of the sub-view instead of
355
- // jumping to the home tab. Mirrors how `modal`/`isSearching` gate keys.
356
- detailActive: boolean;
357
362
 
358
363
  // Refresh counter - incremented when marketplace sync completes
359
364
  // Screens can watch this to know when to refetch data
@@ -449,12 +454,9 @@ export type AppAction =
449
454
  | { type: "UPDATE_PROGRESS"; state: ProgressState }
450
455
  | { type: "HIDE_PROGRESS" }
451
456
 
452
- // Search state (blocks global key handlers)
457
+ // Search box focus
453
458
  | { type: "SET_SEARCHING"; isSearching: boolean }
454
459
 
455
- // Detail sub-view state (gates global Escape→navigate)
456
- | { type: "SET_DETAIL_ACTIVE"; detailActive: boolean }
457
-
458
460
  // Profiles screen
459
461
  | { type: "PROFILES_SELECT"; index: number }
460
462
  | { type: "PROFILES_DATA_LOADING" }