@ramarivera/coding-buddy 0.4.0-alpha.7 → 0.4.0-alpha.9
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.
- package/README.md +18 -39
- package/adapters/claude/hooks/buddy-comment.sh +4 -1
- package/adapters/claude/hooks/name-react.sh +4 -1
- package/adapters/claude/hooks/react.sh +4 -1
- package/adapters/claude/install/backup.ts +36 -118
- package/adapters/claude/install/disable.ts +9 -14
- package/adapters/claude/install/doctor.ts +26 -87
- package/adapters/claude/install/install.ts +39 -66
- package/adapters/claude/install/test-statusline.ts +8 -18
- package/adapters/claude/install/uninstall.ts +18 -26
- package/adapters/claude/plugin/marketplace.json +4 -4
- package/adapters/claude/plugin/plugin.json +3 -5
- package/adapters/claude/server/index.ts +132 -5
- package/adapters/claude/server/path.ts +12 -0
- package/adapters/claude/skills/buddy/SKILL.md +16 -1
- package/adapters/claude/statusline/buddy-status.sh +22 -3
- package/adapters/claude/storage/paths.ts +9 -0
- package/adapters/claude/storage/settings.ts +53 -3
- package/adapters/claude/storage/state.ts +22 -4
- package/adapters/pi/README.md +19 -0
- package/adapters/pi/events.ts +176 -19
- package/adapters/pi/index.ts +3 -1
- package/adapters/pi/logger.ts +52 -0
- package/adapters/pi/prompt.ts +18 -0
- package/adapters/pi/storage.ts +1 -0
- package/cli/biomes.ts +309 -0
- package/cli/buddy-shell.ts +818 -0
- package/cli/index.ts +7 -0
- package/cli/tui.tsx +2244 -0
- package/cli/upgrade.ts +213 -0
- package/core/model.ts +6 -0
- package/package.json +78 -62
- package/scripts/paths.sh +40 -0
- package/server/achievements.ts +15 -0
- package/server/art.ts +1 -0
- package/server/engine.ts +1 -0
- package/server/mcp-launcher.sh +16 -0
- package/server/path.ts +30 -0
- package/server/reactions.ts +1 -0
- package/server/state.ts +3 -0
- package/adapters/claude/popup/buddy-popup.sh +0 -92
- package/adapters/claude/popup/buddy-render.sh +0 -540
- package/adapters/claude/popup/popup-manager.sh +0 -355
package/cli/tui.tsx
ADDED
|
@@ -0,0 +1,2244 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* cli/tui.tsx — fullscreen 3-pane dashboard for claude-buddy (Ink/React)
|
|
4
|
+
*
|
|
5
|
+
* Layout: persistent sidebar | content list | detail preview
|
|
6
|
+
* The sidebar is always visible. Selecting a section opens the
|
|
7
|
+
* middle + right panes for that section's content.
|
|
8
|
+
*
|
|
9
|
+
* Usage: bun run tui
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React, { useState } from "react";
|
|
13
|
+
import { render, Box, Text, useInput, useApp, useStdout } from "ink";
|
|
14
|
+
import {
|
|
15
|
+
readFileSync, existsSync, readdirSync, statSync,
|
|
16
|
+
mkdirSync, writeFileSync, copyFileSync, rmSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { execSync } from "node:child_process";
|
|
19
|
+
import { join, resolve, dirname } from "node:path";
|
|
20
|
+
import {
|
|
21
|
+
buddyStateDir,
|
|
22
|
+
claudeConfigDir,
|
|
23
|
+
claudeSettingsPath,
|
|
24
|
+
claudeSkillDir,
|
|
25
|
+
claudeUserConfigPath,
|
|
26
|
+
} from "../server/path.ts";
|
|
27
|
+
import {
|
|
28
|
+
listCompanionSlots, loadActiveSlot, saveActiveSlot,
|
|
29
|
+
loadConfig, saveConfig, writeStatusState, loadReaction,
|
|
30
|
+
resolveUserId, saveCompanionSlot, updateCompanionSlot, slugify, unusedName,
|
|
31
|
+
setBuddyStatusLine, unsetBuddyStatusLine,
|
|
32
|
+
type BuddyConfig,
|
|
33
|
+
} from "../server/state.ts";
|
|
34
|
+
import {
|
|
35
|
+
RARITY_STARS, STAT_NAMES, SPECIES, RARITIES, generateBones, searchBuddy,
|
|
36
|
+
type Companion, type StatName, type Species, type Rarity,
|
|
37
|
+
type SearchCriteria, type SearchResult, type BuddyBones,
|
|
38
|
+
} from "../server/engine.ts";
|
|
39
|
+
import { getArtFrame, HAT_ART } from "../server/art.ts";
|
|
40
|
+
import {
|
|
41
|
+
ACHIEVEMENTS, loadUnlocked, loadEvents,
|
|
42
|
+
type Achievement, type UnlockedAchievement, type EventCounters,
|
|
43
|
+
} from "../server/achievements.ts";
|
|
44
|
+
|
|
45
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
type Section = "menagerie" | "settings" | "achievements" | "hunt" | "verify" | "doctor" | "backup" | "system";
|
|
48
|
+
type Focus = "sidebar" | "list" | "edit";
|
|
49
|
+
interface SlotEntry { slot: string; companion: Companion }
|
|
50
|
+
|
|
51
|
+
const RARITY_COLOR: Record<string, string> = {
|
|
52
|
+
common: "gray", uncommon: "green", rare: "blue",
|
|
53
|
+
epic: "magenta", legendary: "yellow",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
const CLAUDE_DIR = claudeConfigDir();
|
|
58
|
+
const CLAUDE_JSON = claudeUserConfigPath();
|
|
59
|
+
const STATE_DIR = buddyStateDir();
|
|
60
|
+
const SETTINGS_PATH = claudeSettingsPath();
|
|
61
|
+
const SKILL_PATH = join(claudeSkillDir("buddy"), "SKILL.md");
|
|
62
|
+
const PROJECT_ROOT = resolve(dirname(import.meta.dir));
|
|
63
|
+
|
|
64
|
+
// ─── Sidebar ────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
interface SidebarItem {
|
|
67
|
+
key: Section; icon: string; label: string; description: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const SIDEBAR_ITEMS: SidebarItem[] = [
|
|
71
|
+
{
|
|
72
|
+
key: "menagerie", icon: "🏠", label: "Pets",
|
|
73
|
+
description: [
|
|
74
|
+
"Browse and manage all your saved buddies.",
|
|
75
|
+
"",
|
|
76
|
+
"Filter by typing, navigate with arrows,",
|
|
77
|
+
"press Enter to summon a buddy as active.",
|
|
78
|
+
"",
|
|
79
|
+
"Use the \"Edit Personality\" button at the",
|
|
80
|
+
"bottom to rewrite a buddy's voice.",
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: "settings", icon: "🔧", label: "Config",
|
|
85
|
+
description: [
|
|
86
|
+
"Configure the status line behaviour:",
|
|
87
|
+
"comment cooldown, reaction TTL, bubble",
|
|
88
|
+
"style/position, and rarity visibility.",
|
|
89
|
+
"",
|
|
90
|
+
"Changes are written to config.json and",
|
|
91
|
+
"take effect after restarting Claude Code.",
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
key: "achievements", icon: "🏆", label: "Achievements",
|
|
96
|
+
description: [
|
|
97
|
+
"View all 16 milestone badges you can",
|
|
98
|
+
"unlock with your buddy — pets, coding",
|
|
99
|
+
"streaks, errors witnessed, and more.",
|
|
100
|
+
"",
|
|
101
|
+
"Locked badges show a progress bar;",
|
|
102
|
+
"3 secret ones stay hidden until earned.",
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
key: "hunt", icon: "🎯", label: "Hunt",
|
|
107
|
+
description: [
|
|
108
|
+
"Brute-force search for a specific buddy.",
|
|
109
|
+
"",
|
|
110
|
+
"Choose species, rarity, shiny flag, peak",
|
|
111
|
+
"and dump stats — then start hunting.",
|
|
112
|
+
"",
|
|
113
|
+
"Legendary + shiny may take many minutes.",
|
|
114
|
+
"Pick from results, name and save.",
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
key: "verify", icon: "🔍", label: "Verify",
|
|
119
|
+
description: [
|
|
120
|
+
"Show the deterministic buddy generated",
|
|
121
|
+
"from any user ID — useful for debugging",
|
|
122
|
+
"or exploring what IDs produce.",
|
|
123
|
+
"",
|
|
124
|
+
"Random / Current / Custom hex input.",
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
key: "doctor", icon: "🩺", label: "Doctor",
|
|
129
|
+
description: [
|
|
130
|
+
"Run diagnostic checks on your install:",
|
|
131
|
+
"environment (bun, jq, claude CLI),",
|
|
132
|
+
"filesystem paths, MCP registration,",
|
|
133
|
+
"hooks, status line, and buddy state.",
|
|
134
|
+
"",
|
|
135
|
+
"Green = OK, yellow = warn, red = error.",
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
key: "backup", icon: "💾", label: "Backup",
|
|
140
|
+
description: [
|
|
141
|
+
"Create and browse snapshots of your",
|
|
142
|
+
"claude-buddy state — settings, hooks,",
|
|
143
|
+
"skill, menagerie, status, and config.",
|
|
144
|
+
"",
|
|
145
|
+
"Restore is currently manual (copy from",
|
|
146
|
+
`${STATE_DIR}/backups/<ts>/ folders).`,
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
key: "system", icon: "🚨", label: "System",
|
|
151
|
+
description: [
|
|
152
|
+
"Manage claude-buddy's installation:",
|
|
153
|
+
"",
|
|
154
|
+
"• Re-Enable — runs install-buddy",
|
|
155
|
+
"• Disable — keeps data, removes MCP",
|
|
156
|
+
"• Uninstall — destructive, requires",
|
|
157
|
+
" typing UNINSTALL to confirm",
|
|
158
|
+
"",
|
|
159
|
+
"Auto-backup runs before any uninstall.",
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
function Sidebar({ cursor, section, focus }: {
|
|
165
|
+
cursor: number; section: Section; focus: Focus;
|
|
166
|
+
}) {
|
|
167
|
+
const isFocused = focus === "sidebar";
|
|
168
|
+
return (
|
|
169
|
+
<Box flexDirection="column" paddingX={1}>
|
|
170
|
+
<Text bold color="cyan">{" 🐢 claude-buddy"}</Text>
|
|
171
|
+
<Text>{""}</Text>
|
|
172
|
+
{SIDEBAR_ITEMS.map((item, i) => {
|
|
173
|
+
const isActive = item.key === section && focus !== "sidebar";
|
|
174
|
+
const isCursor = isFocused && i === cursor;
|
|
175
|
+
const borderColor = isCursor ? "cyan" : isActive ? "green" : "gray";
|
|
176
|
+
const borderStyle = isCursor || isActive ? "round" : "single";
|
|
177
|
+
return (
|
|
178
|
+
<Box key={item.key}
|
|
179
|
+
borderStyle={borderStyle as any}
|
|
180
|
+
borderColor={borderColor}
|
|
181
|
+
paddingX={1}
|
|
182
|
+
marginBottom={0}
|
|
183
|
+
>
|
|
184
|
+
<Text bold={isCursor || isActive} color={isCursor ? "cyan" : isActive ? "green" : "white"}>
|
|
185
|
+
{item.icon} {item.label}
|
|
186
|
+
</Text>
|
|
187
|
+
</Box>
|
|
188
|
+
);
|
|
189
|
+
})}
|
|
190
|
+
<Text>{""}</Text>
|
|
191
|
+
<Box
|
|
192
|
+
borderStyle={isFocused && cursor >= SIDEBAR_ITEMS.length ? "round" as any : "single" as any}
|
|
193
|
+
borderColor={isFocused && cursor >= SIDEBAR_ITEMS.length ? "red" : "gray"}
|
|
194
|
+
paddingX={1}
|
|
195
|
+
>
|
|
196
|
+
<Text color={isFocused && cursor >= SIDEBAR_ITEMS.length ? "red" : "gray"}>
|
|
197
|
+
👋 Exit
|
|
198
|
+
</Text>
|
|
199
|
+
</Box>
|
|
200
|
+
</Box>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Middle: Buddy List ─────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
function BuddyListPane({ slots, cursor, activeSlot, focused, searchTerm }: {
|
|
207
|
+
slots: SlotEntry[]; cursor: number; activeSlot: string; focused: boolean;
|
|
208
|
+
searchTerm: string;
|
|
209
|
+
}) {
|
|
210
|
+
const editIdx = slots.length;
|
|
211
|
+
const isEditCursor = focused && cursor === editIdx;
|
|
212
|
+
return (
|
|
213
|
+
<Box flexDirection="column" paddingX={1}>
|
|
214
|
+
<Text bold color={focused ? "cyan" : "gray"}>{" 🏠 Menagerie"}</Text>
|
|
215
|
+
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
|
216
|
+
<Text dimColor>🔎 </Text>
|
|
217
|
+
{searchTerm ? (
|
|
218
|
+
<Text bold color="yellow">{searchTerm}</Text>
|
|
219
|
+
) : (
|
|
220
|
+
<Text dimColor>filter by name, species, rarity…</Text>
|
|
221
|
+
)}
|
|
222
|
+
</Box>
|
|
223
|
+
<Text>{""}</Text>
|
|
224
|
+
{slots.length === 0 ? (
|
|
225
|
+
<Text dimColor>{" "}No buddies match.</Text>
|
|
226
|
+
) : (
|
|
227
|
+
slots.map(({ slot, companion: c }, i) => {
|
|
228
|
+
const isActive = slot === activeSlot;
|
|
229
|
+
const color = RARITY_COLOR[c.bones.rarity] ?? "white";
|
|
230
|
+
const stars = RARITY_STARS[c.bones.rarity];
|
|
231
|
+
const shiny = c.bones.shiny ? "✨" : "";
|
|
232
|
+
const isCursor = focused && i === cursor;
|
|
233
|
+
return (
|
|
234
|
+
<Box key={slot}
|
|
235
|
+
borderStyle={isCursor ? "round" as any : isActive ? "round" as any : "single" as any}
|
|
236
|
+
borderColor={isCursor ? "cyan" : isActive ? "green" : "gray"}
|
|
237
|
+
paddingX={1}
|
|
238
|
+
>
|
|
239
|
+
<Text color={isActive ? "green" : "gray"}>{isActive ? "● " : "○ "}</Text>
|
|
240
|
+
<Text color={color} bold={isCursor || isActive}>{c.name.padEnd(8)}</Text>
|
|
241
|
+
<Text dimColor>{c.bones.species.padEnd(7)}{stars}{shiny}</Text>
|
|
242
|
+
</Box>
|
|
243
|
+
);
|
|
244
|
+
})
|
|
245
|
+
)}
|
|
246
|
+
{slots.length > 0 ? (
|
|
247
|
+
<>
|
|
248
|
+
<Text>{""}</Text>
|
|
249
|
+
<Box
|
|
250
|
+
borderStyle={isEditCursor ? "round" as any : "single" as any}
|
|
251
|
+
borderColor={isEditCursor ? "magenta" : "gray"}
|
|
252
|
+
paddingX={1}
|
|
253
|
+
>
|
|
254
|
+
<Text bold={isEditCursor} color={isEditCursor ? "magenta" : "gray"}>
|
|
255
|
+
✏ Edit Personality
|
|
256
|
+
</Text>
|
|
257
|
+
</Box>
|
|
258
|
+
</>
|
|
259
|
+
) : null}
|
|
260
|
+
</Box>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Middle: Settings List ──────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
const SETTINGS_ITEMS = [
|
|
267
|
+
{ key: "commentCooldown", label: "Comment Cooldown" },
|
|
268
|
+
{ key: "reactionTTL", label: "Reaction TTL" },
|
|
269
|
+
{ key: "bubbleStyle", label: "Bubble Style" },
|
|
270
|
+
{ key: "bubblePosition", label: "Bubble Position" },
|
|
271
|
+
{ key: "showRarity", label: "Show Rarity" },
|
|
272
|
+
{ key: "statusLineEnabled", label: "Status Line" },
|
|
273
|
+
] as const;
|
|
274
|
+
|
|
275
|
+
function SettingsListPane({ cursor, config, focused }: {
|
|
276
|
+
cursor: number; config: BuddyConfig; focused: boolean;
|
|
277
|
+
}) {
|
|
278
|
+
return (
|
|
279
|
+
<Box flexDirection="column" paddingX={1}>
|
|
280
|
+
<Text bold color={focused ? "cyan" : "gray"}>{" 🔧 Settings"}</Text>
|
|
281
|
+
<Text>{""}</Text>
|
|
282
|
+
{SETTINGS_ITEMS.map((item, i) => {
|
|
283
|
+
const val = String(config[item.key as keyof BuddyConfig]);
|
|
284
|
+
const isCursor = focused && i === cursor;
|
|
285
|
+
return (
|
|
286
|
+
<Box key={item.key}
|
|
287
|
+
borderStyle={isCursor ? "round" as any : "single" as any}
|
|
288
|
+
borderColor={isCursor ? "cyan" : "gray"}
|
|
289
|
+
paddingX={1}
|
|
290
|
+
>
|
|
291
|
+
<Text bold={isCursor} color={isCursor ? "cyan" : "white"}>{item.label.padEnd(16)}</Text>
|
|
292
|
+
<Text color="yellow">{val}</Text>
|
|
293
|
+
</Box>
|
|
294
|
+
);
|
|
295
|
+
})}
|
|
296
|
+
</Box>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ─── Achievements: progress map + panes ─────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
// Maps each achievement id to the counter key + threshold it tracks.
|
|
303
|
+
// Kept here (not in achievements.ts) so the UI can render progress bars
|
|
304
|
+
// without baking threshold data into the achievement check functions.
|
|
305
|
+
const ACHIEVEMENT_PROGRESS: Record<string, { counter: keyof EventCounters; threshold: number } | null> = {
|
|
306
|
+
first_steps: null,
|
|
307
|
+
good_boy: { counter: "pets", threshold: 10 },
|
|
308
|
+
best_friend: { counter: "pets", threshold: 50 },
|
|
309
|
+
bug_spotter: { counter: "errors_seen", threshold: 1 },
|
|
310
|
+
error_whisperer: { counter: "errors_seen", threshold: 25 },
|
|
311
|
+
battle_scarred: { counter: "errors_seen", threshold: 100 },
|
|
312
|
+
test_witness: { counter: "tests_failed", threshold: 1 },
|
|
313
|
+
test_veteran: { counter: "tests_failed", threshold: 50 },
|
|
314
|
+
big_mover: { counter: "large_diffs", threshold: 1 },
|
|
315
|
+
refactor_machine: { counter: "large_diffs", threshold: 10 },
|
|
316
|
+
chatterbox: { counter: "reactions_given", threshold: 100 },
|
|
317
|
+
week_streak: { counter: "days_active", threshold: 7 },
|
|
318
|
+
month_streak: { counter: "days_active", threshold: 30 },
|
|
319
|
+
power_user: { counter: "commands_run", threshold: 50 },
|
|
320
|
+
dedicated: { counter: "turns", threshold: 200 },
|
|
321
|
+
thousand_turns: { counter: "turns", threshold: 1000 },
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
function AchievementsListPane({ cursor, unlockedIds, focused }: {
|
|
325
|
+
cursor: number; unlockedIds: Set<string>; focused: boolean;
|
|
326
|
+
}) {
|
|
327
|
+
const total = ACHIEVEMENTS.length;
|
|
328
|
+
const done = ACHIEVEMENTS.filter(a => unlockedIds.has(a.id)).length;
|
|
329
|
+
return (
|
|
330
|
+
<Box flexDirection="column" paddingX={1}>
|
|
331
|
+
<Text bold color={focused ? "cyan" : "gray"}>{" 🏆 Achievements"}</Text>
|
|
332
|
+
<Text dimColor>{" "}{done}/{total} unlocked</Text>
|
|
333
|
+
<Text>{""}</Text>
|
|
334
|
+
{ACHIEVEMENTS.map((a, i) => {
|
|
335
|
+
const isUnlocked = unlockedIds.has(a.id);
|
|
336
|
+
const isHidden = a.secret && !isUnlocked;
|
|
337
|
+
const isCursor = focused && i === cursor;
|
|
338
|
+
const name = isHidden ? "???" : a.name;
|
|
339
|
+
const icon = isHidden ? "🔒" : a.icon;
|
|
340
|
+
return (
|
|
341
|
+
<Box key={a.id}
|
|
342
|
+
borderStyle={isCursor ? "round" as any : "single" as any}
|
|
343
|
+
borderColor={isCursor ? "cyan" : isUnlocked ? "yellow" : "gray"}
|
|
344
|
+
paddingX={1}
|
|
345
|
+
>
|
|
346
|
+
<Text bold={isCursor} color={isUnlocked ? "yellow" : isHidden ? "gray" : "white"}>
|
|
347
|
+
{icon} {name.padEnd(18)}
|
|
348
|
+
</Text>
|
|
349
|
+
<Text color={isUnlocked ? "green" : "gray"}>{isUnlocked ? "✓" : "·"}</Text>
|
|
350
|
+
</Box>
|
|
351
|
+
);
|
|
352
|
+
})}
|
|
353
|
+
</Box>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function AchievementDetailPane({ achievement, unlockedIds, unlocked, events }: {
|
|
358
|
+
achievement: Achievement;
|
|
359
|
+
unlockedIds: Set<string>;
|
|
360
|
+
unlocked: UnlockedAchievement[];
|
|
361
|
+
events: EventCounters;
|
|
362
|
+
}) {
|
|
363
|
+
const isUnlocked = unlockedIds.has(achievement.id);
|
|
364
|
+
const isHidden = achievement.secret && !isUnlocked;
|
|
365
|
+
|
|
366
|
+
if (isHidden) {
|
|
367
|
+
return (
|
|
368
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
369
|
+
<Text>{""}</Text>
|
|
370
|
+
<Text bold color="gray">🔒 ??? (Secret)</Text>
|
|
371
|
+
<Text>{""}</Text>
|
|
372
|
+
<Text dimColor>This achievement is hidden.</Text>
|
|
373
|
+
<Text dimColor>Keep coding to discover it.</Text>
|
|
374
|
+
</Box>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const meta = unlocked.find(u => u.id === achievement.id);
|
|
379
|
+
const prog = ACHIEVEMENT_PROGRESS[achievement.id];
|
|
380
|
+
|
|
381
|
+
let bar = "";
|
|
382
|
+
let progressText = "";
|
|
383
|
+
if (prog && !isUnlocked) {
|
|
384
|
+
const current = events[prog.counter] ?? 0;
|
|
385
|
+
const filled = Math.min(10, Math.floor((current / prog.threshold) * 10));
|
|
386
|
+
bar = "█".repeat(filled) + "░".repeat(10 - filled);
|
|
387
|
+
progressText = `${current} / ${prog.threshold}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return (
|
|
391
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
392
|
+
<Text>{""}</Text>
|
|
393
|
+
<Text bold color={isUnlocked ? "yellow" : "cyan"}>
|
|
394
|
+
{achievement.icon} {achievement.name}
|
|
395
|
+
</Text>
|
|
396
|
+
{achievement.secret ? <Text dimColor>(Secret)</Text> : null}
|
|
397
|
+
<Text>{""}</Text>
|
|
398
|
+
<Text dimColor>{achievement.description}</Text>
|
|
399
|
+
<Text>{""}</Text>
|
|
400
|
+
<Text dimColor>{"─".repeat(28)}</Text>
|
|
401
|
+
<Text>{""}</Text>
|
|
402
|
+
{isUnlocked ? (
|
|
403
|
+
<Box flexDirection="column">
|
|
404
|
+
<Text color="green" bold>✓ Unlocked</Text>
|
|
405
|
+
{meta ? <Text dimColor>on {new Date(meta.unlockedAt).toLocaleDateString()}</Text> : null}
|
|
406
|
+
{meta?.slot ? <Text dimColor>by buddy: {meta.slot}</Text> : null}
|
|
407
|
+
</Box>
|
|
408
|
+
) : prog ? (
|
|
409
|
+
<Box flexDirection="column">
|
|
410
|
+
<Text dimColor>Progress:</Text>
|
|
411
|
+
<Box>
|
|
412
|
+
<Text color="yellow">{bar}</Text>
|
|
413
|
+
<Text>{" "}</Text>
|
|
414
|
+
<Text bold>{progressText}</Text>
|
|
415
|
+
</Box>
|
|
416
|
+
</Box>
|
|
417
|
+
) : (
|
|
418
|
+
<Text dimColor>Locked</Text>
|
|
419
|
+
)}
|
|
420
|
+
</Box>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ─── Doctor: data collection ────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
interface DiagCheck { label: string; value: string; status: "ok" | "warn" | "err" }
|
|
427
|
+
|
|
428
|
+
interface DiagCategory { name: string; icon: string; checks: DiagCheck[] }
|
|
429
|
+
|
|
430
|
+
function tryExec(cmd: string, fallback = "(failed)"): string {
|
|
431
|
+
try {
|
|
432
|
+
return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
433
|
+
} catch { return fallback; }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function runDiagnostics(): DiagCategory[] {
|
|
437
|
+
const categories: DiagCategory[] = [];
|
|
438
|
+
|
|
439
|
+
// Environment
|
|
440
|
+
const env: DiagCheck[] = [];
|
|
441
|
+
const bunVer = tryExec("bun --version");
|
|
442
|
+
env.push({ label: "Bun", value: bunVer, status: bunVer === "(failed)" ? "err" : "ok" });
|
|
443
|
+
const jqVer = tryExec("jq --version", "(not installed)");
|
|
444
|
+
env.push({ label: "jq", value: jqVer, status: jqVer === "(not installed)" ? "warn" : "ok" });
|
|
445
|
+
const claudeVer = tryExec("claude --version", "(not in PATH)");
|
|
446
|
+
env.push({ label: "Claude Code", value: claudeVer, status: claudeVer === "(not in PATH)" ? "warn" : "ok" });
|
|
447
|
+
env.push({ label: "OS", value: tryExec("uname -srm"), status: "ok" });
|
|
448
|
+
env.push({ label: "Shell", value: process.env.SHELL ?? "(unset)", status: "ok" });
|
|
449
|
+
categories.push({ name: "Environment", icon: "💻", checks: env });
|
|
450
|
+
|
|
451
|
+
// Filesystem
|
|
452
|
+
const fs: DiagCheck[] = [];
|
|
453
|
+
const dirs: [string, string][] = [
|
|
454
|
+
[CLAUDE_DIR, CLAUDE_DIR],
|
|
455
|
+
[CLAUDE_JSON, CLAUDE_JSON],
|
|
456
|
+
[STATE_DIR, STATE_DIR],
|
|
457
|
+
["Status script", join(PROJECT_ROOT, "statusline", "buddy-status.sh")],
|
|
458
|
+
];
|
|
459
|
+
for (const [label, path] of dirs) {
|
|
460
|
+
const exists = existsSync(path);
|
|
461
|
+
fs.push({ label, value: exists ? "found" : "MISSING", status: exists ? "ok" : "err" });
|
|
462
|
+
}
|
|
463
|
+
categories.push({ name: "Filesystem", icon: "📁", checks: fs });
|
|
464
|
+
|
|
465
|
+
// MCP & Hooks
|
|
466
|
+
const mcp: DiagCheck[] = [];
|
|
467
|
+
try {
|
|
468
|
+
const claudeJson = JSON.parse(readFileSync(CLAUDE_JSON, "utf8"));
|
|
469
|
+
const registered = !!claudeJson?.mcpServers?.["claude-buddy"];
|
|
470
|
+
mcp.push({ label: "MCP server", value: registered ? "registered" : "NOT registered", status: registered ? "ok" : "err" });
|
|
471
|
+
} catch {
|
|
472
|
+
mcp.push({ label: "MCP server", value: "cannot read config", status: "err" });
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
const settings = JSON.parse(readFileSync(SETTINGS_PATH, "utf8"));
|
|
476
|
+
const hookCount = Object.keys(settings.hooks ?? {}).reduce((n: number, k: string) => n + (settings.hooks[k]?.length ?? 0), 0);
|
|
477
|
+
mcp.push({ label: "Hooks", value: `${hookCount} entries`, status: hookCount > 0 ? "ok" : "warn" });
|
|
478
|
+
mcp.push({ label: "Status line", value: settings.statusLine ? "configured" : "not set", status: settings.statusLine ? "ok" : "warn" });
|
|
479
|
+
} catch {
|
|
480
|
+
mcp.push({ label: "Settings", value: "cannot read", status: "err" });
|
|
481
|
+
}
|
|
482
|
+
const skillPath = SKILL_PATH;
|
|
483
|
+
mcp.push({ label: "Skill", value: existsSync(skillPath) ? "installed" : "MISSING", status: existsSync(skillPath) ? "ok" : "err" });
|
|
484
|
+
categories.push({ name: "Integration", icon: "🔌", checks: mcp });
|
|
485
|
+
|
|
486
|
+
// Buddy state
|
|
487
|
+
const state: DiagCheck[] = [];
|
|
488
|
+
try {
|
|
489
|
+
const menagerie = JSON.parse(readFileSync(join(STATE_DIR, "menagerie.json"), "utf8"));
|
|
490
|
+
const slots = Object.keys(menagerie.companions ?? {});
|
|
491
|
+
state.push({ label: "Menagerie", value: `${slots.length} buddy(s)`, status: slots.length > 0 ? "ok" : "warn" });
|
|
492
|
+
state.push({ label: "Active slot", value: menagerie.active ?? "(none)", status: menagerie.active ? "ok" : "warn" });
|
|
493
|
+
const active = menagerie.companions?.[menagerie.active];
|
|
494
|
+
if (active) {
|
|
495
|
+
state.push({ label: "Active buddy", value: `${active.name} (${active.bones?.rarity} ${active.bones?.species})`, status: "ok" });
|
|
496
|
+
}
|
|
497
|
+
} catch {
|
|
498
|
+
state.push({ label: "Menagerie", value: "not found", status: "warn" });
|
|
499
|
+
}
|
|
500
|
+
const statusJson = join(STATE_DIR, "status.json");
|
|
501
|
+
if (existsSync(statusJson)) {
|
|
502
|
+
try {
|
|
503
|
+
const s = JSON.parse(readFileSync(statusJson, "utf8"));
|
|
504
|
+
state.push({ label: "Status muted", value: String(s.muted ?? false), status: "ok" });
|
|
505
|
+
state.push({ label: "Last reaction", value: s.reaction || "(none)", status: "ok" });
|
|
506
|
+
} catch {
|
|
507
|
+
state.push({ label: "Status", value: "corrupt", status: "err" });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
categories.push({ name: "Buddy State", icon: "🐢", checks: state });
|
|
511
|
+
|
|
512
|
+
return categories;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ─── Middle: Doctor Categories ──────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
function DoctorListPane({ categories, cursor, focused }: {
|
|
518
|
+
categories: DiagCategory[]; cursor: number; focused: boolean;
|
|
519
|
+
}) {
|
|
520
|
+
return (
|
|
521
|
+
<Box flexDirection="column" paddingX={1}>
|
|
522
|
+
<Text bold color={focused ? "cyan" : "gray"}>{" 🩺 Doctor"}</Text>
|
|
523
|
+
<Text>{""}</Text>
|
|
524
|
+
{categories.map((cat, i) => {
|
|
525
|
+
const oks = cat.checks.filter(c => c.status === "ok").length;
|
|
526
|
+
const total = cat.checks.length;
|
|
527
|
+
const allOk = oks === total;
|
|
528
|
+
const isCursor = focused && i === cursor;
|
|
529
|
+
return (
|
|
530
|
+
<Box key={cat.name}
|
|
531
|
+
borderStyle={isCursor ? "round" as any : "single" as any}
|
|
532
|
+
borderColor={isCursor ? "cyan" : allOk ? "green" : "yellow"}
|
|
533
|
+
paddingX={1}
|
|
534
|
+
>
|
|
535
|
+
<Text bold={isCursor} color={isCursor ? "cyan" : "white"}>
|
|
536
|
+
{cat.icon} {cat.name.padEnd(14)}
|
|
537
|
+
</Text>
|
|
538
|
+
<Text color={allOk ? "green" : "yellow"}>{oks}/{total} ✓</Text>
|
|
539
|
+
</Box>
|
|
540
|
+
);
|
|
541
|
+
})}
|
|
542
|
+
</Box>
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ─── Right: Doctor Detail ───────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
function DoctorDetailPane({ category }: { category: DiagCategory }) {
|
|
549
|
+
const statusIcon = (s: string) => s === "ok" ? "✓" : s === "warn" ? "⚠" : "✗";
|
|
550
|
+
const statusColor = (s: string) => s === "ok" ? "green" : s === "warn" ? "yellow" : "red";
|
|
551
|
+
|
|
552
|
+
return (
|
|
553
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
554
|
+
<Text>{""}</Text>
|
|
555
|
+
<Text bold color="cyan">{category.icon} {category.name}</Text>
|
|
556
|
+
<Text>{""}</Text>
|
|
557
|
+
{category.checks.map((check, i) => (
|
|
558
|
+
<Box key={i}>
|
|
559
|
+
<Text color={statusColor(check.status)}>{" "}{statusIcon(check.status)} </Text>
|
|
560
|
+
<Text dimColor>{check.label.padEnd(18)}</Text>
|
|
561
|
+
<Text>{check.value}</Text>
|
|
562
|
+
</Box>
|
|
563
|
+
))}
|
|
564
|
+
</Box>
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ─── Backup: data ───────────────────────────────────────────────────────────
|
|
569
|
+
|
|
570
|
+
const BACKUPS_DIR = join(STATE_DIR, "backups");
|
|
571
|
+
|
|
572
|
+
interface BackupEntry { ts: string; fileCount: number }
|
|
573
|
+
|
|
574
|
+
function getBackups(): BackupEntry[] {
|
|
575
|
+
if (!existsSync(BACKUPS_DIR)) return [];
|
|
576
|
+
return readdirSync(BACKUPS_DIR)
|
|
577
|
+
.filter(f => /^\d{4}-\d{2}-\d{2}-\d{6}$/.test(f))
|
|
578
|
+
.filter(f => statSync(join(BACKUPS_DIR, f)).isDirectory())
|
|
579
|
+
.sort()
|
|
580
|
+
.reverse()
|
|
581
|
+
.map(ts => {
|
|
582
|
+
let fileCount = 0;
|
|
583
|
+
try {
|
|
584
|
+
const m = JSON.parse(readFileSync(join(BACKUPS_DIR, ts, "manifest.json"), "utf8"));
|
|
585
|
+
fileCount = m.files?.length ?? 0;
|
|
586
|
+
} catch {}
|
|
587
|
+
return { ts, fileCount };
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function createBackup(): string {
|
|
592
|
+
const d = new Date();
|
|
593
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
594
|
+
const ts = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
595
|
+
const dir = join(BACKUPS_DIR, ts);
|
|
596
|
+
mkdirSync(dir, { recursive: true });
|
|
597
|
+
|
|
598
|
+
const manifest: { timestamp: string; files: string[] } = { timestamp: ts, files: [] };
|
|
599
|
+
const tryRead = (p: string) => { try { return readFileSync(p, "utf8"); } catch { return null; } };
|
|
600
|
+
|
|
601
|
+
const settingsPath = SETTINGS_PATH;
|
|
602
|
+
if (existsSync(settingsPath)) { writeFileSync(join(dir, "settings.json"), readFileSync(settingsPath)); manifest.files.push("settings.json"); }
|
|
603
|
+
|
|
604
|
+
const claudeJsonRaw = tryRead(CLAUDE_JSON);
|
|
605
|
+
if (claudeJsonRaw) {
|
|
606
|
+
try {
|
|
607
|
+
const mcp = JSON.parse(claudeJsonRaw).mcpServers?.["claude-buddy"];
|
|
608
|
+
if (mcp) { writeFileSync(join(dir, "mcpserver.json"), JSON.stringify(mcp, null, 2)); manifest.files.push("mcpserver.json"); }
|
|
609
|
+
} catch {}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const skillPath = SKILL_PATH;
|
|
613
|
+
if (existsSync(skillPath)) { copyFileSync(skillPath, join(dir, "SKILL.md")); manifest.files.push("SKILL.md"); }
|
|
614
|
+
|
|
615
|
+
const stateDir = join(dir, "claude-buddy");
|
|
616
|
+
mkdirSync(stateDir, { recursive: true });
|
|
617
|
+
for (const f of ["menagerie.json", "status.json", "config.json"]) {
|
|
618
|
+
const src = join(STATE_DIR, f);
|
|
619
|
+
if (existsSync(src)) { copyFileSync(src, join(stateDir, f)); manifest.files.push(`claude-buddy/${f}`); }
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
623
|
+
return ts;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function deleteBackup(ts: string): boolean {
|
|
627
|
+
const dir = join(BACKUPS_DIR, ts);
|
|
628
|
+
if (!existsSync(dir)) return false;
|
|
629
|
+
rmSync(dir, { recursive: true });
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ─── Middle: Backup List ────────────────────────────────────────────────────
|
|
634
|
+
|
|
635
|
+
const BACKUP_ACTIONS = [
|
|
636
|
+
{ key: "create", icon: "➕", label: "Create new backup" },
|
|
637
|
+
] as const;
|
|
638
|
+
|
|
639
|
+
function BackupListPane({ backups, cursor, focused }: {
|
|
640
|
+
backups: BackupEntry[]; cursor: number; focused: boolean;
|
|
641
|
+
}) {
|
|
642
|
+
return (
|
|
643
|
+
<Box flexDirection="column" paddingX={1}>
|
|
644
|
+
<Text bold color={focused ? "cyan" : "gray"}>{" 💾 Backup"}</Text>
|
|
645
|
+
<Text>{""}</Text>
|
|
646
|
+
{BACKUP_ACTIONS.map((a, i) => {
|
|
647
|
+
const isCursor = focused && i === cursor;
|
|
648
|
+
return (
|
|
649
|
+
<Box key={a.key}
|
|
650
|
+
borderStyle={isCursor ? "round" as any : "single" as any}
|
|
651
|
+
borderColor={isCursor ? "cyan" : "gray"}
|
|
652
|
+
paddingX={1}
|
|
653
|
+
>
|
|
654
|
+
<Text bold={isCursor} color={isCursor ? "cyan" : "white"}>
|
|
655
|
+
{a.icon} {a.label}
|
|
656
|
+
</Text>
|
|
657
|
+
</Box>
|
|
658
|
+
);
|
|
659
|
+
})}
|
|
660
|
+
<Text>{""}</Text>
|
|
661
|
+
<Text dimColor>{" "}Snapshots:</Text>
|
|
662
|
+
<Text>{""}</Text>
|
|
663
|
+
{backups.length === 0 ? (
|
|
664
|
+
<Text dimColor>{" "}No backups yet.</Text>
|
|
665
|
+
) : (
|
|
666
|
+
backups.map((b, bi) => {
|
|
667
|
+
const idx = bi + BACKUP_ACTIONS.length;
|
|
668
|
+
const isCursor = focused && cursor === idx;
|
|
669
|
+
return (
|
|
670
|
+
<Box key={b.ts}
|
|
671
|
+
borderStyle={isCursor ? "round" as any : "single" as any}
|
|
672
|
+
borderColor={isCursor ? "cyan" : bi === 0 ? "green" : "gray"}
|
|
673
|
+
paddingX={1}
|
|
674
|
+
>
|
|
675
|
+
<Text bold={isCursor} color={isCursor ? "cyan" : "white"}>{b.ts}</Text>
|
|
676
|
+
<Text dimColor>{" "}{b.fileCount} files</Text>
|
|
677
|
+
{bi === 0 ? <Text color="green">{" latest"}</Text> : null}
|
|
678
|
+
</Box>
|
|
679
|
+
);
|
|
680
|
+
})
|
|
681
|
+
)}
|
|
682
|
+
</Box>
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ─── Right: Backup Detail ───────────────────────────────────────────────────
|
|
687
|
+
|
|
688
|
+
function BackupDetailPane({ backups, cursor }: {
|
|
689
|
+
backups: BackupEntry[]; cursor: number;
|
|
690
|
+
}) {
|
|
691
|
+
if (cursor < BACKUP_ACTIONS.length) {
|
|
692
|
+
return (
|
|
693
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
694
|
+
<Text>{""}</Text>
|
|
695
|
+
<Text bold color="cyan">➕ Create Backup</Text>
|
|
696
|
+
<Text>{""}</Text>
|
|
697
|
+
<Text dimColor>Creates a snapshot of all</Text>
|
|
698
|
+
<Text dimColor>claude-buddy state files:</Text>
|
|
699
|
+
<Text>{""}</Text>
|
|
700
|
+
<Text>{" "}• settings.json</Text>
|
|
701
|
+
<Text>{" "}• MCP server config</Text>
|
|
702
|
+
<Text>{" "}• SKILL.md</Text>
|
|
703
|
+
<Text>{" "}• menagerie.json</Text>
|
|
704
|
+
<Text>{" "}• status.json</Text>
|
|
705
|
+
<Text>{" "}• config.json</Text>
|
|
706
|
+
<Text>{""}</Text>
|
|
707
|
+
<Text dimColor>Press enter to create</Text>
|
|
708
|
+
</Box>
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const b = backups[cursor - BACKUP_ACTIONS.length];
|
|
713
|
+
if (!b) return <Text dimColor>{" "}No selection</Text>;
|
|
714
|
+
|
|
715
|
+
let files: string[] = [];
|
|
716
|
+
try {
|
|
717
|
+
const m = JSON.parse(readFileSync(join(BACKUPS_DIR, b.ts, "manifest.json"), "utf8"));
|
|
718
|
+
files = m.files ?? [];
|
|
719
|
+
} catch {}
|
|
720
|
+
|
|
721
|
+
return (
|
|
722
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
723
|
+
<Text>{""}</Text>
|
|
724
|
+
<Text bold color="cyan">📦 {b.ts}</Text>
|
|
725
|
+
<Text>{""}</Text>
|
|
726
|
+
<Text dimColor>Files in this snapshot:</Text>
|
|
727
|
+
<Text>{""}</Text>
|
|
728
|
+
{files.map((f, i) => (
|
|
729
|
+
<Text key={i}>{" "}• {f}</Text>
|
|
730
|
+
))}
|
|
731
|
+
<Text>{""}</Text>
|
|
732
|
+
<Text dimColor>{"─".repeat(28)}</Text>
|
|
733
|
+
<Text>{""}</Text>
|
|
734
|
+
<Text color="red">d = delete this backup</Text>
|
|
735
|
+
</Box>
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ─── System section: install / disable / uninstall ──────────────────────────
|
|
740
|
+
|
|
741
|
+
type SystemAction = "enable" | "disable" | "uninstall";
|
|
742
|
+
|
|
743
|
+
const SYSTEM_ACTIONS: { key: SystemAction; icon: string; label: string; color: string }[] = [
|
|
744
|
+
{ key: "enable", icon: "🔄", label: "Re-Enable Buddy", color: "green" },
|
|
745
|
+
{ key: "disable", icon: "☠ ", label: "Disable Buddy", color: "red" },
|
|
746
|
+
{ key: "uninstall", icon: "💥", label: "Uninstall (delete all)", color: "red" },
|
|
747
|
+
];
|
|
748
|
+
|
|
749
|
+
function SystemListPane({ cursor, focused }: {
|
|
750
|
+
cursor: number; focused: boolean;
|
|
751
|
+
}) {
|
|
752
|
+
return (
|
|
753
|
+
<Box flexDirection="column" paddingX={1}>
|
|
754
|
+
<Text bold color={focused ? "cyan" : "gray"}>{" 🚨 System"}</Text>
|
|
755
|
+
<Text dimColor>{" "}Manage buddy installation</Text>
|
|
756
|
+
<Text>{""}</Text>
|
|
757
|
+
{SYSTEM_ACTIONS.map((a, i) => {
|
|
758
|
+
const isCursor = focused && i === cursor;
|
|
759
|
+
const borderColor = isCursor ? "cyan" : a.key === "enable" ? "green" : "gray";
|
|
760
|
+
return (
|
|
761
|
+
<Box key={a.key}
|
|
762
|
+
borderStyle={isCursor ? "round" as any : "single" as any}
|
|
763
|
+
borderColor={borderColor}
|
|
764
|
+
paddingX={1}
|
|
765
|
+
>
|
|
766
|
+
<Text bold={isCursor} color={isCursor ? "cyan" : a.color}>
|
|
767
|
+
{a.icon} {a.label}
|
|
768
|
+
</Text>
|
|
769
|
+
</Box>
|
|
770
|
+
);
|
|
771
|
+
})}
|
|
772
|
+
</Box>
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
interface InstallResult { ok: string[]; warn: string[]; error?: string }
|
|
777
|
+
|
|
778
|
+
function runInstall(): InstallResult {
|
|
779
|
+
try {
|
|
780
|
+
execSync("bun run install-buddy", {
|
|
781
|
+
cwd: PROJECT_ROOT,
|
|
782
|
+
encoding: "utf8",
|
|
783
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
784
|
+
});
|
|
785
|
+
return { ok: ["Install completed", "Restart Claude Code to apply"], warn: [], error: undefined };
|
|
786
|
+
} catch (e: any) {
|
|
787
|
+
return { ok: [], warn: [], error: e?.message ?? "install failed" };
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function runUninstall(keepState: boolean): InstallResult {
|
|
792
|
+
const ok: string[] = [];
|
|
793
|
+
const warn: string[] = [];
|
|
794
|
+
try {
|
|
795
|
+
execSync("bun run uninstall", {
|
|
796
|
+
cwd: PROJECT_ROOT,
|
|
797
|
+
encoding: "utf8",
|
|
798
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
799
|
+
});
|
|
800
|
+
ok.push("MCP, hooks, skill removed");
|
|
801
|
+
} catch (e: any) {
|
|
802
|
+
warn.push(`uninstall script: ${e?.message ?? "failed"}`);
|
|
803
|
+
}
|
|
804
|
+
if (!keepState) {
|
|
805
|
+
try {
|
|
806
|
+
const stateDir = STATE_DIR;
|
|
807
|
+
if (existsSync(stateDir)) {
|
|
808
|
+
rmSync(stateDir, { recursive: true, force: true });
|
|
809
|
+
ok.push("State directory deleted");
|
|
810
|
+
}
|
|
811
|
+
} catch (e: any) {
|
|
812
|
+
warn.push(`state cleanup: ${e?.message ?? "failed"}`);
|
|
813
|
+
}
|
|
814
|
+
} else {
|
|
815
|
+
ok.push(`State preserved at ${STATE_DIR}`);
|
|
816
|
+
}
|
|
817
|
+
return { ok, warn };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
type UninstallStage = "warning" | "typing" | "done";
|
|
821
|
+
|
|
822
|
+
function EnableDetailPane({ result, running }: {
|
|
823
|
+
result: InstallResult | null; running: boolean;
|
|
824
|
+
}) {
|
|
825
|
+
if (running) {
|
|
826
|
+
return (
|
|
827
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
828
|
+
<Text>{""}</Text>
|
|
829
|
+
<Text bold color="cyan">🔄 Installing…</Text>
|
|
830
|
+
<Text>{""}</Text>
|
|
831
|
+
<Text dimColor>Running: bun run install-buddy</Text>
|
|
832
|
+
<Text dimColor>This may take a few seconds.</Text>
|
|
833
|
+
</Box>
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
if (result) {
|
|
837
|
+
return (
|
|
838
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
839
|
+
<Text>{""}</Text>
|
|
840
|
+
<Text bold color={result.error ? "red" : "green"}>
|
|
841
|
+
{result.error ? "✗ Install failed" : "✓ Install completed"}
|
|
842
|
+
</Text>
|
|
843
|
+
<Text>{""}</Text>
|
|
844
|
+
{result.error ? (
|
|
845
|
+
<Text color="red">{result.error.slice(0, 200)}</Text>
|
|
846
|
+
) : (
|
|
847
|
+
<>
|
|
848
|
+
{result.ok.map((m, i) => <Text key={i} color="green">{" ✓ "}{m}</Text>)}
|
|
849
|
+
{result.warn.map((m, i) => <Text key={i} color="yellow">{" ⚠ "}{m}</Text>)}
|
|
850
|
+
</>
|
|
851
|
+
)}
|
|
852
|
+
<Text>{""}</Text>
|
|
853
|
+
<Text dimColor>Press enter / esc to continue</Text>
|
|
854
|
+
</Box>
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
return (
|
|
858
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
859
|
+
<Text>{""}</Text>
|
|
860
|
+
<Text bold color="green">🔄 Re-Enable claude-buddy</Text>
|
|
861
|
+
<Text>{""}</Text>
|
|
862
|
+
<Text dimColor>This will register:</Text>
|
|
863
|
+
<Text>{" "}• MCP server in {CLAUDE_JSON}</Text>
|
|
864
|
+
<Text>{" "}• Hooks in settings.json</Text>
|
|
865
|
+
<Text>{" "}• Status line</Text>
|
|
866
|
+
<Text>{" "}• Skill files</Text>
|
|
867
|
+
<Text>{""}</Text>
|
|
868
|
+
<Text dimColor>Idempotent — safe to re-run.</Text>
|
|
869
|
+
<Text>{""}</Text>
|
|
870
|
+
<Text dimColor>{"─".repeat(28)}</Text>
|
|
871
|
+
<Text>{""}</Text>
|
|
872
|
+
<Text>Press <Text bold color="green">enter</Text> to install</Text>
|
|
873
|
+
</Box>
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function UninstallDetailPane({ stage, typed, result, keepState }: {
|
|
878
|
+
stage: UninstallStage; typed: string; result: InstallResult | null; keepState: boolean;
|
|
879
|
+
}) {
|
|
880
|
+
if (stage === "done" && result) {
|
|
881
|
+
return (
|
|
882
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
883
|
+
<Text>{""}</Text>
|
|
884
|
+
<Text bold color="green">✓ Uninstalled</Text>
|
|
885
|
+
<Text>{""}</Text>
|
|
886
|
+
{result.ok.map((m, i) => <Text key={i} color="green">{" ✓ "}{m}</Text>)}
|
|
887
|
+
{result.warn.map((m, i) => <Text key={i} color="yellow">{" ⚠ "}{m}</Text>)}
|
|
888
|
+
<Text>{""}</Text>
|
|
889
|
+
<Text dimColor>Press enter / esc to continue</Text>
|
|
890
|
+
</Box>
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
if (stage === "typing") {
|
|
894
|
+
return (
|
|
895
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
896
|
+
<Text>{""}</Text>
|
|
897
|
+
<Text bold color="red">💥 Final confirmation</Text>
|
|
898
|
+
<Text>{""}</Text>
|
|
899
|
+
<Text color="red">Type UNINSTALL to proceed:</Text>
|
|
900
|
+
<Text>{""}</Text>
|
|
901
|
+
<Box borderStyle="round" borderColor="red" paddingX={1}>
|
|
902
|
+
<Text bold color="yellow">{typed || " "}</Text>
|
|
903
|
+
<Text color="yellow">▌</Text>
|
|
904
|
+
</Box>
|
|
905
|
+
<Text>{""}</Text>
|
|
906
|
+
<Text dimColor>Keep companion data: <Text bold color={keepState ? "green" : "red"}>{keepState ? "YES" : "NO (delete all!)"}</Text></Text>
|
|
907
|
+
<Text dimColor>Press <Text bold>k</Text> to toggle keep-state</Text>
|
|
908
|
+
<Text>{""}</Text>
|
|
909
|
+
<Text dimColor>esc to cancel</Text>
|
|
910
|
+
</Box>
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
return (
|
|
914
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
915
|
+
<Text>{""}</Text>
|
|
916
|
+
<Text bold color="red">💥 Uninstall claude-buddy</Text>
|
|
917
|
+
<Text>{""}</Text>
|
|
918
|
+
<Text color="yellow">⚠ This will remove:</Text>
|
|
919
|
+
<Text>{" "}• MCP server registration</Text>
|
|
920
|
+
<Text>{" "}• Hooks & status line</Text>
|
|
921
|
+
<Text>{" "}• Skill files</Text>
|
|
922
|
+
<Text>{" "}• Optional: {STATE_DIR} (all buddies + backups!)</Text>
|
|
923
|
+
<Text>{""}</Text>
|
|
924
|
+
<Text dimColor>An auto-backup will be created before uninstall.</Text>
|
|
925
|
+
<Text>{""}</Text>
|
|
926
|
+
<Text dimColor>{"─".repeat(28)}</Text>
|
|
927
|
+
<Text>{""}</Text>
|
|
928
|
+
<Text>Press <Text bold color="red">enter</Text> to continue</Text>
|
|
929
|
+
<Text dimColor>(you'll be asked to type UNINSTALL)</Text>
|
|
930
|
+
</Box>
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// ─── Disable confirm pane ───────────────────────────────────────────────────
|
|
935
|
+
|
|
936
|
+
function DisableConfirmPane({ result, confirming }: {
|
|
937
|
+
result: DisableResult | null; confirming: boolean;
|
|
938
|
+
}) {
|
|
939
|
+
if (result) {
|
|
940
|
+
return (
|
|
941
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
942
|
+
<Text>{""}</Text>
|
|
943
|
+
<Text bold color="green">✓ Buddy disabled</Text>
|
|
944
|
+
<Text>{""}</Text>
|
|
945
|
+
{result.ok.map((m, i) => <Text key={i} color="green">{" ✓ "}{m}</Text>)}
|
|
946
|
+
{result.warn.map((m, i) => <Text key={i} color="yellow">{" ⚠ "}{m}</Text>)}
|
|
947
|
+
<Text>{""}</Text>
|
|
948
|
+
<Text dimColor>Companion data preserved at</Text>
|
|
949
|
+
<Text dimColor>{STATE_DIR}</Text>
|
|
950
|
+
<Text>{""}</Text>
|
|
951
|
+
<Text dimColor>Restart Claude Code to apply.</Text>
|
|
952
|
+
<Text dimColor>Re-enable: bun run install-buddy</Text>
|
|
953
|
+
</Box>
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
return (
|
|
957
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
958
|
+
<Text>{""}</Text>
|
|
959
|
+
<Text bold color="red">☠ Disable claude-buddy</Text>
|
|
960
|
+
<Text>{""}</Text>
|
|
961
|
+
<Text dimColor>This will remove:</Text>
|
|
962
|
+
<Text>{" "}• MCP server from {CLAUDE_JSON}</Text>
|
|
963
|
+
<Text>{" "}• Hooks from settings.json</Text>
|
|
964
|
+
<Text>{" "}• Status line configuration</Text>
|
|
965
|
+
<Text>{""}</Text>
|
|
966
|
+
<Text dimColor>Kept:</Text>
|
|
967
|
+
<Text>{" "}• All companions</Text>
|
|
968
|
+
<Text>{" "}• Backups</Text>
|
|
969
|
+
<Text>{" "}• SKILL.md</Text>
|
|
970
|
+
<Text>{""}</Text>
|
|
971
|
+
<Text dimColor>{"─".repeat(28)}</Text>
|
|
972
|
+
<Text>{""}</Text>
|
|
973
|
+
{!confirming ? (
|
|
974
|
+
<Text>Press <Text bold color="red">enter</Text> to confirm</Text>
|
|
975
|
+
) : (
|
|
976
|
+
<Text color="red" bold>Really disable? y = yes, n = cancel</Text>
|
|
977
|
+
)}
|
|
978
|
+
</Box>
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// ─── Disable helpers ────────────────────────────────────────────────────────
|
|
983
|
+
|
|
984
|
+
const CLAUDE_JSON_PATH = CLAUDE_JSON;
|
|
985
|
+
const CLAUDE_SETTINGS_PATH = SETTINGS_PATH;
|
|
986
|
+
|
|
987
|
+
interface DisableResult { ok: string[]; warn: string[] }
|
|
988
|
+
|
|
989
|
+
function disableBuddy(): DisableResult {
|
|
990
|
+
const ok: string[] = [];
|
|
991
|
+
const warn: string[] = [];
|
|
992
|
+
|
|
993
|
+
try {
|
|
994
|
+
const claudeJson = JSON.parse(readFileSync(CLAUDE_JSON_PATH, "utf8"));
|
|
995
|
+
if (claudeJson.mcpServers?.["claude-buddy"]) {
|
|
996
|
+
delete claudeJson.mcpServers["claude-buddy"];
|
|
997
|
+
if (Object.keys(claudeJson.mcpServers).length === 0) delete claudeJson.mcpServers;
|
|
998
|
+
writeFileSync(CLAUDE_JSON_PATH, JSON.stringify(claudeJson, null, 2));
|
|
999
|
+
ok.push("MCP server removed");
|
|
1000
|
+
} else {
|
|
1001
|
+
warn.push("MCP was not registered");
|
|
1002
|
+
}
|
|
1003
|
+
} catch {
|
|
1004
|
+
warn.push(`Could not update ${CLAUDE_JSON}`);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
try {
|
|
1008
|
+
const settings = JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, "utf8"));
|
|
1009
|
+
let changed = false;
|
|
1010
|
+
|
|
1011
|
+
if (settings.statusLine?.command?.includes("buddy")) {
|
|
1012
|
+
delete settings.statusLine;
|
|
1013
|
+
changed = true;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (settings.hooks) {
|
|
1017
|
+
for (const hookType of ["PostToolUse", "Stop", "SessionStart", "SessionEnd"]) {
|
|
1018
|
+
if (settings.hooks[hookType]) {
|
|
1019
|
+
const before = settings.hooks[hookType].length;
|
|
1020
|
+
settings.hooks[hookType] = settings.hooks[hookType].filter(
|
|
1021
|
+
(h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("claude-buddy")),
|
|
1022
|
+
);
|
|
1023
|
+
if (settings.hooks[hookType].length < before) changed = true;
|
|
1024
|
+
if (settings.hooks[hookType].length === 0) delete settings.hooks[hookType];
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
if (changed) {
|
|
1031
|
+
writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
|
|
1032
|
+
ok.push("Hooks & status line removed");
|
|
1033
|
+
} else {
|
|
1034
|
+
warn.push("Nothing to remove from settings.json");
|
|
1035
|
+
}
|
|
1036
|
+
} catch {
|
|
1037
|
+
warn.push("Could not update settings.json");
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return { ok, warn };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// ─── Verify: buddy from user ID ──────────────────────────────────────────────
|
|
1044
|
+
|
|
1045
|
+
const VERIFY_BUTTONS = [
|
|
1046
|
+
{ key: "random", icon: "🎲", label: "Random ID" },
|
|
1047
|
+
{ key: "current", icon: "📍", label: "Use my current ID" },
|
|
1048
|
+
{ key: "edit", icon: "✏ ", label: "Enter custom hex" },
|
|
1049
|
+
] as const;
|
|
1050
|
+
|
|
1051
|
+
function VerifyPane({ userIdInput, editing, preview, buttonCursor, focused }: {
|
|
1052
|
+
userIdInput: string; editing: boolean; preview: { userId: string; bones: BuddyBones } | null;
|
|
1053
|
+
buttonCursor: number; focused: boolean;
|
|
1054
|
+
}) {
|
|
1055
|
+
return (
|
|
1056
|
+
<Box flexDirection="column" paddingX={1}>
|
|
1057
|
+
<Text bold color={focused ? "cyan" : "gray"}>{" 🔍 Verify"}</Text>
|
|
1058
|
+
<Text>{""}</Text>
|
|
1059
|
+
<Text dimColor>Show the deterministic buddy</Text>
|
|
1060
|
+
<Text dimColor>generated from a user ID.</Text>
|
|
1061
|
+
<Text>{""}</Text>
|
|
1062
|
+
<Box borderStyle={editing ? "round" as any : "single" as any} borderColor={editing ? "cyan" : "gray"} paddingX={1} flexDirection="column">
|
|
1063
|
+
<Text dimColor>User ID:</Text>
|
|
1064
|
+
<Box>
|
|
1065
|
+
<Text bold color="yellow">{userIdInput.slice(0, 32) || "(none)"}{userIdInput.length > 32 ? "…" : ""}</Text>
|
|
1066
|
+
{editing ? <Text color="yellow">▌</Text> : null}
|
|
1067
|
+
</Box>
|
|
1068
|
+
</Box>
|
|
1069
|
+
<Text>{""}</Text>
|
|
1070
|
+
{!editing ? (
|
|
1071
|
+
<Box flexDirection="column">
|
|
1072
|
+
{VERIFY_BUTTONS.map((b, i) => {
|
|
1073
|
+
const isCursor = focused && i === buttonCursor;
|
|
1074
|
+
return (
|
|
1075
|
+
<Box key={b.key}
|
|
1076
|
+
borderStyle={isCursor ? "round" as any : "single" as any}
|
|
1077
|
+
borderColor={isCursor ? "cyan" : "gray"}
|
|
1078
|
+
paddingX={1}
|
|
1079
|
+
>
|
|
1080
|
+
<Text bold={isCursor} color={isCursor ? "cyan" : "white"}>
|
|
1081
|
+
{b.icon} {b.label}
|
|
1082
|
+
</Text>
|
|
1083
|
+
</Box>
|
|
1084
|
+
);
|
|
1085
|
+
})}
|
|
1086
|
+
</Box>
|
|
1087
|
+
) : (
|
|
1088
|
+
<Text dimColor>{" "}type hex ⏎ confirm esc cancel</Text>
|
|
1089
|
+
)}
|
|
1090
|
+
{preview && !editing ? (
|
|
1091
|
+
<Box flexDirection="column" marginTop={1}>
|
|
1092
|
+
<Text dimColor>{"─".repeat(28)}</Text>
|
|
1093
|
+
<Text dimColor>Preview shown on the right →</Text>
|
|
1094
|
+
</Box>
|
|
1095
|
+
) : null}
|
|
1096
|
+
</Box>
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// ─── Hunt: criteria + async search + results ─────────────────────────────────
|
|
1101
|
+
|
|
1102
|
+
type HuntPhase = "form" | "searching" | "results";
|
|
1103
|
+
const HUNT_FIELDS = ["species", "rarity", "shiny", "peak", "dump"] as const;
|
|
1104
|
+
type HuntField = typeof HUNT_FIELDS[number];
|
|
1105
|
+
|
|
1106
|
+
interface HuntCriteria {
|
|
1107
|
+
species: Species;
|
|
1108
|
+
rarity: Rarity;
|
|
1109
|
+
shiny: boolean;
|
|
1110
|
+
peak?: StatName;
|
|
1111
|
+
dump?: StatName;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const HUNT_OPTS: Record<HuntField, readonly string[]> = {
|
|
1115
|
+
species: SPECIES,
|
|
1116
|
+
rarity: RARITIES,
|
|
1117
|
+
shiny: ["no", "yes"],
|
|
1118
|
+
peak: ["any", ...STAT_NAMES],
|
|
1119
|
+
dump: ["any", ...STAT_NAMES],
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
function huntMaxAttempts(rarity: Rarity, shiny: boolean): number {
|
|
1123
|
+
let n = 10_000_000;
|
|
1124
|
+
if (rarity === "legendary") n = 200_000_000;
|
|
1125
|
+
else if (rarity === "epic") n = 50_000_000;
|
|
1126
|
+
if (shiny) n *= 3;
|
|
1127
|
+
return n;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function HuntFormPane({ criteria, fieldCursor, optCursors, focused }: {
|
|
1131
|
+
criteria: HuntCriteria;
|
|
1132
|
+
fieldCursor: number;
|
|
1133
|
+
optCursors: Record<HuntField, number>;
|
|
1134
|
+
focused: boolean;
|
|
1135
|
+
}) {
|
|
1136
|
+
const valueFor = (f: HuntField): string => {
|
|
1137
|
+
if (f === "species") return criteria.species;
|
|
1138
|
+
if (f === "rarity") return criteria.rarity;
|
|
1139
|
+
if (f === "shiny") return criteria.shiny ? "yes" : "no";
|
|
1140
|
+
if (f === "peak") return criteria.peak ?? "any";
|
|
1141
|
+
return criteria.dump ?? "any";
|
|
1142
|
+
};
|
|
1143
|
+
const maxAttempts = huntMaxAttempts(criteria.rarity, criteria.shiny);
|
|
1144
|
+
return (
|
|
1145
|
+
<Box flexDirection="column" paddingX={1}>
|
|
1146
|
+
<Text bold color={focused ? "cyan" : "gray"}>{" 🎯 Hunt"}</Text>
|
|
1147
|
+
<Text dimColor>{" "}Search criteria:</Text>
|
|
1148
|
+
<Text>{""}</Text>
|
|
1149
|
+
{HUNT_FIELDS.map((f, i) => {
|
|
1150
|
+
const isCursor = focused && i === fieldCursor;
|
|
1151
|
+
return (
|
|
1152
|
+
<Box key={f}
|
|
1153
|
+
borderStyle={isCursor ? "round" as any : "single" as any}
|
|
1154
|
+
borderColor={isCursor ? "cyan" : "gray"}
|
|
1155
|
+
paddingX={1}
|
|
1156
|
+
>
|
|
1157
|
+
<Text bold={isCursor} color={isCursor ? "cyan" : "white"}>{f.padEnd(9)}</Text>
|
|
1158
|
+
<Text color="yellow">{valueFor(f)}</Text>
|
|
1159
|
+
</Box>
|
|
1160
|
+
);
|
|
1161
|
+
})}
|
|
1162
|
+
<Box
|
|
1163
|
+
borderStyle={focused && fieldCursor === HUNT_FIELDS.length ? "round" as any : "single" as any}
|
|
1164
|
+
borderColor={focused && fieldCursor === HUNT_FIELDS.length ? "green" : "gray"}
|
|
1165
|
+
paddingX={1}
|
|
1166
|
+
>
|
|
1167
|
+
<Text bold color={focused && fieldCursor === HUNT_FIELDS.length ? "green" : "white"}>
|
|
1168
|
+
▶ Start Hunt
|
|
1169
|
+
</Text>
|
|
1170
|
+
</Box>
|
|
1171
|
+
<Text>{""}</Text>
|
|
1172
|
+
<Text dimColor>{" "}Max attempts: {(maxAttempts / 1e6).toFixed(0)}M</Text>
|
|
1173
|
+
</Box>
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function HuntProgressPane({ checked, maxAttempts, found }: {
|
|
1178
|
+
checked: number; maxAttempts: number; found: number;
|
|
1179
|
+
}) {
|
|
1180
|
+
const pct = Math.min(100, Math.floor((checked / maxAttempts) * 100));
|
|
1181
|
+
const filled = Math.floor(pct / 5);
|
|
1182
|
+
const bar = "█".repeat(filled) + "░".repeat(20 - filled);
|
|
1183
|
+
return (
|
|
1184
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
1185
|
+
<Text>{""}</Text>
|
|
1186
|
+
<Text bold color="cyan">Searching…</Text>
|
|
1187
|
+
<Text>{""}</Text>
|
|
1188
|
+
<Text color="yellow">{bar}</Text>
|
|
1189
|
+
<Text dimColor>{(checked / 1e6).toFixed(1)}M / {(maxAttempts / 1e6).toFixed(0)}M ({pct}%)</Text>
|
|
1190
|
+
<Text>{""}</Text>
|
|
1191
|
+
<Text>Matches found: <Text bold color="green">{found}</Text></Text>
|
|
1192
|
+
<Text>{""}</Text>
|
|
1193
|
+
<Text dimColor>Press esc to cancel</Text>
|
|
1194
|
+
</Box>
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function HuntResultsPane({ results, cursor, focused }: {
|
|
1199
|
+
results: SearchResult[]; cursor: number; focused: boolean;
|
|
1200
|
+
}) {
|
|
1201
|
+
if (results.length === 0) {
|
|
1202
|
+
return (
|
|
1203
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
1204
|
+
<Text>{""}</Text>
|
|
1205
|
+
<Text color="red" bold>✗ No matches found</Text>
|
|
1206
|
+
<Text>{""}</Text>
|
|
1207
|
+
<Text dimColor>Try less restrictive criteria.</Text>
|
|
1208
|
+
<Text>{""}</Text>
|
|
1209
|
+
<Text dimColor>Press esc to go back</Text>
|
|
1210
|
+
</Box>
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
return (
|
|
1214
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
1215
|
+
<Text>{""}</Text>
|
|
1216
|
+
<Text bold color="green">✓ {results.length} matches — pick one</Text>
|
|
1217
|
+
<Text>{""}</Text>
|
|
1218
|
+
{results.slice(0, 5).map((r, i) => {
|
|
1219
|
+
const isCursor = focused && i === cursor;
|
|
1220
|
+
const b = r.bones;
|
|
1221
|
+
const statLine = (STAT_NAMES as readonly StatName[]).map(n => `${n.slice(0, 3)}:${b.stats[n]}`).join(" ");
|
|
1222
|
+
return (
|
|
1223
|
+
<Box key={i}
|
|
1224
|
+
borderStyle={isCursor ? "round" as any : "single" as any}
|
|
1225
|
+
borderColor={isCursor ? "cyan" : "gray"}
|
|
1226
|
+
paddingX={1}
|
|
1227
|
+
flexDirection="column"
|
|
1228
|
+
>
|
|
1229
|
+
<Text bold={isCursor} color={isCursor ? "cyan" : "white"}>
|
|
1230
|
+
{b.shiny ? "✨ " : " "}eye={b.eye} hat={b.hat}
|
|
1231
|
+
</Text>
|
|
1232
|
+
<Text dimColor>{statLine}</Text>
|
|
1233
|
+
</Box>
|
|
1234
|
+
);
|
|
1235
|
+
})}
|
|
1236
|
+
<Text>{""}</Text>
|
|
1237
|
+
<Text dimColor>⏎ save & activate esc discard</Text>
|
|
1238
|
+
</Box>
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function HuntNamingPane({ nameInput, chosenBones }: {
|
|
1243
|
+
nameInput: string; chosenBones: BuddyBones;
|
|
1244
|
+
}) {
|
|
1245
|
+
return (
|
|
1246
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
1247
|
+
<Text>{""}</Text>
|
|
1248
|
+
<Text bold color="yellow">Name your new buddy</Text>
|
|
1249
|
+
<Text>{""}</Text>
|
|
1250
|
+
<Text dimColor>{chosenBones.rarity} {chosenBones.species}{chosenBones.shiny ? " ✨" : ""}</Text>
|
|
1251
|
+
<Text>{""}</Text>
|
|
1252
|
+
<Box>
|
|
1253
|
+
<Text dimColor>Name: </Text>
|
|
1254
|
+
<Text bold color="yellow">{nameInput || " "}</Text>
|
|
1255
|
+
<Text color="yellow">▌</Text>
|
|
1256
|
+
</Box>
|
|
1257
|
+
<Text>{""}</Text>
|
|
1258
|
+
<Text dimColor>type name ⏎ save esc cancel</Text>
|
|
1259
|
+
</Box>
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// ─── Right: Buddy Card ──────────────────────────────────────────────────────
|
|
1264
|
+
|
|
1265
|
+
function BuddyCardPane({ companion, slot, isActive, editablePersonality, editCursor = 0 }: {
|
|
1266
|
+
companion: Companion; slot: string; isActive: boolean;
|
|
1267
|
+
editablePersonality?: string;
|
|
1268
|
+
editCursor?: number;
|
|
1269
|
+
}) {
|
|
1270
|
+
const b = companion.bones;
|
|
1271
|
+
const color = RARITY_COLOR[b.rarity] ?? "white";
|
|
1272
|
+
const stars = RARITY_STARS[b.rarity];
|
|
1273
|
+
const shiny = b.shiny ? " ✨" : "";
|
|
1274
|
+
const art = getArtFrame(b.species, b.eye, 0);
|
|
1275
|
+
const hatLine = HAT_ART[b.hat];
|
|
1276
|
+
if (hatLine && !art[0].trim()) art[0] = hatLine;
|
|
1277
|
+
const reaction = loadReaction();
|
|
1278
|
+
const isEditing = typeof editablePersonality === "string";
|
|
1279
|
+
const displayPersonality = isEditing ? editablePersonality! : companion.personality;
|
|
1280
|
+
const overLimit = isEditing && (editablePersonality?.length ?? 0) > 500;
|
|
1281
|
+
|
|
1282
|
+
const mkBar = (val: number) => {
|
|
1283
|
+
const f = Math.round(val / 10);
|
|
1284
|
+
return "█".repeat(f) + "░".repeat(10 - f);
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1287
|
+
return (
|
|
1288
|
+
<Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={2} paddingY={1} width={48}>
|
|
1289
|
+
|
|
1290
|
+
{/* Header: rarity + species */}
|
|
1291
|
+
<Box justifyContent="space-between">
|
|
1292
|
+
<Text color={color}>{stars} {b.rarity.toUpperCase()}{shiny}</Text>
|
|
1293
|
+
<Text dimColor>{b.species.toUpperCase()}</Text>
|
|
1294
|
+
</Box>
|
|
1295
|
+
|
|
1296
|
+
{/* ASCII art */}
|
|
1297
|
+
<Box flexDirection="column" marginTop={2} marginBottom={2}>
|
|
1298
|
+
{art.map((line, i) => line.trim() ? <Text key={i}>{" "}{line}</Text> : null)}
|
|
1299
|
+
</Box>
|
|
1300
|
+
|
|
1301
|
+
{/* Name */}
|
|
1302
|
+
<Box marginBottom={1}>
|
|
1303
|
+
<Text bold color={color}>{companion.name}</Text>
|
|
1304
|
+
</Box>
|
|
1305
|
+
|
|
1306
|
+
{/* Personality — editable when editablePersonality provided */}
|
|
1307
|
+
{isEditing ? (
|
|
1308
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
1309
|
+
<Box borderStyle="round" borderColor={overLimit ? "red" : "magenta"} paddingX={1}>
|
|
1310
|
+
<Text italic color={overLimit ? "red" : "yellow"}>
|
|
1311
|
+
{displayPersonality.slice(0, editCursor)}
|
|
1312
|
+
<Text color="yellow" inverse>{displayPersonality.slice(editCursor, editCursor + 1) || " "}</Text>
|
|
1313
|
+
{displayPersonality.slice(editCursor + 1)}
|
|
1314
|
+
</Text>
|
|
1315
|
+
</Box>
|
|
1316
|
+
<Text dimColor>
|
|
1317
|
+
{displayPersonality.length} / 500
|
|
1318
|
+
{overLimit ? " — over limit" : ""} ⏎ save esc cancel ←→ move
|
|
1319
|
+
</Text>
|
|
1320
|
+
</Box>
|
|
1321
|
+
) : (
|
|
1322
|
+
<Box marginBottom={1}>
|
|
1323
|
+
<Text dimColor italic>"{companion.personality}"</Text>
|
|
1324
|
+
</Box>
|
|
1325
|
+
)}
|
|
1326
|
+
|
|
1327
|
+
{/* Stats */}
|
|
1328
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
1329
|
+
{(STAT_NAMES as readonly StatName[]).map(stat => {
|
|
1330
|
+
const val = b.stats[stat];
|
|
1331
|
+
const isPeak = stat === b.peak;
|
|
1332
|
+
const isDump = stat === b.dump;
|
|
1333
|
+
const marker = isPeak ? " ▲" : isDump ? " ▼" : "";
|
|
1334
|
+
const statColor = isPeak ? "green" : isDump ? "red" : undefined;
|
|
1335
|
+
return (
|
|
1336
|
+
<Box key={stat} justifyContent="space-between">
|
|
1337
|
+
<Text dimColor>{stat.padEnd(10)}</Text>
|
|
1338
|
+
<Text> {mkBar(val)} </Text>
|
|
1339
|
+
<Text bold color={statColor}>{String(val).padStart(3)}{marker.padEnd(2)}</Text>
|
|
1340
|
+
</Box>
|
|
1341
|
+
);
|
|
1342
|
+
})}
|
|
1343
|
+
</Box>
|
|
1344
|
+
|
|
1345
|
+
{/* Reaction */}
|
|
1346
|
+
{reaction?.reaction ? (
|
|
1347
|
+
<Box marginBottom={1}>
|
|
1348
|
+
<Text>💬 <Text italic>{reaction.reaction}</Text></Text>
|
|
1349
|
+
</Box>
|
|
1350
|
+
) : null}
|
|
1351
|
+
|
|
1352
|
+
{/* Footer */}
|
|
1353
|
+
<Box>
|
|
1354
|
+
<Text dimColor>eye: {b.eye} hat: {b.hat} slot: </Text>
|
|
1355
|
+
<Text bold>{slot}</Text>
|
|
1356
|
+
{isActive ? <Text color="green" bold>{" ●"}</Text> : null}
|
|
1357
|
+
</Box>
|
|
1358
|
+
|
|
1359
|
+
</Box>
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// ─── Right: Setting Detail ──────────────────────────────────────────────────
|
|
1364
|
+
|
|
1365
|
+
interface SettingDef {
|
|
1366
|
+
key: string; label: string; description: string[];
|
|
1367
|
+
type: "number" | "options"; options?: string[];
|
|
1368
|
+
min?: number; default: string;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const SETTING_DEFS: SettingDef[] = [
|
|
1372
|
+
{ key: "commentCooldown", label: "Comment Cooldown", description: ["Minimum seconds between", "buddy status line comments.", "", "Lower = chatty, Higher = quiet"], type: "number", min: 0, default: "30" },
|
|
1373
|
+
{ key: "reactionTTL", label: "Reaction TTL", description: ["How long reactions stay", "visible in status line.", "", "0 = permanent"], type: "number", min: 0, default: "0" },
|
|
1374
|
+
{ key: "bubbleStyle", label: "Bubble Style", description: ["Speech bubble style.", "", 'classic → "quoted"', "round → (parens)"], type: "options", options: ["classic", "round"], default: "classic" },
|
|
1375
|
+
{ key: "bubblePosition", label: "Bubble Position", description: ["Bubble placement.", "", "top → above buddy", "left → beside buddy"], type: "options", options: ["top", "left"], default: "top" },
|
|
1376
|
+
{ key: "showRarity", label: "Show Rarity", description: ["Show rarity stars in", "the status line.", "", "true → ★★★★ visible", "false → hidden"], type: "options", options: ["true", "false"], default: "true" },
|
|
1377
|
+
{ key: "statusLineEnabled", label: "Status Line", description: ["Animated buddy in Claude Code's", "status line bar.", "", "true → patches settings.json", "false → removes it", "", "Restart Claude Code after toggle."], type: "options", options: ["true", "false"], default: "false" },
|
|
1378
|
+
];
|
|
1379
|
+
|
|
1380
|
+
function SettingDetailPane({ settingIndex, config, editing, numInput, optCursor }: {
|
|
1381
|
+
settingIndex: number; config: BuddyConfig; editing: boolean; numInput: string; optCursor: number;
|
|
1382
|
+
}) {
|
|
1383
|
+
const def = SETTING_DEFS[settingIndex];
|
|
1384
|
+
const currentVal = String(config[def.key as keyof BuddyConfig]);
|
|
1385
|
+
const inBuddyShell = process.env.BUDDY_SHELL === "1";
|
|
1386
|
+
const showBuddyShellHint = def.key === "statusLineEnabled" && inBuddyShell;
|
|
1387
|
+
return (
|
|
1388
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
1389
|
+
<Text>{""}</Text>
|
|
1390
|
+
<Text bold color="cyan">{def.label}</Text>
|
|
1391
|
+
<Text>{""}</Text>
|
|
1392
|
+
{def.description.map((line, i) => <Text key={i} dimColor>{line}</Text>)}
|
|
1393
|
+
{showBuddyShellHint ? (
|
|
1394
|
+
<Box flexDirection="column" marginTop={1}>
|
|
1395
|
+
<Text color="yellow">⚠ Currently suppressed</Text>
|
|
1396
|
+
<Text dimColor>You're inside buddy-shell — the</Text>
|
|
1397
|
+
<Text dimColor>status line is hidden automatically</Text>
|
|
1398
|
+
<Text dimColor>(buddy already shown in the panel).</Text>
|
|
1399
|
+
<Text dimColor>This setting still persists; it takes</Text>
|
|
1400
|
+
<Text dimColor>effect when you run claude without</Text>
|
|
1401
|
+
<Text dimColor>the shell wrapper.</Text>
|
|
1402
|
+
</Box>
|
|
1403
|
+
) : null}
|
|
1404
|
+
<Text>{""}</Text>
|
|
1405
|
+
<Text dimColor>{"─".repeat(28)}</Text>
|
|
1406
|
+
<Text>{""}</Text>
|
|
1407
|
+
{!editing ? (
|
|
1408
|
+
<Box flexDirection="column">
|
|
1409
|
+
<Text>Current: <Text bold color="yellow">{currentVal}</Text></Text>
|
|
1410
|
+
<Text dimColor>Default: {def.default}</Text>
|
|
1411
|
+
<Text>{""}</Text>
|
|
1412
|
+
<Text dimColor>Press enter to edit</Text>
|
|
1413
|
+
</Box>
|
|
1414
|
+
) : def.type === "options" ? (
|
|
1415
|
+
<Box flexDirection="column">
|
|
1416
|
+
{def.options!.map((opt, i) => (
|
|
1417
|
+
<Text key={opt}>
|
|
1418
|
+
{i === optCursor ? <Text color="green" bold>{" ▸ "}{opt}</Text> : opt === currentVal ? <Text>{" ● "}{opt}</Text> : <Text dimColor>{" ○ "}{opt}</Text>}
|
|
1419
|
+
{opt === def.default ? <Text dimColor>{" (default)"}</Text> : null}
|
|
1420
|
+
</Text>
|
|
1421
|
+
))}
|
|
1422
|
+
<Text>{""}</Text>
|
|
1423
|
+
<Text dimColor>↑↓ select enter confirm esc cancel</Text>
|
|
1424
|
+
</Box>
|
|
1425
|
+
) : (
|
|
1426
|
+
<Box flexDirection="column">
|
|
1427
|
+
<Box>
|
|
1428
|
+
<Text dimColor>{"Value: "}</Text>
|
|
1429
|
+
<Text bold color="yellow" underline>{numInput || " "}</Text>
|
|
1430
|
+
<Text color="yellow">▌</Text>
|
|
1431
|
+
<Text dimColor> seconds</Text>
|
|
1432
|
+
</Box>
|
|
1433
|
+
<Text>{""}</Text>
|
|
1434
|
+
<Text dimColor>Was: {currentVal} Default: {def.default}</Text>
|
|
1435
|
+
<Text>{""}</Text>
|
|
1436
|
+
<Text dimColor>Type number enter confirm esc cancel</Text>
|
|
1437
|
+
</Box>
|
|
1438
|
+
)}
|
|
1439
|
+
</Box>
|
|
1440
|
+
);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// ─── Right: Sidebar Description ─────────────────────────────────────────────
|
|
1444
|
+
|
|
1445
|
+
function SidebarDescriptionPane({ cursor }: { cursor: number }) {
|
|
1446
|
+
if (cursor >= SIDEBAR_ITEMS.length) {
|
|
1447
|
+
return (
|
|
1448
|
+
<Box flexDirection="column" paddingLeft={2} paddingY={1}>
|
|
1449
|
+
<Text bold color="red">👋 Exit</Text>
|
|
1450
|
+
<Text>{""}</Text>
|
|
1451
|
+
<Text dimColor>Close the dashboard and</Text>
|
|
1452
|
+
<Text dimColor>return to your shell.</Text>
|
|
1453
|
+
</Box>
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
const item = SIDEBAR_ITEMS[cursor];
|
|
1457
|
+
if (!item) return null;
|
|
1458
|
+
return (
|
|
1459
|
+
<Box flexDirection="column" paddingLeft={2} paddingY={1}>
|
|
1460
|
+
<Text bold color="cyan">{item.icon} {item.label}</Text>
|
|
1461
|
+
<Text>{""}</Text>
|
|
1462
|
+
{item.description.map((line, i) => (
|
|
1463
|
+
<Text key={i} dimColor={line !== ""}>{line || " "}</Text>
|
|
1464
|
+
))}
|
|
1465
|
+
<Text>{""}</Text>
|
|
1466
|
+
<Text dimColor>{"─".repeat(30)}</Text>
|
|
1467
|
+
<Text>{""}</Text>
|
|
1468
|
+
<Text dimColor>Press ⏎ to open this section</Text>
|
|
1469
|
+
</Box>
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// ─── App ────────────────────────────────────────────────────────────────────
|
|
1474
|
+
|
|
1475
|
+
function App() {
|
|
1476
|
+
const { exit } = useApp();
|
|
1477
|
+
const { stdout } = useStdout();
|
|
1478
|
+
const cols = stdout?.columns ?? 80;
|
|
1479
|
+
const rows = stdout?.rows ?? 24;
|
|
1480
|
+
|
|
1481
|
+
const [section, setSection] = useState<Section>("menagerie");
|
|
1482
|
+
const [focus, setFocus] = useState<Focus>("sidebar");
|
|
1483
|
+
const [sidebarCursor, setSidebarCursor] = useState(0);
|
|
1484
|
+
const [listCursor, setListCursor] = useState(0);
|
|
1485
|
+
const [settCursor, setSettCursor] = useState(0);
|
|
1486
|
+
const [optCursor, setOptCursor] = useState(0);
|
|
1487
|
+
const [numInput, setNumInput] = useState("");
|
|
1488
|
+
const [config, setConfig] = useState<BuddyConfig>(loadConfig());
|
|
1489
|
+
const [message, setMessage] = useState("");
|
|
1490
|
+
const [diagData] = useState(() => runDiagnostics());
|
|
1491
|
+
const [backups, setBackups] = useState(() => getBackups());
|
|
1492
|
+
const [achData] = useState(() => ({
|
|
1493
|
+
unlocked: loadUnlocked(),
|
|
1494
|
+
events: loadEvents(loadActiveSlot()),
|
|
1495
|
+
}));
|
|
1496
|
+
const unlockedIds = React.useMemo(
|
|
1497
|
+
() => new Set(achData.unlocked.map(u => u.id)),
|
|
1498
|
+
[achData.unlocked],
|
|
1499
|
+
);
|
|
1500
|
+
|
|
1501
|
+
// Menagerie search/filter (always-active text input)
|
|
1502
|
+
const [menagSearch, setMenagSearch] = useState("");
|
|
1503
|
+
|
|
1504
|
+
// Menagerie personality editor
|
|
1505
|
+
// input + cursor live in one object so rapid key-repeat (held backspace,
|
|
1506
|
+
// held arrows) can update both atomically via a single functional setter.
|
|
1507
|
+
const [personalityEditing, setPersonalityEditing] = useState(false);
|
|
1508
|
+
const [personalitySlot, setPersonalitySlot] = useState("");
|
|
1509
|
+
const [personalityEdit, setPersonalityEdit] = useState<{ input: string; cursor: number }>({ input: "", cursor: 0 });
|
|
1510
|
+
const personalityInput = personalityEdit.input;
|
|
1511
|
+
const personalityCursor = personalityEdit.cursor;
|
|
1512
|
+
|
|
1513
|
+
// Verify
|
|
1514
|
+
const [verifyInput, setVerifyInput] = useState("");
|
|
1515
|
+
const [verifyEditing, setVerifyEditing] = useState(false);
|
|
1516
|
+
const [verifyPreview, setVerifyPreview] = useState<{ userId: string; bones: BuddyBones } | null>(null);
|
|
1517
|
+
const [verifyButtonCursor, setVerifyButtonCursor] = useState(0);
|
|
1518
|
+
|
|
1519
|
+
// Hunt
|
|
1520
|
+
const [huntPhase, setHuntPhase] = useState<HuntPhase | "naming">("form");
|
|
1521
|
+
const [huntCriteria, setHuntCriteria] = useState<HuntCriteria>({
|
|
1522
|
+
species: SPECIES[0] as Species,
|
|
1523
|
+
rarity: "uncommon" as Rarity,
|
|
1524
|
+
shiny: false,
|
|
1525
|
+
});
|
|
1526
|
+
const [huntFieldCursor, setHuntFieldCursor] = useState(0);
|
|
1527
|
+
const [huntOptCursors, setHuntOptCursors] = useState<Record<HuntField, number>>({
|
|
1528
|
+
species: 0, rarity: 1, shiny: 0, peak: 0, dump: 0,
|
|
1529
|
+
});
|
|
1530
|
+
const [huntChecked, setHuntChecked] = useState(0);
|
|
1531
|
+
const [huntResults, setHuntResults] = useState<SearchResult[]>([]);
|
|
1532
|
+
const [huntResultCursor, setHuntResultCursor] = useState(0);
|
|
1533
|
+
const [huntNameInput, setHuntNameInput] = useState("");
|
|
1534
|
+
const huntCancelRef = React.useRef(false);
|
|
1535
|
+
|
|
1536
|
+
// System (enable / disable / uninstall)
|
|
1537
|
+
const [systemCursor, setSystemCursor] = useState(0);
|
|
1538
|
+
const [disableConfirming, setDisableConfirming] = useState(false);
|
|
1539
|
+
const [disableResult, setDisableResult] = useState<DisableResult | null>(null);
|
|
1540
|
+
const [enableRunning, setEnableRunning] = useState(false);
|
|
1541
|
+
const [enableResult, setEnableResult] = useState<InstallResult | null>(null);
|
|
1542
|
+
const [uninstallStage, setUninstallStage] = useState<UninstallStage>("warning");
|
|
1543
|
+
const [uninstallTyped, setUninstallTyped] = useState("");
|
|
1544
|
+
const [uninstallKeepState, setUninstallKeepState] = useState(true);
|
|
1545
|
+
const [uninstallResult, setUninstallResult] = useState<InstallResult | null>(null);
|
|
1546
|
+
|
|
1547
|
+
const rawSlots = listCompanionSlots();
|
|
1548
|
+
const activeSlot = loadActiveSlot();
|
|
1549
|
+
const slots = React.useMemo(() => {
|
|
1550
|
+
if (!menagSearch) return rawSlots;
|
|
1551
|
+
const q = menagSearch.toLowerCase();
|
|
1552
|
+
return rawSlots.filter(s =>
|
|
1553
|
+
s.companion.name.toLowerCase().includes(q)
|
|
1554
|
+
|| s.companion.bones.species.toLowerCase().includes(q)
|
|
1555
|
+
|| s.companion.bones.rarity.toLowerCase().includes(q),
|
|
1556
|
+
);
|
|
1557
|
+
}, [rawSlots, menagSearch]);
|
|
1558
|
+
|
|
1559
|
+
// Hunt async chunked search
|
|
1560
|
+
React.useEffect(() => {
|
|
1561
|
+
if (huntPhase !== "searching") return;
|
|
1562
|
+
huntCancelRef.current = false;
|
|
1563
|
+
const maxAttempts = huntMaxAttempts(huntCriteria.rarity, huntCriteria.shiny);
|
|
1564
|
+
const CHUNK = 500_000;
|
|
1565
|
+
let total = 0;
|
|
1566
|
+
const allResults: SearchResult[] = [];
|
|
1567
|
+
|
|
1568
|
+
const sc: SearchCriteria = {
|
|
1569
|
+
species: huntCriteria.species,
|
|
1570
|
+
rarity: huntCriteria.rarity,
|
|
1571
|
+
wantShiny: huntCriteria.shiny,
|
|
1572
|
+
wantPeak: huntCriteria.peak,
|
|
1573
|
+
wantDump: huntCriteria.dump,
|
|
1574
|
+
};
|
|
1575
|
+
const step = () => {
|
|
1576
|
+
if (huntCancelRef.current) return;
|
|
1577
|
+
const chunkResults = searchBuddy(sc, CHUNK);
|
|
1578
|
+
allResults.push(...chunkResults);
|
|
1579
|
+
total += CHUNK;
|
|
1580
|
+
setHuntChecked(total);
|
|
1581
|
+
setHuntResults([...allResults]);
|
|
1582
|
+
if (total >= maxAttempts || allResults.length >= 20) {
|
|
1583
|
+
setHuntPhase("results");
|
|
1584
|
+
setHuntResultCursor(0);
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
setTimeout(step, 0);
|
|
1588
|
+
};
|
|
1589
|
+
setTimeout(step, 0);
|
|
1590
|
+
|
|
1591
|
+
return () => { huntCancelRef.current = true; };
|
|
1592
|
+
}, [huntPhase]);
|
|
1593
|
+
|
|
1594
|
+
const sidebarWidth = 35;
|
|
1595
|
+
const middleWidth = 35;
|
|
1596
|
+
|
|
1597
|
+
useInput((input, key) => {
|
|
1598
|
+
setMessage("");
|
|
1599
|
+
|
|
1600
|
+
// Unified: Enter or Space = primary action
|
|
1601
|
+
const isSelect = key.return || input === " ";
|
|
1602
|
+
|
|
1603
|
+
// ─── Sidebar ────────────────────────────
|
|
1604
|
+
if (focus === "sidebar") {
|
|
1605
|
+
if (key.upArrow) setSidebarCursor(c => Math.max(0, c - 1));
|
|
1606
|
+
if (key.downArrow) setSidebarCursor(c => Math.min(SIDEBAR_ITEMS.length, c + 1));
|
|
1607
|
+
if (input === "q") exit();
|
|
1608
|
+
if (isSelect) {
|
|
1609
|
+
if (sidebarCursor >= SIDEBAR_ITEMS.length) { exit(); return; }
|
|
1610
|
+
const selected = SIDEBAR_ITEMS[sidebarCursor].key;
|
|
1611
|
+
setSection(selected);
|
|
1612
|
+
setFocus("list");
|
|
1613
|
+
setListCursor(0);
|
|
1614
|
+
setSettCursor(0);
|
|
1615
|
+
setSystemCursor(0);
|
|
1616
|
+
setMenagSearch("");
|
|
1617
|
+
setPersonalityEditing(false);
|
|
1618
|
+
setPersonalitySlot("");
|
|
1619
|
+
setPersonalityEdit({ input: "", cursor: 0 });
|
|
1620
|
+
setDisableConfirming(false);
|
|
1621
|
+
setDisableResult(null);
|
|
1622
|
+
setEnableResult(null);
|
|
1623
|
+
setUninstallStage("warning");
|
|
1624
|
+
setUninstallTyped("");
|
|
1625
|
+
if (selected === "backup") setBackups(getBackups());
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// ─── Personality Edit ───────────────────
|
|
1630
|
+
// Enter = save, Esc = discard + exit, ↑↓ = switch buddy (discards unsaved),
|
|
1631
|
+
// ←→ = move cursor within the text.
|
|
1632
|
+
else if (focus === "list" && section === "menagerie" && personalityEditing) {
|
|
1633
|
+
if (key.escape) {
|
|
1634
|
+
setPersonalityEditing(false);
|
|
1635
|
+
setPersonalitySlot("");
|
|
1636
|
+
setPersonalityEdit({ input: "", cursor: 0 });
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
if (key.return) {
|
|
1641
|
+
if (personalityInput.length === 0 || personalityInput.length > 500) {
|
|
1642
|
+
setMessage("✗ personality must be 1-500 chars");
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
const entry = rawSlots.find(s => s.slot === personalitySlot);
|
|
1646
|
+
if (!entry) return;
|
|
1647
|
+
if (entry.companion.personality === personalityInput) {
|
|
1648
|
+
setMessage("(no changes)");
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
try {
|
|
1652
|
+
updateCompanionSlot(personalitySlot, { ...entry.companion, personality: personalityInput });
|
|
1653
|
+
setMessage(`✓ ${entry.companion.name}'s personality saved`);
|
|
1654
|
+
} catch (e: any) {
|
|
1655
|
+
setMessage(`✗ ${e?.message ?? "failed"}`);
|
|
1656
|
+
}
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
if (key.leftArrow) {
|
|
1661
|
+
setPersonalityEdit(st => ({ input: st.input, cursor: Math.max(0, st.cursor - 1) }));
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
if (key.rightArrow) {
|
|
1665
|
+
setPersonalityEdit(st => ({ input: st.input, cursor: Math.min(st.input.length, st.cursor + 1) }));
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
if (key.upArrow || key.downArrow) {
|
|
1670
|
+
// Switch buddy — discards unsaved changes of current buddy
|
|
1671
|
+
const maxIdx = slots.length - 1;
|
|
1672
|
+
const newIdx = key.upArrow
|
|
1673
|
+
? Math.max(0, listCursor - 1)
|
|
1674
|
+
: Math.min(maxIdx, listCursor + 1);
|
|
1675
|
+
setListCursor(newIdx);
|
|
1676
|
+
const nextBuddy = slots[newIdx];
|
|
1677
|
+
if (nextBuddy) {
|
|
1678
|
+
setPersonalitySlot(nextBuddy.slot);
|
|
1679
|
+
setPersonalityEdit({ input: nextBuddy.companion.personality, cursor: nextBuddy.companion.personality.length });
|
|
1680
|
+
}
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
if (key.backspace || key.delete) {
|
|
1685
|
+
setPersonalityEdit(st => {
|
|
1686
|
+
if (st.cursor === 0) return st;
|
|
1687
|
+
return {
|
|
1688
|
+
input: st.input.slice(0, st.cursor - 1) + st.input.slice(st.cursor),
|
|
1689
|
+
cursor: st.cursor - 1,
|
|
1690
|
+
};
|
|
1691
|
+
});
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// Insert printable chars (handles typed input and paste).
|
|
1696
|
+
// Strip bracketed-paste markers and any control characters.
|
|
1697
|
+
if (input && input.length > 0) {
|
|
1698
|
+
const cleaned = input
|
|
1699
|
+
.replace(/\x1b\[200~|\x1b\[201~/g, "")
|
|
1700
|
+
.replace(/[\x00-\x1f\x7f]/g, "");
|
|
1701
|
+
if (cleaned.length > 0) {
|
|
1702
|
+
setPersonalityEdit(st => {
|
|
1703
|
+
const remaining = Math.max(0, 500 - st.input.length);
|
|
1704
|
+
const toInsert = cleaned.slice(0, remaining);
|
|
1705
|
+
if (toInsert.length === 0) return st;
|
|
1706
|
+
return {
|
|
1707
|
+
input: st.input.slice(0, st.cursor) + toInsert + st.input.slice(st.cursor),
|
|
1708
|
+
cursor: st.cursor + toInsert.length,
|
|
1709
|
+
};
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// ─── List: Menagerie ────────────────────
|
|
1717
|
+
// Filter is always active while in list mode: typing filters live,
|
|
1718
|
+
// arrows navigate (incl. the "Edit Personality" button at the end),
|
|
1719
|
+
// Enter activates buddy or opens edit mode.
|
|
1720
|
+
else if (focus === "list" && section === "menagerie") {
|
|
1721
|
+
const editIdx = slots.length; // cursor position for the edit button
|
|
1722
|
+
if (key.escape) {
|
|
1723
|
+
if (menagSearch) { setMenagSearch(""); setListCursor(0); return; }
|
|
1724
|
+
setFocus("sidebar");
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
if (key.upArrow) { setListCursor(c => Math.max(0, c - 1)); return; }
|
|
1728
|
+
if (key.downArrow) { setListCursor(c => Math.min(editIdx, c + 1)); return; }
|
|
1729
|
+
if (key.return) {
|
|
1730
|
+
if (listCursor === editIdx) {
|
|
1731
|
+
// Edit Personality button — use buddy just above the button
|
|
1732
|
+
const buddyIdx = Math.min(Math.max(0, listCursor - 1), slots.length - 1);
|
|
1733
|
+
const target = slots[buddyIdx];
|
|
1734
|
+
if (target) {
|
|
1735
|
+
setListCursor(buddyIdx); // move cursor back onto that buddy
|
|
1736
|
+
setPersonalitySlot(target.slot);
|
|
1737
|
+
setPersonalityEdit({ input: target.companion.personality, cursor: target.companion.personality.length });
|
|
1738
|
+
setPersonalityEditing(true);
|
|
1739
|
+
}
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
if (slots[listCursor]) {
|
|
1743
|
+
const { slot, companion } = slots[listCursor];
|
|
1744
|
+
saveActiveSlot(slot);
|
|
1745
|
+
writeStatusState(companion, `*${companion.name} arrives*`);
|
|
1746
|
+
setMessage(`✓ ${companion.name} is now active!`);
|
|
1747
|
+
}
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
if (key.backspace || key.delete) {
|
|
1751
|
+
setMenagSearch(s => s.slice(0, -1));
|
|
1752
|
+
setListCursor(0);
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
// Any printable char → append to filter (reset cursor to top)
|
|
1756
|
+
if (input && input.length === 1 && input >= " " && input !== "\x7f") {
|
|
1757
|
+
setMenagSearch(s => s + input);
|
|
1758
|
+
setListCursor(0);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// ─── List: Settings ─────────────────────
|
|
1763
|
+
else if (focus === "list" && section === "settings") {
|
|
1764
|
+
if (key.escape) setFocus("sidebar");
|
|
1765
|
+
if (input === "q") exit();
|
|
1766
|
+
if (key.upArrow) setSettCursor(c => Math.max(0, c - 1));
|
|
1767
|
+
if (key.downArrow) setSettCursor(c => Math.min(SETTINGS_ITEMS.length - 1, c + 1));
|
|
1768
|
+
if (isSelect) {
|
|
1769
|
+
const def = SETTING_DEFS[settCursor];
|
|
1770
|
+
if (def.type === "options") {
|
|
1771
|
+
const current = String(config[def.key as keyof BuddyConfig]);
|
|
1772
|
+
setOptCursor(def.options!.indexOf(current));
|
|
1773
|
+
} else {
|
|
1774
|
+
setNumInput(String(config[def.key as keyof BuddyConfig]));
|
|
1775
|
+
}
|
|
1776
|
+
setFocus("edit");
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// ─── List: Achievements ─────────────────
|
|
1781
|
+
else if (focus === "list" && section === "achievements") {
|
|
1782
|
+
if (key.escape) setFocus("sidebar");
|
|
1783
|
+
if (input === "q") exit();
|
|
1784
|
+
if (key.upArrow) setListCursor(c => Math.max(0, c - 1));
|
|
1785
|
+
if (key.downArrow) setListCursor(c => Math.min(ACHIEVEMENTS.length - 1, c + 1));
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// ─── List: Verify ───────────────────────
|
|
1789
|
+
else if (focus === "list" && section === "verify") {
|
|
1790
|
+
if (verifyEditing) {
|
|
1791
|
+
if (key.escape) { setVerifyEditing(false); setVerifyInput(""); return; }
|
|
1792
|
+
if (key.return) {
|
|
1793
|
+
const id = verifyInput.trim();
|
|
1794
|
+
if (id) { setVerifyPreview({ userId: id, bones: generateBones(id) }); }
|
|
1795
|
+
setVerifyEditing(false);
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
if (key.backspace || key.delete) { setVerifyInput(s => s.slice(0, -1)); return; }
|
|
1799
|
+
if (input && input.length === 1 && /^[0-9a-fA-F]$/.test(input)) {
|
|
1800
|
+
setVerifyInput(s => s + input);
|
|
1801
|
+
}
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
if (key.escape) setFocus("sidebar");
|
|
1805
|
+
if (input === "q") exit();
|
|
1806
|
+
if (key.upArrow) setVerifyButtonCursor(c => Math.max(0, c - 1));
|
|
1807
|
+
if (key.downArrow) setVerifyButtonCursor(c => Math.min(VERIFY_BUTTONS.length - 1, c + 1));
|
|
1808
|
+
|
|
1809
|
+
const doRandom = () => {
|
|
1810
|
+
const hex = Array.from({ length: 32 }, () =>
|
|
1811
|
+
Math.floor(Math.random() * 256).toString(16).padStart(2, "0")).join("");
|
|
1812
|
+
setVerifyInput(hex);
|
|
1813
|
+
setVerifyPreview({ userId: hex, bones: generateBones(hex) });
|
|
1814
|
+
};
|
|
1815
|
+
const doCurrent = () => {
|
|
1816
|
+
const id = resolveUserId();
|
|
1817
|
+
setVerifyInput(id);
|
|
1818
|
+
setVerifyPreview({ userId: id, bones: generateBones(id) });
|
|
1819
|
+
};
|
|
1820
|
+
const doEdit = () => { setVerifyEditing(true); setVerifyInput(""); };
|
|
1821
|
+
|
|
1822
|
+
if (isSelect) {
|
|
1823
|
+
const b = VERIFY_BUTTONS[verifyButtonCursor];
|
|
1824
|
+
if (b.key === "random") doRandom();
|
|
1825
|
+
else if (b.key === "current") doCurrent();
|
|
1826
|
+
else if (b.key === "edit") doEdit();
|
|
1827
|
+
}
|
|
1828
|
+
// Power-user shortcuts (not shown in help, but kept for speed)
|
|
1829
|
+
if (input === "r") doRandom();
|
|
1830
|
+
if (input === "c") doCurrent();
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// ─── List: Hunt ─────────────────────────
|
|
1834
|
+
else if (focus === "list" && section === "hunt") {
|
|
1835
|
+
if (huntPhase === "form") {
|
|
1836
|
+
if (key.escape) setFocus("sidebar");
|
|
1837
|
+
if (input === "q") exit();
|
|
1838
|
+
const maxIdx = HUNT_FIELDS.length; // +1 for Start button
|
|
1839
|
+
if (key.upArrow) setHuntFieldCursor(c => Math.max(0, c - 1));
|
|
1840
|
+
if (key.downArrow) setHuntFieldCursor(c => Math.min(maxIdx, c + 1));
|
|
1841
|
+
if (isSelect) {
|
|
1842
|
+
if (huntFieldCursor === HUNT_FIELDS.length) {
|
|
1843
|
+
// Start Hunt
|
|
1844
|
+
setHuntChecked(0);
|
|
1845
|
+
setHuntResults([]);
|
|
1846
|
+
setHuntPhase("searching");
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
const f = HUNT_FIELDS[huntFieldCursor];
|
|
1850
|
+
const opts = HUNT_OPTS[f];
|
|
1851
|
+
const cur = huntOptCursors[f];
|
|
1852
|
+
const next = (cur + 1) % opts.length;
|
|
1853
|
+
setHuntOptCursors({ ...huntOptCursors, [f]: next });
|
|
1854
|
+
const val = opts[next];
|
|
1855
|
+
if (f === "species") setHuntCriteria(c => ({ ...c, species: val as Species }));
|
|
1856
|
+
else if (f === "rarity") setHuntCriteria(c => ({ ...c, rarity: val as Rarity }));
|
|
1857
|
+
else if (f === "shiny") setHuntCriteria(c => ({ ...c, shiny: val === "yes" }));
|
|
1858
|
+
else if (f === "peak") setHuntCriteria(c => ({ ...c, peak: val === "any" ? undefined : val as StatName }));
|
|
1859
|
+
else if (f === "dump") setHuntCriteria(c => ({ ...c, dump: val === "any" ? undefined : val as StatName }));
|
|
1860
|
+
}
|
|
1861
|
+
} else if (huntPhase === "searching") {
|
|
1862
|
+
if (key.escape) { huntCancelRef.current = true; setHuntPhase("form"); }
|
|
1863
|
+
} else if (huntPhase === "results") {
|
|
1864
|
+
if (key.escape) { setHuntPhase("form"); setHuntResults([]); setHuntChecked(0); }
|
|
1865
|
+
const top = huntResults.slice(0, 5);
|
|
1866
|
+
if (key.upArrow) setHuntResultCursor(c => Math.max(0, c - 1));
|
|
1867
|
+
if (key.downArrow) setHuntResultCursor(c => Math.min(top.length - 1, c + 1));
|
|
1868
|
+
if (isSelect && top[huntResultCursor]) {
|
|
1869
|
+
setHuntNameInput(unusedName());
|
|
1870
|
+
setHuntPhase("naming");
|
|
1871
|
+
}
|
|
1872
|
+
} else if (huntPhase === "naming") {
|
|
1873
|
+
if (key.escape) { setHuntNameInput(""); setHuntPhase("results"); return; }
|
|
1874
|
+
if (key.return) {
|
|
1875
|
+
const name = huntNameInput.trim() || unusedName();
|
|
1876
|
+
const slot = slugify(name);
|
|
1877
|
+
const chosen = huntResults[huntResultCursor];
|
|
1878
|
+
const existing = new Set(listCompanionSlots().map(e => slugify(e.companion.name)));
|
|
1879
|
+
if (existing.has(slot)) {
|
|
1880
|
+
setMessage(`✗ Slot "${slot}" already taken`);
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
const companion: Companion = {
|
|
1884
|
+
bones: chosen.bones,
|
|
1885
|
+
name,
|
|
1886
|
+
personality: `A ${chosen.bones.rarity} ${chosen.bones.species} who watches code with quiet intensity.`,
|
|
1887
|
+
hatchedAt: Date.now(),
|
|
1888
|
+
userId: chosen.userId,
|
|
1889
|
+
};
|
|
1890
|
+
saveCompanionSlot(companion, slot);
|
|
1891
|
+
saveActiveSlot(slot);
|
|
1892
|
+
writeStatusState(companion, `*${name} arrives*`);
|
|
1893
|
+
setMessage(`✓ ${name} saved to slot "${slot}"`);
|
|
1894
|
+
setHuntNameInput("");
|
|
1895
|
+
setHuntResults([]);
|
|
1896
|
+
setHuntChecked(0);
|
|
1897
|
+
setHuntPhase("form");
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
if (key.backspace || key.delete) { setHuntNameInput(s => s.slice(0, -1)); return; }
|
|
1901
|
+
if (input && input.length === 1 && input >= " " && huntNameInput.length < 14) {
|
|
1902
|
+
setHuntNameInput(s => s + input);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// ─── List: Doctor ───────────────────────
|
|
1908
|
+
else if (focus === "list" && section === "doctor") {
|
|
1909
|
+
if (key.escape) setFocus("sidebar");
|
|
1910
|
+
if (input === "q") exit();
|
|
1911
|
+
if (key.upArrow) setListCursor(c => Math.max(0, c - 1));
|
|
1912
|
+
if (key.downArrow) setListCursor(c => Math.min(diagData.length - 1, c + 1));
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// ─── List: Backup ───────────────────────
|
|
1916
|
+
else if (focus === "list" && section === "backup") {
|
|
1917
|
+
if (key.escape) setFocus("sidebar");
|
|
1918
|
+
if (input === "q") exit();
|
|
1919
|
+
const maxIdx = BACKUP_ACTIONS.length + backups.length - 1;
|
|
1920
|
+
if (key.upArrow) setListCursor(c => Math.max(0, c - 1));
|
|
1921
|
+
if (key.downArrow) setListCursor(c => Math.min(maxIdx, c + 1));
|
|
1922
|
+
if (isSelect) {
|
|
1923
|
+
if (listCursor < BACKUP_ACTIONS.length) {
|
|
1924
|
+
const ts = createBackup();
|
|
1925
|
+
setBackups(getBackups());
|
|
1926
|
+
setMessage(`✓ Backup created: ${ts}`);
|
|
1927
|
+
setListCursor(0);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
if (input === "d" && listCursor >= BACKUP_ACTIONS.length) {
|
|
1931
|
+
const b = backups[listCursor - BACKUP_ACTIONS.length];
|
|
1932
|
+
if (b && deleteBackup(b.ts)) {
|
|
1933
|
+
setBackups(getBackups());
|
|
1934
|
+
setMessage(`✓ Deleted: ${b.ts}`);
|
|
1935
|
+
setListCursor(Math.max(0, listCursor - 1));
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
// ─── List: System ───────────────────────
|
|
1941
|
+
else if (focus === "list" && section === "system") {
|
|
1942
|
+
const action = SYSTEM_ACTIONS[systemCursor]?.key;
|
|
1943
|
+
// Post-result screens: any key returns to list
|
|
1944
|
+
if (action === "enable" && enableResult) {
|
|
1945
|
+
if (key.return || input === " " || key.escape) {
|
|
1946
|
+
setEnableResult(null);
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
if (action === "uninstall" && uninstallStage === "done") {
|
|
1952
|
+
if (key.return || input === " " || key.escape) {
|
|
1953
|
+
setUninstallStage("warning");
|
|
1954
|
+
setUninstallResult(null);
|
|
1955
|
+
setUninstallTyped("");
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
// Uninstall typing stage
|
|
1961
|
+
if (action === "uninstall" && uninstallStage === "typing") {
|
|
1962
|
+
if (key.escape) { setUninstallStage("warning"); setUninstallTyped(""); return; }
|
|
1963
|
+
if (input === "k") { setUninstallKeepState(v => !v); return; }
|
|
1964
|
+
if (key.backspace || key.delete) { setUninstallTyped(s => s.slice(0, -1)); return; }
|
|
1965
|
+
if (key.return) {
|
|
1966
|
+
if (uninstallTyped === "UNINSTALL") {
|
|
1967
|
+
// Auto-backup first
|
|
1968
|
+
try { createBackup(); } catch {}
|
|
1969
|
+
const res = runUninstall(uninstallKeepState);
|
|
1970
|
+
setUninstallResult(res);
|
|
1971
|
+
setUninstallStage("done");
|
|
1972
|
+
setMessage("✓ Uninstall completed");
|
|
1973
|
+
}
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
if (input && input.length === 1 && /^[A-Za-z]$/.test(input) && uninstallTyped.length < 12) {
|
|
1977
|
+
setUninstallTyped(s => s + input.toUpperCase());
|
|
1978
|
+
}
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
// Disable confirm stage
|
|
1982
|
+
if (action === "disable" && disableResult) {
|
|
1983
|
+
if (key.return || input === " " || key.escape) {
|
|
1984
|
+
setDisableResult(null);
|
|
1985
|
+
setDisableConfirming(false);
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
if (action === "disable" && disableConfirming) {
|
|
1991
|
+
if (input === "y") {
|
|
1992
|
+
const res = disableBuddy();
|
|
1993
|
+
setDisableResult(res);
|
|
1994
|
+
setMessage("✓ Buddy disabled");
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
if (input === "n" || key.escape) { setDisableConfirming(false); return; }
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// Default navigation
|
|
2002
|
+
if (key.escape) setFocus("sidebar");
|
|
2003
|
+
if (input === "q") exit();
|
|
2004
|
+
if (key.upArrow) setSystemCursor(c => Math.max(0, c - 1));
|
|
2005
|
+
if (key.downArrow) setSystemCursor(c => Math.min(SYSTEM_ACTIONS.length - 1, c + 1));
|
|
2006
|
+
if (isSelect) {
|
|
2007
|
+
if (action === "enable") {
|
|
2008
|
+
setEnableRunning(true);
|
|
2009
|
+
setEnableResult(null);
|
|
2010
|
+
// Defer heavy execSync so the loading pane renders first
|
|
2011
|
+
setTimeout(() => {
|
|
2012
|
+
const res = runInstall();
|
|
2013
|
+
setEnableRunning(false);
|
|
2014
|
+
setEnableResult(res);
|
|
2015
|
+
setMessage(res.error ? "✗ Install failed" : "✓ Install completed");
|
|
2016
|
+
}, 50);
|
|
2017
|
+
} else if (action === "disable") {
|
|
2018
|
+
setDisableConfirming(true);
|
|
2019
|
+
} else if (action === "uninstall") {
|
|
2020
|
+
setUninstallStage("typing");
|
|
2021
|
+
setUninstallTyped("");
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// ─── Edit: Settings value ───────────────
|
|
2027
|
+
else if (focus === "edit") {
|
|
2028
|
+
const def = SETTING_DEFS[settCursor];
|
|
2029
|
+
if (key.escape) { setNumInput(""); setFocus("list"); }
|
|
2030
|
+
|
|
2031
|
+
if (def.type === "options") {
|
|
2032
|
+
if (key.upArrow) setOptCursor(c => Math.max(0, c - 1));
|
|
2033
|
+
if (key.downArrow) setOptCursor(c => Math.min(def.options!.length - 1, c + 1));
|
|
2034
|
+
if (isSelect) {
|
|
2035
|
+
const selected = def.options![optCursor];
|
|
2036
|
+
const val = selected === "true" ? true : selected === "false" ? false : selected;
|
|
2037
|
+
// Side-effect for statusLineEnabled: also patch settings.json
|
|
2038
|
+
if (def.key === "statusLineEnabled") {
|
|
2039
|
+
try {
|
|
2040
|
+
if (val === true) {
|
|
2041
|
+
const statusScript = join(PROJECT_ROOT, "statusline", "buddy-status.sh");
|
|
2042
|
+
setBuddyStatusLine(statusScript);
|
|
2043
|
+
} else {
|
|
2044
|
+
unsetBuddyStatusLine();
|
|
2045
|
+
}
|
|
2046
|
+
} catch (e: any) {
|
|
2047
|
+
setMessage(`✗ ${e?.message ?? "failed to patch settings.json"}`);
|
|
2048
|
+
setFocus("list");
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
setConfig(saveConfig({ [def.key]: val }));
|
|
2053
|
+
setMessage(`✓ ${def.label} → ${selected}`);
|
|
2054
|
+
setFocus("list");
|
|
2055
|
+
}
|
|
2056
|
+
} else {
|
|
2057
|
+
if (input >= "0" && input <= "9" && numInput.length < 6) setNumInput(prev => prev + input);
|
|
2058
|
+
if (key.backspace || key.delete) setNumInput(prev => prev.slice(0, -1));
|
|
2059
|
+
if (key.return) {
|
|
2060
|
+
const clamped = Math.max(def.min ?? 0, Number.parseInt(numInput || "0", 10));
|
|
2061
|
+
setConfig(saveConfig({ [def.key]: clamped }));
|
|
2062
|
+
setMessage(`✓ ${def.label} → ${clamped}`);
|
|
2063
|
+
setNumInput("");
|
|
2064
|
+
setFocus("list");
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
// ─── Build panes ────────────────────────────
|
|
2071
|
+
const showContent = focus !== "sidebar";
|
|
2072
|
+
let middlePane: React.ReactNode = null;
|
|
2073
|
+
let rightPane: React.ReactNode = null;
|
|
2074
|
+
|
|
2075
|
+
if (showContent) {
|
|
2076
|
+
if (section === "menagerie") {
|
|
2077
|
+
middlePane = <BuddyListPane slots={slots} cursor={listCursor} activeSlot={activeSlot} focused={focus === "list"} searchTerm={menagSearch} />;
|
|
2078
|
+
if (personalityEditing) {
|
|
2079
|
+
// Editing: the right-pane card follows the cursor (slots[listCursor]),
|
|
2080
|
+
// and shows editablePersonality so typing updates live.
|
|
2081
|
+
const entry = slots[listCursor];
|
|
2082
|
+
if (entry) {
|
|
2083
|
+
rightPane = <BuddyCardPane
|
|
2084
|
+
companion={entry.companion}
|
|
2085
|
+
slot={entry.slot}
|
|
2086
|
+
isActive={entry.slot === activeSlot}
|
|
2087
|
+
editablePersonality={personalityInput}
|
|
2088
|
+
editCursor={personalityCursor}
|
|
2089
|
+
/>;
|
|
2090
|
+
}
|
|
2091
|
+
} else {
|
|
2092
|
+
// Normal preview: cursor on edit button → show buddy above it
|
|
2093
|
+
const previewIdx = listCursor < slots.length ? listCursor : Math.max(0, slots.length - 1);
|
|
2094
|
+
if (slots[previewIdx]) {
|
|
2095
|
+
const { slot, companion } = slots[previewIdx];
|
|
2096
|
+
rightPane = <BuddyCardPane companion={companion} slot={slot} isActive={slot === activeSlot} />;
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
} else if (section === "settings") {
|
|
2100
|
+
middlePane = <SettingsListPane cursor={settCursor} config={config} focused={focus === "list"} />;
|
|
2101
|
+
rightPane = <SettingDetailPane settingIndex={settCursor} config={config} editing={focus === "edit"} numInput={numInput} optCursor={optCursor} />;
|
|
2102
|
+
} else if (section === "achievements") {
|
|
2103
|
+
middlePane = <AchievementsListPane cursor={listCursor} unlockedIds={unlockedIds} focused={focus === "list"} />;
|
|
2104
|
+
if (ACHIEVEMENTS[listCursor]) {
|
|
2105
|
+
rightPane = <AchievementDetailPane
|
|
2106
|
+
achievement={ACHIEVEMENTS[listCursor]}
|
|
2107
|
+
unlockedIds={unlockedIds}
|
|
2108
|
+
unlocked={achData.unlocked}
|
|
2109
|
+
events={achData.events}
|
|
2110
|
+
/>;
|
|
2111
|
+
}
|
|
2112
|
+
} else if (section === "verify") {
|
|
2113
|
+
middlePane = <VerifyPane userIdInput={verifyInput} editing={verifyEditing} preview={verifyPreview} buttonCursor={verifyButtonCursor} focused={focus === "list"} />;
|
|
2114
|
+
if (verifyPreview && !verifyEditing) {
|
|
2115
|
+
const syntheticCompanion: Companion = {
|
|
2116
|
+
bones: verifyPreview.bones,
|
|
2117
|
+
name: "Preview",
|
|
2118
|
+
personality: `A ${verifyPreview.bones.rarity} ${verifyPreview.bones.species} generated from user ID.`,
|
|
2119
|
+
hatchedAt: Date.now(),
|
|
2120
|
+
userId: verifyPreview.userId,
|
|
2121
|
+
};
|
|
2122
|
+
rightPane = <BuddyCardPane companion={syntheticCompanion} slot={verifyPreview.userId.slice(0, 8)} isActive={false} />;
|
|
2123
|
+
} else {
|
|
2124
|
+
rightPane = null;
|
|
2125
|
+
}
|
|
2126
|
+
} else if (section === "hunt") {
|
|
2127
|
+
if (huntPhase === "form" || huntPhase === "searching") {
|
|
2128
|
+
middlePane = <HuntFormPane criteria={huntCriteria} fieldCursor={huntFieldCursor} optCursors={huntOptCursors} focused={focus === "list" && huntPhase === "form"} />;
|
|
2129
|
+
rightPane = huntPhase === "searching"
|
|
2130
|
+
? <HuntProgressPane checked={huntChecked} maxAttempts={huntMaxAttempts(huntCriteria.rarity, huntCriteria.shiny)} found={huntResults.length} />
|
|
2131
|
+
: null;
|
|
2132
|
+
} else if (huntPhase === "results") {
|
|
2133
|
+
middlePane = <HuntResultsPane results={huntResults} cursor={huntResultCursor} focused={focus === "list"} />;
|
|
2134
|
+
const selected = huntResults[huntResultCursor];
|
|
2135
|
+
if (selected) {
|
|
2136
|
+
const synthetic: Companion = {
|
|
2137
|
+
bones: selected.bones,
|
|
2138
|
+
name: "(unnamed)",
|
|
2139
|
+
personality: `A ${selected.bones.rarity} ${selected.bones.species} waiting for a name.`,
|
|
2140
|
+
hatchedAt: Date.now(),
|
|
2141
|
+
userId: selected.userId,
|
|
2142
|
+
};
|
|
2143
|
+
rightPane = <BuddyCardPane companion={synthetic} slot={selected.userId.slice(0, 8)} isActive={false} />;
|
|
2144
|
+
} else {
|
|
2145
|
+
rightPane = null;
|
|
2146
|
+
}
|
|
2147
|
+
} else if (huntPhase === "naming") {
|
|
2148
|
+
middlePane = <HuntResultsPane results={huntResults} cursor={huntResultCursor} focused={false} />;
|
|
2149
|
+
rightPane = <HuntNamingPane nameInput={huntNameInput} chosenBones={huntResults[huntResultCursor].bones} />;
|
|
2150
|
+
}
|
|
2151
|
+
} else if (section === "doctor") {
|
|
2152
|
+
middlePane = <DoctorListPane categories={diagData} cursor={listCursor} focused={focus === "list"} />;
|
|
2153
|
+
rightPane = diagData[listCursor] ? <DoctorDetailPane category={diagData[listCursor]} /> : null;
|
|
2154
|
+
} else if (section === "backup") {
|
|
2155
|
+
middlePane = <BackupListPane backups={backups} cursor={listCursor} focused={focus === "list"} />;
|
|
2156
|
+
rightPane = <BackupDetailPane backups={backups} cursor={listCursor} />;
|
|
2157
|
+
} else if (section === "system") {
|
|
2158
|
+
middlePane = <SystemListPane cursor={systemCursor} focused={focus === "list"} />;
|
|
2159
|
+
const action = SYSTEM_ACTIONS[systemCursor]?.key;
|
|
2160
|
+
if (action === "enable") {
|
|
2161
|
+
rightPane = <EnableDetailPane result={enableResult} running={enableRunning} />;
|
|
2162
|
+
} else if (action === "disable") {
|
|
2163
|
+
rightPane = <DisableConfirmPane result={disableResult} confirming={disableConfirming} />;
|
|
2164
|
+
} else if (action === "uninstall") {
|
|
2165
|
+
rightPane = <UninstallDetailPane
|
|
2166
|
+
stage={uninstallStage}
|
|
2167
|
+
typed={uninstallTyped}
|
|
2168
|
+
result={uninstallResult}
|
|
2169
|
+
keepState={uninstallKeepState}
|
|
2170
|
+
/>;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
// ─── Footer ─────────────────────────────────
|
|
2176
|
+
const helpText =
|
|
2177
|
+
focus === "sidebar" ? "↑↓ navigate ⏎/␣ select q quit" :
|
|
2178
|
+
focus === "edit" ? (SETTING_DEFS[settCursor]?.type === "options"
|
|
2179
|
+
? "↑↓ navigate ⏎/␣ confirm esc back"
|
|
2180
|
+
: "type number ⏎ confirm esc back") :
|
|
2181
|
+
section === "menagerie" ? (
|
|
2182
|
+
personalityEditing
|
|
2183
|
+
? "type edit ←→ cursor ↑↓ switch buddy ⏎ save esc discard+exit"
|
|
2184
|
+
: "type to filter ↑↓ nav ⏎ summon/edit esc clear/back"
|
|
2185
|
+
) :
|
|
2186
|
+
section === "achievements" ? "↑↓ navigate esc back q quit" :
|
|
2187
|
+
section === "verify" ? (verifyEditing ? "type hex ⏎ generate esc cancel" : "↑↓ nav ⏎ activate esc back") :
|
|
2188
|
+
section === "hunt" ? (
|
|
2189
|
+
huntPhase === "form" ? "↑↓ field ⏎ cycle/start esc back" :
|
|
2190
|
+
huntPhase === "searching" ? "esc cancel" :
|
|
2191
|
+
huntPhase === "results" ? "↑↓ pick ⏎ choose esc back" :
|
|
2192
|
+
"type name ⏎ save esc cancel"
|
|
2193
|
+
) :
|
|
2194
|
+
section === "doctor" ? "↑↓ navigate esc back q quit" :
|
|
2195
|
+
section === "backup" ? "↑↓ navigate ⏎/␣ select d delete esc back q quit" :
|
|
2196
|
+
section === "system" ? (
|
|
2197
|
+
SYSTEM_ACTIONS[systemCursor]?.key === "uninstall" && uninstallStage === "typing"
|
|
2198
|
+
? "type UNINSTALL k toggle keep ⏎ confirm esc cancel"
|
|
2199
|
+
: SYSTEM_ACTIONS[systemCursor]?.key === "disable" && disableConfirming
|
|
2200
|
+
? "y confirm disable n cancel"
|
|
2201
|
+
: "↑↓ navigate ⏎ activate esc back"
|
|
2202
|
+
) :
|
|
2203
|
+
"↑↓ navigate ⏎/␣ select esc back q quit";
|
|
2204
|
+
|
|
2205
|
+
return (
|
|
2206
|
+
<Box flexDirection="column" height={rows}>
|
|
2207
|
+
<Box>
|
|
2208
|
+
<Text color="cyan" bold>{"─ claude-buddy "}{"─".repeat(Math.max(0, cols - 17))}</Text>
|
|
2209
|
+
</Box>
|
|
2210
|
+
<Box flexGrow={1}>
|
|
2211
|
+
<Box width={sidebarWidth} flexDirection="column" borderStyle="single" borderColor={focus === "sidebar" ? "cyan" : "gray"}>
|
|
2212
|
+
<Sidebar cursor={sidebarCursor} section={section} focus={focus} />
|
|
2213
|
+
</Box>
|
|
2214
|
+
{showContent ? (
|
|
2215
|
+
<>
|
|
2216
|
+
<Box width={middleWidth} flexDirection="column" borderStyle="single" borderColor={focus === "list" ? "cyan" : "gray"}>
|
|
2217
|
+
{middlePane}
|
|
2218
|
+
</Box>
|
|
2219
|
+
<Box flexGrow={1} flexDirection="column" borderStyle="single" borderColor={focus === "edit" ? "cyan" : "gray"}>
|
|
2220
|
+
{rightPane}
|
|
2221
|
+
</Box>
|
|
2222
|
+
</>
|
|
2223
|
+
) : (
|
|
2224
|
+
<Box flexGrow={1} flexDirection="column" borderStyle="single" borderColor="gray">
|
|
2225
|
+
<SidebarDescriptionPane cursor={sidebarCursor} />
|
|
2226
|
+
</Box>
|
|
2227
|
+
)}
|
|
2228
|
+
</Box>
|
|
2229
|
+
{message ? <Box><Text color="green" bold>{" "}{message}</Text></Box> : null}
|
|
2230
|
+
<Box>
|
|
2231
|
+
<Text dimColor>{"─ "}{helpText}{" "}{"─".repeat(Math.max(0, cols - helpText.length - 4))}</Text>
|
|
2232
|
+
</Box>
|
|
2233
|
+
</Box>
|
|
2234
|
+
);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// ─── Entry ──────────────────────────────────────────────────────────────────
|
|
2238
|
+
|
|
2239
|
+
if (!process.stdin.isTTY) {
|
|
2240
|
+
console.error("claude-buddy tui requires an interactive terminal (TTY)");
|
|
2241
|
+
process.exit(1);
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
render(<App />);
|