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/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
- "当前没有 active idea。",
166
+ "Current state: navigation-ready. No active ideas are available.",
167
+ "当前状态:navigation-ready。当前没有 active idea。",
168
168
  )
169
169
  : localize(
170
170
  language,
171
- `${choices.length} active idea(s) are available.`,
172
- `当前有 ${choices.length} 个 active idea。`,
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 renderIdeaList(response, language) {
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
- localize(language, "ID", "ID"),
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
- `${response.summary} ${localize(language, "Counts", "计数")}: ${counts}.`,
320
- "",
321
- `| ${headers.join(" | ")} |`,
322
- `| ${headers.map(() => "---").join(" | ")} |`,
323
- ];
324
- for (const idea of response.items) {
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?.length > 0) {
447
+ if (response.choices) {
436
448
  lines.push(
437
449
  "",
438
- `### ${localize(language, "Available ideas", "可用 ideas")}`,
450
+ `### ${localize(language, "Active ideas", "Active ideas")}`,
439
451
  "",
440
452
  );
441
- for (const idea of response.choices) {
442
- const alias = idea.alias === undefined ? "" : ` (${idea.alias})`;
443
- lines.push(`- ${codeSpan(idea.id)}${alias} ${idea.state}`);
444
- }
445
- }
446
-
447
- if (response.items) {
448
- lines.push("", `### ${localize(language, "Results", "结果")}`, "");
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
- for (const idea of response.items) {
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
- for (const problem of response.problems) {
479
- lines.push(`- [${problem.type}] ${problem.summary}`);
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, writeFile } from "node:fs/promises";
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 writeFile(path, trace.serialize(), { encoding: "utf8", flag: "wx" });
274
+ await flushTrace(path, trace);
240
275
  } catch (caught) {
241
276
  traceError = caught;
242
277
  }
@@ -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
+ }