magus-cli 7.3.1 → 7.4.1

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 (127) 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/c19-paste-routing.test.ts +252 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  15. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  16. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  17. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  18. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  19. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  20. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  21. package/src/__tests__/blind/child-run.ts +114 -0
  22. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  23. package/src/__tests__/component-badge.test.ts +20 -36
  24. package/src/__tests__/footer-hints.test.ts +1 -1
  25. package/src/__tests__/manifest.test.ts +25 -17
  26. package/src/__tests__/marketplace-badge.test.ts +4 -4
  27. package/src/__tests__/models-screen-state.test.ts +36 -13
  28. package/src/__tests__/scope-squares.test.tsx +28 -114
  29. package/src/__tests__/shutdown.test.ts +578 -0
  30. package/src/__tests__/stale-load-repair.test.ts +5 -4
  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__/uppercase-keybindings.test.ts +25 -12
  37. package/src/cli/ansi.ts +2 -2
  38. package/src/cli/bootstrap.ts +33 -2
  39. package/src/cli/doctor.ts +120 -15
  40. package/src/cli/install.ts +35 -4
  41. package/src/cli/profile.ts +18 -1
  42. package/src/cli/router.ts +31 -18
  43. package/src/cli/update-view.ts +1 -1
  44. package/src/data/cli-tools.ts +9 -1
  45. package/src/services/claude-settings.ts +434 -198
  46. package/src/services/cli-tool-commands.ts +45 -5
  47. package/src/services/cli-tool-version.ts +101 -0
  48. package/src/services/gitignore-service.ts +39 -22
  49. package/src/services/hook-registration.ts +23 -6
  50. package/src/services/manifest.ts +117 -52
  51. package/src/services/plugin-manager.ts +19 -5
  52. package/src/services/profile-adopt.ts +7 -2
  53. package/src/services/profile-startup.ts +87 -7
  54. package/src/services/profiles.ts +36 -23
  55. package/src/services/settings-file.ts +164 -4
  56. package/src/services/settings-manager.ts +19 -5
  57. package/src/services/skills-manager.ts +46 -0
  58. package/src/services/tool-probe.ts +316 -44
  59. package/src/services/toolchain.ts +39 -0
  60. package/src/services/update-engine.ts +5 -0
  61. package/src/services/update-plan.ts +9 -0
  62. package/src/tui.tsx +48 -21
  63. package/src/ui/App.tsx +58 -197
  64. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  65. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  66. package/src/ui/adapters/settingsAdapter.ts +0 -21
  67. package/src/ui/adapters/skillsAdapter.ts +27 -8
  68. package/src/ui/adapters/stylesAdapter.ts +15 -11
  69. package/src/ui/components/CategoryHeader.tsx +7 -1
  70. package/src/ui/components/EmptyFilterState.tsx +2 -2
  71. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  72. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  73. package/src/ui/components/TabBar.tsx +93 -21
  74. package/src/ui/components/layout/FooterHints.tsx +6 -27
  75. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  76. package/src/ui/components/layout/index.ts +1 -5
  77. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  78. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  79. package/src/ui/components/modals/MessageModal.tsx +3 -15
  80. package/src/ui/components/modals/ModalContainer.tsx +107 -60
  81. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  82. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  83. package/src/ui/components/primitives/MetaText.tsx +1 -1
  84. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  85. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  86. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  87. package/src/ui/components/primitives/index.ts +4 -3
  88. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  89. package/src/ui/input/KeyRouter.tsx +304 -0
  90. package/src/ui/input/key-dispatch.ts +197 -0
  91. package/src/ui/keymap.ts +905 -0
  92. package/src/ui/renderers/cliToolRenderers.tsx +192 -92
  93. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  94. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  95. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  96. package/src/ui/renderers/modelRenderers.tsx +12 -13
  97. package/src/ui/renderers/modelVisuals.tsx +0 -2
  98. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  99. package/src/ui/renderers/profileRenderers.tsx +32 -37
  100. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  101. package/src/ui/renderers/skillRenderers.tsx +237 -190
  102. package/src/ui/renderers/styleRenderers.tsx +13 -2
  103. package/src/ui/runtime/shutdown.ts +268 -0
  104. package/src/ui/screens/AliasScreen.tsx +118 -100
  105. package/src/ui/screens/CliToolsScreen.tsx +173 -101
  106. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  107. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  108. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  109. package/src/ui/screens/McpScreen.tsx +47 -33
  110. package/src/ui/screens/ModelsScreen.tsx +59 -104
  111. package/src/ui/screens/PluginsScreen.tsx +129 -129
  112. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  113. package/src/ui/screens/SkillsScreen.tsx +184 -204
  114. package/src/ui/screens/StylesScreen.tsx +104 -157
  115. package/src/ui/state/AppContext.tsx +6 -0
  116. package/src/ui/state/DimensionsContext.tsx +1 -10
  117. package/src/ui/state/reducer.ts +5 -8
  118. package/src/ui/state/types.ts +11 -9
  119. package/src/ui/text-width.ts +341 -0
  120. package/src/ui/theme-mode.ts +34 -71
  121. package/src/ui/theme.ts +50 -28
  122. package/src/utils/request-gate.ts +37 -0
  123. package/src/ui/components/ScopeIndicator.tsx +0 -63
  124. package/src/ui/components/SearchInput.tsx +0 -57
  125. package/src/ui/hooks/index.ts +0 -2
  126. package/src/ui/hooks/useKeyboard.ts +0 -24
  127. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -4,6 +4,7 @@ import type { ApplyReporter } from "../../services/update-engine.js";
