dialog-ide 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +201 -0
  3. package/README.md +211 -0
  4. package/THIRD_PARTY_LICENSES.md +157 -0
  5. package/bin/darwin-arm64/aambundle +0 -0
  6. package/bin/darwin-arm64/dgdebug +0 -0
  7. package/bin/darwin-arm64/dialogc +0 -0
  8. package/bin/dialog-lib/stddebug.dg +112 -0
  9. package/bin/dialog-lib/stdlib.dg +6730 -0
  10. package/bin/dialog-lib/unit.dg +187 -0
  11. package/dist/cli/commands/run-skein.d.ts +17 -0
  12. package/dist/cli/commands/run-skein.d.ts.map +1 -0
  13. package/dist/cli/commands/run-skein.js +105 -0
  14. package/dist/cli/commands/run-skein.js.map +1 -0
  15. package/dist/cli/commands/sources.d.ts +12 -0
  16. package/dist/cli/commands/sources.d.ts.map +1 -0
  17. package/dist/cli/commands/sources.js +42 -0
  18. package/dist/cli/commands/sources.js.map +1 -0
  19. package/dist/cli/commands/test.d.ts +9 -0
  20. package/dist/cli/commands/test.d.ts.map +1 -0
  21. package/dist/cli/commands/test.js +41 -0
  22. package/dist/cli/commands/test.js.map +1 -0
  23. package/dist/cli/context.d.ts +9 -0
  24. package/dist/cli/context.d.ts.map +1 -0
  25. package/dist/cli/context.js +78 -0
  26. package/dist/cli/context.js.map +1 -0
  27. package/dist/cli.d.ts +3 -0
  28. package/dist/cli.d.ts.map +1 -0
  29. package/dist/cli.js +32 -0
  30. package/dist/cli.js.map +1 -0
  31. package/dist/dialog-cover-resize.d.ts +2 -0
  32. package/dist/dialog-cover-resize.d.ts.map +1 -0
  33. package/dist/dialog-cover-resize.js +46 -0
  34. package/dist/dialog-cover-resize.js.map +1 -0
  35. package/dist/dialog-export.d.ts +23 -0
  36. package/dist/dialog-export.d.ts.map +1 -0
  37. package/dist/dialog-export.js +133 -0
  38. package/dist/dialog-export.js.map +1 -0
  39. package/dist/dialog-feelies.d.ts +4 -0
  40. package/dist/dialog-feelies.d.ts.map +1 -0
  41. package/dist/dialog-feelies.js +62 -0
  42. package/dist/dialog-feelies.js.map +1 -0
  43. package/dist/dialog-outline.d.ts +19 -0
  44. package/dist/dialog-outline.d.ts.map +1 -0
  45. package/dist/dialog-outline.js +122 -0
  46. package/dist/dialog-outline.js.map +1 -0
  47. package/dist/dialog-project-init.d.ts +5 -0
  48. package/dist/dialog-project-init.d.ts.map +1 -0
  49. package/dist/dialog-project-init.js +107 -0
  50. package/dist/dialog-project-init.js.map +1 -0
  51. package/dist/dialog-source-coverage.d.ts +9 -0
  52. package/dist/dialog-source-coverage.d.ts.map +1 -0
  53. package/dist/dialog-source-coverage.js +61 -0
  54. package/dist/dialog-source-coverage.js.map +1 -0
  55. package/dist/dialog-source-decoration-provider.d.ts +11 -0
  56. package/dist/dialog-source-decoration-provider.d.ts.map +1 -0
  57. package/dist/dialog-source-decoration-provider.js +79 -0
  58. package/dist/dialog-source-decoration-provider.js.map +1 -0
  59. package/dist/dialog-symbol-index.d.ts +19 -0
  60. package/dist/dialog-symbol-index.d.ts.map +1 -0
  61. package/dist/dialog-symbol-index.js +107 -0
  62. package/dist/dialog-symbol-index.js.map +1 -0
  63. package/dist/dialog-symbol-provider.d.ts +5 -0
  64. package/dist/dialog-symbol-provider.d.ts.map +1 -0
  65. package/dist/dialog-symbol-provider.js +56 -0
  66. package/dist/dialog-symbol-provider.js.map +1 -0
  67. package/dist/dialog-web-export.d.ts +25 -0
  68. package/dist/dialog-web-export.d.ts.map +1 -0
  69. package/dist/dialog-web-export.js +283 -0
  70. package/dist/dialog-web-export.js.map +1 -0
  71. package/dist/dialog-workspace-symbol-provider.d.ts +16 -0
  72. package/dist/dialog-workspace-symbol-provider.d.ts.map +1 -0
  73. package/dist/dialog-workspace-symbol-provider.js +107 -0
  74. package/dist/dialog-workspace-symbol-provider.js.map +1 -0
  75. package/dist/dialog-workspace-watcher.d.ts +22 -0
  76. package/dist/dialog-workspace-watcher.d.ts.map +1 -0
  77. package/dist/dialog-workspace-watcher.js +102 -0
  78. package/dist/dialog-workspace-watcher.js.map +1 -0
  79. package/dist/dialoged/skein/compile-error.d.ts +10 -0
  80. package/dist/dialoged/skein/compile-error.d.ts.map +1 -0
  81. package/dist/dialoged/skein/compile-error.js +28 -0
  82. package/dist/dialoged/skein/compile-error.js.map +1 -0
  83. package/dist/dialoged/skein/dynamic.d.ts +14 -0
  84. package/dist/dialoged/skein/dynamic.d.ts.map +1 -0
  85. package/dist/dialoged/skein/dynamic.js +176 -0
  86. package/dist/dialoged/skein/dynamic.js.map +1 -0
  87. package/dist/dialoged/skein/frotz-build.d.ts +10 -0
  88. package/dist/dialoged/skein/frotz-build.d.ts.map +1 -0
  89. package/dist/dialoged/skein/frotz-build.js +70 -0
  90. package/dist/dialoged/skein/frotz-build.js.map +1 -0
  91. package/dist/dialoged/skein/index.d.ts +15 -0
  92. package/dist/dialoged/skein/index.d.ts.map +1 -0
  93. package/dist/dialoged/skein/index.js +31 -0
  94. package/dist/dialoged/skein/index.js.map +1 -0
  95. package/dist/dialoged/skein/io.d.ts +15 -0
  96. package/dist/dialoged/skein/io.d.ts.map +1 -0
  97. package/dist/dialoged/skein/io.js +53 -0
  98. package/dist/dialoged/skein/io.js.map +1 -0
  99. package/dist/dialoged/skein/persistence.d.ts +13 -0
  100. package/dist/dialoged/skein/persistence.d.ts.map +1 -0
  101. package/dist/dialoged/skein/persistence.js +228 -0
  102. package/dist/dialoged/skein/persistence.js.map +1 -0
  103. package/dist/dialoged/skein/process.d.ts +38 -0
  104. package/dist/dialoged/skein/process.d.ts.map +1 -0
  105. package/dist/dialoged/skein/process.js +189 -0
  106. package/dist/dialoged/skein/process.js.map +1 -0
  107. package/dist/dialoged/skein/progress.d.ts +17 -0
  108. package/dist/dialoged/skein/progress.d.ts.map +1 -0
  109. package/dist/dialoged/skein/progress.js +9 -0
  110. package/dist/dialoged/skein/progress.js.map +1 -0
  111. package/dist/dialoged/skein/project.d.ts +44 -0
  112. package/dist/dialoged/skein/project.d.ts.map +1 -0
  113. package/dist/dialoged/skein/project.js +223 -0
  114. package/dist/dialoged/skein/project.js.map +1 -0
  115. package/dist/dialoged/skein/search.d.ts +15 -0
  116. package/dist/dialoged/skein/search.d.ts.map +1 -0
  117. package/dist/dialoged/skein/search.js +99 -0
  118. package/dist/dialoged/skein/search.js.map +1 -0
  119. package/dist/dialoged/skein/service.d.ts +91 -0
  120. package/dist/dialoged/skein/service.d.ts.map +1 -0
  121. package/dist/dialoged/skein/service.js +1097 -0
  122. package/dist/dialoged/skein/service.js.map +1 -0
  123. package/dist/dialoged/skein/session.d.ts +96 -0
  124. package/dist/dialoged/skein/session.d.ts.map +1 -0
  125. package/dist/dialoged/skein/session.js +690 -0
  126. package/dist/dialoged/skein/session.js.map +1 -0
  127. package/dist/dialoged/skein/syntax.d.ts +5 -0
  128. package/dist/dialoged/skein/syntax.d.ts.map +1 -0
  129. package/dist/dialoged/skein/syntax.js +150 -0
  130. package/dist/dialoged/skein/syntax.js.map +1 -0
  131. package/dist/dialoged/skein/trace.d.ts +31 -0
  132. package/dist/dialoged/skein/trace.d.ts.map +1 -0
  133. package/dist/dialoged/skein/trace.js +172 -0
  134. package/dist/dialoged/skein/trace.js.map +1 -0
  135. package/dist/dialoged/skein/tree.d.ts +114 -0
  136. package/dist/dialoged/skein/tree.d.ts.map +1 -0
  137. package/dist/dialoged/skein/tree.js +701 -0
  138. package/dist/dialoged/skein/tree.js.map +1 -0
  139. package/dist/dialoged/skein/ui/ansi.d.ts +4 -0
  140. package/dist/dialoged/skein/ui/ansi.d.ts.map +1 -0
  141. package/dist/dialoged/skein/ui/ansi.js +215 -0
  142. package/dist/dialoged/skein/ui/ansi.js.map +1 -0
  143. package/dist/dialoged/skein/ui/diff.d.ts +7 -0
  144. package/dist/dialoged/skein/ui/diff.d.ts.map +1 -0
  145. package/dist/dialoged/skein/ui/diff.js +17 -0
  146. package/dist/dialoged/skein/ui/diff.js.map +1 -0
  147. package/dist/dialoged/skein/ui/knot-menu.d.ts +5 -0
  148. package/dist/dialoged/skein/ui/knot-menu.d.ts.map +1 -0
  149. package/dist/dialoged/skein/ui/knot-menu.js +89 -0
  150. package/dist/dialoged/skein/ui/knot-menu.js.map +1 -0
  151. package/dist/dialoged/skein/ui/marker-colors.d.ts +5 -0
  152. package/dist/dialoged/skein/ui/marker-colors.d.ts.map +1 -0
  153. package/dist/dialoged/skein/ui/marker-colors.js +18 -0
  154. package/dist/dialoged/skein/ui/marker-colors.js.map +1 -0
  155. package/dist/dialoged/skein/ui/render.d.ts +13 -0
  156. package/dist/dialoged/skein/ui/render.d.ts.map +1 -0
  157. package/dist/dialoged/skein/ui/render.js +332 -0
  158. package/dist/dialoged/skein/ui/render.js.map +1 -0
  159. package/dist/dialoged/skein/ui/traceRender.d.ts +11 -0
  160. package/dist/dialoged/skein/ui/traceRender.d.ts.map +1 -0
  161. package/dist/dialoged/skein/ui/traceRender.js +139 -0
  162. package/dist/dialoged/skein/ui/traceRender.js.map +1 -0
  163. package/dist/dialoged/skein/ui/tree-pane.d.ts +3 -0
  164. package/dist/dialoged/skein/ui/tree-pane.d.ts.map +1 -0
  165. package/dist/dialoged/skein/ui/tree-pane.js +135 -0
  166. package/dist/dialoged/skein/ui/tree-pane.js.map +1 -0
  167. package/dist/dialoged/skein/ui/webExportPage.d.ts +29 -0
  168. package/dist/dialoged/skein/ui/webExportPage.d.ts.map +1 -0
  169. package/dist/dialoged/skein/ui/webExportPage.js +72 -0
  170. package/dist/dialoged/skein/ui/webExportPage.js.map +1 -0
  171. package/dist/extension.d.ts +4 -0
  172. package/dist/extension.d.ts.map +1 -0
  173. package/dist/extension.js +885 -0
  174. package/dist/extension.js.map +1 -0
  175. package/dist/session-runner.d.ts +23 -0
  176. package/dist/session-runner.d.ts.map +1 -0
  177. package/dist/session-runner.js +92 -0
  178. package/dist/session-runner.js.map +1 -0
  179. package/language-configuration.json +43 -0
  180. package/media/icon.png +0 -0
  181. package/media/icons/arrow-down-circle-solid.svg +3 -0
  182. package/media/icons/arrow-down-solid.svg +3 -0
  183. package/media/icons/arrow-path-solid.svg +3 -0
  184. package/media/icons/arrow-right-solid.svg +3 -0
  185. package/media/icons/arrow-up-on-square-stack-solid.svg +5 -0
  186. package/media/icons/arrow-up-solid.svg +3 -0
  187. package/media/icons/arrow-uturn-left.svg +3 -0
  188. package/media/icons/arrow-uturn-right.svg +3 -0
  189. package/media/icons/bookmark-solid.svg +3 -0
  190. package/media/icons/bug-ant-solid.svg +3 -0
  191. package/media/icons/check-badge-solid.svg +3 -0
  192. package/media/icons/check-circle-solid.svg +3 -0
  193. package/media/icons/chevron-double-down-solid.svg +4 -0
  194. package/media/icons/chevron-double-up-solid.svg +4 -0
  195. package/media/icons/command-line-solid.svg +3 -0
  196. package/media/icons/cpu-chip-solid.svg +4 -0
  197. package/media/icons/dg-file-dark.svg +6 -0
  198. package/media/icons/dg-file-light.svg +6 -0
  199. package/media/icons/ellipsis-vertical-solid.svg +3 -0
  200. package/media/icons/exclamation-triangle.svg +3 -0
  201. package/media/icons/folder-arrow-down-solid.svg +3 -0
  202. package/media/icons/globe-alt.svg +3 -0
  203. package/media/icons/lock-closed-solid.svg +3 -0
  204. package/media/icons/magnifying-glass-solid.svg +3 -0
  205. package/media/icons/pencil-solid.svg +4 -0
  206. package/media/icons/play-solid.svg +3 -0
  207. package/media/icons/plus-circle-solid.svg +3 -0
  208. package/media/icons/scissors-solid.svg +5 -0
  209. package/media/icons/tag-solid.svg +3 -0
  210. package/media/icons/trash-solid.svg +3 -0
  211. package/media/icons/x-circle-solid.svg +3 -0
  212. package/media/js/datastar.js +9 -0
  213. package/media/js/main.js +1109 -0
  214. package/media/js/skein-loader.js +10 -0
  215. package/media/js/trace-loader.js +4 -0
  216. package/media/js/trace.js +147 -0
  217. package/media/style.css +5783 -0
  218. package/package.json +231 -0
  219. package/resources/bundle/default-cover.png +0 -0
  220. package/resources/bundle/introduction-to-if.pdf +0 -0
  221. package/resources/bundle/play-if-card.pdf +1990 -1
  222. package/resources/bundle/play.css +432 -0
  223. package/resources/bundle/style.css +450 -0
  224. package/resources/dfrotz-skein-patch.dg +4 -0
  225. package/syntaxes/dialog.tmLanguage.json +490 -0
