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/config.ts
CHANGED
|
@@ -11,8 +11,8 @@ import {
|
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { join, resolve } from "node:path";
|
|
13
13
|
import * as readline from "node:readline/promises";
|
|
14
|
-
import type { CliFlags, Config } from "./types.ts";
|
|
15
|
-
import { MODEL_FALLBACK, MODEL_PRIMARY } from "./types.ts";
|
|
14
|
+
import type { CliFlags, Config, ThemeName } from "./types.ts";
|
|
15
|
+
import { MODEL_FALLBACK, MODEL_PRIMARY, THEME_NAMES } from "./types.ts";
|
|
16
16
|
|
|
17
17
|
const DEFAULT_MAX_HISTORY = 50;
|
|
18
18
|
|
|
@@ -47,6 +47,10 @@ export function parseFlags(argv: string[]): CliFlags {
|
|
|
47
47
|
flags.persona = argv[++i];
|
|
48
48
|
} else if (a === "--theme" && valueAfter(i) !== undefined) {
|
|
49
49
|
flags.theme = argv[++i];
|
|
50
|
+
} else if (a === "--no-intro") {
|
|
51
|
+
flags.noIntro = true;
|
|
52
|
+
} else if (a === "--fast") {
|
|
53
|
+
flags.fast = true;
|
|
50
54
|
} else if (a !== undefined && a.startsWith("--model=")) {
|
|
51
55
|
flags.model = a.slice("--model=".length);
|
|
52
56
|
} else if (a !== undefined && a.startsWith("--persona=")) {
|
|
@@ -132,6 +136,25 @@ export function isValidApiKey(k: string | undefined | null): k is string {
|
|
|
132
136
|
return true;
|
|
133
137
|
}
|
|
134
138
|
|
|
139
|
+
function parseOptionalBoolean(value: unknown): boolean | undefined {
|
|
140
|
+
if (typeof value === "boolean") return value;
|
|
141
|
+
if (typeof value !== "string") return undefined;
|
|
142
|
+
switch (value.trim().toLowerCase()) {
|
|
143
|
+
case "1":
|
|
144
|
+
case "true":
|
|
145
|
+
case "yes":
|
|
146
|
+
case "on":
|
|
147
|
+
return true;
|
|
148
|
+
case "0":
|
|
149
|
+
case "false":
|
|
150
|
+
case "no":
|
|
151
|
+
case "off":
|
|
152
|
+
return false;
|
|
153
|
+
default:
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
135
158
|
async function readApiKeyFromStdin(): Promise<string | null> {
|
|
136
159
|
const rl = readline.createInterface({
|
|
137
160
|
input: process.stdin,
|
|
@@ -146,16 +169,20 @@ async function readApiKeyFromStdin(): Promise<string | null> {
|
|
|
146
169
|
}
|
|
147
170
|
}
|
|
148
171
|
|
|
149
|
-
export async function ensureApiKey(
|
|
172
|
+
export async function ensureApiKey(opts?: {
|
|
173
|
+
prompt?: () => Promise<string | null>;
|
|
174
|
+
}): Promise<string> {
|
|
150
175
|
const fileCfg = await loadConfigFile();
|
|
151
176
|
const envKey = process.env.OPENROUTER_API_KEY;
|
|
152
177
|
if (isValidApiKey(envKey)) return envKey.trim();
|
|
153
178
|
if (isValidApiKey(fileCfg.apiKey)) return fileCfg.apiKey.trim();
|
|
154
179
|
|
|
155
|
-
|
|
156
|
-
|
|
180
|
+
if (!opts?.prompt) {
|
|
181
|
+
console.log("Drexler notice no API key on file. Even CEO need credentials.");
|
|
182
|
+
console.log("Get free key at: https://openrouter.ai/keys");
|
|
183
|
+
}
|
|
157
184
|
|
|
158
|
-
const entered = await readApiKeyFromStdin();
|
|
185
|
+
const entered = await (opts?.prompt ?? readApiKeyFromStdin)();
|
|
159
186
|
if (!isValidApiKey(entered)) {
|
|
160
187
|
console.error("No valid API key provided. Drexler refuse to work pro bono.");
|
|
161
188
|
process.exit(1);
|
|
@@ -212,11 +239,19 @@ export async function resolveConfig(argv: string[]): Promise<Config> {
|
|
|
212
239
|
const themeCandidate =
|
|
213
240
|
flags.theme ?? process.env.DREXLER_THEME ?? fileCfg.theme;
|
|
214
241
|
const theme =
|
|
215
|
-
themeCandidate === "
|
|
216
|
-
themeCandidate
|
|
217
|
-
|
|
218
|
-
? themeCandidate
|
|
242
|
+
typeof themeCandidate === "string" &&
|
|
243
|
+
THEME_NAMES.includes(themeCandidate as ThemeName)
|
|
244
|
+
? (themeCandidate as ThemeName)
|
|
219
245
|
: undefined;
|
|
220
246
|
|
|
221
|
-
|
|
247
|
+
const noIntro =
|
|
248
|
+
flags.noIntro ??
|
|
249
|
+
parseOptionalBoolean(process.env.DREXLER_NO_INTRO) ??
|
|
250
|
+
parseOptionalBoolean(fileCfg.noIntro);
|
|
251
|
+
const fast =
|
|
252
|
+
flags.fast ??
|
|
253
|
+
parseOptionalBoolean(process.env.DREXLER_FAST) ??
|
|
254
|
+
parseOptionalBoolean(fileCfg.fast);
|
|
255
|
+
|
|
256
|
+
return { apiKey, model, maxHistory, personaPath, theme, noIntro, fast };
|
|
222
257
|
}
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { startRepl } from "./repl.ts";
|
|
21
21
|
import { App } from "./ui/App.tsx";
|
|
22
22
|
import { MascotIntro } from "./ui/MascotIntro.tsx";
|
|
23
|
+
import { promptForApiKeyWithInk } from "./ui/SetupPrompt.tsx";
|
|
23
24
|
import { ThemeProvider } from "./ui/ThemeContext.tsx";
|
|
24
25
|
import { getActiveTheme, selectTheme, setActiveTheme } from "./ui/themes.ts";
|
|
25
26
|
|
|
@@ -41,7 +42,9 @@ Usage: drexler [options]
|
|
|
41
42
|
Options:
|
|
42
43
|
--model <31b|26b|id> model alias or full OpenRouter id
|
|
43
44
|
--persona <path> custom persona markdown
|
|
44
|
-
--theme <
|
|
45
|
+
--theme <name> color theme (default apollo)
|
|
46
|
+
--no-intro skip startup banner and mascot
|
|
47
|
+
--fast fast startup mode, implies --no-intro
|
|
45
48
|
--version, -v print version
|
|
46
49
|
--help, -h this help
|
|
47
50
|
|
|
@@ -51,9 +54,18 @@ Slash commands inside REPL:
|
|
|
51
54
|
/exit exit
|
|
52
55
|
/synergy SYNERGY!
|
|
53
56
|
/model [id] show or switch model
|
|
57
|
+
/theme [name] show or switch theme; append save to persist
|
|
58
|
+
/startup [mode] persist startup mode: fast, no-intro, normal
|
|
54
59
|
/history message + token count
|
|
55
60
|
/regenerate re-roll last response
|
|
61
|
+
/retry [style] re-roll last response as terse or brutal
|
|
62
|
+
/expand print latest response
|
|
63
|
+
/quote quote latest response
|
|
64
|
+
/search <term> search transcript
|
|
65
|
+
/export <fmt> [path] export md, txt, json, or html
|
|
56
66
|
/save [path] archive conversation as markdown
|
|
67
|
+
/save-last [path] save latest response
|
|
68
|
+
/copy-last copy latest response to clipboard
|
|
57
69
|
|
|
58
70
|
Ctrl+C exits gracefully.`;
|
|
59
71
|
|
|
@@ -70,8 +82,13 @@ async function main(): Promise<void> {
|
|
|
70
82
|
process.exit(0);
|
|
71
83
|
}
|
|
72
84
|
|
|
85
|
+
const isInteractive =
|
|
86
|
+
process.stdout.isTTY === true && process.stdin.isTTY === true;
|
|
87
|
+
|
|
73
88
|
// Acquire API key. Prompts interactively if missing — runs BEFORE banner.
|
|
74
|
-
await ensureApiKey(
|
|
89
|
+
await ensureApiKey({
|
|
90
|
+
prompt: isInteractive ? promptForApiKeyWithInk : undefined,
|
|
91
|
+
});
|
|
75
92
|
|
|
76
93
|
let config;
|
|
77
94
|
try {
|
|
@@ -105,27 +122,27 @@ async function main(): Promise<void> {
|
|
|
105
122
|
systemPromptWithMood,
|
|
106
123
|
config.maxHistory,
|
|
107
124
|
);
|
|
108
|
-
|
|
109
|
-
const isInteractive =
|
|
110
|
-
process.stdout.isTTY === true && process.stdin.isTTY === true;
|
|
125
|
+
const skipIntro = config.noIntro === true || config.fast === true;
|
|
111
126
|
|
|
112
127
|
if (isInteractive) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
if (!skipIntro) {
|
|
129
|
+
// Print intro to stdout before Ink mounts. Ink's <Static> can't host
|
|
130
|
+
// animated state, and we want the banner visible from boot.
|
|
131
|
+
console.log("");
|
|
132
|
+
await typewriterBanner();
|
|
133
|
+
console.log(tagline());
|
|
134
|
+
console.log("");
|
|
135
|
+
// Animated welcome card via transient Ink instance.
|
|
136
|
+
const intro = render(
|
|
137
|
+
React.createElement(ThemeProvider, {
|
|
138
|
+
value: getActiveTheme(),
|
|
139
|
+
children: React.createElement(MascotIntro, { greeting }),
|
|
140
|
+
}),
|
|
141
|
+
{ exitOnCtrlC: false },
|
|
142
|
+
);
|
|
143
|
+
await intro.waitUntilExit();
|
|
144
|
+
intro.unmount();
|
|
145
|
+
}
|
|
129
146
|
|
|
130
147
|
console.log("");
|
|
131
148
|
console.log(" " + infoLine() + " · mood: " + mood);
|
|
@@ -134,7 +151,7 @@ async function main(): Promise<void> {
|
|
|
134
151
|
const { waitUntilExit } = render(
|
|
135
152
|
React.createElement(ThemeProvider, {
|
|
136
153
|
value: getActiveTheme(),
|
|
137
|
-
children: React.createElement(App, { conversation, config }),
|
|
154
|
+
children: React.createElement(App, { conversation, config, mood }),
|
|
138
155
|
}),
|
|
139
156
|
{ exitOnCtrlC: false },
|
|
140
157
|
);
|
|
@@ -144,11 +161,13 @@ async function main(): Promise<void> {
|
|
|
144
161
|
|
|
145
162
|
// Non-TTY fallback: linear output, readline-based REPL.
|
|
146
163
|
console.log("");
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
164
|
+
if (!skipIntro) {
|
|
165
|
+
console.log(banner());
|
|
166
|
+
console.log(tagline());
|
|
167
|
+
console.log("");
|
|
168
|
+
console.log(welcomeBox(greeting, termCols()));
|
|
169
|
+
console.log("");
|
|
170
|
+
}
|
|
152
171
|
console.log(" " + infoLine() + " · mood: " + mood);
|
|
153
172
|
console.log("");
|
|
154
173
|
|
package/src/renderer.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
import { highlight } from "cli-highlight";
|
|
3
2
|
import { marked } from "marked";
|
|
4
3
|
import { markedTerminal } from "marked-terminal";
|
|
5
4
|
import { THINKING_LINES, WITTICISMS } from "./sayings.ts";
|
|
@@ -10,13 +9,26 @@ export function getColors() {
|
|
|
10
9
|
return buildChalkColors(getActiveTheme());
|
|
11
10
|
}
|
|
12
11
|
|
|
12
|
+
// cli-highlight is ~160KB and only used for code blocks inside markdown.
|
|
13
|
+
// Pre-warm in background so cold boot doesn't pay parse cost on critical path;
|
|
14
|
+
// fall back to dim raw text on the off-chance a code block renders pre-load.
|
|
15
|
+
type HighlightFn = (code: string, opts: { language?: string; ignoreIllegals?: boolean }) => string;
|
|
16
|
+
let highlightFn: HighlightFn | null = null;
|
|
17
|
+
void import("cli-highlight").then((m) => {
|
|
18
|
+
highlightFn = m.highlight as HighlightFn;
|
|
19
|
+
});
|
|
20
|
+
|
|
13
21
|
function highlightCodeBlock(code: string, lang: string | undefined): string {
|
|
14
22
|
let body: string;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
if (highlightFn) {
|
|
24
|
+
try {
|
|
25
|
+
body = lang
|
|
26
|
+
? highlightFn(code, { language: lang, ignoreIllegals: true })
|
|
27
|
+
: highlightFn(code, { ignoreIllegals: true });
|
|
28
|
+
} catch {
|
|
29
|
+
body = chalk.gray(code);
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
20
32
|
body = chalk.gray(code);
|
|
21
33
|
}
|
|
22
34
|
const c = getColors();
|
|
@@ -327,18 +339,10 @@ export function error(msg: string): string {
|
|
|
327
339
|
return getColors().error(msg);
|
|
328
340
|
}
|
|
329
341
|
|
|
330
|
-
export function warning(msg: string): string {
|
|
331
|
-
return getColors().warning(msg);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
342
|
export function dim(msg: string): string {
|
|
335
343
|
return getColors().dim(msg);
|
|
336
344
|
}
|
|
337
345
|
|
|
338
|
-
export function separator(): string {
|
|
339
|
-
return getColors().apolloDim("─".repeat(BOX_WIDTH));
|
|
340
|
-
}
|
|
341
|
-
|
|
342
346
|
export function pickThinkingLine(): string {
|
|
343
347
|
return THINKING_LINES[Math.floor(Math.random() * THINKING_LINES.length)] ?? "Drexler thinking";
|
|
344
348
|
}
|
package/src/repl.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
isSlash,
|
|
6
6
|
type CommandAction,
|
|
7
7
|
} from "./commands.ts";
|
|
8
|
+
import { saveConfig } from "./config.ts";
|
|
8
9
|
import type { Conversation } from "./conversation.ts";
|
|
9
10
|
import { streamChat, type FetchFn } from "./llm.ts";
|
|
10
11
|
import type { Message } from "./types.ts";
|
|
@@ -40,11 +41,25 @@ export interface ReplDeps {
|
|
|
40
41
|
print: (s: string) => void;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
function
|
|
44
|
+
async function persistPreferences(
|
|
45
|
+
partial: Partial<Config> | undefined,
|
|
46
|
+
print: (s: string) => void,
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
if (!partial) return;
|
|
49
|
+
try {
|
|
50
|
+
await saveConfig(partial);
|
|
51
|
+
print(info("Drexler preferences filed."));
|
|
52
|
+
} catch (e) {
|
|
53
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
54
|
+
print(error(`Could not save preferences: ${msg}`));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function pickFallback(currentModel: string): string {
|
|
44
59
|
return currentModel === MODEL_PRIMARY ? MODEL_FALLBACK : MODEL_PRIMARY;
|
|
45
60
|
}
|
|
46
61
|
|
|
47
|
-
function buildMessagesWithReminder(conv: Conversation): Message[] {
|
|
62
|
+
export function buildMessagesWithReminder(conv: Conversation): Message[] {
|
|
48
63
|
const snap = conv.snapshot();
|
|
49
64
|
const turns = conv.userTurns;
|
|
50
65
|
if (turns > 0 && turns % REMINDER_INTERVAL === 0) {
|
|
@@ -70,7 +85,10 @@ interface KeypressKey {
|
|
|
70
85
|
}
|
|
71
86
|
type KeypressListener = (str: string | undefined, key: KeypressKey) => void;
|
|
72
87
|
|
|
73
|
-
async function streamFromHistory(
|
|
88
|
+
async function streamFromHistory(
|
|
89
|
+
deps: ReplDeps,
|
|
90
|
+
instruction?: string,
|
|
91
|
+
): Promise<void> {
|
|
74
92
|
const spinner = startSpinner();
|
|
75
93
|
let firstToken = true;
|
|
76
94
|
const accent = createAccentBarWriter();
|
|
@@ -102,7 +120,12 @@ async function streamFromHistory(deps: ReplDeps): Promise<void> {
|
|
|
102
120
|
apiKey: deps.config.apiKey,
|
|
103
121
|
model: deps.config.model,
|
|
104
122
|
fallbackModel: pickFallback(deps.config.model),
|
|
105
|
-
messages:
|
|
123
|
+
messages: instruction
|
|
124
|
+
? [
|
|
125
|
+
...buildMessagesWithReminder(deps.conversation),
|
|
126
|
+
{ role: "system", content: instruction },
|
|
127
|
+
]
|
|
128
|
+
: buildMessagesWithReminder(deps.conversation),
|
|
106
129
|
onToken,
|
|
107
130
|
signal: abort.signal,
|
|
108
131
|
fetchFn: deps.fetchFn,
|
|
@@ -151,8 +174,11 @@ export async function handleLine(
|
|
|
151
174
|
config: deps.config,
|
|
152
175
|
print: deps.print,
|
|
153
176
|
});
|
|
177
|
+
if (action.type === "continue") {
|
|
178
|
+
await persistPreferences(action.persistConfig, deps.print);
|
|
179
|
+
}
|
|
154
180
|
if (action.type === "regenerate") {
|
|
155
|
-
await streamFromHistory(deps);
|
|
181
|
+
await streamFromHistory(deps, action.instruction);
|
|
156
182
|
return { type: "continue" };
|
|
157
183
|
}
|
|
158
184
|
return action;
|
package/src/types.ts
CHANGED
|
@@ -8,12 +8,27 @@ export interface Message {
|
|
|
8
8
|
export const MODEL_PRIMARY = "google/gemma-4-31b-it";
|
|
9
9
|
export const MODEL_FALLBACK = "google/gemma-4-26b-a4b-it";
|
|
10
10
|
|
|
11
|
+
export const THEME_NAMES = [
|
|
12
|
+
"apollo",
|
|
13
|
+
"amber",
|
|
14
|
+
"mono",
|
|
15
|
+
"terminal",
|
|
16
|
+
"dealroom",
|
|
17
|
+
"midnight",
|
|
18
|
+
"paper",
|
|
19
|
+
"plasma",
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export type ThemeName = (typeof THEME_NAMES)[number];
|
|
23
|
+
|
|
11
24
|
export interface Config {
|
|
12
25
|
apiKey: string;
|
|
13
26
|
model: string;
|
|
14
27
|
maxHistory: number;
|
|
15
28
|
personaPath: string;
|
|
16
|
-
theme?:
|
|
29
|
+
theme?: ThemeName;
|
|
30
|
+
noIntro?: boolean;
|
|
31
|
+
fast?: boolean;
|
|
17
32
|
}
|
|
18
33
|
|
|
19
34
|
export interface PersonaData {
|
|
@@ -25,6 +40,8 @@ export interface CliFlags {
|
|
|
25
40
|
model?: string;
|
|
26
41
|
persona?: string;
|
|
27
42
|
theme?: string;
|
|
43
|
+
noIntro?: boolean;
|
|
44
|
+
fast?: boolean;
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
export interface OpenRouterRequestBody {
|