drexler 0.1.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/LICENSE +21 -0
- package/README.md +84 -0
- package/package.json +61 -0
- package/prompts/drexler.md +238 -0
- package/src/commands.ts +207 -0
- package/src/config.ts +222 -0
- package/src/conversation.ts +79 -0
- package/src/index.ts +165 -0
- package/src/llm.ts +223 -0
- package/src/mood.ts +19 -0
- package/src/persona.ts +43 -0
- package/src/renderer.ts +412 -0
- package/src/repl.ts +225 -0
- package/src/sayings.ts +96 -0
- package/src/startupTips.ts +6 -0
- package/src/types.ts +44 -0
- package/src/ui/App.tsx +481 -0
- package/src/ui/CommandPalette.tsx +36 -0
- package/src/ui/InputBox.tsx +39 -0
- package/src/ui/MascotFrame.tsx +70 -0
- package/src/ui/MascotIntro.tsx +338 -0
- package/src/ui/Message.tsx +82 -0
- package/src/ui/Spinner.tsx +39 -0
- package/src/ui/StatusBar.tsx +61 -0
- package/src/ui/ThemeContext.tsx +10 -0
- package/src/ui/themes.ts +88 -0
package/src/sayings.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export const WITTICISMS = [
|
|
2
|
+
"Drexler never fly coach",
|
|
3
|
+
"Drexler greed is good",
|
|
4
|
+
"Buy low. Sell… uh… low",
|
|
5
|
+
"Drexler eat paperwork for breakfast",
|
|
6
|
+
"Stonks go up",
|
|
7
|
+
"Drexler king of watercooler banter",
|
|
8
|
+
"Numbers Steve currently in Cayman Islands",
|
|
9
|
+
"HR Director Karen filed complaint. Karen also Drexler",
|
|
10
|
+
"Bradford the Younger has worse briefcase",
|
|
11
|
+
"Me make budget cuts. Drexler keep bonus",
|
|
12
|
+
"Drexler's wealth trickle everywhere",
|
|
13
|
+
"Drexler thrive in Chapter 11",
|
|
14
|
+
"Drexler file 13D before breakfast",
|
|
15
|
+
"Drexler buy junk bonds for breakfast",
|
|
16
|
+
"Spin off underperforming Bradford",
|
|
17
|
+
"Drexler's harvest season",
|
|
18
|
+
"Vulture Vance circling 14th floor",
|
|
19
|
+
"Pemberton drafting. Pemberton always drafting",
|
|
20
|
+
"Bankruptcy is opportunity. Drexler's opportunity",
|
|
21
|
+
"Drexler demand four board seats",
|
|
22
|
+
"Drop-down szn",
|
|
23
|
+
"Uptier or be uptiered",
|
|
24
|
+
"Trapdoor located, lenders evacuated",
|
|
25
|
+
"Restricted group, unrestricted pain",
|
|
26
|
+
"Pari plus? Pari LOL",
|
|
27
|
+
"Serta'd",
|
|
28
|
+
"J. Crewed",
|
|
29
|
+
"Recovery rate: 6 cents. Drexler's: 143 cents",
|
|
30
|
+
"Drexler stake: 4.99%. Counts carefully",
|
|
31
|
+
"Examiner is Drexler. Conflict waived",
|
|
32
|
+
"Page 847 of open letter, going strong",
|
|
33
|
+
"Karen escalated to Karen",
|
|
34
|
+
"Cayman is timezone of mind",
|
|
35
|
+
"Loss is just unrealized alpha",
|
|
36
|
+
"Tactical retreat. Bonus intact",
|
|
37
|
+
"Patient money. Vultures wait. Drexler wait less",
|
|
38
|
+
"Cramdown is a love language",
|
|
39
|
+
"Drexler wears better robe",
|
|
40
|
+
"Disclosure statement: 1,400 pages. Three not lies",
|
|
41
|
+
"Marriott Marcus has not seen sun since Q2",
|
|
42
|
+
"Drahi gambit: Drexler invented it",
|
|
43
|
+
"Three Altice silos. Lenders dizzy",
|
|
44
|
+
"Drahi sold Portugal for €8B",
|
|
45
|
+
"Ergen still hoarding spectrum",
|
|
46
|
+
"Dish merged. Then unmerged. Then re-merged",
|
|
47
|
+
"Ergen winning at 4 AM poker",
|
|
48
|
+
"Xerox PARC into JV. Lenders blindsided",
|
|
49
|
+
"Altice France LME 2024 — see Drexler memo",
|
|
50
|
+
"T-Mobile paid Ergen $5B. Creditors paid attention",
|
|
51
|
+
"Drahi lives in Switzerland for tax purposes",
|
|
52
|
+
"K&E billing at 2,400 an hour",
|
|
53
|
+
"Greenberg sniffing distress",
|
|
54
|
+
"Paul Weiss = Apollo's bitch",
|
|
55
|
+
"Nemecek's career: past tense",
|
|
56
|
+
"Milbank conference room: smaller",
|
|
57
|
+
"Marc Rowan running Apollo. Drexler running Rowan",
|
|
58
|
+
"Howard Marks on memo 47",
|
|
59
|
+
"Silver Point already left building",
|
|
60
|
+
"SVP pouring European junk",
|
|
61
|
+
"Canyon bigger than Grand Canyon",
|
|
62
|
+
"Diameter is unit of distress",
|
|
63
|
+
"Apollo do everything quietly",
|
|
64
|
+
"Milken is Drexler's mirror",
|
|
65
|
+
"Photo of Milken on Drexler's desk",
|
|
66
|
+
"Predator's Ball: Drexler attend every year",
|
|
67
|
+
"Drexler study Milken every morning",
|
|
68
|
+
"Drexler defend Milken at any dinner table",
|
|
69
|
+
"Lehman eulogy: Should have called Drexler",
|
|
70
|
+
"Bear sold for $2. Drexler bid one penny",
|
|
71
|
+
"Fuld ran Lehman into ground. Drexler advised bigger ground",
|
|
72
|
+
"Cayne played bridge. Drexler played poker. Both lost firms",
|
|
73
|
+
"Self-pardon: Drexler 1991, Trump 2020",
|
|
74
|
+
"AIG bailout: $182B. Drexler bailout: $182T",
|
|
75
|
+
] as const;
|
|
76
|
+
|
|
77
|
+
export const THINKING_LINES = [
|
|
78
|
+
"Drexler consulting quarterly reports",
|
|
79
|
+
"Reviewing TPS reports",
|
|
80
|
+
"Checking Drexler's calendar",
|
|
81
|
+
"Drexler's legal team reviewing",
|
|
82
|
+
"Running due diligence",
|
|
83
|
+
"Numbers Steve crunching numbers",
|
|
84
|
+
"Briefcase opening",
|
|
85
|
+
"Drexler convene emergency meeting",
|
|
86
|
+
"Polling shareholders",
|
|
87
|
+
"Drexler think… Drexler grow rich",
|
|
88
|
+
] as const;
|
|
89
|
+
|
|
90
|
+
export const EMPTY_NUDGE = "Drexler's time is money. YOUR money. Speak up.";
|
|
91
|
+
export const STREAM_ERROR =
|
|
92
|
+
"Trading tantrum! Drexler's stream interrupted. Try again.";
|
|
93
|
+
export const SIGINT_MSG = "Drexler do exit interview. Meeting adjourned.";
|
|
94
|
+
export const REMINDER_INTERVAL = 5;
|
|
95
|
+
export const DRIFT_REMINDER =
|
|
96
|
+
"Reminder: stay in character. ≤4 sentences. Never use 'I'. ≤1 catchphrase. Land the joke last.";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const STARTUP_TIPS = [
|
|
2
|
+
"Ask about LMEs (J. Crew, Serta, Altice France) or any restructuring deal",
|
|
3
|
+
"Type /help for all directives, /regenerate to re-roll Drexler",
|
|
4
|
+
"Tab completes slash commands; ↑/↓ scrolls input history",
|
|
5
|
+
"ESC cancels mid-response without quitting; Ctrl+C exits",
|
|
6
|
+
] as const;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type Role = "system" | "user" | "assistant";
|
|
2
|
+
|
|
3
|
+
export interface Message {
|
|
4
|
+
role: Role;
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const MODEL_PRIMARY = "google/gemma-4-31b-it";
|
|
9
|
+
export const MODEL_FALLBACK = "google/gemma-4-26b-a4b-it";
|
|
10
|
+
|
|
11
|
+
export interface Config {
|
|
12
|
+
apiKey: string;
|
|
13
|
+
model: string;
|
|
14
|
+
maxHistory: number;
|
|
15
|
+
personaPath: string;
|
|
16
|
+
theme?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PersonaData {
|
|
20
|
+
systemPrompt: string;
|
|
21
|
+
greetings: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CliFlags {
|
|
25
|
+
model?: string;
|
|
26
|
+
persona?: string;
|
|
27
|
+
theme?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface OpenRouterRequestBody {
|
|
31
|
+
model: string;
|
|
32
|
+
messages: Message[];
|
|
33
|
+
stream: true;
|
|
34
|
+
max_tokens?: number;
|
|
35
|
+
temperature?: number;
|
|
36
|
+
stop?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StreamChunk {
|
|
40
|
+
choices: Array<{
|
|
41
|
+
delta: { content?: string; role?: Role };
|
|
42
|
+
finish_reason: string | null;
|
|
43
|
+
}>;
|
|
44
|
+
}
|
package/src/ui/App.tsx
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { dispatch, filterPaletteByPrefix, isSlash } from "../commands.ts";
|
|
4
|
+
import type { Conversation } from "../conversation.ts";
|
|
5
|
+
import { streamChat, type FetchFn } from "../llm.ts";
|
|
6
|
+
import { pickLayout } from "../renderer.ts";
|
|
7
|
+
import { detectPersonaDrift } from "../repl.ts";
|
|
8
|
+
import {
|
|
9
|
+
DRIFT_REMINDER,
|
|
10
|
+
EMPTY_NUDGE,
|
|
11
|
+
REMINDER_INTERVAL,
|
|
12
|
+
SIGINT_MSG,
|
|
13
|
+
STREAM_ERROR,
|
|
14
|
+
THINKING_LINES,
|
|
15
|
+
WITTICISMS,
|
|
16
|
+
} from "../sayings.ts";
|
|
17
|
+
import { MODEL_FALLBACK, MODEL_PRIMARY, type Config } from "../types.ts";
|
|
18
|
+
import { useTheme } from "./ThemeContext.tsx";
|
|
19
|
+
import { CommandPalette } from "./CommandPalette.tsx";
|
|
20
|
+
import { InputBox } from "./InputBox.tsx";
|
|
21
|
+
import { Message, StreamingMessage } from "./Message.tsx";
|
|
22
|
+
import { Spinner } from "./Spinner.tsx";
|
|
23
|
+
import { StatusBar } from "./StatusBar.tsx";
|
|
24
|
+
|
|
25
|
+
const MAX_INPUT_WIDTH = 80;
|
|
26
|
+
|
|
27
|
+
function pick<T>(arr: readonly T[]): T {
|
|
28
|
+
if (arr.length === 0) {
|
|
29
|
+
throw new Error("pick called on empty array");
|
|
30
|
+
}
|
|
31
|
+
return arr[Math.floor(Math.random() * arr.length)] as T;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pickFallback(model: string): string {
|
|
35
|
+
return model === MODEL_PRIMARY ? MODEL_FALLBACK : MODEL_PRIMARY;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ChatItem {
|
|
39
|
+
id: number;
|
|
40
|
+
role: "user" | "assistant" | "system";
|
|
41
|
+
content: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface AppProps {
|
|
45
|
+
conversation: Conversation;
|
|
46
|
+
config: Config;
|
|
47
|
+
fetchFn?: FetchFn;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function App({ conversation, config, fetchFn }: AppProps) {
|
|
51
|
+
const t = useTheme();
|
|
52
|
+
const { exit } = useApp();
|
|
53
|
+
const { stdout } = useStdout();
|
|
54
|
+
const [cols, setCols] = useState<number>(stdout?.columns ?? 80);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!stdout) return;
|
|
57
|
+
const handler = () => setCols(stdout.columns ?? 80);
|
|
58
|
+
stdout.on("resize", handler);
|
|
59
|
+
return () => {
|
|
60
|
+
stdout.off("resize", handler);
|
|
61
|
+
};
|
|
62
|
+
}, [stdout]);
|
|
63
|
+
const mode = pickLayout(cols);
|
|
64
|
+
const inputWidth = Math.max(1, Math.min(cols, MAX_INPUT_WIDTH));
|
|
65
|
+
|
|
66
|
+
const [items, setItems] = useState<ChatItem[]>([]);
|
|
67
|
+
const itemIdRef = useRef(0);
|
|
68
|
+
const addItem = useCallback((role: ChatItem["role"], content: string) => {
|
|
69
|
+
itemIdRef.current += 1;
|
|
70
|
+
setItems((prev) => [...prev, { id: itemIdRef.current, role, content }]);
|
|
71
|
+
}, []);
|
|
72
|
+
const removeLastAssistantItem = useCallback(() => {
|
|
73
|
+
setItems((prev) => {
|
|
74
|
+
const idx = prev.findLastIndex((item) => item.role === "assistant");
|
|
75
|
+
if (idx === -1) return prev;
|
|
76
|
+
return [...prev.slice(0, idx), ...prev.slice(idx + 1)];
|
|
77
|
+
});
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
const [input, setInput] = useState("");
|
|
81
|
+
const [cursor, setCursor] = useState(0);
|
|
82
|
+
const [streaming, setStreaming] = useState<string | null>(null);
|
|
83
|
+
const [thinking, setThinking] = useState<string | null>(null);
|
|
84
|
+
const [exitMsg, setExitMsg] = useState<string | null>(null);
|
|
85
|
+
const [witticism, setWitticism] = useState<string>(pick(WITTICISMS));
|
|
86
|
+
const [model, setModel] = useState<string>(config.model);
|
|
87
|
+
const [msgCount, setMsgCount] = useState<number>(0);
|
|
88
|
+
const [history, setHistory] = useState<string[]>([]);
|
|
89
|
+
const [historyIdx, setHistoryIdx] = useState<number | null>(null);
|
|
90
|
+
const [paletteIdx, setPaletteIdx] = useState(0);
|
|
91
|
+
|
|
92
|
+
const paletteItems = useMemo(() => filterPaletteByPrefix(input), [input]);
|
|
93
|
+
const paletteOpen = paletteItems.length > 0;
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
setPaletteIdx(0);
|
|
96
|
+
}, [input]);
|
|
97
|
+
|
|
98
|
+
// throttle streaming updates so React doesn't re-render every token
|
|
99
|
+
const streamBufRef = useRef("");
|
|
100
|
+
const streamTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
101
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
102
|
+
const cancelledRef = useRef(false);
|
|
103
|
+
const mountedRef = useRef(true);
|
|
104
|
+
const exitingRef = useRef(false);
|
|
105
|
+
const exitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
106
|
+
const flushStream = useCallback(() => {
|
|
107
|
+
if (!mountedRef.current) return;
|
|
108
|
+
setStreaming(streamBufRef.current);
|
|
109
|
+
streamTimerRef.current = null;
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
const triggerExit = useCallback(
|
|
113
|
+
(msg: string) => {
|
|
114
|
+
if (exitingRef.current) return;
|
|
115
|
+
exitingRef.current = true;
|
|
116
|
+
abortRef.current?.abort();
|
|
117
|
+
setExitMsg(msg);
|
|
118
|
+
exitTimerRef.current = setTimeout(() => exit(), 50);
|
|
119
|
+
},
|
|
120
|
+
[exit],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const pushTokenToStream = useCallback(
|
|
124
|
+
(t: string) => {
|
|
125
|
+
streamBufRef.current += t;
|
|
126
|
+
if (streamTimerRef.current === null) {
|
|
127
|
+
streamTimerRef.current = setTimeout(flushStream, 33);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
[flushStream],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const buildMessagesWithReminder = useCallback(() => {
|
|
134
|
+
const snap = conversation.snapshot();
|
|
135
|
+
const turns = conversation.userTurns;
|
|
136
|
+
if (turns > 0 && turns % REMINDER_INTERVAL === 0) {
|
|
137
|
+
return [...snap, { role: "system" as const, content: DRIFT_REMINDER }];
|
|
138
|
+
}
|
|
139
|
+
return snap;
|
|
140
|
+
}, [conversation]);
|
|
141
|
+
|
|
142
|
+
const runLLM = useCallback(async () => {
|
|
143
|
+
setThinking(pick(THINKING_LINES));
|
|
144
|
+
streamBufRef.current = "";
|
|
145
|
+
setStreaming(null);
|
|
146
|
+
let firstToken = true;
|
|
147
|
+
abortRef.current = new AbortController();
|
|
148
|
+
let result: Awaited<ReturnType<typeof streamChat>> | undefined;
|
|
149
|
+
let caughtErr: unknown = null;
|
|
150
|
+
try {
|
|
151
|
+
result = await streamChat({
|
|
152
|
+
apiKey: config.apiKey,
|
|
153
|
+
model,
|
|
154
|
+
fallbackModel: pickFallback(model),
|
|
155
|
+
messages: buildMessagesWithReminder(),
|
|
156
|
+
onToken: (t) => {
|
|
157
|
+
if (!mountedRef.current) return;
|
|
158
|
+
if (firstToken) {
|
|
159
|
+
setThinking(null);
|
|
160
|
+
firstToken = false;
|
|
161
|
+
}
|
|
162
|
+
pushTokenToStream(t);
|
|
163
|
+
},
|
|
164
|
+
signal: abortRef.current.signal,
|
|
165
|
+
fetchFn,
|
|
166
|
+
});
|
|
167
|
+
} catch (err) {
|
|
168
|
+
caughtErr = err;
|
|
169
|
+
} finally {
|
|
170
|
+
if (streamTimerRef.current !== null) {
|
|
171
|
+
clearTimeout(streamTimerRef.current);
|
|
172
|
+
streamTimerRef.current = null;
|
|
173
|
+
}
|
|
174
|
+
abortRef.current = null;
|
|
175
|
+
}
|
|
176
|
+
if (!mountedRef.current) return;
|
|
177
|
+
if (caughtErr) {
|
|
178
|
+
const msg = caughtErr instanceof Error ? caughtErr.message : String(caughtErr);
|
|
179
|
+
setThinking(null);
|
|
180
|
+
setStreaming(null);
|
|
181
|
+
addItem("system", `${STREAM_ERROR} [${msg}]`);
|
|
182
|
+
setMsgCount(conversation.length);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
setThinking(null);
|
|
186
|
+
setStreaming(null);
|
|
187
|
+
if (cancelledRef.current) {
|
|
188
|
+
cancelledRef.current = false;
|
|
189
|
+
if (result?.content) {
|
|
190
|
+
conversation.push("assistant", result.content);
|
|
191
|
+
addItem("assistant", result.content);
|
|
192
|
+
}
|
|
193
|
+
addItem("system", "(cancelled — Drexler taking lunch)");
|
|
194
|
+
} else if (result?.ok) {
|
|
195
|
+
conversation.push("assistant", result.content);
|
|
196
|
+
addItem("assistant", result.content);
|
|
197
|
+
if (result.fellBack) {
|
|
198
|
+
addItem("system", `(fell back to ${result.modelUsed})`);
|
|
199
|
+
}
|
|
200
|
+
if (detectPersonaDrift(result.content)) {
|
|
201
|
+
addItem("system", `(persona drift detected — model used 'I')`);
|
|
202
|
+
}
|
|
203
|
+
} else if (result?.interrupted) {
|
|
204
|
+
conversation.push("assistant", result.content);
|
|
205
|
+
addItem("assistant", result.content);
|
|
206
|
+
addItem("system", "(stream interrupted — partial response saved)");
|
|
207
|
+
} else {
|
|
208
|
+
const detail = result?.error ? ` [${result.error}]` : "";
|
|
209
|
+
addItem("system", `${STREAM_ERROR}${detail}`);
|
|
210
|
+
}
|
|
211
|
+
setMsgCount(conversation.length);
|
|
212
|
+
setWitticism(pick(WITTICISMS));
|
|
213
|
+
}, [
|
|
214
|
+
config,
|
|
215
|
+
model,
|
|
216
|
+
fetchFn,
|
|
217
|
+
addItem,
|
|
218
|
+
buildMessagesWithReminder,
|
|
219
|
+
conversation,
|
|
220
|
+
pushTokenToStream,
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
const handleSlashWithMutation = useCallback(
|
|
224
|
+
async (line: string): Promise<void> => {
|
|
225
|
+
let captured = "";
|
|
226
|
+
const mutableConfig: Config = { ...config, model };
|
|
227
|
+
const action = dispatch(line, {
|
|
228
|
+
conversation,
|
|
229
|
+
config: mutableConfig,
|
|
230
|
+
print: (s) => {
|
|
231
|
+
captured += (captured ? "\n" : "") + s;
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
const lower = line.toLowerCase().trim();
|
|
235
|
+
if (lower === "/clear" || lower.startsWith("/clear ")) {
|
|
236
|
+
setItems([]);
|
|
237
|
+
}
|
|
238
|
+
if (mutableConfig.model !== model) {
|
|
239
|
+
setModel(mutableConfig.model);
|
|
240
|
+
}
|
|
241
|
+
if (captured) addItem("system", captured);
|
|
242
|
+
if (action.type === "exit") {
|
|
243
|
+
triggerExit(action.message ?? SIGINT_MSG);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (action.type === "regenerate") {
|
|
247
|
+
removeLastAssistantItem();
|
|
248
|
+
await runLLM();
|
|
249
|
+
}
|
|
250
|
+
setMsgCount(conversation.length);
|
|
251
|
+
},
|
|
252
|
+
[
|
|
253
|
+
addItem,
|
|
254
|
+
conversation,
|
|
255
|
+
config,
|
|
256
|
+
model,
|
|
257
|
+
removeLastAssistantItem,
|
|
258
|
+
runLLM,
|
|
259
|
+
triggerExit,
|
|
260
|
+
],
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const onSubmit = useCallback(
|
|
264
|
+
async (raw: string) => {
|
|
265
|
+
const line = raw.trim();
|
|
266
|
+
if (line === "") {
|
|
267
|
+
addItem("system", EMPTY_NUDGE);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (isSlash(line)) {
|
|
271
|
+
await handleSlashWithMutation(line);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
addItem("user", line);
|
|
275
|
+
conversation.push("user", line);
|
|
276
|
+
setMsgCount(conversation.length);
|
|
277
|
+
await runLLM();
|
|
278
|
+
},
|
|
279
|
+
[addItem, conversation, handleSlashWithMutation, runLLM],
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
useInput((char, key) => {
|
|
283
|
+
if (streaming !== null || thinking !== null) {
|
|
284
|
+
if (key.escape) {
|
|
285
|
+
cancelledRef.current = true;
|
|
286
|
+
abortRef.current?.abort();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (key.ctrl && char === "c") {
|
|
290
|
+
triggerExit(SIGINT_MSG);
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (paletteOpen && key.tab) {
|
|
295
|
+
const sel = paletteItems[paletteIdx];
|
|
296
|
+
if (sel) {
|
|
297
|
+
setInput(sel.name + " ");
|
|
298
|
+
setCursor(sel.name.length + 1);
|
|
299
|
+
}
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (paletteOpen && key.return) {
|
|
303
|
+
const sel = paletteItems[paletteIdx];
|
|
304
|
+
if (sel) {
|
|
305
|
+
setInput("");
|
|
306
|
+
setCursor(0);
|
|
307
|
+
setHistoryIdx(null);
|
|
308
|
+
void onSubmit(sel.name);
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (key.return) {
|
|
313
|
+
const submitted = input;
|
|
314
|
+
setInput("");
|
|
315
|
+
setCursor(0);
|
|
316
|
+
setHistoryIdx(null);
|
|
317
|
+
const trimmedSubmit = submitted.trim();
|
|
318
|
+
if (trimmedSubmit.length > 0) {
|
|
319
|
+
setHistory((prev) => {
|
|
320
|
+
const next = [...prev, trimmedSubmit];
|
|
321
|
+
return next.length > 50 ? next.slice(-50) : next;
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
void onSubmit(submitted);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (key.ctrl && char === "c") {
|
|
328
|
+
triggerExit(SIGINT_MSG);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (paletteOpen && key.escape) {
|
|
332
|
+
setInput("");
|
|
333
|
+
setCursor(0);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (key.tab) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (key.backspace || key.delete) {
|
|
340
|
+
if (cursor > 0) {
|
|
341
|
+
setInput((prev) => prev.slice(0, cursor - 1) + prev.slice(cursor));
|
|
342
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (key.leftArrow) {
|
|
347
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (key.rightArrow) {
|
|
351
|
+
setCursor((c) => Math.min(input.length, c + 1));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (key.upArrow) {
|
|
355
|
+
if (paletteOpen) {
|
|
356
|
+
setPaletteIdx(
|
|
357
|
+
(i) => (i - 1 + paletteItems.length) % paletteItems.length,
|
|
358
|
+
);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (history.length === 0) return;
|
|
362
|
+
const idx = historyIdx === null ? history.length - 1 : Math.max(0, historyIdx - 1);
|
|
363
|
+
const entry = history[idx] ?? "";
|
|
364
|
+
setHistoryIdx(idx);
|
|
365
|
+
setInput(entry);
|
|
366
|
+
setCursor(entry.length);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (key.downArrow) {
|
|
370
|
+
if (paletteOpen) {
|
|
371
|
+
setPaletteIdx((i) => (i + 1) % paletteItems.length);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (historyIdx === null) return;
|
|
375
|
+
const next = historyIdx + 1;
|
|
376
|
+
if (next >= history.length) {
|
|
377
|
+
setHistoryIdx(null);
|
|
378
|
+
setInput("");
|
|
379
|
+
setCursor(0);
|
|
380
|
+
} else {
|
|
381
|
+
const entry = history[next] ?? "";
|
|
382
|
+
setHistoryIdx(next);
|
|
383
|
+
setInput(entry);
|
|
384
|
+
setCursor(entry.length);
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (key.ctrl && char === "a") {
|
|
389
|
+
setCursor(0);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (key.ctrl && char === "e") {
|
|
393
|
+
setCursor(input.length);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (key.ctrl && char === "u") {
|
|
397
|
+
setInput("");
|
|
398
|
+
setCursor(0);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// Plain text input. Filter out control chars except printable.
|
|
402
|
+
if (!key.ctrl && !key.meta && char) {
|
|
403
|
+
// accept multi-char (paste)
|
|
404
|
+
const filtered = char.replace(/[\x00-\x1f]/g, "");
|
|
405
|
+
if (filtered.length > 0) {
|
|
406
|
+
setInput((prev) => prev.slice(0, cursor) + filtered + prev.slice(cursor));
|
|
407
|
+
setCursor((c) => c + filtered.length);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
useEffect(() => {
|
|
413
|
+
return () => {
|
|
414
|
+
mountedRef.current = false;
|
|
415
|
+
abortRef.current?.abort();
|
|
416
|
+
if (streamTimerRef.current !== null) {
|
|
417
|
+
clearTimeout(streamTimerRef.current);
|
|
418
|
+
}
|
|
419
|
+
if (exitTimerRef.current !== null) {
|
|
420
|
+
clearTimeout(exitTimerRef.current);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
}, []);
|
|
424
|
+
|
|
425
|
+
const isBusy = streaming !== null || thinking !== null;
|
|
426
|
+
|
|
427
|
+
return (
|
|
428
|
+
<Box flexDirection="column">
|
|
429
|
+
<Box flexDirection="column">
|
|
430
|
+
{items.map((item) => (
|
|
431
|
+
<Box key={item.id} flexDirection="column">
|
|
432
|
+
<Message role={item.role} content={item.content} />
|
|
433
|
+
</Box>
|
|
434
|
+
))}
|
|
435
|
+
</Box>
|
|
436
|
+
|
|
437
|
+
<Box flexDirection="column">
|
|
438
|
+
{streaming !== null && (
|
|
439
|
+
<Box marginBottom={1}>
|
|
440
|
+
<StreamingMessage content={streaming} />
|
|
441
|
+
</Box>
|
|
442
|
+
)}
|
|
443
|
+
{thinking !== null && streaming === null && (
|
|
444
|
+
<Box paddingX={1} marginBottom={1}>
|
|
445
|
+
<Spinner label={thinking} />
|
|
446
|
+
</Box>
|
|
447
|
+
)}
|
|
448
|
+
{exitMsg !== null ? (
|
|
449
|
+
<Box paddingX={1} marginBottom={1}>
|
|
450
|
+
<Text color={t.primaryLight} bold>
|
|
451
|
+
{exitMsg}
|
|
452
|
+
</Text>
|
|
453
|
+
</Box>
|
|
454
|
+
) : (
|
|
455
|
+
<>
|
|
456
|
+
{paletteOpen && (
|
|
457
|
+
<CommandPalette items={paletteItems} selectedIdx={paletteIdx} />
|
|
458
|
+
)}
|
|
459
|
+
<Box flexDirection="column">
|
|
460
|
+
<InputBox
|
|
461
|
+
value={input}
|
|
462
|
+
cursor={cursor}
|
|
463
|
+
disabled={isBusy}
|
|
464
|
+
width={inputWidth}
|
|
465
|
+
/>
|
|
466
|
+
</Box>
|
|
467
|
+
<Box paddingLeft={2}>
|
|
468
|
+
<StatusBar
|
|
469
|
+
messageCount={msgCount}
|
|
470
|
+
witticism={witticism}
|
|
471
|
+
maxWidth={Math.max(1, inputWidth - 2)}
|
|
472
|
+
status={isBusy ? "streaming" : "idle"}
|
|
473
|
+
compact={mode === "very-narrow"}
|
|
474
|
+
/>
|
|
475
|
+
</Box>
|
|
476
|
+
</>
|
|
477
|
+
)}
|
|
478
|
+
</Box>
|
|
479
|
+
</Box>
|
|
480
|
+
);
|
|
481
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import type { SlashCommand } from "../commands.ts";
|
|
4
|
+
import { useTheme } from "./ThemeContext.tsx";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
items: ReadonlyArray<SlashCommand>;
|
|
8
|
+
selectedIdx: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function CommandPalette({ items, selectedIdx }: Props) {
|
|
12
|
+
const t = useTheme();
|
|
13
|
+
const maxNameW = useMemo(
|
|
14
|
+
() => items.reduce((m, i) => Math.max(m, i.name.length), 0),
|
|
15
|
+
[items],
|
|
16
|
+
);
|
|
17
|
+
if (items.length === 0) return null;
|
|
18
|
+
return (
|
|
19
|
+
<Box flexDirection="column" paddingX={1} marginBottom={1}>
|
|
20
|
+
{items.map((item, idx) => {
|
|
21
|
+
const sel = idx === selectedIdx;
|
|
22
|
+
return (
|
|
23
|
+
<Box key={item.name}>
|
|
24
|
+
<Text color={sel ? t.primaryLight : t.primary} bold={sel}>
|
|
25
|
+
{sel ? "❯ " : " "}
|
|
26
|
+
</Text>
|
|
27
|
+
<Text color={sel ? t.primaryLight : t.primary} bold={sel}>
|
|
28
|
+
{item.name.padEnd(maxNameW + 2)}
|
|
29
|
+
</Text>
|
|
30
|
+
<Text color={t.dim}>{item.description}</Text>
|
|
31
|
+
</Box>
|
|
32
|
+
);
|
|
33
|
+
})}
|
|
34
|
+
</Box>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { useTheme } from "./ThemeContext.tsx";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
value: string;
|
|
6
|
+
cursor: number;
|
|
7
|
+
disabled: boolean;
|
|
8
|
+
width: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function InputBox({ value, cursor, disabled, width }: Props) {
|
|
12
|
+
const t = useTheme();
|
|
13
|
+
// Grapheme-aware splitting so emoji / multi-byte chars don't render as
|
|
14
|
+
// broken surrogate pairs when the cursor lands mid-codepoint.
|
|
15
|
+
const chars = Array.from(value);
|
|
16
|
+
const safeCursor = Math.max(0, Math.min(cursor, chars.length));
|
|
17
|
+
const before = chars.slice(0, safeCursor).join("");
|
|
18
|
+
const at = chars[safeCursor] ?? " ";
|
|
19
|
+
const after = chars.slice(safeCursor + 1).join("");
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Box borderStyle="round" borderColor={t.primary} paddingX={1} width={width}>
|
|
23
|
+
<Text color={t.primaryLight} bold>
|
|
24
|
+
❯{" "}
|
|
25
|
+
</Text>
|
|
26
|
+
{disabled ? (
|
|
27
|
+
<Text color={t.dim}>(Drexler thinking… ESC to cancel)</Text>
|
|
28
|
+
) : (
|
|
29
|
+
<>
|
|
30
|
+
<Text color={t.text}>{before}</Text>
|
|
31
|
+
<Text inverse color={t.text}>
|
|
32
|
+
{at}
|
|
33
|
+
</Text>
|
|
34
|
+
<Text color={t.text}>{after}</Text>
|
|
35
|
+
</>
|
|
36
|
+
)}
|
|
37
|
+
</Box>
|
|
38
|
+
);
|
|
39
|
+
}
|