silvermoon 0.2.1 → 0.3.0-rc.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/LICENSE +21 -0
- package/README.md +115 -121
- package/README.zh-CN.md +96 -95
- package/bin/silvermoon.js +7 -2
- package/docs/getting-started.md +23 -11
- package/docs/maintaining.md +83 -6
- package/docs/npm-package-releases.md +33 -4
- package/docs/operations.md +11 -7
- package/docs/reference.md +131 -32
- package/docs/repository-tasks.md +43 -15
- package/package.json +46 -8
- package/skills/silvermoon/SKILL.md +224 -123
- package/skills/silvermoon/references/adoption.md +3 -2
- package/src/adoption.js +51 -35
- package/src/cli.js +148 -39
- package/src/config.js +76 -16
- package/src/create-idea.js +22 -10
- package/src/git-snapshot.js +212 -0
- package/src/git.js +209 -18
- package/src/guidance.js +65 -23
- package/src/idea-layout.js +59 -23
- package/src/idea-metadata.js +48 -0
- package/src/idea-templates.js +133 -0
- package/src/index.js +64 -53
- package/src/language.js +4 -0
- package/src/list-ideas.js +118 -55
- package/src/observation.js +9 -1
- package/src/repository.js +2 -8
- package/src/response.js +93 -95
- package/src/subprocess.js +27 -0
- package/src/trace.js +37 -2
- package/src/tui-table.js +71 -0
- package/src/tui.js +205 -0
- package/src/whatsnext.js +71 -18
- package/assets/silvermoon-avatar.svg +0 -395
- package/assets/silvermoon.svg +0 -176
- package/docs/assets/silvermoon-avatar.svg +0 -395
package/src/response.js
CHANGED
|
@@ -163,13 +163,13 @@ function dialogueResponse(intention, internalObservation, language) {
|
|
|
163
163
|
summary: choices.length === 0
|
|
164
164
|
? localize(
|
|
165
165
|
language,
|
|
166
|
-
"No active ideas are available.",
|
|
167
|
-
"
|
|
166
|
+
"Current state: navigation-ready. No active ideas are available.",
|
|
167
|
+
"当前状态:navigation-ready。当前没有 active idea。",
|
|
168
168
|
)
|
|
169
169
|
: localize(
|
|
170
170
|
language,
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
`Current state: navigation-ready. ${choices.length} active idea(s) are available.`,
|
|
172
|
+
`当前状态:navigation-ready。当前有 ${choices.length} 个 active idea。`,
|
|
173
173
|
),
|
|
174
174
|
choices,
|
|
175
175
|
nextSteps,
|
|
@@ -304,34 +304,85 @@ function markdownTableCell(value) {
|
|
|
304
304
|
.replaceAll(/\r?\n/g, " ");
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
function
|
|
307
|
+
function renderMarkdownTable(headers, rows) {
|
|
308
|
+
return [
|
|
309
|
+
`| ${headers.join(" | ")} |`,
|
|
310
|
+
`| ${headers.map(() => "---").join(" | ")} |`,
|
|
311
|
+
...rows.map((row) =>
|
|
312
|
+
`| ${row.map(markdownTableCell).join(" | ")} |`
|
|
313
|
+
),
|
|
314
|
+
];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function formatDate(timestamp, language, now) {
|
|
318
|
+
const date = new Date(timestamp);
|
|
319
|
+
if (!Number.isFinite(date.getTime()) || !Number.isFinite(now.getTime())) {
|
|
320
|
+
throw new TypeError("Cannot render an invalid timestamp");
|
|
321
|
+
}
|
|
322
|
+
const duration = now.getTime() - date.getTime();
|
|
323
|
+
const elapsed = Math.abs(duration);
|
|
324
|
+
const minute = 60_000;
|
|
325
|
+
const hour = 60 * minute;
|
|
326
|
+
const day = 24 * hour;
|
|
327
|
+
if (elapsed > 7 * day) {
|
|
328
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
329
|
+
}
|
|
330
|
+
if (elapsed < minute) {
|
|
331
|
+
return duration < 0
|
|
332
|
+
? localize(language, "soon", "马上")
|
|
333
|
+
: localize(language, "just now", "刚刚");
|
|
334
|
+
}
|
|
335
|
+
const [amount, unit, chineseUnit] = elapsed < hour
|
|
336
|
+
? [Math.floor(elapsed / minute), "m", "分钟"]
|
|
337
|
+
: elapsed < day
|
|
338
|
+
? [Math.floor(elapsed / hour), "h", "小时"]
|
|
339
|
+
: [Math.floor(elapsed / day), "d", "天"];
|
|
340
|
+
return duration < 0
|
|
341
|
+
? localize(language, `in ${amount}${unit}`, `${amount}${chineseUnit}后`)
|
|
342
|
+
: localize(language, `${amount}${unit} ago`, `${amount}${chineseUnit}前`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function renderIdeaTable(ideas, language, now) {
|
|
308
346
|
const headers = [
|
|
309
|
-
|
|
310
|
-
localize(language, "Alias", "Alias"),
|
|
347
|
+
"Alias / ID",
|
|
311
348
|
localize(language, "State", "状态"),
|
|
312
349
|
localize(language, "Created", "创建时间"),
|
|
313
350
|
localize(language, "Title", "标题"),
|
|
314
351
|
];
|
|
352
|
+
return renderMarkdownTable(
|
|
353
|
+
headers,
|
|
354
|
+
ideas.map((idea) => [
|
|
355
|
+
idea.alias ?? idea.id,
|
|
356
|
+
idea.state,
|
|
357
|
+
formatDate(idea.createdAt, language, now),
|
|
358
|
+
idea.title,
|
|
359
|
+
]),
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function renderIdeaList(response, language, now) {
|
|
315
364
|
const counts = Object.entries(response.inventory.counts)
|
|
365
|
+
.filter(([, count]) => count > 0)
|
|
316
366
|
.map(([state, count]) => `${state}=${count}`)
|
|
317
|
-
.join(", ");
|
|
318
|
-
const lines = [
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
367
|
+
.join(localize(language, ", ", ","));
|
|
368
|
+
const lines = [counts.length === 0
|
|
369
|
+
? response.summary
|
|
370
|
+
: localize(
|
|
371
|
+
language,
|
|
372
|
+
`${response.summary} Counts: ${counts}.`,
|
|
373
|
+
`${response.summary}计数:${counts}。`,
|
|
374
|
+
)];
|
|
375
|
+
if (response.query?.createdSince) {
|
|
376
|
+
lines.push(
|
|
377
|
+
`- ${localize(language, "Created since", "创建时间下界")}: ${formatDate(response.query.createdSince, language, now)}`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (response.query?.createdBefore) {
|
|
325
381
|
lines.push(
|
|
326
|
-
|
|
327
|
-
idea.id,
|
|
328
|
-
idea.alias,
|
|
329
|
-
idea.state,
|
|
330
|
-
idea.createdAt,
|
|
331
|
-
idea.title,
|
|
332
|
-
].map(markdownTableCell).join(" | ")} |`,
|
|
382
|
+
`- ${localize(language, "Created before", "创建时间上界")}: ${formatDate(response.query.createdBefore, language, now)}`,
|
|
333
383
|
);
|
|
334
384
|
}
|
|
385
|
+
lines.push("", ...renderIdeaTable(response.items, language, now));
|
|
335
386
|
if (response.items.length === 0) {
|
|
336
387
|
lines.push(
|
|
337
388
|
"",
|
|
@@ -341,56 +392,17 @@ function renderIdeaList(response, language) {
|
|
|
341
392
|
return lines.join("\n");
|
|
342
393
|
}
|
|
343
394
|
|
|
344
|
-
export function renderResponse(response) {
|
|
395
|
+
export function renderResponse(response, { now = new Date() } = {}) {
|
|
345
396
|
const language = response.language ?? DEFAULT_LANGUAGE;
|
|
346
397
|
if (response.kind === "idea-list") {
|
|
347
398
|
return [
|
|
348
399
|
`## ${responseTitle(response)}`,
|
|
349
400
|
"",
|
|
350
|
-
renderIdeaList(response, language),
|
|
401
|
+
renderIdeaList(response, language, now),
|
|
351
402
|
].join("\n");
|
|
352
403
|
}
|
|
353
404
|
const lines = [`## ${responseTitle(response)}`, "", response.summary];
|
|
354
405
|
|
|
355
|
-
if (response.inventory) {
|
|
356
|
-
const counts = Object.entries(response.inventory.counts)
|
|
357
|
-
.map(([state, count]) => `${state}=${count}`)
|
|
358
|
-
.join(", ");
|
|
359
|
-
lines.push(
|
|
360
|
-
"",
|
|
361
|
-
`- ${localize(language, "States", "状态")}: ${response.query.states
|
|
362
|
-
.map(codeSpan)
|
|
363
|
-
.join(", ")}`,
|
|
364
|
-
);
|
|
365
|
-
if (response.query.query !== null) {
|
|
366
|
-
lines.push(
|
|
367
|
-
`- ${localize(language, "Query", "查询")}: ${codeSpan(response.query.query)}`,
|
|
368
|
-
);
|
|
369
|
-
}
|
|
370
|
-
if (response.query.createdSince !== null) {
|
|
371
|
-
lines.push(
|
|
372
|
-
`- ${localize(language, "Created since", "创建时间下界")}: ${codeSpan(response.query.createdSince)}`,
|
|
373
|
-
);
|
|
374
|
-
}
|
|
375
|
-
if (response.query.createdBefore !== null) {
|
|
376
|
-
lines.push(
|
|
377
|
-
`- ${localize(language, "Created before", "创建时间上界")}: ${codeSpan(response.query.createdBefore)}`,
|
|
378
|
-
);
|
|
379
|
-
}
|
|
380
|
-
lines.push(
|
|
381
|
-
`- ${localize(language, "Sort", "排序")}: ${codeSpan(response.query.sort)}`,
|
|
382
|
-
`- ${localize(language, "Limit", "上限")}: ${response.query.limit === null
|
|
383
|
-
? localize(language, "none", "无")
|
|
384
|
-
: codeSpan(response.query.limit)}`,
|
|
385
|
-
`- ${localize(language, "Matched", "匹配")}: ${response.inventory.matched}`,
|
|
386
|
-
`- ${localize(language, "Returned", "返回")}: ${response.inventory.returned}`,
|
|
387
|
-
`- ${localize(language, "Truncated", "已截断")}: ${response.inventory.truncated
|
|
388
|
-
? localize(language, "yes", "是")
|
|
389
|
-
: localize(language, "no", "否")}`,
|
|
390
|
-
`- ${localize(language, "Counts", "计数")}: ${counts}`,
|
|
391
|
-
);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
406
|
if (response.validation) {
|
|
395
407
|
const target = response.validation.target.type === "commit"
|
|
396
408
|
? `${response.validation.target.type} ${response.validation.target.revision}`
|
|
@@ -432,40 +444,22 @@ export function renderResponse(response) {
|
|
|
432
444
|
);
|
|
433
445
|
}
|
|
434
446
|
|
|
435
|
-
if (response.choices
|
|
447
|
+
if (response.choices) {
|
|
436
448
|
lines.push(
|
|
437
449
|
"",
|
|
438
|
-
`### ${localize(language, "
|
|
450
|
+
`### ${localize(language, "Active ideas", "Active ideas")}`,
|
|
439
451
|
"",
|
|
440
452
|
);
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
if (response.items.length === 0) {
|
|
450
|
-
lines.push(localize(language, "No ideas matched.", "没有匹配的 idea。"));
|
|
453
|
+
if (response.choices.length === 0) {
|
|
454
|
+
lines.push(
|
|
455
|
+
localize(
|
|
456
|
+
language,
|
|
457
|
+
"No active ideas are available.",
|
|
458
|
+
"当前没有 active idea。",
|
|
459
|
+
),
|
|
460
|
+
);
|
|
451
461
|
} else {
|
|
452
|
-
|
|
453
|
-
const details = [
|
|
454
|
-
`${localize(language, "State", "状态")}=${codeSpan(idea.state)}`,
|
|
455
|
-
`${localize(language, "Created", "创建时间")}=${codeSpan(idea.createdAt)}`,
|
|
456
|
-
];
|
|
457
|
-
if (idea.alias !== undefined) {
|
|
458
|
-
details.push(
|
|
459
|
-
`${localize(language, "Alias", "Alias")}=${codeSpan(idea.alias)}`,
|
|
460
|
-
);
|
|
461
|
-
}
|
|
462
|
-
if (idea.title !== undefined) {
|
|
463
|
-
details.push(
|
|
464
|
-
`${localize(language, "Title", "标题")}=${codeSpan(idea.title)}`,
|
|
465
|
-
);
|
|
466
|
-
}
|
|
467
|
-
lines.push(`- ${codeSpan(idea.id)}: ${details.join("; ")}`);
|
|
468
|
-
}
|
|
462
|
+
lines.push(...renderIdeaTable(response.choices, language, now));
|
|
469
463
|
}
|
|
470
464
|
}
|
|
471
465
|
|
|
@@ -475,9 +469,13 @@ export function renderResponse(response) {
|
|
|
475
469
|
`### ${localize(language, "Issues to address", "需要处理的问题")}`,
|
|
476
470
|
"",
|
|
477
471
|
);
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
472
|
+
lines.push(...renderMarkdownTable(
|
|
473
|
+
[
|
|
474
|
+
localize(language, "Type", "类型"),
|
|
475
|
+
localize(language, "Summary", "概要"),
|
|
476
|
+
],
|
|
477
|
+
response.problems.map(({ type, summary }) => [type, summary]),
|
|
478
|
+
));
|
|
481
479
|
}
|
|
482
480
|
|
|
483
481
|
if (response.nextSteps?.length > 0) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { traceSync } from "./trace.js";
|
|
4
|
+
|
|
5
|
+
export function runSubprocess(
|
|
6
|
+
command,
|
|
7
|
+
args,
|
|
8
|
+
options,
|
|
9
|
+
{
|
|
10
|
+
attributes = {},
|
|
11
|
+
spanName = "process.command",
|
|
12
|
+
spawn = spawnSync,
|
|
13
|
+
} = {},
|
|
14
|
+
) {
|
|
15
|
+
return traceSync(
|
|
16
|
+
spanName,
|
|
17
|
+
attributes,
|
|
18
|
+
() => spawn(command, args, options),
|
|
19
|
+
(completed) => ({
|
|
20
|
+
attributes: {
|
|
21
|
+
exitCode: completed.status,
|
|
22
|
+
signal: completed.signal,
|
|
23
|
+
},
|
|
24
|
+
status: completed.status === 0 ? "ok" : "error",
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
}
|
package/src/trace.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { constants } from "node:fs";
|
|
4
|
-
import { access, lstat,
|
|
4
|
+
import { access, lstat, open } from "node:fs/promises";
|
|
5
5
|
import { dirname, resolve } from "node:path";
|
|
6
6
|
|
|
7
7
|
export const TRACE_SCHEMA_VERSION = 2;
|
|
@@ -217,6 +217,41 @@ export function traceSync(name, attributes, callback, summarize = () => ({})) {
|
|
|
217
217
|
);
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
async function flushTrace(path, trace) {
|
|
221
|
+
const span = trace.start("trace.flush", null, {});
|
|
222
|
+
let handle;
|
|
223
|
+
let failure;
|
|
224
|
+
let spanEnded = false;
|
|
225
|
+
try {
|
|
226
|
+
handle = await open(path, "wx");
|
|
227
|
+
await handle.writeFile(trace.serialize(), { encoding: "utf8" });
|
|
228
|
+
await handle.sync();
|
|
229
|
+
trace.end(span, "ok", { eventCount: trace.events.length + 1 });
|
|
230
|
+
spanEnded = true;
|
|
231
|
+
await handle.appendFile(
|
|
232
|
+
`${JSON.stringify(trace.events.at(-1))}\n`,
|
|
233
|
+
{ encoding: "utf8" },
|
|
234
|
+
);
|
|
235
|
+
await handle.sync();
|
|
236
|
+
} catch (caught) {
|
|
237
|
+
if (!spanEnded) {
|
|
238
|
+
trace.end(span, "error", { errorName: errorName(caught) });
|
|
239
|
+
}
|
|
240
|
+
failure = caught;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (handle) {
|
|
244
|
+
try {
|
|
245
|
+
await handle.close();
|
|
246
|
+
} catch (caught) {
|
|
247
|
+
failure = failure
|
|
248
|
+
? new AggregateError([failure, caught], `Cannot close trace file: ${path}`)
|
|
249
|
+
: caught;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (failure) throw failure;
|
|
253
|
+
}
|
|
254
|
+
|
|
220
255
|
export async function withTraceFile(file, name, attributes, callback) {
|
|
221
256
|
if (file === undefined) return callback();
|
|
222
257
|
|
|
@@ -236,7 +271,7 @@ export async function withTraceFile(file, name, attributes, callback) {
|
|
|
236
271
|
|
|
237
272
|
let traceError;
|
|
238
273
|
try {
|
|
239
|
-
await
|
|
274
|
+
await flushTrace(path, trace);
|
|
240
275
|
} catch (caught) {
|
|
241
276
|
traceError = caught;
|
|
242
277
|
}
|
package/src/tui-table.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import stringWidth from "string-width";
|
|
2
|
+
import { parse } from "tui-md";
|
|
3
|
+
|
|
4
|
+
function cellText(node) {
|
|
5
|
+
if (node.type === "image") return node.alt ?? "";
|
|
6
|
+
if (typeof node.value === "string") return node.value;
|
|
7
|
+
return (node.children ?? []).map(cellText).join("");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function tableLines(node) {
|
|
11
|
+
const rows = node.children.map((row) => row.children.map(cellText));
|
|
12
|
+
const widths = rows[0].map((_, column) =>
|
|
13
|
+
Math.max(3, ...rows.map((row) => stringWidth(row[column] ?? "") + 2))
|
|
14
|
+
);
|
|
15
|
+
const border = (left, middle, right) =>
|
|
16
|
+
`${left}${widths.map((width) => "─".repeat(width)).join(middle)}${right}`;
|
|
17
|
+
const rowLine = (row) =>
|
|
18
|
+
`│${widths.map((width, column) => {
|
|
19
|
+
const value = row[column] ?? "";
|
|
20
|
+
const space = width - 2 - stringWidth(value);
|
|
21
|
+
const alignment = node.align?.[column];
|
|
22
|
+
const left = alignment === "right"
|
|
23
|
+
? space
|
|
24
|
+
: alignment === "center" ? Math.floor(space / 2) : 0;
|
|
25
|
+
return ` ${" ".repeat(left)}${value}${" ".repeat(space - left)} │`;
|
|
26
|
+
}).join("")}`;
|
|
27
|
+
const lines = [
|
|
28
|
+
border("┌", "┬", "┐"),
|
|
29
|
+
rowLine(rows[0]),
|
|
30
|
+
border("├", "┼", "┤"),
|
|
31
|
+
];
|
|
32
|
+
for (const [index, row] of rows.slice(1).entries()) {
|
|
33
|
+
if (index > 0) lines.push(border("├", "┼", "┤"));
|
|
34
|
+
lines.push(rowLine(row));
|
|
35
|
+
}
|
|
36
|
+
lines.push(border("└", "┴", "┘"));
|
|
37
|
+
return { lines, width: stringWidth(lines[0]) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function tuiMarkdownBlocks(content, availableWidth) {
|
|
41
|
+
if (!content.includes("|")) return [{ kind: "markdown", content }];
|
|
42
|
+
const blocks = [];
|
|
43
|
+
let offset = 0;
|
|
44
|
+
for (const node of parse(content).children) {
|
|
45
|
+
if (node.type !== "table") continue;
|
|
46
|
+
if (!node.children.every((row) =>
|
|
47
|
+
row.children.every((cell) =>
|
|
48
|
+
cell.children.every((child) => child.type === "text")
|
|
49
|
+
)
|
|
50
|
+
)) continue;
|
|
51
|
+
const table = tableLines(node);
|
|
52
|
+
const hasWideText = node.children.some((row) =>
|
|
53
|
+
row.children.some((cell) => {
|
|
54
|
+
const text = cellText(cell);
|
|
55
|
+
return stringWidth(text) > text.length;
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
if (!hasWideText || table.width > availableWidth) continue;
|
|
59
|
+
const start = node.position.start.offset;
|
|
60
|
+
const end = node.position.end.offset;
|
|
61
|
+
if (start > offset) {
|
|
62
|
+
blocks.push({ kind: "markdown", content: content.slice(offset, start) });
|
|
63
|
+
}
|
|
64
|
+
blocks.push({ kind: "table", lines: table.lines, width: table.width });
|
|
65
|
+
offset = end;
|
|
66
|
+
}
|
|
67
|
+
if (offset < content.length) {
|
|
68
|
+
blocks.push({ kind: "markdown", content: content.slice(offset) });
|
|
69
|
+
}
|
|
70
|
+
return blocks.length === 0 ? [{ kind: "markdown", content }] : blocks;
|
|
71
|
+
}
|
package/src/tui.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createCliRenderer } from "@opentui/core";
|
|
2
|
+
import { win32 } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
createRoot,
|
|
5
|
+
useKeyboard,
|
|
6
|
+
useRenderer,
|
|
7
|
+
useTerminalDimensions,
|
|
8
|
+
} from "@opentui/react";
|
|
9
|
+
import { createElement, useEffect, useMemo, useRef, useState } from "react";
|
|
10
|
+
import { Markdown } from "tui-md";
|
|
11
|
+
|
|
12
|
+
import { runSubprocess } from "./subprocess.js";
|
|
13
|
+
import { tuiMarkdownBlocks } from "./tui-table.js";
|
|
14
|
+
|
|
15
|
+
const COPY_STATUS_DURATION_MS = 3000;
|
|
16
|
+
const SHORTCUT_HINT = " [q/Esc] quit [scroll] navigate [drag, y] copy";
|
|
17
|
+
const SUCCESS_COLOR = "#9ccb9e";
|
|
18
|
+
const WARNING_COLOR = "#e2b86b";
|
|
19
|
+
|
|
20
|
+
function spawnClipboard(command, args, options) {
|
|
21
|
+
return runSubprocess(command, args, options, {
|
|
22
|
+
attributes: { operation: "windows-clipboard" },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function copyStatusColor(status) {
|
|
27
|
+
if (status === null) return "#6b6b6b";
|
|
28
|
+
if (status.kind === "success") return SUCCESS_COLOR;
|
|
29
|
+
if (status.kind === "warning") return WARNING_COLOR;
|
|
30
|
+
throw new Error(`Unsupported copy status kind: ${status.kind}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function copyWindowsClipboard(
|
|
34
|
+
text,
|
|
35
|
+
spawn = spawnClipboard,
|
|
36
|
+
systemRoot = process.env.SystemRoot,
|
|
37
|
+
) {
|
|
38
|
+
if (!systemRoot) return { ok: false, reason: "SystemRoot is unavailable" };
|
|
39
|
+
const script = [
|
|
40
|
+
"$ErrorActionPreference = 'Stop'",
|
|
41
|
+
"[Console]::InputEncoding = New-Object System.Text.UTF8Encoding($false)",
|
|
42
|
+
"$value = [Console]::In.ReadToEnd()",
|
|
43
|
+
"Set-Clipboard -Value $value",
|
|
44
|
+
].join("; ");
|
|
45
|
+
const result = spawn(win32.join(
|
|
46
|
+
systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe",
|
|
47
|
+
), [
|
|
48
|
+
"-NoProfile", "-NonInteractive", "-Command", script,
|
|
49
|
+
], {
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
input: Buffer.from(text, "utf8"),
|
|
52
|
+
timeout: 5000,
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
});
|
|
55
|
+
if (result.error) return { ok: false, reason: result.error.message };
|
|
56
|
+
if (result.status !== 0) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
reason: result.stderr?.trim().split(/\r?\n/, 1)[0]
|
|
60
|
+
|| `PowerShell exited with status ${result.status}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return { ok: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function copyTuiSelection(renderer, {
|
|
67
|
+
stdout = process.stdout,
|
|
68
|
+
platform = process.platform,
|
|
69
|
+
windowsClipboard = copyWindowsClipboard,
|
|
70
|
+
} = {}) {
|
|
71
|
+
const text = renderer.getSelection()?.getSelectedText();
|
|
72
|
+
if (!text) return { message: "Select text first", kind: "warning" };
|
|
73
|
+
if (renderer.copyToClipboardOSC52(text)) {
|
|
74
|
+
return { message: "Copy sent to terminal clipboard", kind: "success" };
|
|
75
|
+
}
|
|
76
|
+
if (platform === "win32" && stdout === process.stdout) {
|
|
77
|
+
const result = windowsClipboard(text);
|
|
78
|
+
return result.ok
|
|
79
|
+
? { message: "Copied to Windows clipboard", kind: "success" }
|
|
80
|
+
: { message: `Copy failed: ${result.reason}`, kind: "warning" };
|
|
81
|
+
}
|
|
82
|
+
return { message: "No clipboard; Shift+drag", kind: "warning" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function MarkdownViewer({ content, stdout }) {
|
|
86
|
+
const renderer = useRenderer();
|
|
87
|
+
const { height, width } = useTerminalDimensions();
|
|
88
|
+
const [copyStatus, setCopyStatus] = useState(null);
|
|
89
|
+
const copyStatusTimeout = useRef(null);
|
|
90
|
+
const contentWidth = Math.max(1, width - 4);
|
|
91
|
+
const blocks = useMemo(
|
|
92
|
+
() => tuiMarkdownBlocks(content, contentWidth),
|
|
93
|
+
[content, contentWidth],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
useEffect(() => () => clearTimeout(copyStatusTimeout.current), []);
|
|
97
|
+
|
|
98
|
+
useKeyboard((key) => {
|
|
99
|
+
if (key.name === "q" || key.name === "escape") renderer.destroy();
|
|
100
|
+
if (key.name === "y") {
|
|
101
|
+
clearTimeout(copyStatusTimeout.current);
|
|
102
|
+
setCopyStatus(copyTuiSelection(renderer, { stdout }));
|
|
103
|
+
copyStatusTimeout.current = setTimeout(() => {
|
|
104
|
+
setCopyStatus(null);
|
|
105
|
+
copyStatusTimeout.current = null;
|
|
106
|
+
}, COPY_STATUS_DURATION_MS);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return createElement(
|
|
111
|
+
"box",
|
|
112
|
+
{
|
|
113
|
+
flexDirection: "column",
|
|
114
|
+
height,
|
|
115
|
+
width,
|
|
116
|
+
},
|
|
117
|
+
createElement(
|
|
118
|
+
"box",
|
|
119
|
+
{
|
|
120
|
+
backgroundColor: "#1e1e1e",
|
|
121
|
+
flexShrink: 0,
|
|
122
|
+
paddingX: 1,
|
|
123
|
+
width: "100%",
|
|
124
|
+
},
|
|
125
|
+
createElement("text", { fg: "#569cd6" }, "Silvermoon"),
|
|
126
|
+
createElement(
|
|
127
|
+
"text",
|
|
128
|
+
{
|
|
129
|
+
fg: copyStatusColor(copyStatus),
|
|
130
|
+
height: 1,
|
|
131
|
+
truncate: true,
|
|
132
|
+
width: "100%",
|
|
133
|
+
wrapMode: "none",
|
|
134
|
+
},
|
|
135
|
+
copyStatus ? ` ${copyStatus.message}` : SHORTCUT_HINT,
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
createElement(
|
|
139
|
+
"scrollbox",
|
|
140
|
+
{
|
|
141
|
+
flexGrow: 1,
|
|
142
|
+
flexShrink: 1,
|
|
143
|
+
scrollY: true,
|
|
144
|
+
width: "100%",
|
|
145
|
+
},
|
|
146
|
+
createElement(
|
|
147
|
+
"box",
|
|
148
|
+
{
|
|
149
|
+
flexDirection: "column",
|
|
150
|
+
paddingX: 2,
|
|
151
|
+
paddingY: 1,
|
|
152
|
+
width: contentWidth,
|
|
153
|
+
},
|
|
154
|
+
...blocks.map((block, index) =>
|
|
155
|
+
block.kind === "markdown"
|
|
156
|
+
? createElement(Markdown, { key: index, content: block.content })
|
|
157
|
+
: createElement(
|
|
158
|
+
"box",
|
|
159
|
+
{ key: index, flexDirection: "column", marginBottom: 1, width: "100%" },
|
|
160
|
+
...block.lines.map((line, lineIndex) =>
|
|
161
|
+
createElement("text", {
|
|
162
|
+
key: lineIndex,
|
|
163
|
+
fg: lineIndex === 1 ? "#569cd6" : "#d4d4d4",
|
|
164
|
+
width: block.width,
|
|
165
|
+
wrapMode: "none",
|
|
166
|
+
}, line)
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function tuiOutputStream(stdout, platform = process.platform) {
|
|
176
|
+
if (platform !== "win32") return stdout;
|
|
177
|
+
|
|
178
|
+
// A distinct stream makes OpenTUI use its byte feed instead of writing UTF-8
|
|
179
|
+
// directly through the native Windows console's legacy output code page.
|
|
180
|
+
return {
|
|
181
|
+
get columns() { return stdout.columns; },
|
|
182
|
+
get rows() { return stdout.rows; },
|
|
183
|
+
get isTTY() { return stdout.isTTY; },
|
|
184
|
+
write: (...args) => stdout.write(...args),
|
|
185
|
+
getColorDepth: (...args) => stdout.getColorDepth(...args),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function renderTuiMarkdown(content, { stdin, stdout }) {
|
|
190
|
+
let renderer;
|
|
191
|
+
try {
|
|
192
|
+
renderer = await createCliRenderer({
|
|
193
|
+
clearOnShutdown: true,
|
|
194
|
+
exitOnCtrlC: true,
|
|
195
|
+
screenMode: "alternate-screen",
|
|
196
|
+
stdin,
|
|
197
|
+
stdout: tuiOutputStream(stdout),
|
|
198
|
+
});
|
|
199
|
+
renderer.setTerminalTitle("Silvermoon");
|
|
200
|
+
createRoot(renderer).render(createElement(MarkdownViewer, { content, stdout }));
|
|
201
|
+
} catch (caught) {
|
|
202
|
+
renderer?.destroy();
|
|
203
|
+
throw caught;
|
|
204
|
+
}
|
|
205
|
+
}
|