drexler 0.1.1 → 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 +12 -0
- package/README.md +24 -5
- package/package.json +2 -1
- package/src/commands.ts +515 -32
- package/src/config.ts +46 -11
- package/src/index.ts +46 -27
- package/src/renderer.ts +18 -14
- package/src/repl.ts +31 -5
- package/src/types.ts +18 -1
- package/src/ui/App.tsx +309 -107
- package/src/ui/CommandPalette.tsx +102 -8
- package/src/ui/DealDeskHeader.tsx +219 -0
- package/src/ui/InputBox.tsx +115 -10
- package/src/ui/Message.tsx +94 -24
- package/src/ui/SetupPrompt.tsx +85 -0
- package/src/ui/Spinner.tsx +45 -6
- package/src/ui/StatusBar.tsx +39 -7
- package/src/ui/TranscriptViewport.tsx +255 -0
- package/src/ui/graphemes.ts +119 -0
- package/src/ui/themes.ts +36 -3
package/src/commands.ts
CHANGED
|
@@ -1,30 +1,51 @@
|
|
|
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
|
|
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
|
-
/
|
|
41
|
+
/retry [style] - re-roll last response, optionally terse or brutal
|
|
42
|
+
/expand - print Drexler's latest response
|
|
43
|
+
/quote - quote Drexler's latest response
|
|
44
|
+
/search <term> - search this meeting transcript
|
|
45
|
+
/export <fmt> [path] - export as md, txt, json, or html
|
|
46
|
+
/save [path] - archive conversation to markdown file
|
|
47
|
+
/save-last [path] - save Drexler's last response
|
|
48
|
+
/copy-last - copy Drexler's last response to clipboard`;
|
|
28
49
|
|
|
29
50
|
const WHITESPACE_RE = /\s+/;
|
|
30
51
|
|
|
@@ -39,9 +60,18 @@ export const COMMAND_PALETTE: ReadonlyArray<SlashCommand> = [
|
|
|
39
60
|
{ name: "/exit", description: "Adjourn meeting" },
|
|
40
61
|
{ name: "/synergy", description: "SYNERGY!" },
|
|
41
62
|
{ name: "/model", description: "Show or switch model" },
|
|
63
|
+
{ name: "/theme", description: "Show or switch theme" },
|
|
64
|
+
{ name: "/startup", description: "Persist startup mode" },
|
|
42
65
|
{ name: "/history", description: "Message + token count" },
|
|
43
66
|
{ name: "/regenerate", description: "Re-roll last response" },
|
|
67
|
+
{ name: "/retry", description: "Retry terse or brutal" },
|
|
68
|
+
{ name: "/expand", description: "Print last response" },
|
|
69
|
+
{ name: "/quote", description: "Quote last response" },
|
|
70
|
+
{ name: "/search", description: "Search transcript" },
|
|
71
|
+
{ name: "/export", description: "Export md, txt, json, or html" },
|
|
44
72
|
{ name: "/save", description: "Archive conversation as markdown" },
|
|
73
|
+
{ name: "/save-last", description: "Save last Drexler response" },
|
|
74
|
+
{ name: "/copy-last", description: "Copy last response" },
|
|
45
75
|
];
|
|
46
76
|
|
|
47
77
|
export function filterPaletteByPrefix(
|
|
@@ -49,6 +79,8 @@ export function filterPaletteByPrefix(
|
|
|
49
79
|
): ReadonlyArray<SlashCommand> {
|
|
50
80
|
if (!input.startsWith("/") || input.includes(" ")) return [];
|
|
51
81
|
const prefix = input.toLowerCase();
|
|
82
|
+
const exact = COMMAND_PALETTE.find((c) => c.name.toLowerCase() === prefix);
|
|
83
|
+
if (exact) return [exact];
|
|
52
84
|
return COMMAND_PALETTE.filter((c) =>
|
|
53
85
|
c.name.toLowerCase().startsWith(prefix),
|
|
54
86
|
);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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("&", "&")
|
|
327
|
+
.replaceAll("<", "<")
|
|
328
|
+
.replaceAll(">", ">")
|
|
329
|
+
.replaceAll('"', """)
|
|
330
|
+
.replaceAll("'", "'");
|
|
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
|
+
}
|