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.
@@ -0,0 +1,85 @@
1
+ import { Box, Text, render, useApp, useInput } from "ink";
2
+ import React, { useState } from "react";
3
+ import { isValidApiKey } from "../config.ts";
4
+
5
+ interface SetupPromptProps {
6
+ onDone: (value: string | null) => void;
7
+ }
8
+
9
+ function SetupPrompt({ onDone }: SetupPromptProps) {
10
+ const { exit } = useApp();
11
+ const [key, setKey] = useState("");
12
+ const [notice, setNotice] = useState<string | null>(null);
13
+
14
+ useInput((input, keypress) => {
15
+ if (keypress.ctrl && input === "c") {
16
+ onDone(null);
17
+ exit();
18
+ return;
19
+ }
20
+ if (keypress.return) {
21
+ const trimmed = key.trim();
22
+ if (!isValidApiKey(trimmed)) {
23
+ setNotice("Enter a valid OpenRouter API key before continuing.");
24
+ return;
25
+ }
26
+ onDone(trimmed);
27
+ exit();
28
+ return;
29
+ }
30
+ if (keypress.escape) {
31
+ onDone(null);
32
+ exit();
33
+ return;
34
+ }
35
+ if (keypress.backspace || keypress.delete) {
36
+ setKey((prev) => prev.slice(0, -1));
37
+ setNotice(null);
38
+ return;
39
+ }
40
+ if (!keypress.ctrl && !keypress.meta && input) {
41
+ const filtered = input.replace(/[\x00-\x1f]/g, "");
42
+ if (filtered.length > 0) {
43
+ setKey((prev) => prev + filtered);
44
+ setNotice(null);
45
+ }
46
+ }
47
+ });
48
+
49
+ const masked = key.length > 0 ? "•".repeat(Math.min(key.length, 48)) : "";
50
+
51
+ return (
52
+ <Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
53
+ <Text color="green" bold>
54
+ Drexler first-run setup
55
+ </Text>
56
+ <Text color="gray">OpenRouter key required. Get one at https://openrouter.ai/keys</Text>
57
+ <Box marginTop={1}>
58
+ <Text color="green" bold>
59
+ API key
60
+ </Text>
61
+ <Text color="gray"> │ </Text>
62
+ <Text>{masked}</Text>
63
+ <Text inverse>{key.length === 0 ? " " : ""}</Text>
64
+ </Box>
65
+ <Text color="gray">Enter saves securely. Esc cancels. Ctrl+C exits.</Text>
66
+ {notice ? <Text color="yellow">{notice}</Text> : null}
67
+ </Box>
68
+ );
69
+ }
70
+
71
+ export async function promptForApiKeyWithInk(): Promise<string | null> {
72
+ let resolvePrompt!: (value: string | null) => void;
73
+ const done = new Promise<string | null>((resolve) => {
74
+ resolvePrompt = resolve;
75
+ });
76
+ const instance = render(
77
+ React.createElement(SetupPrompt, {
78
+ onDone: (value) => resolvePrompt(value),
79
+ }),
80
+ { exitOnCtrlC: false },
81
+ );
82
+ const value = await done;
83
+ instance.unmount();
84
+ return value;
85
+ }
@@ -1,14 +1,23 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { useEffect, useState } from "react";
3
+ import { fitDisplayText } from "./graphemes.ts";
3
4
  import { useTheme } from "./ThemeContext.tsx";
4
5
 
5
6
  const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
+ const STAGES = [
8
+ "pricing risk",
9
+ "checking covenants",
10
+ "marking comps",
11
+ "drafting memo",
12
+ "tightening language",
13
+ ];
6
14
 
7
15
  interface Props {
8
16
  label: string;
17
+ width?: number;
9
18
  }
10
19
 
