magus-cli 7.6.0 → 7.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 (83) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-parity.test.ts +29 -15
  3. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  4. package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
  5. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  6. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  7. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  8. package/src/__tests__/blind-780-review2.test.ts +1203 -0
  9. package/src/__tests__/cli-update-view.test.ts +3 -3
  10. package/src/__tests__/deprecated-remove.test.ts +827 -0
  11. package/src/__tests__/doctor.test.ts +159 -1
  12. package/src/__tests__/failure-popup-review2.test.tsx +920 -0
  13. package/src/__tests__/failure-popup.test.tsx +419 -0
  14. package/src/__tests__/final-fixes-780.test.tsx +724 -0
  15. package/src/__tests__/format-update-outcome.test.ts +9 -34
  16. package/src/__tests__/gitignore-profile.test.ts +0 -4
  17. package/src/__tests__/models-screen-state.test.ts +18 -40
  18. package/src/__tests__/plugin-resolution.test.ts +52 -13
  19. package/src/__tests__/plugin-row-text.test.ts +30 -37
  20. package/src/__tests__/profile-dependencies.test.ts +7 -0
  21. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  22. package/src/__tests__/profile-startup.test.ts +40 -12
  23. package/src/__tests__/stale-load-repair.test.ts +31 -23
  24. package/src/__tests__/update-apply.test.ts +13 -29
  25. package/src/__tests__/update-plan.test.ts +23 -17
  26. package/src/__tests__/useStaleLoadModal.test.ts +1 -42
  27. package/src/__tests__/version-unknown-state.test.ts +15 -17
  28. package/src/cli/doctor.ts +151 -30
  29. package/src/cli/install.ts +5 -6
  30. package/src/cli/update-view.ts +58 -15
  31. package/src/cli/update.ts +27 -5
  32. package/src/services/catalog-cache-store.ts +8 -6
  33. package/src/services/claude-cli.ts +106 -19
  34. package/src/services/claude-settings.ts +84 -36
  35. package/src/services/gitignore-service.ts +0 -19
  36. package/src/services/plugin-env-needs.ts +69 -0
  37. package/src/services/plugin-manager.ts +31 -19
  38. package/src/services/plugin-resolution.ts +34 -42
  39. package/src/services/profile-absorb.ts +9 -0
  40. package/src/services/profile-activate.ts +1 -1
  41. package/src/services/profile-startup.ts +30 -27
  42. package/src/services/profile-state.ts +81 -2
  43. package/src/services/resolver.ts +95 -1
  44. package/src/services/update-engine.ts +326 -31
  45. package/src/services/update-plan.ts +293 -33
  46. package/src/types/index.ts +7 -0
  47. package/src/ui/App.tsx +186 -123
  48. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  49. package/src/ui/adapters/failureReport.ts +137 -0
  50. package/src/ui/adapters/pluginsAdapter.ts +299 -28
  51. package/src/ui/adapters/startupReports.ts +282 -0
  52. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  53. package/src/ui/components/modals/MessageModal.tsx +7 -13
  54. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  55. package/src/ui/components/modals/index.ts +1 -0
  56. package/src/ui/hooks/startupQueue.ts +56 -0
  57. package/src/ui/hooks/useGitignoreModal.ts +31 -31
  58. package/src/ui/hooks/useProfileStartupModal.ts +127 -122
  59. package/src/ui/hooks/useStaleLoadModal.ts +33 -53
  60. package/src/ui/keymap.ts +14 -1
  61. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  62. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  63. package/src/ui/renderers/pluginRowText.ts +25 -25
  64. package/src/ui/renderers/pluginStatusText.ts +18 -1
  65. package/src/ui/screens/AliasScreen.tsx +38 -44
  66. package/src/ui/screens/CliToolsScreen.tsx +65 -51
  67. package/src/ui/screens/EnvVarsScreen.tsx +21 -6
  68. package/src/ui/screens/GitignoreScreen.tsx +49 -75
  69. package/src/ui/screens/McpRegistryScreen.tsx +72 -40
  70. package/src/ui/screens/McpScreen.tsx +55 -24
  71. package/src/ui/screens/ModelsScreen.tsx +46 -86
  72. package/src/ui/screens/PluginsScreen.tsx +316 -245
  73. package/src/ui/screens/ProfilesScreen.tsx +88 -73
  74. package/src/ui/screens/SkillsScreen.tsx +83 -80
  75. package/src/ui/screens/StylesScreen.tsx +211 -275
  76. package/src/ui/state/AppContext.tsx +72 -7
  77. package/src/ui/state/DimensionsContext.tsx +3 -22
  78. package/src/ui/state/reducer.ts +25 -14
  79. package/src/ui/state/types.ts +61 -31
  80. package/src/utils/user-path.ts +60 -0
  81. package/src/__tests__/styles-status-line.test.ts +0 -72
  82. package/src/__tests__/version-snapshot.test.ts +0 -212
  83. package/src/services/version-snapshot.ts +0 -189
