claudeup 4.16.0 → 4.18.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 (77) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/alias-parser.test.ts +317 -0
  3. package/src/__tests__/alias-shell-writer.test.ts +661 -0
  4. package/src/__tests__/alias-store.test.ts +86 -0
  5. package/src/__tests__/gitignore-fixer.test.ts +64 -1
  6. package/src/__tests__/gitignore-prerun.test.ts +2 -2
  7. package/src/__tests__/gitignore-service.test.ts +42 -0
  8. package/src/__tests__/marketplaces.test.ts +40 -0
  9. package/src/__tests__/plugin-manager-fallback.test.ts +120 -0
  10. package/src/__tests__/useGitignoreModal.test.ts +2 -2
  11. package/src/data/alias-flags.js +196 -0
  12. package/src/data/alias-flags.ts +291 -0
  13. package/src/data/gitignore-reasons.js +97 -0
  14. package/src/data/gitignore-reasons.ts +103 -0
  15. package/src/data/marketplaces.js +19 -1
  16. package/src/data/marketplaces.ts +17 -1
  17. package/src/services/alias-settings.js +51 -0
  18. package/src/services/alias-settings.ts +63 -0
  19. package/src/services/alias-shell-writer.js +764 -0
  20. package/src/services/alias-shell-writer.ts +873 -0
  21. package/src/services/alias-store.js +77 -0
  22. package/src/services/alias-store.ts +112 -0
  23. package/src/services/gitignore-fixer.js +70 -10
  24. package/src/services/gitignore-fixer.ts +76 -9
  25. package/src/services/gitignore-prerun.js +3 -3
  26. package/src/services/gitignore-prerun.ts +3 -3
  27. package/src/services/gitignore-service.js +20 -2
  28. package/src/services/gitignore-service.ts +23 -1
  29. package/src/services/marketplace-fetcher.js +96 -0
  30. package/src/services/marketplace-fetcher.ts +137 -0
  31. package/src/services/plugin-manager.js +6 -59
  32. package/src/services/plugin-manager.ts +16 -91
  33. package/src/services/skillsmp-client.js +29 -9
  34. package/src/services/skillsmp-client.ts +38 -8
  35. package/src/types/gitignore.ts +1 -1
  36. package/src/types/index.ts +1 -0
  37. package/src/ui/App.js +10 -4
  38. package/src/ui/App.tsx +9 -3
  39. package/src/ui/components/TabBar.js +2 -1
  40. package/src/ui/components/TabBar.tsx +2 -1
  41. package/src/ui/components/layout/FooterHints.js +29 -0
  42. package/src/ui/components/layout/FooterHints.tsx +52 -0
  43. package/src/ui/components/layout/ScreenLayout.js +2 -1
  44. package/src/ui/components/layout/ScreenLayout.tsx +12 -3
  45. package/src/ui/components/layout/index.js +1 -0
  46. package/src/ui/components/layout/index.ts +5 -0
  47. package/src/ui/components/modals/SelectModal.js +8 -1
  48. package/src/ui/components/modals/SelectModal.tsx +12 -1
  49. package/src/ui/hooks/useGitignoreModal.js +7 -8
  50. package/src/ui/hooks/useGitignoreModal.ts +8 -9
  51. package/src/ui/renderers/gitignoreRenderers.js +36 -23
  52. package/src/ui/renderers/gitignoreRenderers.tsx +50 -41
  53. package/src/ui/screens/AliasScreen.js +1008 -0
  54. package/src/ui/screens/AliasScreen.tsx +1402 -0
  55. package/src/ui/screens/CliToolsScreen.js +6 -1
  56. package/src/ui/screens/CliToolsScreen.tsx +6 -1
  57. package/src/ui/screens/EnvVarsScreen.js +6 -1
  58. package/src/ui/screens/EnvVarsScreen.tsx +6 -1
  59. package/src/ui/screens/GitignoreScreen.js +189 -88
  60. package/src/ui/screens/GitignoreScreen.tsx +312 -132
  61. package/src/ui/screens/McpRegistryScreen.js +13 -2
  62. package/src/ui/screens/McpRegistryScreen.tsx +13 -2
  63. package/src/ui/screens/McpScreen.js +6 -1
  64. package/src/ui/screens/McpScreen.tsx +6 -1
  65. package/src/ui/screens/ModelSelectorScreen.js +8 -2
  66. package/src/ui/screens/ModelSelectorScreen.tsx +8 -2
  67. package/src/ui/screens/PluginsScreen.js +13 -2
  68. package/src/ui/screens/PluginsScreen.tsx +13 -2
  69. package/src/ui/screens/ProfilesScreen.js +8 -1
  70. package/src/ui/screens/ProfilesScreen.tsx +8 -1
  71. package/src/ui/screens/SkillsScreen.js +21 -4
  72. package/src/ui/screens/SkillsScreen.tsx +39 -5
  73. package/src/ui/screens/StatusLineScreen.js +7 -1
  74. package/src/ui/screens/StatusLineScreen.tsx +7 -1
  75. package/src/ui/screens/index.js +1 -0
  76. package/src/ui/screens/index.ts +1 -0
  77. package/src/ui/state/types.ts +4 -2
