drexler 0.1.1 → 0.2.1

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/src/commands.ts CHANGED
@@ -1,30 +1,52 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { existsSync, writeFileSync } from "node:fs";
2
3
  import { resolve as pathResolve } from "node:path";
3
4
  import { resolveModel } from "./config.ts";
4
5
  import type { Conversation } from "./conversation.ts";
5
- import { error } from "./renderer.ts";
6
- import type { Config } from "./types.ts";
6
+ import { error, resetMarkedTheme } from "./renderer.ts";
7
+ import { THEME_NAMES, type Config, type ThemeName } from "./types.ts";
8
+ import {
9
+ getActiveTheme,
10
+ isThemeName,
11
+ setActiveTheme,
12
+ THEMES,
13
+ } from "./ui/themes.ts";
7
14
 
8
15
  export type CommandAction =
9
- | { type: "continue" }
16
+ | { type: "continue"; persistConfig?: Partial<Config> }
10
17
  | { type: "exit"; message?: string }
11
- | { type: "regenerate" };
18
+ | { type: "regenerate"; instruction?: string; removedAssistant: boolean };
12
19
 
13
20
  interface CommandContext {
14
21
  conversation: Conversation;
15
22
  config: Config;
16
23
  print: (s: string) => void;
24
+ copyToClipboard?: (text: string) => ClipboardResult;
17
25
  }
18
26
 
27
+ type ClipboardResult =
28
+ | { ok: true; command: string }
29
+ | { ok: false; reason: string };
30
+
19
31
  const HELP_TEXT = `New memo to staff! Drexler permit following directives:
20
32
  /help - this memo
21
33
  /clear - shred all documents (reset history)
22
34
  /exit - meeting adjourned
23
35
  /synergy - SYNERGY!
24
36
  /model - show or switch model (e.g. /model 26b)
37
+ /theme - show or switch theme (${THEME_NAMES.join(", ")})
38
+ /startup - persist startup mode (fast, no-intro, normal)
25
39
  /history - count messages and approximate tokens
26
40
  /regenerate - re-roll Drexler's last response
27
- /save [path] - archive conversation to markdown file`;
41
+ /redo - alias for /regenerate
42
+ /retry [style] - re-roll last response, optionally terse or brutal
43
+ /expand - print Drexler's latest response
44
+ /quote - quote Drexler's latest response
45
+ /search <term> - search this meeting transcript
46
+ /export <fmt> [path] - export as md, txt, json, or html
47
+ /save [path] - archive conversation to markdown file
48
+ /save-last [path] - save Drexler's last response
49
+ /copy-last - copy Drexler's last response to clipboard`;
28
50
 
29
51
  const WHITESPACE_RE = /\s+/;
30
52
 
@@ -39,9 +61,19 @@ export const COMMAND_PALETTE: ReadonlyArray<SlashCommand> = [
39
61
  { name: "/exit", description: "Adjourn meeting" },
40
62
  { name: "/synergy", description: "SYNERGY!" },
41
63
  { name: "/model", description: "Show or switch model" },
64
+ { name: "/theme", description: "Show or switch theme" },
65
+ { name: "/startup", description: "Persist startup mode" },
42
66
  { name: "/history", description: "Message + token count" },
43
67
  { name: "/regenerate", description: "Re-roll last response" },
68
+ { name: "/redo", description: "Alias for regenerate" },
69
+ { name: "/retry", description: "Retry terse or brutal" },
70
+ { name: "/expand", description: "Print last response" },
71
+ { name: "/quote", description: "Quote last response" },
72
+ { name: "/search", description: "Search transcript" },
73
+ { name: "/export", description: "Export md, txt, json, or html" },
44
74
  { name: "/save", description: "Archive conversation as markdown" },
75
+ { name: "/save-last", description: "Save last Drexler response" },
76
+ { name: "/copy-last", description: "Copy last response" },
45
77
  ];
46
78
 