@@ -1,9 +1,16 @@
1
1
  import type React from "react";
2
- import { type ReactNode, createContext, useContext, useReducer } from "react";
2
+ import {
3
+ type ReactNode,
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useReducer,
8
+ } from "react";
3
9
  import { appReducer, initialState } from "./reducer.js";
4
10
  import type {
5
11
  AppAction,
6
12
  AppState,
13
+ FailureReport,
7
14
  ModalState,
8
15
  ProgressState,
9
16
  Route,
@@ -83,6 +90,44 @@ export function useNavigation() {
83
90
  };
84
91
  }
85
92
 
93
+ // ============================================================================
94
+ // Hook: useFailurePopup
95
+ // ============================================================================
96
+
97
+ /**
98
+ * The failure popup as ONE stable callback: show `report`, or do nothing for
99
+ * null — the "nothing failed" answer every report builder gives. Stable
100
+ * across renders, unlike `useModal()`, so a `useCallback` that reports a
101
+ * failure can list it without being rebuilt — and without re-running an
102
+ * effect that depends on that callback.
103
+ */
104
+ export function useFailurePopup(): (
105
+ report: FailureReport | null,
106
+ ) => Promise<void> {
107
+ const { dispatch } = useApp();
108
+ return useCallback(
109
+ (report: FailureReport | null) =>
110
+ new Promise<void>((resolve) => {
111
+ if (!report) {
112
+ resolve();
113
+ return;
114
+ }
115
+ dispatch({
116
+ type: "SHOW_MODAL",
117
+ modal: {
118
+ type: "failures",
119
+ report,
120
+ onDismiss: () => {
121
+ dispatch({ type: "HIDE_MODAL" });
122
+ resolve();
123
+ },
124
+ },
125
+ });
126
+ }),
127
+ [dispatch],
128
+ );
129
+ }
130
+
86
131
  // ============================================================================
87
132
  // Hook: useModal
88
133
  // ============================================================================
@@ -174,11 +219,11 @@ export function useModal() {
174
219
  });
175
220
  },
176
221
 