@@ -1,22 +1,40 @@
1
1
  import React, { useEffect, useState, useCallback, useMemo } from "react";
2
2
  import { spawn } from "node:child_process";
3
+ import { appendFileSync } from "node:fs";
4
+ import { useRenderer } from "@opentui/react";
3
5
  import { useApp, useModal } from "../state/AppContext.js";
4
6
  import { useKeyboardHandler } from "../hooks/useKeyboardHandler.js";
5
7
  import { ScreenLayout } from "../components/layout/index.js";
6
8
  import {
7
9
  loadGitignoreState,
8
- applyAllSafeFixes,
9
10
  applyDestructiveFix,
11
+ ensureProjectManifestForEdit,
10
12
  applyTemplate,
11
13
  type GitignoreState,
12
14
  } from "../../services/gitignore-service.js";
13
15
  import { BUILTIN_TEMPLATES } from "../../data/gitignore-templates.js";
14
- import { renderRuleRow, type RuleRow } from "../renderers/gitignoreRenderers.js";
15
- import type { ResolvedRule, Violation } from "../../types/gitignore.js";
16
+ import { getGitignoreRuleReason } from "../../data/gitignore-reasons.js";
17
+ import {
18
+ renderRuleRow,
19
+ type RuleRow,
20
+ } from "../renderers/gitignoreRenderers.js";
21
+ import type { ResolvedRule, Tier, Violation } from "../../types/gitignore.js";
22
+
23
+ // Clean rows are grouped by the manifest that defined them. Order = display
24
+ // order, top to bottom. "Default" is the user-facing name for the built-in
25
+ // rules. Each label names where you'd go to change that group's rules.
26
+ const SOURCE_ORDER: Tier[] = ["project", "global", "profile", "builtin"];
27
+ const SOURCE_LABELS: Record<Tier, string> = {
28
+ project: "Project · .claude/gitignore.json",
29
+ global: "Global · ~/.claude/gitignore.json",
30
+ profile: "Profile",
31
+ builtin: "Default · built into claudeup",
32
+ };
16
33
 
17
34
  export function GitignoreScreen() {
18
35
  const { state } = useApp();
19
36
  const modal = useModal();
37
+ const renderer = useRenderer();
20
38
  const cwd = state.projectPath;
21
39
 
22
40
  const [data, setData] = useState<GitignoreState | null>(null);
@@ -41,31 +59,38 @@ export function GitignoreScreen() {
41
59
  void reload();
42
60
  }, [reload]);
43
61
 
44
- // Build the unified row list: every rule, sorted with violations first so
45
- // problems are visible at the top of the list without scrolling.
62
+ // Build the managed list: every desired gitignore rule, with all matching
63
+ // violations grouped under the rule that owns them.
46
64
  const rows: RuleRow[] = useMemo(() => {
47
65
  if (!data) return [];
48
- const violationByPattern = new Map<string, Violation>();
49
- for (const v of data.violations) {
50
- // A violation's `path` is the rule's pattern for ignored-but-should-track
51
- // and missing-from-gitignore; for tracked-but-should-ignore it's the
52
- // actual tracked file under a directory rule. Index by rule pattern by
53
- // matching either path or its prefix.
54
- // Simplest: index by path; lookup will try exact then prefix.
55
- violationByPattern.set(v.path, v);
56
- }
57
66
 
58
67
  const annotated: RuleRow[] = data.resolved.rules.map((rule) => {
59
- const v =
60
- violationByPattern.get(rule.pattern) ??
61
- findPrefixMatch(rule, data.violations);
62
- return { rule, violation: v };
68
+ const violations = data.violations.filter((v) =>
69
+ violationMatchesRule(rule, v),
70
+ );
71
+ return { rule, violations };
63
72
  });
64
73
 
74
+ // Display order = flat index order (the keyboard handler walks this array),
75
+ // so the on-screen layout must match this sort exactly:
76
+ // 1. violations first (most violations first)
77
+ // 2. then clean rows grouped by source manifest (SOURCE_ORDER)
78
+ // 3. alphabetical within each group
79
+ const sourceRank = (tier: ResolvedRule["source"]): number => {
80
+ const i = SOURCE_ORDER.indexOf(tier);
81
+ return i === -1 ? SOURCE_ORDER.length : i;
82
+ };
65
83
  return annotated.sort((a, b) => {
66
- const aBad = a.violation ? 0 : 1;
67
- const bBad = b.violation ? 0 : 1;
68
- if (aBad !== bBad) return aBad - bBad;
84
+ const aViol = a.violations.length > 0;
85
+ const bViol = b.violations.length > 0;
86
+ if (aViol !== bViol) return aViol ? -1 : 1;
87
+ if (aViol && bViol && a.violations.length !== b.violations.length) {
88
+ return b.violations.length - a.violations.length;
89
+ }
90
+ if (!aViol) {
91
+ const sr = sourceRank(a.rule.source) - sourceRank(b.rule.source);
92
+ if (sr !== 0) return sr;
93
+ }
69
94
  return a.rule.pattern.localeCompare(b.rule.pattern);
70
95
  });
71
96
  }, [data]);
