@chainingintention/pi-web-cn 1.202607.10 → 1.202608.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.
Files changed (111) hide show
  1. package/README.md +9 -9
  2. package/dist/cli.js +27 -12
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/{CodeViewer-5vQNxhKQ.js → CodeViewer-j37b7YHz.js} +1 -1
  5. package/dist/client/assets/{TerminalPanel-Ctz1zP84.js → TerminalPanel-DqDiXDrk.js} +15 -15
  6. package/dist/client/assets/{UnifiedDiffViewer-DXLMHyO2.js → UnifiedDiffViewer-AQTSNI1y.js} +3 -3
  7. package/dist/client/assets/index-DDuQX9_Y.js +4101 -0
  8. package/dist/client/index.html +1 -1
  9. package/dist/config.js +36 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/nativeServices/servicePlan.js +3 -2
  12. package/dist/nativeServices/servicePlan.js.map +1 -1
  13. package/dist/nativeServices/serviceProbe.js +7 -5
  14. package/dist/nativeServices/serviceProbe.js.map +1 -1
  15. package/dist/pi-web-plugins/info/infoInternals.js +223 -0
  16. package/dist/pi-web-plugins/info/pi-web-plugin.js +19 -22
  17. package/dist/pi-web-plugins/relays/markdownDocument.js +81 -0
  18. package/dist/pi-web-plugins/relays/package.json +9 -0
  19. package/dist/pi-web-plugins/relays/pi-web-plugin.js +44 -0
  20. package/dist/pi-web-plugins/relays/relayDiscovery.js +96 -0
  21. package/dist/pi-web-plugins/relays/relaysPanelElement.js +327 -0
  22. package/dist/pi-web-plugins/relays/vendor/README.md +24 -0
  23. package/dist/pi-web-plugins/relays/vendor/marked.esm.js +76 -0
  24. package/dist/pi-web-plugins/updates/pi-web-plugin.js +100 -100
  25. package/dist/pi-web-plugins/updates/updatesLogic.js +8 -5
  26. package/dist/plugin-api.d.ts +5 -1
  27. package/dist/server/configRoutes.js +11 -0
  28. package/dist/server/configRoutes.js.map +1 -1
  29. package/dist/server/diagnostics/nodePtyNativeModule.js +36 -0
  30. package/dist/server/diagnostics/nodePtyNativeModule.js.map +1 -0
  31. package/dist/server/git/gitService.js +176 -5
  32. package/dist/server/git/gitService.js.map +1 -1
  33. package/dist/server/piWebStatus.js +7 -4
  34. package/dist/server/piWebStatus.js.map +1 -1
  35. package/dist/server/realtime/sessionEventHub.js +25 -4
  36. package/dist/server/realtime/sessionEventHub.js.map +1 -1
  37. package/dist/server/sessiond/sessionDaemonStartup.js +0 -42
  38. package/dist/server/sessiond/sessionDaemonStartup.js.map +1 -1
  39. package/dist/server/sessiond/sessionServiceDependencies.js +31 -0
  40. package/dist/server/sessiond/sessionServiceDependencies.js.map +1 -0
  41. package/dist/server/sessions/askUserTool.js +105 -0
  42. package/dist/server/sessions/askUserTool.js.map +1 -0
  43. package/dist/server/sessions/authProviderOptions.js +26 -29
  44. package/dist/server/sessions/authProviderOptions.js.map +1 -1
  45. package/dist/server/sessions/authRoutes.js +9 -1
  46. package/dist/server/sessions/authRoutes.js.map +1 -1
  47. package/dist/server/sessions/authService.js +143 -33
  48. package/dist/server/sessions/authService.js.map +1 -1
  49. package/dist/server/sessions/globalProviderPolicy.js +167 -0
  50. package/dist/server/sessions/globalProviderPolicy.js.map +1 -0
  51. package/dist/server/sessions/modelCatalogRefresher.js +192 -0
  52. package/dist/server/sessions/modelCatalogRefresher.js.map +1 -0
  53. package/dist/server/sessions/oauthLoginFlowService.js +181 -56
  54. package/dist/server/sessions/oauthLoginFlowService.js.map +1 -1
  55. package/dist/server/sessions/parentSessionLocator.js +75 -0
  56. package/dist/server/sessions/parentSessionLocator.js.map +1 -0
  57. package/dist/server/sessions/pendingAskStore.js +278 -0
  58. package/dist/server/sessions/pendingAskStore.js.map +1 -0
  59. package/dist/server/sessions/piSessionService.js +1329 -214
  60. package/dist/server/sessions/piSessionService.js.map +1 -1
  61. package/dist/server/sessions/plainTextTheme.js +112 -0
  62. package/dist/server/sessions/plainTextTheme.js.map +1 -0
  63. package/dist/server/sessions/projectAuthService.js +3 -3
  64. package/dist/server/sessions/projectAuthService.js.map +1 -1
  65. package/dist/server/sessions/sessionActivityCoordinator.js +38 -0
  66. package/dist/server/sessions/sessionActivityCoordinator.js.map +1 -0
  67. package/dist/server/sessions/sessionCommandService.js +60 -5
  68. package/dist/server/sessions/sessionCommandService.js.map +1 -1
  69. package/dist/server/sessions/sessionContentSearch.js +74 -0
  70. package/dist/server/sessions/sessionContentSearch.js.map +1 -0
  71. package/dist/server/sessions/sessionFileHeader.js +45 -0
  72. package/dist/server/sessions/sessionFileHeader.js.map +1 -0
  73. package/dist/server/sessions/sessionNotificationStore.js +484 -0
  74. package/dist/server/sessions/sessionNotificationStore.js.map +1 -0
  75. package/dist/server/sessions/sessionRoutes.js +265 -1
  76. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  77. package/dist/server/sessions/sessionTreeProjection.js +250 -0
  78. package/dist/server/sessions/sessionTreeProjection.js.map +1 -0
  79. package/dist/server/sessions/sessionUnreadStore.js +520 -0
  80. package/dist/server/sessions/sessionUnreadStore.js.map +1 -0
  81. package/dist/server/sessions/spawnSubsessionTool.js +10 -12
  82. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  83. package/dist/server/sessions/spawnTargetResolver.js +3 -16
  84. package/dist/server/sessions/spawnTargetResolver.js.map +1 -1
  85. package/dist/server/terminals/terminalService.js +8 -2
  86. package/dist/server/terminals/terminalService.js.map +1 -1
  87. package/dist/server/workspaces/gitWorktreeDiscovery.js +9 -0
  88. package/dist/server/workspaces/gitWorktreeDiscovery.js.map +1 -1
  89. package/dist/server/workspaces/projectWorkspaceCwds.js +22 -0
  90. package/dist/server/workspaces/projectWorkspaceCwds.js.map +1 -0
  91. package/dist/server/workspaces/workspaceService.js +15 -2
  92. package/dist/server/workspaces/workspaceService.js.map +1 -1
  93. package/dist/shared/activity.js +9 -1
  94. package/dist/shared/activity.js.map +1 -1
  95. package/dist/shared/apiTypes.d.ts +434 -8
  96. package/dist/shared/apiTypes.js +26 -0
  97. package/dist/shared/apiTypes.js.map +1 -1
  98. package/dist/shared/capabilities.js +9 -0
  99. package/dist/shared/capabilities.js.map +1 -1
  100. package/dist/shared/federatedRoutes.js +14 -0
  101. package/dist/shared/federatedRoutes.js.map +1 -1
  102. package/dist/shared/thinkingLevels.d.ts +2 -0
  103. package/dist/shared/thinkingLevels.js +25 -0
  104. package/dist/shared/thinkingLevels.js.map +1 -1
  105. package/docs/config.md +76 -9
  106. package/docs/plugins.md +72 -16
  107. package/install.sh +1 -1
  108. package/package.json +10 -8
  109. package/dist/client/assets/index-Jmsbve6u.js +0 -3173
  110. package/dist/server/sessions/sessionArchiveMigration.js +0 -590
  111. package/dist/server/sessions/sessionArchiveMigration.js.map +0 -1
