claudeup 4.35.0 → 4.36.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 (63) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/git-worktree.test.ts +108 -0
  3. package/src/__tests__/scope-squares.test.tsx +165 -0
  4. package/src/__tests__/theme-adaptive-colors.test.ts +307 -0
  5. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  6. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  7. package/src/main.tsx +21 -5
  8. package/src/opentui.d.ts +21 -12
  9. package/src/services/claude-settings.ts +37 -7
  10. package/src/services/git-worktree.ts +129 -0
  11. package/src/services/plugin-manager.ts +10 -1
  12. package/src/ui/App.tsx +19 -12
  13. package/src/ui/adapters/pluginsAdapter.ts +174 -168
  14. package/src/ui/adapters/settingsAdapter.ts +119 -116
  15. package/src/ui/adapters/skillsAdapter.ts +203 -196
  16. package/src/ui/components/CategoryHeader.tsx +9 -8
  17. package/src/ui/components/EmptyFilterState.tsx +10 -5
  18. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  19. package/src/ui/components/ScopeIndicator.tsx +10 -6
  20. package/src/ui/components/ScrollableList.tsx +3 -2
  21. package/src/ui/components/SearchInput.tsx +2 -1
  22. package/src/ui/components/StyledText.tsx +5 -4
  23. package/src/ui/components/TabBar.tsx +4 -3
  24. package/src/ui/components/layout/FooterHints.tsx +37 -30
  25. package/src/ui/components/layout/Panel.tsx +6 -5
  26. package/src/ui/components/layout/ProgressBar.tsx +7 -6
  27. package/src/ui/components/layout/ScopeTabs.tsx +6 -5
  28. package/src/ui/components/layout/ScreenLayout.tsx +27 -24
  29. package/src/ui/components/layout/index.ts +3 -3
  30. package/src/ui/components/modals/ConfirmModal.tsx +12 -11
  31. package/src/ui/components/modals/InputModal.tsx +14 -6
  32. package/src/ui/components/modals/LoadingModal.tsx +6 -5
  33. package/src/ui/components/modals/MessageModal.tsx +9 -8
  34. package/src/ui/components/modals/SelectModal.tsx +11 -7
  35. package/src/ui/components/modals/VersionMismatchModal.tsx +14 -16
  36. package/src/ui/components/primitives/ActionHints.tsx +26 -26
  37. package/src/ui/components/primitives/DetailSection.tsx +13 -12
  38. package/src/ui/components/primitives/KeyValueLine.tsx +9 -8
  39. package/src/ui/components/primitives/ListCategoryRow.tsx +25 -27
  40. package/src/ui/components/primitives/MetaText.tsx +3 -3
  41. package/src/ui/components/primitives/ScopeDetail.tsx +48 -48
  42. package/src/ui/components/primitives/ScopeSquares.tsx +47 -22
  43. package/src/ui/components/primitives/SelectableRow.tsx +22 -16
  44. package/src/ui/hooks/useGitignoreModal.ts +78 -74
  45. package/src/ui/registry.ts +11 -11
  46. package/src/ui/renderers/cliToolRenderers.tsx +260 -203
  47. package/src/ui/renderers/gitignoreRenderers.tsx +43 -42
  48. package/src/ui/renderers/mcpRenderers.tsx +121 -117
  49. package/src/ui/renderers/pluginRenderers.tsx +528 -471
  50. package/src/ui/renderers/profileRenderers.tsx +346 -300
  51. package/src/ui/renderers/settingsRenderers.tsx +183 -176
  52. package/src/ui/renderers/skillRenderers.tsx +410 -326
  53. package/src/ui/screens/AliasScreen.tsx +1336 -1309
  54. package/src/ui/screens/CliToolsScreen.tsx +92 -40
  55. package/src/ui/screens/EnvVarsScreen.tsx +19 -13
  56. package/src/ui/screens/GitignoreScreen.tsx +510 -493
  57. package/src/ui/screens/McpRegistryScreen.tsx +28 -21
  58. package/src/ui/screens/McpScreen.tsx +12 -3
  59. package/src/ui/screens/PluginsScreen.tsx +17 -7
  60. package/src/ui/screens/ProfilesScreen.tsx +39 -23
  61. package/src/ui/screens/SkillsScreen.tsx +832 -688
  62. package/src/ui/theme-mode.ts +73 -0
  63. package/src/ui/theme.ts +147 -53
