drexler 0.1.1 → 0.2.1
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 +17 -0
- package/README.md +25 -5
- package/package.json +2 -1
- package/src/commands.ts +515 -32
- package/src/config.ts +46 -11
- package/src/index.ts +47 -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 +103 -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
|
@@ -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
|
+
}
|
package/src/ui/Spinner.tsx
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
{
|
|
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}>
|
|
72
|
+
<Text color={t.primaryDim}> · </Text>
|
|
34
73
|
<Text color={t.dim}>{seconds}s</Text>
|
|
35
74
|
</>
|
|
36
75
|
) : null}
|
package/src/ui/StatusBar.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
|
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
|
|
84
|
+
if (isThemeName(candidate)) {
|
|
52
85
|
return candidate;
|
|
53
86
|
}
|
|
54
87
|
console.error(`Unknown theme "${candidate}", falling back to apollo.`);
|