4
4
  import type { PluginUpdateItem } from "../../services/update-plan.js";
5
5
  import type { Marketplace } from "../../types/index.js";
6
6
  import type { PluginContentsEntry } from "../state/types.js";
7
+ import type { BadgeTone } from "../theme.js";
7
8
 
8
9
  // Virtual marketplace name for the community sub-section of claude-plugins-official
9
10
  export const COMMUNITY_VIRTUAL_MARKETPLACE =
@@ -23,7 +24,7 @@ export interface PluginCategoryItem {
23
24
  isExpanded: boolean;
24
25
  isCommunitySection?: boolean;
25
26
  /** Visual tone for the category row */
26
- tone: "yellow" | "gray" | "green" | "red" | "purple" | "teal" | "byline";
27
+ tone: BadgeTone | "byline";
27
28
  /** Badge text shown on category row (e.g. "★ Official") */
28
29
  badge?: string;
29
30
  }
@@ -196,24 +197,26 @@ function categoryStyling(
196
197
  marketplaceEnabled: boolean,
197
198
  ): { tone: PluginCategoryItem["tone"]; badge?: string } {
198
199
  if (!marketplaceEnabled) {
199
- return { tone: "gray" };
200
+ return { tone: "neutral" };
200
201
  }
201
202
  if (isCommunitySection) {
202
- return { tone: "gray", badge: "3rd Party" };
203
+ return { tone: "neutral", badge: "3rd Party" };
203
204
  }
205
+ // `info`, not `warning`: "official" is a fact about the source, and amber
206
+ // means "needs a look". The ★ carries the distinction.
204
207
  if (mp.name === "claude-plugins-official") {
205
- return { tone: "yellow", badge: "★ Official" };
208
+ return { tone: "info", badge: "★ Official" };
206
209
  }
207
210
  if (mp.name === "claude-code-plugins") {
208
- return { tone: "red", badge: "⚠ Deprecated" };
211
+ return { tone: "danger", badge: "⚠ Deprecated" };
209
212
  }
210
213
  if (mp.official) {
211
- return { tone: "yellow", badge: "★ Official" };
214
+ return { tone: "info", badge: "★ Official" };
212
215
  }
213
216
  if (isMadAppGang(mp)) {
214
217
  return { tone: "byline", badge: "by MadAppGang" };
215
218
  }
216
- return { tone: "green", badge: "✓ Added" };
219
+ return { tone: "success", badge: "✓ Added" };
217
220
  }
218
221
 