@@ -6,539 +6,556 @@ import { useApp, useModal } from "../state/AppContext.js";
6
6
  import { useKeyboardHandler } from "../hooks/useKeyboardHandler.js";
7
7
  import { ScreenLayout } from "../components/layout/index.js";
8
8
  import {
9
- loadGitignoreState,
10
- applyDestructiveFix,
11
- ensureProjectManifestForEdit,
12
- applyTemplate,
13
- type GitignoreState,
9
+ loadGitignoreState,
10
+ applyDestructiveFix,
11
+ ensureProjectManifestForEdit,
12
+ applyTemplate,
13
+ type GitignoreState,
14
14
  } from "../../services/gitignore-service.js";
15
15
  import { BUILTIN_TEMPLATES } from "../../data/gitignore-templates.js";
16
16
  import { getGitignoreRuleReason } from "../../data/gitignore-reasons.js";
17
17
  import {
18
- renderRuleRow,
19
- type RuleRow,
18
+ renderRuleRow,
19
+ type RuleRow,
20
20
  } from "../renderers/gitignoreRenderers.js";
21
21
  import type { ResolvedRule, Tier, Violation } from "../../types/gitignore.js";
22
+ import { theme } from "../theme.js";
22
23
 
23
24
  // Clean rows are grouped by the manifest that defined them. Order = display
24
25
  // order, top to bottom. "Default" is the user-facing name for the built-in
25
26
  // rules. Each label names where you'd go to change that group's rules.
26
27
  const SOURCE_ORDER: Tier[] = ["project", "global", "profile", "builtin"];
27
28
  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",
29
+ project: "Project · .claude/gitignore.json",
30
+ global: "Global · ~/.claude/gitignore.json",
31
+ profile: "Profile",
32
+ builtin: "Default · built into claudeup",
32
33
  };
33
34
 