@@ -78,52 +103,96 @@ export function GitignoreScreen() {
78
103
  }, [rows.length, selectedIdx]);
79
104
 
80
105
  const selectedRow = rows[selectedIdx];
81
- const violationCount = data?.violations.length ?? 0;
82
- const safeCount =
83
- data?.violations.filter((v) => v.severity === "safe").length ?? 0;
84
- const destructiveCount = violationCount - safeCount;
106
+ const ignoreRuleCount =
107
+ data?.resolved.rules.filter((rule) => rule.action === "ignore").length ?? 0;
108
+ const trackRuleCount =
109
+ data?.resolved.rules.filter((rule) => rule.action === "track").length ?? 0;
110
+ const toFixCount = data?.violations.length ?? 0;
111
+
112
+ // Group rows for display while preserving each row's flat index in `rows`
113
+ // (the keyboard handler walks `rows` by index, so the highlight must track
114
+ // the original index, not the position within a rendered group).
115
+ const indexed = rows.map((row, index) => ({ row, index }));
116
+ const toFixRows = indexed.filter((r) => r.row.violations.length > 0);
117
+ // Clean rows grouped by which manifest defined them. Sections render only
118
+ // when non-empty, so e.g. the Profile group stays hidden until profiles
119
+ // actually contribute rules.
120
+ const cleanIndexed = indexed.filter((r) => r.row.violations.length === 0);
121
+ const sourceGroups = SOURCE_ORDER.map((tier) => ({
122
+ tier,
123
+ label: SOURCE_LABELS[tier],
124
+ items: cleanIndexed.filter((r) => r.row.rule.source === tier),
125
+ })).filter((g) => g.items.length > 0);
126
+ const cleanCount = cleanIndexed.length;
85
127
 
86
128
  // ── Action handlers ───────────────────────────────────────────────────
87
129
 
