pi-incarnate 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +113 -21
- package/SECURITY.md +10 -3
- package/characters/README.md +5 -1
- package/characters/mira/CHARACTER.md +1 -1
- package/evals/persona-scenarios.json +104 -0
- package/extensions/index.ts +34 -17
- package/package.json +5 -1
- package/scripts/persona-eval.mjs +211 -0
- package/src/avatar-manager.ts +156 -0
- package/src/avatar.ts +93 -22
- package/src/character-bundle.ts +330 -0
- package/src/character-catalog.ts +69 -0
- package/src/character-editor.ts +199 -0
- package/src/character-lifecycle.ts +94 -0
- package/src/character-loader.ts +36 -12
- package/src/character-section-editor.ts +120 -0
- package/src/commands.ts +45 -20
- package/src/form-editor.ts +115 -0
- package/src/markdown.ts +12 -0
- package/src/menu.ts +752 -0
- package/src/mood.ts +1 -1
- package/src/persona.ts +3 -1
- package/src/select-menu.ts +70 -0
- package/src/session-state.ts +17 -5
- package/src/ui-protocol.ts +66 -0
package/src/menu.ts
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
installPersonalAvatar,
|
|
5
|
+
prepareAvatarImport,
|
|
6
|
+
removePersonalAvatars,
|
|
7
|
+
resolveAvatarSourcePath,
|
|
8
|
+
resolveUserPath,
|
|
9
|
+
} from "./avatar-manager.ts";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
characterSource,
|
|
13
|
+
discoverCharacterCatalog,
|
|
14
|
+
type CharacterCatalogEntry,
|
|
15
|
+
type CharacterLocations,
|
|
16
|
+
} from "./character-catalog.ts";
|
|
17
|
+
import {
|
|
18
|
+
CHARACTER_BUNDLE_SUFFIX,
|
|
19
|
+
installCharacterBundle,
|
|
20
|
+
prepareCharacterBundleImport,
|
|
21
|
+
writeCharacterBundle,
|
|
22
|
+
} from "./character-bundle.ts";
|
|
23
|
+
import {
|
|
24
|
+
archivePersonalCharacter,
|
|
25
|
+
discoverArchivedCharacters,
|
|
26
|
+
renamePersonalCharacter,
|
|
27
|
+
restoreArchivedCharacter,
|
|
28
|
+
} from "./character-lifecycle.ts";
|
|
29
|
+
import {
|
|
30
|
+
CharacterEditError,
|
|
31
|
+
createCharacterTemplate,
|
|
32
|
+
createPersonalCharacter,
|
|
33
|
+
createPersonalOverride,
|
|
34
|
+
isPersonalCharacter,
|
|
35
|
+
readCharacterCard,
|
|
36
|
+
readPersonalCharacterDraft,
|
|
37
|
+
updatePersonalCharacter,
|
|
38
|
+
} from "./character-editor.ts";
|
|
39
|
+
import {
|
|
40
|
+
GUIDED_CHARACTER_SECTIONS,
|
|
41
|
+
readCharacterSection,
|
|
42
|
+
sectionStarter,
|
|
43
|
+
updateCharacterName,
|
|
44
|
+
updateCharacterSection,
|
|
45
|
+
type GuidedCharacterSection,
|
|
46
|
+
} from "./character-section-editor.ts";
|
|
47
|
+
import { readFormDraft, savePersonalForm } from "./form-editor.ts";
|
|
48
|
+
import { discoverCharacters, isCharacterId, loadCharacter, type Character, type CharacterIssue } from "./character-loader.ts";
|
|
49
|
+
import { confirmMenu, selectMenu } from "./select-menu.ts";
|
|
50
|
+
import type { AvatarMode, IncarnateSessionState } from "./session-state.ts";
|
|
51
|
+
|
|
52
|
+
export interface IncarnateMenuDependencies {
|
|
53
|
+
locations: CharacterLocations;
|
|
54
|
+
state: IncarnateSessionState;
|
|
55
|
+
onStateChange?: (ctx: ExtensionCommandContext) => Promise<void> | void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type MainAction = "character" | "mood" | "avatar" | "manage" | "status" | "off" | "close";
|
|
59
|
+
type ManageAction = "add" | "edit" | "organize" | "back";
|
|
60
|
+
type AddAction = "create" | "repair" | "import" | "restore" | "back";
|
|
61
|
+
type EditAction = "edit-sections" | "edit-complete" | "avatar-file" | "forms" | "back";
|
|
62
|
+
type OrganizeAction = "rename" | "archive" | "export" | "back";
|
|
63
|
+
|
|
64
|
+
const SOURCE_LABELS = { personal: "personal", "built-in": "built-in" } as const;
|
|
65
|
+
|
|
66
|
+
function errorMessage(error: unknown, fallback: string): string {
|
|
67
|
+
return error instanceof Error ? error.message : fallback;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function statusText(state: IncarnateSessionState, locations: CharacterLocations): string {
|
|
71
|
+
const active = state.activeCharacter;
|
|
72
|
+
if (!active) return `Character mode: off\nAvatar: ${state.avatarMode}`;
|
|
73
|
+
return `Active character: ${active.name} (${active.id})\nSource: ${characterSource(active, locations)}\nMood: ${state.currentMood ?? "none"}\nAvatar: ${state.avatarMode}\nForms: ${active.forms.filter((form) => form.status === "available").length}/${active.forms.length} available\nCard: ${active.cardPath}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function chooseCharacter(
|
|
77
|
+
ctx: ExtensionCommandContext,
|
|
78
|
+
locations: CharacterLocations,
|
|
79
|
+
title: string,
|
|
80
|
+
): Promise<CharacterCatalogEntry | undefined> {
|
|
81
|
+
const { entries, issues } = await discoverCharacterCatalog(locations);
|
|
82
|
+
if (entries.length === 0) {
|
|
83
|
+
ctx.ui.notify("No valid characters found", "warning");
|
|
84
|
+
if (issues.length > 0) ctx.ui.notify(issues[0]!.message, "error");
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const labels = entries.map(({ character, source }) => `${character.name} (${character.id}) · ${SOURCE_LABELS[source]}`);
|
|
88
|
+
const selected = await selectMenu(ctx, title, [...labels, "← Back"]);
|
|
89
|
+
if (!selected || selected === "← Back") return undefined;
|
|
90
|
+
return entries[labels.indexOf(selected)];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function switchCharacter(ctx: ExtensionCommandContext, dependencies: IncarnateMenuDependencies): Promise<void> {
|
|
94
|
+
const selected = await chooseCharacter(ctx, dependencies.locations, "Choose a character");
|
|
95
|
+
if (!selected) return;
|
|
96
|
+
dependencies.state.activate(selected.character);
|
|
97
|
+
await dependencies.onStateChange?.(ctx);
|
|
98
|
+
ctx.ui.notify(`Character enabled: ${selected.character.name} (${selected.character.id})`, "info");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function chooseMood(ctx: ExtensionCommandContext, dependencies: IncarnateMenuDependencies): Promise<void> {
|
|
102
|
+
const active = dependencies.state.activeCharacter;
|
|
103
|
+
if (!active) {
|
|
104
|
+
ctx.ui.notify("Enable a character before selecting a mood", "warning");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const presets = [...active.mood.presets.values()];
|
|
108
|
+
if (presets.length === 0) {
|
|
109
|
+
ctx.ui.notify(`${active.name} has no mood presets`, "info");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const labels = presets.map((preset) => `${preset.id}${preset.id === dependencies.state.currentMood ? " · active" : ""}`);
|
|
113
|
+
const selected = await selectMenu(ctx, `Mood · ${active.name}`, [...labels, "← Back"]);
|
|
114
|
+
if (!selected || selected === "← Back") return;
|
|
115
|
+
const preset = presets[labels.indexOf(selected)];
|
|
116
|
+
if (!preset) return;
|
|
117
|
+
dependencies.state.setMood(preset.id);
|
|
118
|
+
await dependencies.onStateChange?.(ctx);
|
|
119
|
+
ctx.ui.notify(`Mood changed: ${preset.id}`, "info");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function chooseAvatarMode(ctx: ExtensionCommandContext, dependencies: IncarnateMenuDependencies): Promise<void> {
|
|
123
|
+
const options: Array<{ mode: AvatarMode; label: string }> = [
|
|
124
|
+
{ mode: "auto", label: "Auto · responsive portrait/status" },
|
|
125
|
+
{ mode: "full", label: "Full · prefer portrait preview" },
|
|
126
|
+
{ mode: "compact", label: "Compact · character status only" },
|
|
127
|
+
{ mode: "off", label: "Off · hide character UI" },
|
|
128
|
+
];
|
|
129
|
+
const labels = options.map(({ mode, label }) => `${label}${mode === dependencies.state.avatarMode ? " · active" : ""}`);
|
|
130
|
+
const selected = await selectMenu(ctx, "Avatar mode", [...labels, "← Back"]);
|
|
131
|
+
if (!selected || selected === "← Back") return;
|
|
132
|
+
const option = options[labels.indexOf(selected)];
|
|
133
|
+
if (!option) return;
|
|
134
|
+
dependencies.state.setAvatarMode(option.mode);
|
|
135
|
+
await dependencies.onStateChange?.(ctx);
|
|
136
|
+
ctx.ui.notify(`Avatar: ${option.mode}`, "info");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function promptForCharacterId(
|
|
140
|
+
ctx: ExtensionCommandContext,
|
|
141
|
+
locations: CharacterLocations,
|
|
142
|
+
name: string,
|
|
143
|
+
): Promise<string | undefined> {
|
|
144
|
+
const { entries } = await discoverCharacterCatalog(locations);
|
|
145
|
+
const existingIds = new Set(entries.map((entry) => entry.character.id));
|
|
146
|
+
const normalizedName = name
|
|
147
|
+
.normalize("NFKD")
|
|
148
|
+
.toLowerCase()
|
|
149
|
+
.replace(/[’']/g, "")
|
|
150
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
151
|
+
.replace(/^-+|-+$/g, "");
|
|
152
|
+
const base = isCharacterId(normalizedName) ? normalizedName : "character";
|
|
153
|
+
let suggestion = base;
|
|
154
|
+
let suffix = 2;
|
|
155
|
+
while (existingIds.has(suggestion)) suggestion = `${base}-${suffix++}`;
|
|
156
|
+
|
|
157
|
+
while (true) {
|
|
158
|
+
const value = await ctx.ui.input(`Character id · suggested: ${suggestion}`, "leave empty to use suggestion");
|
|
159
|
+
if (value === undefined) return undefined;
|
|
160
|
+
const raw = value.trim();
|
|
161
|
+
const id = raw
|
|
162
|
+
? raw.toLowerCase().replace(/[\s_]+/g, "-").replace(/^-+|-+$/g, "")
|
|
163
|
+
: suggestion;
|
|
164
|
+
if (!isCharacterId(id)) {
|
|
165
|
+
ctx.ui.notify("Use English letters or numbers for the id; spaces become hyphens. The character name may be Chinese.", "error");
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (existingIds.has(id)) {
|
|
169
|
+
ctx.ui.notify(`Character already exists: ${id}. Choose a character editing action instead.`, "error");
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (raw && raw !== id) ctx.ui.notify(`Character id normalized to: ${id}`, "info");
|
|
173
|
+
return id;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function promptForCharacterName(ctx: ExtensionCommandContext): Promise<string | undefined> {
|
|
178
|
+
while (true) {
|
|
179
|
+
const value = await ctx.ui.input("Character name", "Chinese and other languages are supported");
|
|
180
|
+
if (value === undefined) return undefined;
|
|
181
|
+
const name = value.trim().replace(/[\r\n]+/g, " ");
|
|
182
|
+
if (name) return name;
|
|
183
|
+
ctx.ui.notify("Character name must not be empty", "error");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function createCharacter(ctx: ExtensionCommandContext, dependencies: IncarnateMenuDependencies): Promise<void> {
|
|
188
|
+
const personalRoot = dependencies.locations.personalRoot;
|
|
189
|
+
if (!personalRoot) {
|
|
190
|
+
ctx.ui.notify("Personal character storage is not configured", "error");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const name = await promptForCharacterName(ctx);
|
|
194
|
+
if (!name) return;
|
|
195
|
+
const id = await promptForCharacterId(ctx, dependencies.locations, name);
|
|
196
|
+
if (!id) return;
|
|
197
|
+
const markdown = await ctx.ui.editor(`Create ${name} (${id})`, createCharacterTemplate(name));
|
|
198
|
+
if (markdown === undefined) return;
|
|
199
|
+
try {
|
|
200
|
+
const character = await createPersonalCharacter(personalRoot, id, markdown);
|
|
201
|
+
ctx.ui.notify(`Character created: ${character.name}\n${character.cardPath}`, "info");
|
|
202
|
+
if (await confirmMenu(ctx, "Enable character?", `Use ${character.name} in this session now?`)) {
|
|
203
|
+
dependencies.state.activate(character);
|
|
204
|
+
await dependencies.onStateChange?.(ctx);
|
|
205
|
+
}
|
|
206
|
+
} catch (error) {
|
|
207
|
+
ctx.ui.notify(errorMessage(error, "Failed to create character"), "error");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function saveEditedCharacter(
|
|
212
|
+
dependencies: IncarnateMenuDependencies,
|
|
213
|
+
selected: CharacterCatalogEntry,
|
|
214
|
+
markdown: string,
|
|
215
|
+
): Promise<Character> {
|
|
216
|
+
const personalRoot = dependencies.locations.personalRoot;
|
|
217
|
+
if (!personalRoot) throw new CharacterEditError("Personal character storage is not configured");
|
|
218
|
+
if (isPersonalCharacter(selected.character, personalRoot)) {
|
|
219
|
+
return await updatePersonalCharacter(personalRoot, selected.character.id, markdown);
|
|
220
|
+
}
|
|
221
|
+
return await createPersonalOverride(personalRoot, selected.character, markdown);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function editCharacter(ctx: ExtensionCommandContext, dependencies: IncarnateMenuDependencies): Promise<void> {
|
|
225
|
+
const selected = await chooseCharacter(ctx, dependencies.locations, "Edit character card");
|
|
226
|
+
if (!selected) return;
|
|
227
|
+
if (selected.source === "built-in") {
|
|
228
|
+
const confirmed = await confirmMenu(
|
|
229
|
+
ctx,
|
|
230
|
+
"Create personal copy?",
|
|
231
|
+
`${selected.character.name} is built in. Editing creates a personal override that survives package updates.`,
|
|
232
|
+
);
|
|
233
|
+
if (!confirmed) return;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const current = await readCharacterCard(selected.character);
|
|
237
|
+
const markdown = await ctx.ui.editor(`Edit ${selected.character.name} (${selected.character.id})`, current);
|
|
238
|
+
if (markdown === undefined || markdown === current) return;
|
|
239
|
+
const character = await saveEditedCharacter(dependencies, selected, markdown);
|
|
240
|
+
if (dependencies.state.activeCharacter?.id === character.id) {
|
|
241
|
+
dependencies.state.activate(character);
|
|
242
|
+
await dependencies.onStateChange?.(ctx);
|
|
243
|
+
}
|
|
244
|
+
ctx.ui.notify(`Character card saved: ${character.name}\n${character.cardPath}`, "info");
|
|
245
|
+
} catch (error) {
|
|
246
|
+
ctx.ui.notify(errorMessage(error, "Failed to edit character"), "error");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function editCharacterSections(
|
|
251
|
+
ctx: ExtensionCommandContext,
|
|
252
|
+
dependencies: IncarnateMenuDependencies,
|
|
253
|
+
): Promise<void> {
|
|
254
|
+
const selected = await chooseCharacter(ctx, dependencies.locations, "Edit character sections");
|
|
255
|
+
if (!selected) return;
|
|
256
|
+
if (selected.source === "built-in") {
|
|
257
|
+
const confirmed = await confirmMenu(
|
|
258
|
+
ctx,
|
|
259
|
+
"Create personal copy?",
|
|
260
|
+
`${selected.character.name} is built in. Editing creates a personal override that survives package updates.`,
|
|
261
|
+
);
|
|
262
|
+
if (!confirmed) return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const current = await readCharacterCard(selected.character);
|
|
267
|
+
const options: Array<{ action: "name" | GuidedCharacterSection | "back"; label: string }> = [
|
|
268
|
+
{ action: "name", label: `Display name · ${selected.character.name}` },
|
|
269
|
+
...GUIDED_CHARACTER_SECTIONS.map((name) => {
|
|
270
|
+
const draft = readCharacterSection(current, name);
|
|
271
|
+
const optionalStatus = name === "Current Mood" || name === "Tools and Forms"
|
|
272
|
+
? ` · ${draft.exists ? "configured" : "not configured"}`
|
|
273
|
+
: "";
|
|
274
|
+
return { action: name, label: `${name}${optionalStatus}` };
|
|
275
|
+
}),
|
|
276
|
+
{ action: "back", label: "← Back" },
|
|
277
|
+
];
|
|
278
|
+
const choice = await selectMenu(ctx, `Character sections · ${selected.character.name}`, options.map((option) => option.label));
|
|
279
|
+
if (!choice) return;
|
|
280
|
+
const action = options.find((option) => option.label === choice)?.action;
|
|
281
|
+
if (!action || action === "back") return;
|
|
282
|
+
|
|
283
|
+
let markdown: string;
|
|
284
|
+
let changedLabel: string;
|
|
285
|
+
if (action === "name") {
|
|
286
|
+
const name = await ctx.ui.input(`Display name · current: ${selected.character.name}`, "enter a new display name");
|
|
287
|
+
if (name === undefined) return;
|
|
288
|
+
markdown = updateCharacterName(current, name);
|
|
289
|
+
if (markdown === current.replace(/\r\n?/g, "\n")) return;
|
|
290
|
+
changedLabel = "Display name";
|
|
291
|
+
} else {
|
|
292
|
+
const draft = readCharacterSection(current, action);
|
|
293
|
+
const content = await ctx.ui.editor(
|
|
294
|
+
`Edit section · ${action}`,
|
|
295
|
+
draft.exists ? draft.content : sectionStarter(action),
|
|
296
|
+
);
|
|
297
|
+
if (content === undefined || (draft.exists && content === draft.content)) return;
|
|
298
|
+
markdown = updateCharacterSection(current, action, content);
|
|
299
|
+
changedLabel = action;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const character = await saveEditedCharacter(dependencies, selected, markdown);
|
|
303
|
+
await refreshEditedCharacter(ctx, dependencies, character);
|
|
304
|
+
ctx.ui.notify(`Character section saved: ${changedLabel}\n${character.cardPath}`, "info");
|
|
305
|
+
} catch (error) {
|
|
306
|
+
ctx.ui.notify(errorMessage(error, "Failed to edit character section"), "error");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function repairableIssue(issue: CharacterIssue): boolean {
|
|
311
|
+
return issue.code === "invalid-card" || issue.code === "missing-card";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function repairCharacter(ctx: ExtensionCommandContext, dependencies: IncarnateMenuDependencies): Promise<void> {
|
|
315
|
+
const personalRoot = dependencies.locations.personalRoot;
|
|
316
|
+
if (!personalRoot) {
|
|
317
|
+
ctx.ui.notify("Personal character storage is not configured", "error");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const issues = (await discoverCharacters(personalRoot)).issues.filter(repairableIssue);
|
|
321
|
+
if (issues.length === 0) {
|
|
322
|
+
ctx.ui.notify("No repairable personal character cards found", "info");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const labels = issues.map((issue) => `${issue.id} · ${issue.code}`);
|
|
327
|
+
const selected = await selectMenu(ctx, "Repair character card", [...labels, "← Back"]);
|
|
328
|
+
if (!selected || selected === "← Back") return;
|
|
329
|
+
const issue = issues[labels.indexOf(selected)];
|
|
330
|
+
if (!issue) return;
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const current = issue.code === "missing-card"
|
|
334
|
+
? createCharacterTemplate(issue.id)
|
|
335
|
+
: await readPersonalCharacterDraft(personalRoot, issue.id);
|
|
336
|
+
const markdown = await ctx.ui.editor(`Repair ${issue.id} · ${issue.code}`, current);
|
|
337
|
+
if (markdown === undefined || (issue.code !== "missing-card" && markdown === current)) return;
|
|
338
|
+
const character = await updatePersonalCharacter(personalRoot, issue.id, markdown);
|
|
339
|
+
if (dependencies.state.activeCharacter?.id === character.id) {
|
|
340
|
+
dependencies.state.activate(character);
|
|
341
|
+
await dependencies.onStateChange?.(ctx);
|
|
342
|
+
}
|
|
343
|
+
ctx.ui.notify(`Character card repaired: ${character.name}\n${character.cardPath}`, "info");
|
|
344
|
+
} catch (error) {
|
|
345
|
+
ctx.ui.notify(errorMessage(error, "Failed to repair character"), "error");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function personalCharacterForResources(
|
|
350
|
+
ctx: ExtensionCommandContext,
|
|
351
|
+
dependencies: IncarnateMenuDependencies,
|
|
352
|
+
selected: CharacterCatalogEntry,
|
|
353
|
+
): Promise<Character | undefined> {
|
|
354
|
+
const personalRoot = dependencies.locations.personalRoot;
|
|
355
|
+
if (!personalRoot) throw new CharacterEditError("Personal character storage is not configured");
|
|
356
|
+
if (selected.source === "personal") return selected.character;
|
|
357
|
+
const confirmed = await confirmMenu(
|
|
358
|
+
ctx,
|
|
359
|
+
"Create personal copy?",
|
|
360
|
+
`${selected.character.name} is built in. Avatar changes require a personal override that survives package updates.`,
|
|
361
|
+
);
|
|
362
|
+
if (!confirmed) return undefined;
|
|
363
|
+
const markdown = await readCharacterCard(selected.character);
|
|
364
|
+
return await createPersonalOverride(personalRoot, selected.character, markdown);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function refreshEditedCharacter(
|
|
368
|
+
ctx: ExtensionCommandContext,
|
|
369
|
+
dependencies: IncarnateMenuDependencies,
|
|
370
|
+
character: Character,
|
|
371
|
+
): Promise<void> {
|
|
372
|
+
if (dependencies.state.activeCharacter?.id !== character.id) return;
|
|
373
|
+
dependencies.state.activate(character);
|
|
374
|
+
await dependencies.onStateChange?.(ctx);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function manageAvatarFile(ctx: ExtensionCommandContext, dependencies: IncarnateMenuDependencies): Promise<void> {
|
|
378
|
+
const personalRoot = dependencies.locations.personalRoot;
|
|
379
|
+
if (!personalRoot) {
|
|
380
|
+
ctx.ui.notify("Personal character storage is not configured", "error");
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const selected = await chooseCharacter(ctx, dependencies.locations, "Manage character avatar");
|
|
384
|
+
if (!selected) return;
|
|
385
|
+
const action = await selectMenu(ctx, `Avatar file · ${selected.character.name}`, [
|
|
386
|
+
"Import avatar file",
|
|
387
|
+
"Remove avatar file",
|
|
388
|
+
"← Back",
|
|
389
|
+
]);
|
|
390
|
+
if (!action || action === "← Back") return;
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
if (action === "Import avatar file") {
|
|
394
|
+
const input = await ctx.ui.input("Avatar file", "path to a .ansi or .txt file; drag and drop is supported");
|
|
395
|
+
if (!input?.trim()) return;
|
|
396
|
+
const sourcePath = resolveAvatarSourcePath(input, ctx.cwd);
|
|
397
|
+
const prepared = await prepareAvatarImport(sourcePath);
|
|
398
|
+
const character = await personalCharacterForResources(ctx, dependencies, selected);
|
|
399
|
+
if (!character) return;
|
|
400
|
+
const targetPath = await installPersonalAvatar(personalRoot, character.id, prepared);
|
|
401
|
+
await refreshEditedCharacter(ctx, dependencies, character);
|
|
402
|
+
ctx.ui.notify(`Avatar imported: ${prepared.width}×${prepared.height}\n${targetPath}`, "info");
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const confirmed = await confirmMenu(ctx, "Remove avatar?", `Remove the personal avatar for ${selected.character.name}?`);
|
|
407
|
+
if (!confirmed) return;
|
|
408
|
+
const character = await personalCharacterForResources(ctx, dependencies, selected);
|
|
409
|
+
if (!character) return;
|
|
410
|
+
const removed = await removePersonalAvatars(personalRoot, character.id);
|
|
411
|
+
await refreshEditedCharacter(ctx, dependencies, character);
|
|
412
|
+
ctx.ui.notify(removed > 0 ? `Avatar removed: ${character.name}` : `${character.name} has no personal avatar`, "info");
|
|
413
|
+
} catch (error) {
|
|
414
|
+
ctx.ui.notify(errorMessage(error, "Failed to manage avatar"), "error");
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function manageForms(ctx: ExtensionCommandContext, dependencies: IncarnateMenuDependencies): Promise<void> {
|
|
419
|
+
const personalRoot = dependencies.locations.personalRoot;
|
|
420
|
+
if (!personalRoot) {
|
|
421
|
+
ctx.ui.notify("Personal character storage is not configured", "error");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const selected = await chooseCharacter(ctx, dependencies.locations, "Manage preference forms");
|
|
425
|
+
if (!selected) return;
|
|
426
|
+
if (selected.character.forms.length === 0) {
|
|
427
|
+
ctx.ui.notify("No forms declared. Add a Tools and Forms section to the character card first.", "info");
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const labels = selected.character.forms.map(
|
|
431
|
+
(form, index) => `${index + 1}. ${form.label} · ${form.status} · ${form.declaredPath}`,
|
|
432
|
+
);
|
|
433
|
+
const choice = await selectMenu(ctx, `Preference forms · ${selected.character.name}`, [...labels, "← Back"]);
|
|
434
|
+
if (!choice || choice === "← Back") return;
|
|
435
|
+
const form = selected.character.forms[labels.indexOf(choice)];
|
|
436
|
+
if (!form) return;
|
|
437
|
+
if (form.status === "invalid") {
|
|
438
|
+
ctx.ui.notify(`Cannot edit invalid form: ${form.reason ?? form.declaredPath}`, "error");
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
if (selected.source === "built-in") {
|
|
444
|
+
const confirmed = await confirmMenu(
|
|
445
|
+
ctx,
|
|
446
|
+
"Create personal copy?",
|
|
447
|
+
`${selected.character.name} is built in. Form changes require a personal override that survives package updates.`,
|
|
448
|
+
);
|
|
449
|
+
if (!confirmed) return;
|
|
450
|
+
}
|
|
451
|
+
const draft = await readFormDraft(selected.character.directory, form.declaredPath, form.label);
|
|
452
|
+
const content = await ctx.ui.editor(`Edit form · ${form.label}`, draft.content);
|
|
453
|
+
if (content === undefined || (draft.exists && content === draft.content)) return;
|
|
454
|
+
|
|
455
|
+
const character = selected.source === "personal"
|
|
456
|
+
? selected.character
|
|
457
|
+
: await createPersonalOverride(personalRoot, selected.character, await readCharacterCard(selected.character));
|
|
458
|
+
const targetPath = await savePersonalForm(personalRoot, character.id, form.declaredPath, content);
|
|
459
|
+
const refreshed = await loadCharacter(personalRoot, character.id);
|
|
460
|
+
await refreshEditedCharacter(ctx, dependencies, refreshed);
|
|
461
|
+
ctx.ui.notify(`Preference form saved: ${form.label}\n${targetPath}`, "info");
|
|
462
|
+
} catch (error) {
|
|
463
|
+
ctx.ui.notify(errorMessage(error, "Failed to edit preference form"), "error");
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function choosePersonalCharacter(
|
|
468
|
+
ctx: ExtensionCommandContext,
|
|
469
|
+
locations: CharacterLocations,
|
|
470
|
+
title: string,
|
|
471
|
+
): Promise<Character | undefined> {
|
|
472
|
+
const { entries } = await discoverCharacterCatalog(locations);
|
|
473
|
+
const characters = entries.filter((entry) => entry.source === "personal").map((entry) => entry.character);
|
|
474
|
+
if (characters.length === 0) {
|
|
475
|
+
ctx.ui.notify("No personal characters found", "info");
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
const labels = characters.map((character) => `${character.name} (${character.id})`);
|
|
479
|
+
const selected = await selectMenu(ctx, title, [...labels, "← Back"]);
|
|
480
|
+
if (!selected || selected === "← Back") return undefined;
|
|
481
|
+
return characters[labels.indexOf(selected)];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function normalizeCharacterId(value: string): string {
|
|
485
|
+
return value.trim().toLowerCase().replace(/[\s_]+/g, "-").replace(/^-+|-+$/g, "");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function renameCharacter(ctx: ExtensionCommandContext, dependencies: IncarnateMenuDependencies): Promise<void> {
|
|
489
|
+
const personalRoot = dependencies.locations.personalRoot;
|
|
490
|
+
if (!personalRoot) return;
|
|
491
|
+
const character = await choosePersonalCharacter(ctx, dependencies.locations, "Rename personal character");
|
|
492
|
+
if (!character) return;
|
|
493
|
+
const input = await ctx.ui.input("New character id", "lowercase letters, numbers, and interior hyphens");
|
|
494
|
+
if (input === undefined) return;
|
|
495
|
+
const nextId = normalizeCharacterId(input);
|
|
496
|
+
if (!isCharacterId(nextId)) {
|
|
497
|
+
ctx.ui.notify("Invalid character id", "error");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const { entries } = await discoverCharacterCatalog(dependencies.locations);
|
|
501
|
+
if (entries.some((entry) => entry.character.id === nextId && nextId !== character.id)) {
|
|
502
|
+
ctx.ui.notify(`Character id already exists: ${nextId}`, "error");
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (!(await confirmMenu(ctx, "Rename character?", `${character.id} → ${nextId}`))) return;
|
|
506
|
+
try {
|
|
507
|
+
const renamed = await renamePersonalCharacter(personalRoot, character.id, nextId);
|
|
508
|
+
if (dependencies.state.activeCharacter?.id === character.id) {
|
|
509
|
+
dependencies.state.activate(renamed);
|
|
510
|
+
await dependencies.onStateChange?.(ctx);
|
|
511
|
+
}
|
|
512
|
+
ctx.ui.notify(`Character renamed: ${renamed.name} (${renamed.id})`, "info");
|
|
513
|
+
} catch (error) {
|
|
514
|
+
ctx.ui.notify(errorMessage(error, "Failed to rename character"), "error");
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function archiveCharacter(ctx: ExtensionCommandContext, dependencies: IncarnateMenuDependencies): Promise<void> {
|
|
519
|
+
const personalRoot = dependencies.locations.personalRoot;
|
|
520
|
+
if (!personalRoot) return;
|
|
521
|
+
const character = await choosePersonalCharacter(ctx, dependencies.locations, "Archive personal character");
|
|
522
|
+
if (!character) return;
|
|
523
|
+
if (!(await confirmMenu(ctx, "Archive character?", `${character.name} will leave the active character list but can be restored.`))) return;
|
|
524
|
+
try {
|
|
525
|
+
const target = await archivePersonalCharacter(personalRoot, character.id);
|
|
526
|
+
if (dependencies.state.activeCharacter?.id === character.id) {
|
|
527
|
+
dependencies.state.deactivate();
|
|
528
|
+
await dependencies.onStateChange?.(ctx);
|
|
529
|
+
}
|
|
530
|
+
ctx.ui.notify(`Character archived: ${character.name}\n${target}`, "info");
|
|
531
|
+
} catch (error) {
|
|
532
|
+
ctx.ui.notify(errorMessage(error, "Failed to archive character"), "error");
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function restoreCharacter(ctx: ExtensionCommandContext, dependencies: IncarnateMenuDependencies): Promise<void> {
|
|
537
|
+
const personalRoot = dependencies.locations.personalRoot;
|
|
538
|
+
if (!personalRoot) return;
|
|
539
|
+
let characters: Character[];
|
|
540
|
+
let issues: CharacterIssue[];
|
|
541
|
+
try {
|
|
542
|
+
({ characters, issues } = await discoverArchivedCharacters(personalRoot));
|
|
543
|
+
} catch (error) {
|
|
544
|
+
ctx.ui.notify(errorMessage(error, "Failed to inspect character archive"), "error");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (characters.length === 0) {
|
|
548
|
+
ctx.ui.notify(issues.length > 0 ? "No valid archived characters found" : "Character archive is empty", "info");
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const labels = characters.map((character) => `${character.name} (${character.id})`);
|
|
552
|
+
const selected = await selectMenu(ctx, "Restore archived character", [...labels, "← Back"]);
|
|
553
|
+
if (!selected || selected === "← Back") return;
|
|
554
|
+
const archived = characters[labels.indexOf(selected)];
|
|
555
|
+
if (!archived) return;
|
|
556
|
+
try {
|
|
557
|
+
const restored = await restoreArchivedCharacter(personalRoot, archived.id);
|
|
558
|
+
ctx.ui.notify(`Character restored: ${restored.name} (${restored.id})`, "info");
|
|
559
|
+
if (await confirmMenu(ctx, "Enable character?", `Use ${restored.name} in this session now?`)) {
|
|
560
|
+
dependencies.state.activate(restored);
|
|
561
|
+
await dependencies.onStateChange?.(ctx);
|
|
562
|
+
}
|
|
563
|
+
} catch (error) {
|
|
564
|
+
ctx.ui.notify(errorMessage(error, "Failed to restore character"), "error");
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function exportCharacterBundle(
|
|
569
|
+
ctx: ExtensionCommandContext,
|
|
570
|
+
dependencies: IncarnateMenuDependencies,
|
|
571
|
+
): Promise<void> {
|
|
572
|
+
const selected = await chooseCharacter(ctx, dependencies.locations, "Export character package");
|
|
573
|
+
if (!selected) return;
|
|
574
|
+
const filename = `${selected.character.id}${CHARACTER_BUNDLE_SUFFIX}`;
|
|
575
|
+
const input = await ctx.ui.input(`Export file · default: ${filename}`, "leave empty to use the current directory");
|
|
576
|
+
if (input === undefined) return;
|
|
577
|
+
const availableForms = selected.character.forms.filter((form) => form.status === "available").length;
|
|
578
|
+
try {
|
|
579
|
+
const targetPath = resolveUserPath(input.trim() || filename, ctx.cwd);
|
|
580
|
+
const confirmed = await confirmMenu(
|
|
581
|
+
ctx,
|
|
582
|
+
"Export character package?",
|
|
583
|
+
`Includes the character card, preferred avatar, and ${availableForms} available declared form(s). Preference forms may contain private information.`,
|
|
584
|
+
);
|
|
585
|
+
if (!confirmed) return;
|
|
586
|
+
const summary = await writeCharacterBundle(selected.character, targetPath);
|
|
587
|
+
ctx.ui.notify(
|
|
588
|
+
`Character package exported: ${summary.name}\nAvatar: ${summary.avatarIncluded ? "included" : "none"} · Forms: ${summary.formsIncluded}/${summary.formsDeclared}\n${targetPath}`,
|
|
589
|
+
"info",
|
|
590
|
+
);
|
|
591
|
+
} catch (error) {
|
|
592
|
+
ctx.ui.notify(errorMessage(error, "Failed to export character package"), "error");
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function importCharacterBundle(
|
|
597
|
+
ctx: ExtensionCommandContext,
|
|
598
|
+
dependencies: IncarnateMenuDependencies,
|
|
599
|
+
): Promise<void> {
|
|
600
|
+
const personalRoot = dependencies.locations.personalRoot;
|
|
601
|
+
if (!personalRoot) {
|
|
602
|
+
ctx.ui.notify("Personal character storage is not configured", "error");
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const input = await ctx.ui.input("Character package", `path to a *${CHARACTER_BUNDLE_SUFFIX} file`);
|
|
606
|
+
if (!input?.trim()) return;
|
|
607
|
+
try {
|
|
608
|
+
const sourcePath = resolveUserPath(input, ctx.cwd);
|
|
609
|
+
const bundle = await prepareCharacterBundleImport(sourcePath);
|
|
610
|
+
const confirmed = await confirmMenu(
|
|
611
|
+
ctx,
|
|
612
|
+
"Import character package?",
|
|
613
|
+
`${bundle.name} (${bundle.id}) · Avatar: ${bundle.avatar ? "included" : "none"} · Forms: ${bundle.forms.length}. Existing personal characters are never overwritten.`,
|
|
614
|
+
);
|
|
615
|
+
if (!confirmed) return;
|
|
616
|
+
const character = await installCharacterBundle(personalRoot, bundle);
|
|
617
|
+
ctx.ui.notify(`Character package imported: ${character.name} (${character.id})\n${character.directory}`, "info");
|
|
618
|
+
if (dependencies.state.activeCharacter?.id === character.id) {
|
|
619
|
+
dependencies.state.activate(character);
|
|
620
|
+
await dependencies.onStateChange?.(ctx);
|
|
621
|
+
} else if (await confirmMenu(ctx, "Enable character?", `Use ${character.name} in this session now?`)) {
|
|
622
|
+
dependencies.state.activate(character);
|
|
623
|
+
await dependencies.onStateChange?.(ctx);
|
|
624
|
+
}
|
|
625
|
+
} catch (error) {
|
|
626
|
+
ctx.ui.notify(errorMessage(error, "Failed to import character package"), "error");
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function manageAddCharacter(
|
|
631
|
+
ctx: ExtensionCommandContext,
|
|
632
|
+
dependencies: IncarnateMenuDependencies,
|
|
633
|
+
): Promise<void> {
|
|
634
|
+
const options: Array<{ action: AddAction; label: string }> = [
|
|
635
|
+
{ action: "create", label: "Create character card" },
|
|
636
|
+
{ action: "repair", label: "Repair invalid character card" },
|
|
637
|
+
{ action: "import", label: "Import character package" },
|
|
638
|
+
{ action: "restore", label: "Restore archived character" },
|
|
639
|
+
{ action: "back", label: "← Back" },
|
|
640
|
+
];
|
|
641
|
+
const selected = await selectMenu(ctx, "Add or restore character", options.map((option) => option.label));
|
|
642
|
+
if (!selected) return;
|
|
643
|
+
const action = options.find((option) => option.label === selected)?.action;
|
|
644
|
+
if (!action || action === "back") return;
|
|
645
|
+
if (action === "create") await createCharacter(ctx, dependencies);
|
|
646
|
+
if (action === "repair") await repairCharacter(ctx, dependencies);
|
|
647
|
+
if (action === "import") await importCharacterBundle(ctx, dependencies);
|
|
648
|
+
if (action === "restore") await restoreCharacter(ctx, dependencies);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function manageCharacterEditing(
|
|
652
|
+
ctx: ExtensionCommandContext,
|
|
653
|
+
dependencies: IncarnateMenuDependencies,
|
|
654
|
+
): Promise<void> {
|
|
655
|
+
const options: Array<{ action: EditAction; label: string }> = [
|
|
656
|
+
{ action: "edit-sections", label: "Edit character sections" },
|
|
657
|
+
{ action: "edit-complete", label: "Edit complete character card" },
|
|
658
|
+
{ action: "avatar-file", label: "Manage character avatar" },
|
|
659
|
+
{ action: "forms", label: "Manage preference forms" },
|
|
660
|
+
{ action: "back", label: "← Back" },
|
|
661
|
+
];
|
|
662
|
+
const selected = await selectMenu(ctx, "Edit character", options.map((option) => option.label));
|
|
663
|
+
if (!selected) return;
|
|
664
|
+
const action = options.find((option) => option.label === selected)?.action;
|
|
665
|
+
if (!action || action === "back") return;
|
|
666
|
+
if (action === "edit-sections") await editCharacterSections(ctx, dependencies);
|
|
667
|
+
if (action === "edit-complete") await editCharacter(ctx, dependencies);
|
|
668
|
+
if (action === "avatar-file") await manageAvatarFile(ctx, dependencies);
|
|
669
|
+
if (action === "forms") await manageForms(ctx, dependencies);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function manageCharacterOrganization(
|
|
673
|
+
ctx: ExtensionCommandContext,
|
|
674
|
+
dependencies: IncarnateMenuDependencies,
|
|
675
|
+
): Promise<void> {
|
|
676
|
+
const options: Array<{ action: OrganizeAction; label: string }> = [
|
|
677
|
+
{ action: "rename", label: "Rename personal character" },
|
|
678
|
+
{ action: "archive", label: "Archive personal character" },
|
|
679
|
+
{ action: "export", label: "Export character package" },
|
|
680
|
+
{ action: "back", label: "← Back" },
|
|
681
|
+
];
|
|
682
|
+
const selected = await selectMenu(ctx, "Manage or export character", options.map((option) => option.label));
|
|
683
|
+
if (!selected) return;
|
|
684
|
+
const action = options.find((option) => option.label === selected)?.action;
|
|
685
|
+
if (!action || action === "back") return;
|
|
686
|
+
if (action === "rename") await renameCharacter(ctx, dependencies);
|
|
687
|
+
if (action === "archive") await archiveCharacter(ctx, dependencies);
|
|
688
|
+
if (action === "export") await exportCharacterBundle(ctx, dependencies);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function manageCharacterResources(
|
|
692
|
+
ctx: ExtensionCommandContext,
|
|
693
|
+
dependencies: IncarnateMenuDependencies,
|
|
694
|
+
): Promise<void> {
|
|
695
|
+
const options: Array<{ action: ManageAction; label: string }> = [
|
|
696
|
+
{ action: "add", label: "Add or restore character" },
|
|
697
|
+
{ action: "edit", label: "Edit character" },
|
|
698
|
+
{ action: "organize", label: "Manage or export character" },
|
|
699
|
+
{ action: "back", label: "← Main menu" },
|
|
700
|
+
];
|
|
701
|
+
const selected = await selectMenu(ctx, "Character library", options.map((option) => option.label));
|
|
702
|
+
if (!selected) return;
|
|
703
|
+
const action = options.find((option) => option.label === selected)?.action;
|
|
704
|
+
if (!action || action === "back") return;
|
|
705
|
+
if (action === "add") await manageAddCharacter(ctx, dependencies);
|
|
706
|
+
if (action === "edit") await manageCharacterEditing(ctx, dependencies);
|
|
707
|
+
if (action === "organize") await manageCharacterOrganization(ctx, dependencies);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function mainActions(state: IncarnateSessionState): Array<{ action: MainAction; label: string }> {
|
|
711
|
+
const active = state.activeCharacter;
|
|
712
|
+
const actions: Array<{ action: MainAction; label: string }> = [
|
|
713
|
+
{ action: "character", label: active ? `Change character · ${active.name}` : "Choose character" },
|
|
714
|
+
];
|
|
715
|
+
if (active?.mood.presets.size) {
|
|
716
|
+
actions.push({ action: "mood", label: `Choose mood${state.currentMood ? ` · ${state.currentMood}` : ""}` });
|
|
717
|
+
}
|
|
718
|
+
if (active) {
|
|
719
|
+
actions.push({ action: "avatar", label: `Avatar mode · ${state.avatarMode}` });
|
|
720
|
+
}
|
|
721
|
+
actions.push(
|
|
722
|
+
{ action: "manage", label: "Character library" },
|
|
723
|
+
{ action: "status", label: "Show status" },
|
|
724
|
+
);
|
|
725
|
+
if (active) actions.push({ action: "off", label: "Disable active character" });
|
|
726
|
+
actions.push({ action: "close", label: "Close menu" });
|
|
727
|
+
return actions;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export async function openIncarnateMenu(
|
|
731
|
+
ctx: ExtensionCommandContext,
|
|
732
|
+
dependencies: IncarnateMenuDependencies,
|
|
733
|
+
): Promise<void> {
|
|
734
|
+
while (true) {
|
|
735
|
+
const options = mainActions(dependencies.state);
|
|
736
|
+
const selected = await selectMenu(ctx, "pi-incarnate", options.map((option) => option.label));
|
|
737
|
+
if (!selected) return;
|
|
738
|
+
const action = options.find((option) => option.label === selected)?.action;
|
|
739
|
+
if (!action || action === "close") return;
|
|
740
|
+
if (action === "character") await switchCharacter(ctx, dependencies);
|
|
741
|
+
if (action === "mood") await chooseMood(ctx, dependencies);
|
|
742
|
+
if (action === "avatar") await chooseAvatarMode(ctx, dependencies);
|
|
743
|
+
if (action === "manage") await manageCharacterResources(ctx, dependencies);
|
|
744
|
+
if (action === "status") ctx.ui.notify(statusText(dependencies.state, dependencies.locations), "info");
|
|
745
|
+
if (action === "off") {
|
|
746
|
+
const previous = dependencies.state.activeCharacter;
|
|
747
|
+
dependencies.state.deactivate();
|
|
748
|
+
await dependencies.onStateChange?.(ctx);
|
|
749
|
+
ctx.ui.notify(previous ? `Character disabled: ${previous.name}` : "Character mode is already off", "info");
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|