@@ -1,4 +1,11 @@
1
1
  // Generated from pi-web-plugins/info/pi-web-plugin.ts. Do not edit directly.
2
+ // Skeleton of a PI WEB plugin: metadata plus contribution definitions.
3
+ //
4
+ // Everything the bundled Info panel and action actually render lives in
5
+ // infoInternals.ts. That file is replaceable implementation detail — when
6
+ // copying this plugin as a starting point, keep this file's shape and swap
7
+ // the internals for your own.
8
+ import { copyDiagnostics, renderInfoPanel } from "./infoInternals.js";
2
9
  const plugin = {
3
10
  apiVersion: 1,
4
11
  name: "信息插件",
@@ -6,43 +13,33 @@ const plugin = {
6
13
  contributions: {
7
14
  actions: [
8
15
  {
9
- id: "workspace.show-path",
10
- title: "显示当前工作区路径",
16
+ id: "copy-diagnostics",
17
+ title: "复制 PI WEB 诊断信息",
18
+ description: "复制此机器的版本、安装和状态详情,以便粘贴到错误报告中",
11
19
  group: "信息",
12
- enabled: (context) => context.state.selectedWorkspace !== undefined,
13
- run: (context) => {
14
- const path = context.state.selectedWorkspace?.path ?? "未选择工作区";
15
- window.alert(path);
16
- },
20
+ run: (context) => copyDiagnostics(context),
17
21
  },
18
22
  ],
19
23
  workspaceLabels: [
20
24
  {
21
25
  id: "workspace.kind-label",
22
26
  order: 100,
23
- items: (context) => [{ type: "text", text: context.workspace.isGitRepo ? "git" : "文件夹", title: context.workspace.path }],
27
+ items: (context) => [{ type: "text", text: context.workspace.isGitRepo ? "Git" : "文件夹", title: context.workspace.path }],
24
28
  },
25
29
  ],
26
30
  workspacePanels: [
27
31
  {
28
32
  id: "workspace.info",
29
33
  title: "信息",
30
- icon: svg `
31
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
32
- <circle cx="12" cy="12" r="9"></circle>
33
- <path d="M12 11v5"></path>
34
- <path d="M12 8h.01"></path>
35
- </svg>
34
+ icon: svg `
35
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
36
+ <circle cx="12" cy="12" r="9"></circle>
37
+ <path d="M12 11v5"></path>
38
+ <path d="M12 8h.01"></path>
39
+ </svg>
36
40
  `,
37
41
  order: 1000,
38
- render: (context) => html `
39
- <section class="toolbar"><strong>信息</strong></section>
40
- <section class="viewer">
41
- <p><strong>工作区</strong></p>
42
- <p class="muted">${context.workspace.label}</p>
43
- <p class="muted">${context.workspace.path}</p>
44
- </section>
45
- `,
42
+ render: (context) => renderInfoPanel(html, context),
46
43
  },
47
44
  ],
48
45
  },
@@ -0,0 +1,81 @@
1
+ // Generated from pi-web-plugins/relays/markdownDocument.ts. Do not edit directly.
2
+ import { marked } from "./vendor/marked.esm.js";
3
+ // Raw HTML inside relay documents is escaped before sanitizing, so the
4
+ // sanitizer only ever sees marked-generated markup. This mirrors the safety
5
+ // rules of the app's renderer in src/client/src/formatting/markdown.ts; the
6
+ // plugin cannot import that module, so the rules are duplicated here.
7
+ const renderer = new marked.Renderer();
8
+ renderer.html = ({ text }) => escapeHtml(text);
9
+ const MAX_MARKDOWN_CACHE_ENTRIES = 300;
10
+ const markdownHtmlCache = new Map();
11
+ /** Relay documents ending in .md render as markdown; everything else stays preformatted text. */
12
+ export function isMarkdownDocumentPath(path) {
13
+ return path.toLowerCase().endsWith(".md");
14
+ }
15
+ /** Render one relay markdown document into sanitized HTML safe to interpolate into innerHTML. */
16
+ export function renderRelayDocumentHtml(source) {
17
+ const cached = markdownHtmlCache.get(source);
18
+ if (cached !== undefined)
19
+ return cached;
20
+ const html = marked.parse(source, { async: false, breaks: true, gfm: true, renderer });
21
+ const safeHtml = sanitizeHtml(html);
22
+ markdownHtmlCache.set(source, safeHtml);
23
+ if (markdownHtmlCache.size > MAX_MARKDOWN_CACHE_ENTRIES) {
24
+ const oldest = markdownHtmlCache.keys().next().value;
25
+ if (oldest !== undefined)
26
+ markdownHtmlCache.delete(oldest);
27
+ }
28
+ return safeHtml;
29
+ }
30
+ function escapeHtml(text) {
31
+ return text
32
+ .replaceAll("&", "&amp;")
33
+ .replaceAll("<", "&lt;")
34
+ .replaceAll(">", "&gt;");
35
+ }
36
+ const TABLE_SCROLL_CLASS = "table-scroll";
37
+ function sanitizeHtml(html) {
38
+ const template = document.createElement("template");
39
+ template.innerHTML = html;
40
+ template.content.querySelectorAll("script, style, iframe, object, embed").forEach((node) => { node.remove(); });
41
+ template.content.querySelectorAll("*").forEach((element) => {
42
+ for (const attribute of [...element.attributes]) {
43
+ const name = attribute.name.toLowerCase();
44
+ if (name.startsWith("on"))
45
+ element.removeAttribute(attribute.name);
46
+ if ((name === "href" || name === "src") && !isSafeUrl(attribute.value))
47
+ element.removeAttribute(attribute.name);
48
+ }
49
+ if (element.tagName === "A") {
50
+ element.setAttribute("target", "_blank");
51
+ element.setAttribute("rel", "noreferrer noopener");
52
+ }
53
+ });
54
+ wrapTablesInScrollRegions(template.content);
55
+ return template.innerHTML;
56
+ }
57
+ // Tables keep their natural width and scroll horizontally instead of being
58
+ // squeezed into the panel, which is unreadable on narrow screens.
59
+ function wrapTablesInScrollRegions(root) {
60
+ root.querySelectorAll("table").forEach((table) => {
61
+ if (table.parentElement?.classList.contains(TABLE_SCROLL_CLASS) === true)
62
+ return;
63
+ const wrapper = document.createElement("div");
64
+ wrapper.className = TABLE_SCROLL_CLASS;
65
+ wrapper.setAttribute("role", "region");
66
+ wrapper.setAttribute("aria-label", "表格");
67
+ wrapper.setAttribute("tabindex", "0");
68
+ table.before(wrapper);
69
+ wrapper.append(table);
70
+ });
71
+ }
72
+ function isSafeUrl(url) {
73
+ if (url.startsWith("#") || url.startsWith("/"))
74
+ return true;
75
+ try {
76
+ return ["http:", "https:", "mailto:"].includes(new URL(url).protocol);
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@pi-web/relays-plugin",
3
+ "private": true,
4
+ "piWeb": {
5
+ "plugins": [
6
+ { "id": "relays", "module": "pi-web-plugin.js" }
7
+ ]
8
+ }
9
+ }
@@ -0,0 +1,44 @@
1
+ // Generated from pi-web-plugins/relays/pi-web-plugin.ts. Do not edit directly.
2
+ import { RELAYS_ROOT } from "./relayDiscovery.js";
3
+ import { defineRelaysPanelElement } from "./relaysPanelElement.js";
4
+ const plugin = {
5
+ apiVersion: 1,
6
+ name: "中继",
7
+ activate: ({ pluginId, html, svg }) => {
8
+ defineRelaysPanelElement();
9
+ return {
10
+ contributions: {
11
+ actions: [
12
+ {
13
+ id: "workspace.open-relays",
14
+ title: "打开工作区中继",
15
+ description: `打开工作区的中继标签页。中继存放在 ${RELAYS_ROOT}。`,
16
+ group: "工作区",
17
+ enabled: (context) => context.state.selectedWorkspace !== undefined,
18
+ run: (context) => {
19
+ if (context.state.selectedWorkspace === undefined)
20
+ return;
21
+ context.selectWorkspaceTool(`${pluginId}:workspace.relays`);
22
+ },
23
+ },
24
+ ],
25
+ workspacePanels: [
26
+ {
27
+ id: "workspace.relays",
28
+ title: "中继",
29
+ icon: svg `
30
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
31
+ <path d="M9 17H7A5 5 0 0 1 7 7h2"></path>
32
+ <path d="M15 7h2a5 5 0 1 1 0 10h-2"></path>
33
+ <line x1="8" y1="12" x2="16" y2="12"></line>
34
+ </svg>
35
+ `,
36
+ order: 50,
37
+ render: (context) => html `<pi-web-relays-panel .context=${context}></pi-web-relays-panel>`,
38
+ },
39
+ ],
40
+ },
41
+ };
42
+ },
43
+ };
44
+ export default plugin;
@@ -0,0 +1,96 @@
1
+ // Generated from pi-web-plugins/relays/relayDiscovery.ts. Do not edit directly.
2
+ export const RELAYS_ROOT = ".pi-web/relays";
3
+ /** Documents that anchor a relay packet, in display order. Any other files follow alphabetically. */
4
+ export const RELAY_ANCHOR_DOCUMENTS = ["status.md", "charter.md", "log.md"];
5
+ // The workspace file API rejects with these messages when a path is absent or
6
+ // is not a directory. For the relays root both mean "zero relays", not a failure.
7
+ const missingListingErrorMessages = new Set(["Path does not exist", "Path is not a directory"]);
8
+ /** List the workspace's relays, most recently modified first. Never rejects. */
9
+ export async function listWorkspaceRelays(files) {
10
+ let listing;
11
+ try {
12
+ listing = await files.listFiles(RELAYS_ROOT);
13
+ }
14
+ catch (error) {
15
+ return fileAccessFailure(error);
16
+ }
17
+ const relays = sortRelaysByRecency(listing.entries
18
+ .filter((entry) => entry.type === "directory")
19
+ .map((entry) => toRelaySummary(entry)));
20
+ return { kind: "loaded", relays };
21
+ }
22
+ /** List one relay's documents: status.md, charter.md, log.md first, then alphabetical. Never rejects. */
23
+ export async function listRelayDocuments(files, relayPath) {
24
+ let listing;
25
+ try {
26
+ listing = await files.listFiles(relayPath);
27
+ }
28
+ catch (error) {
29
+ return fileAccessFailure(error);
30
+ }
31
+ const documents = orderRelayDocuments(listing.entries
32
+ .filter((entry) => entry.type === "file")
33
+ .map((entry) => toRelayDocumentEntry(entry)));
34
+ return { kind: "loaded", documents };
35
+ }
36
+ /** Read one relay document. Never rejects. */
37
+ export async function readRelayDocument(files, documentPath) {
38
+ try {
39
+ const file = await files.readFile(documentPath);
40
+ return { kind: "loaded", content: file.content, truncated: file.truncated, binary: file.binary };
41
+ }
42
+ catch (error) {
43
+ return fileAccessFailure(error);
44
+ }
45
+ }
46
+ /** Newest first; relays without a usable modifiedAt sort last, alphabetically. */
47
+ export function sortRelaysByRecency(relays) {
48
+ return [...relays].sort(compareRelaysByRecency);
49
+ }
50
+ /** Anchor documents first in fixed order, then everything else alphabetically. */
51
+ export function orderRelayDocuments(documents) {
52
+ return [...documents].sort(compareRelayDocuments);
53
+ }
54
+ /** The document a relay opens on: status.md when present, otherwise the first ordered document. */
55
+ export function defaultRelayDocument(documents) {
56
+ // Anchor ordering guarantees status.md is first whenever it exists.
57
+ return orderRelayDocuments(documents)[0];
58
+ }
59
+ function toRelaySummary(entry) {
60
+ return { name: entry.name, path: entry.path, modifiedAt: entry.modifiedAt };
61
+ }
62
+ function toRelayDocumentEntry(entry) {
63
+ return { name: entry.name, path: entry.path, modifiedAt: entry.modifiedAt };
64
+ }
65
+ function compareRelaysByRecency(left, right) {
66
+ const leftTime = timestampOf(left.modifiedAt);
67
+ const rightTime = timestampOf(right.modifiedAt);
68
+ if (leftTime !== undefined && rightTime !== undefined && leftTime !== rightTime)
69
+ return rightTime - leftTime;
70
+ if (leftTime !== undefined)
71
+ return -1;
72
+ if (rightTime !== undefined)
73
+ return 1;
74
+ return left.name.localeCompare(right.name);
75
+ }
76
+ function timestampOf(modifiedAt) {
77
+ if (modifiedAt === undefined)
78
+ return undefined;
79
+ const time = Date.parse(modifiedAt);
80
+ return Number.isNaN(time) ? undefined : time;
81
+ }
82
+ function compareRelayDocuments(left, right) {
83
+ const anchorOrder = anchorIndexOf(left.name) - anchorIndexOf(right.name);
84
+ if (anchorOrder !== 0)
85
+ return anchorOrder;
86
+ return left.name.localeCompare(right.name);
87
+ }
88
+ function anchorIndexOf(name) {
89
+ const index = RELAY_ANCHOR_DOCUMENTS.indexOf(name);
90
+ return index === -1 ? RELAY_ANCHOR_DOCUMENTS.length : index;
91
+ }
92
+ function fileAccessFailure(error) {
93
+ if (error instanceof Error && missingListingErrorMessages.has(error.message))
94
+ return { kind: "missing" };
95
+ return { kind: "unavailable", detail: error instanceof Error ? error.message : String(error) };
96
+ }
@@ -0,0 +1,327 @@
1
+ // Generated from pi-web-plugins/relays/relaysPanelElement.ts. Do not edit directly.
2
+ import { isMarkdownDocumentPath, renderRelayDocumentHtml } from "./markdownDocument.js";
3
+ import { defaultRelayDocument, listRelayDocuments, listWorkspaceRelays, readRelayDocument, RELAYS_ROOT, } from "./relayDiscovery.js";
4
+ export const relaysPanelTagName = "pi-web-relays-panel";
5
+ export function defineRelaysPanelElement() {
6
+ if (!customElements.get(relaysPanelTagName))
7
+ customElements.define(relaysPanelTagName, PiWebRelaysPanel);
8
+ }
9
+ /**
10
+ * Read-only relay browser: relay picker (auto-opens a single relay), one tab
11
+ * per relay document, and a document viewer rendering markdown documents as
12
+ * sanitized HTML and everything else as preformatted text. All async loads
13
+ * flow through scanToken so stale responses for a previous workspace or
14
+ * selection never overwrite newer state.
15
+ */
16
+ class PiWebRelaysPanel extends HTMLElement {
17
+ contextValue;
18
+ listing;
19
+ selectedRelayPath;
20
+ documents;
21
+ selectedDocumentPath;
22
+ documentContent;
23
+ scanToken = 0;
24
+ root;
25
+ constructor() {
26
+ super();
27
+ this.root = this.attachShadow({ mode: "open" });
28
+ }
29
+ set context(value) {
30
+ const previousKey = this.contextValue === undefined ? undefined : contextKey(this.contextValue);
31
+ const nextKey = value === undefined ? undefined : contextKey(value);
32
+ this.contextValue = value;
33
+ // Parent app updates should not rescan or rebuild this shadow DOM for the
34
+ // same workspace (mirrors the workspace-tasks panel).
35
+ if (previousKey === nextKey)
36
+ return;
37
+ if (value === undefined) {
38
+ this.resetScanState();
39
+ this.render();
40
+ return;
41
+ }
42
+ void this.scan(value, {});
43
+ }
44
+ connectedCallback() {
45
+ this.render();
46
+ }
47
+ /** Rescan relays, then reload the selected relay's documents and the open document. */
48
+ async scan(context, selection) {
49
+ const token = ++this.scanToken;
50
+ this.resetScanState();
51
+ this.render();
52
+ const listing = await listWorkspaceRelays(context.files);
53
+ if (!this.isCurrentScan(context, token))
54
+ return;
55
+ this.listing = listing;
56
+ // listWorkspaceRelays returns most recently modified first, so the first
57
+ // relay is the default pre-selection.
58
+ const relay = listing.kind === "loaded"
59
+ ? listing.relays.find((candidate) => candidate.path === selection.relayPath) ?? listing.relays[0]
60
+ : undefined;
61
+ this.selectedRelayPath = relay?.path;
62
+ if (relay === undefined) {
63
+ this.render();
64
+ return;
65
+ }
66
+ await this.loadDocuments(context, token, relay.path, selection.documentPath);
67
+ }
68
+ async openRelay(context, relayPath) {
69
+ const token = ++this.scanToken;
70
+ this.selectedRelayPath = relayPath;
71
+ await this.loadDocuments(context, token, relayPath, undefined);
72
+ }
73
+ async openDocument(context, documentPath) {
74
+ const token = ++this.scanToken;
75
+ this.selectedDocumentPath = documentPath;
76
+ await this.loadDocumentContent(context, token, documentPath);
77
+ }
78
+ async loadDocuments(context, token, relayPath, preferredDocumentPath) {
79
+ this.documents = undefined;
80
+ this.selectedDocumentPath = undefined;
81
+ this.documentContent = undefined;
82
+ this.render();
83
+ const documents = await listRelayDocuments(context.files, relayPath);
84
+ if (!this.isCurrentScan(context, token))
85
+ return;
86
+ this.documents = documents;
87
+ const document = documents.kind === "loaded"
88
+ ? documents.documents.find((candidate) => candidate.path === preferredDocumentPath) ?? defaultRelayDocument(documents.documents)
89
+ : undefined;
90
+ this.selectedDocumentPath = document?.path;
91
+ if (document === undefined) {
92
+ this.render();
93
+ return;
94
+ }
95
+ await this.loadDocumentContent(context, token, document.path);
96
+ }
97
+ async loadDocumentContent(context, token, documentPath) {
98
+ this.documentContent = undefined;
99
+ this.render();
100
+ const content = await readRelayDocument(context.files, documentPath);
101
+ if (!this.isCurrentScan(context, token))
102
+ return;
103
+ this.documentContent = content;
104
+ this.render();
105
+ }
106
+ refresh() {
107
+ const context = this.contextValue;
108
+ if (context === undefined)
109
+ return;
110
+ void this.scan(context, { relayPath: this.selectedRelayPath, documentPath: this.selectedDocumentPath });
111
+ }
112
+ resetScanState() {
113
+ this.listing = undefined;
114
+ this.selectedRelayPath = undefined;
115
+ this.documents = undefined;
116
+ this.selectedDocumentPath = undefined;
117
+ this.documentContent = undefined;
118
+ }
119
+ isCurrentScan(context, token) {
120
+ return token === this.scanToken && this.contextValue !== undefined && contextKey(this.contextValue) === contextKey(context);
121
+ }
122
+ render() {
123
+ const context = this.contextValue;
124
+ if (context === undefined) {
125
+ this.root.innerHTML = `${relaysStyles()}<section class="empty">请选择工作区。</section>`;
126
+ return;
127
+ }
128
+ this.root.innerHTML = `
129
+ ${relaysStyles()}
130
+ <section class="toolbar">
131
+ <strong>中继</strong>
132
+ <span class="toolbar-actions">
133
+ ${this.renderRelayPicker()}
134
+ <button class="icon-button" data-refresh aria-label="刷新" title="刷新">${refreshIconSvg()}</button>
135
+ </span>
136
+ </section>
137
+ ${this.renderDocumentTabs()}
138
+ <section class="viewer">${this.renderViewer()}</section>
139
+ `;
140
+ this.root.querySelector("button[data-refresh]")?.addEventListener("click", () => {
141
+ this.refresh();
142
+ });
143
+ this.root.querySelector("select[data-relay-picker]")?.addEventListener("change", (event) => {
144
+ const picker = event.target;
145
+ if (picker instanceof HTMLSelectElement)
146
+ void this.openRelay(context, picker.value);
147
+ });
148
+ for (const tab of this.root.querySelectorAll("button[data-document-path]")) {
149
+ tab.addEventListener("click", () => {
150
+ const documentPath = tab.getAttribute("data-document-path");
151
+ if (documentPath !== null)
152
+ void this.openDocument(context, documentPath);
153
+ });
154
+ }
155
+ }
156
+ renderRelayPicker() {
157
+ const listing = this.listing;
158
+ if (listing?.kind !== "loaded" || listing.relays.length === 0)
159
+ return "";
160
+ // A single relay opens immediately; a one-option picker would be noise.
161
+ if (listing.relays.length === 1) {
162
+ const relay = listing.relays[0];
163
+ return relay === undefined ? "" : `<span class="relay-name" title="${escapeAttr(relay.path)}">${escapeHtml(relay.name)}</span>`;
164
+ }
165
+ const options = listing.relays.map((relay) => {
166
+ const selected = relay.path === this.selectedRelayPath ? " selected" : "";
167
+ return `<option value="${escapeAttr(relay.path)}"${selected}>${escapeHtml(relay.name)}</option>`;
168
+ }).join("");
169
+ return `<select data-relay-picker aria-label="中继">${options}</select>`;
170
+ }
171
+ renderDocumentTabs() {
172
+ const documents = this.documents;
173
+ if (documents?.kind !== "loaded" || documents.documents.length === 0)
174
+ return "";
175
+ const tabs = documents.documents.map((document) => {
176
+ const active = document.path === this.selectedDocumentPath;
177
+ return `<button class="document-tab${active ? " active" : ""}" data-document-path="${escapeAttr(document.path)}"${active ? ' aria-current="true"' : ""}>${escapeHtml(document.name)}</button>`;
178
+ }).join("");
179
+ return `<nav class="document-tabs" aria-label="中继文档">${tabs}</nav>`;
180
+ }
181
+ renderViewer() {
182
+ const listing = this.listing;
183
+ if (listing === undefined)
184
+ return `<p class="muted">正在扫描 ${escapeHtml(RELAYS_ROOT)}…</p>`;
185
+ if (listing.kind === "unavailable")
186
+ return renderErrorState("无法扫描工作区中继。", listing.detail);
187
+ if (listing.kind === "missing" || listing.relays.length === 0)
188
+ return renderEmptyState();
189
+ return this.renderSelectedRelay();
190
+ }
191
+ renderSelectedRelay() {
192
+ const documents = this.documents;
193
+ if (documents === undefined)
194
+ return `<p class="muted">正在加载中继文档…</p>`;
195
+ if (documents.kind === "unavailable")
196
+ return renderErrorState("无法列出此中继的文档。", documents.detail);
197
+ if (documents.kind === "missing") {
198
+ return `<div class="empty-state"><strong>此中继已不存在。</strong><p>点击“刷新”重新扫描 ${escapeHtml(RELAYS_ROOT)}。</p></div>`;
199
+ }
200
+ if (documents.documents.length === 0) {
201
+ return `
202
+ <div class="empty-state">
203
+ <strong>此中继还没有文档。</strong>
204
+ <p>中继包通常包含 <code>status.md</code>、<code>charter.md</code> 和 <code>log.md</code>。</p>
205
+ </div>
206
+ `;
207
+ }
208
+ return this.renderSelectedDocument();
209
+ }
210
+ renderSelectedDocument() {
211
+ const documentPath = this.selectedDocumentPath;
212
+ const content = this.documentContent;
213
+ if (documentPath === undefined)
214
+ return `<p class="muted">请选择文档。</p>`;
215
+ if (content === undefined)
216
+ return `<p class="muted">正在加载 ${escapeHtml(documentName(documentPath))}…</p>`;
217
+ if (content.kind === "unavailable")
218
+ return renderErrorState("无法读取此文档。", content.detail);
219
+ if (content.kind === "missing") {
220
+ return `<div class="empty-state"><strong>此文档已不存在。</strong><p>点击“刷新”重新扫描中继。</p></div>`;
221
+ }
222
+ if (content.binary) {
223
+ return `<div class="empty-state"><strong>二进制文件:${escapeHtml(documentName(documentPath))}</strong><p>二进制文档无法提供文本预览。</p></div>`;
224
+ }
225
+ const truncation = content.truncated
226
+ ? `<div class="status info">此文档已截断,仅显示开头内容。</div>`
227
+ : "";
228
+ if (isMarkdownDocumentPath(documentPath)) {
229
+ return `${truncation}<div class="document markdown">${renderRelayDocumentHtml(content.content)}</div>`;
230
+ }
231
+ return `${truncation}<pre class="document">${escapeHtml(content.content)}</pre>`;
232
+ }
233
+ }
234
+ /** Reload glyph matching the app's own refresh control (AppRefreshControl). */
235
+ function refreshIconSvg() {
236
+ return `
237
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
238
+ <path d="M20 6v5h-5"></path>
239
+ <path d="M4 18v-5h5"></path>
240
+ <path d="M18.2 9A7 7 0 0 0 6.1 6.8L4 9"></path>
241
+ <path d="M5.8 15a7 7 0 0 0 12.1 2.2L20 15"></path>
242
+ </svg>
243
+ `;
244
+ }
245
+ function contextKey(context) {
246
+ return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`;
247
+ }
248
+ function documentName(path) {
249
+ return path.slice(path.lastIndexOf("/") + 1);
250
+ }
251
+ function renderEmptyState() {
252
+ return `
253
+ <div class="empty-state">
254
+ <strong>此工作区中没有中继。</strong>
255
+ <p>中继包存放在 <code>${escapeHtml(RELAYS_ROOT)}/&lt;name&gt;/</code>,此工作区目前还没有中继包。</p>
256
+ </div>
257
+ `;
258
+ }
259
+ function renderErrorState(message, detail) {
260
+ return `<div class="status error"><strong>${escapeHtml(message)}</strong><pre>${escapeHtml(detail)}</pre></div>`;
261
+ }
262
+ function relaysStyles() {
263
+ return `
264
+ <style>
265
+ :host { display: contents; }
266
+ /* Toolbar and tab strip are panel chrome: they must never flex-shrink
267
+ (the app container is a fixed-height flex column; with shrink enabled
268
+ the viewer's huge content basis starves them down to a sliver once a
269
+ tall document renders). The viewer absorbs all shrinking instead. */
270
+ .toolbar { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); }
271
+ .toolbar-actions { display: inline-flex; align-items: center; flex-wrap: nowrap; justify-content: flex-end; gap: 8px; min-width: 0; }
272
+ .relay-name { min-width: 0; color: var(--pi-text-secondary); overflow-wrap: anywhere; }
273
+ /* Bottom padding (not viewer margin) so the gap below the tabs persists
274
+ when the viewer's content scrolls up against its top edge. */
275
+ .document-tabs { flex: 0 0 auto; display: flex; flex-wrap: nowrap; gap: 6px; padding: 8px 12px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; }
276
+ .viewer { flex: 1 1 auto; box-sizing: border-box; display: grid; align-content: start; gap: 12px; min-height: 0; overflow: auto; padding: 12px; }
277
+ /* Grid children default to min-width: auto; without these caps a wide code
278
+ block or table would silently stretch the whole viewer track. */
279
+ .viewer > * { box-sizing: border-box; min-width: 0; max-width: 100%; }
280
+ button, select { border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); font: inherit; }
281
+ button { cursor: pointer; padding: 6px 10px; }
282
+ button.icon-button { flex: 0 0 auto; display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; }
283
+ button.icon-button svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
284
+ select { min-width: 0; max-width: 240px; padding: 5px 6px; }
285
+ .document-tab { flex: 0 0 auto; white-space: nowrap; font-size: 12px; padding: 4px 10px; }
286
+ .document-tab.active { border-color: var(--pi-accent-border); background: var(--pi-accent); color: var(--pi-bg); }
287
+ code, pre { border: 1px solid var(--pi-border-muted); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
288
+ code { padding: 2px 5px; }
289
+ pre { margin: 0; overflow: auto; padding: 8px; white-space: pre-wrap; overflow-wrap: anywhere; }
290
+ .document.markdown { line-height: 1.5; overflow-wrap: anywhere; }
291
+ .document.markdown p, .document.markdown ul, .document.markdown ol, .document.markdown pre, .document.markdown blockquote, .document.markdown .table-scroll { margin: 0 0 10px; }
292
+ .document.markdown > :last-child { margin-bottom: 0; }
293
+ .document.markdown h1, .document.markdown h2, .document.markdown h3, .document.markdown h4 { line-height: 1.25; margin: 14px 0 8px; }
294
+ .document.markdown h1:first-child, .document.markdown h2:first-child, .document.markdown h3:first-child, .document.markdown h4:first-child { margin-top: 0; }
295
+ .document.markdown h1 { font-size: 18px; }
296
+ .document.markdown h2 { font-size: 16px; }
297
+ .document.markdown h3 { font-size: 14px; }
298
+ .document.markdown h4 { font-size: 13px; }
299
+ .document.markdown ul, .document.markdown ol { padding-left: 22px; }
300
+ .document.markdown li + li { margin-top: 3px; }
301
+ .document.markdown pre { white-space: pre; overflow-wrap: normal; }
302
+ .document.markdown pre code { border: 0; background: transparent; padding: 0; }
303
+ .document.markdown img { box-sizing: border-box; max-width: 100%; }
304
+ .document.markdown blockquote { border-left: 3px solid var(--pi-border-muted); color: var(--pi-muted); padding-left: 10px; }
305
+ .document.markdown a { color: var(--pi-accent); }
306
+ .document.markdown .table-scroll { max-width: 100%; overflow-x: auto; }
307
+ /* Cells wrap at word boundaries only: a wide table keeps its natural width
308
+ and scrolls inside .table-scroll instead of being squeezed unreadably. */
309
+ .document.markdown table { border-collapse: collapse; overflow-wrap: normal; }
310
+ .document.markdown th, .document.markdown td { border: 1px solid var(--pi-border-muted); padding: 4px 8px; }
311
+ .status pre { margin-top: 8px; }
312
+ .muted { color: var(--pi-muted); }
313
+ .empty-state { border: 1px dashed var(--pi-border-muted); border-radius: 8px; color: var(--pi-muted); padding: 12px; }
314
+ .empty-state p { margin: 6px 0 0; }
315
+ .status { border: 1px solid var(--pi-border); border-radius: 8px; padding: 10px; }
316
+ .status.info { border-color: var(--pi-accent-border); background: var(--pi-bg-overlay-soft); }
317
+ .status.error { border-color: var(--pi-danger); color: var(--pi-danger); }
318
+ .empty { padding: 16px; color: var(--pi-muted); }
319
+ </style>
320
+ `;
321
+ }
322
+ function escapeHtml(value) {
323
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
324
+ }
325
+ function escapeAttr(value) {
326
+ return escapeHtml(value).replaceAll('"', "&quot;");
327
+ }
@@ -0,0 +1,24 @@
1
+ # Vendored dependencies
2
+
3
+ ## marked (`marked.esm.js`)
4
+
5
+ - **Source:** `node_modules/marked/lib/marked.esm.js` — marked v18.0.6, the
6
+ version the repo pins (`"marked": "^18.0.6"` in the root `package.json`).
7
+ - **License:** MIT — copyright (c) 2018-2026 MarkedJS, (c) 2011-2018
8
+ Christopher Jeffrey. The attribution header at the top of `marked.esm.js` is
9
+ preserved; see also `node_modules/marked/LICENSE.md`.
10
+ - **Why vendored:** bundled plugins load in the browser as standalone ES
11
+ modules and cannot resolve bare package specifiers. The plugin therefore
12
+ ships the exact marked build the repo already depends on, rather than
13
+ hand-rolling a markdown subset or adding a markdown helper to the plugin API.
14
+ - **Local modification:** the trailing `//# sourceMappingURL=marked.esm.js.map`
15
+ comment was removed because the (much larger) source map is not vendored.
16
+ Everything else is byte-identical to the published file.
17
+ - **`marked.esm.d.ts`** is a hand-written minimal declaration covering only the
18
+ surface `markdownDocument.ts` uses. The plugin build
19
+ (`scripts/build-plugins.mjs`) skips `.d.ts` files and copies `.js` assets
20
+ verbatim, so the vendored module ships as-is.
21
+
22
+ **Updating:** after a `marked` upgrade in the root `package.json`, copy the new
23
+ `node_modules/marked/lib/marked.esm.js` here, re-apply the sourceMappingURL
24
+ removal, and update this note.