@andocorp/cli 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/README.md +136 -0
- package/package.json +42 -0
- package/src/adapters.test.ts +50 -0
- package/src/adapters.ts +215 -0
- package/src/args.test.ts +28 -0
- package/src/args.ts +98 -0
- package/src/cli-helpers.test.ts +82 -0
- package/src/cli-helpers.ts +149 -0
- package/src/client.test.ts +235 -0
- package/src/client.ts +378 -0
- package/src/components/prompt-line.ts +179 -0
- package/src/components/transcript-pane.test.ts +26 -0
- package/src/components/transcript-pane.ts +457 -0
- package/src/config.ts +53 -0
- package/src/emoji-suggestions.ts +152 -0
- package/src/format.test.ts +54 -0
- package/src/format.ts +611 -0
- package/src/help.test.ts +13 -0
- package/src/help.ts +48 -0
- package/src/index.ts +466 -0
- package/src/interactive.ts +832 -0
- package/src/test-helpers.ts +207 -0
- package/src/types.ts +24 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatDateSeparator,
|
|
3
|
+
formatMessageFooter,
|
|
4
|
+
formatMessageHeaderParts,
|
|
5
|
+
getMessageBody,
|
|
6
|
+
getMessageBodyLines,
|
|
7
|
+
getVisibleTextLength,
|
|
8
|
+
LINK_END_MARKER,
|
|
9
|
+
LINK_SEPARATOR_MARKER,
|
|
10
|
+
LINK_START_MARKER,
|
|
11
|
+
renderCodeBlockText,
|
|
12
|
+
renderTerminalText,
|
|
13
|
+
truncate,
|
|
14
|
+
} from "../format.js";
|
|
15
|
+
import { Message } from "../types.js";
|
|
16
|
+
|
|
17
|
+
const ANSI_RESET = "\u001b[0m";
|
|
18
|
+
const ANSI_MESSAGE_HEADER = "\u001b[38;5;248m";
|
|
19
|
+
const ANSI_SELECTION_MARKER = "\u001b[38;2;255;215;0m";
|
|
20
|
+
|
|
21
|
+
type InlineSegment = {
|
|
22
|
+
text: string;
|
|
23
|
+
url?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type InlineToken = {
|
|
27
|
+
text: string;
|
|
28
|
+
visibleLength: number;
|
|
29
|
+
url?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function getSelectedItemPrefix(params: {
|
|
33
|
+
isFocusedPane: boolean;
|
|
34
|
+
isSelected: boolean;
|
|
35
|
+
}) {
|
|
36
|
+
if (!params.isSelected) {
|
|
37
|
+
return " ";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return `${ANSI_SELECTION_MARKER}${params.isFocusedPane ? ">" : "-"}${ANSI_RESET}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatLinkedText(url: string, text: string) {
|
|
44
|
+
return `${LINK_START_MARKER}${url}${LINK_SEPARATOR_MARKER}${text}${LINK_END_MARKER}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseInlineSegments(text: string): InlineSegment[] {
|
|
48
|
+
const segments: InlineSegment[] = [];
|
|
49
|
+
let buffer = "";
|
|
50
|
+
let index = 0;
|
|
51
|
+
|
|
52
|
+
const flushBuffer = () => {
|
|
53
|
+
if (buffer === "") {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
segments.push({ text: buffer });
|
|
58
|
+
buffer = "";
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
while (index < text.length) {
|
|
62
|
+
const character = text[index];
|
|
63
|
+
if (character == null) {
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (character !== LINK_START_MARKER) {
|
|
68
|
+
buffer += character;
|
|
69
|
+
index += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
flushBuffer();
|
|
74
|
+
index += 1;
|
|
75
|
+
|
|
76
|
+
let url = "";
|
|
77
|
+
while (index < text.length && text[index] !== LINK_SEPARATOR_MARKER) {
|
|
78
|
+
const urlCharacter = text[index];
|
|
79
|
+
if (urlCharacter != null) {
|
|
80
|
+
url += urlCharacter;
|
|
81
|
+
}
|
|
82
|
+
index += 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (text[index] === LINK_SEPARATOR_MARKER) {
|
|
86
|
+
index += 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let label = "";
|
|
90
|
+
while (index < text.length && text[index] !== LINK_END_MARKER) {
|
|
91
|
+
const labelCharacter = text[index];
|
|
92
|
+
if (labelCharacter != null) {
|
|
93
|
+
label += labelCharacter;
|
|
94
|
+
}
|
|
95
|
+
index += 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (text[index] === LINK_END_MARKER) {
|
|
99
|
+
index += 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
segments.push({ text: label, url });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
flushBuffer();
|
|
106
|
+
return segments;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function splitToken(token: InlineToken, width: number) {
|
|
110
|
+
const chunks: InlineToken[] = [];
|
|
111
|
+
for (let index = 0; index < token.text.length; index += width) {
|
|
112
|
+
const chunkText = token.text.slice(index, index + width);
|
|
113
|
+
chunks.push({
|
|
114
|
+
text: chunkText,
|
|
115
|
+
visibleLength: chunkText.length,
|
|
116
|
+
url: token.url,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return chunks;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function tokenizeInlineSegments(text: string): InlineToken[] {
|
|
124
|
+
const tokens: InlineToken[] = [];
|
|
125
|
+
|
|
126
|
+
for (const segment of parseInlineSegments(text)) {
|
|
127
|
+
let buffer = "";
|
|
128
|
+
for (const character of segment.text) {
|
|
129
|
+
if (character === " ") {
|
|
130
|
+
if (buffer !== "") {
|
|
131
|
+
tokens.push({
|
|
132
|
+
text: buffer,
|
|
133
|
+
visibleLength: buffer.length,
|
|
134
|
+
url: segment.url,
|
|
135
|
+
});
|
|
136
|
+
buffer = "";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
tokens.push({
|
|
140
|
+
text: " ",
|
|
141
|
+
visibleLength: 1,
|
|
142
|
+
});
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
buffer += character;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (buffer !== "") {
|
|
150
|
+
tokens.push({
|
|
151
|
+
text: buffer,
|
|
152
|
+
visibleLength: buffer.length,
|
|
153
|
+
url: segment.url,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return tokens;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function renderInlineTokens(tokens: InlineToken[]) {
|
|
162
|
+
return tokens
|
|
163
|
+
.map((token) =>
|
|
164
|
+
token.url == null ? token.text : formatLinkedText(token.url, token.text)
|
|
165
|
+
)
|
|
166
|
+
.join("");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function wrapLine(text: string, width: number) {
|
|
170
|
+
if (width <= 0) {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const tokens = tokenizeInlineSegments(text);
|
|
175
|
+
const lines: string[] = [];
|
|
176
|
+
let currentTokens: InlineToken[] = [];
|
|
177
|
+
let currentLength = 0;
|
|
178
|
+
|
|
179
|
+
const pushCurrentLine = () => {
|
|
180
|
+
while (currentTokens[0]?.text === " ") {
|
|
181
|
+
currentTokens.shift();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
while (currentTokens[currentTokens.length - 1]?.text === " ") {
|
|
185
|
+
currentTokens.pop();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (currentTokens.length === 0) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
lines.push(renderInlineTokens(currentTokens));
|
|
193
|
+
currentTokens = [];
|
|
194
|
+
currentLength = 0;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
for (const token of tokens) {
|
|
198
|
+
if (token.text === " ") {
|
|
199
|
+
if (currentLength === 0) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (currentLength + 1 <= width) {
|
|
204
|
+
currentTokens.push(token);
|
|
205
|
+
currentLength += 1;
|
|
206
|
+
} else {
|
|
207
|
+
pushCurrentLine();
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (token.visibleLength <= width - currentLength) {
|
|
213
|
+
currentTokens.push(token);
|
|
214
|
+
currentLength += token.visibleLength;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (currentLength > 0) {
|
|
219
|
+
pushCurrentLine();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (token.visibleLength <= width) {
|
|
223
|
+
currentTokens.push(token);
|
|
224
|
+
currentLength = token.visibleLength;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const chunk of splitToken(token, width)) {
|
|
229
|
+
lines.push(renderInlineTokens([chunk]));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
pushCurrentLine();
|
|
234
|
+
return lines;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function wrapPreformattedLine(text: string, width: number) {
|
|
238
|
+
if (width <= 0) {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (getVisibleTextLength(text) <= width) {
|
|
243
|
+
return [text];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const lines: string[] = [];
|
|
247
|
+
for (const segment of parseInlineSegments(text)) {
|
|
248
|
+
if (segment.text === "") {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (let index = 0; index < segment.text.length; index += width) {
|
|
253
|
+
const chunk = segment.text.slice(index, index + width);
|
|
254
|
+
lines.push(
|
|
255
|
+
segment.url == null ? chunk : formatLinkedText(segment.url, chunk)
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return lines;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function centerText(text: string, width: number) {
|
|
264
|
+
if (width <= 0) {
|
|
265
|
+
return "";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (text.length >= width) {
|
|
269
|
+
return truncate(text, width);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const leftPadding = Math.floor((width - text.length) / 2);
|
|
273
|
+
return `${" ".repeat(leftPadding)}${text}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function colorMessageHeader(text: string) {
|
|
277
|
+
return `${ANSI_MESSAGE_HEADER}${text}${ANSI_RESET}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function getMessageDateKey(value: Date | string | number | null | undefined) {
|
|
281
|
+
if (value == null) {
|
|
282
|
+
return "";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const date = new Date(value);
|
|
286
|
+
if (Number.isNaN(date.getTime())) {
|
|
287
|
+
return "";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function renderTranscriptLines(params: {
|
|
294
|
+
items: Message[];
|
|
295
|
+
selectedIndex: number;
|
|
296
|
+
width: number;
|
|
297
|
+
bodyRows: number;
|
|
298
|
+
isFocusedPane: boolean;
|
|
299
|
+
currentMemberId?: string | null;
|
|
300
|
+
}) {
|
|
301
|
+
const {
|
|
302
|
+
items,
|
|
303
|
+
selectedIndex,
|
|
304
|
+
width,
|
|
305
|
+
bodyRows,
|
|
306
|
+
isFocusedPane,
|
|
307
|
+
currentMemberId,
|
|
308
|
+
} = params;
|
|
309
|
+
|
|
310
|
+
if (width <= 0 || bodyRows <= 0) {
|
|
311
|
+
return {
|
|
312
|
+
lines: [],
|
|
313
|
+
hasAbove: false,
|
|
314
|
+
hasBelow: false,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const blocks = items.map((message, index) => {
|
|
319
|
+
const previousMessage = index > 0 ? items[index - 1] : null;
|
|
320
|
+
const showDateSeparator =
|
|
321
|
+
previousMessage == null ||
|
|
322
|
+
getMessageDateKey(previousMessage.created_at) !==
|
|
323
|
+
getMessageDateKey(message.created_at);
|
|
324
|
+
|
|
325
|
+
const prefix = getSelectedItemPrefix({
|
|
326
|
+
isFocusedPane,
|
|
327
|
+
isSelected: index === selectedIndex,
|
|
328
|
+
});
|
|
329
|
+
const contentWidth = Math.max(8, width - 4);
|
|
330
|
+
const bodyLines = getMessageBodyLines(message).flatMap((line) => {
|
|
331
|
+
const wrappedLine = line.preserveWhitespace
|
|
332
|
+
? wrapPreformattedLine(line.text, contentWidth)
|
|
333
|
+
: wrapLine(line.text, contentWidth);
|
|
334
|
+
|
|
335
|
+
if (wrappedLine.length > 0) {
|
|
336
|
+
return wrappedLine.map((text) => ({
|
|
337
|
+
text,
|
|
338
|
+
isCodeBlock: line.isCodeBlock === true,
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return [{ text: line.text, isCodeBlock: line.isCodeBlock === true }];
|
|
343
|
+
});
|
|
344
|
+
const renderedBodyLines =
|
|
345
|
+
bodyLines.length > 0
|
|
346
|
+
? bodyLines
|
|
347
|
+
: [{ text: getMessageBody(message), isCodeBlock: false }];
|
|
348
|
+
const footer = formatMessageFooter(message);
|
|
349
|
+
|
|
350
|
+
const lines: { text: string; isCodeBlock?: boolean; isHeader: boolean }[] = [];
|
|
351
|
+
if (showDateSeparator) {
|
|
352
|
+
lines.push({
|
|
353
|
+
text: centerText(formatDateSeparator(message.created_at), width),
|
|
354
|
+
isHeader: false,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
lines.push({
|
|
359
|
+
text: (() => {
|
|
360
|
+
const header = formatMessageHeaderParts(message, { currentMemberId });
|
|
361
|
+
return `${prefix} ${colorMessageHeader(header.timestamp)} ${header.author}`;
|
|
362
|
+
})(),
|
|
363
|
+
isHeader: true,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
for (let bodyLineIndex = 0; bodyLineIndex < renderedBodyLines.length; bodyLineIndex += 1) {
|
|
367
|
+
const line = renderedBodyLines[bodyLineIndex];
|
|
368
|
+
if (line == null) {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (line.isCodeBlock === true) {
|
|
373
|
+
const codeBlockLines = [line.text];
|
|
374
|
+
let nextIndex = bodyLineIndex + 1;
|
|
375
|
+
while (nextIndex < renderedBodyLines.length) {
|
|
376
|
+
const nextLine = renderedBodyLines[nextIndex];
|
|
377
|
+
if (nextLine?.isCodeBlock !== true) {
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
codeBlockLines.push(nextLine.text);
|
|
382
|
+
nextIndex += 1;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const codeBlockWidth = Math.max(
|
|
386
|
+
1,
|
|
387
|
+
...codeBlockLines.map((codeLine) => getVisibleTextLength(codeLine))
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
lines.push({
|
|
391
|
+
text: ` ┌${"─".repeat(codeBlockWidth + 2)}┐`,
|
|
392
|
+
isHeader: false,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
for (const codeLine of codeBlockLines) {
|
|
396
|
+
const visibleLength = getVisibleTextLength(codeLine);
|
|
397
|
+
lines.push({
|
|
398
|
+
text: ` │ ${renderCodeBlockText(codeLine)}${" ".repeat(codeBlockWidth - visibleLength)} │`,
|
|
399
|
+
isHeader: false,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
lines.push({
|
|
404
|
+
text: ` └${"─".repeat(codeBlockWidth + 2)}┘`,
|
|
405
|
+
isHeader: false,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
bodyLineIndex = nextIndex - 1;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
lines.push({
|
|
413
|
+
text: ` ${line.text}`,
|
|
414
|
+
isHeader: false,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (footer !== "") {
|
|
419
|
+
lines.push({ text: ` ${footer}`, isHeader: false });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return { index, lines };
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const selectedBlockIndex = blocks.findIndex((block) => block.index === selectedIndex);
|
|
426
|
+
const safeSelectedBlockIndex = selectedBlockIndex === -1 ? 0 : selectedBlockIndex;
|
|
427
|
+
|
|
428
|
+
let visibleStart = 0;
|
|
429
|
+
let visibleEnd = blocks.length;
|
|
430
|
+
let visibleLineCount = blocks.reduce((sum, block) => sum + block.lines.length, 0);
|
|
431
|
+
|
|
432
|
+
while (visibleLineCount > bodyRows && visibleStart < safeSelectedBlockIndex) {
|
|
433
|
+
visibleLineCount -= blocks[visibleStart]?.lines.length ?? 0;
|
|
434
|
+
visibleStart += 1;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
while (visibleLineCount > bodyRows && visibleEnd - 1 > safeSelectedBlockIndex) {
|
|
438
|
+
visibleEnd -= 1;
|
|
439
|
+
visibleLineCount -= blocks[visibleEnd]?.lines.length ?? 0;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const renderedLines = blocks
|
|
443
|
+
.slice(visibleStart, visibleEnd)
|
|
444
|
+
.flatMap((block) => block.lines)
|
|
445
|
+
.slice(0, bodyRows)
|
|
446
|
+
.map((line) => {
|
|
447
|
+
const truncatedText = truncate(line.text, width);
|
|
448
|
+
const coloredText = renderTerminalText(truncatedText);
|
|
449
|
+
return line.isHeader ? colorMessageHeader(coloredText) : coloredText;
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
lines: renderedLines,
|
|
454
|
+
hasAbove: visibleStart > 0,
|
|
455
|
+
hasBelow: visibleEnd < blocks.length,
|
|
456
|
+
};
|
|
457
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { SavedConfig } from "./types.js";
|
|
5
|
+
|
|
6
|
+
type SavedConfigFile = SavedConfig & {
|
|
7
|
+
savedAt: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function getConfigPath() {
|
|
11
|
+
return path.join(os.homedir(), ".config", "ando", "config.json");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function readSavedConfig(): Promise<SavedConfig | null> {
|
|
15
|
+
try {
|
|
16
|
+
const raw = await readFile(getConfigPath(), "utf8");
|
|
17
|
+
const parsed = JSON.parse(raw) as Partial<SavedConfigFile>;
|
|
18
|
+
if (
|
|
19
|
+
typeof parsed.sessionToken !== "string" ||
|
|
20
|
+
parsed.sessionToken.trim() === "" ||
|
|
21
|
+
typeof parsed.convexUrl !== "string" ||
|
|
22
|
+
parsed.convexUrl.trim() === ""
|
|
23
|
+
) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
sessionToken: parsed.sessionToken,
|
|
29
|
+
convexUrl: parsed.convexUrl,
|
|
30
|
+
};
|
|
31
|
+
} catch (error) {
|
|
32
|
+
const errorCode =
|
|
33
|
+
error instanceof Error && "code" in error ? error.code : undefined;
|
|
34
|
+
if (errorCode === "ENOENT") {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function saveConfig(config: SavedConfig) {
|
|
43
|
+
const configPath = getConfigPath();
|
|
44
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
45
|
+
const payload: SavedConfigFile = {
|
|
46
|
+
...config,
|
|
47
|
+
savedAt: new Date().toISOString(),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
await writeFile(configPath, JSON.stringify(payload, null, 2), {
|
|
51
|
+
mode: 0o600,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import emojiData from "emojibase-data/en/data.json" with { type: "json" };
|
|
2
|
+
import shortcodeData from "emojibase-data/en/shortcodes/cldr.json" with { type: "json" };
|
|
3
|
+
|
|
4
|
+
type EmojiSuggestion = {
|
|
5
|
+
emoji: string;
|
|
6
|
+
label: string;
|
|
7
|
+
shortcode: string;
|
|
8
|
+
keywords: string[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
12
|
+
return typeof value === "object" && value !== null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getString(value: unknown): string | null {
|
|
16
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getStringArray(value: unknown): string[] {
|
|
20
|
+
if (!Array.isArray(value)) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return value.filter((item): item is string => typeof item === "string" && item !== "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getPrimaryShortcode(value: unknown): string | null {
|
|
28
|
+
if (typeof value === "string" && value !== "") {
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!Array.isArray(value)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
value.find(
|
|
38
|
+
(item): item is string => typeof item === "string" && item !== ""
|
|
39
|
+
) ?? null
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildEmojiSuggestions(): EmojiSuggestion[] {
|
|
44
|
+
const suggestions: EmojiSuggestion[] = [];
|
|
45
|
+
const rawShortcodes: Record<string, unknown> = isRecord(shortcodeData)
|
|
46
|
+
? shortcodeData
|
|
47
|
+
: {};
|
|
48
|
+
|
|
49
|
+
for (const entry of emojiData) {
|
|
50
|
+
if (!isRecord(entry)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const emoji = getString(entry.emoji);
|
|
55
|
+
const label = getString(entry.label);
|
|
56
|
+
const hexcode = getString(entry.hexcode);
|
|
57
|
+
if (emoji == null || label == null || hexcode == null) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const shortcode = getPrimaryShortcode(rawShortcodes[hexcode]);
|
|
62
|
+
if (shortcode == null) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const keywords = Array.from(
|
|
67
|
+
new Set(
|
|
68
|
+
[...getStringArray(entry.tags), ...label.toLowerCase().split(/\s+/)]
|
|
69
|
+
.map((word) => word.trim())
|
|
70
|
+
.filter((word) => word !== "")
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
suggestions.push({
|
|
75
|
+
emoji,
|
|
76
|
+
label,
|
|
77
|
+
shortcode,
|
|
78
|
+
keywords,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return suggestions;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const emojiSuggestions = buildEmojiSuggestions();
|
|
86
|
+
|
|
87
|
+
function scoreSuggestion(suggestion: EmojiSuggestion, query: string) {
|
|
88
|
+
let score = 0;
|
|
89
|
+
const normalizedLabel = suggestion.label.toLowerCase();
|
|
90
|
+
|
|
91
|
+
if (suggestion.shortcode === query) {
|
|
92
|
+
score = 1000;
|
|
93
|
+
} else if (suggestion.shortcode.startsWith(query)) {
|
|
94
|
+
score = 500;
|
|
95
|
+
} else if (suggestion.shortcode.includes(query)) {
|
|
96
|
+
score = 120;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (normalizedLabel === query) {
|
|
100
|
+
score = Math.max(score, 800);
|
|
101
|
+
} else if (normalizedLabel.startsWith(query)) {
|
|
102
|
+
score = Math.max(score, 350);
|
|
103
|
+
} else if (normalizedLabel.includes(query)) {
|
|
104
|
+
score = Math.max(score, 80);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const keyword of suggestion.keywords) {
|
|
108
|
+
if (keyword === query) {
|
|
109
|
+
score = Math.max(score, 250);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (keyword.startsWith(query)) {
|
|
114
|
+
score = Math.max(score, 150);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (keyword.includes(query)) {
|
|
119
|
+
score = Math.max(score, 40);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return score;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function getSuggestionQuery(value: string) {
|
|
127
|
+
const match = value.toLowerCase().match(/(?:^|[\s:])([a-z][a-z0-9_+-]{0,31})$/);
|
|
128
|
+
return match?.[1] ?? "";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function getEmojiSuggestions(value: string, limit = 5): EmojiSuggestion[] {
|
|
132
|
+
const query = getSuggestionQuery(value);
|
|
133
|
+
if (query === "") {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return emojiSuggestions
|
|
138
|
+
.map((suggestion) => ({
|
|
139
|
+
suggestion,
|
|
140
|
+
score: scoreSuggestion(suggestion, query),
|
|
141
|
+
}))
|
|
142
|
+
.filter((entry) => entry.score > 0)
|
|
143
|
+
.sort((left, right) => {
|
|
144
|
+
if (right.score !== left.score) {
|
|
145
|
+
return right.score - left.score;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return left.suggestion.shortcode.localeCompare(right.suggestion.shortcode);
|
|
149
|
+
})
|
|
150
|
+
.slice(0, limit)
|
|
151
|
+
.map((entry) => entry.suggestion);
|
|
152
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createMessage, createReaction } from "./test-helpers.js";
|
|
3
|
+
import {
|
|
4
|
+
formatReactionSummary,
|
|
5
|
+
getMessageBody,
|
|
6
|
+
getVisibleText,
|
|
7
|
+
getVisibleTextLength,
|
|
8
|
+
renderTerminalText,
|
|
9
|
+
} from "./format.js";
|
|
10
|
+
|
|
11
|
+
describe("format", () => {
|
|
12
|
+
it("renders markdown links as visible link labels", () => {
|
|
13
|
+
const message = createMessage({
|
|
14
|
+
markdown_content:
|
|
15
|
+
"to leave comments in figma: [https://www.figma.com/design/file](https://www.figma.com/design/file)",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(getVisibleText(getMessageBody(message))).toBe(
|
|
19
|
+
"to leave comments in figma: https://www.figma.com/design/file"
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders markdown links in blue without hyperlink escapes", () => {
|
|
24
|
+
const message = createMessage({
|
|
25
|
+
markdown_content: "[Figma file](https://www.figma.com/design/file)",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(renderTerminalText(getMessageBody(message))).toBe(
|
|
29
|
+
"\u001b[34mFigma file\u001b[0m"
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("treats terminal color escape sequences as zero-width", () => {
|
|
34
|
+
const message = createMessage({
|
|
35
|
+
markdown_content: "[Figma file](https://www.figma.com/design/file)",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(getVisibleTextLength(renderTerminalText(getMessageBody(message)))).toBe(
|
|
39
|
+
"Figma file".length
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("renders identical reactions without spacing them apart", () => {
|
|
44
|
+
const message = createMessage({
|
|
45
|
+
message_reactions: [
|
|
46
|
+
createReaction({ id: "reaction-1", emoji_text: "🔥" }),
|
|
47
|
+
createReaction({ id: "reaction-2", emoji_text: "🔥" }),
|
|
48
|
+
createReaction({ id: "reaction-3", emoji_text: "🔥" }),
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(formatReactionSummary(message)).toBe("🔥🔥🔥");
|
|
53
|
+
});
|
|
54
|
+
});
|