47
79
  export function filterPaletteByPrefix(
@@ -110,6 +142,12 @@ export function dispatch(input: string, ctx: CommandContext): CommandAction {
110
142
  handleModel(args, ctx);
111
143
  return { type: "continue" };
112
144
 
145
+ case "theme":
146
+ return handleTheme(args, ctx);
147
+
148
+ case "startup":
149
+ return handleStartup(args, ctx);
150
+
113
151
  case "history":
114
152
  ctx.print(
115
153
  `Drexler ledger: ${ctx.conversation.length} message${
@@ -126,36 +164,50 @@ export function dispatch(input: string, ctx: CommandContext): CommandAction {
126
164
  ctx.print("Drexler need input first. State concern.");
127
165
  return { type: "continue" };
128
166
  }
129
- ctx.conversation.popLastAssistant();
167
+ if (name === "retry" && args.length > 0) {
168
+ const style = args[0]?.toLowerCase();
169
+ if (style === "terse" || style === "brutal") {
170
+ const instruction =
171
+ style === "terse"
172
+ ? "Regenerate the previous answer. Make it terse, direct, and no longer than two sentences."
173
+ : "Regenerate the previous answer. Make it sharper, more skeptical, and more forceful while staying useful.";
174
+ const removedAssistant = ctx.conversation.popLastAssistant();
175
+ ctx.print(`Drexler reconsidering. Style mandate: ${style}.`);
176
+ return { type: "regenerate", instruction, removedAssistant };
177
+ }
178
+ ctx.print(error(`Unknown retry style: ${args[0]}. Use terse or brutal.`));
179
+ return { type: "continue" };
180
+ }
181
+ const removedAssistant = ctx.conversation.popLastAssistant();
130
182
  ctx.print("Drexler reconsidering. Stand by.");
131
- return { type: "regenerate" };
183
+ return { type: "regenerate", removedAssistant };
132
184
  }
133
185
 
186
+ case "expand":
187
+ handleExpand(ctx);
188
+ return { type: "continue" };
189
+
190
+ case "quote":
191
+ handleQuote(ctx);
192
+ return { type: "continue" };
193
+
194
+ case "search":
195
+ handleSearch(commandRemainder(input, name), ctx);
196
+ return { type: "continue" };
197
+
198
+ case "export":
199
+ handleExport(input, args, ctx);
200
+ return { type: "continue" };
201
+
134
202
  case "save": {
135
203
  const pathArg = stripMatchingQuotes(commandRemainder(input, name));
136
- if (pathArg && pathArg.split(/[/\\]/).includes("..")) {
137
- ctx.print(
138
- error(`Invalid path: ${pathArg} (no '..' segments allowed).`),
139
- );
140
- return { type: "continue" };
141
- }
142
- const target = pathArg
143
- ? pathResolve(pathArg)
144
- : pathResolve(`drexler-${Date.now()}.md`);
145
- if (!target.toLowerCase().endsWith(".md")) {
146
- ctx.print(error(`Save target must end in .md: ${target}`));
147
- return { type: "continue" };
148
- }
149
- if (existsSync(target)) {
150
- ctx.print(
151
- error(
152
- `File exists: ${target}. Refuse to overwrite. Use a different path.`,
153
- ),
154
- );
155
- return { type: "continue" };
156
- }
204
+ const target = resolveWriteTarget(pathArg, "drexler", ".md", ctx);
205
+ if (!target) return { type: "continue" };
157
206
  try {
158
- writeFileSync(target, formatConversationAsMarkdown(ctx.conversation));
207
+ writeFileSync(
208
+ target,
209
+ formatConversationAsMarkdown(ctx.conversation, ctx.config),
210
+ );
159
211
  ctx.print(`Drexler archive sealed: ${target}`);
160
212
  } catch (e) {
161
213
  const msg = e instanceof Error ? e.message : String(e);
@@ -164,6 +216,16 @@ export function dispatch(input: string, ctx: CommandContext): CommandAction {
164
216
  return { type: "continue" };
165
217
  }
166
218
 
219
+ case "save-last": {
220
+ handleSaveLast(commandRemainder(input, name), ctx);
221
+ return { type: "continue" };
222
+ }
223
+
224
+ case "copy-last": {
225
+ handleCopyLast(ctx);
226
+ return { type: "continue" };
227
+ }
228
+
167
229
  default:
168
230
  ctx.print(
169
231
  "Drexler not recognize that corporate directive. Try /help.",
@@ -172,13 +234,51 @@ export function dispatch(input: string, ctx: CommandContext): CommandAction {
172
234
  }
173
235
  }
174
236
 
175
- function formatConversationAsMarkdown(conv: Conversation): string {
237
+ function resolveWriteTarget(
238
+ pathArg: string,
239
+ defaultPrefix: string,
240
+ requiredExt: string,
241
+ ctx: CommandContext,
242
+ ): string | null {
243
+ if (pathArg && pathArg.split(/[/\\]/).includes("..")) {
244
+ ctx.print(error(`Invalid path: ${pathArg} (no '..' segments allowed).`));
245
+ return null;
246
+ }
247
+ const target = pathArg
248
+ ? pathResolve(pathArg)
249
+ : pathResolve(`${defaultPrefix}-${Date.now()}${requiredExt}`);
250
+ if (!target.toLowerCase().endsWith(requiredExt)) {
251
+ ctx.print(error(`Target must end in ${requiredExt}: ${target}`));
252
+ return null;
253
+ }
254
+ if (existsSync(target)) {
255
+ ctx.print(
256
+ error(`File exists: ${target}. Refuse to overwrite. Use a different path.`),
257
+ );
258
+ return null;
259
+ }
260
+ return target;
261
+ }
262
+
263
+ function exportMetadata(conv: Conversation, config: Config): string[] {
264
+ return [
265
+ `Saved: ${new Date().toISOString()}`,
266
+ `Messages: ${conv.length}`,
267
+ `Approx tokens: ${conv.approximateTokens()}`,
268
+ `Model: ${config.model}`,
269
+ `Theme: ${config.theme ?? currentThemeName(config)}`,
270
+ ];
271
+ }
272
+
273
+ function formatConversationAsMarkdown(
274
+ conv: Conversation,
275
+ config: Config,
276
+ ): string {
176
277
  const snap = conv.snapshot();
177
278
  const lines: string[] = [
178
279
  `# Drexler Conversation`,
179
280
  ``,
180
- `Saved: ${new Date().toISOString()}`,
181
- `Messages: ${conv.length}`,
281
+ ...exportMetadata(conv, config),
182
282
  ``,
183
283
  `---`,
184
284
  ``,
@@ -191,6 +291,310 @@ function formatConversationAsMarkdown(conv: Conversation): string {
191
291
  return lines.join("\n");
192
292
  }
193
293
 
294
+ function formatConversationAsText(conv: Conversation, config: Config): string {
295
+ const snap = conv.snapshot();
296
+ const lines: string[] = [
297
+ "Drexler Conversation",
298
+ ...exportMetadata(conv, config),
299
+ "",
300
+ ];
301
+ for (const m of snap) {
302
+ if (m.role === "system") continue;
303
+ const heading = m.role === "user" ? "You" : "Drexler";
304
+ lines.push(`[${heading}]`, m.content, "");
305
+ }
306
+ return lines.join("\n");
307
+ }
308
+
309
+ function formatConversationAsJson(conv: Conversation, config: Config): string {
310
+ return JSON.stringify(
311
+ {
312
+ exportedAt: new Date().toISOString(),
313
+ messageCount: conv.length,
314
+ approximateTokens: conv.approximateTokens(),
315
+ model: config.model,
316
+ theme: config.theme ?? currentThemeName(config),
317
+ messages: conv.snapshot().filter((m) => m.role !== "system"),
318
+ },
319
+ null,
320
+ 2,
321
+ );
322
+ }
323
+
324
+ function escapeHtml(input: string): string {
325
+ return input
326
+ .replaceAll("&", "&amp;")
327
+ .replaceAll("<", "&lt;")
328
+ .replaceAll(">", "&gt;")
329
+ .replaceAll('"', "&quot;")
330
+ .replaceAll("'", "&#39;");
331
+ }
332
+
333
+ function formatConversationAsHtml(conv: Conversation, config: Config): string {
334
+ const exportedAt = new Date().toISOString();
335
+ const theme = config.theme ?? currentThemeName(config);
336
+ const rows = conv
337
+ .snapshot()
338
+ .filter((m) => m.role !== "system")
339
+ .map((m) => {
340
+ const label = m.role === "user" ? "You" : "Drexler";
341
+ return `<article class="message ${m.role}"><h2>${label}</h2><div>${escapeHtml(
342
+ m.content,
343
+ ).replaceAll("\n", "<br>")}</div></article>`;
344
+ })
345
+ .join("\n");
346
+
347
+ return `<!doctype html>
348
+ <html lang="en">
349
+ <head>
350
+ <meta charset="utf-8">
351
+ <meta name="viewport" content="width=device-width, initial-scale=1">
352
+ <title>Drexler Conversation</title>
353
+ <style>
354
+ :root { color-scheme: dark; --bg: #101216; --panel: #171a21; --line: #303846; --text: #f6f1e8; --muted: #aeb6c4; --gold: #d6b25e; --blue: #8ab4f8; }
355
+ * { box-sizing: border-box; }
356
+ body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.55 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
357
+ main { max-width: 920px; margin: 0 auto; padding: 44px 24px 56px; }
358
+ header { border-bottom: 1px solid var(--line); margin-bottom: 28px; padding-bottom: 18px; }
359
+ h1 { margin: 0 0 8px; font-size: 34px; letter-spacing: 0; }
360
+ .meta { color: var(--muted); display: flex; flex-wrap: wrap; gap: 8px 16px; font-size: 13px; }
361
+ .message { border: 1px solid var(--line); border-radius: 8px; padding: 18px 20px; margin: 16px 0; background: var(--panel); break-inside: avoid; }
362
+ .message h2 { margin: 0 0 10px; font-size: 13px; letter-spacing: .08em; text-transform: uppercase; color: var(--gold); }
363
+ .message.user h2 { color: var(--blue); }
364
+ @media print {
365
+ :root { color-scheme: light; --bg: #ffffff; --panel: #ffffff; --line: #d7dce5; --text: #111827; --muted: #4b5563; --gold: #7c5600; --blue: #174ea6; }
366
+ main { padding: 24px 0; max-width: none; }
367
+ }
368
+ </style>
369
+ </head>
370
+ <body>
371
+ <main>
372
+ <header>
373
+ <h1>Drexler Conversation</h1>
374
+ <div class="meta">
375
+ <span>Exported ${escapeHtml(exportedAt)}</span>
376
+ <span>${conv.length} messages</span>
377
+ <span>~${conv.approximateTokens()} tokens</span>
378
+ <span>Model ${escapeHtml(config.model)}</span>
379
+ <span>Theme ${escapeHtml(theme)}</span>
380
+ </div>
381
+ </header>
382
+ ${rows || "<p>No transcript yet.</p>"}
383
+ </main>
384
+ </body>
385
+ </html>
386
+ `;
387
+ }
388
+
389
+ function lastAssistantMessage(conv: Conversation): string | null {
390
+ const snap = conv.snapshot();
391
+ for (let i = snap.length - 1; i >= 0; i--) {
392
+ const m = snap[i];
393
+ if (m?.role === "assistant") return m.content;
394
+ }
395
+ return null;
396
+ }
397
+
398
+ function formatLastAssistantAsMarkdown(content: string): string {
399
+ return [
400
+ "# Drexler Last Response",
401
+ "",
402
+ `Saved: ${new Date().toISOString()}`,
403
+ "",
404
+ "---",
405
+ "",
406
+ content,
407
+ "",
408
+ ].join("\n");
409
+ }
410
+
411
+ function handleSaveLast(rawPath: string, ctx: CommandContext): void {
412
+ const last = lastAssistantMessage(ctx.conversation);
413
+ if (!last) {
414
+ ctx.print("Drexler has not issued a response to save yet.");
415
+ return;
416
+ }
417
+ const target = resolveWriteTarget(
418
+ stripMatchingQuotes(rawPath.trim()),
419
+ "drexler-last",
420
+ ".md",
421
+ ctx,
422
+ );
423
+ if (!target) return;
424
+ try {
425
+ writeFileSync(target, formatLastAssistantAsMarkdown(last));
426
+ ctx.print(`Drexler last response sealed: ${target}`);
427
+ } catch (e) {
428
+ const msg = e instanceof Error ? e.message : String(e);
429
+ ctx.print(error(`Could not save last response: ${msg}`));
430
+ }
431
+ }
432
+
433
+ function handleExpand(ctx: CommandContext): void {
434
+ const last = lastAssistantMessage(ctx.conversation);
435
+ if (!last) {
436
+ ctx.print("Drexler has no response to expand yet.");
437
+ return;
438
+ }
439
+ ctx.print(last);
440
+ }
441
+
442
+ function handleQuote(ctx: CommandContext): void {
443
+ const last = lastAssistantMessage(ctx.conversation);
444
+ if (!last) {
445
+ ctx.print("Drexler has no response to quote yet.");
446
+ return;
447
+ }
448
+ const quoted = last
449
+ .split("\n")
450
+ .map((line) => `> ${line}`)
451
+ .join("\n");
452
+ ctx.print(quoted);
453
+ }
454
+
455
+ export function copyTextToClipboard(text: string): ClipboardResult {
456
+ const candidates =
457
+ process.platform === "darwin"
458
+ ? [{ command: "pbcopy", args: [] }]
459
+ : process.platform === "win32"
460
+ ? [{ command: "cmd.exe", args: ["/c", "clip"] }]
461
+ : [
462
+ { command: "wl-copy", args: [] },
463
+ { command: "xclip", args: ["-selection", "clipboard"] },
464
+ { command: "xsel", args: ["--clipboard", "--input"] },
465
+ ];
466
+
467
+ const failures: string[] = [];
468
+ for (const candidate of candidates) {
469
+ const result = spawnSync(candidate.command, candidate.args, {
470
+ input: text,
471
+ encoding: "utf8",
472
+ stdio: ["pipe", "ignore", "pipe"],
473
+ });
474
+ if (result.status === 0) {
475
+ return { ok: true, command: candidate.command };
476
+ }
477
+ if (result.error) {
478
+ failures.push(`${candidate.command}: ${result.error.message}`);
479
+ } else if (result.stderr) {
480
+ failures.push(`${candidate.command}: ${String(result.stderr).trim()}`);
481
+ } else {
482
+ failures.push(`${candidate.command}: exit ${result.status ?? "unknown"}`);
483
+ }
484
+ }
485
+
486
+ return {
487
+ ok: false,
488
+ reason: failures.length > 0 ? failures.join("; ") : "no clipboard utility found",
489
+ };
490
+ }
491
+
492
+ function handleCopyLast(ctx: CommandContext): void {
493
+ const last = lastAssistantMessage(ctx.conversation);
494
+ if (!last) {
495
+ ctx.print("Drexler has not issued a response to copy yet.");
496
+ return;
497
+ }
498
+
499
+ const result = (ctx.copyToClipboard ?? copyTextToClipboard)(last);
500
+ if (result.ok) {
501
+ ctx.print(`Drexler copied last response to clipboard via ${result.command}.`);
502
+ return;
503
+ }
504
+
505
+ ctx.print(
506
+ error(
507
+ `Clipboard unavailable: ${result.reason}. Use /save-last [path] to archive Drexler's last response.`,
508
+ ),
509
+ );
510
+ }
511
+
512
+ function compactSnippet(content: string, term: string): string {
513
+ const normalized = content.replace(/\s+/g, " ").trim();
514
+ const idx = normalized.toLowerCase().indexOf(term.toLowerCase());
515
+ if (idx === -1) return normalized.slice(0, 96);
516
+ const start = Math.max(0, idx - 32);
517
+ const end = Math.min(normalized.length, idx + term.length + 48);
518
+ const prefix = start > 0 ? "..." : "";
519
+ const suffix = end < normalized.length ? "..." : "";
520
+ return `${prefix}${normalized.slice(start, end)}${suffix}`;
521
+ }
522
+
523
+ function handleSearch(rawTerm: string, ctx: CommandContext): void {
524
+ const term = stripMatchingQuotes(rawTerm.trim());
525
+ if (!term) {
526
+ ctx.print(error("Usage: /search <term>"));
527
+ return;
528
+ }
529
+
530
+ const matches = ctx.conversation
531
+ .snapshot()
532
+ .filter((m) => m.role !== "system" && m.content.toLowerCase().includes(term.toLowerCase()));
533
+
534
+ if (matches.length === 0) {
535
+ ctx.print(`No transcript matches for "${term}".`);
536
+ return;
537
+ }
538
+
539
+ const lines = [`Search results for "${term}": ${matches.length}`];
540
+ matches.slice(0, 8).forEach((m, i) => {
541
+ const label = m.role === "user" ? "You" : "Drexler";
542
+ lines.push(`${i + 1}. ${label}: ${compactSnippet(m.content, term)}`);
543
+ });
544
+ if (matches.length > 8) {
545
+ lines.push(`...${matches.length - 8} more matches`);
546
+ }
547
+ ctx.print(lines.join("\n"));
548
+ }
549
+
550
+ type ExportFormat = "md" | "txt" | "json" | "html";
551
+
552
+ const EXPORT_EXTENSIONS: Record<ExportFormat, string> = {
553
+ md: ".md",
554
+ txt: ".txt",
555
+ json: ".json",
556
+ html: ".html",
557
+ };
558
+
559
+ function isExportFormat(value: string | undefined): value is ExportFormat {
560
+ return value === "md" || value === "txt" || value === "json" || value === "html";
561
+ }
562
+
563
+ function handleExport(input: string, args: string[], ctx: CommandContext): void {
564
+ const requested = args[0]?.toLowerCase();
565
+ if (!isExportFormat(requested)) {
566
+ ctx.print(error("Usage: /export md|txt|json|html [path]"));
567
+ return;
568
+ }
569
+
570
+ const rawRemainder = commandRemainder(input, "export");
571
+ const rawPath = rawRemainder.slice(args[0]!.length).trim();
572
+ const target = resolveWriteTarget(
573
+ stripMatchingQuotes(rawPath),
574
+ `drexler-export`,
575
+ EXPORT_EXTENSIONS[requested],
576
+ ctx,
577
+ );
578
+ if (!target) return;
579
+
580
+ const body =
581
+ requested === "md"
582
+ ? formatConversationAsMarkdown(ctx.conversation, ctx.config)
583
+ : requested === "txt"
584
+ ? formatConversationAsText(ctx.conversation, ctx.config)
585
+ : requested === "json"
586
+ ? formatConversationAsJson(ctx.conversation, ctx.config)
587
+ : formatConversationAsHtml(ctx.conversation, ctx.config);
588
+
589
+ try {
590
+ writeFileSync(target, body);
591
+ ctx.print(`Drexler export filed (${requested}): ${target}`);
592
+ } catch (e) {
593
+ const msg = e instanceof Error ? e.message : String(e);
594
+ ctx.print(error(`Could not export: ${msg}`));
595
+ }
596
+ }
597
+
194
598
  function handleModel(args: string[], ctx: CommandContext): void {
195
599
  if (args.length === 0) {
196
600
  ctx.print(`Current model: ${ctx.config.model}`);
@@ -205,3 +609,82 @@ function handleModel(args: string[], ctx: CommandContext): void {
205
609
  ctx.print(error(msg));
206
610
  }
207
611
  }
612
+
613
+ function currentThemeName(config: Config): ThemeName {
614
+ const activeTheme = getActiveTheme();
615
+ const match = THEME_NAMES.find((name) => THEMES[name] === activeTheme);
616
+ return match ?? config.theme ?? "apollo";
617
+ }
618
+
619
+ function currentStartupMode(config: Config): "fast" | "no-intro" | "normal" {
620
+ if (config.fast === true) return "fast";
621
+ if (config.noIntro === true) return "no-intro";
622
+ return "normal";
623
+ }
624
+
625
+ function handleStartup(args: string[], ctx: CommandContext): CommandAction {
626
+ if (args.length === 0) {
627
+ ctx.print(`Current startup mode: ${currentStartupMode(ctx.config)}`);
628
+ return { type: "continue" };
629
+ }
630
+
631
+ const requested = args[0]?.toLowerCase();
632
+ if (requested === "fast") {
633
+ ctx.config.fast = true;
634
+ ctx.config.noIntro = true;
635
+ ctx.print("Drexler save startup mode: fast.");
636
+ return { type: "continue", persistConfig: { fast: true, noIntro: true } };
637
+ }
638
+ if (requested === "no-intro") {
639
+ ctx.config.fast = false;
640
+ ctx.config.noIntro = true;
641
+ ctx.print("Drexler save startup mode: no-intro.");
642
+ return { type: "continue", persistConfig: { fast: false, noIntro: true } };
643
+ }
644
+ if (requested === "normal") {
645
+ ctx.config.fast = false;
646
+ ctx.config.noIntro = false;
647
+ ctx.print("Drexler restore full theatrical entrance.");
648
+ return { type: "continue", persistConfig: { fast: false, noIntro: false } };
649
+ }
650
+
651
+ ctx.print(error(`Unknown startup mode: ${args[0]}. Use fast, no-intro, or normal.`));
652
+ return { type: "continue" };
653
+ }
654
+
655
+ function handleTheme(args: string[], ctx: CommandContext): CommandAction {
656
+ if (args.length === 0) {
657
+ ctx.print(`Current theme: ${currentThemeName(ctx.config)}`);
658
+ return { type: "continue" };
659
+ }
660
+
661
+ if (args[0]?.toLowerCase() === "save") {
662
+ const current = currentThemeName(ctx.config);
663
+ ctx.config.theme = current;
664
+ ctx.print(`Drexler save boardroom decor: ${current}`);
665
+ return { type: "continue", persistConfig: { theme: current } };
666
+ }
667
+
668
+ const requested = args[0]?.toLowerCase();
669
+ if (!isThemeName(requested)) {
670
+ ctx.print(
671
+ error(
672
+ `Unknown theme: "${args[0] ?? ""}". Use ${THEME_NAMES.join(", ")}.`,
673
+ ),
674
+ );
675
+ return { type: "continue" };
676
+ }
677
+
678
+ ctx.config.theme = requested;
679
+ setActiveTheme(requested);
680
+ resetMarkedTheme();
681
+ const shouldSave = args.slice(1).some((arg) => arg.toLowerCase() === "save");
682
+ ctx.print(
683
+ shouldSave
684
+ ? `Drexler redecorate boardroom and save: ${requested}`
685
+ : `Drexler redecorate boardroom: ${requested}`,
686
+ );
687
+ return shouldSave
688
+ ? { type: "continue", persistConfig: { theme: requested } }
689
+ : { type: "continue" };
690
+ }