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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { memo } from "react";
|
|
3
|
+
import { useTheme } from "./ThemeContext.tsx";
|
|
4
|
+
|
|
5
|
+
export const MASCOT_WIDTH = 17;
|
|
6
|
+
|
|
7
|
+
export const BRIEFCASE_FINAL = [
|
|
8
|
+
" ╔════╗ ",
|
|
9
|
+
" ╔════╩════╩════╗",
|
|
10
|
+
" ║ \\__ __/ ║",
|
|
11
|
+
" ║ ◆ ◆ ║",
|
|
12
|
+
" ║ ╔════╗ ║",
|
|
13
|
+
" ║ ║ $$ ║ ║",
|
|
14
|
+
" ╚════╩════╩════╝",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export const BROW_LINES = {
|
|
18
|
+
hidden: " ║ ║",
|
|
19
|
+
raised: " ║ \\_ _/ ║",
|
|
20
|
+
focused: " ║ \\__ __/ ║",
|
|
21
|
+
flat: " ║ ── ── ║",
|
|
22
|
+
normal: BRIEFCASE_FINAL[2],
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export interface MascotState {
|
|
26
|
+
walls: "dim" | "on";
|
|
27
|
+
brows: keyof typeof BROW_LINES;
|
|
28
|
+
eyes: "hidden" | "open" | "closed";
|
|
29
|
+
showLock: boolean;
|
|
30
|
+
dollars: "hidden" | "on" | "dim";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function renderMascotLines(p: MascotState): string[] {
|
|
34
|
+
const lines: string[] = [...BRIEFCASE_FINAL];
|
|
35
|
+
lines[2] = BROW_LINES[p.brows];
|
|
36
|
+
lines[3] =
|
|
37
|
+
p.eyes === "open"
|
|
38
|
+
? BRIEFCASE_FINAL[3]
|
|
39
|
+
: p.eyes === "closed"
|
|
40
|
+
? " ║ ─ ─ ║"
|
|
41
|
+
: " ║ ║";
|
|
42
|
+
|
|
43
|
+
if (!p.showLock) {
|
|
44
|
+
lines[4] = " ║ ║";
|
|
45
|
+
lines[5] = " ║ ║";
|
|
46
|
+
} else if (p.dollars === "dim") {
|
|
47
|
+
lines[5] = " ║ ║ ░░ ║ ║";
|
|
48
|
+
} else if (p.dollars === "hidden") {
|
|
49
|
+
lines[5] = " ║ ║ ║ ║";
|
|
50
|
+
}
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function MascotFrameView(p: MascotState) {
|
|
55
|
+
const t = useTheme();
|
|
56
|
+
const wallColor = p.walls === "dim" ? t.primaryDim : t.primary;
|
|
57
|
+
const lines = renderMascotLines(p);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Box flexDirection="column" width={MASCOT_WIDTH} flexShrink={0}>
|
|
61
|
+
{lines.map((line, idx) => (
|
|
62
|
+
<Text key={idx} color={wallColor}>
|
|
63
|
+
{line}
|
|
64
|
+
</Text>
|
|
65
|
+
))}
|
|
66
|
+
</Box>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const MascotFrame = memo(MascotFrameView);
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { STARTUP_TIPS } from "../startupTips.ts";
|
|
4
|
+
import {
|
|
5
|
+
MascotFrame,
|
|
6
|
+
MASCOT_WIDTH,
|
|
7
|
+
type MascotState,
|
|
8
|
+
} from "./MascotFrame.tsx";
|
|
9
|
+
import { useTheme } from "./ThemeContext.tsx";
|
|
10
|
+
|
|
11
|
+
interface IntroFrame extends MascotState {
|
|
12
|
+
delayMs: number;
|
|
13
|
+
note: IntroBootNote;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const INTRO_STATUS_PREFIX = " ◆ ";
|
|
17
|
+
export const INTRO_BOOT_NOTES = [
|
|
18
|
+
"Briefcase boot",
|
|
19
|
+
"Deal tape live",
|
|
20
|
+
"Covenants OK",
|
|
21
|
+
"Risk marked",
|
|
22
|
+
"Capital set",
|
|
23
|
+
"Fees captured",
|
|
24
|
+
"Board notified",
|
|
25
|
+
"Final audit",
|
|
26
|
+
"Bid armed",
|
|
27
|
+
"Drexler online",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
type IntroBootNote = (typeof INTRO_BOOT_NOTES)[number];
|
|
31
|
+
|
|
32
|
+
const FRAMES: IntroFrame[] = [
|
|
33
|
+
{
|
|
34
|
+
walls: "dim",
|
|
35
|
+
brows: "hidden",
|
|
36
|
+
eyes: "hidden",
|
|
37
|
+
showLock: true,
|
|
38
|
+
dollars: "hidden",
|
|
39
|
+
delayMs: 520,
|
|
40
|
+
note: INTRO_BOOT_NOTES[0],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
walls: "on",
|
|
44
|
+
brows: "raised",
|
|
45
|
+
eyes: "hidden",
|
|
46
|
+
showLock: true,
|
|
47
|
+
dollars: "hidden",
|
|
48
|
+
delayMs: 520,
|
|
49
|
+
note: INTRO_BOOT_NOTES[1],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
walls: "on",
|
|
53
|
+
brows: "raised",
|
|
54
|
+
eyes: "open",
|
|
55
|
+
showLock: true,
|
|
56
|
+
dollars: "hidden",
|
|
57
|
+
delayMs: 560,
|
|
58
|
+
note: INTRO_BOOT_NOTES[2],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
walls: "on",
|
|
62
|
+
brows: "flat",
|
|
63
|
+
eyes: "closed",
|
|
64
|
+
showLock: true,
|
|
65
|
+
dollars: "dim",
|
|
66
|
+
delayMs: 420,
|
|
67
|
+
note: INTRO_BOOT_NOTES[3],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
walls: "on",
|
|
71
|
+
brows: "focused",
|
|
72
|
+
eyes: "open",
|
|
73
|
+
showLock: true,
|
|
74
|
+
dollars: "dim",
|
|
75
|
+
delayMs: 520,
|
|
76
|
+
note: INTRO_BOOT_NOTES[4],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
walls: "on",
|
|
80
|
+
brows: "normal",
|
|
81
|
+
eyes: "open",
|
|
82
|
+
showLock: true,
|
|
83
|
+
dollars: "on",
|
|
84
|
+
delayMs: 620,
|
|
85
|
+
note: INTRO_BOOT_NOTES[5],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
walls: "on",
|
|
89
|
+
brows: "flat",
|
|
90
|
+
eyes: "closed",
|
|
91
|
+
showLock: true,
|
|
92
|
+
dollars: "on",
|
|
93
|
+
delayMs: 360,
|
|
94
|
+
note: INTRO_BOOT_NOTES[6],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
walls: "on",
|
|
98
|
+
brows: "focused",
|
|
99
|
+
eyes: "open",
|
|
100
|
+
showLock: true,
|
|
101
|
+
dollars: "dim",
|
|
102
|
+
delayMs: 440,
|
|
103
|
+
note: INTRO_BOOT_NOTES[7],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
walls: "on",
|
|
107
|
+
brows: "raised",
|
|
108
|
+
eyes: "open",
|
|
109
|
+
showLock: true,
|
|
110
|
+
dollars: "on",
|
|
111
|
+
delayMs: 520,
|
|
112
|
+
note: INTRO_BOOT_NOTES[8],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
walls: "on",
|
|
116
|
+
brows: "normal",
|
|
117
|
+
eyes: "open",
|
|
118
|
+
showLock: true,
|
|
119
|
+
dollars: "on",
|
|
120
|
+
delayMs: 1200,
|
|
121
|
+
note: INTRO_BOOT_NOTES[9],
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const COMPACT_NOTES = ["Booting", "Scanning", "Online"];
|
|
126
|
+
const COMPACT_DELAY_MS = 850;
|
|
127
|
+
const SETTLE_HOLD_MS = 1200;
|
|
128
|
+
const FRAME_CHROME_WIDTH = 4;
|
|
129
|
+
const GUTTER_WIDTH = 4;
|
|
130
|
+
const SPLIT_DIVIDER_WIDTH = 3;
|
|
131
|
+
const BOOT_BAR_WIDTH = MASCOT_WIDTH - 1;
|
|
132
|
+
const SPLIT_DIVIDER_HEIGHT = 9;
|
|
133
|
+
const SPLIT_DIVIDER_ROWS: number[] = Array.from(
|
|
134
|
+
{ length: SPLIT_DIVIDER_HEIGHT },
|
|
135
|
+
(_, i) => i,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
interface IntroProps {
|
|
139
|
+
greeting: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function bootBar(frameIdx: number, total: number): string {
|
|
143
|
+
const active = Math.max(
|
|
144
|
+
1,
|
|
145
|
+
Math.ceil(((frameIdx + 1) / total) * BOOT_BAR_WIDTH),
|
|
146
|
+
);
|
|
147
|
+
return " " + "▰".repeat(active) + "▱".repeat(BOOT_BAR_WIDTH - active);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function TipsPanel({ width }: { width: number }) {
|
|
151
|
+
const t = useTheme();
|
|
152
|
+
const textWidth = Math.max(1, width);
|
|
153
|
+
return (
|
|
154
|
+
<Box flexDirection="column" width={textWidth}>
|
|
155
|
+
<Text bold color={t.primaryLight}>
|
|
156
|
+
Tips for getting started
|
|
157
|
+
</Text>
|
|
158
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
159
|
+
{STARTUP_TIPS.map((tip, idx) => (
|
|
160
|
+
<Text key={tip} color={t.dim}>
|
|
161
|
+
<Text color={t.primary}>{idx + 1}. </Text>
|
|
162
|
+
{tip}
|
|
163
|
+
</Text>
|
|
164
|
+
))}
|
|
165
|
+
</Box>
|
|
166
|
+
</Box>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function MascotIntro({ greeting }: IntroProps) {
|
|
171
|
+
const t = useTheme();
|
|
172
|
+
const { exit } = useApp();
|
|
173
|
+
const { stdout } = useStdout();
|
|
174
|
+
const [cols, setCols] = useState(stdout?.columns ?? 80);
|
|
175
|
+
const [frameIdx, setFrameIdx] = useState(0);
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!stdout) return;
|
|
179
|
+
const handler = () => setCols(stdout.columns ?? 80);
|
|
180
|
+
stdout.on("resize", handler);
|
|
181
|
+
return () => {
|
|
182
|
+
stdout.off("resize", handler);
|
|
183
|
+
};
|
|
184
|
+
}, [stdout]);
|
|
185
|
+
|
|
186
|
+
useInput((_input, key) => {
|
|
187
|
+
if (key.escape || key.return || (key.ctrl && _input === "c")) exit();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const mountedRef = useRef(true);
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
return () => {
|
|
193
|
+
mountedRef.current = false;
|
|
194
|
+
};
|
|
195
|
+
}, []);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
const compact = cols < 72;
|
|
199
|
+
const total = compact ? COMPACT_NOTES.length : FRAMES.length;
|
|
200
|
+
if (frameIdx >= total - 1) {
|
|
201
|
+
const handle = setTimeout(() => {
|
|
202
|
+
if (mountedRef.current) exit();
|
|
203
|
+
}, SETTLE_HOLD_MS);
|
|
204
|
+
return () => clearTimeout(handle);
|
|
205
|
+
}
|
|
206
|
+
const delay = compact
|
|
207
|
+
? COMPACT_DELAY_MS
|
|
208
|
+
: (FRAMES[frameIdx] ?? FRAMES[FRAMES.length - 1]!).delayMs;
|
|
209
|
+
const handle = setTimeout(() => {
|
|
210
|
+
if (mountedRef.current) setFrameIdx((i) => i + 1);
|
|
211
|
+
}, delay);
|
|
212
|
+
return () => clearTimeout(handle);
|
|
213
|
+
}, [cols, frameIdx, exit]);
|
|
214
|
+
|
|
215
|
+
const state = FRAMES[frameIdx] ?? FRAMES[FRAMES.length - 1]!;
|
|
216
|
+
// Below 21 cols, mascot (17) + gutter (4) overflow — render text-only.
|
|
217
|
+
const tinyTerminal = cols < 21;
|
|
218
|
+
const compact = cols < 72;
|
|
219
|
+
const sideBySide = cols >= 112;
|
|
220
|
+
const available = compact
|
|
221
|
+
? Math.max(1, cols - 1)
|
|
222
|
+
: Math.max(28, cols);
|
|
223
|
+
const innerWidth = compact
|
|
224
|
+
? available
|
|
225
|
+
: Math.max(24, available - FRAME_CHROME_WIDTH);
|
|
226
|
+
const leftPanelWidth = compact
|
|
227
|
+
? available
|
|
228
|
+
: sideBySide
|
|
229
|
+
? Math.max(
|
|
230
|
+
MASCOT_WIDTH + GUTTER_WIDTH + 24,
|
|
231
|
+
Math.floor((innerWidth - SPLIT_DIVIDER_WIDTH) / 2),
|
|
232
|
+
)
|
|
233
|
+
: innerWidth;
|
|
234
|
+
const tipsWidth = sideBySide
|
|
235
|
+
? Math.max(20, innerWidth - leftPanelWidth - SPLIT_DIVIDER_WIDTH)
|
|
236
|
+
: innerWidth;
|
|
237
|
+
const copyWidth = compact
|
|
238
|
+
? available
|
|
239
|
+
: sideBySide
|
|
240
|
+
? Math.max(18, leftPanelWidth - MASCOT_WIDTH - GUTTER_WIDTH)
|
|
241
|
+
: innerWidth;
|
|
242
|
+
const bar = bootBar(
|
|
243
|
+
Math.min(frameIdx, compact ? COMPACT_NOTES.length - 1 : FRAMES.length - 1),
|
|
244
|
+
compact ? COMPACT_NOTES.length : FRAMES.length,
|
|
245
|
+
);
|
|
246
|
+
const barColor =
|
|
247
|
+
frameIdx < (compact ? COMPACT_NOTES.length : FRAMES.length) / 3
|
|
248
|
+
? t.error
|
|
249
|
+
: frameIdx < ((compact ? COMPACT_NOTES.length : FRAMES.length) * 2) / 3
|
|
250
|
+
? t.warning
|
|
251
|
+
: t.primaryLight;
|
|
252
|
+
const note = compact
|
|
253
|
+
? COMPACT_NOTES[Math.min(frameIdx, COMPACT_NOTES.length - 1)]!
|
|
254
|
+
: state.note;
|
|
255
|
+
const mascotStatus = `${INTRO_STATUS_PREFIX}${note}`;
|
|
256
|
+
|
|
257
|
+
if (tinyTerminal) {
|
|
258
|
+
return (
|
|
259
|
+
<Box width={available} flexDirection="column">
|
|
260
|
+
<Text color={barColor}>{mascotStatus}</Text>
|
|
261
|
+
<Text bold color={t.primaryLight}>
|
|
262
|
+
Drexler™
|
|
263
|
+
</Text>
|
|
264
|
+
<Text color={t.primaryLight}>{greeting}</Text>
|
|
265
|
+
</Box>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (compact) {
|
|
270
|
+
return (
|
|
271
|
+
<Box marginLeft={1} width={available} flexDirection="column">
|
|
272
|
+
<Text color={barColor}>{bar}</Text>
|
|
273
|
+
<Text color={barColor}>{mascotStatus}</Text>
|
|
274
|
+
<Text bold color={t.primaryLight}>
|
|
275
|
+
Drexler International™
|
|
276
|
+
</Text>
|
|
277
|
+
<Text color={t.primaryLight}>{greeting}</Text>
|
|
278
|
+
</Box>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<Box width={available}>
|
|
284
|
+
<Box
|
|
285
|
+
width={available}
|
|
286
|
+
borderStyle="round"
|
|
287
|
+
borderColor={t.primary}
|
|
288
|
+
paddingX={1}
|
|
289
|
+
flexDirection={sideBySide ? "row" : "column"}
|
|
290
|
+
alignItems={sideBySide ? "flex-start" : "center"}
|
|
291
|
+
>
|
|
292
|
+
<Box flexDirection={sideBySide ? "row" : "column"} width={leftPanelWidth}>
|
|
293
|
+
<Box
|
|
294
|
+
width={MASCOT_WIDTH}
|
|
295
|
+
flexShrink={0}
|
|
296
|
+
flexDirection="column"
|
|
297
|
+
marginRight={sideBySide ? GUTTER_WIDTH : 0}
|
|
298
|
+
>
|
|
299
|
+
<MascotFrame {...state} />
|
|
300
|
+
<Text color={barColor}>{bar}</Text>
|
|
301
|
+
<Text color={barColor}>{mascotStatus}</Text>
|
|
302
|
+
</Box>
|
|
303
|
+
<Box
|
|
304
|
+
flexDirection="column"
|
|
305
|
+
justifyContent="center"
|
|
306
|
+
width={copyWidth}
|
|
307
|
+
marginTop={sideBySide ? 1 : 0}
|
|
308
|
+
>
|
|
309
|
+
<Text bold color={t.primaryLight}>
|
|
310
|
+
Drexler International™
|
|
311
|
+
</Text>
|
|
312
|
+
<Box height={1} />
|
|
313
|
+
<Text color={t.primaryLight}>{greeting}</Text>
|
|
314
|
+
<Box height={1} />
|
|
315
|
+
</Box>
|
|
316
|
+
</Box>
|
|
317
|
+
{sideBySide ? (
|
|
318
|
+
<>
|
|
319
|
+
<Box flexDirection="column" width={SPLIT_DIVIDER_WIDTH}>
|
|
320
|
+
{SPLIT_DIVIDER_ROWS.map((idx) => (
|
|
321
|
+
<Text key={idx} color={t.primaryDim}>
|
|
322
|
+
{" │ "}
|
|
323
|
+
</Text>
|
|
324
|
+
))}
|
|
325
|
+
</Box>
|
|
326
|
+
<Box width={tipsWidth}>
|
|
327
|
+
<TipsPanel width={tipsWidth} />
|
|
328
|
+
</Box>
|
|
329
|
+
</>
|
|
330
|
+
) : (
|
|
331
|
+
<Box marginTop={1} width={tipsWidth}>
|
|
332
|
+
<TipsPanel width={tipsWidth} />
|
|
333
|
+
</Box>
|
|
334
|
+
)}
|
|
335
|
+
</Box>
|
|
336
|
+
</Box>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { memo, useMemo } from "react";
|
|
3
|
+
import { renderMarkdown } from "../renderer.ts";
|
|
4
|
+
import { useTheme } from "./ThemeContext.tsx";
|
|
5
|
+
|
|
6
|
+
interface MessageItem {
|
|
7
|
+
role: "user" | "assistant" | "system";
|
|
8
|
+
content: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function Separator() {
|
|
12
|
+
const t = useTheme();
|
|
13
|
+
return (
|
|
14
|
+
<Box paddingX={1} marginBottom={1}>
|
|
15
|
+
<Text color={t.primaryDim}>{"─".repeat(40)}</Text>
|
|
16
|
+
</Box>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function MessageInner({ role, content }: MessageItem) {
|
|
21
|
+
const t = useTheme();
|
|
22
|
+
if (role === "user") {
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<Box paddingX={1} marginBottom={1}>
|
|
26
|
+
<Text color={t.dim}>❯ </Text>
|
|
27
|
+
<Text color={t.text}>{content}</Text>
|
|
28
|
+
</Box>
|
|
29
|
+
</>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (role === "system") {
|
|
33
|
+
return (
|
|
34
|
+
<Box paddingX={1} marginBottom={1}>
|
|
35
|
+
<Text color={t.dim} italic>
|
|
36
|
+
{content}
|
|
37
|
+
</Text>
|
|
38
|
+
</Box>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
// assistant: left accent bar + markdown rendering, separator below
|
|
42
|
+
const lines = useMemo(
|
|
43
|
+
() => renderMarkdown(content).trimEnd().split("\n"),
|
|
44
|
+
[content],
|
|
45
|
+
);
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
49
|
+
{lines.map((ln, i) => (
|
|
50
|
+
<Box key={i}>
|
|
51
|
+
<Text color={t.primary}>│ </Text>
|
|
52
|
+
<Text>{ln}</Text>
|
|
53
|
+
</Box>
|
|
54
|
+
))}
|
|
55
|
+
</Box>
|
|
56
|
+
<Separator />
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const Message = memo(MessageInner);
|
|
62
|
+
|
|
63
|
+
interface StreamingProps {
|
|
64
|
+
content: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function StreamingMessageInner({ content }: StreamingProps) {
|
|
68
|
+
const t = useTheme();
|
|
69
|
+
const lines = useMemo(() => content.split("\n"), [content]);
|
|
70
|
+
return (
|
|
71
|
+
<Box flexDirection="column">
|
|
72
|
+
{lines.map((ln, i) => (
|
|
73
|
+
<Box key={i}>
|
|
74
|
+
<Text color={t.primary}>│ </Text>
|
|
75
|
+
<Text color={t.text}>{ln}</Text>
|
|
76
|
+
</Box>
|
|
77
|
+
))}
|
|
78
|
+
</Box>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const StreamingMessage = memo(StreamingMessageInner);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { useTheme } from "./ThemeContext.tsx";
|
|
4
|
+
|
|
5
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Spinner({ label }: Props) {
|
|
12
|
+
const t = useTheme();
|
|
13
|
+
const [i, setI] = useState(0);
|
|
14
|
+
const [elapsedMs, setElapsedMs] = useState(0);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const start = Date.now();
|
|
18
|
+
const tick = setInterval(() => {
|
|
19
|
+
setI((x) => (x + 1) % FRAMES.length);
|
|
20
|
+
setElapsedMs(Date.now() - start);
|
|
21
|
+
}, 80);
|
|
22
|
+
return () => clearInterval(tick);
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
const seconds = Math.floor(elapsedMs / 1000);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Box>
|
|
29
|
+
<Text color={t.primary}>{FRAMES[i]} </Text>
|
|
30
|
+
<Text color={t.dim}>{label}…</Text>
|
|
31
|
+
{seconds > 0 ? (
|
|
32
|
+
<>
|
|
33
|
+
<Text color={t.primaryDim}>{" "}</Text>
|
|
34
|
+
<Text color={t.dim}>{seconds}s</Text>
|
|
35
|
+
</>
|
|
36
|
+
) : null}
|
|
37
|
+
</Box>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { useTheme } from "./ThemeContext.tsx";
|
|
3
|
+
|
|
4
|
+
export type StatusDot = "idle" | "streaming" | "error";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
messageCount: number;
|
|
8
|
+
witticism: string;
|
|
9
|
+
maxWidth?: number;
|
|
10
|
+
status?: StatusDot;
|
|
11
|
+
compact?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MAX_WITTICISM_LEN = 60;
|
|
15
|
+
|
|
16
|
+
function clampText(input: string, max: number): string {
|
|
17
|
+
if (input.length <= max) return input;
|
|
18
|
+
if (max <= 0) return "";
|
|
19
|
+
if (max === 1) return "…";
|
|
20
|
+
return input.slice(0, max - 1) + "…";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function StatusBar({
|
|
24
|
+
messageCount,
|
|
25
|
+
witticism,
|
|
26
|
+
maxWidth,
|
|
27
|
+
status = "idle",
|
|
28
|
+
compact = false,
|
|
29
|
+
}: Props) {
|
|
30
|
+
const t = useTheme();
|
|
31
|
+
const dotColor: Record<StatusDot, string> = {
|
|
32
|
+
idle: t.primaryLight,
|
|
33
|
+
streaming: t.warning,
|
|
34
|
+
error: t.error,
|
|
35
|
+
};
|
|
36
|
+
const countLabel = `${messageCount} message${messageCount === 1 ? "" : "s"}`;
|
|
37
|
+
const quoteWidth =
|
|
38
|
+
typeof maxWidth === "number"
|
|
39
|
+
? Math.max(0, maxWidth - "● ".length - countLabel.length - " │ ".length - 2)
|
|
40
|
+
: MAX_WITTICISM_LEN;
|
|
41
|
+
const safe = clampText(witticism, Math.min(MAX_WITTICISM_LEN, quoteWidth));
|
|
42
|
+
const box = compact ? (
|
|
43
|
+
<Box>
|
|
44
|
+
<Text color={dotColor[status]}>● </Text>
|
|
45
|
+
<Text color={t.dim}>{countLabel}</Text>
|
|
46
|
+
</Box>
|
|
47
|
+
) : (
|
|
48
|
+
<Box>
|
|
49
|
+
<Text color={dotColor[status]}>● </Text>
|
|
50
|
+
<Text color={t.dim}>{countLabel}</Text>
|
|
51
|
+
<Text color={t.primaryDim}>{" │ "}</Text>
|
|
52
|
+
<Text color={t.dim} italic>
|
|
53
|
+
"{safe}"
|
|
54
|
+
</Text>
|
|
55
|
+
</Box>
|
|
56
|
+
);
|
|
57
|
+
if (typeof maxWidth === "number") {
|
|
58
|
+
return <Box width={maxWidth}>{box}</Box>;
|
|
59
|
+
}
|
|
60
|
+
return box;
|
|
61
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import { getActiveTheme, type Theme } from "./themes.ts";
|
|
3
|
+
|
|
4
|
+
const ThemeCtx = createContext<Theme>(getActiveTheme());
|
|
5
|
+
export function ThemeProvider({ value, children }: { value: Theme; children: ReactNode }) {
|
|
6
|
+
return <ThemeCtx.Provider value={value}>{children}</ThemeCtx.Provider>;
|
|
7
|
+
}
|
|
8
|
+
export function useTheme(): Theme {
|
|
9
|
+
return useContext(ThemeCtx);
|
|
10
|
+
}
|
package/src/ui/themes.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
export interface Theme {
|
|
4
|
+
primary: string; // e.g. "#007e54" or "green"
|
|
5
|
+
primaryLight: string;
|
|
6
|
+
primaryDim: string;
|
|
7
|
+
text: string;
|
|
8
|
+
dim: string;
|
|
9
|
+
error: string;
|
|
10
|
+
warning: string;
|
|
11
|
+
ansi: boolean; // true = use named ANSI colors, false = hex
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ThemeName = "apollo" | "amber" | "mono";
|
|
15
|
+
|
|
16
|
+
export const THEMES: Record<ThemeName, Theme> = {
|
|
17
|
+
apollo: {
|
|
18
|
+
primary: "#007e54", primaryLight: "#00a86b", primaryDim: "#005c3a",
|
|
19
|
+
text: "#e0e0e0", dim: "#6b7280",
|
|
20
|
+
error: "#ef4444", warning: "#eab308",
|
|
21
|
+
ansi: false,
|
|
22
|
+
},
|
|
23
|
+
amber: {
|
|
24
|
+
primary: "#d97706", primaryLight: "#f59e0b", primaryDim: "#92400e",
|
|
25
|
+
text: "#e0e0e0", dim: "#6b7280",
|
|
26
|
+
error: "#ef4444", warning: "#eab308",
|
|
27
|
+
ansi: false,
|
|
28
|
+
},
|
|
29
|
+
mono: {
|
|
30
|
+
primary: "white", primaryLight: "white", primaryDim: "gray",
|
|
31
|
+
text: "white", dim: "gray",
|
|
32
|
+
error: "red", warning: "yellow",
|
|
33
|
+
ansi: true,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let active: Theme = THEMES.apollo;
|
|
38
|
+
|
|
39
|
+
export function setActiveTheme(name: ThemeName): void {
|
|
40
|
+
active = THEMES[name];
|
|
41
|
+
}
|
|
42
|
+
export function getActiveTheme(): Theme {
|
|
43
|
+
return active;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function selectTheme(opts: {
|
|
47
|
+
flag?: string; env?: string; configValue?: string;
|
|
48
|
+
}): ThemeName {
|
|
49
|
+
if (process.env.NO_COLOR && process.env.NO_COLOR.length > 0) return "mono";
|
|
50
|
+
const candidate = opts.flag ?? opts.env ?? opts.configValue ?? "apollo";
|
|
51
|
+
if (candidate === "apollo" || candidate === "amber" || candidate === "mono") {
|
|
52
|
+
return candidate;
|
|
53
|
+
}
|
|
54
|
+
console.error(`Unknown theme "${candidate}", falling back to apollo.`);
|
|
55
|
+
return "apollo";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ansiNamedColor(name: string): typeof chalk.white {
|
|
59
|
+
switch (name) {
|
|
60
|
+
case "black": return chalk.black;
|
|
61
|
+
case "red": return chalk.red;
|
|
62
|
+
case "green": return chalk.green;
|
|
63
|
+
case "yellow": return chalk.yellow;
|
|
64
|
+
case "blue": return chalk.blue;
|
|
65
|
+
case "magenta": return chalk.magenta;
|
|
66
|
+
case "cyan": return chalk.cyan;
|
|
67
|
+
case "white": return chalk.white;
|
|
68
|
+
case "gray":
|
|
69
|
+
case "grey":
|
|
70
|
+
return chalk.gray;
|
|
71
|
+
default:
|
|
72
|
+
return chalk.white;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function buildChalkColors(theme: Theme) {
|
|
77
|
+
const wrap = (color: string) =>
|
|
78
|
+
theme.ansi ? ansiNamedColor(color) : chalk.hex(color);
|
|
79
|
+
return {
|
|
80
|
+
apollo: wrap(theme.primary),
|
|
81
|
+
apolloLight: wrap(theme.primaryLight),
|
|
82
|
+
apolloDim: wrap(theme.primaryDim),
|
|
83
|
+
text: wrap(theme.text),
|
|
84
|
+
dim: wrap(theme.dim),
|
|
85
|
+
error: wrap(theme.error),
|
|
86
|
+
warning: wrap(theme.warning),
|
|
87
|
+
};
|
|
88
|
+
}
|