177
- message: (
178
- title: string,
179
- message: string,
180
- variant: "info" | "success" | "error" = "info",
181
- ): Promise<void> => {
222
+ /**
223
+ * Information to read and dismiss. Never a failure: that is `failures`,
224
+ * which has the one shape every failure uses.
225
+ */
226
+ message: (title: string, message: string): Promise<void> => {
182
227
  return new Promise((resolve) => {
183
228
  dispatch({
184
229
  type: "SHOW_MODAL",
@@ -186,7 +231,27 @@ export function useModal() {
186
231
  type: "message",
187
232
  title,
188
233
  message,
189
- variant,
234
+ onDismiss: () => {
235
+ dispatch({ type: "HIDE_MODAL" });
236
+ resolve();
237
+ },
238
+ },
239
+ });
240
+ });
241
+ },
242
+
243
+ /**
244
+ * The failure popup — see {@link FailureReport}. Resolves once dismissed.
245
+ * Use it for anything that did not happen and should have; `message` is
246
+ * for information.
247
+ */
248
+ failures: (report: FailureReport): Promise<void> => {
249
+ return new Promise((resolve) => {
250
+ dispatch({
251
+ type: "SHOW_MODAL",
252
+ modal: {
253
+ type: "failures",
254
+ report,
190
255
  onDismiss: () => {
191
256
  dispatch({ type: "HIDE_MODAL" });
192
257
  resolve();
@@ -29,8 +29,6 @@ interface DimensionsProviderProps {
29
29
  showProgress?: boolean;
30
30
  /** Whether update banner is visible */
31
31
  showUpdateBanner?: boolean;
32
- /** Number of transient banners (recovery, mismatch) currently visible */
33
- transientBannerCount?: number;
34
32
  }
35
33
 
36
34
  function calculateDimensions(
@@ -38,7 +36,6 @@ function calculateDimensions(
38
36
  rows: number,
39
37
  showProgress: boolean,
40
38
  showUpdateBanner: boolean,
41
- transientBannerCount: number,
42
39
  ): Dimensions {
43
40
  const terminalWidth = columns;
44
41
  const terminalHeight = rows;
@@ -47,7 +44,6 @@ function calculateDimensions(
47
44
  let contentHeight = terminalHeight - APP_MARGINS;
48
45
  if (showProgress) contentHeight -= PROGRESS_HEIGHT;
49
46
  if (showUpdateBanner) contentHeight -= 1;
50
- contentHeight -= transientBannerCount;
51
47
  contentHeight = Math.max(10, contentHeight); // Minimum 10 lines for full layout
52
48
 
53
49
  // Calculate available content width (accounting for padding)
@@ -74,7 +70,6 @@ export function DimensionsProvider({
74
70
  children,
75
71
  showProgress = false,
76
72
  showUpdateBanner = false,
77
- transientBannerCount = 0,
78
73
  }: DimensionsProviderProps) {
79
74
  const renderer = useRenderer();
80
75
 
@@ -84,24 +79,17 @@ export function DimensionsProvider({
84
79
  renderer.height,
85
80
  showProgress,
86
81
  showUpdateBanner,
87
- transientBannerCount,
88
82
  ),
89
83
  );
90
84
 
91
85
  // Handle terminal resize
92
86
  useOnResize((width, height) => {
93
87
  setDimensions(
94
- calculateDimensions(
95
- width,
96
- height,
97
- showProgress,
98
- showUpdateBanner,
99
- transientBannerCount,
100
- ),
88
+ calculateDimensions(width, height, showProgress, showUpdateBanner),
101
89
  );
102
90
  });
103
91
 
104
- // Update when showProgress/showUpdateBanner/transientBannerCount changes
92
+ // Update when showProgress/showUpdateBanner changes
105
93
  useEffect(() => {
106
94
  setDimensions(
107
95
  calculateDimensions(
@@ -109,16 +97,9 @@ export function DimensionsProvider({
109
97
  renderer.height,
110
98
  showProgress,
111
99
  showUpdateBanner,
112
- transientBannerCount,
113
100
  ),
114
101
  );
115
- }, [
116
- renderer.width,
117
- renderer.height,
118
- showProgress,
119
- showUpdateBanner,
120
- transientBannerCount,
121
- ]);
102
+ }, [renderer.width, renderer.height, showProgress, showUpdateBanner]);
122
103
 
123
104
  return (
124
105
  <DimensionsContext.Provider value={dimensions}>
@@ -16,6 +16,9 @@ export const initialState: AppState = {
16
16
  // Refresh counter for triggering data refetch
17
17
  dataRefreshVersion: 0,
18
18
 
19
+ // 0 is the check every open runs; each bump asks for another
20
+ gitignoreCheckRequest: 0,
21
+
19
22
  // Screen-specific state
20
23
  plugins: {
21
24
  scope: "project",
@@ -33,6 +36,7 @@ export const initialState: AppState = {
33
36
  selectedIndex: 0,
34
37
  servers: { status: "idle" },
35
38
  installedServers: {},
39
+ changedThisSession: [],
36
40
  },
37
41
 
38
42
  mcpRegistry: {
@@ -69,7 +73,6 @@ export const initialState: AppState = {
69
73
  searchQuery: "",
70
74
  snapshot: { status: "idle" },
71
75
  selected: new Set(),
72
- status: null,
73
76
  isFilling: false,
74
77
  },
75
78
 
@@ -81,7 +84,6 @@ export const initialState: AppState = {
81
84
  selectedIndex: 0,
82
85
  searchQuery: "",
83
86
  data: { status: "idle" },
84
- status: null,
85
87
  isApplying: false,
86
88
  // False until the plugin lookup answers, and false again if it fails. Both cases
87
89
  // render the screen as it was before mates existed, which is the honest drawing of
@@ -294,6 +296,16 @@ export function appReducer(state: AppState, action: AppAction): AppState {
294
296
  },
295
297
  };
296
298
 
299
+ case "MCP_SERVER_CHANGED":
300
+ if (state.mcp.changedThisSession.includes(action.name)) return state;
301
+ return {
302
+ ...state,
303
+ mcp: {
304
+ ...state.mcp,
305
+ changedThisSession: [...state.mcp.changedThisSession, action.name],
306
+ },
307
+ };
308
+
297
309
  // =========================================================================
298
310
  // MCP Registry Screen
299
311
  // =========================================================================
@@ -647,12 +659,6 @@ export function appReducer(state: AppState, action: AppAction): AppState {
647
659
  },
648
660
  };
649
661
 
650
- case "STYLES_STATUS_SET":
651
- return { ...state, styles: { ...state.styles, status: action.status } };
652
-
653
- case "STYLES_STATUS_CLEAR":
654
- return { ...state, styles: { ...state.styles, status: null } };
655
-
656
662
  case "STYLES_FILL_START":
657
663
  return { ...state, styles: { ...state.styles, isFilling: true } };
658
664
 
@@ -742,12 +748,6 @@ export function appReducer(state: AppState, action: AppAction): AppState {
742
748
  },
743
749
  };
744
750
 
745
- case "MODELS_STATUS_SET":
746
- return { ...state, models: { ...state.models, status: action.status } };
747
-
748
- case "MODELS_STATUS_CLEAR":
749
- return { ...state, models: { ...state.models, status: null } };
750
-
751
751
  case "MODELS_APPLY_START":
752
752
  return { ...state, models: { ...state.models, isApplying: true } };
753
753
 
@@ -801,6 +801,17 @@ export function appReducer(state: AppState, action: AppAction): AppState {
801
801
  case "DATA_REFRESH_COMPLETE":
802
802
  return { ...state, dataRefreshVersion: state.dataRefreshVersion + 1 };
803
803
 
804
+ // One action for both, so no activation path can do one and forget the
805
+ // other: the screens reload what the profile turned on, and the gitignore
806
+ // question is asked again, because a first activation is what turns a
807
+ // committed settings file into a generated one git should not track.
808
+ case "PROFILE_ACTIVATED":
809
+ return {
810
+ ...state,
811
+ dataRefreshVersion: state.dataRefreshVersion + 1,
812
+ gitignoreCheckRequest: state.gitignoreCheckRequest + 1,
813
+ };
814
+
804
815
  default:
805
816
  return state;
806
817
  }
@@ -86,6 +86,35 @@ export interface SelectOption {
86
86
  meta?: string;
87
87
  }
88
88
 
89
+ /**
90
+ * What failed, as ONE popup shape every failure report on every screen uses:
91
+ * the state counted in the headline, one row per thing that failed (or per
92
+ * group sharing a reason), and — only when every row shares it — the one fix.
93
+ * Subjects are whatever failed: plugins, marketplaces, skills, files, the
94
+ * profile. Rendered by `FailureListModal`; built by pure functions
95
+ * (`ui/adapters/failureReport.ts`), so the words are tested without a renderer.
96
+ */
97
+ export interface FailureReport {
98
+ /** The state, counted: "3 plugins were not updated", "claudish was not updated". */
99
+ headline: string;
100
+ rows: FailureRow[];
101
+ /** The one thing to do about ALL of them, or null when they differ. */
102
+ fix: string | null;
103
+ }
104
+
105
+ export interface FailureRow {
106
+ /** What failed — a plugin name, a file, or several sharing the reason. */
107
+ subject: string;
108
+ /** Why, in the user's terms. One line: an exception keeps its first line only. */
109
+ reason: string;
110
+ /**
111
+ * What to do about THIS row, when it differs from the others'. Absent when
112
+ * there is none, or when every row shares it — the report's `fix` then says
113
+ * it once.
114
+ */
115
+ fix?: string;
116
+ }
117
+
89
118
  export type ModalState =
90
119
  | {
91
120
  type: "confirm";
@@ -122,10 +151,21 @@ export type ModalState =
122
151
  cancellable?: boolean;
123
152
  }
124
153
  | {
154
+ /**
155
+ * Information the user dismisses: help text, a command to run. It has
156
+ * no variants, deliberately. A success shows as the new state of the
157
+ * screen and needs nothing more; a failure goes in the failure popup
158
+ * (`failures`), so every failure reads the same way.
159
+ */
125
160
  type: "message";
126
161
  title: string;
127
162
  message: string;
128
- variant: "info" | "success" | "error";
163
+ onDismiss: () => void;
164
+ }
165
+ | {
166
+ /** What failed and why — see {@link FailureReport}. Any key dismisses it. */
167
+ type: "failures";
168
+ report: FailureReport;
129
169
  onDismiss: () => void;
130
170
  }
131
171
  | {
@@ -188,6 +228,13 @@ export interface McpScreenState {
188
228
  selectedIndex: number;
189
229
  servers: AsyncData<McpServer[]>;
190
230
  installedServers: Record<string, boolean>;
231
+ /**
232
+ * Servers added or removed in THIS magus session. A running Claude Code
233
+ * reads its MCP configuration at startup, so each of these takes effect
234
+ * only after it restarts — a property of the server, shown on its row and
235
+ * in its detail rather than said once as a message and lost.
236
+ */
237
+ changedThisSession: string[];
191
238
  }
192
239
 
193
240
  export interface McpRegistryScreenState {
@@ -252,16 +299,6 @@ export interface StylesScreenState {
252
299
  * style, which is not something a stray Space press should do.
253
300
  */
254
301
  selected: Set<string>;
255
- /**
256
- * The status line's current message, or null for the default line.
257
- *
258
- * App state for the same reason `isFilling` is: `Router` swaps the component
259
- * type on a tab change, so anything held in the screen's own `useState` is
260
- * destroyed by `9 → 1 → 9`. It also stops being cleared on a wall clock —
261
- * the message now survives until the next action replaces it, so the last
262
- * thing you did is still on screen whenever you look.
263
- */
264
- status: { text: string; tone: "success" | "error" } | null;
265
302
  /**
266
303
  * True while a template fill (Claude Code over the whole project) is running.
267
304
  *
@@ -293,15 +330,6 @@ export interface ModelsScreenState {
293
330
  searchQuery: string;
294
331
  /** Routing status + the config behind it, in one read. */
295
332
  data: AsyncData<ModelsSnapshot>;
296
- /**
297
- * The status line's current message, or null for the default line.
298
- *
299
- * App state for the same reason the Styles screen's is: `Router` swaps the
300
- * component type on a tab change, so anything held in the screen's own
301
- * `useState` is destroyed by `0 → 1 → 0`. The message survives until the next
302
- * action replaces it, so the last thing you did is still on screen.
303
- */
304
- status: { text: string; tone: "success" | "error" } | null;
305
333
  /**
306
334
  * True while an apply or a clear is writing.
307
335
  *
@@ -364,6 +392,14 @@ export interface AppState {
364
392
  // Screens can watch this to know when to refetch data
365
393
  dataRefreshVersion: number;
366
394
 
395
+ /**
396
+ * Bumped to ask the app for a fresh gitignore check. The app shell owns the
397
+ * check and its question (ui/App.tsx); anything that makes generated files
398
+ * appear — a profile activating — asks through this rather than reaching
399
+ * into the shell.
400
+ */
401
+ gitignoreCheckRequest: number;
402
+
367
403
  // Screen-specific state
368
404
  plugins: PluginsScreenState;
369
405
  mcp: McpScreenState;
@@ -422,6 +458,8 @@ export type AppAction =
422
458
  installedServers: Record<string, boolean>;
423
459
  }
424
460
  | { type: "MCP_DATA_ERROR"; error: Error }
461
+ /** A server was added or removed — it needs a Claude Code restart. */
462
+ | { type: "MCP_SERVER_CHANGED"; name: string }
425
463
 
426
464
  // MCP Registry screen
427
465
  | { type: "MCP_REGISTRY_SET_QUERY"; query: string }
@@ -487,11 +525,6 @@ export type AppAction =
487
525
  | { type: "STYLES_DATA_SUCCESS"; snapshot: StylesSnapshot }
488
526
  | { type: "STYLES_DATA_ERROR"; error: Error }
489
527
  | { type: "STYLES_TOGGLE"; id: string }
490
- | {
491
- type: "STYLES_STATUS_SET";
492
- status: { text: string; tone: "success" | "error" };
493
- }
494
- | { type: "STYLES_STATUS_CLEAR" }
495
528
  | { type: "STYLES_FILL_START" }
496
529
  | { type: "STYLES_FILL_END" }
497
530
  | { type: "STYLES_SET_SELECTION"; ids: string[] }
@@ -508,11 +541,6 @@ export type AppAction =
508
541
  | { type: "MODELS_DATA_LOADING" }
509
542
  | { type: "MODELS_DATA_SUCCESS"; snapshot: ModelsSnapshot }
510
543
  | { type: "MODELS_DATA_ERROR"; error: Error }
511
- | {
512
- type: "MODELS_STATUS_SET";
513
- status: { text: string; tone: "success" | "error" };
514
- }
515
- | { type: "MODELS_STATUS_CLEAR" }
516
544
  | { type: "MODELS_APPLY_START" }
517
545
  | { type: "MODELS_APPLY_END" }
518
546
  // Dispatched once, when the plugin lookup behind the mate slots lands.
@@ -520,4 +548,6 @@ export type AppAction =
520
548
  | { type: "MODELS_CATALOG_LOADED"; catalog: CatalogModel[] }
521
549
 
522
550
  // Data refresh - triggers screens to refetch
523
- | { type: "DATA_REFRESH_COMPLETE" };
551
+ | { type: "DATA_REFRESH_COMPLETE" }
552
+ // A profile became active: screens refetch, and the gitignore check re-runs
553
+ | { type: "PROFILE_ACTIVATED" };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A file as the user names it, never as an absolute path.
3
+ *
4
+ * magus names a file only when the user must act on it (fix it by hand,
5
+ * commit it), and then in the words they would type: `~/.claude/settings.json`
6
+ * for their own settings, `.claude/settings.json` for this project's. The
7
+ * absolute path is where this machine keeps it — the mechanism, and noise
8
+ * that pushes the actual problem off the end of the line.
9
+ */
10
+
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ /** `true` when `file` is `dir` itself or inside it. */
15
+ function isInside(file: string, dir: string): boolean {
16
+ const rel = path.relative(dir, file);
17
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
18
+ }
19
+
20
+ /**
21
+ * `file` in the user's terms:
22
+ *
23
+ * 1. Under the user's own `~/.claude/` → `~/.claude/…` (their USER settings),
24
+ * checked first so a project opened at the home folder still names it so.
25
+ * 2. Under the project folder → relative to it: `.claude/settings.json`.
26
+ * 3. Anywhere else with a `.claude/` folder in its path → from that folder
27
+ * on, which is how the user knows a project's settings file.
28
+ * 4. Under the home folder → `~/…`.
29
+ * 5. Otherwise its name alone.
30
+ */
31
+ export function userFacingPath(
32
+ file: string,
33
+ projectDir: string = process.cwd(),
34
+ home: string = os.homedir(),
35
+ ): string {
36
+ const abs = path.resolve(file);
37
+ const userClaude = home ? path.join(home, ".claude") : "";
38
+ if (userClaude && isInside(abs, userClaude)) {
39
+ return `~/${path.relative(home, abs)}`;
40
+ }
41
+ if (projectDir && isInside(abs, path.resolve(projectDir))) {
42
+ const rel = path.relative(path.resolve(projectDir), abs);
43
+ if (rel) return rel;
44
+ }
45
+ const marker = `${path.sep}.claude${path.sep}`;
46
+ const at = abs.lastIndexOf(marker);
47
+ if (at >= 0) return abs.slice(at + 1);
48
+ if (home && isInside(abs, home)) return `~/${path.relative(home, abs)}`;
49
+ return path.basename(abs);
50
+ }
51
+
52
+ /** Every occurrence of `file`'s absolute path in `text`, in the user's terms. */
53
+ export function withUserFacingPath(
54
+ text: string,
55
+ file: string,
56
+ projectDir?: string,
57
+ home?: string,
58
+ ): string {
59
+ return text.split(file).join(userFacingPath(file, projectDir, home));
60
+ }
@@ -1,72 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { appReducer, initialState } from "../ui/state/reducer.js";
3
- import type { AppAction, AppState } from "../ui/state/types.js";
4
-
5
- function run(actions: AppAction[], from: AppState = initialState): AppState {
6
- return actions.reduce(appReducer, from);
7
- }
8
-
9
- /**
10
- * The status line lives in app state, not in the screen.
11
- *
12
- * Two bugs drove this, and both were invisible to every test we had:
13
- *
14
- * 1. It was cleared on a 5-second wall clock, so the message describing what
15
- * you just did was usually gone before you looked at it — and unobservable
16
- * to anything sampling the screen a moment later.
17
- * 2. It was component-local, and `Router` swaps the component type on a tab
18
- * change, so `9 → 1 → 9` destroyed it.
19
- */
20
- describe("status line state", () => {
21
- test("survives leaving the tab and coming back", () => {
22
- const shown = run([
23
- {
24
- type: "STYLES_STATUS_SET",
25
- status: { text: "Selection reset to the live style", tone: "success" },
26
- },
27
- ]);
28
- expect(shown.styles.status?.text).toBe("Selection reset to the live style");
29
-
30
- // Navigating away and back is what unmounts the screen.
31
- const roundTrip = run(
32
- [
33
- { type: "NAVIGATE", route: { screen: "plugins" } },
34
- { type: "NAVIGATE", route: { screen: "styles" } },
35
- ],
36
- shown,
37
- );
38
- expect(roundTrip.styles.status?.text).toBe(
39
- "Selection reset to the live style",
40
- );
41
- });
42
-
43
- test("a new message replaces the previous one", () => {
44
- const state = run([
45
- { type: "STYLES_STATUS_SET", status: { text: "first", tone: "success" } },
46
- { type: "STYLES_STATUS_SET", status: { text: "second", tone: "error" } },
47
- ]);
48
- expect(state.styles.status).toEqual({ text: "second", tone: "error" });
49
- });
50
-
51
- test("clearing returns the line to its default", () => {
52
- const state = run([
53
- { type: "STYLES_STATUS_SET", status: { text: "done", tone: "success" } },
54
- { type: "STYLES_STATUS_CLEAR" },
55
- ]);
56
- expect(state.styles.status).toBeNull();
57
- });
58
-
59
- test("starts empty", () => {
60
- expect(initialState.styles.status).toBeNull();
61
- });
62
-
63
- test("keeps the tone, so an error cannot render as a success", () => {
64
- const state = run([
65
- {
66
- type: "STYLES_STATUS_SET",
67
- status: { text: "Already talking to GitHub", tone: "error" },
68
- },
69
- ]);
70
- expect(state.styles.status?.tone).toBe("error");
71
- });
72
- });