88
- const handleApplySafe = useCallback(async () => {
89
- if (!data || safeCount === 0) return;
90
- const r = await applyAllSafeFixes(cwd, data.violations);
91
- await modal.message(
92
- "Applied safe fixes",
93
- `Appended ${r.appended.length} pattern(s) to .gitignore.${
94
- r.alreadyPresent.length > 0
95
- ? `\n\n${r.alreadyPresent.length} pattern(s) were already present.`
96
- : ""
97
- }`,
98
- "success",
99
- );
100
- await reload();
101
- }, [cwd, data, safeCount, modal, reload]);
102
-
103
- const handleApplySelected = useCallback(async () => {
104
- if (!selectedRow?.violation) return;
105
- const v = selectedRow.violation;
130
+ // Fix every out-of-sync row. Iterates the grouped rows (not the flat
131
+ // violation list) so each .gitignore entry uses the rule's pattern/glob,
132
+ // not one specific path under it — same association handleApplyOne relies on.
133
+ const handleFixAll = useCallback(async () => {
134
+ if (!data || toFixRows.length === 0) return;
135
+ try {
136
+ const messages: string[] = [];
137
+ const failures: string[] = [];
138
+ for (const { row } of toFixRows) {
139
+ for (const v of row.violations) {
140
+ const r = await applyDestructiveFix(cwd, v, row.rule.pattern);
141
+ (r.applied ? messages : failures).push(r.message);
142
+ }
143
+ }
144
+ await reload();
145
+ if (failures.length > 0) {
146
+ await modal.message(
147
+ "Some fixes didn't apply",
148
+ [...failures, ...messages].join("\n"),
149
+ "error",
150
+ );
151
+ }
152
+ } catch (err) {
153
+ await modal.message(
154
+ "Fix failed",
155
+ err instanceof Error ? err.message : String(err),
156
+ "error",
157
+ );
158
+ }
159
+ }, [cwd, data, toFixRows, reload, modal]);
106
160
 
107
- if (v.severity === "safe") {
108
- await handleApplySafe();
161
+ // Fix just the highlighted row.
162
+ const handleFixOne = useCallback(async () => {
163
+ if (!selectedRow) {
164
+ await modal.message(
165
+ "No row selected",
166
+ "Use ↑↓ to highlight a row that needs fixing, then press f.",
167
+ "info",
168
+ );
109
169
  return;
110
170
  }
111
-
112
- const confirmed = await modal.confirm(
113
- "Apply destructive fix?",
114
- `${v.kind} on ${v.path}\n\n` +
115
- `This mutates the git index (e.g. \`git rm --cached\` or \`git add\`).\n` +
116
- `You can review the change with \`git status\` afterwards.`,
117
- );
118
- if (!confirmed) return;
119
-
120
- try {
121
- const result = await applyDestructiveFix(cwd, v);
171
+ if (selectedRow.violations.length === 0) {
122
172
  await modal.message(
123
- result.applied ? "Fix applied" : "Fix failed",
124
- result.message,
125
- result.applied ? "success" : "error",
173
+ "Nothing to fix",
174
+ `${selectedRow.rule.pattern} already matches. f only applies to rows that need fixing.`,
175
+ "info",
126
176
  );
177
+ return;
178
+ }
179
+
180
+ try {
181
+ const results: Array<{ applied: boolean; message: string }> = [];
182
+ for (const v of selectedRow.violations) {
183
+ results.push(await applyDestructiveFix(cwd, v, selectedRow.rule.pattern));
184
+ }
185
+ const failed = results.filter((r) => !r.applied);
186
+ await reload();
187
+ // Success is silent — the reload removes the fixed row. Only surface a
188
+ // modal when something didn't apply.
189
+ if (failed.length > 0) {
190
+ await modal.message(
191
+ "Some fixes didn't apply",
192
+ results.map((r) => r.message).join("\n"),
193
+ "error",
194
+ );
195
+ }
127
196
  } catch (err) {
128
197
  await modal.message(
129
198
  "Fix failed",
@@ -131,13 +200,16 @@ export function GitignoreScreen() {
131
200
  "error",
132
201
  );
133
202
  }
134
- await reload();
135
- }, [selectedRow, handleApplySafe, modal, cwd, reload]);
203
+ }, [selectedRow, modal, cwd, reload]);
136
204
 
137
205
  const handleApplyTemplate = useCallback(async () => {
138
206
  const names = Object.keys(BUILTIN_TEMPLATES);
139
207
  if (names.length === 0) {
140
- await modal.message("No templates", "No built-in templates available.", "info");
208
+ await modal.message(
209
+ "No templates",
210
+ "No built-in templates available.",
211
+ "info",
212
+ );
141
213
  return;
142
214
  }
143
215
  const selected = await modal.select(
@@ -170,17 +242,37 @@ export function GitignoreScreen() {
170
242
  }, [cwd, modal, reload]);
171
243
 
172
244
  const handleEditManifest = useCallback(async () => {
173
- const projectManifest = `${cwd}/.claude/gitignore.json`;
245
+ const current = data ?? await loadGitignoreState(cwd);
246
+ const projectManifest = await ensureProjectManifestForEdit(
247
+ cwd,
248
+ current.resolved,
249
+ );
174
250
  const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
175
- await new Promise<void>((resolveEdit) => {
176
- const child = spawn(editor, [projectManifest], { stdio: "inherit" });
177
- child.on("exit", () => resolveEdit());
178
- child.on("error", () => resolveEdit());
179
- });
251
+ renderer.suspend();
252
+ try {
253
+ await new Promise<void>((resolveEdit) => {
254
+ const child = spawn(editor, [projectManifest], { stdio: "inherit" });
255
+ child.on("exit", () => resolveEdit());
256
+ child.on("error", () => resolveEdit());
257
+ });
258
+ } finally {
259
+ renderer.resume();
260
+ }
180
261
  await reload();
181
- }, [cwd, reload]);
262
+ renderer.requestRender();
263
+ }, [cwd, data, reload, renderer]);
182
264
 
183
265
  useKeyboardHandler((input, key) => {
266
+ if (process.env.CLAUDEUP_DEBUG_KEYS === "1") {
267
+ try {
268
+ appendFileSync(
269
+ "/tmp/claudeup-keys.log",
270
+ `${new Date().toISOString()} screen=gitignore input=${JSON.stringify(input)} name=${JSON.stringify(key.name)} shift=${key.shift} ctrl=${key.ctrl} meta=${key.meta}\n`,
271
+ );
272
+ } catch {
273
+ // best-effort logging — never break the UI
274
+ }
275
+ }
184
276
  if (state.modal) return;
185
277
  if (input === "j" || key.downArrow) {
186
278
  setSelectedIdx((i) => Math.min(i + 1, Math.max(0, rows.length - 1)));
@@ -190,12 +282,13 @@ export function GitignoreScreen() {
190
282
  setSelectedIdx((i) => Math.max(i - 1, 0));
191
283
  return;
192
284
  }
193
- if (input === "f") {
194
- void handleApplySafe();
285
+ // F = fix everything out of sync; f = fix just the selected row.
286
+ if (input === "F" || (input === "f" && key.shift)) {
287
+ void handleFixAll();
195
288
  return;
196
289
  }
197
- if (input === "F") {
198
- void handleApplySelected();
290
+ if (input === "f") {
291
+ void handleFixOne();
199
292
  return;
200
293
  }
201
294
  if (input === "t") {
@@ -213,7 +306,9 @@ export function GitignoreScreen() {
213
306
 
214
307
  // ── Rendering ─────────────────────────────────────────────────────────
215
308
 
216
- const summary = data ? renderSummary(violationCount, safeCount, destructiveCount) : null;
309
+ const summary = data
310
+ ? renderSummary(rows.length, ignoreRuleCount, trackRuleCount, toFixCount)
311
+ : null;
217
312
 
218
313
  const listBody = loading ? (
219
314
  <text fg="gray">Loading...</text>
@@ -223,14 +318,35 @@ export function GitignoreScreen() {
223
318
  <text fg="gray">No rules resolved.</text>
224
319
  ) : (
225
320
  <box flexDirection="column">
226
- {rows.slice(0, 200).map((row, i) => renderRuleRow(row, i === selectedIdx))}
321
+ {toFixRows.length > 0 && (
322
+ <box flexDirection="column">
323
+ <text fg="red">{`Needs fixing (${toFixRows.length})`}</text>
324
+ {toFixRows
325
+ .slice(0, 100)
326
+ .map(({ row, index }) => renderRuleRow(row, index === selectedIdx))}
327
+ <text> </text>
328
+ </box>
329
+ )}
330
+ <text fg="gray">{`Managed items (${cleanCount})`}</text>
331
+ {/* One section per manifest source (Default / Global / Project /
332
+ Profile). Empty sources are dropped, so headers only appear when
333
+ they have rows. The header names where you'd change those rules. */}
334
+ {sourceGroups.map((group, gi) => (
335
+ <box flexDirection="column" key={group.tier}>
336
+ {gi > 0 && <text> </text>}
337
+ <text fg="gray">{` ${group.label}`}</text>
338
+ {group.items.map(({ row, index }) =>
339
+ renderRuleRow(row, index === selectedIdx),
340
+ )}
341
+ </box>
342
+ ))}
227
343
  </box>
228
344
  );
229
345
 
230
346
  const listPanel = (
231
347
  <box flexDirection="column">
232
348
  {summary}
233
- <text>{" "}</text>
349
+ <text> </text>
234
350
  {listBody}
235
351
  </box>
236
352
  );
@@ -238,52 +354,60 @@ export function GitignoreScreen() {
238
354
  const detailPanel = renderDetail(selectedRow, data?.warnings ?? []);
239
355
 
240
356
  const subtitle = data
241
- ? violationCount === 0
242
- ? "all clean"
243
- : `${violationCount} violation${violationCount === 1 ? "" : "s"}`
357
+ ? toFixCount === 0
358
+ ? `${rows.length} managed`
359
+ : `${toFixCount} to fix`
244
360
  : "";
245
361
 
246
362
  return (
247
363
  <ScreenLayout
248
- title="Gitignore Manager"
364
+ title="Managed Git State"
249
365
  subtitle={subtitle}
250
366
  currentScreen="gitignore"
251
- footerHints="↑↓ move f fix-all-safe F fix-this t tpl e edit r reload"
367
+ footerHints={[
368
+ { keys: ["↑", "↓"], label: "move" },
369
+ { keys: ["f"], label: "fix selected" },
370
+ { keys: ["F"], label: "fix all" },
371
+ { keys: ["t"], label: "template" },
372
+ { keys: ["e"], label: "edit" },
373
+ { keys: ["r"], label: "reload" },
374
+ ]}
252
375
  listPanel={listPanel}
253
376
  detailPanel={detailPanel}
254
377
  />
255
378
  );
256
379
  }
257
380
 
258
- function findPrefixMatch(
381
+ function violationMatchesRule(
259
382
  rule: ResolvedRule,
260
- violations: Violation[],
261
- ): Violation | undefined {
262
- // A directory rule like "ai-docs/sessions/" can have a violation on a child
263
- // path like "ai-docs/sessions/leak.md". Match by path-startsWith-pattern.
264
- if (!rule.pattern.endsWith("/") && !rule.pattern.includes("*")) return undefined;
383
+ violation: Violation,
384
+ ): boolean {
385
+ if (violation.path === rule.pattern) return true;
386
+ if (!rule.pattern.endsWith("/") && !rule.pattern.includes("*")) return false;
265
387
  const prefix = rule.pattern.replace(/\*+$/, "");
266
- return violations.find((v) => v.path.startsWith(prefix));
388
+ return violation.path.startsWith(prefix);
267
389
  }
268
390
 
269
391
  function renderSummary(
270
- violations: number,
271
- safe: number,
272
- destructive: number,
392
+ rules: number,
393
+ ignoreRules: number,
394
+ trackRules: number,
395
+ toFix: number,
273
396
  ): React.ReactNode {
274
- if (violations === 0) {
275
- return <text fg="green">✓ All rules satisfied — nothing to fix.</text>;
276
- }
277
-
278
397
  return (
279
- <text>
280
- <span fg="white">{`${violations} violation${violations === 1 ? "" : "s"} `}</span>
281
- <span fg="gray">{`(`}</span>
282
- {safe > 0 && <span fg="yellow">{`${safe} safe`}</span>}
283
- {safe > 0 && destructive > 0 && <span fg="gray">{`, `}</span>}
284
- {destructive > 0 && <span fg="red">{`${destructive} destructive`}</span>}
285
- <span fg="gray">{`)`}</span>
286
- </text>
398
+ <box flexDirection="column">
399
+ <text>
400
+ <span fg="white">{`${rules} managed item${rules === 1 ? "" : "s"}`}</span>
401
+ </text>
402
+ <text>
403
+ <span fg="gray">{`${ignoreRules} ignored ${trackRules} tracked`}</span>
404
+ </text>
405
+ {toFix === 0 ? (
406
+ <text fg="green">Everything matches — nothing to fix.</text>
407
+ ) : (
408
+ <text fg="yellow">{`${toFix} need${toFix === 1 ? "s" : ""} fixing — f one, F all`}</text>
409
+ )}
410
+ </box>
287
411
  );
288
412
  }
289
413
 
@@ -299,7 +423,9 @@ function renderDetail(
299
423
  <box flexDirection="column" marginTop={1}>
300
424
  <text fg="yellow">Warnings:</text>
301
425
  {warnings.map((w, i) => (
302
- <text fg="gray" key={i}>{w}</text>
426
+ <text fg="gray" key={i}>
427
+ {w}
428
+ </text>
303
429
  ))}
304
430
  </box>
305
431
  )}
@@ -307,58 +433,112 @@ function renderDetail(
307
433
  );
308
434
  }
309
435
 
310
- const { rule, violation } = row;
436
+ const { rule, violations } = row;
437
+ const reason = getGitignoreRuleReason(rule.action, rule.pattern);
438
+
439
+ const setIn = sourceName(rule.source);
311
440
 
312
- if (!violation) {
313
- // Rule is satisfied — show what it enforces and where it came from
441
+ if (violations.length === 0) {
314
442
  return (
315
443
  <box flexDirection="column">
316
- <text fg="white"><strong>{rule.pattern}</strong></text>
317
- <text>{" "}</text>
318
- <text fg="green">{`✓ ${rule.action === "ignore" ? "Ignored" : "Tracked"} as required.`}</text>
319
- <text>{" "}</text>
320
- <text fg="gray">{`Rule from ${rule.source} tier.`}</text>
444
+ <text fg="white">
445
+ <strong>{rule.pattern}</strong>
446
+ </text>
447
+ <text> </text>
448
+ <text fg="green">{`✓ Already ${rule.action === "ignore" ? "ignored" : "tracked"} — nothing to do.`}</text>
449
+ <text> </text>
450
+ <text fg="gray">{reason}</text>
451
+ <text> </text>
452
+ <text fg="gray">{`Set in: ${setIn}`}</text>
453
+ {renderWarnings(warnings)}
321
454
  </box>
322
455
  );
323
456
  }
324
457
 
325
- // Rule is violated — explain and show the action
326
458
  return (
327
459
  <box flexDirection="column">
328
- <text fg="white"><strong>{violation.path}</strong></text>
329
- <text>{" "}</text>
330
- <text fg="white">{describeKind(violation.kind)}</text>
331
- <text>{" "}</text>
332
- <text fg="gray">{`Rule from ${rule.source} tier.`}</text>
333
- <text fg={violation.severity === "safe" ? "yellow" : "red"}>
334
- {violation.severity === "safe"
335
- ? "Safe — only appends to .gitignore."
336
- : "Destructive — mutates the git index."}
460
+ <text fg="white">
461
+ <strong>{rule.pattern}</strong>
337
462
  </text>
338
- <text>{" "}</text>
339
- <text fg="cyan">{actionHint(violation.kind)}</text>
463
+ <text> </text>
464
+ <text>
465
+ <span fg="gray">Should be: </span>
466
+ <span fg="white">
467
+ {rule.action === "ignore" ? "ignored by git" : "tracked by git"}
468
+ </span>
469
+ </text>
470
+ <text fg="gray">{reason}</text>
471
+ <text fg="gray">{`Set in: ${setIn}`}</text>
472
+ <text> </text>
473
+ <text fg="yellow">{`Needs fixing (${violations.length}):`}</text>
474
+ {violations.map((v, i) => (
475
+ <box
476
+ key={`${v.kind}:${v.path}:${i}`}
477
+ flexDirection="column"
478
+ marginTop={i === 0 ? 0 : 1}
479
+ >
480
+ <text fg="white">{v.path}</text>
481
+ <text fg="gray">{describeKind(v.kind)}</text>
482
+ <text fg={v.severity === "safe" ? "cyan" : "red"}>
483
+ {actionHint(v.kind)}
484
+ </text>
485
+ </box>
486
+ ))}
487
+ {renderWarnings(warnings)}
340
488
  </box>
341
489
  );
342
490
  }
343
491
 
492
+ /** Friendly, where-to-change-it name for a rule's source manifest. */
493
+ function sourceName(source: ResolvedRule["source"]): string {
494
+ switch (source) {
495
+ case "project":
496
+ return "Project (.claude/gitignore.json)";
497
+ case "global":
498
+ return "Global (~/.claude/gitignore.json)";
499
+ case "profile":
500
+ return "Profile";
501
+ case "builtin":
502
+ return "Default (built into claudeup)";
503
+ }
504
+ }
505
+
344
506
  function describeKind(kind: Violation["kind"]): string {
345
507
  switch (kind) {
346
- case "tracked-but-should-ignore": return "This file is in the git index but the manifest says it should be ignored.";
347
- case "ignored-but-should-track": return "Your .gitignore excludes this path but the manifest says it should be tracked.";
348
- case "untracked-and-should-track": return "This path exists on disk but git doesn't track it; the manifest says it should.";
349
- case "missing-from-gitignore": return "The manifest says ignore this path, but it's not in any .gitignore line.";
508
+ case "tracked-but-should-ignore":
509
+ return "Git is tracking it, but it should be ignored.";
510
+ case "ignored-but-should-track":
511
+ return "A .gitignore rule hides it, but it should be in git.";
512
+ case "untracked-and-should-track":
513
+ return "It's on disk but git isn't tracking it yet.";
514
+ case "missing-from-gitignore":
515
+ return "It should be ignored, but nothing in .gitignore covers it.";
350
516
  }
351
517
  }
352
518
 
353
519
  function actionHint(kind: Violation["kind"]): string {
354
520
  switch (kind) {
355
521
  case "tracked-but-should-ignore":
356
- return "[F] runs `git rm --cached` and appends a .gitignore entry.";
522
+ return "Press f to stop tracking it and add it to .gitignore.";
357
523
  case "ignored-but-should-track":
358
- return "[F] removes the matching .gitignore line and runs `git add`.";
524
+ return "Press f to remove the .gitignore line and add it to git.";
359
525
  case "untracked-and-should-track":
360
- return "[F] runs `git add` to stage the path.";
526
+ return "Press f to add it to git.";
361
527
  case "missing-from-gitignore":
362
- return "[f] appends this and every other safe entry to .gitignore.";
528
+ return "Press f to add it to .gitignore (F to fix all).";
363
529
  }
364
530
  }
531
+
532
+ function renderWarnings(warnings: string[]): React.ReactNode {
533
+ if (warnings.length === 0) return null;
534
+ return (
535
+ <box flexDirection="column" marginTop={1}>
536
+ <text fg="yellow">Warnings:</text>
537
+ {warnings.map((w, i) => (
538
+ <text fg="gray" key={i}>
539
+ {w}
540
+ </text>
541
+ ))}
542
+ </box>
543
+ );
544
+ }
@@ -214,8 +214,19 @@ export function McpRegistryScreen() {
214
214
  };
215
215
  // Footer hints
216
216
  const footerHints = isSearchActive
217
- ? "Type to search │ ↑↓:nav │ Enter:done │ Esc:cancel"
218
- : "↑↓:nav │ Enter:install │ /:search │ R:refresh │ l:local";
217
+ ? [
218
+ { keys: ["type"], label: "search" },
219
+ { keys: ["↑", "↓"], label: "nav" },
220
+ { keys: ["Enter"], label: "done" },
221
+ { keys: ["Esc"], label: "cancel" },
222
+ ]
223
+ : [
224
+ { keys: ["↑", "↓"], label: "nav" },
225
+ { keys: ["Enter"], label: "install" },
226
+ { keys: ["/"], label: "search" },
227
+ { keys: ["R"], label: "refresh" },
228
+ { keys: ["l"], label: "local" },
229
+ ];
219
230
  // Status for search placeholder
220
231
  const searchPlaceholder = `${servers.length} servers │ / to search`;
221
232
  return (_jsx(ScreenLayout, { title: "claudeup MCP Registry", subtitle: "Powered by MCP Registry", currentScreen: "mcp-registry", search: {
@@ -303,8 +303,19 @@ export function McpRegistryScreen() {
303
303
 
304
304
  // Footer hints
305
305
  const footerHints = isSearchActive
306
- ? "Type to search │ ↑↓:nav │ Enter:done │ Esc:cancel"
307
- : "↑↓:nav │ Enter:install │ /:search │ R:refresh │ l:local";
306
+ ? [
307
+ { keys: ["type"], label: "search" },
308
+ { keys: ["↑", "↓"], label: "nav" },
309
+ { keys: ["Enter"], label: "done" },
310
+ { keys: ["Esc"], label: "cancel" },
311
+ ]
312
+ : [
313
+ { keys: ["↑", "↓"], label: "nav" },
314
+ { keys: ["Enter"], label: "install" },
315
+ { keys: ["/"], label: "search" },
316
+ { keys: ["R"], label: "refresh" },
317
+ { keys: ["l"], label: "local" },
318
+ ];
308
319
 
309
320
  // Status for search placeholder
310
321
  const searchPlaceholder = `${servers.length} servers │ / to search`;
@@ -166,6 +166,11 @@ export function McpScreen() {
166
166
  const installedCount = Object.keys(mcp.installedServers).length;
167
167
  const enabledCount = Object.values(mcp.installedServers).filter((v) => v === true).length;
168
168
  const subtitle = `${enabledCount} enabled │ ${installedCount} configured │ / to search`;
169
- return (_jsx(ScreenLayout, { title: "claudeup MCP Servers", subtitle: subtitle, currentScreen: "mcp", footerHints: "\u2191\u2193:nav \u2502 Enter:toggle \u2502 /:search \u2502 r:registry", listPanel: _jsx(ScrollableList, { items: allListItems, selectedIndex: mcp.selectedIndex, renderItem: renderMcpRow, maxHeight: dimensions.listPanelHeight }), detailPanel: renderMcpDetail(selectedItem, isLoading) }));
169
+ return (_jsx(ScreenLayout, { title: "claudeup MCP Servers", subtitle: subtitle, currentScreen: "mcp", footerHints: [
170
+ { keys: ["↑", "↓"], label: "nav" },
171
+ { keys: ["Enter"], label: "toggle" },
172
+ { keys: ["/"], label: "search" },
173
+ { keys: ["r"], label: "registry" },
174
+ ], listPanel: _jsx(ScrollableList, { items: allListItems, selectedIndex: mcp.selectedIndex, renderItem: renderMcpRow, maxHeight: dimensions.listPanelHeight }), detailPanel: renderMcpDetail(selectedItem, isLoading) }));
170
175
  }
171
176
  export default McpScreen;