silvermoon 0.2.2 → 0.3.0-rc.2

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.
@@ -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
+ }
package/src/whatsnext.js CHANGED
@@ -9,12 +9,14 @@ import {
9
9
  } from "./dialogue.js";
10
10
  import { createCommandRun } from "./domain.js";
11
11
  import { inspectPhaseGuidance } from "./guidance.js";
12
+ import {
13
+ metadataFailureObservation,
14
+ readIdeaInventoryItem,
15
+ } from "./idea-metadata.js";
12
16
  import {
13
17
  compareCommits,
14
18
  fetchPrimary,
15
- inspectCurrentBranch,
16
- inspectWorktreeChanges,
17
- resolveHead,
19
+ inspectRepositoryState,
18
20
  sanitizeGitMessage,
19
21
  } from "./git.js";
20
22
  import { canonicalizeOutputLanguage } from "./language.js";
@@ -175,28 +177,36 @@ function selectIdea(ideas, selector) {
175
177
  ?? null;
176
178
  }
177
179
 
178
- function lifecycleInstruction(idea, language) {
180
+ function lifecycleContentLanguageInstruction(contentLanguage, language) {
181
+ return localize(
182
+ language,
183
+ `Use ${contentLanguage} for natural-language content in the current world, its supporting files, and the ledger. Preserve canonical headings, stable IDs, paths, and machine fields.`,
184
+ `在当前世界、同世界辅助文件和 ledger 的自然语言内容中使用 ${contentLanguage}。保留 canonical 标题、稳定 ID、路径和机器字段。`,
185
+ );
186
+ }
187
+
188
+ function lifecycleInstruction(idea, language, contentLanguage) {
179
189
  const name = ideaName(idea);
180
190
  if (idea.state === "preparing") {
181
- return localize(
191
+ return joinInstructions([localize(
182
192
  language,
183
193
  `Continue idea ${name} in ${idea.worlds.idealRevision.documentPath} and ${idea.ledgerPath}. Preserve the other worlds. When the Ideal World is ready, ask the user to approve exact revision ${idea.idealRevision}; only after explicit approval write it to approvedRevision in ${idea.statusPath}.`,
184
194
  `继续在 ${idea.worlds.idealRevision.documentPath} 和 ${idea.ledgerPath} 推进 idea ${name},并保留其他世界。理想契约就绪后,请用户明确批准精确 revision ${idea.idealRevision};只有获得明确批准后,才将其写入 ${idea.statusPath} 的 approvedRevision。`,
185
- );
195
+ ), lifecycleContentLanguageInstruction(contentLanguage, language)]);
186
196
  }
187
197
  if (idea.state === "implementing") {
188
- return localize(
198
+ return joinInstructions([localize(
189
199
  language,
190
200
  `Continue idea ${name} in ${idea.worlds.implementationRevision.documentPath}, its supporting files under ${idea.worlds.implementationRevision.path}, and ${idea.ledgerPath}. Do not change ${idea.worlds.idealRevision.path} unless the Ideal World must change. After all implementation evidence is published, ask the user to accept exact revision ${idea.implementationRevision}; only then write it to implementationAcceptedRevision in ${idea.statusPath}.`,
191
201
  `继续在 ${idea.worlds.implementationRevision.documentPath}、${idea.worlds.implementationRevision.path} 下的辅助文件和 ${idea.ledgerPath} 推进 idea ${name}。除非理想契约确实需要变化,否则不要修改 ${idea.worlds.idealRevision.path}。全部实现证据发布后,请用户明确验收精确 revision ${idea.implementationRevision};只有获得明确验收后,才将其写入 ${idea.statusPath} 的 implementationAcceptedRevision。`,
192
- );
202
+ ), lifecycleContentLanguageInstruction(contentLanguage, language)]);
193
203
  }
194
204
  if (idea.state === "deploying") {
195
- return localize(
205
+ return joinInstructions([localize(
196
206
  language,
197
207
  `Continue idea ${name} from ${idea.worlds.deploymentRevision.documentPath}, its supporting files under ${idea.worlds.deploymentRevision.path}, and ${idea.ledgerPath}. Preserve nested worlds. After external evidence is complete and published, ask the user to accept exact revision ${idea.deploymentRevision}; only then write it to deploymentAcceptedRevision in ${idea.statusPath}.`,
198
208
  `从 ${idea.worlds.deploymentRevision.documentPath}、${idea.worlds.deploymentRevision.path} 下的辅助文件和 ${idea.ledgerPath} 继续推进 idea ${name},并保留内层世界。外部证据完成且发布后,请用户明确验收精确 revision ${idea.deploymentRevision};只有获得明确验收后,才将其写入 ${idea.statusPath} 的 deploymentAcceptedRevision。`,
199
- );
209
+ ), lifecycleContentLanguageInstruction(contentLanguage, language)]);
200
210
  }
201
211
  if (idea.state === "abandoned") {
202
212
  return localize(
@@ -242,9 +252,9 @@ async function assessRepositoryReadinessInternal({
242
252
  synchronizePrimary = true,
243
253
  }) {
244
254
  const language = observed.outputLanguage;
245
- let changes;
255
+ let repository;
246
256
  try {
247
- changes = inspectWorktreeChanges(root);
257
+ repository = inspectRepositoryState(root);
248
258
  } catch (caught) {
249
259
  const problem = {
250
260
  type: "worktree-inspection-failed",
@@ -260,6 +270,7 @@ async function assessRepositoryReadinessInternal({
260
270
  ready: false,
261
271
  };
262
272
  }
273
+ const { branch, changes, head } = repository;
263
274
 
264
275
  const dirty =
265
276
  changes.conflicted.length > 0
@@ -307,7 +318,6 @@ async function assessRepositoryReadinessInternal({
307
318
  localSteps.push(...worktreeInstructionSteps(changes, language));
308
319
  }
309
320
 
310
- const head = resolveHead(root);
311
321
  if (head === null) {
312
322
  localProblems.push({
313
323
  type: "head-missing",
@@ -324,7 +334,6 @@ async function assessRepositoryReadinessInternal({
324
334
  ));
325
335
  }
326
336
 
327
- const branch = inspectCurrentBranch(root);
328
337
  if (branch.branch === null) {
329
338
  localProblems.push({
330
339
  type: "detached-head",
@@ -596,9 +605,49 @@ export async function whatsNext({
596
605
  );
597
606
  }
598
607
 
608
+ let navigationObservation = readiness.observation;
609
+ if (selector === undefined || !selected) {
610
+ let activeItems;
611
+ try {
612
+ const activeIds = new Set(
613
+ readiness.observation.ideas.activeIdeas.map(({ id }) => id),
614
+ );
615
+ activeItems = await traceAsync(
616
+ "ideas.navigation-metadata",
617
+ { ideaCount: activeIds.size },
618
+ () => Promise.all(
619
+ observed.layout.ideas
620
+ .filter(({ id }) => activeIds.has(id))
621
+ .map((idea) => readIdeaInventoryItem(observed.observation.root, idea)),
622
+ ),
623
+ );
624
+ } catch (caught) {
625
+ const failure = metadataFailureObservation(
626
+ observed,
627
+ caught,
628
+ recheckCommand,
629
+ );
630
+ return runtime.complete(failure.observation, failure.responseContext);
631
+ }
632
+ const itemsById = new Map(activeItems.map((item) => [item.id, item]));
633
+ navigationObservation = {
634
+ ...readiness.observation,
635
+ ideas: {
636
+ ...readiness.observation.ideas,
637
+ activeIdeas: readiness.observation.ideas.activeIdeas.map(({ id }) => {
638
+ const item = itemsById.get(id);
639
+ if (item === undefined) {
640
+ throw new Error(`Missing observed navigation metadata for ${id}`);
641
+ }
642
+ return item;
643
+ }),
644
+ },
645
+ };
646
+ }
647
+
599
648
  if (selector === undefined) {
600
649
  return runtime.complete(
601
- { ...readiness.observation, state: "navigation-ready" },
650
+ { ...navigationObservation, state: "navigation-ready" },
602
651
  {
603
652
  nextSteps: navigationInstruction(
604
653
  observed.layout.ideas,
@@ -609,8 +658,8 @@ export async function whatsNext({
609
658
  }
610
659
  if (!selected) {
611
660
  return runtime.complete(
612
- dialogueReadyObservation(readiness.observation, "idea-not-found", {
613
- candidates: readiness.observation.ideas.activeIdeas,
661
+ dialogueReadyObservation(navigationObservation, "idea-not-found", {
662
+ candidates: navigationObservation.ideas.activeIdeas,
614
663
  }),
615
664
  {
616
665
  nextSteps: joinInstructions([
@@ -667,7 +716,11 @@ export async function whatsNext({
667
716
  ...(guidance === undefined ? {} : { guidance }),
668
717
  }),
669
718
  {
670
- nextSteps: lifecycleInstruction(selected, observed.outputLanguage),
719
+ nextSteps: lifecycleInstruction(
720
+ selected,
721
+ observed.outputLanguage,
722
+ observed.contentLanguage,
723
+ ),
671
724
  },
672
725
  );
673
726
  }