11
- export function Spinner({ label }: Props) {
20
+ export function Spinner({ label, width = 80 }: Props) {
12
21
  const t = useTheme();
13
22
  const [i, setI] = useState(0);
14
23
  const [elapsedMs, setElapsedMs] = useState(0);
@@ -23,14 +32,44 @@ export function Spinner({ label }: Props) {
23
32
  }, []);
24
33
 
25
34
  const seconds = Math.floor(elapsedMs / 1000);
35
+ const stage = STAGES[Math.floor(elapsedMs / 1600) % STAGES.length]!;
36
+ const safeWidth = Math.max(1, Math.floor(width));
37
+ const elapsedLabel = seconds > 0 ? ` · ${seconds}s` : "";
38
+ const detail = `${label} · ${stage}${elapsedLabel}`;
39
+
40
+ if (safeWidth < 24) {
41
+ return (
42
+ <Box width={safeWidth} flexShrink={1}>
43
+ <Text color={t.primaryLight} wrap="truncate">
44
+ {fitDisplayText(`${FRAMES[i]} ${detail}`, safeWidth)}
45
+ </Text>
46
+ </Box>
47
+ );
48
+ }
49
+
50
+ const labelBudget = Math.max(1, safeWidth - 22);
51
+ const showStage = safeWidth >= 42;
26
52
 
27
53
  return (
28
- <Box>
29
- <Text color={t.primary}>{FRAMES[i]} </Text>
30
- <Text color={t.dim}>{label}…</Text>
31
- {seconds > 0 ? (
54
+ <Box
55
+ borderStyle="round"
56
+ borderColor={t.primaryDim}
57
+ paddingX={1}
58
+ width={safeWidth}
59
+ flexShrink={1}
60
+ >
61
+ <Text color={t.primaryLight}>{FRAMES[i]} </Text>
62
+ <Text color={t.primaryLight} bold>
63
+ WORKING
64
+ </Text>
65
+ <Text color={t.primaryDim}> ─ </Text>
66
+ <Text color={t.text} wrap="truncate">
67
+ {fitDisplayText(label, labelBudget)}
68
+ </Text>
69
+ {showStage ? <Text color={t.dim}> · {stage}</Text> : null}
70
+ {seconds > 0 && safeWidth >= 34 ? (
32
71
  <>
33
- <Text color={t.primaryDim}>{" "}</Text>
72
+ <Text color={t.primaryDim}> · </Text>
34
73
  <Text color={t.dim}>{seconds}s</Text>
35
74
  </>
36
75
  ) : null}
@@ -1,4 +1,6 @@
1
1
  import { Box, Text } from "ink";
2
+ import { memo, useMemo } from "react";
3
+ import { fitDisplayText } from "./graphemes.ts";
2
4
  import { useTheme } from "./ThemeContext.tsx";
3
5
 
4
6
  export type StatusDot = "idle" | "streaming" | "error";
@@ -9,6 +11,7 @@ interface Props {
9
11
  maxWidth?: number;
10
12
  status?: StatusDot;
11
13
  compact?: boolean;
14
+ scrollHint?: string;
12
15
  }
13
16
 
14
17
  const MAX_WITTICISM_LEN = 60;
@@ -20,34 +23,61 @@ function clampText(input: string, max: number): string {
20
23
  return input.slice(0, max - 1) + "…";
21
24
  }
22
25
 
23
- export function StatusBar({
26
+ function StatusBarInner({
24
27
  messageCount,
25
28
  witticism,
26
29
  maxWidth,
27
30
  status = "idle",
28
31
  compact = false,
32
+ scrollHint,
29
33
  }: Props) {
30
34
  const t = useTheme();
31
- const dotColor: Record<StatusDot, string> = {
32
- idle: t.primaryLight,
33
- streaming: t.warning,
34
- error: t.error,
35
- };
35
+ const dotColor = useMemo<Record<StatusDot, string>>(
36
+ () => ({
37
+ idle: t.primaryLight,
38
+ streaming: t.warning,
39
+ error: t.error,
40
+ }),
41
+ [t.primaryLight, t.warning, t.error],
42
+ );
36
43
  const countLabel = `${messageCount} message${messageCount === 1 ? "" : "s"}`;
44
+ const hintLabel = scrollHint ? ` │ ${scrollHint}` : "";
37
45
  const quoteWidth =
38
46
  typeof maxWidth === "number"
39
- ? Math.max(0, maxWidth - "● ".length - countLabel.length - " │ ".length - 2)
47
+ ? Math.max(
48
+ 0,
49
+ maxWidth -
50
+ "● ".length -
51
+ countLabel.length -
52
+ hintLabel.length -
53
+ " │ ".length -
54
+ 2,
55
+ )
40
56
  : MAX_WITTICISM_LEN;
41
57
  const safe = clampText(witticism, Math.min(MAX_WITTICISM_LEN, quoteWidth));
42
58
  const box = compact ? (
43
59
  <Box>
44
60
  <Text color={dotColor[status]}>● </Text>
45
61
  <Text color={t.dim}>{countLabel}</Text>
62
+ {scrollHint ? (
63
+ <>
64
+ <Text color={t.primaryDim}>{" │ "}</Text>
65
+ <Text color={t.primaryLight}>
66
+ {fitDisplayText(scrollHint, Math.max(1, maxWidth ?? 24))}
67
+ </Text>
68
+ </>
69
+ ) : null}
46
70
  </Box>
47
71
  ) : (
48
72
  <Box>
49
73
  <Text color={dotColor[status]}>● </Text>
50
74
  <Text color={t.dim}>{countLabel}</Text>
75
+ {scrollHint ? (
76
+ <>
77
+ <Text color={t.primaryDim}>{" │ "}</Text>
78
+ <Text color={t.primaryLight}>{scrollHint}</Text>
79
+ </>
80
+ ) : null}
51
81
  <Text color={t.primaryDim}>{" │ "}</Text>
52
82
  <Text color={t.dim} italic>
53
83
  "{safe}"
@@ -59,3 +89,5 @@ export function StatusBar({
59
89
  }
60
90
  return box;
61
91
  }
92
+
93
+ export const StatusBar = memo(StatusBarInner);
@@ -0,0 +1,255 @@
1
+ import { Box, Text } from "ink";
2
+ import { Children, memo, useMemo, type ReactNode } from "react";
3
+ import { displayWidth, fitDisplayText } from "./graphemes.ts";
4
+ import { useTheme } from "./ThemeContext.tsx";
5
+
6
+ export interface TranscriptViewportItem {
7
+ id?: string | number;
8
+ role: "user" | "assistant" | "system";
9
+ content: string;
10
+ }
11
+
12
+ export interface TranscriptViewportProps {
13
+ items?: readonly TranscriptViewportItem[];
14
+ children?: ReactNode;
15
+ renderItem?: (item: TranscriptViewportItem, index: number) => ReactNode;
16
+ maxRows?: number;
17
+ cols?: number;
18
+ compact?: boolean;
19
+ scrollOffset?: number;
20
+ }
21
+
22
+ interface TranscriptEntry {
23
+ key: string;
24
+ node: ReactNode;
25
+ estimatedRows: number;
26
+ }
27
+
28
+ const DEFAULT_MAX_ROWS = 18;
29
+ const DEFAULT_COLS = 80;
30
+ const MIN_COLS = 1;
31
+
32
+ const ROLE_LABELS: Record<TranscriptViewportItem["role"], string> = {
33
+ user: "YOU",
34
+ assistant: "DREXLER",
35
+ system: "SYSTEM",
36
+ };
37
+
38
+ function lineCount(input: string): number {
39
+ if (input.length === 0) return 1;
40
+ return input.split("\n").length;
41
+ }
42
+
43
+ function itemRows(item: TranscriptViewportItem, compact: boolean): number {
44
+ if (compact) return 1;
45
+ return 1 + lineCount(item.content);
46
+ }
47
+
48
+ function roleColor(
49
+ role: TranscriptViewportItem["role"],
50
+ theme: ReturnType<typeof useTheme>,
51
+ ): string {
52
+ if (role === "system") return theme.warning;
53
+ return theme.primaryLight;
54
+ }
55
+
56
+ function DefaultTranscriptItem({
57
+ item,
58
+ compact,
59
+ cols,
60
+ }: {
61
+ item: TranscriptViewportItem;
62
+ compact: boolean;
63
+ cols: number;
64
+ }) {
65
+ const t = useTheme();
66
+ const label = ROLE_LABELS[item.role];
67
+
68
+ if (compact) {
69
+ const prefix = `${label} │ `;
70
+ const budget = Math.max(1, cols - displayWidth(prefix));
71
+ const firstLine = item.content.split("\n")[0] ?? "";
72
+ return (
73
+ <Box width={cols} flexShrink={1}>
74
+ <Text color={roleColor(item.role, t)} bold>
75
+ {fitDisplayText(prefix, cols)}
76
+ </Text>
77
+ {displayWidth(prefix) < cols ? (
78
+ <Text color={item.role === "system" ? t.dim : t.text} wrap="truncate">
79
+ {fitDisplayText(firstLine, budget)}
80
+ </Text>
81
+ ) : null}
82
+ </Box>
83
+ );
84
+ }
85
+
86
+ const contentWidth = Math.max(1, cols - 2);
87
+ return (
88
+ <Box flexDirection="column" width={cols} flexShrink={1}>
89
+ <Text color={roleColor(item.role, t)} bold wrap="truncate">
90
+ {fitDisplayText(label, cols)}
91
+ </Text>
92
+ {item.content.split("\n").map((line, index) => (
93
+ <Box key={index} paddingLeft={1} width={cols} flexShrink={1}>
94
+ <Text color={t.primaryDim}>│ </Text>
95
+ <Text color={item.role === "system" ? t.dim : t.text} wrap="truncate">
96
+ {fitDisplayText(line, contentWidth)}
97
+ </Text>
98
+ </Box>
99
+ ))}
100
+ </Box>
101
+ );
102
+ }
103
+
104
+ function childrenToEntries(children: ReactNode): TranscriptEntry[] {
105
+ return Children.toArray(children).map((child, index) => ({
106
+ key: `child-${index}`,
107
+ node: child,
108
+ estimatedRows: 1,
109
+ }));
110
+ }
111
+
112
+ function itemsToEntries({
113
+ items,
114
+ renderItem,
115
+ compact,
116
+ cols,
117
+ }: {
118
+ items: readonly TranscriptViewportItem[];
119
+ renderItem?: (item: TranscriptViewportItem, index: number) => ReactNode;
120
+ compact: boolean;
121
+ cols: number;
122
+ }): TranscriptEntry[] {
123
+ return items.map((item, index) => ({
124
+ key: String(item.id ?? index),
125
+ node: renderItem ? (
126
+ renderItem(item, index)
127
+ ) : (
128
+ <DefaultTranscriptItem item={item} compact={compact} cols={cols} />
129
+ ),
130
+ estimatedRows: itemRows(item, compact),
131
+ }));
132
+ }
133
+
134
+ function selectWindow(
135
+ entries: TranscriptEntry[],
136
+ maxRows: number,
137
+ scrollOffset: number,
138
+ ): {
139
+ visible: TranscriptEntry[];
140
+ hiddenBefore: number;
141
+ hiddenAfter: number;
142
+ } {
143
+ if (entries.length === 0) {
144
+ return { visible: [], hiddenBefore: 0, hiddenAfter: 0 };
145
+ }
146
+
147
+ const safeRows = Math.max(1, Math.floor(maxRows));
148
+ const safeOffset = Math.max(0, Math.min(Math.floor(scrollOffset), entries.length - 1));
149
+ const end = entries.length - safeOffset;
150
+ let reserveTop = 0;
151
+ const reserveBottom = safeOffset > 0 ? 1 : 0;
152
+ let start = Math.max(0, end - 1);
153
+
154
+ for (let pass = 0; pass < 3; pass++) {
155
+ const budget = Math.max(1, safeRows - reserveTop - reserveBottom);
156
+ let used = 0;
157
+ start = end;
158
+
159
+ while (start > 0) {
160
+ const entry = entries[start - 1]!;
161
+ const rows = Math.max(1, entry.estimatedRows);
162
+ if (used > 0 && used + rows > budget) break;
163
+ start -= 1;
164
+ used += rows;
165
+ if (used >= budget) break;
166
+ }
167
+
168
+ const nextReserveTop = start > 0 ? 1 : 0;
169
+ if (nextReserveTop === reserveTop) break;
170
+ reserveTop = nextReserveTop;
171
+ }
172
+
173
+ return {
174
+ visible: entries.slice(start, end),
175
+ hiddenBefore: start,
176
+ hiddenAfter: entries.length - end,
177
+ };
178
+ }
179
+
180
+ function ScrollIndicator({
181
+ direction,
182
+ count,
183
+ compact,
184
+ cols,
185
+ }: {
186
+ direction: "earlier" | "newer";
187
+ count: number;
188
+ compact: boolean;
189
+ cols: number;
190
+ }) {
191
+ const t = useTheme();
192
+ const arrow = direction === "earlier" ? "↑" : "↓";
193
+ const label = compact
194
+ ? `${arrow} ${count} ${direction}`
195
+ : `${arrow} ${count} ${direction} transcript item${count === 1 ? "" : "s"} hidden`;
196
+
197
+ return (
198
+ <Box width={cols} flexShrink={1}>
199
+ <Text color={t.primaryDim} wrap="truncate">
200
+ {fitDisplayText(label, cols)}
201
+ </Text>
202
+ </Box>
203
+ );
204
+ }
205
+
206
+ function TranscriptViewportInner({
207
+ items,
208
+ children,
209
+ renderItem,
210
+ maxRows = DEFAULT_MAX_ROWS,
211
+ cols = DEFAULT_COLS,
212
+ compact = false,
213
+ scrollOffset = 0,
214
+ }: TranscriptViewportProps) {
215
+ const width = Math.max(MIN_COLS, Math.floor(cols));
216
+ const entries = useMemo(
217
+ () =>
218
+ items
219
+ ? itemsToEntries({ items, renderItem, compact, cols: width })
220
+ : childrenToEntries(children),
221
+ [children, compact, items, renderItem, width],
222
+ );
223
+ const { visible, hiddenBefore, hiddenAfter } = useMemo(
224
+ () => selectWindow(entries, maxRows, scrollOffset),
225
+ [entries, maxRows, scrollOffset],
226
+ );
227
+
228
+ return (
229
+ <Box flexDirection="column" width={width} flexShrink={1}>
230
+ {hiddenBefore > 0 ? (
231
+ <ScrollIndicator
232
+ direction="earlier"
233
+ count={hiddenBefore}
234
+ compact={compact}
235
+ cols={width}
236
+ />
237
+ ) : null}
238
+ {visible.map((entry) => (
239
+ <Box key={entry.key} flexDirection="column" width={width} flexShrink={1}>
240
+ {entry.node}
241
+ </Box>
242
+ ))}
243
+ {hiddenAfter > 0 ? (
244
+ <ScrollIndicator
245
+ direction="newer"
246
+ count={hiddenAfter}
247
+ compact={compact}
248
+ cols={width}
249
+ />
250
+ ) : null}
251
+ </Box>
252
+ );
253
+ }
254
+
255
+ export const TranscriptViewport = memo(TranscriptViewportInner);
@@ -0,0 +1,119 @@
1
+ let segmenter: Intl.Segmenter | null = null;
2
+
3
+ function getSegmenter(): Intl.Segmenter | null {
4
+ if (typeof Intl.Segmenter !== "function") return null;
5
+ segmenter ??= new Intl.Segmenter(undefined, { granularity: "grapheme" });
6
+ return segmenter;
7
+ }
8
+
9
+ export function splitGraphemes(input: string): string[] {
10
+ const active = getSegmenter();
11
+ if (!active) return Array.from(input);
12
+ return Array.from(active.segment(input), (part) => part.segment);
13
+ }
14
+
15
+ export function graphemeLength(input: string): number {
16
+ return splitGraphemes(input).length;
17
+ }
18
+
19
+ function isWideCodePoint(codePoint: number): boolean {
20
+ return (
21
+ codePoint >= 0x1100 &&
22
+ (codePoint <= 0x115f ||
23
+ codePoint === 0x2329 ||
24
+ codePoint === 0x232a ||
25
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
26
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
27
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
28
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
29
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
30
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
31
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6))
32
+ );
33
+ }
34
+
35
+ function graphemeWidth(input: string): number {
36
+ if (input.length === 0) return 0;
37
+ if (/^\p{Mark}+$/u.test(input)) return 0;
38
+ if (/\p{Extended_Pictographic}/u.test(input)) return 2;
39
+ let width = 0;
40
+ for (const char of input) {
41
+ const codePoint = char.codePointAt(0) ?? 0;
42
+ if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
43
+ continue;
44
+ }
45
+ if (/\p{Mark}/u.test(char)) continue;
46
+ width += isWideCodePoint(codePoint) ? 2 : 1;
47
+ }
48
+ return width;
49
+ }
50
+
51
+ export function displayWidth(input: string): number {
52
+ return splitGraphemes(input).reduce(
53
+ (sum, grapheme) => sum + graphemeWidth(grapheme),
54
+ 0,
55
+ );
56
+ }
57
+
58
+ export function fitDisplayText(input: string, maxWidth: number): string {
59
+ if (maxWidth <= 0) return "";
60
+ if (displayWidth(input) <= maxWidth) return input;
61
+ if (maxWidth === 1) return "…";
62
+
63
+ let out = "";
64
+ for (const part of splitGraphemes(input)) {
65
+ if (displayWidth(`${out}${part}…`) > maxWidth) break;
66
+ out += part;
67
+ }
68
+ return `${out}…`;
69
+ }
70
+
71
+ export function clampCursor(input: string, cursor: number): number {
72
+ return Math.max(0, Math.min(cursor, graphemeLength(input)));
73
+ }
74
+
75
+ export function insertAtCursor(
76
+ input: string,
77
+ cursor: number,
78
+ inserted: string,
79
+ ): { value: string; cursor: number } {
80
+ const chars = splitGraphemes(input);
81
+ const safeCursor = Math.max(0, Math.min(cursor, chars.length));
82
+ const next = [
83
+ ...chars.slice(0, safeCursor),
84
+ inserted,
85
+ ...chars.slice(safeCursor),
86
+ ].join("");
87
+ return {
88
+ value: next,
89
+ cursor: safeCursor + graphemeLength(inserted),
90
+ };
91
+ }
92
+
93
+ export function deleteBeforeCursor(
94
+ input: string,
95
+ cursor: number,
96
+ ): { value: string; cursor: number } {
97
+ const chars = splitGraphemes(input);
98
+ const safeCursor = Math.max(0, Math.min(cursor, chars.length));
99
+ if (safeCursor === 0) return { value: input, cursor: 0 };
100
+ chars.splice(safeCursor - 1, 1);
101
+ return {
102
+ value: chars.join(""),
103
+ cursor: safeCursor - 1,
104
+ };
105
+ }
106
+
107
+ export function deleteAtCursor(
108
+ input: string,
109
+ cursor: number,
110
+ ): { value: string; cursor: number } {
111
+ const chars = splitGraphemes(input);
112
+ const safeCursor = Math.max(0, Math.min(cursor, chars.length));
113
+ if (safeCursor >= chars.length) return { value: input, cursor: safeCursor };
114
+ chars.splice(safeCursor, 1);
115
+ return {
116
+ value: chars.join(""),
117
+ cursor: safeCursor,
118
+ };
119
+ }
package/src/ui/themes.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import chalk from "chalk";
2
+ import { THEME_NAMES, type ThemeName } from "../types.ts";
2
3
 
3
4
  export interface Theme {
4
5
  primary: string; // e.g. "#007e54" or "green"
@@ -11,8 +12,6 @@ export interface Theme {
11
12
  ansi: boolean; // true = use named ANSI colors, false = hex
12
13
  }
13
14
 
14
- export type ThemeName = "apollo" | "amber" | "mono";
15
-
16
15
  export const THEMES: Record<ThemeName, Theme> = {
17
16
  apollo: {
18
17
  primary: "#007e54", primaryLight: "#00a86b", primaryDim: "#005c3a",
@@ -32,6 +31,36 @@ export const THEMES: Record<ThemeName, Theme> = {
32
31
  error: "red", warning: "yellow",
33
32
  ansi: true,
34
33
  },
34
+ terminal: {
35
+ primary: "green", primaryLight: "cyan", primaryDim: "gray",
36
+ text: "white", dim: "gray",
37
+ error: "red", warning: "yellow",
38
+ ansi: true,
39
+ },
40
+ dealroom: {
41
+ primary: "#0f766e", primaryLight: "#14b8a6", primaryDim: "#115e59",
42
+ text: "#f3f4f6", dim: "#94a3b8",
43
+ error: "#f43f5e", warning: "#f59e0b",
44
+ ansi: false,
45
+ },
46
+ midnight: {
47
+ primary: "#38bdf8", primaryLight: "#7dd3fc", primaryDim: "#0369a1",
48
+ text: "#e5e7eb", dim: "#64748b",
49
+ error: "#fb7185", warning: "#facc15",
50
+ ansi: false,
51
+ },
52
+ paper: {
53
+ primary: "#1d4ed8", primaryLight: "#2563eb", primaryDim: "#1e3a8a",
54
+ text: "#f8fafc", dim: "#94a3b8",
55
+ error: "#b91c1c", warning: "#b45309",
56
+ ansi: false,
57
+ },
58
+ plasma: {
59
+ primary: "#db2777", primaryLight: "#f472b6", primaryDim: "#7e22ce",
60
+ text: "#f8fafc", dim: "#94a3b8",
61
+ error: "#f43f5e", warning: "#f59e0b",
62
+ ansi: false,
63
+ },
35
64
  };
36
65
 
37
66
  let active: Theme = THEMES.apollo;
@@ -43,12 +72,16 @@ export function getActiveTheme(): Theme {
43
72
  return active;
44
73
  }
45
74
 
75
+ export function isThemeName(value: string | undefined): value is ThemeName {
76
+ return THEME_NAMES.includes(value as ThemeName);
77
+ }
78
+
46
79
  export function selectTheme(opts: {
47
80
  flag?: string; env?: string; configValue?: string;
48
81
  }): ThemeName {
49
82
  if (process.env.NO_COLOR && process.env.NO_COLOR.length > 0) return "mono";
50
83
  const candidate = opts.flag ?? opts.env ?? opts.configValue ?? "apollo";
51
- if (candidate === "apollo" || candidate === "amber" || candidate === "mono") {
84
+ if (isThemeName(candidate)) {
52
85
  return candidate;
53
86
  }
54
87
  console.error(`Unknown theme "${candidate}", falling back to apollo.`);