34
35
  export function GitignoreScreen() {
35
- const { state } = useApp();
36
- const modal = useModal();
37
- const renderer = useRenderer();
38
- const cwd = state.projectPath;
39
-
40
- const [data, setData] = useState<GitignoreState | null>(null);
41
- const [loading, setLoading] = useState(true);
42
- const [error, setError] = useState<string | null>(null);
43
- const [selectedIdx, setSelectedIdx] = useState(0);
44
-
45
- const reload = useCallback(async () => {
46
- setLoading(true);
47
- setError(null);
48
- try {
49
- const s = await loadGitignoreState(cwd);
50
- setData(s);
51
- } catch (err) {
52
- setError(err instanceof Error ? err.message : String(err));
53
- } finally {
54
- setLoading(false);
55
- }
56
- }, [cwd]);
57
-
58
- useEffect(() => {
59
- void reload();
60
- }, [reload]);
61
-
62
- // Build the managed list: every desired gitignore rule, with all matching
63
- // violations grouped under the rule that owns them.
64
- const rows: RuleRow[] = useMemo(() => {
65
- if (!data) return [];
66
-
67
- const annotated: RuleRow[] = data.resolved.rules.map((rule) => {
68
- const violations = data.violations.filter((v) =>
69
- violationMatchesRule(rule, v),
70
- );
71
- return { rule, violations };
72
- });
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
- };
83
- return annotated.sort((a, b) => {
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
- }
94
- return a.rule.pattern.localeCompare(b.rule.pattern);
95
- });
96
- }, [data]);
97
-
98
- // Clamp selection when rows shrink.
99
- useEffect(() => {
100
- if (selectedIdx >= rows.length) {
101
- setSelectedIdx(Math.max(0, rows.length - 1));
102
- }
103
- }, [rows.length, selectedIdx]);
104
-
105
- const selectedRow = rows[selectedIdx];
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;
127
-
128
- // ── Action handlers ───────────────────────────────────────────────────
129
-
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]);
160
-
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
- );
169
- return;
170
- }
171
- if (selectedRow.violations.length === 0) {
172
- await modal.message(
173
- "Nothing to fix",
174
- `${selectedRow.rule.pattern} already matches. f only applies to rows that need fixing.`,
175
- "info",
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
- }
196
- } catch (err) {
197
- await modal.message(
198
- "Fix failed",
199
- err instanceof Error ? err.message : String(err),
200
- "error",
201
- );
202
- }
203
- }, [selectedRow, modal, cwd, reload]);
204
-
205
- const handleApplyTemplate = useCallback(async () => {
206
- const names = Object.keys(BUILTIN_TEMPLATES);
207
- if (names.length === 0) {
208
- await modal.message(
209
- "No templates",
210
- "No built-in templates available.",
211
- "info",
212
- );
213
- return;
214
- }
215
- const selected = await modal.select(
216
- "Apply template",
217
- "Merge the template into the project manifest at .claude/gitignore.json.",
218
- names.map((n) => ({ label: n, value: n })),
219
- );
220
- if (!selected) return;
221
- try {
222
- const r = await applyTemplate(cwd, selected);
223
- const conflictNote =
224
- r.conflicts.length > 0
225
- ? `\n\nSkipped due to conflicts: ${r.conflicts.join(", ")}`
226
- : "";
227
- await modal.message(
228
- "Template applied",
229
- `Wrote ${r.appliedTo}\n\n` +
230
- `Added ${r.added.ignore.length} ignore + ${r.added.track.length} track entries.` +
231
- conflictNote,
232
- "success",
233
- );
234
- } catch (err) {
235
- await modal.message(
236
- "Template failed",
237
- err instanceof Error ? err.message : String(err),
238
- "error",
239
- );
240
- }
241
- await reload();
242
- }, [cwd, modal, reload]);
243
-
244
- const handleEditManifest = useCallback(async () => {
245
- const current = data ?? await loadGitignoreState(cwd);
246
- const projectManifest = await ensureProjectManifestForEdit(
247
- cwd,
248
- current.resolved,
249
- );
250
- const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
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
- }
261
- await reload();
262
- renderer.requestRender();
263
- }, [cwd, data, reload, renderer]);
264
-
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
- }
276
- if (state.modal) return;
277
- if (input === "j" || key.downArrow) {
278
- setSelectedIdx((i) => Math.min(i + 1, Math.max(0, rows.length - 1)));
279
- return;
280
- }
281
- if (input === "k" || key.upArrow) {
282
- setSelectedIdx((i) => Math.max(i - 1, 0));
283
- return;
284
- }
285
- // F = fix everything out of sync; f = fix just the selected row.
286
- if (input === "F" || (input === "f" && key.shift)) {
287
- void handleFixAll();
288
- return;
289
- }
290
- if (input === "f") {
291
- void handleFixOne();
292
- return;
293
- }
294
- if (input === "t") {
295
- void handleApplyTemplate();
296
- return;
297
- }
298
- if (input === "e") {
299
- void handleEditManifest();
300
- return;
301
- }
302
- if (input === "r") {
303
- void reload();
304
- }
305
- });
306
-
307
- // ── Rendering ─────────────────────────────────────────────────────────
308
-
309
- const summary = data
310
- ? renderSummary(rows.length, ignoreRuleCount, trackRuleCount, toFixCount)
311
- : null;
312
-
313
- const listBody = loading ? (
314
- <text fg="gray">Loading...</text>
315
- ) : error ? (
316
- <text fg="red">Error: {error}</text>
317
- ) : rows.length === 0 ? (
318
- <text fg="gray">No rules resolved.</text>
319
- ) : (
320
- <box flexDirection="column">
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 /
36
+ const { state } = useApp();
37
+ const modal = useModal();
38
+ const renderer = useRenderer();
39
+ const cwd = state.projectPath;
40
+
41
+ const [data, setData] = useState<GitignoreState | null>(null);
42
+ const [loading, setLoading] = useState(true);
43
+ const [error, setError] = useState<string | null>(null);
44
+ const [selectedIdx, setSelectedIdx] = useState(0);
45
+
46
+ const reload = useCallback(async () => {
47
+ setLoading(true);
48
+ setError(null);
49
+ try {
50
+ const s = await loadGitignoreState(cwd);
51
+ setData(s);
52
+ } catch (err) {
53
+ setError(err instanceof Error ? err.message : String(err));
54
+ } finally {
55
+ setLoading(false);
56
+ }
57
+ }, [cwd]);
58
+
59
+ useEffect(() => {
60
+ void reload();
61
+ }, [reload]);
62
+
63
+ // Build the managed list: every desired gitignore rule, with all matching
64
+ // violations grouped under the rule that owns them.
65
+ const rows: RuleRow[] = useMemo(() => {
66
+ if (!data) return [];
67
+
68
+ const annotated: RuleRow[] = data.resolved.rules.map((rule) => {
69
+ const violations = data.violations.filter((v) =>
70
+ violationMatchesRule(rule, v),
71
+ );
72
+ return { rule, violations };
73
+ });
74
+
75
+ // Display order = flat index order (the keyboard handler walks this array),
76
+ // so the on-screen layout must match this sort exactly:
77
+ // 1. violations first (most violations first)
78
+ // 2. then clean rows grouped by source manifest (SOURCE_ORDER)
79
+ // 3. alphabetical within each group
80
+ const sourceRank = (tier: ResolvedRule["source"]): number => {
81
+ const i = SOURCE_ORDER.indexOf(tier);
82
+ return i === -1 ? SOURCE_ORDER.length : i;
83
+ };
84
+ return annotated.sort((a, b) => {
85
+ const aViol = a.violations.length > 0;
86
+ const bViol = b.violations.length > 0;
87
+ if (aViol !== bViol) return aViol ? -1 : 1;
88
+ if (aViol && bViol && a.violations.length !== b.violations.length) {
89
+ return b.violations.length - a.violations.length;
90
+ }
91
+ if (!aViol) {
92
+ const sr = sourceRank(a.rule.source) - sourceRank(b.rule.source);
93
+ if (sr !== 0) return sr;
94
+ }
95
+ return a.rule.pattern.localeCompare(b.rule.pattern);
96
+ });
97
+ }, [data]);
98
+
99
+ // Clamp selection when rows shrink.
100
+ useEffect(() => {
101
+ if (selectedIdx >= rows.length) {
102
+ setSelectedIdx(Math.max(0, rows.length - 1));
103
+ }
104
+ }, [rows.length, selectedIdx]);
105
+
106
+ const selectedRow = rows[selectedIdx];
107
+ const ignoreRuleCount =
108
+ data?.resolved.rules.filter((rule) => rule.action === "ignore").length ?? 0;
109
+ const trackRuleCount =
110
+ data?.resolved.rules.filter((rule) => rule.action === "track").length ?? 0;
111
+ const toFixCount = data?.violations.length ?? 0;
112
+
113
+ // Group rows for display while preserving each row's flat index in `rows`
114
+ // (the keyboard handler walks `rows` by index, so the highlight must track
115
+ // the original index, not the position within a rendered group).
116
+ const indexed = rows.map((row, index) => ({ row, index }));
117
+ const toFixRows = indexed.filter((r) => r.row.violations.length > 0);
118
+ // Clean rows grouped by which manifest defined them. Sections render only
119
+ // when non-empty, so e.g. the Profile group stays hidden until profiles
120
+ // actually contribute rules.
121
+ const cleanIndexed = indexed.filter((r) => r.row.violations.length === 0);
122
+ const sourceGroups = SOURCE_ORDER.map((tier) => ({
123
+ tier,
124
+ label: SOURCE_LABELS[tier],
125
+ items: cleanIndexed.filter((r) => r.row.rule.source === tier),
126
+ })).filter((g) => g.items.length > 0);
127
+ const cleanCount = cleanIndexed.length;
128
+
129
+ // ── Action handlers ───────────────────────────────────────────────────
130
+
131
+ // Fix every out-of-sync row. Iterates the grouped rows (not the flat
132
+ // violation list) so each .gitignore entry uses the rule's pattern/glob,
133
+ // not one specific path under it — same association handleApplyOne relies on.
134
+ const handleFixAll = useCallback(async () => {
135
+ if (!data || toFixRows.length === 0) return;
136
+ try {
137
+ const messages: string[] = [];
138
+ const failures: string[] = [];
139
+ for (const { row } of toFixRows) {
140
+ for (const v of row.violations) {
141
+ const r = await applyDestructiveFix(cwd, v, row.rule.pattern);
142
+ (r.applied ? messages : failures).push(r.message);
143
+ }
144
+ }
145
+ await reload();
146
+ if (failures.length > 0) {
147
+ await modal.message(
148
+ "Some fixes didn't apply",
149
+ [...failures, ...messages].join("\n"),
150
+ "error",
151
+ );
152
+ }
153
+ } catch (err) {
154
+ await modal.message(
155
+ "Fix failed",
156
+ err instanceof Error ? err.message : String(err),
157
+ "error",
158
+ );
159
+ }
160
+ }, [cwd, data, toFixRows, reload, modal]);
161
+
162
+ // Fix just the highlighted row.
163
+ const handleFixOne = useCallback(async () => {
164
+ if (!selectedRow) {
165
+ await modal.message(
166
+ "No row selected",
167
+ "Use ↑↓ to highlight a row that needs fixing, then press f.",
168
+ "info",
169
+ );
170
+ return;
171
+ }
172
+ if (selectedRow.violations.length === 0) {
173
+ await modal.message(
174
+ "Nothing to fix",
175
+ `${selectedRow.rule.pattern} already matches. f only applies to rows that need fixing.`,
176
+ "info",
177
+ );
178
+ return;
179
+ }
180
+
181
+ try {
182
+ const results: Array<{ applied: boolean; message: string }> = [];
183
+ for (const v of selectedRow.violations) {
184
+ results.push(
185
+ await applyDestructiveFix(cwd, v, selectedRow.rule.pattern),
186
+ );
187
+ }
188
+ const failed = results.filter((r) => !r.applied);
189
+ await reload();
190
+ // Success is silent — the reload removes the fixed row. Only surface a
191
+ // modal when something didn't apply.
192
+ if (failed.length > 0) {
193
+ await modal.message(
194
+ "Some fixes didn't apply",
195
+ results.map((r) => r.message).join("\n"),
196
+ "error",
197
+ );
198
+ }
199
+ } catch (err) {
200
+ await modal.message(
201
+ "Fix failed",
202
+ err instanceof Error ? err.message : String(err),
203
+ "error",
204
+ );
205
+ }
206
+ }, [selectedRow, modal, cwd, reload]);
207
+
208
+ const handleApplyTemplate = useCallback(async () => {
209
+ const names = Object.keys(BUILTIN_TEMPLATES);
210
+ if (names.length === 0) {
211
+ await modal.message(
212
+ "No templates",
213
+ "No built-in templates available.",
214
+ "info",
215
+ );
216
+ return;
217
+ }
218
+ const selected = await modal.select(
219
+ "Apply template",
220
+ "Merge the template into the project manifest at .claude/gitignore.json.",
221
+ names.map((n) => ({ label: n, value: n })),
222
+ );
223
+ if (!selected) return;
224
+ try {
225
+ const r = await applyTemplate(cwd, selected);
226
+ const conflictNote =
227
+ r.conflicts.length > 0
228
+ ? `\n\nSkipped due to conflicts: ${r.conflicts.join(", ")}`
229
+ : "";
230
+ await modal.message(
231
+ "Template applied",
232
+ `Wrote ${r.appliedTo}\n\n` +
233
+ `Added ${r.added.ignore.length} ignore + ${r.added.track.length} track entries.` +
234
+ conflictNote,
235
+ "success",
236
+ );
237
+ } catch (err) {
238
+ await modal.message(
239
+ "Template failed",
240
+ err instanceof Error ? err.message : String(err),
241
+ "error",
242
+ );
243
+ }
244
+ await reload();
245
+ }, [cwd, modal, reload]);
246
+
247
+ const handleEditManifest = useCallback(async () => {
248
+ const current = data ?? (await loadGitignoreState(cwd));
249
+ const projectManifest = await ensureProjectManifestForEdit(
250
+ cwd,
251
+ current.resolved,
252
+ );
253
+ const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
254
+ renderer.suspend();
255
+ try {
256
+ await new Promise<void>((resolveEdit) => {
257
+ const child = spawn(editor, [projectManifest], { stdio: "inherit" });
258
+ child.on("exit", () => resolveEdit());
259
+ child.on("error", () => resolveEdit());
260
+ });
261
+ } finally {
262
+ renderer.resume();
263
+ }
264
+ await reload();
265
+ renderer.requestRender();
266
+ }, [cwd, data, reload, renderer]);
267
+
268
+ useKeyboardHandler((input, key) => {
269
+ if (process.env.CLAUDEUP_DEBUG_KEYS === "1") {
270
+ try {
271
+ appendFileSync(
272
+ "/tmp/claudeup-keys.log",
273
+ `${new Date().toISOString()} screen=gitignore input=${JSON.stringify(input)} name=${JSON.stringify(key.name)} shift=${key.shift} ctrl=${key.ctrl} meta=${key.meta}\n`,
274
+ );
275
+ } catch {
276
+ // best-effort logging — never break the UI
277
+ }
278
+ }
279
+ if (state.modal) return;
280
+ if (input === "j" || key.downArrow) {
281
+ setSelectedIdx((i) => Math.min(i + 1, Math.max(0, rows.length - 1)));
282
+ return;
283
+ }
284
+ if (input === "k" || key.upArrow) {
285
+ setSelectedIdx((i) => Math.max(i - 1, 0));
286
+ return;
287
+ }
288
+ // F = fix everything out of sync; f = fix just the selected row.
289
+ if (input === "F" || (input === "f" && key.shift)) {
290
+ void handleFixAll();
291
+ return;
292
+ }
293
+ if (input === "f") {
294
+ void handleFixOne();
295
+ return;
296
+ }
297
+ if (input === "t") {
298
+ void handleApplyTemplate();
299
+ return;
300
+ }
301
+ if (input === "e") {
302
+ void handleEditManifest();
303
+ return;
304
+ }
305
+ if (input === "r") {
306
+ void reload();
307
+ }
308
+ });
309
+
310
+ // ── Rendering ─────────────────────────────────────────────────────────
311
+
312
+ const summary = data
313
+ ? renderSummary(rows.length, ignoreRuleCount, trackRuleCount, toFixCount)
314
+ : null;
315
+
316
+ const listBody = loading ? (
317
+ <text fg={theme.colors.muted}>Loading...</text>
318
+ ) : error ? (
319
+ <text fg={theme.colors.danger}>Error: {error}</text>
320
+ ) : rows.length === 0 ? (
321
+ <text fg={theme.colors.muted}>No rules resolved.</text>
322
+ ) : (
323
+ <box flexDirection="column">
324
+ {toFixRows.length > 0 && (
325
+ <box flexDirection="column">
326
+ <text
327
+ fg={theme.colors.danger}
328
+ >{`Needs fixing (${toFixRows.length})`}</text>
329
+ {toFixRows
330
+ .slice(0, 100)
331
+ .map(({ row, index }) => renderRuleRow(row, index === selectedIdx))}
332
+ <text fg={theme.colors.text}> </text>
333
+ </box>
334
+ )}
335
+ <text fg={theme.colors.muted}>{`Managed items (${cleanCount})`}</text>
336
+ {/* One section per manifest source (Default / Global / Project /
332
337
  Profile). Empty sources are dropped, so headers only appear when
333
338
  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
- ))}
343
- </box>
344
- );
345
-
346
- const listPanel = (
347
- <box flexDirection="column">
348
- {summary}
349
- <text> </text>
350
- {listBody}
351
- </box>
352
- );
353
-
354
- const detailPanel = renderDetail(selectedRow, data?.warnings ?? []);
355
-
356
- const subtitle = data
357
- ? toFixCount === 0
358
- ? `${rows.length} managed`
359
- : `${toFixCount} to fix`
360
- : "";
361
-
362
- return (
363
- <ScreenLayout
364
- title="Managed Git State"
365
- subtitle={subtitle}
366
- currentScreen="gitignore"
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
- ]}
375
- listPanel={listPanel}
376
- detailPanel={detailPanel}
377
- />
378
- );
339
+ {sourceGroups.map((group, gi) => (
340
+ <box flexDirection="column" key={group.tier}>
341
+ {gi > 0 && <text fg={theme.colors.text}> </text>}
342
+ <text fg={theme.colors.muted}>{` ${group.label}`}</text>
343
+ {group.items.map(({ row, index }) =>
344
+ renderRuleRow(row, index === selectedIdx),
345
+ )}
346
+ </box>
347
+ ))}
348
+ </box>
349
+ );
350
+
351
+ const listPanel = (
352
+ <box flexDirection="column">
353
+ {summary}
354
+ <text fg={theme.colors.text}> </text>
355
+ {listBody}
356
+ </box>
357
+ );
358
+
359
+ const detailPanel = renderDetail(selectedRow, data?.warnings ?? []);
360
+
361
+ const subtitle = data
362
+ ? toFixCount === 0
363
+ ? `${rows.length} managed`
364
+ : `${toFixCount} to fix`
365
+ : "";
366
+
367
+ return (
368
+ <ScreenLayout
369
+ title="Managed Git State"
370
+ subtitle={subtitle}
371
+ currentScreen="gitignore"
372
+ footerHints={[
373
+ { keys: ["↑", "↓"], label: "move" },
374
+ { keys: ["f"], label: "fix selected" },
375
+ { keys: ["F"], label: "fix all" },
376
+ { keys: ["t"], label: "template" },
377
+ { keys: ["e"], label: "edit" },
378
+ { keys: ["r"], label: "reload" },
379
+ ]}
380
+ listPanel={listPanel}
381
+ detailPanel={detailPanel}
382
+ />
383
+ );
379
384
  }
380
385
 
381
386
  function violationMatchesRule(
382
- rule: ResolvedRule,
383
- violation: Violation,
387
+ rule: ResolvedRule,
388
+ violation: Violation,
384
389
  ): boolean {
385
- if (violation.path === rule.pattern) return true;
386
- if (!rule.pattern.endsWith("/") && !rule.pattern.includes("*")) return false;
387
- const prefix = rule.pattern.replace(/\*+$/, "");
388
- return violation.path.startsWith(prefix);
390
+ if (violation.path === rule.pattern) return true;
391
+ if (!rule.pattern.endsWith("/") && !rule.pattern.includes("*")) return false;
392
+ const prefix = rule.pattern.replace(/\*+$/, "");
393
+ return violation.path.startsWith(prefix);
389
394
  }
390
395
 
391
396
  function renderSummary(
392
- rules: number,
393
- ignoreRules: number,
394
- trackRules: number,
395
- toFix: number,
397
+ rules: number,
398
+ ignoreRules: number,
399
+ trackRules: number,
400
+ toFix: number,
396
401
  ): React.ReactNode {
397
- return (
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>
411
- );
402
+ return (
403
+ <box flexDirection="column">
404
+ <text fg={theme.colors.text}>
405
+ <span
406
+ fg={theme.colors.text}
407
+ >{`${rules} managed item${rules === 1 ? "" : "s"}`}</span>
408
+ </text>
409
+ <text fg={theme.colors.text}>
410
+ <span
411
+ fg={theme.colors.muted}
412
+ >{`${ignoreRules} ignored ${trackRules} tracked`}</span>
413
+ </text>
414
+ {toFix === 0 ? (
415
+ <text fg={theme.colors.success}>
416
+ Everything matches — nothing to fix.
417
+ </text>
418
+ ) : (
419
+ <text
420
+ fg={theme.colors.warning}
421
+ >{`${toFix} need${toFix === 1 ? "s" : ""} fixing — f one, F all`}</text>
422
+ )}
423
+ </box>
424
+ );
412
425
  }
413
426
 
414
427
  function renderDetail(
415
- row: RuleRow | undefined,
416
- warnings: string[],
428
+ row: RuleRow | undefined,
429
+ warnings: string[],
417
430
  ): React.ReactNode {
418
- if (!row) {
419
- return (
420
- <box flexDirection="column">
421
- <text fg="gray">No rule selected.</text>
422
- {warnings.length > 0 && (
423
- <box flexDirection="column" marginTop={1}>
424
- <text fg="yellow">Warnings:</text>
425
- {warnings.map((w, i) => (
426
- <text fg="gray" key={i}>
427
- {w}
428
- </text>
429
- ))}
430
- </box>
431
- )}
432
- </box>
433
- );
434
- }
435
-
436
- const { rule, violations } = row;
437
- const reason = getGitignoreRuleReason(rule.action, rule.pattern);
438
-
439
- const setIn = sourceName(rule.source);
440
-
441
- if (violations.length === 0) {
442
- return (
443
- <box flexDirection="column">
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)}
454
- </box>
455
- );
456
- }
457
-
458
- return (
459
- <box flexDirection="column">
460
- <text fg="white">
461
- <strong>{rule.pattern}</strong>
462
- </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)}
488
- </box>
489
- );
431
+ if (!row) {
432
+ return (
433
+ <box flexDirection="column">
434
+ <text fg={theme.colors.muted}>No rule selected.</text>
435
+ {warnings.length > 0 && (
436
+ <box flexDirection="column" marginTop={1}>
437
+ <text fg={theme.colors.warning}>Warnings:</text>
438
+ {warnings.map((w, i) => (
439
+ <text fg={theme.colors.muted} key={i}>
440
+ {w}
441
+ </text>
442
+ ))}
443
+ </box>
444
+ )}
445
+ </box>
446
+ );
447
+ }
448
+
449
+ const { rule, violations } = row;
450
+ const reason = getGitignoreRuleReason(rule.action, rule.pattern);
451
+
452
+ const setIn = sourceName(rule.source);
453
+
454
+ if (violations.length === 0) {
455
+ return (
456
+ <box flexDirection="column">
457
+ <text fg={theme.colors.text}>
458
+ <strong>{rule.pattern}</strong>
459
+ </text>
460
+ <text fg={theme.colors.text}> </text>
461
+ <text
462
+ fg={theme.colors.success}
463
+ >{`✓ Already ${rule.action === "ignore" ? "ignored" : "tracked"} — nothing to do.`}</text>
464
+ <text fg={theme.colors.text}> </text>
465
+ <text fg={theme.colors.muted}>{reason}</text>
466
+ <text fg={theme.colors.text}> </text>
467
+ <text fg={theme.colors.muted}>{`Set in: ${setIn}`}</text>
468
+ {renderWarnings(warnings)}
469
+ </box>
470
+ );
471
+ }
472
+
473
+ return (
474
+ <box flexDirection="column">
475
+ <text fg={theme.colors.text}>
476
+ <strong>{rule.pattern}</strong>
477
+ </text>
478
+ <text fg={theme.colors.text}> </text>
479
+ <text fg={theme.colors.text}>
480
+ <span fg={theme.colors.muted}>Should be: </span>
481
+ <span fg={theme.colors.text}>
482
+ {rule.action === "ignore" ? "ignored by git" : "tracked by git"}
483
+ </span>
484
+ </text>
485
+ <text fg={theme.colors.muted}>{reason}</text>
486
+ <text fg={theme.colors.muted}>{`Set in: ${setIn}`}</text>
487
+ <text fg={theme.colors.text}> </text>
488
+ <text
489
+ fg={theme.colors.warning}
490
+ >{`Needs fixing (${violations.length}):`}</text>
491
+ {violations.map((v, i) => (
492
+ <box
493
+ key={`${v.kind}:${v.path}:${i}`}
494
+ flexDirection="column"
495
+ marginTop={i === 0 ? 0 : 1}
496
+ >
497
+ <text fg={theme.colors.text}>{v.path}</text>
498
+ <text fg={theme.colors.muted}>{describeKind(v.kind)}</text>
499
+ <text fg={v.severity === "safe" ? "cyan" : "red"}>
500
+ {actionHint(v.kind)}
501
+ </text>
502
+ </box>
503
+ ))}
504
+ {renderWarnings(warnings)}
505
+ </box>
506
+ );
490
507
  }
491
508
 
492
509
  /** Friendly, where-to-change-it name for a rule's source manifest. */
493
510
  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
- }
511
+ switch (source) {
512
+ case "project":
513
+ return "Project (.claude/gitignore.json)";
514
+ case "global":
515
+ return "Global (~/.claude/gitignore.json)";
516
+ case "profile":
517
+ return "Profile";
518
+ case "builtin":
519
+ return "Default (built into claudeup)";
520
+ }
504
521
  }
505
522
 
506
523
  function describeKind(kind: Violation["kind"]): string {
507
- switch (kind) {
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.";
516
- }
524
+ switch (kind) {
525
+ case "tracked-but-should-ignore":
526
+ return "Git is tracking it, but it should be ignored.";
527
+ case "ignored-but-should-track":
528
+ return "A .gitignore rule hides it, but it should be in git.";
529
+ case "untracked-and-should-track":
530
+ return "It's on disk but git isn't tracking it yet.";
531
+ case "missing-from-gitignore":
532
+ return "It should be ignored, but nothing in .gitignore covers it.";
533
+ }
517
534
  }
518
535
 
519
536
  function actionHint(kind: Violation["kind"]): string {
520
- switch (kind) {
521
- case "tracked-but-should-ignore":
522
- return "Press f to stop tracking it and add it to .gitignore.";
523
- case "ignored-but-should-track":
524
- return "Press f to remove the .gitignore line and add it to git.";
525
- case "untracked-and-should-track":
526
- return "Press f to add it to git.";
527
- case "missing-from-gitignore":
528
- return "Press f to add it to .gitignore (F to fix all).";
529
- }
537
+ switch (kind) {
538
+ case "tracked-but-should-ignore":
539
+ return "Press f to stop tracking it and add it to .gitignore.";
540
+ case "ignored-but-should-track":
541
+ return "Press f to remove the .gitignore line and add it to git.";
542
+ case "untracked-and-should-track":
543
+ return "Press f to add it to git.";
544
+ case "missing-from-gitignore":
545
+ return "Press f to add it to .gitignore (F to fix all).";
546
+ }
530
547
  }
531
548
 
532
549
  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
- );
550
+ if (warnings.length === 0) return null;
551
+ return (
552
+ <box flexDirection="column" marginTop={1}>
553
+ <text fg={theme.colors.warning}>Warnings:</text>
554
+ {warnings.map((w, i) => (
555
+ <text fg={theme.colors.muted} key={i}>
556
+ {w}
557
+ </text>
558
+ ))}
559
+ </box>
560
+ );
544
561
  }