219
222
  /**
@@ -4,7 +4,6 @@ import {
4
4
  type SettingDefinition,
5
5
  } from "../../data/settings-catalog.js";
6
6
  import type { ScopedSettingValues } from "../../services/settings-manager.js";
7
- import { type UiColor, theme } from "../theme.js";
8
7
 
9
8
  // ─── Item types ────────────────────────────────────────────────────────────────
10
9
 
@@ -51,26 +50,6 @@ export const CATEGORY_ORDER: SettingCategory[] = [
51
50
  "advanced",
52
51
  ];
53
52
 
54
- export const CATEGORY_BG: Record<SettingCategory, UiColor> = {
55
- recommended: theme.colors.success,
56
- agents: theme.colors.info,
57
- models: theme.colors.accent,
58
- workflow: theme.colors.link,
59
- terminal: theme.colors.warning,
60
- performance: theme.colors.accent,
61
- advanced: theme.colors.warning,
62
- };
63
-
64
- export const CATEGORY_COLOR: Record<SettingCategory, UiColor> = {
65
- recommended: theme.colors.success,
66
- agents: theme.colors.info,
67
- models: theme.colors.info,
68
- workflow: theme.colors.link,
69
- terminal: theme.colors.link,
70
- performance: "magentaBright",
71
- advanced: theme.colors.warning,
72
- };
73
-
74
53
  export const CATEGORY_DESCRIPTIONS: Record<SettingCategory, string> = {
75
54
  recommended: "Most impactful settings every user should know.",
76
55
  agents: "Agent teams, task lists, and subagent configuration.",
@@ -1,4 +1,5 @@
1
1
  import type { SkillInfo, SkillSetInfo } from "../../types/index.js";
2
+ import type { BadgeTone } from "../theme.js";
2
3
 
3
4
  // ─── Item types ───────────────────────────────────────────────────────────────
4
5
 
@@ -9,7 +10,7 @@ export interface SkillCategoryItem {
9
10
  title: string;
10
11
  categoryKey: string;
11
12
  count?: number;
12
- tone: "purple" | "green" | "teal" | "yellow" | "gray" | "red";
13
+ tone: BadgeTone;
13
14
  badge?: string;
14
15
  star?: string;
15
16
  }
@@ -38,6 +39,25 @@ export type SkillBrowserItem =
38
39
 
39
40
  // ─── Adapter ─────────────────────────────────────────────────────────────────
40
41
 
42
+ /**
43
+ * The skills the Installed group lists: every skill on disk, narrowed by the
44
+ * search query.
45
+ *
46
+ * The ONE selector for "installed" on this screen. The header's "N installed"
47
+ * used to count a different list — the fetched catalogue's `installed` flags —
48
+ * so it said "1 installed" above a group headed "Installed (16)". Both now read
49
+ * this, so they cannot disagree.
50
+ */
51
+ export function installedGroup(
52
+ installed: readonly SkillInfo[],
53
+ query: string,
54
+ ): SkillInfo[] {
55
+ const lowerQuery = query.toLowerCase();
56
+ return lowerQuery
57
+ ? installed.filter((s) => s.name.toLowerCase().includes(lowerQuery))
58
+ : [...installed];
59
+ }
60
+
41
61
  export interface BuildSkillBrowserItemsArgs {
42
62
  recommended: SkillInfo[];
43
63
  popular: SkillInfo[];
@@ -67,9 +87,7 @@ export function buildSkillBrowserItems({
67
87
  const items: SkillBrowserItem[] = [];
68
88
 
69
89
  // ── INSTALLED: always shown at top (if any) ──
70
- const installedFiltered = lowerQuery
71
- ? installed.filter((s) => s.name.toLowerCase().includes(lowerQuery))
72
- : installed;
90
+ const installedFiltered = installedGroup(installed, query);
73
91
 
74
92
  if (installedFiltered.length > 0) {
75
93
  items.push({
@@ -79,7 +97,8 @@ export function buildSkillBrowserItems({
79
97
  title: "Installed",
80
98
  categoryKey: "installed",
81
99
  count: installedFiltered.length,
82
- tone: "purple",
100
+ // The group's name is a state, so it wears that state's role.
101
+ tone: "success",
83
102
  star: "\u25CF ",
84
103
  });
85
104
  for (const skill of installedFiltered) {
@@ -123,7 +142,7 @@ export function buildSkillBrowserItems({
123
142
  title: "Recommended",
124
143
  categoryKey: "recommended",
125
144
  count: recommendedCount,
126
- tone: "green",
145
+ tone: "neutral",
127
146
  star: "\u2605 ",
128
147
  });
129
148
 
@@ -180,7 +199,7 @@ export function buildSkillBrowserItems({
180
199
  title: "Search",
181
200
  categoryKey: "popular",
182
201
  count: deduped.length,
183
- tone: "teal",
202
+ tone: "neutral",
184
203
  });
185
204
  for (const skill of deduped) {
186
205
  items.push({
@@ -213,7 +232,7 @@ export function buildSkillBrowserItems({
213
232
  title: "Popular",
214
233
  categoryKey: "popular",
215
234
  count: popularDeduped.length,
216
- tone: "teal",
235
+ tone: "neutral",
217
236
  });
218
237
  for (const skill of popularDeduped) {
219
238
  items.push({
@@ -10,6 +10,7 @@ import type {
10
10
  StylePreset,
11
11
  StyleSource,
12
12
  } from "../../services/styles-manager.js";
13
+ import type { BadgeTone } from "../theme.js";
13
14
 
14
15
  // ─── Item types ───────────────────────────────────────────────────────────────
15
16
 
@@ -44,7 +45,7 @@ export interface StyleCategoryItem {
44
45
  title: string;
45
46
  categoryKey: StyleCategoryKey;
46
47
  count: number;
47
- tone: "purple" | "green" | "teal" | "yellow" | "gray" | "red";
48
+ tone: BadgeTone;
48
49
  badge?: string;
49
50
  }
50
51
 
@@ -326,11 +327,11 @@ export function buildStyleBrowserItems({
326
327
  title: "Community",
327
328
  categoryKey: "community",
328
329
  count: rows.length + orphans.length,
329
- // Yellow, shared with Anthropic captures: both sections are text
330
- // captured from somewhere else onto this disk, which is the right
331
- // pairing. Red is the only unused tone and it means danger here — a
332
- // normal, working section must not wear it.
333
- tone: "yellow",
330
+ // `info`, shared with Anthropic captures: both sections are text
331
+ // captured from somewhere else onto this disk — a fact about where it
332
+ // came from, which is what `info` means. Never `danger`: a normal,
333
+ // working section must not wear it.
334
+ tone: "info",
334
335
  badge: "from GitHub",
335
336
  });
336
337
 
@@ -373,17 +374,20 @@ export function buildStyleBrowserItems({
373
374
  // count, and a long badge wraps onto a second line on a narrow pane, which
374
375
  // costs a whole row and breaks the scan down the left column. The full
375
376
  // explanation lives in the detail panel, where there is room for it.
377
+ //
378
+ // Sections are groups, not states, so they wear `neutral` — except the two
379
+ // captured from outside, which share `info` (see pushCommunity).
376
380
  pushImports(
377
381
  "anthropic",
378
382
  "anthropic",
379
383
  "Anthropic official",
380
- "yellow",
384
+ "info",
381
385
  "captured",
382
386
  );
383
- pushPresets(verbosity, "verbosity", "Verbosity", "purple", "pick one");
384
- pushPresets(modifiers, "modifier", "Modifiers", "teal", "combine freely");
385
- pushImports("team", "team", "Team", "green", "in the repo");
386
- pushImports("personal", "personal", "Personal", "gray", "this machine");
387
+ pushPresets(verbosity, "verbosity", "Verbosity", "neutral", "pick one");
388
+ pushPresets(modifiers, "modifier", "Modifiers", "neutral", "combine freely");
389
+ pushImports("team", "team", "Team", "neutral", "in the repo");
390
+ pushImports("personal", "personal", "Personal", "neutral", "this machine");
387
391
  pushCommunity();
388
392
 
389
393
  return items;
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { type UiColor, theme } from "../theme.js";
3
+ import { rowLead } from "./primitives/SelectableRow.js";
3
4
 
4
5
  interface CategoryHeaderProps {
5
6
  /** Category title */
@@ -38,7 +39,12 @@ export function CategoryHeader({
38
39
  <box flexDirection="row" width="100%" height={1} overflow="hidden">
39
40
  <box flexShrink={1} minWidth={4} overflow="hidden" height={1}>
40
41
  <text fg={theme.colors.text}>
41
- <span fg={theme.colors.muted}>{expandIcon}</span>
42
+ {/* The lead cell every list row keeps blank, so the arrow sits in
43
+ the same column as it does on the selected row. */}
44
+ <span fg={theme.colors.muted}>
45
+ {rowLead(false)}
46
+ {expandIcon}
47
+ </span>
42
48
  <span fg={theme.colors.text}>
43
49
  <strong> {title}</strong>
44
50
  </span>
@@ -12,7 +12,7 @@ export function EmptyFilterState({
12
12
  }: EmptyFilterStateProps) {
13
13
  return (
14
14
  <box flexDirection="column" marginTop={2} paddingLeft={2} paddingRight={2}>
15
- <text fg={theme.colors.warning}>
15
+ <text fg={theme.colors.text}>
16
16
  No {entityName} found for "{query}"
17
17
  </text>
18
18
  <box marginTop={1}>
@@ -22,7 +22,7 @@ export function EmptyFilterState({
22
22
  </box>
23
23
  <text fg={theme.colors.muted}>this is a bug, report it at:</text>
24
24
  <box marginTop={1}>
25
- <text fg={theme.colors.link}>github.com/MadAppGang/magus/issues</text>
25
+ <text fg={theme.colors.info}>github.com/MadAppGang/magus/issues</text>
26
26
  </box>
27
27
  <box marginTop={1}>
28
28
  <text fg={theme.colors.muted}>Press Esc to clear the filter.</text>
@@ -0,0 +1,105 @@
1
+ import React from "react";
2
+ import type { Screen } from "../state/types.js";
3
+ import { theme } from "../theme.js";
4
+ import { ScreenLayout } from "./layout/ScreenLayout.js";
5
+ import { ErrorNotice } from "./primitives/index.js";
6
+
7
+ interface ScreenErrorBoundaryProps {
8
+ /** The screen being guarded. The fallback keeps its tab highlighted. */
9
+ screen: Screen;
10
+ children: React.ReactNode;
11
+ }
12
+
13
+ interface ScreenErrorBoundaryState {
14
+ error: Error | null;
15
+ /** The screen the error belongs to, so leaving it clears the error. */
16
+ screen: Screen;
17
+ }
18
+
19
+ /**
20
+ * Contains a render error to the screen that threw it.
21
+ *
22
+ * Without it a throw in any one screen reached OpenTUI's root boundary, which
23
+ * replaces the WHOLE app with a stack trace: the tab bar went with it, and so
24
+ * did every key handler mounted under the root, leaving the user nothing to
25
+ * press but Ctrl+C. Here the failing screen alone is replaced by an error
26
+ * panel that keeps the tab bar, and the global keys (digits, Tab, q, ?, Esc)
27
+ * stay live: they are a key layer of their own, and the failed screen's layers
28
+ * unregistered when it unmounted — a search box it left focused included.
29
+ *
30
+ * Switching tab clears the error, which is also how the user retries: coming
31
+ * back mounts the screen fresh.
32
+ */
33
+ export class ScreenErrorBoundary extends React.Component<
34
+ ScreenErrorBoundaryProps,
35
+ ScreenErrorBoundaryState
36
+ > {
37
+ constructor(props: ScreenErrorBoundaryProps) {
38
+ super(props);
39
+ this.state = { error: null, screen: props.screen };
40
+ }
41
+
42
+ static getDerivedStateFromProps(
43
+ props: ScreenErrorBoundaryProps,
44
+ state: ScreenErrorBoundaryState,
45
+ ): Partial<ScreenErrorBoundaryState> | null {
46
+ // A different screen owes nothing to the previous one's failure.
47
+ return props.screen === state.screen
48
+ ? null
49
+ : { error: null, screen: props.screen };
50
+ }
51
+
52
+ static getDerivedStateFromError(
53
+ error: unknown,
54
+ ): Partial<ScreenErrorBoundaryState> {
55
+ return {
56
+ error: error instanceof Error ? error : new Error(String(error)),
57
+ };
58
+ }
59
+
60
+ render(): React.ReactNode {
61
+ if (this.state.error) {
62
+ return (
63
+ <ScreenErrorPanel screen={this.props.screen} error={this.state.error} />
64
+ );
65
+ }
66
+ return this.props.children;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * The panel drawn in place of a screen that failed to render. Its footer
72
+ * advertises only what still works — the global and scroll keys — because the
73
+ * screen's own bindings went with the screen.
74
+ */
75
+ function ScreenErrorPanel({
76
+ screen,
77
+ error,
78
+ }: {
79
+ screen: Screen;
80
+ error: Error;
81
+ }) {
82
+ const stack = (error.stack ?? "").split("\n").slice(1);
83
+
84
+ return (
85
+ <ScreenLayout
86
+ title="magus — this screen failed"
87
+ currentScreen={screen}
88
+ footer={{ owner: "none" }}
89
+ listPanel={
90
+ <ErrorNotice
91
+ title="This screen stopped drawing because of an error."
92
+ detail={error.message || String(error)}
93
+ hint="Other tabs still work. Switch away and back to retry."
94
+ />
95
+ }
96
+ detailPanel={stack.map((line, index) => (
97
+ // Stack lines have no identity beyond their position.
98
+ // biome-ignore lint/suspicious/noArrayIndexKey: a static, never-reordered list
99
+ <text key={index} fg={theme.colors.muted}>
100
+ {line.trim()}
101
+ </text>
102
+ ))}
103
+ />
104
+ );
105
+ }
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
  import { useDimensions } from "../state/DimensionsContext.js";
3
3
  import type { Screen } from "../state/types.js";
4
+ import { displayWidth } from "../text-width.js";
4
5
  import { theme } from "../theme.js";
5
6
 
6
7
  interface Tab {
@@ -26,36 +27,106 @@ export const TABS: Tab[] = [
26
27
  { key: "0", label: "Models", screen: "models" },
27
28
  ];
28
29
 
29
- /** Columns one tab occupies: the text plus its one-space padding each side. */
30
- function cellWidth(text: string): number {
31
- return text.length + 2;
30
+ /** What the bar draws: one text per tab, in TABS order, and whether each cell
31
+ * carries its one-space padding either side. */
32
+ export interface TabLayout {
33
+ texts: string[];
34
+ padded: boolean;
32
35
  }
33
36
 
34
- /** Columns the whole bar occupies: every cell plus the separators between. */
35
- export function barWidth(texts: string[]): number {
37
+ /** Shortest label prefix an inactive tab may shrink to before it drops to its
38
+ * key alone. Three letters still name the tab ("Plu", "Set", "Git"). */
39
+ const MIN_PREFIX = 3;
40
+
41
+ /**
42
+ * Columns the whole bar occupies: every cell plus a one-column separator
43
+ * between cells. A padded cell adds one space each side of its text.
44
+ */
45
+ export function barWidth(texts: string[], padded = true): number {
36
46
  if (texts.length === 0) return 0;
37
- return texts.reduce((n, t) => n + cellWidth(t), 0) + (texts.length - 1);
47
+ const pad = padded ? 2 : 0;
48
+ return (
49
+ texts.reduce((n, t) => n + displayWidth(t) + pad, 0) + (texts.length - 1)
50
+ );
51
+ }
52
+
53
+ const fullText = (tab: Tab) => `${tab.key}:${tab.label}`;
54
+
55
+ /**
56
+ * Tier 3: the active tab in full, every inactive tab as `key:` plus a label
57
+ * prefix. Prefixes start at MIN_PREFIX and grow one letter at a time, round
58
+ * robin in tab order, while the bar still fits — so the spare columns go to
59
+ * names rather than to blank space. `null` when even the shortest form does not
60
+ * fit.
61
+ */
62
+ function prefixLayout(
63
+ tabs: Tab[],
64
+ currentScreen: Screen,
65
+ available: number,
66
+ ): string[] | null {
67
+ const lengths = tabs.map((tab) =>
68
+ tab.screen === currentScreen
69
+ ? tab.label.length
70
+ : Math.min(MIN_PREFIX, tab.label.length),
71
+ );
72
+ const render = () =>
73
+ tabs.map((tab, i) =>
74
+ tab.screen === currentScreen
75
+ ? fullText(tab)
76
+ : `${tab.key}:${tab.label.slice(0, lengths[i])}`,
77
+ );
78
+ let width = barWidth(render(), false);
79
+ if (width > available) return null;
80
+
81
+ let grew = true;
82
+ while (grew) {
83
+ grew = false;
84
+ for (let i = 0; i < tabs.length; i++) {
85
+ const tab = tabs[i] as Tab;
86
+ const length = lengths[i] as number;
87
+ if (length >= tab.label.length || width + 1 > available) continue;
88
+ lengths[i] = length + 1;
89
+ width += 1;
90
+ grew = true;
91
+ }
92
+ }
93
+ return render();
38
94
  }
39
95
 
40
96
  /**
41
- * Choose what each tab shows for the space available.
97
+ * Choose what each tab shows for the space available — the first tier that
98
+ * fits, in this order:
99
+ *
100
+ * 1. every tab `key:label`, padded;
101
+ * 2. every tab `key:label`, unpadded;
102
+ * 3. the active tab `key:label`, each inactive tab `key:` plus a label prefix
103
+ * of at least three letters;
104
+ * 4. the active tab `key:label`, each inactive tab its key alone.
42
105
  *
43
- * The full bar is ~110 columns at ten tabs, so on a standard 100-column window
44
- * it overflows and OpenTUI clips each cell — producing a row of truncated stubs
45
- * ("1:", "4:", "7:Git ") that names nothing. Dropping the inactive labels keeps
46
- * the bar honest: the number is what you press, and the one tab whose name
47
- * matters is the one you are on. Compacted, ten tabs need ~46 columns.
106
+ * The active tab always keeps its whole name and every tab always keeps its
107
+ * key. The steps exist because the old all-or-nothing rule dropped straight
108
+ * from the full bar (110 columns) to bare numbers, so a 100-column window
109
+ * showed "1 2 3 … 9 0" beside one name when a readable bar fitted with room to
110
+ * spare.
48
111
  */
49
112
  export function layoutTabs(
50
113
  tabs: Tab[],
51
114
  currentScreen: Screen,
52
115
  available: number,
53
- ): string[] {
54
- const full = tabs.map((tab) => `${tab.key}:${tab.label}`);
55
- if (barWidth(full) <= available) return full;
56
- return tabs.map((tab) =>
57
- tab.screen === currentScreen ? `${tab.key}:${tab.label}` : tab.key,
116
+ ): TabLayout {
117
+ const full = tabs.map(fullText);
118
+ if (barWidth(full, true) <= available) return { texts: full, padded: true };
119
+ if (barWidth(full, false) <= available) return { texts: full, padded: false };
120
+
121
+ const prefixed = prefixLayout(tabs, currentScreen, available);
122
+ if (prefixed) return { texts: prefixed, padded: false };
123
+
124
+ const keys = tabs.map((tab) =>
125
+ tab.screen === currentScreen ? fullText(tab) : tab.key,
58
126
  );
127
+ // Tier 4 keeps its padding when that still fits — nothing better can use
128
+ // the room, and the padding is what makes a bare digit read as a tab.
129
+ return { texts: keys, padded: barWidth(keys, true) <= available };
59
130
  }
60
131
 
61
132
  interface TabBarProps {
@@ -66,14 +137,15 @@ export function TabBar({ currentScreen }: TabBarProps) {
66
137
  const dimensions = useDimensions();
67
138
  // ScreenLayout pads the bar by 1 each side, inside a container padded by 1.
68
139
  const available = Math.max(10, dimensions.terminalWidth - 4);
69
- const texts = layoutTabs(TABS, currentScreen, available);
140
+ const { texts, padded } = layoutTabs(TABS, currentScreen, available);
141
+ const pad = padded ? " " : "";
70
142
 
71
143
  return (
72
144
  <box flexDirection="row" gap={0}>
73
145
  {TABS.map((tab, index) => {
74
146
  const isSelected = tab.screen === currentScreen;
75
147
  const isLast = index === TABS.length - 1;
76
- const text = texts[index];
148
+ const text = `${pad}${texts[index]}${pad}`;
77
149
 
78
150
  return (
79
151
  <box key={tab.key} flexDirection="row">
@@ -81,12 +153,12 @@ export function TabBar({ currentScreen }: TabBarProps) {
81
153
  {isSelected ? (
82
154
  <box>
83
155
  <text bg={theme.colors.accent} fg={theme.hints.fg}>
84
- <strong> {text} </strong>
156
+ <strong>{text}</strong>
85
157
  </text>
86
158
  </box>
87
159
  ) : (
88
160
  <box>
89
- <text fg={theme.colors.muted}> {text} </text>
161
+ <text fg={theme.colors.muted}>{text}</text>
90
162
  </box>
91
163
  )}
92
164
  {/* Separator */}
@@ -1,31 +1,15 @@
1
1
  import type React from "react";
2
+ import { FOOTER_GAP, joinKeys } from "../../keymap.js";
2
3
  import { hintChip } from "../../theme-mode.js";
3
4
  import { theme } from "../../theme.js";
4
5
 
5
6
  /** One footer hint: a key (or key group) and the action it performs. */
6
7
  export interface FooterHint {
7
8
  /** Key(s) for this action, e.g. ["↑", "↓"] or ["U"]. Joined by `joinKeys`. */
8
- keys: string[];
9
+ keys: readonly string[];
9
10
  label: string;
10
11
  }
11
12
 
12
- /**
13
- * Render a key group as one chip's text.
14
- *
15
- * Single-character keys butt together, because `↑↓` is how an arrow pair is written
16
- * everywhere and a separator there would be noise. Anything longer needs a gap, or the
17
- * names run into each other and read as one key that does not exist — MEASURED on screen:
18
- * `["Enter", "a"]` rendered as `Entera`, and the layout's own scroll hint
19
- * `["^U/^D", "PgUp/Dn"]` rendered as `^U/^DPgUp/Dn` on every screen in the app.
20
- *
21
- * A space rather than a slash, because several key names already contain one.
22
- */
23
- export function joinKeys(keys: string[]): string {
24
- return keys.every((k) => [...k].length === 1)
25
- ? keys.join("")
26
- : keys.join(" ");
27
- }
28
-
29
13
  // The shared chip palette, not a local one. This file used to define its own
30
14
  // (`theme.colors.dim` block + terminal-foreground ink), which is why the footer
31
15
  // stayed a low-contrast grey-on-grey strip after the palette was made vivid —
@@ -34,7 +18,6 @@ export function joinKeys(keys: string[]): string {
34
18
  // `hintChip()` rather than `theme.hints`: the chip is two fills that must sit a
35
19
  // measured step apart, and "a step quieter" is only definable against a page.
36
20
  // The static palette cannot express it; `theme-mode` resolves it.
37
- const GAP = " ";
38
21
 
39
22
  /**
40
23
  * Render a row of footer hints as key badges + labels:
@@ -42,11 +25,12 @@ const GAP = " ";
42
25
  * [↑↓] move [U] update [/] search
43
26
  *
44
27
  * Each key is shown in a chip so it reads as a pressable key, not prose.
45
- * Shared across screens so every footer looks the same.
28
+ * Shared across screens so every footer looks the same. `footerWidth` in
29
+ * ui/keymap.ts measures exactly this layout — change one, change both.
46
30
  */
47
31
  export function FooterHints({
48
32
  hints,
49
- }: { hints: FooterHint[] }): React.ReactNode {
33
+ }: { hints: readonly FooterHint[] }): React.ReactNode {
50
34
  const ink = hintChip();
51
35
  const nodes: React.ReactNode[] = [];
52
36
  hints.forEach((hint, i) => {
@@ -55,7 +39,7 @@ export function FooterHints({
55
39
  if (i > 0) {
56
40
  nodes.push(
57
41
  <span key={`gap-${id}`} fg={theme.colors.muted}>
58
- {GAP}
42
+ {FOOTER_GAP}
59
43
  </span>,
60
44
  );
61
45
  }
@@ -82,9 +66,4 @@ export function FooterHints({
82
66
  return <text fg={theme.colors.text}>{nodes}</text>;
83
67
  }
84
68
 
85
- /** Convenience for callers that already have a node: same as <FooterHints>. */
86
- export function renderFooterHints(hints: FooterHint[]): React.ReactNode {
87
- return <FooterHints hints={hints} />;
88
- }
89
-
90
69
  export default FooterHints;