drexler 0.2.12 → 0.2.14
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 +15 -0
- package/README.md +58 -13
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/ui/App.tsx +26 -14
- package/src/ui/CommandPalette.tsx +23 -13
- package/src/ui/DealDeskHeader.tsx +248 -80
- package/src/ui/MarkdownBody.tsx +382 -0
- package/src/ui/MascotIntro.tsx +568 -73
- package/src/ui/Message.tsx +28 -15
- package/src/ui/Spinner.tsx +11 -9
- package/src/ui/StatusBar.tsx +11 -43
- package/src/ui/SynergyEvent.tsx +3 -2
- package/src/ui/TranscriptViewport.tsx +271 -30
- package/src/ui/displayContent.ts +114 -0
- package/src/ui/graphemes.ts +1 -0
package/src/ui/Message.tsx
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import { memo, useMemo } from "react";
|
|
3
3
|
import { renderMarkdown } from "../renderer.ts";
|
|
4
|
+
import {
|
|
5
|
+
firstDisplayLine,
|
|
6
|
+
normalizeAssistantDisplayContent,
|
|
7
|
+
normalizeAssistantMarkdownRenderContent,
|
|
8
|
+
} from "./displayContent.ts";
|
|
4
9
|
import { fitDisplayText } from "./graphemes.ts";
|
|
10
|
+
import { MarkdownBody } from "./MarkdownBody.tsx";
|
|
5
11
|
import { useTheme } from "./ThemeContext.tsx";
|
|
6
12
|
|
|
7
13
|
interface MessageItem {
|
|
@@ -30,12 +36,16 @@ function Separator() {
|
|
|
30
36
|
|
|
31
37
|
function MessageInner({ role, content }: MessageItem) {
|
|
32
38
|
const t = useTheme();
|
|
39
|
+
const displayContent =
|
|
40
|
+
role === "assistant"
|
|
41
|
+
? normalizeAssistantMarkdownRenderContent(content)
|
|
42
|
+
: content;
|
|
33
43
|
const assistantLines = useMemo(
|
|
34
44
|
() =>
|
|
35
45
|
role === "assistant"
|
|
36
|
-
? renderMarkdown(
|
|
46
|
+
? renderMarkdown(displayContent).trimEnd().split("\n")
|
|
37
47
|
: [],
|
|
38
|
-
[
|
|
48
|
+
[displayContent, role],
|
|
39
49
|
);
|
|
40
50
|
|
|
41
51
|
if (role === "user") {
|
|
@@ -111,12 +121,16 @@ interface StreamingProps {
|
|
|
111
121
|
|
|
112
122
|
function StreamingMessageInner({ content, width = 80 }: StreamingProps) {
|
|
113
123
|
const t = useTheme();
|
|
114
|
-
const lines = useMemo(() => content.split("\n"), [content]);
|
|
115
124
|
const safeWidth = Math.max(1, Math.floor(width));
|
|
116
|
-
const
|
|
125
|
+
const innerWidth = Math.max(1, safeWidth - 2);
|
|
126
|
+
const compactDisplayContent = normalizeAssistantDisplayContent(content);
|
|
127
|
+
const markdownDisplayContent = normalizeAssistantMarkdownRenderContent(content);
|
|
117
128
|
|
|
118
129
|
if (safeWidth < 18) {
|
|
119
|
-
const compactLine = fitDisplayText(
|
|
130
|
+
const compactLine = fitDisplayText(
|
|
131
|
+
firstDisplayLine(compactDisplayContent).replace(/\s+/g, " "),
|
|
132
|
+
safeWidth,
|
|
133
|
+
);
|
|
120
134
|
return (
|
|
121
135
|
<Box width={safeWidth} flexShrink={1}>
|
|
122
136
|
<Text color={t.primaryLight} wrap="truncate">
|
|
@@ -135,16 +149,15 @@ function StreamingMessageInner({ content, width = 80 }: StreamingProps) {
|
|
|
135
149
|
<Text color={t.primaryDim}> ─ </Text>
|
|
136
150
|
<Text color={t.dim}>drafting live</Text>
|
|
137
151
|
</Box>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
))}
|
|
152
|
+
<MarkdownBody
|
|
153
|
+
content={markdownDisplayContent}
|
|
154
|
+
baseColor={t.text}
|
|
155
|
+
accentColor={t.primaryLight}
|
|
156
|
+
dimColor={t.dim}
|
|
157
|
+
codeColor={t.primaryDim}
|
|
158
|
+
width={innerWidth}
|
|
159
|
+
paddingLeft={1}
|
|
160
|
+
/>
|
|
148
161
|
</Box>
|
|
149
162
|
);
|
|
150
163
|
}
|
package/src/ui/Spinner.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
|
-
import { fitDisplayText } from "./graphemes.ts";
|
|
3
|
+
import { displayWidth, fitDisplayText } from "./graphemes.ts";
|
|
4
4
|
import { useTheme } from "./ThemeContext.tsx";
|
|
5
5
|
|
|
6
6
|
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
@@ -47,8 +47,15 @@ export function Spinner({ label, width = 80 }: Props) {
|
|
|
47
47
|
);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
const
|
|
50
|
+
const innerWidth = Math.max(1, safeWidth - 4);
|
|
51
51
|
const showStage = safeWidth >= 42;
|
|
52
|
+
const stageLabel = showStage ? ` · ${stage}` : "";
|
|
53
|
+
const secondsLabel = seconds > 0 && safeWidth >= 34 ? ` · ${seconds}s` : "";
|
|
54
|
+
const fixedWidth =
|
|
55
|
+
displayWidth(`${FRAMES[i]} WORKING ─ `) +
|
|
56
|
+
displayWidth(stageLabel) +
|
|
57
|
+
displayWidth(secondsLabel);
|
|
58
|
+
const labelBudget = Math.max(1, innerWidth - fixedWidth);
|
|
52
59
|
|
|
53
60
|
return (
|
|
54
61
|
<Box
|
|
@@ -66,13 +73,8 @@ export function Spinner({ label, width = 80 }: Props) {
|
|
|
66
73
|
<Text color={t.text} wrap="truncate">
|
|
67
74
|
{fitDisplayText(label, labelBudget)}
|
|
68
75
|
</Text>
|
|
69
|
-
{
|
|
70
|
-
{
|
|
71
|
-
<>
|
|
72
|
-
<Text color={t.primaryDim}> · </Text>
|
|
73
|
-
<Text color={t.dim}>{seconds}s</Text>
|
|
74
|
-
</>
|
|
75
|
-
) : null}
|
|
76
|
+
{stageLabel ? <Text color={t.dim}>{stageLabel}</Text> : null}
|
|
77
|
+
{secondsLabel ? <Text color={t.dim}>{secondsLabel}</Text> : null}
|
|
76
78
|
</Box>
|
|
77
79
|
);
|
|
78
80
|
}
|
package/src/ui/StatusBar.tsx
CHANGED
|
@@ -16,13 +16,6 @@ interface Props {
|
|
|
16
16
|
|
|
17
17
|
const MAX_WITTICISM_LEN = 60;
|
|
18
18
|
|
|
19
|
-
function clampText(input: string, max: number): string {
|
|
20
|
-
if (input.length <= max) return input;
|
|
21
|
-
if (max <= 0) return "";
|
|
22
|
-
if (max === 1) return "…";
|
|
23
|
-
return input.slice(0, max - 1) + "…";
|
|
24
|
-
}
|
|
25
|
-
|
|
26
19
|
function StatusBarInner({
|
|
27
20
|
messageCount,
|
|
28
21
|
witticism,
|
|
@@ -40,52 +33,27 @@ function StatusBarInner({
|
|
|
40
33
|
}),
|
|
41
34
|
[t.primaryLight, t.warning, t.error],
|
|
42
35
|
);
|
|
36
|
+
const safeWidth =
|
|
37
|
+
typeof maxWidth === "number" ? Math.max(1, Math.floor(maxWidth)) : undefined;
|
|
43
38
|
const countLabel = `${messageCount} message${messageCount === 1 ? "" : "s"}`;
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
maxWidth -
|
|
50
|
-
"● ".length -
|
|
51
|
-
countLabel.length -
|
|
52
|
-
hintLabel.length -
|
|
53
|
-
" │ ".length -
|
|
54
|
-
2,
|
|
55
|
-
)
|
|
56
|
-
: MAX_WITTICISM_LEN;
|
|
57
|
-
const safe = clampText(witticism, Math.min(MAX_WITTICISM_LEN, quoteWidth));
|
|
39
|
+
const quote = `"${fitDisplayText(witticism, MAX_WITTICISM_LEN)}"`;
|
|
40
|
+
const line = compact
|
|
41
|
+
? `${countLabel}${scrollHint ? ` │ ${scrollHint}` : ""}`
|
|
42
|
+
: `${countLabel}${scrollHint ? ` │ ${scrollHint}` : ""} │ ${quote}`;
|
|
43
|
+
const body = fitDisplayText(line, Math.max(1, (safeWidth ?? 80) - 2));
|
|
58
44
|
const box = compact ? (
|
|
59
45
|
<Box>
|
|
60
46
|
<Text color={dotColor[status]}>● </Text>
|
|
61
|
-
<Text color={t.dim}>{
|
|
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}
|
|
47
|
+
<Text color={t.dim} wrap="truncate">{body}</Text>
|
|
70
48
|
</Box>
|
|
71
49
|
) : (
|
|
72
50
|
<Box>
|
|
73
51
|
<Text color={dotColor[status]}>● </Text>
|
|
74
|
-
<Text color={t.dim}>{
|
|
75
|
-
{scrollHint ? (
|
|
76
|
-
<>
|
|
77
|
-
<Text color={t.primaryDim}>{" │ "}</Text>
|
|
78
|
-
<Text color={t.primaryLight}>{scrollHint}</Text>
|
|
79
|
-
</>
|
|
80
|
-
) : null}
|
|
81
|
-
<Text color={t.primaryDim}>{" │ "}</Text>
|
|
82
|
-
<Text color={t.dim} italic>
|
|
83
|
-
"{safe}"
|
|
84
|
-
</Text>
|
|
52
|
+
<Text color={t.dim} italic wrap="truncate">{body}</Text>
|
|
85
53
|
</Box>
|
|
86
54
|
);
|
|
87
|
-
if (typeof
|
|
88
|
-
return <Box width={
|
|
55
|
+
if (typeof safeWidth === "number") {
|
|
56
|
+
return <Box width={safeWidth}>{box}</Box>;
|
|
89
57
|
}
|
|
90
58
|
return box;
|
|
91
59
|
}
|
package/src/ui/SynergyEvent.tsx
CHANGED
|
@@ -16,8 +16,9 @@ export interface SynergyEventDefinition {
|
|
|
16
16
|
|
|
17
17
|
export const SYNERGY_EVENT_FRAMES = 28;
|
|
18
18
|
const FULL_EVENT_WIDTH = 88;
|
|
19
|
-
const
|
|
20
|
-
const
|
|
19
|
+
const FULL_EVENT_ART_ROWS = 6;
|
|
20
|
+
const FULL_EVENT_CHROME_ROWS = 7;
|
|
21
|
+
const FULL_EVENT_ROWS = FULL_EVENT_CHROME_ROWS + FULL_EVENT_ART_ROWS;
|
|
21
22
|
|
|
22
23
|
export const SYNERGY_EVENTS: readonly SynergyEventDefinition[] = [
|
|
23
24
|
{
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import { Children, memo, useMemo, type ReactNode } from "react";
|
|
3
|
+
import {
|
|
4
|
+
assistantDisplayLines,
|
|
5
|
+
firstDisplayLine,
|
|
6
|
+
normalizeAssistantDisplayContent,
|
|
7
|
+
type AssistantDisplayLine,
|
|
8
|
+
} from "./displayContent.ts";
|
|
3
9
|
import { displayWidth, fitDisplayText, splitGraphemes } from "./graphemes.ts";
|
|
4
10
|
import { useTheme } from "./ThemeContext.tsx";
|
|
5
11
|
|
|
@@ -41,6 +47,105 @@ const ROLE_MARKERS: Record<TranscriptViewportItem["role"], string> = {
|
|
|
41
47
|
system: "!",
|
|
42
48
|
};
|
|
43
49
|
|
|
50
|
+
const BODY_SUFFIX = " │";
|
|
51
|
+
const CONTINUATION_PREFIX = "│ ";
|
|
52
|
+
const CODE_GUTTER = "┃ ";
|
|
53
|
+
const DRACULA_CODE = {
|
|
54
|
+
text: "#f8f8f2",
|
|
55
|
+
keyword: "#ff79c6",
|
|
56
|
+
function: "#50fa7b",
|
|
57
|
+
string: "#f1fa8c",
|
|
58
|
+
number: "#bd93f9",
|
|
59
|
+
comment: "#6272a4",
|
|
60
|
+
operator: "#8be9fd",
|
|
61
|
+
gutter: "#6272a4",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
interface WrappedTranscriptLine {
|
|
65
|
+
kind: AssistantDisplayLine["kind"];
|
|
66
|
+
text: string;
|
|
67
|
+
language?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface CodeToken {
|
|
71
|
+
kind:
|
|
72
|
+
| "plain"
|
|
73
|
+
| "keyword"
|
|
74
|
+
| "function"
|
|
75
|
+
| "string"
|
|
76
|
+
| "number"
|
|
77
|
+
| "comment"
|
|
78
|
+
| "operator";
|
|
79
|
+
text: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const CODE_KEYWORDS = new Set([
|
|
83
|
+
"and",
|
|
84
|
+
"as",
|
|
85
|
+
"async",
|
|
86
|
+
"await",
|
|
87
|
+
"break",
|
|
88
|
+
"case",
|
|
89
|
+
"catch",
|
|
90
|
+
"class",
|
|
91
|
+
"const",
|
|
92
|
+
"continue",
|
|
93
|
+
"def",
|
|
94
|
+
"default",
|
|
95
|
+
"do",
|
|
96
|
+
"elif",
|
|
97
|
+
"else",
|
|
98
|
+
"except",
|
|
99
|
+
"export",
|
|
100
|
+
"extends",
|
|
101
|
+
"false",
|
|
102
|
+
"finally",
|
|
103
|
+
"for",
|
|
104
|
+
"from",
|
|
105
|
+
"function",
|
|
106
|
+
"if",
|
|
107
|
+
"import",
|
|
108
|
+
"in",
|
|
109
|
+
"interface",
|
|
110
|
+
"let",
|
|
111
|
+
"new",
|
|
112
|
+
"none",
|
|
113
|
+
"not",
|
|
114
|
+
"null",
|
|
115
|
+
"or",
|
|
116
|
+
"pass",
|
|
117
|
+
"return",
|
|
118
|
+
"switch",
|
|
119
|
+
"throw",
|
|
120
|
+
"true",
|
|
121
|
+
"try",
|
|
122
|
+
"type",
|
|
123
|
+
"var",
|
|
124
|
+
"while",
|
|
125
|
+
"with",
|
|
126
|
+
"yield",
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
const CODE_OPERATOR_RE = /^[()[\]{}.,:;+\-*/%=<>!&|^~?]+/u;
|
|
130
|
+
const CODE_NUMBER_RE = /^\b(?:0x[\da-f]+|\d+(?:\.\d+)?)\b/iu;
|
|
131
|
+
const CODE_IDENTIFIER_RE = /^[A-Za-z_$][\w$]*/u;
|
|
132
|
+
|
|
133
|
+
function bodyPrefixForRole(role: TranscriptViewportItem["role"]): string {
|
|
134
|
+
if (role === "user") return "│ › ";
|
|
135
|
+
if (role === "assistant") return "│ ◆ ";
|
|
136
|
+
return CONTINUATION_PREFIX;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function transcriptContentWidth(
|
|
140
|
+
role: TranscriptViewportItem["role"],
|
|
141
|
+
cols: number,
|
|
142
|
+
): number {
|
|
143
|
+
return Math.max(
|
|
144
|
+
1,
|
|
145
|
+
cols - displayWidth(bodyPrefixForRole(role)) - displayWidth(BODY_SUFFIX),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
44
149
|
function wrapDisplayLine(input: string, maxWidth: number): string[] {
|
|
45
150
|
const width = Math.max(1, maxWidth);
|
|
46
151
|
if (input.length === 0) return [""];
|
|
@@ -91,10 +196,99 @@ function wrapDisplayLine(input: string, maxWidth: number): string[] {
|
|
|
91
196
|
return rows.filter((row, index) => row.length > 0 || index === 0);
|
|
92
197
|
}
|
|
93
198
|
|
|
94
|
-
function
|
|
95
|
-
return content
|
|
96
|
-
|
|
97
|
-
|
|
199
|
+
function displayContentForItem(item: TranscriptViewportItem): string {
|
|
200
|
+
if (item.role !== "assistant") return item.content;
|
|
201
|
+
return normalizeAssistantDisplayContent(item.content);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function displayLinesForItem(item: TranscriptViewportItem): AssistantDisplayLine[] {
|
|
205
|
+
if (item.role === "assistant") return assistantDisplayLines(item.content);
|
|
206
|
+
return item.content.split("\n").map((text) => ({ kind: "text", text }));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function wrappedTranscriptLines(
|
|
210
|
+
item: TranscriptViewportItem,
|
|
211
|
+
contentWidth: number,
|
|
212
|
+
): WrappedTranscriptLine[] {
|
|
213
|
+
return displayLinesForItem(item).flatMap((line) => {
|
|
214
|
+
const width =
|
|
215
|
+
line.kind === "code"
|
|
216
|
+
? Math.max(1, contentWidth - displayWidth(CODE_GUTTER))
|
|
217
|
+
: contentWidth;
|
|
218
|
+
return wrapDisplayLine(line.text, width).map((text) => ({
|
|
219
|
+
kind: line.kind,
|
|
220
|
+
text,
|
|
221
|
+
language: line.language,
|
|
222
|
+
}));
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function tokenizeCodeLine(line: string): CodeToken[] {
|
|
227
|
+
const tokens: CodeToken[] = [];
|
|
228
|
+
let rest = line;
|
|
229
|
+
|
|
230
|
+
while (rest.length > 0) {
|
|
231
|
+
const comment =
|
|
232
|
+
rest.startsWith("#") || rest.startsWith("//") ? rest : undefined;
|
|
233
|
+
if (comment !== undefined) {
|
|
234
|
+
tokens.push({ kind: "comment", text: comment });
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const stringQuote = rest[0];
|
|
239
|
+
if (stringQuote === "\"" || stringQuote === "'" || stringQuote === "`") {
|
|
240
|
+
let end = 1;
|
|
241
|
+
let escaped = false;
|
|
242
|
+
while (end < rest.length) {
|
|
243
|
+
const char = rest[end];
|
|
244
|
+
if (escaped) {
|
|
245
|
+
escaped = false;
|
|
246
|
+
} else if (char === "\\") {
|
|
247
|
+
escaped = true;
|
|
248
|
+
} else if (char === stringQuote) {
|
|
249
|
+
end += 1;
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
end += 1;
|
|
253
|
+
}
|
|
254
|
+
tokens.push({ kind: "string", text: rest.slice(0, end) });
|
|
255
|
+
rest = rest.slice(end);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const number = CODE_NUMBER_RE.exec(rest)?.[0];
|
|
260
|
+
if (number) {
|
|
261
|
+
tokens.push({ kind: "number", text: number });
|
|
262
|
+
rest = rest.slice(number.length);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const identifier = CODE_IDENTIFIER_RE.exec(rest)?.[0];
|
|
267
|
+
if (identifier) {
|
|
268
|
+
const after = rest.slice(identifier.length);
|
|
269
|
+
const kind =
|
|
270
|
+
CODE_KEYWORDS.has(identifier.toLowerCase())
|
|
271
|
+
? "keyword"
|
|
272
|
+
: /^\s*\(/u.test(after)
|
|
273
|
+
? "function"
|
|
274
|
+
: "plain";
|
|
275
|
+
tokens.push({ kind, text: identifier });
|
|
276
|
+
rest = rest.slice(identifier.length);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const operator = CODE_OPERATOR_RE.exec(rest)?.[0];
|
|
281
|
+
if (operator) {
|
|
282
|
+
tokens.push({ kind: "operator", text: operator });
|
|
283
|
+
rest = rest.slice(operator.length);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
tokens.push({ kind: "plain", text: rest[0]! });
|
|
288
|
+
rest = rest.slice(1);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return tokens;
|
|
98
292
|
}
|
|
99
293
|
|
|
100
294
|
function itemRows(
|
|
@@ -103,13 +297,8 @@ function itemRows(
|
|
|
103
297
|
cols: number,
|
|
104
298
|
): number {
|
|
105
299
|
if (compact) return 1;
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
const contentWidth = Math.max(
|
|
109
|
-
1,
|
|
110
|
-
cols - displayWidth(bodyPrefix) - displayWidth(bodySuffix),
|
|
111
|
-
);
|
|
112
|
-
return 2 + wrappedContentRows(item.content, contentWidth).length;
|
|
300
|
+
const contentWidth = transcriptContentWidth(item.role, cols);
|
|
301
|
+
return 2 + wrappedTranscriptLines(item, contentWidth).length;
|
|
113
302
|
}
|
|
114
303
|
|
|
115
304
|
function roleAccentColor(
|
|
@@ -145,12 +334,39 @@ function DefaultTranscriptItem({
|
|
|
145
334
|
const t = useTheme();
|
|
146
335
|
const label = ROLE_LABELS[item.role];
|
|
147
336
|
const accent = roleAccentColor(item.role, t);
|
|
337
|
+
const renderCodeLine = (line: string) =>
|
|
338
|
+
tokenizeCodeLine(line).map((token, tokenIndex) => {
|
|
339
|
+
const color =
|
|
340
|
+
token.kind === "keyword"
|
|
341
|
+
? DRACULA_CODE.keyword
|
|
342
|
+
: token.kind === "function"
|
|
343
|
+
? DRACULA_CODE.function
|
|
344
|
+
: token.kind === "string"
|
|
345
|
+
? DRACULA_CODE.string
|
|
346
|
+
: token.kind === "number" || token.kind === "operator"
|
|
347
|
+
? token.kind === "number"
|
|
348
|
+
? DRACULA_CODE.number
|
|
349
|
+
: DRACULA_CODE.operator
|
|
350
|
+
: token.kind === "comment"
|
|
351
|
+
? DRACULA_CODE.comment
|
|
352
|
+
: DRACULA_CODE.text;
|
|
353
|
+
return (
|
|
354
|
+
<Text
|
|
355
|
+
key={tokenIndex}
|
|
356
|
+
color={color}
|
|
357
|
+
bold={token.kind === "keyword" || token.kind === "function"}
|
|
358
|
+
italic={token.kind === "comment"}
|
|
359
|
+
>
|
|
360
|
+
{token.text}
|
|
361
|
+
</Text>
|
|
362
|
+
);
|
|
363
|
+
});
|
|
148
364
|
|
|
149
365
|
if (compact) {
|
|
150
366
|
const marker = item.role === "assistant" ? "◆" : ROLE_MARKERS[item.role];
|
|
151
367
|
const prefix = `${label} ${marker} `;
|
|
152
368
|
const budget = Math.max(1, cols - displayWidth(prefix));
|
|
153
|
-
const firstLine = item
|
|
369
|
+
const firstLine = firstDisplayLine(displayContentForItem(item));
|
|
154
370
|
return (
|
|
155
371
|
<Box width={cols} flexShrink={1}>
|
|
156
372
|
<Text color={accent} bold>
|
|
@@ -168,13 +384,9 @@ function DefaultTranscriptItem({
|
|
|
168
384
|
const headerPrefix = `╭─ ${label} `;
|
|
169
385
|
const headerRuleWidth = Math.max(0, cols - displayWidth(headerPrefix) - 1);
|
|
170
386
|
const footerWidth = Math.max(0, cols - 2);
|
|
171
|
-
const bodyPrefix = item.role
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
const contentWidth = Math.max(
|
|
175
|
-
1,
|
|
176
|
-
cols - displayWidth(bodyPrefix) - displayWidth(bodySuffix),
|
|
177
|
-
);
|
|
387
|
+
const bodyPrefix = bodyPrefixForRole(item.role);
|
|
388
|
+
const contentWidth = transcriptContentWidth(item.role, cols);
|
|
389
|
+
const displayLines = wrappedTranscriptLines(item, contentWidth);
|
|
178
390
|
|
|
179
391
|
return (
|
|
180
392
|
<Box flexDirection="column" width={cols} flexShrink={1}>
|
|
@@ -184,18 +396,39 @@ function DefaultTranscriptItem({
|
|
|
184
396
|
cols,
|
|
185
397
|
)}
|
|
186
398
|
</Text>
|
|
187
|
-
{
|
|
399
|
+
{displayLines.map((line, index) => (
|
|
188
400
|
<Box key={index} width={cols} flexShrink={1}>
|
|
189
401
|
<Text color={accent} bold={item.role === "user"}>
|
|
190
|
-
{index === 0 ? bodyPrefix :
|
|
402
|
+
{index === 0 ? bodyPrefix : CONTINUATION_PREFIX}
|
|
191
403
|
</Text>
|
|
192
|
-
|
|
193
|
-
{
|
|
404
|
+
{line.kind === "code" ? (
|
|
405
|
+
<Text color={DRACULA_CODE.gutter}>{CODE_GUTTER}</Text>
|
|
406
|
+
) : null}
|
|
407
|
+
<Text
|
|
408
|
+
color={
|
|
409
|
+
line.kind === "code"
|
|
410
|
+
? DRACULA_CODE.text
|
|
411
|
+
: roleBodyColor(item.role, t)
|
|
412
|
+
}
|
|
413
|
+
>
|
|
414
|
+
{line.kind === "code"
|
|
415
|
+
? renderCodeLine(
|
|
416
|
+
fitDisplayText(
|
|
417
|
+
line.text,
|
|
418
|
+
Math.max(1, contentWidth - displayWidth(CODE_GUTTER)),
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
: fitDisplayText(line.text, contentWidth)}
|
|
194
422
|
</Text>
|
|
195
423
|
<Text color={accent} bold={item.role === "user"}>
|
|
196
424
|
{`${" ".repeat(
|
|
197
|
-
Math.max(
|
|
198
|
-
|
|
425
|
+
Math.max(
|
|
426
|
+
0,
|
|
427
|
+
contentWidth -
|
|
428
|
+
displayWidth(line.text) -
|
|
429
|
+
(line.kind === "code" ? displayWidth(CODE_GUTTER) : 0),
|
|
430
|
+
),
|
|
431
|
+
)}${BODY_SUFFIX}`}
|
|
199
432
|
</Text>
|
|
200
433
|
</Box>
|
|
201
434
|
))}
|
|
@@ -275,11 +508,19 @@ function selectWindow(
|
|
|
275
508
|
reserveTop = nextReserveTop;
|
|
276
509
|
}
|
|
277
510
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
511
|
+
const visible = entries.slice(start, end);
|
|
512
|
+
const visibleRows = visible.reduce(
|
|
513
|
+
(sum, entry) => sum + Math.max(1, entry.estimatedRows),
|
|
514
|
+
0,
|
|
515
|
+
);
|
|
516
|
+
let hiddenBefore = start;
|
|
517
|
+
let hiddenAfter = entries.length - end;
|
|
518
|
+
if (visibleRows + (hiddenBefore > 0 ? 1 : 0) + (hiddenAfter > 0 ? 1 : 0) > safeRows) {
|
|
519
|
+
if (hiddenBefore > 0) hiddenBefore = 0;
|
|
520
|
+
if (visibleRows + (hiddenAfter > 0 ? 1 : 0) > safeRows) hiddenAfter = 0;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return { visible, hiddenBefore, hiddenAfter };
|
|
283
524
|
}
|
|
284
525
|
|
|
285
526
|
function ScrollIndicator({
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const FENCE_OPEN_RE = /^[ \t]*(`{3,}|~{3,})([^`~\n\r]*)$/u;
|
|
2
|
+
const TAB_DISPLAY = " ";
|
|
3
|
+
|
|
4
|
+
export interface AssistantDisplayLine {
|
|
5
|
+
kind: "text" | "code";
|
|
6
|
+
text: string;
|
|
7
|
+
language?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isFenceClose(line: string, fenceChar: string, minLength: number): boolean {
|
|
11
|
+
const trimmed = line.trim();
|
|
12
|
+
if (trimmed.length < minLength) return false;
|
|
13
|
+
for (const char of trimmed) {
|
|
14
|
+
if (char !== fenceChar) return false;
|
|
15
|
+
}
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isMarkdownFence(info: string): boolean {
|
|
20
|
+
const lang = info.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
|
|
21
|
+
return lang === "markdown" || lang === "md" || lang === "mdown";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fenceLanguage(info: string): string | undefined {
|
|
25
|
+
const lang = info.trim().split(/\s+/u)[0]?.toLowerCase();
|
|
26
|
+
return lang && !isMarkdownFence(lang) ? lang : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assistantDisplayLines(content: string): AssistantDisplayLine[] {
|
|
30
|
+
const lines = content.replace(/\r\n?/gu, "\n").split("\n");
|
|
31
|
+
const output: AssistantDisplayLine[] = [];
|
|
32
|
+
let fenceChar = "";
|
|
33
|
+
let fenceLength = 0;
|
|
34
|
+
let fenceKind: AssistantDisplayLine["kind"] = "text";
|
|
35
|
+
let fenceLang: string | undefined;
|
|
36
|
+
|
|
37
|
+
for (const rawLine of lines) {
|
|
38
|
+
const line = rawLine.replace(/\t/gu, TAB_DISPLAY);
|
|
39
|
+
if (fenceChar.length > 0) {
|
|
40
|
+
if (isFenceClose(line, fenceChar, fenceLength)) {
|
|
41
|
+
fenceChar = "";
|
|
42
|
+
fenceLength = 0;
|
|
43
|
+
fenceKind = "text";
|
|
44
|
+
fenceLang = undefined;
|
|
45
|
+
} else {
|
|
46
|
+
output.push({ kind: fenceKind, text: line, language: fenceLang });
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const openingFence = FENCE_OPEN_RE.exec(line);
|
|
52
|
+
if (openingFence) {
|
|
53
|
+
const marker = openingFence[1]!;
|
|
54
|
+
const info = openingFence[2] ?? "";
|
|
55
|
+
fenceChar = marker[0]!;
|
|
56
|
+
fenceLength = marker.length;
|
|
57
|
+
fenceKind = isMarkdownFence(info) ? "text" : "code";
|
|
58
|
+
fenceLang = fenceKind === "code" ? fenceLanguage(info) : undefined;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
output.push({ kind: "text", text: line });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return output;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function normalizeAssistantDisplayContent(content: string): string {
|
|
69
|
+
return assistantDisplayLines(content)
|
|
70
|
+
.map((line) => line.text)
|
|
71
|
+
.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function normalizeAssistantMarkdownRenderContent(content: string): string {
|
|
75
|
+
const lines = content.replace(/\r\n?/gu, "\n").split("\n");
|
|
76
|
+
const output: string[] = [];
|
|
77
|
+
let fenceChar = "";
|
|
78
|
+
let fenceLength = 0;
|
|
79
|
+
let markdownFence = false;
|
|
80
|
+
|
|
81
|
+
for (const rawLine of lines) {
|
|
82
|
+
const line = rawLine.replace(/\t/gu, TAB_DISPLAY);
|
|
83
|
+
if (fenceChar.length > 0) {
|
|
84
|
+
if (isFenceClose(line, fenceChar, fenceLength)) {
|
|
85
|
+
if (!markdownFence) output.push("```");
|
|
86
|
+
fenceChar = "";
|
|
87
|
+
fenceLength = 0;
|
|
88
|
+
markdownFence = false;
|
|
89
|
+
} else {
|
|
90
|
+
output.push(line);
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const openingFence = FENCE_OPEN_RE.exec(line);
|
|
96
|
+
if (openingFence) {
|
|
97
|
+
const marker = openingFence[1]!;
|
|
98
|
+
const info = openingFence[2] ?? "";
|
|
99
|
+
fenceChar = marker[0]!;
|
|
100
|
+
fenceLength = marker.length;
|
|
101
|
+
markdownFence = isMarkdownFence(info);
|
|
102
|
+
if (!markdownFence) output.push("```");
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
output.push(line);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return output.join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function firstDisplayLine(content: string): string {
|
|
113
|
+
return content.split("\n").find((line) => line.trim().length > 0) ?? "";
|
|
114
|
+
}
|
package/src/ui/graphemes.ts
CHANGED
|
@@ -35,6 +35,7 @@ function isWideCodePoint(codePoint: number): boolean {
|
|
|
35
35
|
function graphemeWidth(input: string): number {
|
|
36
36
|
if (input.length === 0) return 0;
|
|
37
37
|
if (/^\p{Mark}+$/u.test(input)) return 0;
|
|
38
|
+
if (/^[©®™]$/u.test(input)) return 1;
|
|
38
39
|
if (/\p{Extended_Pictographic}/u.test(input)) return 2;
|
|
39
40
|
let width = 0;
|
|
40
41
|
for (const char of input) {
|