@@ -0,0 +1,1109 @@
1
+ // Client-side JS the server-rendered skein page relies on. Actual event dispatch to the server
2
+ // (click -> POST) is Datastar's job via data-on:*="@post(...)" attributes baked directly into
3
+ // the server-rendered markup (see render.ts/tree-pane.ts) - this file only holds what Datastar
4
+ // has no opinion about: focus management, SVG drawing, drag math, and popover positioning.
5
+ window.sk = {
6
+ // scrollToKnotId (service.ts's select-knot/new-child broadcasts) scrolls that knot's own
7
+ // transcript row into view instead of the command input - the transcript always shows the
8
+ // whole spine root-to-leaf regardless of which knot on it was clicked (tree.ts's selectKnot
9
+ // never truncates it), so scrolling to the input alone only happened to land on the right knot
10
+ // when it was already the leaf; clicking any ancestor scrolled straight past it to the bottom.
11
+ // Omitted (handleSendCommand/handleSseConnect's own bare calls) falls back to the input itself -
12
+ // correct there since a just-run command's new response is always the last thing shown, right
13
+ // above it.
14
+ //
15
+ // Only focuses the input when the target is the spine's actual leaf (or unspecified) - there's
16
+ // nothing to type into anyway unless you're actually looking at the spine's end.
17
+ resetAndFocusCommandInput(scrollToKnotId) {
18
+ // Deferred, not run inline: this executes from a dynamically-appended <script> patch
19
+ // (service.ts's sendScript), which can run before Datastar has fully finished settling the
20
+ // *separate*, immediately-preceding content patch in the same SSE stream - the two are
21
+ // independent datastar-patch-elements events, not one atomic update. A single
22
+ // requestAnimationFrame (the fix used elsewhere in this file, e.g. initTreeGraph's own
23
+ // MutationObserver-triggered drawTreeArrows) was NOT enough here and left scrollIntoView a
24
+ // silent no-op - confirmed by direct comparison against a real running instance of this page,
25
+ // embedded the same way extension.ts's outer webview embeds it (an iframe next to a
26
+ // position:sticky sibling, the tree pane): identical code, only the delay changed, and only a
27
+ // real ~150ms setTimeout reliably let whatever else still needed to settle finish first. A
28
+ // plain rAF, and even rAF-then-rAF, both still landed too early in testing.
29
+ setTimeout(() => {
30
+ const el = document.getElementById('new-command-input');
31
+ const isLeaf = scrollToKnotId == null || scrollToKnotId === spineLeafId();
32
+ if (el) {
33
+ el.value = '';
34
+ el.dispatchEvent(new Event('input', { bubbles: true }));
35
+ }
36
+ const knotEl = scrollToKnotId != null && document.getElementById(`knot-${scrollToKnotId}`);
37
+ if (knotEl) {
38
+ // block: 'center', not 'nearest' - also verified empirically: 'nearest' and 'end' silently
39
+ // no-op for a transcript row in this layout, while 'start' and 'center' reliably scroll.
40
+ // 'center' keeps some surrounding context visible instead of jamming the knot against the
41
+ // very top edge.
42
+ knotEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
43
+ } else {
44
+ // No specific knot (or it wasn't found) - fall back to the input itself, where 'nearest'
45
+ // has always worked reliably (it's the last element in the column, so "nearest" and
46
+ // "aligned to the bottom" naturally coincide).
47
+ el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
48
+ }
49
+ if (isLeaf) {
50
+ el?.focus({ preventScroll: true });
51
+ }
52
+ }, 150);
53
+ },
54
+
55
+ // An acknowledging flash (service.ts's broadcastFlash) - ported near-verbatim from dialog-tool's
56
+ // own sk.showFlash: top-center, one at a time (a new flash dismisses whatever's currently
57
+ // showing rather than stacking), fades in then auto-dismisses. Built client-side rather than
58
+ // server-rendered into renderApp, so the ordinary #skein-app morph a tree change triggers can
59
+ // never clip one mid-display. type is 'info' (default; blue, no dismiss button, auto-fades) or
60
+ // 'error' (red, persists until the user dismisses it via the X button or Escape) - service.ts's
61
+ // broadcastFlash sends 'error' for a rejected action with no other UI to surface through (e.g.
62
+ // deleting a knot with a locked descendant), since postAction's fire-and-forget callers never
63
+ // look at the response.
64
+ _dismissFlash() {
65
+ if (this._flashTimer) {
66
+ clearTimeout(this._flashTimer);
67
+ this._flashTimer = null;
68
+ }
69
+ if (this._flashEl) {
70
+ this._flashEl.remove();
71
+ this._flashEl = null;
72
+ }
73
+ },
74
+
75
+ showFlash(message, type = 'info') {
76
+ const isError = type === 'error';
77
+ this._dismissFlash();
78
+
79
+ const wrapper = document.createElement('div');
80
+ wrapper.className = 'fixed top-20 left-1/2 -translate-x-1/2 z-50';
81
+ wrapper.style.pointerEvents = isError ? 'auto' : 'none';
82
+
83
+ const inner = document.createElement('div');
84
+ inner.className = isError
85
+ ? 'flex items-center gap-3 bg-red-600 text-white px-6 py-3 rounded-lg shadow-lg'
86
+ : 'bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg transition-opacity duration-500';
87
+ if (!isError) inner.style.opacity = '0';
88
+
89
+ const msg = document.createElement('span');
90
+ msg.textContent = message;
91
+ inner.appendChild(msg);
92
+
93
+ if (isError) {
94
+ inner.setAttribute('tabindex', '-1');
95
+ inner.addEventListener('keydown', (e) => {
96
+ if (e.key === 'Escape') this._dismissFlash();
97
+ });
98
+ const btn = document.createElement('button');
99
+ btn.type = 'button';
100
+ btn.className = 'ml-2 opacity-80 hover:opacity-100 text-lg font-bold cursor-pointer';
101
+ btn.textContent = '✕';
102
+ btn.addEventListener('click', () => this._dismissFlash());
103
+ inner.appendChild(btn);
104
+ }
105
+
106
+ wrapper.appendChild(inner);
107
+ document.body.appendChild(wrapper);
108
+ this._flashEl = wrapper;
109
+
110
+ if (isError) {
111
+ inner.focus();
112
+ } else {
113
+ requestAnimationFrame(() => {
114
+ inner.style.opacity = '1';
115
+ this._flashTimer = setTimeout(() => {
116
+ inner.style.opacity = '0';
117
+ this._flashTimer = setTimeout(() => this._dismissFlash(), 600);
118
+ }, 2000);
119
+ });
120
+ }
121
+ },
122
+
123
+ // A centered progress modal for Replay All (service.ts's ProgressHost implementation) - ported
124
+ // visually from dialog-tool's modals/progress + components/modal.clj (backdrop, centered white
125
+ // panel, header/body layout, <progress> bar), but with a Cancel button that actually works:
126
+ // dialog-tool's own sets a :continue flag that's never read anywhere in its codebase, so
127
+ // clicking it does nothing there. Here it POSTs to /actions/cancel-replay, which flips the
128
+ // CancellationToken session.ts's replayTo checks between each replayed command (see service.ts's
129
+ // withProgress/cancelCurrentReplay).
130
+ showProgress(title, cancellable) {
131
+ this.hideProgress();
132
+
133
+ const overlay = document.createElement('div');
134
+ overlay.id = 'progress-modal';
135
+ overlay.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-grayscale';
136
+
137
+ const panel = document.createElement('div');
138
+ panel.className = 'bg-base-100 rounded-lg shadow-xl max-w-full min-w-md mx-4';
139
+ panel.setAttribute('role', 'dialog');
140
+ panel.setAttribute('aria-modal', 'true');
141
+
142
+ const header = document.createElement('div');
143
+ header.className = 'px-6 py-4 border-b border-base-200';
144
+ const heading = document.createElement('h3');
145
+ heading.className = 'text-lg font-medium text-base-content';
146
+ heading.textContent = title;
147
+ header.appendChild(heading);
148
+
149
+ const body = document.createElement('div');
150
+ body.className = 'px-6 py-4';
151
+
152
+ const statusRow = document.createElement('div');
153
+ statusRow.className = 'flex justify-between mb-2';
154
+ const percentEl = document.createElement('span');
155
+ percentEl.id = 'progress-modal-percent';
156
+ percentEl.className = 'text-sm font-medium text-base-content';
157
+ percentEl.textContent = '0%';
158
+ const labelEl = document.createElement('span');
159
+ labelEl.id = 'progress-modal-label';
160
+ labelEl.className = 'text-sm text-base-content opacity-70';
161
+ statusRow.append(percentEl, labelEl);
162
+
163
+ const bar = document.createElement('progress');
164
+ bar.id = 'progress-modal-bar';
165
+ bar.className = 'progress progress-primary w-full';
166
+ bar.value = 0;
167
+ bar.max = 100;
168
+ bar.setAttribute('aria-label', title);
169
+
170
+ body.append(statusRow, bar);
171
+
172
+ if (cancellable) {
173
+ const buttonRow = document.createElement('div');
174
+ buttonRow.className = 'flex justify-end gap-2 mt-4';
175
+ const cancelBtn = document.createElement('button');
176
+ cancelBtn.type = 'button';
177
+ cancelBtn.className = 'btn btn-neutral';
178
+ cancelBtn.textContent = 'Cancel';
179
+ cancelBtn.addEventListener('click', () => {
180
+ fetch('/actions/cancel-replay', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
181
+ });
182
+ buttonRow.appendChild(cancelBtn);
183
+ body.appendChild(buttonRow);
184
+ }
185
+
186
+ panel.append(header, body);
187
+ overlay.appendChild(panel);
188
+ document.body.appendChild(overlay);
189
+ },
190
+
191
+ updateProgress(percent, message) {
192
+ const bar = document.getElementById('progress-modal-bar');
193
+ if (bar) bar.value = percent;
194
+ const percentEl = document.getElementById('progress-modal-percent');
195
+ if (percentEl) percentEl.textContent = Math.round(percent) + '%';
196
+ const labelEl = document.getElementById('progress-modal-label');
197
+ if (labelEl) labelEl.textContent = message || '';
198
+ },
199
+
200
+ hideProgress() {
201
+ const el = document.getElementById('progress-modal');
202
+ if (el) el.remove();
203
+ },
204
+
205
+ // A real modal (OK/Cancel, Escape-to-cancel) for editing a knot's label - replaces an earlier
206
+ // window.prompt()-based attempt, which VS Code's webview sandbox (no allow-modals) silently
207
+ // swallows entirely. Built the same way as showProgress above: plain DOM construction, not
208
+ // server-rendered Datastar markup, since there's no session-level "which modal is open" state
209
+ // worth tracking server-side for something this transient. Submitting POSTs to /actions/set-label
210
+ // directly; a 409 (tree.ts's LabelConflictError - labels are tree-unique) shows its message
211
+ // inline via the alert-error block and leaves the modal open with the input focused for another
212
+ // try, rather than closing on failure. Success (204) relies on the session's own 'change'
213
+ // broadcast to re-render the knot's label chip - this modal only ever closes itself, never
214
+ // touches #skein-app.
215
+ showLabelModal(knotId, currentLabel) {
216
+ this.hideLabelModal();
217
+ // The trigger is a menu item inside an open dropdown - that dropdown's content is a native
218
+ // Popover (see knot-menu.ts's own doc comment on why), which renders in the browser's top
219
+ // layer, *above* this modal's plain fixed/z-50 overlay regardless of z-index. Left open, it
220
+ // would keep visually covering the modal until some unrelated click closed it. closeAllDropdowns
221
+ // (below) is the same close-and-sync-with-server logic the outside-click listener already uses.
222
+ closeAllDropdowns();
223
+
224
+ const overlay = document.createElement('div');
225
+ overlay.id = 'label-modal';
226
+ overlay.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-grayscale';
227
+
228
+ const panel = document.createElement('div');
229
+ panel.className = 'bg-base-100 rounded-lg shadow-xl max-w-full min-w-md mx-4';
230
+ panel.setAttribute('role', 'dialog');
231
+ panel.setAttribute('aria-modal', 'true');
232
+ panel.setAttribute('tabindex', '-1');
233
+
234
+ const header = document.createElement('div');
235
+ header.className = 'px-6 py-4 border-b border-base-200';
236
+ const heading = document.createElement('h3');
237
+ heading.className = 'text-lg font-medium text-base-content';
238
+ heading.textContent = 'Edit Label';
239
+ header.appendChild(heading);
240
+
241
+ const body = document.createElement('div');
242
+ body.className = 'px-6 py-4';
243
+
244
+ const errorEl = document.createElement('div');
245
+ errorEl.className = 'alert alert-error text-sm mb-3 hidden';
246
+ errorEl.setAttribute('role', 'alert');
247
+ errorEl.setAttribute('aria-live', 'assertive');
248
+ body.appendChild(errorEl);
249
+
250
+ const input = document.createElement('input');
251
+ input.type = 'text';
252
+ input.className = 'input input-bordered w-full';
253
+ input.value = currentLabel || '';
254
+ input.placeholder = 'Label (blank to clear)';
255
+ input.setAttribute('aria-label', 'Label');
256
+ body.appendChild(input);
257
+
258
+ const buttonRow = document.createElement('div');
259
+ buttonRow.className = 'flex justify-end gap-2 mt-4';
260
+
261
+ const cancelBtn = document.createElement('button');
262
+ cancelBtn.type = 'button';
263
+ cancelBtn.className = 'btn btn-neutral';
264
+ cancelBtn.textContent = 'Cancel';
265
+ cancelBtn.addEventListener('click', () => this.hideLabelModal());
266
+
267
+ const okBtn = document.createElement('button');
268
+ okBtn.type = 'button';
269
+ okBtn.className = 'btn btn-primary';
270
+ okBtn.textContent = 'OK';
271
+ const submit = async () => {
272
+ errorEl.classList.add('hidden');
273
+ let res;
274
+ try {
275
+ res = await fetch('/actions/set-label', {
276
+ method: 'POST',
277
+ headers: { 'Content-Type': 'application/json' },
278
+ body: JSON.stringify({ knotId, label: input.value })
279
+ });
280
+ } catch {
281
+ errorEl.textContent = 'Failed to reach the server.';
282
+ errorEl.classList.remove('hidden');
283
+ return;
284
+ }
285
+ if (res.status === 204) {
286
+ this.hideLabelModal();
287
+ return;
288
+ }
289
+ if (res.status === 409) {
290
+ const body = await res.json().catch(() => ({}));
291
+ errorEl.textContent = body.error || 'That label is already in use.';
292
+ } else {
293
+ errorEl.textContent = 'Failed to save label.';
294
+ }
295
+ errorEl.classList.remove('hidden');
296
+ input.focus();
297
+ input.select();
298
+ };
299
+ okBtn.addEventListener('click', submit);
300
+
301
+ buttonRow.append(cancelBtn, okBtn);
302
+ body.appendChild(buttonRow);
303
+
304
+ panel.append(header, body);
305
+ overlay.appendChild(panel);
306
+ document.body.appendChild(overlay);
307
+
308
+ panel.addEventListener('keydown', (e) => {
309
+ if (e.key === 'Escape') {
310
+ e.preventDefault();
311
+ this.hideLabelModal();
312
+ } else if (e.key === 'Enter') {
313
+ e.preventDefault();
314
+ submit();
315
+ } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') {
316
+ // Native Cmd/Ctrl+A select-all doesn't reach the input reliably inside a VS Code webview
317
+ // (the same class of platform quirk documented elsewhere in this file - see the keydown
318
+ // accelerator listener below on why Option+letter needs evt.code instead of evt.key), so
319
+ // this does it explicitly rather than relying on the browser default.
320
+ e.preventDefault();
321
+ input.select();
322
+ }
323
+ });
324
+
325
+ input.focus();
326
+ input.select();
327
+ },
328
+
329
+ hideLabelModal() {
330
+ document.getElementById('label-modal')?.remove();
331
+ },
332
+
333
+ // Edit Command's modal - near-identical to showLabelModal above, except a blank command is
334
+ // rejected client-side (never even POSTed) rather than treated as "clear" the way a blank label
335
+ // is, since a knot has to have *some* command text. A command matching a sibling's is no longer
336
+ // a rejection (service.ts's setCommand merges the two knots together instead - see tree.ts's
337
+ // mergeSiblingInto). Success (204) relies on the session's own 'change' broadcast to refresh
338
+ // the knot's response (service.ts's setCommand replays it under the corrected text) - this
339
+ // modal never touches #skein-app directly, same as the label modal.
340
+ showCommandModal(knotId, currentCommand) {
341
+ this.hideCommandModal();
342
+ closeAllDropdowns();
343
+
344
+ const overlay = document.createElement('div');
345
+ overlay.id = 'command-modal';
346
+ overlay.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-grayscale';
347
+
348
+ const panel = document.createElement('div');
349
+ panel.className = 'bg-base-100 rounded-lg shadow-xl max-w-full min-w-md mx-4';
350
+ panel.setAttribute('role', 'dialog');
351
+ panel.setAttribute('aria-modal', 'true');
352
+ panel.setAttribute('tabindex', '-1');
353
+
354
+ const header = document.createElement('div');
355
+ header.className = 'px-6 py-4 border-b border-base-200';
356
+ const heading = document.createElement('h3');
357
+ heading.className = 'text-lg font-medium text-base-content';
358
+ heading.textContent = 'Edit Command';
359
+ header.appendChild(heading);
360
+
361
+ const body = document.createElement('div');
362
+ body.className = 'px-6 py-4';
363
+
364
+ const errorEl = document.createElement('div');
365
+ errorEl.className = 'alert alert-error text-sm mb-3 hidden';
366
+ errorEl.setAttribute('role', 'alert');
367
+ errorEl.setAttribute('aria-live', 'assertive');
368
+ body.appendChild(errorEl);
369
+
370
+ const input = document.createElement('input');
371
+ input.type = 'text';
372
+ input.className = 'input input-bordered w-full font-mono';
373
+ input.value = currentCommand || '';
374
+ input.placeholder = 'Command';
375
+ input.setAttribute('aria-label', 'Command');
376
+ body.appendChild(input);
377
+
378
+ const buttonRow = document.createElement('div');
379
+ buttonRow.className = 'flex justify-end gap-2 mt-4';
380
+
381
+ const cancelBtn = document.createElement('button');
382
+ cancelBtn.type = 'button';
383
+ cancelBtn.className = 'btn btn-neutral';
384
+ cancelBtn.textContent = 'Cancel';
385
+ cancelBtn.addEventListener('click', () => this.hideCommandModal());
386
+
387
+ const okBtn = document.createElement('button');
388
+ okBtn.type = 'button';
389
+ okBtn.className = 'btn btn-primary';
390
+ okBtn.textContent = 'OK';
391
+ const submit = async () => {
392
+ errorEl.classList.add('hidden');
393
+ if (input.value.trim() === '') {
394
+ errorEl.textContent = 'Command cannot be blank.';
395
+ errorEl.classList.remove('hidden');
396
+ input.focus();
397
+ return;
398
+ }
399
+ let res;
400
+ try {
401
+ res = await fetch('/actions/set-command', {
402
+ method: 'POST',
403
+ headers: { 'Content-Type': 'application/json' },
404
+ body: JSON.stringify({ knotId, command: input.value })
405
+ });
406
+ } catch {
407
+ errorEl.textContent = 'Failed to reach the server.';
408
+ errorEl.classList.remove('hidden');
409
+ return;
410
+ }
411
+ if (res.status === 204) {
412
+ this.hideCommandModal();
413
+ return;
414
+ }
415
+ errorEl.textContent = 'Failed to save command.';
416
+ errorEl.classList.remove('hidden');
417
+ input.focus();
418
+ input.select();
419
+ };
420
+ okBtn.addEventListener('click', submit);
421
+
422
+ buttonRow.append(cancelBtn, okBtn);
423
+ body.appendChild(buttonRow);
424
+
425
+ panel.append(header, body);
426
+ overlay.appendChild(panel);
427
+ document.body.appendChild(overlay);
428
+
429
+ panel.addEventListener('keydown', (e) => {
430
+ if (e.key === 'Escape') {
431
+ e.preventDefault();
432
+ this.hideCommandModal();
433
+ } else if (e.key === 'Enter') {
434
+ e.preventDefault();
435
+ submit();
436
+ } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') {
437
+ e.preventDefault();
438
+ input.select();
439
+ }
440
+ });
441
+
442
+ input.focus();
443
+ input.select();
444
+ },
445
+
446
+ hideCommandModal() {
447
+ document.getElementById('command-modal')?.remove();
448
+ },
449
+
450
+ // Insert Parent's modal - near-identical to showCommandModal above (same field, same
451
+ // Escape/Enter/Cmd+A keydown wiring), with three deliberate differences: the field always
452
+ // starts blank (it's the new knot's own command, not a rename of the existing one, so there's
453
+ // no "current" text to pre-fill or select-all on open), the heading/placeholder/endpoint are
454
+ // its own, and success moves the active knot (service.ts's handleInsertParent broadcasts its
455
+ // own resetAndFocusCommandInput) rather than just refreshing the edited knot in place.
456
+ showInsertParentModal(knotId) {
457
+ this.hideInsertParentModal();
458
+ closeAllDropdowns();
459
+
460
+ const overlay = document.createElement('div');
461
+ overlay.id = 'insert-parent-modal';
462
+ overlay.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-grayscale';
463
+
464
+ const panel = document.createElement('div');
465
+ panel.className = 'bg-base-100 rounded-lg shadow-xl max-w-full min-w-md mx-4';
466
+ panel.setAttribute('role', 'dialog');
467
+ panel.setAttribute('aria-modal', 'true');
468
+ panel.setAttribute('tabindex', '-1');
469
+
470
+ const header = document.createElement('div');
471
+ header.className = 'px-6 py-4 border-b border-base-200';
472
+ const heading = document.createElement('h3');
473
+ heading.className = 'text-lg font-medium text-base-content';
474
+ heading.textContent = 'Insert Parent';
475
+ header.appendChild(heading);
476
+
477
+ const body = document.createElement('div');
478
+ body.className = 'px-6 py-4';
479
+
480
+ const errorEl = document.createElement('div');
481
+ errorEl.className = 'alert alert-error text-sm mb-3 hidden';
482
+ errorEl.setAttribute('role', 'alert');
483
+ errorEl.setAttribute('aria-live', 'assertive');
484
+ body.appendChild(errorEl);
485
+
486
+ const input = document.createElement('input');
487
+ input.type = 'text';
488
+ input.className = 'input input-bordered w-full font-mono';
489
+ input.placeholder = 'New command';
490
+ input.setAttribute('aria-label', 'Command');
491
+ body.appendChild(input);
492
+
493
+ const buttonRow = document.createElement('div');
494
+ buttonRow.className = 'flex justify-end gap-2 mt-4';
495
+
496
+ const cancelBtn = document.createElement('button');
497
+ cancelBtn.type = 'button';
498
+ cancelBtn.className = 'btn btn-neutral';
499
+ cancelBtn.textContent = 'Cancel';
500
+ cancelBtn.addEventListener('click', () => this.hideInsertParentModal());
501
+
502
+ const okBtn = document.createElement('button');
503
+ okBtn.type = 'button';
504
+ okBtn.className = 'btn btn-primary';
505
+ okBtn.textContent = 'OK';
506
+ const submit = async () => {
507
+ errorEl.classList.add('hidden');
508
+ if (input.value.trim() === '') {
509
+ errorEl.textContent = 'Command cannot be blank.';
510
+ errorEl.classList.remove('hidden');
511
+ input.focus();
512
+ return;
513
+ }
514
+ let res;
515
+ try {
516
+ res = await fetch('/actions/insert-parent', {
517
+ method: 'POST',
518
+ headers: { 'Content-Type': 'application/json' },
519
+ body: JSON.stringify({ knotId, command: input.value })
520
+ });
521
+ } catch {
522
+ errorEl.textContent = 'Failed to reach the server.';
523
+ errorEl.classList.remove('hidden');
524
+ return;
525
+ }
526
+ if (res.status === 204) {
527
+ this.hideInsertParentModal();
528
+ return;
529
+ }
530
+ errorEl.textContent = 'Failed to insert parent.';
531
+ errorEl.classList.remove('hidden');
532
+ input.focus();
533
+ input.select();
534
+ };
535
+ okBtn.addEventListener('click', submit);
536
+
537
+ buttonRow.append(cancelBtn, okBtn);
538
+ body.appendChild(buttonRow);
539
+
540
+ panel.append(header, body);
541
+ overlay.appendChild(panel);
542
+ document.body.appendChild(overlay);
543
+
544
+ panel.addEventListener('keydown', (e) => {
545
+ if (e.key === 'Escape') {
546
+ e.preventDefault();
547
+ this.hideInsertParentModal();
548
+ } else if (e.key === 'Enter') {
549
+ e.preventDefault();
550
+ submit();
551
+ } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') {
552
+ e.preventDefault();
553
+ input.select();
554
+ }
555
+ });
556
+
557
+ input.focus();
558
+ },
559
+
560
+ hideInsertParentModal() {
561
+ document.getElementById('insert-parent-modal')?.remove();
562
+ },
563
+
564
+ // ---------------------------------------------------------------------------
565
+ // Tree/graph pane: SVG connector lines + drag-to-pan.
566
+ // Ported from dialog-tool's own main.js (initTreeGraph/drawTreeArrows) - same element ids
567
+ // (#tree-pane, [data-tree-node-id], [data-parent-id], [data-active-knot]) so this applies
568
+ // unchanged to tree-pane.ts's markup.
569
+
570
+ _treeGraphReady: false,
571
+ _arrowSvg: null,
572
+ _treeGraphZoom: 1,
573
+ _TREE_GRAPH_MIN_ZOOM: 0.2,
574
+ _TREE_GRAPH_MAX_ZOOM: 2.5,
575
+
576
+ /**
577
+ * Applies a zoom factor centered on a point (mouse position for wheel-zoom, viewport center for
578
+ * the +/- buttons) by scaling tree-pane.ts's #tree-pane-content wrapper - #tree-pane itself
579
+ * stays unscaled so it remains a plain, predictable overflow:auto scroll container (its
580
+ * scrollWidth/scrollHeight naturally grow to match the scaled content per the CSS Transforms
581
+ * spec, so panning still works correctly post-zoom). Keeps the point under clientX/clientY
582
+ * visually fixed by solving for the scrollLeft/scrollTop that preserve its position: the
583
+ * transform-origin is the content's own top-left (see tree-pane.ts's origin-top-left class), so
584
+ * a point at contentX/contentY (in unscaled content pixels) always renders at
585
+ * contentX*zoom - scrollLeft, and we want that to equal the same viewport offset before and
586
+ * after the zoom changes.
587
+ */
588
+ _zoomTreeGraph(factor, clientX, clientY) {
589
+ const pane = document.getElementById('tree-pane');
590
+ const content = document.getElementById('tree-pane-content');
591
+ if (!pane || !content) return;
592
+
593
+ const oldZoom = this._treeGraphZoom;
594
+ const newZoom = Math.min(this._TREE_GRAPH_MAX_ZOOM, Math.max(this._TREE_GRAPH_MIN_ZOOM, oldZoom * factor));
595
+ if (newZoom === oldZoom) return;
596
+
597
+ const rect = pane.getBoundingClientRect();
598
+ const originX = clientX !== undefined ? clientX - rect.left : pane.clientWidth / 2;
599
+ const originY = clientY !== undefined ? clientY - rect.top : pane.clientHeight / 2;
600
+ const contentX = pane.scrollLeft + originX;
601
+ const contentY = pane.scrollTop + originY;
602
+ const ratio = newZoom / oldZoom;
603
+
604
+ this._treeGraphZoom = newZoom;
605
+ content.style.transform = `scale(${newZoom})`;
606
+ pane.scrollLeft = contentX * ratio - originX;
607
+ pane.scrollTop = contentY * ratio - originY;
608
+ requestAnimationFrame(() => this.drawTreeArrows());
609
+ },
610
+
611
+ // The bottom-right zoom buttons (render.ts) - centered on the pane's current viewport middle,
612
+ // since there's no cursor position to zoom toward from a button click.
613
+ zoomTreeGraphIn() {
614
+ this._zoomTreeGraph(1.25);
615
+ },
616
+
617
+ zoomTreeGraphOut() {
618
+ this._zoomTreeGraph(1 / 1.25);
619
+ },
620
+
621
+ initTreeGraph() {
622
+ if (this._treeGraphReady) return;
623
+ this._treeGraphReady = true;
624
+
625
+ const pane = document.getElementById('tree-pane');
626
+ if (!pane) return;
627
+
628
+ requestAnimationFrame(() => this.drawTreeArrows());
629
+ const self = this;
630
+ const observer = new MutationObserver(() => {
631
+ observer.disconnect();
632
+ requestAnimationFrame(() => {
633
+ self.drawTreeArrows();
634
+ observer.observe(pane, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'data-active-knot'] });
635
+ });
636
+ });
637
+ observer.observe(pane, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'data-active-knot'] });
638
+
639
+ new ResizeObserver(() => requestAnimationFrame(() => self.drawTreeArrows())).observe(pane);
640
+
641
+ // Drag-to-scroll (pan): clicking the pane background and dragging scrolls the pane. Clicks
642
+ // on interactive descendants (the node pill, the "..." trigger) are left alone so they still
643
+ // fire their own data-on:click handlers. The pill is a div[role="button"] rather than a real
644
+ // <button> (it has to contain the menu's nested <details>), so that's excluded explicitly
645
+ // alongside the genuine form controls.
646
+ //
647
+ // Pointer events + setPointerCapture, not plain mouse events on document: this pane lives
648
+ // inside the skein webview's own iframe, so a document-level mouseup listener never fires at
649
+ // all if the button is released outside that iframe (or outside the VS Code window entirely,
650
+ // e.g. dragging past its edge) - dragging was left stuck true forever, and the pane kept
651
+ // panning on the next mousemove even with the button up (regression reported after the dot-
652
+ // grid background made panning motion visible enough to notice).
653
+ //
654
+ // setPointerCapture alone isn't enough here: it doesn't reliably survive the pointer leaving
655
+ // this webview's own iframe for VS Code's surrounding chrome (a separate frame/document our
656
+ // script has no visibility into at all), so a release out there can still go unseen. Two
657
+ // independent backstops cover that: (1) document's pointerleave - unlike move/up, per spec
658
+ // enter/leave events keep targeting wherever the pointer actually is even while captured, so
659
+ // this reliably fires the instant the cursor exits the iframe, and we proactively end the
660
+ // drag right then (we'd get no further events out there to track anyway); (2) e.buttons on
661
+ // every pointermove we do still receive - a direct, always-accurate read of which buttons are
662
+ // physically down *right now*, independent of whether we ever saw the up/cancel event for
663
+ // this pointer - catches the drag having ended out-of-frame if the cursor re-enters afterward
664
+ // with the button already released.
665
+ let dragging = false;
666
+ let startX = 0;
667
+ let startY = 0;
668
+ let scrollX = 0;
669
+ let scrollY = 0;
670
+
671
+ const stopDragging = () => {
672
+ if (!dragging) return;
673
+ dragging = false;
674
+ pane.style.cursor = '';
675
+ pane.style.userSelect = '';
676
+ };
677
+
678
+ pane.addEventListener('pointerdown', (e) => {
679
+ if (e.button !== 0) return;
680
+ if (e.target.closest('button, a, input, select, textarea, [role="button"], details')) return;
681
+ dragging = true;
682
+ startX = e.clientX;
683
+ startY = e.clientY;
684
+ scrollX = pane.scrollLeft;
685
+ scrollY = pane.scrollTop;
686
+ pane.style.cursor = 'grabbing';
687
+ pane.style.userSelect = 'none';
688
+ pane.setPointerCapture(e.pointerId);
689
+ e.preventDefault();
690
+ });
691
+
692
+ pane.addEventListener('pointermove', (e) => {
693
+ if (!dragging) return;
694
+ if ((e.buttons & 1) === 0) {
695
+ stopDragging();
696
+ return;
697
+ }
698
+ pane.scrollLeft = scrollX - (e.clientX - startX);
699
+ pane.scrollTop = scrollY - (e.clientY - startY);
700
+ });
701
+
702
+ pane.addEventListener('pointerup', stopDragging);
703
+ pane.addEventListener('pointercancel', stopDragging);
704
+ document.addEventListener('pointerleave', stopDragging);
705
+
706
+ // Mouse-wheel zoom, centered on the cursor - preventDefault (and { passive: false }, without
707
+ // which some browsers ignore preventDefault entirely on wheel listeners for scroll-performance
708
+ // reasons) replaces the pane's native wheel-scroll with zoom instead, matching the request:
709
+ // panning is already drag-based, so repurposing plain wheel for zoom doesn't remove any
710
+ // existing way to move around. The exponential factor gives smooth, proportional zoom for
711
+ // both discrete mouse wheels (large deltaY per notch) and fine-grained trackpad scrolling.
712
+ pane.addEventListener('wheel', (e) => {
713
+ e.preventDefault();
714
+ const factor = Math.exp(-e.deltaY * 0.0015);
715
+ self._zoomTreeGraph(factor, e.clientX, e.clientY);
716
+ }, { passive: false });
717
+ },
718
+
719
+ drawTreeArrows() {
720
+ const pane = document.getElementById('tree-pane');
721
+ if (!pane) return;
722
+
723
+ this._arrowSvg?.remove();
724
+ this._arrowSvg = null;
725
+
726
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
727
+ Object.assign(svg.style, {
728
+ position: 'absolute',
729
+ top: '0',
730
+ left: '0',
731
+ width: pane.scrollWidth + 'px',
732
+ height: pane.scrollHeight + 'px',
733
+ overflow: 'visible',
734
+ pointerEvents: 'none'
735
+ });
736
+
737
+ const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
738
+ const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
739
+ marker.setAttribute('id', 'tree-arrow');
740
+ marker.setAttribute('markerWidth', '6');
741
+ marker.setAttribute('markerHeight', '6');
742
+ marker.setAttribute('refX', '5');
743
+ marker.setAttribute('refY', '3');
744
+ marker.setAttribute('orient', 'auto');
745
+ const tip = document.createElementNS('http://www.w3.org/2000/svg', 'path');
746
+ tip.setAttribute('d', 'M0,0 L0,6 L6,3 z');
747
+ tip.setAttribute('fill', 'currentColor');
748
+ marker.appendChild(tip);
749
+ defs.appendChild(marker);
750
+ svg.appendChild(defs);
751
+
752
+ const paneRect = pane.getBoundingClientRect();
753
+ const scrollLeft = pane.scrollLeft;
754
+ const scrollTop = pane.scrollTop;
755
+ const nodes = {};
756
+ pane.querySelectorAll('[data-tree-node-id]').forEach((el) => {
757
+ const id = el.getAttribute('data-tree-node-id');
758
+ const pid = el.getAttribute('data-parent-id'); // absent on root
759
+ const r = el.getBoundingClientRect();
760
+ nodes[id] = {
761
+ parentId: pid || null,
762
+ cx: r.left - paneRect.left + scrollLeft + r.width / 2,
763
+ top: r.top - paneRect.top + scrollTop,
764
+ bottom: r.bottom - paneRect.top + scrollTop
765
+ };
766
+ });
767
+
768
+ for (const n of Object.values(nodes)) {
769
+ if (!n.parentId || !nodes[n.parentId]) continue;
770
+ const p = nodes[n.parentId];
771
+ const x1 = p.cx;
772
+ const y1 = p.bottom;
773
+ const x2 = n.cx;
774
+ const y2 = n.top;
775
+ // Elbow connector: straight down from the parent, a sharp turn to horizontal at the
776
+ // midpoint, then a slightly curved turn back to vertical into the child.
777
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
778
+ if (x1 === x2) {
779
+ path.setAttribute('d', `M${x1},${y1} L${x2},${y2}`);
780
+ } else {
781
+ const ymid = (y1 + y2) / 2;
782
+ const dir = x2 > x1 ? 1 : -1;
783
+ const r = Math.min(10, Math.abs(x2 - x1) / 2, Math.max(0, y2 - ymid));
784
+ const xTurn = x2 - dir * r;
785
+ path.setAttribute(
786
+ 'd',
787
+ `M${x1},${y1} L${x1},${ymid} L${xTurn},${ymid} Q${x2},${ymid} ${x2},${ymid + r} L${x2},${y2}`
788
+ );
789
+ }
790
+ path.setAttribute('stroke', 'currentColor');
791
+ path.setAttribute('stroke-opacity', '0.35');
792
+ path.setAttribute('stroke-width', '1.5');
793
+ path.setAttribute('fill', 'none');
794
+ path.setAttribute('marker-end', 'url(#tree-arrow)');
795
+ svg.appendChild(path);
796
+ }
797
+
798
+ this._arrowSvg = svg;
799
+ pane.appendChild(svg);
800
+
801
+ const activeId = pane.getAttribute('data-active-knot');
802
+ if (activeId) {
803
+ pane.querySelector(`[data-tree-node-id="${activeId}"]`)?.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' });
804
+ }
805
+ },
806
+
807
+ // ---------------------------------------------------------------------------
808
+ // Tree pane drag-to-resize. Ported from dialog-tool's own main.js, same element ids
809
+ // (#tree-pane-handle, #tree-pane-outer) as render.ts's renderApp.
810
+
811
+ _treePaneResizeReady: false,
812
+
813
+ initTreePaneResize() {
814
+ if (this._treePaneResizeReady) return;
815
+ this._treePaneResizeReady = true;
816
+
817
+ const handle = document.getElementById('tree-pane-handle');
818
+ const pane = document.getElementById('tree-pane-outer');
819
+ if (!handle || !pane) return;
820
+
821
+ let currentWidth = localStorage.getItem('treePaneWidth') || null;
822
+ if (currentWidth) pane.style.width = currentWidth;
823
+
824
+ new MutationObserver(() => {
825
+ if (currentWidth && pane.style.width !== currentWidth) {
826
+ pane.style.width = currentWidth;
827
+ }
828
+ }).observe(pane, { attributes: true, attributeFilter: ['style'] });
829
+
830
+ handle.addEventListener('mousedown', (startEvt) => {
831
+ startEvt.preventDefault();
832
+ const startX = startEvt.clientX;
833
+ const startWidth = pane.getBoundingClientRect().width;
834
+
835
+ const onMove = (e) => {
836
+ const delta = e.clientX - startX;
837
+ const maxWidth = Math.floor(window.innerWidth * 0.8);
838
+ currentWidth = Math.min(maxWidth, Math.max(160, startWidth + delta)) + 'px';
839
+ pane.style.width = currentWidth;
840
+ };
841
+
842
+ const onUp = () => {
843
+ document.removeEventListener('mousemove', onMove);
844
+ document.removeEventListener('mouseup', onUp);
845
+ document.body.style.cursor = '';
846
+ document.body.style.userSelect = '';
847
+ if (currentWidth) localStorage.setItem('treePaneWidth', currentWidth);
848
+ };
849
+
850
+ document.body.style.cursor = 'col-resize';
851
+ document.body.style.userSelect = 'none';
852
+ document.addEventListener('mousemove', onMove);
853
+ document.addEventListener('mouseup', onUp);
854
+ });
855
+ },
856
+
857
+ // The search box's own ArrowUp/ArrowDown navigation (render.ts's renderSearchBox/
858
+ // renderSearchResults - SEARCH_RESULT_KEYDOWN calls this from both the input and every result
859
+ // button). Without this, ArrowDown/ArrowUp inside the results dropdown just did the browser's
860
+ // default thing - scrolled the (overflow-y-auto) list - instead of moving between results, since
861
+ // there's no native way to make plain buttons in a list behave like a real listbox. current is
862
+ // whichever element the keydown fired on (el, via Datastar); direction is 1 (down) or -1 (up).
863
+ // Clamps at the last result rather than wrapping - going past the first one refocuses the input,
864
+ // a normal combobox convention - so there's always an obvious way back to typing.
865
+ navigateSearchResults(current, direction) {
866
+ const results = Array.from(document.querySelectorAll('[data-search-result]'));
867
+ if (results.length === 0) return;
868
+
869
+ const currentIndex = results.indexOf(current);
870
+ const nextIndex = currentIndex + direction;
871
+ if (nextIndex < 0) {
872
+ document.getElementById('skein-search-input')?.focus();
873
+ return;
874
+ }
875
+ results[Math.min(results.length - 1, nextIndex)]?.focus();
876
+ },
877
+
878
+ // The per-knot actions menu (knot-menu.ts's renderKnotMenu) needs almost no client-side code:
879
+ // it's a native <details class="dropdown dropdown-right"> inline in each knot's own markup,
880
+ // opened by posting to /actions/open-graph-menu or /actions/open-transcript-menu (a plain
881
+ // Datastar data-on:click on the trigger, like everything else in this UI) and positioned by plain
882
+ // daisyUI .dropdown/.dropdown-content CSS - the server decides which knot's menu is open in
883
+ // each pane separately (session.ts's graphMenuId/transcriptMenuId) and sets the native `open`
884
+ // attribute directly in the re-rendered markup, plus toggles the dropdown-content's own
885
+ // popover="manual" via a data-effect (see knot-menu.ts's doc comment for why it's a real
886
+ // top-layer popover, and why "manual" rather than the default "auto"). "manual" means nothing
887
+ // ever closes it but that explicit toggle - not outside clicks, not Escape - so the listener
888
+ // below is what actually provides the outside-click-to-close a user expects.
889
+ };
890
+
891
+ // Closes every open per-knot dropdown, both visually (removing `open`/hiding the Popover, for
892
+ // instant feedback) and server-side (POST /actions/close-menus keeps session.ts's graphMenuId/
893
+ // transcriptMenuId in sync, so a later unrelated SSE patch doesn't re-open it). Shared by the
894
+ // outside-click listener below and sk.showLabelModal, which needs the same cleanup when its
895
+ // trigger was a menu item inside a still-open dropdown.
896
+ function closeAllDropdowns() {
897
+ const openMenus = document.querySelectorAll('details.dropdown[open]');
898
+ if (openMenus.length === 0) return;
899
+ openMenus.forEach((details) => {
900
+ details.removeAttribute('open');
901
+ details.querySelector('[popover]')?.togglePopover(false);
902
+ });
903
+ fetch('/actions/close-menus', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
904
+ }
905
+
906
+ // Dismisses the search box's results dropdown the same way Escape does (clears the query - see
907
+ // render.ts's renderSearchBox) - mirrors closeAllDropdowns' own direct-DOM-update-plus-raw-fetch
908
+ // shape, since a plain click listener has no Datastar action/signal access of its own. Setting
909
+ // input.value directly (rather than going through Datastar) is fine here: the POST below tells
910
+ // the server the query is now blank, and the next SSE patch re-renders the input from that
911
+ // server-side truth anyway, same as every other patched element.
912
+ function dismissSearch() {
913
+ const input = document.getElementById('skein-search-input');
914
+ if (!input || input.value === '') return;
915
+ input.value = '';
916
+ fetch('/actions/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ searchQuery: '' }) });
917
+ }
918
+
919
+ // Clicking outside the open dropdown closes it; clicking outside the search box dismisses its
920
+ // results the same way. Skips anything inside a .dropdown/#skein-search-box entirely - a click on
921
+ // a trigger, menu item, or search result is already handled by its own data-on:click.
922
+ document.addEventListener('click', (evt) => {
923
+ if (!evt.target.closest('.dropdown')) closeAllDropdowns();
924
+ if (!evt.target.closest('#skein-search-box')) dismissSearch();
925
+ });
926
+
927
+ // ---------------------------------------------------------------------------
928
+ // Keyboard accelerators - mirrors dialog-tool's own Cmd/Option-based scheme (doc/skein.md),
929
+ // adapted to dialog-ide's actual routes. One capturing document-level keydown listener,
930
+ // intentionally global rather than scoped to "focus is outside a text field": the command input
931
+ // auto-refocuses after nearly every action (sk.resetAndFocusCommandInput), so excluding INPUT/
932
+ // TEXTAREA focus would make every shortcut below fire almost never, and none of these modifier
933
+ // combos (Cmd, Option) are part of normal command typing anyway - same reasoning undo/redo
934
+ // already used. Suppressed while the progress modal is up (a replay is already in flight; its own
935
+ // Cancel button is the only control that makes sense then).
936
+ //
937
+ // Knot-scoped shortcuts (Option+letter, no Shift) act on whichever knot is currently active, not
938
+ // whichever knot's menu happens to be open/hovered - read from #tree-pane's data-active-knot, the
939
+ // same attribute drawTreeArrows already relies on. Root-only guards (id 0 can't be locked,
940
+ // labeled, or deleted) mirror knot-menu.ts's own menuItemClass(isRoot) disabling - skipping
941
+ // server-side here rather than letting session.ts throw into an unhandled 500.
942
+
943
+ function postAction(path, body) {
944
+ fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}) });
945
+ }
946
+
947
+ function activeKnotId() {
948
+ const raw = document.getElementById('tree-pane')?.getAttribute('data-active-knot');
949
+ return raw ? Number(raw) : null;
950
+ }
951
+
952
+ function spineLeafId() {
953
+ const raw = document.querySelector('nav')?.getAttribute('data-spine-leaf-id');
954
+ return raw ? Number(raw) : null;
955
+ }
956
+
957
+ // ⌥L's target: the knot's current label, read off the "Edit Label" menu button's own
958
+ // data-current-label attribute (knot-menu.ts) rather than requiring the dropdown to be open -
959
+ // that attribute exists in the DOM regardless of the <details>'s open/closed state.
960
+ function currentLabelOf(knotId) {
961
+ return document.querySelector(`#knot-${knotId} [data-current-label]`)?.dataset.currentLabel ?? '';
962
+ }
963
+
964
+ // ⌥E's target: the knot's current command, read off the "Edit Command" menu button's own
965
+ // data-current-command attribute (knot-menu.ts) - same convention as currentLabelOf above.
966
+ function currentCommandOf(knotId) {
967
+ return document.querySelector(`#knot-${knotId} [data-current-command]`)?.dataset.currentCommand ?? '';
968
+ }
969
+
970
+ document.addEventListener('keydown', (evt) => {
971
+ if (
972
+ document.getElementById('progress-modal') ||
973
+ document.getElementById('label-modal') ||
974
+ document.getElementById('command-modal') ||
975
+ document.getElementById('insert-parent-modal')
976
+ ) return;
977
+
978
+ const mod = evt.metaKey || evt.ctrlKey;
979
+ const opt = evt.altKey;
980
+ // Cmd/Ctrl never recompose a letter key, so evt.key (plain 's'/'z') is fine for the mod branch
981
+ // below. Option does recompose it on macOS - Option+R types "®", not "r" - so evt.key would
982
+ // never match the letters below and the shortcut would silently fall through to inserting that
983
+ // composed character into whatever's focused instead of calling preventDefault(). evt.code (the
984
+ // physical key position, e.g. "KeyR") is layout/composition-independent and unaffected by
985
+ // Option, so every opt-branch check below matches on that instead.
986
+ const key = evt.key.toLowerCase();
987
+ const code = evt.code;
988
+
989
+ // Command Palette - VS Code's webview-forwarding bridge only reaches the outer webview shell
990
+ // (getWebviewHtml in extension.ts), not this nested localhost iframe where keydowns actually
991
+ // land, so there's no when-clause that can catch this directly. Instead, relay it up via
992
+ // postMessage; the shell re-dispatches a synthetic keydown on itself, which the bridge *can*
993
+ // see and forward on to workbench.action.showCommands (bound in package.json's
994
+ // contributes.keybindings, scoped to activeWebviewPanelId == 'dialogIdeSkein'). Deliberately
995
+ // narrow (just this one combo) rather than relaying every unhandled key - the same modifier
996
+ // combos without Shift (Cmd/Ctrl+A/C/V/X/Z) have native text-editing meaning in whatever's
997
+ // focused, which relaying would break.
998
+ if (mod && evt.shiftKey && !opt && code === 'KeyA') {
999
+ evt.preventDefault();
1000
+ window.parent.postMessage(
1001
+ { type: 'forward-keydown', key: 'a', code, metaKey: evt.metaKey, ctrlKey: evt.ctrlKey, shiftKey: true, altKey: false },
1002
+ '*'
1003
+ );
1004
+ return;
1005
+ }
1006
+
1007
+ // Tree-wide.
1008
+ if (mod && !opt && key === 's') {
1009
+ evt.preventDefault();
1010
+ postAction('/actions/save');
1011
+ return;
1012
+ }
1013
+ if (mod && !opt && key === 'z') {
1014
+ evt.preventDefault();
1015
+ postAction(evt.shiftKey ? '/actions/redo' : '/actions/undo');
1016
+ return;
1017
+ }
1018
+ if (opt && evt.shiftKey && code === 'KeyR') {
1019
+ evt.preventDefault();
1020
+ postAction('/actions/replay-all');
1021
+ return;
1022
+ }
1023
+ if (opt && evt.shiftKey && code === 'KeyB') {
1024
+ evt.preventDefault();
1025
+ const knotId = spineLeafId();
1026
+ if (knotId !== null) postAction('/actions/bless-changes', { knotId });
1027
+ return;
1028
+ }
1029
+ if (opt && !evt.shiftKey && code === 'KeyF') {
1030
+ evt.preventDefault();
1031
+ document.getElementById('skein-search-input')?.focus();
1032
+ return;
1033
+ }
1034
+ // First Knot / Last Knot - jump straight to root / the selected spine's leaf. Unlike every
1035
+ // other shortcut here, these (and the four plain-arrow ones below) don't need activeKnotId() -
1036
+ // session.ts's navigateSpine reads the current active knot and computes the target itself, so
1037
+ // there's nothing for the client to look up first.
1038
+ if (opt && evt.shiftKey && code === 'ArrowUp') {
1039
+ evt.preventDefault();
1040
+ postAction('/actions/navigate-spine', { direction: 'first' });
1041
+ return;
1042
+ }
1043
+ if (opt && evt.shiftKey && code === 'ArrowDown') {
1044
+ evt.preventDefault();
1045
+ postAction('/actions/navigate-spine', { direction: 'last' });
1046
+ return;
1047
+ }
1048
+
1049
+ // Knot-scoped - act on the active knot.
1050
+ if (opt && !evt.shiftKey) {
1051
+ const knotId = activeKnotId();
1052
+ if (knotId === null) return;
1053
+
1054
+ if (code === 'KeyB') {
1055
+ evt.preventDefault();
1056
+ postAction('/actions/bless-knot', { knotId });
1057
+ } else if (code === 'KeyR') {
1058
+ evt.preventDefault();
1059
+ postAction('/actions/replay-to', { knotId });
1060
+ } else if (code === 'KeyA') {
1061
+ evt.preventDefault();
1062
+ postAction('/actions/new-child', { knotId });
1063
+ } else if (code === 'KeyT') {
1064
+ // Trace - root has no parent to replay to, so its own Trace action traces startup instead
1065
+ // (same isRoot branch knot-menu.ts's Trace item uses). Doesn't check whether this knot was
1066
+ // reached via a keystroke prompt first (unlike knot-menu.ts's up-front disabling) - session.
1067
+ // ts's traceKnot itself already refuses that case safely, same as every other accelerator
1068
+ // here that doesn't pre-validate a knot's specific state before firing.
1069
+ evt.preventDefault();
1070
+ if (knotId === 0) {
1071
+ postAction('/actions/trace-startup');
1072
+ } else {
1073
+ postAction('/actions/trace-knot', { knotId });
1074
+ }
1075
+ } else if (code === 'KeyK' && knotId !== 0) {
1076
+ evt.preventDefault();
1077
+ postAction('/actions/toggle-lock', { knotId });
1078
+ } else if (code === 'KeyD' && knotId !== 0) {
1079
+ evt.preventDefault();
1080
+ postAction('/actions/delete-knot', { knotId });
1081
+ } else if (code === 'KeyL' && knotId !== 0) {
1082
+ evt.preventDefault();
1083
+ sk.showLabelModal(knotId, currentLabelOf(knotId));
1084
+ } else if (code === 'KeyE' && knotId !== 0) {
1085
+ evt.preventDefault();
1086
+ sk.showCommandModal(knotId, currentCommandOf(knotId));
1087
+ } else if (code === 'KeyX') {
1088
+ // Toggle Expand - the nav graph's ▾/▸ chevron (tree-pane.ts), otherwise mouse-only.
1089
+ evt.preventDefault();
1090
+ postAction('/actions/toggle-tree-node', { knotId });
1091
+ } else if (code === 'ArrowUp') {
1092
+ // Parent Knot.
1093
+ evt.preventDefault();
1094
+ postAction('/actions/navigate-spine', { direction: 'up' });
1095
+ } else if (code === 'ArrowDown') {
1096
+ // Child Knot (whichever child is currently on the spine).
1097
+ evt.preventDefault();
1098
+ postAction('/actions/navigate-spine', { direction: 'down' });
1099
+ } else if (code === 'ArrowLeft') {
1100
+ // Previous Sibling (alphabetical order, matching the nav graph).
1101
+ evt.preventDefault();
1102
+ postAction('/actions/navigate-spine', { direction: 'left' });
1103
+ } else if (code === 'ArrowRight') {
1104
+ // Next Sibling.
1105
+ evt.preventDefault();
1106
+ postAction('/actions/navigate-spine', { direction: 'right' });
1107
+ }
1108
+ }
1109
+ });