@nimbus-sh/worker 0.1.4 → 0.1.6

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 (141) hide show
  1. package/README.md +37 -0
  2. package/dist/_shared/crypto.d.ts +15 -0
  3. package/dist/_shared/crypto.d.ts.map +1 -0
  4. package/dist/_shared/crypto.js +122 -0
  5. package/dist/facets/manager.d.ts +24 -0
  6. package/dist/facets/manager.d.ts.map +1 -1
  7. package/dist/facets/manager.js +279 -70
  8. package/dist/facets/process.d.ts +11 -2
  9. package/dist/facets/process.d.ts.map +1 -1
  10. package/dist/facets/process.js +187 -21
  11. package/dist/facets/vite-dev-server.d.ts +2 -0
  12. package/dist/facets/vite-dev-server.d.ts.map +1 -1
  13. package/dist/facets/vite-dev-server.js +39 -12
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -0
  17. package/dist/loaders/child-process/spawn-facet.d.ts +1 -3
  18. package/dist/loaders/child-process/spawn-facet.d.ts.map +1 -1
  19. package/dist/loaders/child-process/spawn-facet.js +1 -12
  20. package/dist/loaders/child-process/spawn-pool.d.ts +1 -1
  21. package/dist/loaders/child-process/spawn-pool.d.ts.map +1 -1
  22. package/dist/loaders/child-process/spawn-pool.js +1 -6
  23. package/dist/loaders/loader-pool.d.ts +7 -0
  24. package/dist/loaders/loader-pool.d.ts.map +1 -1
  25. package/dist/loaders/loader-pool.js +3 -1
  26. package/dist/npm/installer.d.ts.map +1 -1
  27. package/dist/npm/installer.js +47 -27
  28. package/dist/npm/pre-bundle-facet.d.ts.map +1 -1
  29. package/dist/npm/pre-bundle-facet.js +2 -1
  30. package/dist/npm/resolve-one-facet.d.ts.map +1 -1
  31. package/dist/npm/resolve-one-facet.js +41 -13
  32. package/dist/npm/resolver.d.ts.map +1 -1
  33. package/dist/npm/resolver.js +70 -35
  34. package/dist/router/index.d.ts.map +1 -1
  35. package/dist/router/index.js +45 -21
  36. package/dist/router/remote-api.d.ts.map +1 -1
  37. package/dist/router/remote-api.js +29 -8
  38. package/dist/runtime/barrel-synthesizer.d.ts +7 -0
  39. package/dist/runtime/barrel-synthesizer.d.ts.map +1 -1
  40. package/dist/runtime/barrel-synthesizer.js +11 -0
  41. package/dist/runtime/clang-runner.d.ts +2 -2
  42. package/dist/runtime/clang-runner.d.ts.map +1 -1
  43. package/dist/runtime/clang-runner.js +64 -47
  44. package/dist/runtime/esbuild-service.d.ts +12 -5
  45. package/dist/runtime/esbuild-service.d.ts.map +1 -1
  46. package/dist/runtime/esbuild-service.js +28 -14
  47. package/dist/runtime/node-runner.d.ts +2 -0
  48. package/dist/runtime/node-runner.d.ts.map +1 -1
  49. package/dist/runtime/node-shims.d.ts +4 -2
  50. package/dist/runtime/node-shims.d.ts.map +1 -1
  51. package/dist/runtime/node-shims.js +404 -55
  52. package/dist/runtime/os-contracts.d.ts +128 -0
  53. package/dist/runtime/os-contracts.d.ts.map +1 -0
  54. package/dist/runtime/os-contracts.js +1 -0
  55. package/dist/runtime/package-manager.d.ts +31 -0
  56. package/dist/runtime/package-manager.d.ts.map +1 -1
  57. package/dist/runtime/package-manager.js +162 -15
  58. package/dist/runtime/port-registry.d.ts +5 -0
  59. package/dist/runtime/port-registry.d.ts.map +1 -1
  60. package/dist/runtime/port-registry.js +22 -1
  61. package/dist/runtime/pyodide-runtime-assets.d.ts +19 -0
  62. package/dist/runtime/pyodide-runtime-assets.d.ts.map +1 -0
  63. package/dist/runtime/pyodide-runtime-assets.js +44 -0
  64. package/dist/runtime/python-repl.d.ts +3 -0
  65. package/dist/runtime/python-repl.d.ts.map +1 -1
  66. package/dist/runtime/python-repl.js +18 -32
  67. package/dist/runtime/python-runner.d.ts +8 -9
  68. package/dist/runtime/python-runner.d.ts.map +1 -1
  69. package/dist/runtime/python-runner.js +958 -118
  70. package/dist/runtime/python-socket-shim.d.ts +11 -0
  71. package/dist/runtime/python-socket-shim.d.ts.map +1 -0
  72. package/dist/runtime/python-socket-shim.js +526 -0
  73. package/dist/runtime/require-resolver.d.ts.map +1 -1
  74. package/dist/runtime/require-resolver.js +2 -11
  75. package/dist/runtime/ruby-gems.d.ts +25 -0
  76. package/dist/runtime/ruby-gems.d.ts.map +1 -0
  77. package/dist/runtime/ruby-gems.js +582 -0
  78. package/dist/runtime/ruby-runner.d.ts +2 -4
  79. package/dist/runtime/ruby-runner.d.ts.map +1 -1
  80. package/dist/runtime/ruby-runner.js +335 -34
  81. package/dist/runtime/runtime-catalog.d.ts +16 -4
  82. package/dist/runtime/runtime-catalog.d.ts.map +1 -1
  83. package/dist/runtime/runtime-catalog.js +21 -10
  84. package/dist/runtime/runtime-registry.d.ts +3 -0
  85. package/dist/runtime/runtime-registry.d.ts.map +1 -1
  86. package/dist/runtime/runtime-registry.js +4 -0
  87. package/dist/runtime/sqlite-runtime-fs-bridge.d.ts +43 -0
  88. package/dist/runtime/sqlite-runtime-fs-bridge.d.ts.map +1 -0
  89. package/dist/runtime/sqlite-runtime-fs-bridge.js +224 -0
  90. package/dist/runtime/static-server.d.ts +3 -0
  91. package/dist/runtime/static-server.d.ts.map +1 -0
  92. package/dist/runtime/static-server.js +121 -0
  93. package/dist/runtime/vfs-snapshot.d.ts +57 -0
  94. package/dist/runtime/vfs-snapshot.d.ts.map +1 -0
  95. package/dist/runtime/vfs-snapshot.js +141 -0
  96. package/dist/runtime/virtual-socket-kernel.d.ts +16 -0
  97. package/dist/runtime/virtual-socket-kernel.d.ts.map +1 -0
  98. package/dist/runtime/virtual-socket-kernel.js +360 -0
  99. package/dist/runtime/wasi-instance.d.ts +6 -3
  100. package/dist/runtime/wasi-instance.d.ts.map +1 -1
  101. package/dist/runtime/wasm-runner.d.ts +1 -25
  102. package/dist/runtime/wasm-runner.d.ts.map +1 -1
  103. package/dist/runtime/wasm-runner.js +1 -160
  104. package/dist/session/agent.d.ts +43 -0
  105. package/dist/session/agent.d.ts.map +1 -0
  106. package/dist/session/agent.js +1252 -0
  107. package/dist/session/init.d.ts.map +1 -1
  108. package/dist/session/init.js +64 -19
  109. package/dist/session/nimbus-session.d.ts +14 -2
  110. package/dist/session/nimbus-session.d.ts.map +1 -1
  111. package/dist/session/nimbus-session.js +104 -30
  112. package/dist/session/programmatic.d.ts +10 -0
  113. package/dist/session/programmatic.d.ts.map +1 -1
  114. package/dist/session/programmatic.js +121 -0
  115. package/dist/session/routes.d.ts.map +1 -1
  116. package/dist/session/routes.js +19 -1
  117. package/dist/session/rpc.d.ts +11 -0
  118. package/dist/session/rpc.d.ts.map +1 -1
  119. package/dist/session/rpc.js +56 -43
  120. package/dist/session/supervisor-rpc.d.ts +15 -0
  121. package/dist/session/supervisor-rpc.d.ts.map +1 -1
  122. package/dist/session/supervisor-rpc.js +32 -0
  123. package/dist/session/ws.d.ts +4 -3
  124. package/dist/session/ws.d.ts.map +1 -1
  125. package/dist/session/ws.js +11 -1
  126. package/dist/shell/unix-commands.d.ts.map +1 -1
  127. package/dist/shell/unix-commands.js +52 -51
  128. package/dist/vfs/path.d.ts +8 -4
  129. package/dist/vfs/path.d.ts.map +1 -1
  130. package/dist/vfs/path.js +16 -4
  131. package/dist/vfs/sqlite-vfs.d.ts +2 -0
  132. package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
  133. package/dist/vfs/sqlite-vfs.js +11 -0
  134. package/dist/vfs/symlink-registry.d.ts +7 -1
  135. package/dist/vfs/symlink-registry.d.ts.map +1 -1
  136. package/dist/vfs/symlink-registry.js +16 -11
  137. package/package.json +4 -1
  138. package/public/index.html +240 -0
  139. package/public/s/index.html +1316 -128
  140. package/runtime-contracts/pyodide-workerd-adapter.json +18 -0
  141. package/scripts/bundle-runtime.mjs +99 -24
@@ -29,7 +29,7 @@
29
29
  .toolbar .sep { width: 1px; height: 16px; background: #30363d; margin: 0 4px; }
30
30
  .toolbar .vite-status { font-size: 11px; color: #8b949e; margin-left: auto; }
31
31
  .toolbar .vite-status.running { color: #3fb950; }
32
- /* Process count button. Sits next to the layout-mode buttons. Hidden
32
+ /* Process count button. Sits next to the workspace buttons. Hidden
33
33
  (display:none) until at least one process has been seen in this
34
34
  session so it doesn't clutter a brand-new session's toolbar. */
35
35
  .toolbar #btnProcs { display: none; gap: 6px; align-items: center; font-family: ui-monospace, Menlo, monospace; }
@@ -96,11 +96,81 @@
96
96
  /* Preview panel */
97
97
  .panel-preview { flex: 1; display: flex; flex-direction: column; background: #0d1117; overflow: hidden; min-width: 0; }
98
98
  .panel-preview.hidden { display: none; }
99
- .preview-bar { display: flex; align-items: center; gap: 6px; padding: 3px 8px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; }
99
+ .preview-bar { display: flex; align-items: center; gap: 6px; padding: 3px 8px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; min-width: 0; }
100
+ .preview-tabs {
101
+ display: flex; align-items: center; gap: 3px; min-width: 110px; max-width: 45%;
102
+ overflow-x: auto; scrollbar-width: none; flex: 0 1 auto;
103
+ }
104
+ .preview-tabs::-webkit-scrollbar { display: none; }
105
+ .preview-bar .preview-tab {
106
+ height: 22px; display: inline-flex; align-items: center; gap: 5px; min-width: 0;
107
+ max-width: 150px; padding: 0 8px; border-radius: 4px 4px 0 0;
108
+ border: 1px solid transparent; border-bottom-color: transparent;
109
+ background: transparent; color: #8b949e; font-size: 11px; line-height: 1;
110
+ white-space: nowrap; cursor: pointer;
111
+ }
112
+ .preview-bar .preview-tab:hover { background: #21262d; color: #c9d1d9; }
113
+ .preview-bar .preview-tab.active {
114
+ background: #0d1117; color: #f0f6fc; border-color: #30363d;
115
+ border-bottom-color: #0d1117;
116
+ }
117
+ .preview-bar .preview-tab.running::before {
118
+ content: ""; width: 6px; height: 6px; border-radius: 999px;
119
+ background: #3fb950; box-shadow: 0 0 8px rgba(63,185,80,0.55);
120
+ flex: 0 0 auto;
121
+ }
122
+ .preview-bar .preview-tab.markdown::before {
123
+ content: "md"; color: #79c0ff; font: 9px/1 ui-monospace, Menlo, monospace;
124
+ border: 1px solid rgba(121,192,255,0.35); border-radius: 3px; padding: 1px 2px;
125
+ flex: 0 0 auto;
126
+ }
127
+ .preview-tab-label { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
100
128
  .preview-bar .url-bar { flex: 1; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 2px 8px; font-size: 11px; border-radius: 4px; font-family: monospace; }
101
129
  .preview-bar button { background: none; border: none; color: #8b949e; cursor: pointer; font-size: 14px; padding: 2px 4px; }
102
130
  .preview-bar button:hover { color: #c9d1d9; }
103
131
  #preview-frame { flex: 1; border: none; background: white; width: 100%; }
132
+ #markdown-preview {
133
+ display: none; flex: 1; min-height: 0; overflow: auto;
134
+ background: #0d1117; color: #c9d1d9;
135
+ }
136
+ #markdown-preview.active { display: block; }
137
+ .markdown-body {
138
+ max-width: 860px; margin: 0 auto; padding: 34px 44px 56px;
139
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
140
+ font-size: 14px; line-height: 1.65;
141
+ }
142
+ .markdown-body h1, .markdown-body h2, .markdown-body h3 {
143
+ color: #f0f6fc; line-height: 1.25; margin: 24px 0 12px;
144
+ border-bottom: 1px solid #30363d; padding-bottom: 8px;
145
+ }
146
+ .markdown-body h1 { font-size: 30px; margin-top: 0; }
147
+ .markdown-body h2 { font-size: 22px; }
148
+ .markdown-body h3 { font-size: 18px; border-bottom: 0; padding-bottom: 0; }
149
+ .markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body table,
150
+ .markdown-body pre, .markdown-body blockquote { margin: 0 0 16px; }
151
+ .markdown-body a { color: #58a6ff; text-decoration: none; }
152
+ .markdown-body a:hover { text-decoration: underline; }
153
+ .markdown-body code {
154
+ background: rgba(110, 118, 129, 0.28); color: #f0f6fc;
155
+ border-radius: 4px; padding: 0.15em 0.35em;
156
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
157
+ font-size: 0.92em;
158
+ }
159
+ .markdown-body pre {
160
+ background: #161b22; border: 1px solid #30363d; border-radius: 6px;
161
+ padding: 14px 16px; overflow: auto;
162
+ }
163
+ .markdown-body pre code { background: transparent; padding: 0; color: #c9d1d9; }
164
+ .markdown-body blockquote {
165
+ border-left: 3px solid #3fb950; color: #8b949e; padding: 4px 0 4px 14px;
166
+ }
167
+ .markdown-body table { border-collapse: collapse; width: 100%; display: block; overflow: auto; }
168
+ .markdown-body th, .markdown-body td {
169
+ border: 1px solid #30363d; padding: 7px 10px; text-align: left;
170
+ }
171
+ .markdown-body th { background: #161b22; color: #f0f6fc; }
172
+ .markdown-body tr:nth-child(2n) td { background: rgba(22, 27, 34, 0.5); }
173
+ .markdown-body hr { border: 0; border-top: 1px solid #30363d; margin: 24px 0; }
104
174
 
105
175
  /* Footer */
106
176
  .footer { padding: 3px 12px; background: #161b22; border-top: 1px solid #30363d; font-size: 11px; color: #8b949e; flex-shrink: 0; display: flex; justify-content: space-between; gap: 12px; }
@@ -109,26 +179,201 @@
109
179
  .footer .stat-value { color: #c9d1d9; }
110
180
  .footer .stat-value.good { color: #3fb950; }
111
181
 
112
- /* Layout modes */
113
- .main.terminal-only .panel-preview,
114
- .main.terminal-only .panel-editor,
115
- .main.terminal-only .resize-handle { display: none; }
116
- .main.terminal-only .panel-terminal { flex: 1; }
117
- .main.preview-only .panel-terminal,
118
- .main.preview-only .panel-editor,
119
- .main.preview-only .resize-handle { display: none; }
120
- .main.preview-only .panel-preview { flex: 1; }
121
-
122
- /* Editor layout mode:
182
+ /* Agent chat */
183
+ .panel-agent {
184
+ display: none; flex: 1; min-width: 0; min-height: 0;
185
+ background: #0f141b; color: #d6dee8; flex-direction: column;
186
+ }
187
+ .agent-shell {
188
+ flex: 1; min-height: 0; display: grid;
189
+ grid-template-rows: auto 1fr auto;
190
+ width: 100%;
191
+ background:
192
+ linear-gradient(180deg, rgba(88,166,255,0.04), rgba(63,185,80,0.015) 28%, transparent 48%),
193
+ #0f141b;
194
+ }
195
+ .agent-top {
196
+ display: flex; align-items: center; gap: 8px;
197
+ padding: 7px 12px; background: rgba(22,27,34,0.96);
198
+ border-bottom: 1px solid #30363d; min-height: 40px;
199
+ }
200
+ .agent-heading { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
201
+ .agent-title { font-size: 12px; font-weight: 650; color: #f0f6fc; white-space: nowrap; }
202
+ .agent-subtitle {
203
+ color: #8b949e; font-size: 11px; overflow: hidden; text-overflow: ellipsis;
204
+ white-space: nowrap; max-width: 220px;
205
+ }
206
+ .agent-status {
207
+ margin-left: auto; font-size: 11px; color: #8b949e;
208
+ font-family: ui-monospace, Menlo, monospace; display: inline-flex;
209
+ align-items: center; gap: 6px; min-width: 0; white-space: nowrap;
210
+ }
211
+ .agent-status::before {
212
+ content: ''; width: 6px; height: 6px; border-radius: 50%;
213
+ background: #6e7681; box-shadow: 0 0 0 1px rgba(255,255,255,0.05);
214
+ }
215
+ .agent-status.ready { color: #3fb950; }
216
+ .agent-status.ready::before { background: #3fb950; box-shadow: 0 0 8px rgba(63,185,80,0.55); }
217
+ .agent-status.warn { color: #d29922; }
218
+ .agent-status.warn::before { background: #d29922; box-shadow: 0 0 8px rgba(210,153,34,0.45); }
219
+ .agent-status.streaming { color: #58a6ff; }
220
+ .agent-status.streaming::before { background: #58a6ff; animation: pulse 1.1s ease-in-out infinite; }
221
+ .agent-top button,
222
+ .agent-composer button {
223
+ background: #21262d; border: 1px solid #30363d; color: #c9d1d9;
224
+ padding: 4px 10px; border-radius: 6px; cursor: pointer; font-size: 11px;
225
+ }
226
+ .agent-top button:hover,
227
+ .agent-composer button:hover { background: #30363d; }
228
+ .agent-top button:disabled,
229
+ .agent-composer button:disabled { opacity: 0.55; cursor: not-allowed; }
230
+ .agent-top .agent-secondary { color: #8b949e; }
231
+ .agent-top select {
232
+ background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
233
+ padding: 4px 8px; border-radius: 6px; font-size: 11px;
234
+ max-width: 260px;
235
+ }
236
+ .agent-messages {
237
+ min-height: 0; overflow: auto; padding: 22px 18px 26px;
238
+ display: flex; flex-direction: column; gap: 16px;
239
+ scrollbar-width: thin; scrollbar-color: #30363d transparent;
240
+ }
241
+ .agent-empty {
242
+ margin: auto; max-width: 560px; color: #8b949e; text-align: center;
243
+ font-size: 13px; line-height: 1.6; padding: 28px;
244
+ border: 1px dashed #30363d; border-radius: 8px;
245
+ background: rgba(22,27,34,0.55);
246
+ }
247
+ .agent-msg {
248
+ width: min(900px, 100%); margin: 0 auto;
249
+ display: flex; gap: 10px; align-items: flex-start;
250
+ }
251
+ .agent-msg.user { justify-content: flex-end; }
252
+ .agent-avatar {
253
+ width: 24px; height: 24px; border-radius: 6px; flex: 0 0 auto;
254
+ display: grid; place-items: center; margin-top: 2px;
255
+ border: 1px solid #30363d; background: #161b22;
256
+ color: #8b949e; font: 10px ui-monospace, Menlo, monospace;
257
+ }
258
+ .agent-msg.assistant .agent-avatar { color: #7ee787; border-color: rgba(63,185,80,0.35); background: rgba(63,185,80,0.08); }
259
+ .agent-msg.user .agent-avatar { display: none; }
260
+ .agent-content {
261
+ min-width: 0; max-width: min(760px, 86%); display: flex; flex-direction: column; gap: 8px;
262
+ }
263
+ .agent-msg.user .agent-content { align-items: flex-end; }
264
+ .agent-meta {
265
+ color: #6e7681; font: 10px ui-monospace, Menlo, monospace;
266
+ text-transform: uppercase; letter-spacing: 0.02em;
267
+ }
268
+ .agent-bubble {
269
+ border-radius: 8px; padding: 10px 12px; overflow-wrap: anywhere;
270
+ font-size: 13px; line-height: 1.58; color: #d6dee8;
271
+ }
272
+ .agent-msg.assistant .agent-bubble {
273
+ background: rgba(22,27,34,0.78); border: 1px solid rgba(48,54,61,0.85);
274
+ }
275
+ .agent-msg.user .agent-bubble {
276
+ background: linear-gradient(180deg, rgba(31,111,235,0.24), rgba(31,111,235,0.15));
277
+ border: 1px solid rgba(88,166,255,0.65); color: #f0f6fc;
278
+ max-width: min(680px, 76%);
279
+ }
280
+ .agent-bubble p { margin: 0 0 10px; }
281
+ .agent-bubble p:last-child { margin-bottom: 0; }
282
+ .agent-bubble ul, .agent-bubble ol { margin: 6px 0 10px 18px; }
283
+ .agent-bubble li { margin: 3px 0; }
284
+ .agent-bubble code {
285
+ background: rgba(110,118,129,0.25); border-radius: 4px;
286
+ padding: 0.12em 0.35em; color: #f0f6fc;
287
+ font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.92em;
288
+ }
289
+ .agent-bubble pre {
290
+ margin: 8px 0; padding: 10px 12px; border-radius: 8px;
291
+ border: 1px solid #30363d; background: #0d1117; overflow: auto;
292
+ white-space: pre;
293
+ }
294
+ .agent-bubble pre code { background: transparent; padding: 0; }
295
+ .agent-thinking {
296
+ display: inline-flex; align-items: center; gap: 7px;
297
+ color: #8b949e; font-size: 12px; padding: 4px 0;
298
+ }
299
+ .agent-thinking .dots { display: inline-flex; gap: 4px; }
300
+ .agent-thinking .dots span {
301
+ width: 5px; height: 5px; border-radius: 50%; background: #58a6ff;
302
+ animation: agent-dot 1s ease-in-out infinite;
303
+ }
304
+ .agent-thinking .dots span:nth-child(2) { animation-delay: 120ms; }
305
+ .agent-thinking .dots span:nth-child(3) { animation-delay: 240ms; }
306
+ @keyframes agent-dot { 0%, 70%, 100% { opacity: 0.32; transform: translateY(0); } 35% { opacity: 1; transform: translateY(-2px); } }
307
+ .agent-cursor {
308
+ display: inline-block; width: 2px; height: 1em; margin-left: 2px;
309
+ background: #58a6ff; vertical-align: -0.15em; animation: agent-cursor 1s steps(1) infinite;
310
+ }
311
+ @keyframes agent-cursor { 0%, 45% { opacity: 1; } 46%, 100% { opacity: 0; } }
312
+ .agent-reasoning {
313
+ border-left: 2px solid rgba(88,166,255,0.65); padding: 7px 10px;
314
+ background: rgba(88,166,255,0.06); color: #8b949e; border-radius: 0 8px 8px 0;
315
+ font-size: 12px; line-height: 1.5;
316
+ }
317
+ .agent-reasoning summary { cursor: pointer; color: #79c0ff; font-weight: 600; }
318
+ .agent-reasoning pre { margin-top: 7px; white-space: pre-wrap; font: 12px/1.5 ui-monospace, Menlo, monospace; color: #c9d1d9; }
319
+ .agent-tools { display: flex; flex-direction: column; gap: 7px; }
320
+ .agent-tool {
321
+ border: 1px solid #30363d; background: rgba(13,17,23,0.72);
322
+ border-radius: 8px; overflow: hidden;
323
+ }
324
+ .agent-tool summary {
325
+ display: flex; align-items: center; gap: 8px; cursor: pointer;
326
+ padding: 7px 9px; list-style: none; user-select: none;
327
+ }
328
+ .agent-tool summary::-webkit-details-marker { display: none; }
329
+ .agent-tool .tool-dot {
330
+ width: 7px; height: 7px; border-radius: 50%; background: #6e7681; flex: 0 0 auto;
331
+ }
332
+ .agent-tool.running .tool-dot { background: #58a6ff; box-shadow: 0 0 8px rgba(88,166,255,0.7); animation: pulse 1s ease-in-out infinite; }
333
+ .agent-tool.done .tool-dot { background: #3fb950; }
334
+ .agent-tool.error .tool-dot { background: #ff7b72; }
335
+ .agent-tool .tool-name { font: 12px ui-monospace, Menlo, monospace; color: #f0f6fc; }
336
+ .agent-tool .tool-state { margin-left: auto; color: #8b949e; font-size: 11px; }
337
+ .agent-tool.running .tool-state { color: #58a6ff; }
338
+ .agent-tool.done .tool-state { color: #3fb950; }
339
+ .agent-tool.error .tool-state { color: #ff7b72; }
340
+ .agent-tool-body {
341
+ border-top: 1px solid #30363d; padding: 8px 9px; display: grid;
342
+ gap: 8px; background: rgba(22,27,34,0.55);
343
+ }
344
+ .agent-tool-body label {
345
+ display: block; color: #6e7681; font-size: 10px; text-transform: uppercase;
346
+ margin-bottom: 4px; font-family: ui-monospace, Menlo, monospace;
347
+ }
348
+ .agent-tool-body pre {
349
+ margin: 0; max-height: 190px; overflow: auto; white-space: pre-wrap;
350
+ background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
351
+ padding: 8px; color: #c9d1d9; font: 11px/1.5 ui-monospace, Menlo, monospace;
352
+ }
353
+ .agent-composer {
354
+ display: grid; grid-template-columns: 1fr auto; gap: 8px;
355
+ padding: 12px 14px; background: rgba(22,27,34,0.96);
356
+ border-top: 1px solid #30363d;
357
+ }
358
+ .agent-composer textarea {
359
+ resize: none; min-height: 44px; max-height: 170px; background: #0d1117;
360
+ border: 1px solid #30363d; color: #f0f6fc; border-radius: 8px;
361
+ padding: 10px 12px; font: 13px/1.45 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; outline: none;
362
+ }
363
+ .agent-composer textarea:focus {
364
+ border-color: #58a6ff; box-shadow: 0 0 0 2px rgba(88,166,255,0.16);
365
+ }
366
+ .agent-composer button { min-width: 76px; font-weight: 650; }
367
+ .agent-composer button.streaming { border-color: #ff7b72; color: #ff7b72; }
368
+
369
+ /* Editor workspace:
123
370
  FILE TREE | editor-stack (editor TOP + terminal BOTTOM) | PREVIEW
124
371
 
125
- Editor + terminal live INSIDE .panel-left-stack. In 1D modes
126
- (terminal-only / split / preview-only) the stack is
127
- display:contents (layout-transparent — children flex directly
128
- into .main). In editor mode the stack flips to flex column so
129
- editor (top) + terminal (bottom) stack vertically. */
372
+ Editor, Agent, and terminal live inside .panel-left-stack. The top
373
+ surface switches between the editor and chat while the terminal and
374
+ preview stay visible. */
130
375
 
131
- /* Editor panel — hidden by default; shown by mode-specific rules. */
376
+ /* Editor panel — hidden by default; shown by workspace rules. */
132
377
  .panel-editor {
133
378
  flex: 1; display: none; flex-direction: column;
134
379
  background: #1e1e1e; /* VSCode dark editor background */
@@ -136,12 +381,11 @@
136
381
  }
137
382
  .panel-editor.active { display: flex; }
138
383
 
139
- /* Wrapper for the editor-stack column. display:contents in 1D
140
- modes (transparent). flex column in editor mode. */
384
+ /* Wrapper for the center workspace column. */
141
385
  .panel-left-stack { display: contents; }
142
386
 
143
387
  /* File-tree sidebar. Lives as a FIRST child of
144
- .main; visible only in 'editor' mode. ~250px default width,
388
+ .main; visible in the editor workspace. ~250px default width,
145
389
  resizable via .tree-resize-handle. */
146
390
  .panel-tree {
147
391
  display: none; flex-direction: column;
@@ -241,6 +485,11 @@
241
485
  .main.editor .panel-terminal {
242
486
  display: flex; flex: 0 0 40%; min-height: 80px;
243
487
  }
488
+ .main.editor .panel-agent { display: none; }
489
+ .main.editor .panel-left-stack.agent-surface .panel-editor { display: none; }
490
+ .main.editor .panel-left-stack.agent-surface .panel-agent {
491
+ display: flex; flex: 1 1 60%; min-height: 100px;
492
+ }
244
493
  .main.editor .vresize-handle { display: block; }
245
494
  .main.editor .resize-handle { display: block; }
246
495
  .main.editor .panel-preview { display: flex; flex: 1; min-width: 0; }
@@ -291,13 +540,9 @@
291
540
  </div>
292
541
  </div>
293
542
  <div class="toolbar" id="toolbar">
294
- <button id="btnTerminal" class="active" title="Terminal">Terminal</button>
295
- <button id="btnPreview" title="Preview">Preview</button>
296
- <button id="btnSplit" title="Split view">Split</button>
297
- <!-- Single canonical editor mode:
298
- file tree | editor (top) + terminal (bottom) | preview.
299
- Lazy-loads Monaco + the tree on first click. -->
300
- <button id="btnEditor" title="Editor (file tree + Monaco + terminal + preview)">Editor</button>
543
+ <!-- Single workspace: file tree | editor/agent + terminal | preview. -->
544
+ <button id="btnEditor" class="active" title="Editor">Editor</button>
545
+ <button id="btnAgent" title="Agent chat">Agent</button>
301
546
  <div class="sep"></div>
302
547
  <button id="btnProcs" title="Toggle process logs panel">
303
548
  <span class="procs-dot"></span>
@@ -308,9 +553,8 @@
308
553
  <span class="vite-status" id="viteStatus"></span>
309
554
  </div>
310
555
  <div class="main" id="mainPanel">
311
- <!-- File-tree sidebar. Visible ONLY
312
- in editor mode (CSS toggles display:flex on .main.editor).
313
- The .tree-resize-handle sits between tree and editor stack;
556
+ <!-- File-tree sidebar. Visible in the workspace.
557
+ The .tree-resize-handle sits between tree and the center stack;
314
558
  dragging it adjusts .panel-tree's width inline. -->
315
559
  <div class="panel-tree" id="treePanel">
316
560
  <div class="tree-toolbar">
@@ -325,11 +569,7 @@
325
569
  <div class="tree-body" id="treeBody"></div>
326
570
  </div>
327
571
  <div class="tree-resize-handle" id="treeResizeHandle"></div>
328
- <!-- The .panel-left-stack wrapper is display:contents in 1D modes
329
- (terminal-only, split, preview-only) — its children act as
330
- direct flex children of .main. In editor mode, CSS flips it to
331
- display:flex so editor and terminal stack vertically in the middle
332
- column. -->
572
+ <!-- The .panel-left-stack wrapper is the middle workspace column. -->
333
573
  <div class="panel-left-stack" id="leftStack">
334
574
  <div class="panel-editor" id="editorPanel">
335
575
  <div class="editor-bar">
@@ -352,9 +592,29 @@
352
592
  <span id="editorStatusEnc">UTF-8</span>
353
593
  </div>
354
594
  </div>
355
- <!-- Vertical resize handle between
356
- editor (top) and terminal (bottom) inside the editor-stack
357
- column. Visible only in editor mode (.main.editor). -->
595
+ <div class="panel-agent" id="agentPanel">
596
+ <div class="agent-shell">
597
+ <div class="agent-top">
598
+ <div class="agent-heading">
599
+ <div class="agent-title">Nimbus Agent</div>
600
+ <div class="agent-subtitle" id="agentSubtitle">Workspace operator</div>
601
+ </div>
602
+ <select id="agentAccount" title="Cloudflare account" style="display:none"></select>
603
+ <button id="agentConnect" style="display:none">Connect Cloudflare</button>
604
+ <button id="agentDisconnect" class="agent-secondary" style="display:none">Disconnect</button>
605
+ <button id="agentClear" class="agent-secondary" title="Clear chat">Clear</button>
606
+ <div class="agent-status" id="agentStatus">Checking...</div>
607
+ </div>
608
+ <div class="agent-messages" id="agentMessages">
609
+ <div class="agent-empty" id="agentEmpty">Cloudflare connection required.</div>
610
+ </div>
611
+ <form class="agent-composer" id="agentForm">
612
+ <textarea id="agentInput" rows="2" placeholder="Message Nimbus"></textarea>
613
+ <button id="agentSend" type="submit">Send</button>
614
+ </form>
615
+ </div>
616
+ </div>
617
+ <!-- Vertical resize handle between the top surface and terminal. -->
358
618
  <div class="vresize-handle" id="editorTerminalResizeHandle"></div>
359
619
  <div class="panel-terminal">
360
620
  <div id="terminal-container"></div>
@@ -367,11 +627,14 @@
367
627
  <div class="resize-handle" id="resizeHandle"></div>
368
628
  <div class="panel-preview" id="previewPanel">
369
629
  <div class="preview-bar">
370
- <span style="color:#3fb950;font-size:12px">&#9679;</span>
630
+ <div class="preview-tabs" id="previewTabs" aria-label="Preview tabs"></div>
371
631
  <input class="url-bar" id="urlBar" value="" readonly>
372
632
  <button id="btnReloadPreview" title="Reload">&#8635;</button>
373
633
  </div>
374
634
  <iframe id="preview-frame" src="about:blank"></iframe>
635
+ <div id="markdown-preview" aria-label="Markdown preview">
636
+ <article class="markdown-body" id="markdown-preview-body"></article>
637
+ </div>
375
638
  </div>
376
639
  </div>
377
640
  <!-- Ctrl+P command palette. Lazy-populated fuzzy file finder; fs-list
@@ -409,6 +672,11 @@
409
672
  const STATS_URL = SESSION_PREFIX + '/api/stats';
410
673
  const VITE_URL = SESSION_PREFIX + '/preview/';
411
674
  const WORKER_URL = SESSION_PREFIX + '/worker/';
675
+ const AGENT_STATUS_URL = SESSION_PREFIX + '/api/agent/status';
676
+ const AGENT_MESSAGES_URL = SESSION_PREFIX + '/api/agent/messages';
677
+ const AGENT_OAUTH_START_URL = SESSION_PREFIX + '/api/agent/oauth/start';
678
+ const AGENT_OAUTH_LOGOUT_URL = SESSION_PREFIX + '/api/agent/oauth/logout';
679
+ const AGENT_ACCOUNT_URL = SESSION_PREFIX + '/api/agent/account';
412
680
  // The iframe starts pointing at /preview/ which handles its own
413
681
  // "no dev server running" placeholder. When the user spawns a
414
682
  // wrangler dev (via `wrangler dev` in the terminal), stats polling
@@ -475,63 +743,67 @@
475
743
  const stText = document.getElementById('statusText');
476
744
  const previewFrame = document.getElementById('preview-frame');
477
745
  const previewPanel = document.getElementById('previewPanel');
746
+ const previewTabsEl = document.getElementById('previewTabs');
747
+ const markdownPreview = document.getElementById('markdown-preview');
748
+ const markdownPreviewBody = document.getElementById('markdown-preview-body');
478
749
  const mainPanel = document.getElementById('mainPanel');
479
750
  const viteStatus = document.getElementById('viteStatus');
480
751
  let ws = null, rd = 1000, statsTimer = null;
481
- // Current layout modes. Editor mode keeps the file tree, Monaco,
482
- // terminal, and preview visible together.
483
- let layout = 'split';
752
+ // The app has one workspace layout. Only the top center surface switches.
753
+ let layout = 'editor';
754
+ let workSurface = 'editor';
484
755
 
485
756
  // ── Layout controls ──
486
757
  function setLayout(mode) {
487
- layout = mode;
488
- mainPanel.className = 'main' + (mode !== 'split' ? ' ' + mode : '');
489
- document.getElementById('btnTerminal').classList.toggle('active', mode === 'terminal-only');
490
- document.getElementById('btnPreview').classList.toggle('active', mode === 'preview-only');
491
- document.getElementById('btnSplit').classList.toggle('active', mode === 'split');
492
- document.getElementById('btnEditor').classList.toggle('active', mode === 'editor');
493
- // Editor panel visibility — drives display:flex via .active.
758
+ workSurface = mode === 'agent' ? 'agent' : 'editor';
759
+ layout = 'editor';
760
+ mainPanel.className = 'main editor';
761
+ const leftStack = document.getElementById('leftStack');
762
+ leftStack.classList.toggle('agent-surface', workSurface === 'agent');
763
+ document.getElementById('btnEditor').classList.toggle('active', workSurface === 'editor');
764
+ document.getElementById('btnAgent').classList.toggle('active', workSurface === 'agent');
765
+ // Editor panel visibility — drives display:flex via .active unless
766
+ // the agent surface is currently selected.
494
767
  const editorPanel = document.getElementById('editorPanel');
495
- const wantEditor = (mode === 'editor');
768
+ const wantEditor = (workSurface === 'editor');
496
769
  editorPanel.classList.toggle('active', wantEditor);
497
- // Lazy-init Monaco + the file tree the FIRST time the user
498
- // switches to editor mode. Cold session pays neither cost.
770
+ FileTree.ensureLoaded().catch(() => {});
771
+ // Lazy-init Monaco the first time the editor surface is selected.
499
772
  if (wantEditor) {
500
- Editor.ensureLoaded();
501
- FileTree.ensureLoaded();
773
+ Editor.ensureLoaded()
774
+ .then(() => Editor.openDefaultWelcome())
775
+ .catch(() => {});
776
+ }
777
+ if (workSurface === 'agent') {
778
+ Agent.ensureLoaded().catch(() => {});
502
779
  }
503
780
  setTimeout(() => {
504
- // Re-apply persisted pane dims for this layout.
505
- // PaneResizer uses inline flex which is mode-specific (split vs
506
- // editor have different pane structures); onLayoutChange picks
507
- // the right elements for the active mode.
781
+ // Re-apply persisted pane dims for this workspace.
508
782
  try { if (typeof PaneResizer !== 'undefined') PaneResizer.onLayoutChange(); } catch {}
509
783
  try { fitAddon.fit(); } catch {}
510
784
  // Re-layout Monaco after the panel resizes. automaticLayout:true
511
- // already handles this but explicit layout() on mode switch
785
+ // already handles this but explicit layout() on surface switch
512
786
  // avoids a one-frame flash where the editor sees its old size.
513
787
  if (window.__nimbusMonacoEditor) {
514
788
  try { window.__nimbusMonacoEditor.layout(); } catch {}
515
789
  }
516
790
  }, 50);
517
791
  }
518
- document.getElementById('btnTerminal').onclick = () => setLayout('terminal-only');
519
- document.getElementById('btnPreview').onclick = () => setLayout('preview-only');
520
- document.getElementById('btnSplit').onclick = () => setLayout('split');
521
792
  document.getElementById('btnEditor').onclick = () => setLayout('editor');
793
+ document.getElementById('btnAgent').onclick = () => setLayout('agent');
522
794
  document.getElementById('btnRefresh').onclick = () => reloadPreview();
523
795
  document.getElementById('btnReloadPreview').onclick = () => reloadPreview();
524
796
 
525
797
  // ── Editor module ──
526
798
  //
527
799
  // Lazy-loads Monaco via cdnjs UMD on first call to ensureLoaded().
528
- // Cold session (terminal-only flow) never pays the ~5MB download.
529
800
  //
530
801
  // Owns the open file, dirty state, Ctrl+P palette, Ctrl+S save.
531
802
  // All fs operations route through the live WS via fs-read /
532
803
  // fs-write / fs-list messages → init.ts handler → SqliteVFS.
533
804
  //
534
- // Perf-invariant: Monaco is not loaded on cold terminal-only sessions.
805
+ // Perf-invariant: Monaco is loaded by runtime appending, not by an eager
806
+ // <script src> in the served HTML.
535
807
  const Editor = (function() {
536
808
  // CDN choice: cdnjs hosts Monaco's UMD AMD-loader (`loader.min.js`)
537
809
  // which fetches the remaining vs/* chunks on demand. Total cold
@@ -550,11 +822,13 @@
550
822
  // race on the same path.
551
823
  const pending = new Map();
552
824
  let nextReqId = 1;
825
+ const DEFAULT_WELCOME_PATH = '/home/user/welcome.md';
553
826
 
554
827
  // Current open file state.
555
828
  let openPath = null; // POSIX absolute path or null
556
829
  let originalContent = ''; // last-saved content (for dirty check)
557
830
  let editor = null; // Monaco editor instance
831
+ let markdownUpdateTimer = null;
558
832
 
559
833
  function tabEl() { return document.getElementById('editorTab'); }
560
834
  function setTabFile(path, dirty) {
@@ -668,6 +942,20 @@
668
942
  return map[ext] || 'plaintext';
669
943
  }
670
944
 
945
+ function isMarkdownPath(path) {
946
+ return /\.(md|markdown|mdown|mkdn)$/i.test(path || '');
947
+ }
948
+
949
+ function scheduleMarkdownPreviewUpdate(content) {
950
+ if (!openPath || !isMarkdownPath(openPath)) return;
951
+ if (markdownUpdateTimer) clearTimeout(markdownUpdateTimer);
952
+ markdownUpdateTimer = setTimeout(() => {
953
+ showMarkdownPreview(openPath, content).catch((e) => {
954
+ setStatusFlash('markdown preview failed: ' + (e?.message || e));
955
+ });
956
+ }, 160);
957
+ }
958
+
671
959
  function ensureLoaded() {
672
960
  if (loaded) return Promise.resolve();
673
961
  if (loading) return loading;
@@ -803,6 +1091,9 @@
803
1091
  if (typeof FileTree !== 'undefined') {
804
1092
  try { FileTree.markDirty(openPath, dirty); } catch {}
805
1093
  }
1094
+ if (isMarkdownPath(openPath)) {
1095
+ scheduleMarkdownPreviewUpdate(cur);
1096
+ }
806
1097
  });
807
1098
  // Cursor position → status bar.
808
1099
  const statusPos = document.getElementById('editorStatusPos');
@@ -823,7 +1114,7 @@
823
1114
  setTimeout(() => editor.layout(), 0);
824
1115
  }
825
1116
 
826
- async function openFile(path) {
1117
+ async function openFile(path, options = {}) {
827
1118
  if (!path) return;
828
1119
  // Dirty-prompt before switching files. If the current buffer has
829
1120
  // unsaved changes, ask the user whether to save or discard.
@@ -880,6 +1171,13 @@
880
1171
  if (typeof FileTree !== 'undefined') {
881
1172
  try { FileTree.setSelected(path); FileTree.markDirty(path, false); } catch {}
882
1173
  }
1174
+ if (isMarkdownPath(path)) {
1175
+ showMarkdownPreview(path, content).catch((e) => {
1176
+ setStatusFlash('markdown preview failed: ' + (e?.message || e));
1177
+ });
1178
+ } else if (!options.keepMarkdownPreview) {
1179
+ showAppPreview();
1180
+ }
883
1181
  editor.focus();
884
1182
  }
885
1183
 
@@ -1027,7 +1325,21 @@
1027
1325
  fileListCachedAt = 0;
1028
1326
  }
1029
1327
 
1030
- return { ensureLoaded, openFile, save, openPalette, tryHandleFsResult, drainFsQueue, invalidateFileListCache };
1328
+ function openDefaultWelcome() {
1329
+ if (openPath) return Promise.resolve();
1330
+ return openFile(DEFAULT_WELCOME_PATH);
1331
+ }
1332
+
1333
+ function refreshMarkdownPreview() {
1334
+ if (!openPath || !editor || !isMarkdownPath(openPath)) return Promise.resolve();
1335
+ return showMarkdownPreview(openPath, editor.getValue());
1336
+ }
1337
+
1338
+ return {
1339
+ ensureLoaded, openFile, save, openPalette, tryHandleFsResult,
1340
+ drainFsQueue, invalidateFileListCache, openDefaultWelcome,
1341
+ refreshMarkdownPreview,
1342
+ };
1031
1343
  })();
1032
1344
 
1033
1345
  // ── FileTree module ──
@@ -1593,56 +1905,947 @@
1593
1905
  };
1594
1906
  })();
1595
1907
 
1596
- // Which preview backend the iframe currently points at. Driven by
1597
- // /api/stats polling: wrangler.running > vite.running > placeholder.
1598
- // Tracked separately from iframe.src so user reloads / hash changes
1599
- // don't confuse the switcher. Starts as null so the first
1600
- // applyPreviewTarget call always paints (even if nothing is running
1601
- // yet — that still wants the VITE_URL placeholder loaded).
1602
- let activePreview = null; // null | 'vite' | 'worker' | 'none'
1908
+ // ── Agent chat module ─────────────────────────────────────────────
1909
+ const Agent = (function() {
1910
+ const statusEl = document.getElementById('agentStatus');
1911
+ const subtitleEl = document.getElementById('agentSubtitle');
1912
+ const messagesEl = document.getElementById('agentMessages');
1913
+ const emptyEl = document.getElementById('agentEmpty');
1914
+ const form = document.getElementById('agentForm');
1915
+ const input = document.getElementById('agentInput');
1916
+ const sendBtn = document.getElementById('agentSend');
1917
+ const connectBtn = document.getElementById('agentConnect');
1918
+ const disconnectBtn = document.getElementById('agentDisconnect');
1919
+ const clearBtn = document.getElementById('agentClear');
1920
+ const accountSelect = document.getElementById('agentAccount');
1921
+
1922
+ let loaded = false;
1923
+ let status = null;
1924
+ let messages = [];
1925
+ let busy = false;
1926
+ let currentAbort = null;
1927
+ let oauthPoll = null;
1928
+ let renderSeq = 0;
1929
+
1930
+ function setAgentStatus(text, tone) {
1931
+ statusEl.textContent = text;
1932
+ statusEl.className = 'agent-status' + (tone ? ' ' + tone : '');
1933
+ }
1934
+
1935
+ function canStartChat() {
1936
+ return !!status && status.configured && status.connected;
1937
+ }
1938
+
1939
+ function applyControls() {
1940
+ const oauth = status?.oauth || {};
1941
+ const owner = status?.ownerToken || {};
1942
+ const connected = !!status?.connected;
1943
+ const canStart = canStartChat();
1944
+ connectBtn.style.display = oauth.configured && !oauth.connected ? 'inline-block' : 'none';
1945
+ disconnectBtn.style.display = oauth.connected ? 'inline-block' : 'none';
1946
+ accountSelect.style.display = oauth.connected && Array.isArray(oauth.accounts) && oauth.accounts.length > 1
1947
+ ? 'inline-block'
1948
+ : 'none';
1949
+ input.disabled = !canStart || busy;
1950
+ sendBtn.disabled = !canStart || (!busy && !input.value.trim());
1951
+ sendBtn.textContent = busy ? 'Stop' : 'Send';
1952
+ sendBtn.classList.toggle('streaming', busy);
1953
+ if (!status) {
1954
+ emptyEl.textContent = 'Cloudflare connection required.';
1955
+ subtitleEl.textContent = 'Workspace operator';
1956
+ setAgentStatus('Checking...', '');
1957
+ } else if (!status.configured) {
1958
+ emptyEl.textContent = 'Configure Cloudflare OAuth or an owner API token to enable chat.';
1959
+ subtitleEl.textContent = 'Cloudflare Workers AI';
1960
+ setAgentStatus('AI not configured', 'warn');
1961
+ } else if (connected) {
1962
+ const mode = oauth.connected ? 'Cloudflare connected' : owner.configured ? 'Owner token' : 'Ready';
1963
+ emptyEl.textContent = 'Ask Nimbus to inspect or change this session.';
1964
+ subtitleEl.textContent = 'Shell · files · runtimes · previews';
1965
+ setAgentStatus((busy ? 'Streaming' : mode) + ' · ' + status.model, busy ? 'streaming' : 'ready');
1966
+ } else {
1967
+ emptyEl.textContent = 'Connect Cloudflare to use Workers AI.';
1968
+ subtitleEl.textContent = 'Use your Cloudflare quota';
1969
+ setAgentStatus('Connect Cloudflare', 'warn');
1970
+ }
1971
+ }
1972
+
1973
+ function renderAccounts() {
1974
+ const oauth = status?.oauth || {};
1975
+ const accounts = Array.isArray(oauth.accounts) ? oauth.accounts : [];
1976
+ accountSelect.replaceChildren();
1977
+ for (const account of accounts) {
1978
+ const opt = document.createElement('option');
1979
+ opt.value = account.id;
1980
+ opt.textContent = account.name || account.id;
1981
+ opt.selected = account.id === oauth.accountId;
1982
+ accountSelect.appendChild(opt);
1983
+ }
1984
+ }
1985
+
1986
+ function roleLabel(role, name) {
1987
+ if (role === 'tool') return name || 'tool';
1988
+ return role === 'assistant' ? 'Nimbus' : 'You';
1989
+ }
1990
+
1991
+ function formatTime(ts) {
1992
+ const date = new Date(ts || Date.now());
1993
+ if (Number.isNaN(date.getTime())) return '';
1994
+ return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
1995
+ }
1996
+
1997
+ function renderMessages(list = messages) {
1998
+ renderSeq++;
1999
+ const seq = renderSeq;
2000
+ const displayList = normalizeTranscriptForRender(list);
2001
+ messagesEl.replaceChildren();
2002
+ if (!displayList || displayList.length === 0) {
2003
+ messagesEl.appendChild(emptyEl);
2004
+ return;
2005
+ }
2006
+ for (const msg of displayList) {
2007
+ messagesEl.appendChild(renderMessage(msg, seq));
2008
+ }
2009
+ messagesEl.scrollTop = messagesEl.scrollHeight;
2010
+ }
2011
+
2012
+ function normalizeTranscriptForRender(list) {
2013
+ if (!Array.isArray(list)) return [];
2014
+ const out = [];
2015
+ let pendingTools = [];
2016
+ const flushPendingTools = () => {
2017
+ for (const tool of pendingTools) {
2018
+ out.push({ id: tool.toolCallId, role: 'assistant', content: '', createdAt: Date.now(), parts: [tool] });
2019
+ }
2020
+ pendingTools = [];
2021
+ };
2022
+ for (const msg of list) {
2023
+ if (msg?.role === 'tool') {
2024
+ pendingTools.push(legacyToolPart(msg));
2025
+ continue;
2026
+ }
2027
+ if (msg?.role === 'assistant' && pendingTools.length > 0) {
2028
+ const next = { ...msg, parts: [...pendingTools, ...normalizeTurnParts(msg)] };
2029
+ pendingTools = [];
2030
+ out.push(next);
2031
+ continue;
2032
+ }
2033
+ flushPendingTools();
2034
+ out.push(msg);
2035
+ }
2036
+ flushPendingTools();
2037
+ return out;
2038
+ }
2039
+
2040
+ function renderMessage(msg, seq) {
2041
+ if (msg.role === 'tool') return renderStoredToolMessage(msg);
2042
+
2043
+ const row = document.createElement('div');
2044
+ row.className = 'agent-msg ' + msg.role;
2045
+ const avatar = document.createElement('div');
2046
+ avatar.className = 'agent-avatar';
2047
+ avatar.textContent = msg.role === 'assistant' ? 'N' : '';
2048
+ const content = document.createElement('div');
2049
+ content.className = 'agent-content';
2050
+
2051
+ const meta = document.createElement('div');
2052
+ meta.className = 'agent-meta';
2053
+ meta.textContent = roleLabel(msg.role, msg.name) + (msg.createdAt ? ' · ' + formatTime(msg.createdAt) : '');
2054
+
2055
+ const isStreaming = !!msg.streaming;
2056
+
2057
+ if (msg.role === 'assistant') content.appendChild(meta);
2058
+
2059
+ if (msg.role === 'assistant') {
2060
+ const parts = normalizeTurnParts(msg);
2061
+ if (msg.error) {
2062
+ const bubble = document.createElement('div');
2063
+ bubble.className = 'agent-bubble';
2064
+ bubble.textContent = msg.error;
2065
+ content.appendChild(bubble);
2066
+ } else if (parts.length > 0) {
2067
+ renderTurnParts(content, parts, { streaming: isStreaming, seq });
2068
+ } else if (isStreaming) {
2069
+ content.appendChild(renderThinking('Thinking'));
2070
+ }
2071
+ } else if (msg.content || msg.error) {
2072
+ const bubble = document.createElement('div');
2073
+ bubble.className = 'agent-bubble';
2074
+ if (msg.error) {
2075
+ bubble.textContent = msg.error;
2076
+ } else {
2077
+ bubble.textContent = String(msg.content || '');
2078
+ }
2079
+ content.appendChild(bubble);
2080
+ }
2081
+
2082
+ if (msg.role === 'user') {
2083
+ content.appendChild(meta);
2084
+ row.append(content);
2085
+ } else {
2086
+ row.append(avatar, content);
2087
+ }
2088
+ return row;
2089
+ }
2090
+
2091
+ function normalizeTurnParts(msg) {
2092
+ if (Array.isArray(msg.parts)) return msg.parts.filter((part) => part && typeof part === 'object');
2093
+ const parts = [];
2094
+ if (msg.reasoning) parts.push({ type: 'reasoning', text: String(msg.reasoning) });
2095
+ if (Array.isArray(msg.tools)) {
2096
+ for (const tool of msg.tools) parts.push({ type: 'tool', ...tool });
2097
+ }
2098
+ if (msg.content) parts.push({ type: 'text', text: String(msg.content) });
2099
+ return parts;
2100
+ }
2101
+
2102
+ function renderTurnParts(content, parts, opts = {}) {
2103
+ const lastTextIndex = findLastTextPartIndex(parts);
2104
+ parts.forEach((part, index) => {
2105
+ if (part.type === 'reasoning') {
2106
+ if (part.text) content.appendChild(renderReasoning(part.text));
2107
+ } else if (part.type === 'tool') {
2108
+ content.appendChild(renderToolCard(part));
2109
+ } else if (part.type === 'text' && part.text) {
2110
+ const bubble = document.createElement('div');
2111
+ bubble.className = 'agent-bubble';
2112
+ renderMarkdownInto(bubble, part.text, {
2113
+ cursor: !!opts.streaming && index === lastTextIndex,
2114
+ seq: opts.seq,
2115
+ });
2116
+ content.appendChild(bubble);
2117
+ }
2118
+ });
2119
+ if (opts.streaming && (parts.length === 0 || shouldShowPostPartThinking(parts))) {
2120
+ content.appendChild(renderThinking('Thinking'));
2121
+ }
2122
+ }
2123
+
2124
+ function findLastTextPartIndex(parts) {
2125
+ for (let i = parts.length - 1; i >= 0; i--) {
2126
+ if (parts[i]?.type === 'text' && parts[i].text) return i;
2127
+ }
2128
+ return -1;
2129
+ }
2130
+
2131
+ function shouldShowPostPartThinking(parts) {
2132
+ const last = parts[parts.length - 1];
2133
+ return !!last && last.type === 'tool' && last.status !== 'running';
2134
+ }
2135
+
2136
+ function renderStoredToolMessage(msg) {
2137
+ const row = document.createElement('div');
2138
+ row.className = 'agent-msg assistant';
2139
+ const avatar = document.createElement('div');
2140
+ avatar.className = 'agent-avatar';
2141
+ avatar.textContent = 'T';
2142
+ const content = document.createElement('div');
2143
+ content.className = 'agent-content';
2144
+ content.appendChild(renderToolCard(legacyToolPart(msg)));
2145
+ row.append(avatar, content);
2146
+ return row;
2147
+ }
2148
+
2149
+ function legacyToolPart(msg) {
2150
+ const payload = parseToolPayload(msg);
2151
+ const output = payload.output ?? msg.content;
2152
+ return {
2153
+ type: 'tool',
2154
+ toolCallId: msg.id,
2155
+ toolName: payload.tool || msg.name || 'tool',
2156
+ input: payload.input,
2157
+ output,
2158
+ status: isToolOutputFailure(output) ? 'error' : 'done',
2159
+ durationMs: payload.durationMs,
2160
+ };
2161
+ }
2162
+
2163
+ function parseToolPayload(msg) {
2164
+ try {
2165
+ const parsed = JSON.parse(msg.content || '{}');
2166
+ if (parsed && typeof parsed === 'object') return parsed;
2167
+ } catch {}
2168
+ return { tool: msg.name || 'tool', output: msg.content || '' };
2169
+ }
2170
+
2171
+ function renderThinking(label) {
2172
+ const wrap = document.createElement('div');
2173
+ wrap.className = 'agent-thinking';
2174
+ const dots = document.createElement('span');
2175
+ dots.className = 'dots';
2176
+ dots.append(document.createElement('span'), document.createElement('span'), document.createElement('span'));
2177
+ const text = document.createElement('span');
2178
+ text.textContent = label;
2179
+ wrap.append(dots, text);
2180
+ return wrap;
2181
+ }
2182
+
2183
+ function renderReasoning(text) {
2184
+ const details = document.createElement('details');
2185
+ details.className = 'agent-reasoning';
2186
+ const summary = document.createElement('summary');
2187
+ summary.textContent = 'Thinking';
2188
+ const pre = document.createElement('pre');
2189
+ pre.textContent = text;
2190
+ details.append(summary, pre);
2191
+ return details;
2192
+ }
2193
+
2194
+ function renderToolCard(tool) {
2195
+ const status = tool.status || 'done';
2196
+ const details = document.createElement('details');
2197
+ details.className = 'agent-tool ' + status;
2198
+ if (status === 'running' || status === 'error') details.open = true;
2199
+
2200
+ const summary = document.createElement('summary');
2201
+ const dot = document.createElement('span');
2202
+ dot.className = 'tool-dot';
2203
+ const name = document.createElement('span');
2204
+ name.className = 'tool-name';
2205
+ name.textContent = tool.toolName || 'tool';
2206
+ const state = document.createElement('span');
2207
+ state.className = 'tool-state';
2208
+ state.textContent = toolStateLabel(tool);
2209
+ summary.append(dot, name, state);
2210
+
2211
+ const body = document.createElement('div');
2212
+ body.className = 'agent-tool-body';
2213
+ if (tool.input !== undefined) body.appendChild(renderToolSection('Input', tool.input));
2214
+ if (tool.output !== undefined) body.appendChild(renderToolSection(status === 'error' ? 'Error' : 'Output', tool.output));
2215
+ if (tool.error) body.appendChild(renderToolSection('Error', tool.error));
2216
+ details.append(summary, body);
2217
+ return details;
2218
+ }
2219
+
2220
+ function toolStateLabel(tool) {
2221
+ if (tool.status === 'running') return 'running';
2222
+ if (tool.status === 'error') return tool.durationMs ? 'failed · ' + formatDuration(tool.durationMs) : 'failed';
2223
+ return tool.durationMs ? 'done · ' + formatDuration(tool.durationMs) : 'done';
2224
+ }
2225
+
2226
+ function formatDuration(ms) {
2227
+ if (!Number.isFinite(ms) || ms < 100) return '<0.1s';
2228
+ return (ms / 1000).toFixed(1) + 's';
2229
+ }
2230
+
2231
+ function renderToolSection(label, value) {
2232
+ const section = document.createElement('div');
2233
+ const title = document.createElement('label');
2234
+ title.textContent = label;
2235
+ const pre = document.createElement('pre');
2236
+ pre.textContent = prettyValue(value);
2237
+ section.append(title, pre);
2238
+ return section;
2239
+ }
2240
+
2241
+ function prettyValue(value) {
2242
+ if (typeof value === 'string') return value;
2243
+ try { return JSON.stringify(value, null, 2); } catch { return String(value); }
2244
+ }
2245
+
2246
+ function isToolOutputFailure(output) {
2247
+ if (!output || typeof output !== 'object') return false;
2248
+ if (typeof output.error === 'string' && output.error.trim()) return true;
2249
+ if (output.success === false) return true;
2250
+ if (typeof output.exitCode === 'number' && output.exitCode !== 0) return true;
2251
+ if (output.exit && typeof output.exit === 'object' && typeof output.exit.code === 'number') {
2252
+ return output.exit.code !== 0;
2253
+ }
2254
+ return false;
2255
+ }
2256
+
2257
+ function renderMarkdownInto(el, text, opts = {}) {
2258
+ const token = String((opts.seq || renderSeq) + ':' + text.length + ':' + (opts.cursor ? '1' : '0'));
2259
+ el.dataset.renderToken = token;
2260
+ el.textContent = text;
2261
+ renderMarkdown(text).then((html) => {
2262
+ if (el.dataset.renderToken !== token) return;
2263
+ el.innerHTML = html;
2264
+ if (opts.cursor) {
2265
+ const cursor = document.createElement('span');
2266
+ cursor.className = 'agent-cursor';
2267
+ el.appendChild(cursor);
2268
+ }
2269
+ }).catch(() => {
2270
+ if (opts.cursor) {
2271
+ const cursor = document.createElement('span');
2272
+ cursor.className = 'agent-cursor';
2273
+ el.appendChild(cursor);
2274
+ }
2275
+ });
2276
+ }
2277
+
2278
+ async function refreshStatus() {
2279
+ const r = await fetch(AGENT_STATUS_URL, { headers: { Accept: 'application/json' } });
2280
+ const payload = await r.json();
2281
+ if (!r.ok) throw new Error(payload.error || 'Agent status failed');
2282
+ status = payload;
2283
+ renderAccounts();
2284
+ applyControls();
2285
+ return status;
2286
+ }
2287
+
2288
+ async function loadMessages() {
2289
+ const r = await fetch(AGENT_MESSAGES_URL, { headers: { Accept: 'application/json' } });
2290
+ const payload = await r.json();
2291
+ if (!r.ok) throw new Error(payload.error || 'Agent messages failed');
2292
+ messages = Array.isArray(payload.messages) ? payload.messages : [];
2293
+ renderMessages();
2294
+ }
2295
+
2296
+ async function connectCloudflare() {
2297
+ connectBtn.disabled = true;
2298
+ try {
2299
+ const r = await fetch(AGENT_OAUTH_START_URL, { method: 'POST', headers: { Accept: 'application/json' } });
2300
+ const payload = await r.json();
2301
+ if (!r.ok || !payload.authUrl) {
2302
+ throw new Error(payload.error || 'OAuth start failed');
2303
+ }
2304
+ const popup = window.open(payload.authUrl, 'nimbus-agent-oauth', 'width=720,height=760');
2305
+ if (!popup) location.href = payload.authUrl;
2306
+ setAgentStatus('Waiting for Cloudflare...', 'warn');
2307
+ if (oauthPoll) clearInterval(oauthPoll);
2308
+ oauthPoll = setInterval(() => {
2309
+ if (!popup || popup.closed) {
2310
+ clearInterval(oauthPoll);
2311
+ oauthPoll = null;
2312
+ refreshStatus().catch((e) => setAgentStatus(e?.message || String(e), 'warn'));
2313
+ }
2314
+ }, 1200);
2315
+ } catch (e) {
2316
+ setAgentStatus(e?.message || String(e), 'warn');
2317
+ } finally {
2318
+ connectBtn.disabled = false;
2319
+ }
2320
+ }
2321
+
2322
+ async function selectAccount() {
2323
+ const accountId = accountSelect.value;
2324
+ if (!accountId) return;
2325
+ await fetch(AGENT_ACCOUNT_URL, {
2326
+ method: 'POST',
2327
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
2328
+ body: JSON.stringify({ accountId }),
2329
+ }).then(async (r) => {
2330
+ const payload = await r.json().catch(() => ({}));
2331
+ if (!r.ok) throw new Error(payload.error || 'Account switch failed');
2332
+ });
2333
+ await refreshStatus();
2334
+ }
2335
+
2336
+ async function disconnectCloudflare() {
2337
+ disconnectBtn.disabled = true;
2338
+ try {
2339
+ const r = await fetch(AGENT_OAUTH_LOGOUT_URL, { method: 'POST', headers: { Accept: 'application/json' } });
2340
+ const payload = await r.json().catch(() => ({}));
2341
+ if (!r.ok) throw new Error(payload.error || 'Disconnect failed');
2342
+ status = null;
2343
+ await refreshStatus();
2344
+ } catch (e) {
2345
+ setAgentStatus(e?.message || String(e), 'warn');
2346
+ } finally {
2347
+ disconnectBtn.disabled = false;
2348
+ }
2349
+ }
2350
+
2351
+ async function clearMessages() {
2352
+ const r = await fetch(AGENT_MESSAGES_URL, { method: 'DELETE', headers: { Accept: 'application/json' } });
2353
+ const payload = await r.json().catch(() => ({}));
2354
+ if (!r.ok) {
2355
+ setAgentStatus(payload.error || 'Clear failed', 'warn');
2356
+ return;
2357
+ }
2358
+ messages = [];
2359
+ renderMessages();
2360
+ }
2361
+
2362
+ async function sendMessage(text) {
2363
+ const content = String(text || '').trim();
2364
+ if (busy) {
2365
+ stopStreaming();
2366
+ return;
2367
+ }
2368
+ if (!content) return;
2369
+ busy = true;
2370
+ currentAbort = new AbortController();
2371
+ applyControls();
2372
+ input.value = '';
2373
+ const optimistic = {
2374
+ id: 'pending-user',
2375
+ role: 'user',
2376
+ content,
2377
+ createdAt: Date.now(),
2378
+ };
2379
+ const pending = {
2380
+ id: 'pending-assistant',
2381
+ role: 'assistant',
2382
+ content: '',
2383
+ createdAt: Date.now(),
2384
+ streaming: true,
2385
+ parts: [],
2386
+ };
2387
+ let liveList = [...messages, optimistic, pending];
2388
+ renderMessages(liveList);
2389
+ const repaint = () => {
2390
+ liveList = [...messages, pending];
2391
+ renderMessages(liveList);
2392
+ };
2393
+ try {
2394
+ const r = await fetch(AGENT_MESSAGES_URL, {
2395
+ method: 'POST',
2396
+ headers: { 'Content-Type': 'application/json', Accept: 'application/x-ndjson' },
2397
+ body: JSON.stringify({ message: content, stream: true }),
2398
+ signal: currentAbort.signal,
2399
+ });
2400
+ if (!r.ok || !r.body) {
2401
+ const payload = await r.json().catch(() => ({}));
2402
+ throw new Error(payload.error || 'Agent request failed');
2403
+ }
2404
+ await readAgentStream(r.body, pending, repaint);
2405
+ } catch (e) {
2406
+ if (e?.name !== 'AbortError') {
2407
+ pending.streaming = false;
2408
+ pending.error = e?.message || String(e);
2409
+ repaint();
2410
+ }
2411
+ } finally {
2412
+ busy = false;
2413
+ currentAbort = null;
2414
+ applyControls();
2415
+ input.focus();
2416
+ }
2417
+ }
2418
+
2419
+ function stopStreaming() {
2420
+ if (currentAbort) currentAbort.abort();
2421
+ }
2422
+
2423
+ async function readAgentStream(body, liveAssistant, repaint) {
2424
+ const reader = body.getReader();
2425
+ const decoder = new TextDecoder();
2426
+ let buffer = '';
2427
+ while (true) {
2428
+ const { value, done } = await reader.read();
2429
+ if (done) break;
2430
+ buffer += decoder.decode(value, { stream: true });
2431
+ let lineEnd = buffer.indexOf('\n');
2432
+ while (lineEnd >= 0) {
2433
+ const line = buffer.slice(0, lineEnd).trim();
2434
+ buffer = buffer.slice(lineEnd + 1);
2435
+ if (line) applyStreamEvent(JSON.parse(line), liveAssistant, repaint);
2436
+ lineEnd = buffer.indexOf('\n');
2437
+ }
2438
+ }
2439
+ const tail = buffer.trim();
2440
+ if (tail) applyStreamEvent(JSON.parse(tail), liveAssistant, repaint);
2441
+ }
2442
+
2443
+ function applyStreamEvent(event, liveAssistant, repaint) {
2444
+ if (!event || typeof event !== 'object') return;
2445
+ if (event.type === 'start') {
2446
+ if (Array.isArray(event.messages)) messages = event.messages;
2447
+ } else if (event.type === 'assistant-start') {
2448
+ liveAssistant.id = event.messageId || liveAssistant.id;
2449
+ liveAssistant.createdAt = event.createdAt || liveAssistant.createdAt;
2450
+ } else if (event.type === 'text-delta') {
2451
+ const delta = String(event.delta || '');
2452
+ liveAssistant.content += delta;
2453
+ appendLiveTextPart(liveAssistant, 'text', delta);
2454
+ } else if (event.type === 'reasoning-delta') {
2455
+ appendLiveTextPart(liveAssistant, 'reasoning', String(event.delta || ''));
2456
+ } else if (event.type === 'tool-call') {
2457
+ upsertLiveTool(liveAssistant, {
2458
+ toolCallId: event.toolCallId,
2459
+ toolName: event.toolName,
2460
+ input: event.input,
2461
+ status: 'running',
2462
+ startedAt: Date.now(),
2463
+ });
2464
+ } else if (event.type === 'tool-result' || event.type === 'tool-error') {
2465
+ const tool = upsertLiveTool(liveAssistant, {
2466
+ toolCallId: event.toolCallId,
2467
+ toolName: event.toolName,
2468
+ input: event.input,
2469
+ });
2470
+ tool.status = event.type === 'tool-error' ? 'error' : (event.status || 'done');
2471
+ tool.output = event.type === 'tool-error' ? { error: event.error } : event.output;
2472
+ tool.error = event.type === 'tool-error' ? event.error : '';
2473
+ if (tool.startedAt) tool.durationMs = Date.now() - tool.startedAt;
2474
+ } else if (event.type === 'done') {
2475
+ liveAssistant.streaming = false;
2476
+ if (Array.isArray(event.messages)) {
2477
+ messages = event.messages;
2478
+ renderMessages();
2479
+ return;
2480
+ }
2481
+ if (event.message) {
2482
+ messages = [...messages, event.message];
2483
+ }
2484
+ } else if (event.type === 'error') {
2485
+ liveAssistant.streaming = false;
2486
+ liveAssistant.error = event.error || 'Agent request failed';
2487
+ if (Array.isArray(event.messages)) messages = event.messages;
2488
+ setAgentStatus(liveAssistant.error, 'warn');
2489
+ }
2490
+ repaint();
2491
+ }
2492
+
2493
+ function appendLiveTextPart(liveAssistant, type, delta) {
2494
+ if (!delta) return;
2495
+ if (!Array.isArray(liveAssistant.parts)) liveAssistant.parts = [];
2496
+ const last = liveAssistant.parts[liveAssistant.parts.length - 1];
2497
+ if (last && last.type === type) {
2498
+ last.text = String(last.text || '') + delta;
2499
+ } else {
2500
+ liveAssistant.parts.push({ type, text: delta });
2501
+ }
2502
+ }
2503
+
2504
+ function upsertLiveTool(liveAssistant, patch) {
2505
+ if (!Array.isArray(liveAssistant.parts)) liveAssistant.parts = [];
2506
+ let tool = liveAssistant.parts.find((item) => item.type === 'tool' && item.toolCallId === patch.toolCallId);
2507
+ if (!tool) {
2508
+ tool = { type: 'tool', status: 'running' };
2509
+ liveAssistant.parts.push(tool);
2510
+ }
2511
+ Object.assign(tool, patch);
2512
+ return tool;
2513
+ }
2514
+
2515
+ function wire() {
2516
+ form.addEventListener('submit', (e) => {
2517
+ e.preventDefault();
2518
+ if (busy) stopStreaming();
2519
+ else sendMessage(input.value);
2520
+ });
2521
+ input.addEventListener('keydown', (e) => {
2522
+ if (e.key === 'Enter' && !e.shiftKey) {
2523
+ e.preventDefault();
2524
+ sendMessage(input.value);
2525
+ }
2526
+ });
2527
+ input.addEventListener('input', applyControls);
2528
+ connectBtn.addEventListener('click', connectCloudflare);
2529
+ disconnectBtn.addEventListener('click', disconnectCloudflare);
2530
+ clearBtn.addEventListener('click', clearMessages);
2531
+ accountSelect.addEventListener('change', selectAccount);
2532
+ window.addEventListener('message', (event) => {
2533
+ if (event.origin !== location.origin) return;
2534
+ if (event.data && event.data.type === 'nimbus-agent-oauth') {
2535
+ if (oauthPoll) {
2536
+ clearInterval(oauthPoll);
2537
+ oauthPoll = null;
2538
+ }
2539
+ refreshStatus().catch(() => {});
2540
+ }
2541
+ });
2542
+ }
2543
+
2544
+ async function ensureLoaded() {
2545
+ if (!loaded) {
2546
+ loaded = true;
2547
+ wire();
2548
+ }
2549
+ const results = await Promise.allSettled([refreshStatus(), loadMessages()]);
2550
+ if (results[0].status === 'rejected') {
2551
+ setAgentStatus(results[0].reason?.message || String(results[0].reason), 'warn');
2552
+ input.disabled = true;
2553
+ sendBtn.disabled = true;
2554
+ }
2555
+ if (results[1].status === 'rejected') renderMessages([]);
2556
+ }
2557
+
2558
+ const api = { ensureLoaded, refreshStatus, loadMessages };
2559
+ window.NimbusAgent = api;
2560
+ return api;
2561
+ })();
2562
+
2563
+ // Tabbed preview surface. Markdown preview and live app previews share
2564
+ // the right pane without fighting over a single iframe.
2565
+ let previewBackendKind = 'none'; // 'vite' | 'worker' | 'none'
2566
+ let activePreviewTabId = null;
2567
+ let markdownRenderSeq = 0;
2568
+ const previewTabs = new Map();
2569
+ let markdownRendererPromise = null;
2570
+ let markdownRenderer = null;
1603
2571
 
1604
2572
  function previewUrlFor(kind) {
1605
2573
  return kind === 'worker' ? WORKER_URL : VITE_URL;
1606
2574
  }
1607
2575
 
1608
- function applyPreviewTarget(kind) {
1609
- // Idempotent: only update iframe + URL bar when the backend kind
1610
- // actually changes. Avoids tearing down the inner app on every
1611
- // 5-second stats poll.
1612
- if (activePreview === kind) return;
1613
- activePreview = kind;
1614
- const url = previewUrlFor(kind);
1615
- // Always use the VITE_URL path when nothing is running — that
1616
- // endpoint serves the styled "no dev server" placeholder, which
1617
- // self-refreshes via its own polling loop.
1618
- previewFrame.src = url;
2576
+ function portPreviewUrl(port) {
2577
+ return SESSION_PREFIX + '/port/' + Number(port) + '/';
2578
+ }
2579
+
2580
+ function absolutePreviewUrl(url) {
2581
+ return new URL(url, location.origin).href;
2582
+ }
2583
+
2584
+ function shortPathLabel(path) {
2585
+ const parts = String(path || '').split('/').filter(Boolean);
2586
+ return parts[parts.length - 1] || 'Markdown';
2587
+ }
2588
+
2589
+ function appTabFor(kind) {
2590
+ const appKind = kind === 'worker' ? 'worker' : 'vite';
2591
+ return {
2592
+ id: 'app:' + appKind,
2593
+ type: 'app',
2594
+ kind: appKind,
2595
+ label: appKind === 'worker' ? 'Worker' : 'Preview',
2596
+ title: appKind === 'worker' ? 'Cloudflare Worker preview' : 'Vite preview',
2597
+ url: previewUrlFor(appKind),
2598
+ running: previewBackendKind === appKind,
2599
+ };
2600
+ }
2601
+
2602
+ function renderPreviewTabs() {
2603
+ if (!previewTabsEl) return;
2604
+ previewTabsEl.innerHTML = '';
2605
+ for (const tab of previewTabs.values()) {
2606
+ const button = document.createElement('button');
2607
+ button.type = 'button';
2608
+ button.className = [
2609
+ 'preview-tab',
2610
+ tab.type === 'markdown' ? 'markdown' : '',
2611
+ tab.running ? 'running' : '',
2612
+ tab.id === activePreviewTabId ? 'active' : '',
2613
+ ].filter(Boolean).join(' ');
2614
+ button.title = tab.title || tab.url || tab.path || tab.label;
2615
+ button.onclick = () => activatePreviewTab(tab.id);
2616
+
2617
+ const label = document.createElement('span');
2618
+ label.className = 'preview-tab-label';
2619
+ label.textContent = tab.label;
2620
+ button.appendChild(label);
2621
+
2622
+ previewTabsEl.appendChild(button);
2623
+ }
2624
+ }
2625
+
2626
+ function ensurePreviewTab(tab, options = {}) {
2627
+ const existing = previewTabs.get(tab.id);
2628
+ const merged = {
2629
+ ...(existing || {}),
2630
+ ...tab,
2631
+ createdAt: existing?.createdAt || Date.now(),
2632
+ updatedAt: Date.now(),
2633
+ };
2634
+ previewTabs.set(tab.id, merged);
2635
+ if (!existing && options.focusNew) {
2636
+ activatePreviewTab(tab.id);
2637
+ } else {
2638
+ renderPreviewTabs();
2639
+ }
2640
+ return merged;
2641
+ }
2642
+
2643
+ function firstAppTabId() {
2644
+ const port = [...previewTabs.values()].filter((tab) => tab.type === 'port').pop();
2645
+ if (port) return port.id;
2646
+ const preferred = previewBackendKind === 'worker' ? 'app:worker' : 'app:vite';
2647
+ if (previewTabs.has(preferred)) return preferred;
2648
+ if (previewTabs.has('app:vite')) return 'app:vite';
2649
+ if (previewTabs.has('app:worker')) return 'app:worker';
2650
+ return ensurePreviewTab(appTabFor('vite')).id;
2651
+ }
2652
+
2653
+ function activatePreviewTab(id) {
2654
+ const tab = previewTabs.get(id);
2655
+ if (!tab) return;
2656
+ activePreviewTabId = id;
2657
+ renderPreviewTabs();
2658
+ if (tab.type === 'markdown') {
2659
+ previewPanel.classList.add('markdown-mode');
2660
+ if (previewFrame) previewFrame.style.display = 'none';
2661
+ if (markdownPreview) markdownPreview.classList.add('active');
2662
+ setPreviewUrlBar('Markdown Preview · ' + tab.path);
2663
+ paintMarkdownPreview(tab.markdown || '').catch(() => {});
2664
+ return;
2665
+ }
2666
+
2667
+ previewPanel.classList.remove('markdown-mode');
2668
+ if (markdownPreview) markdownPreview.classList.remove('active');
2669
+ if (previewFrame) {
2670
+ previewFrame.style.display = 'block';
2671
+ const next = absolutePreviewUrl(tab.url);
2672
+ if (previewFrame.src !== next) previewFrame.src = tab.url;
2673
+ }
2674
+ setPreviewUrlBar(tab.url);
2675
+ }
2676
+
2677
+ function normalizePortStats(portStats) {
2678
+ const raw = Array.isArray(portStats?.ports)
2679
+ ? portStats.ports
2680
+ : Array.isArray(portStats)
2681
+ ? portStats
2682
+ : [];
2683
+ const seen = new Set();
2684
+ const ports = [];
2685
+ for (const entry of raw) {
2686
+ const port = Number(entry?.port);
2687
+ if (!Number.isInteger(port) || port <= 0 || seen.has(port)) continue;
2688
+ seen.add(port);
2689
+ ports.push({ port, pid: Number(entry?.pid) || 0 });
2690
+ }
2691
+ return ports;
2692
+ }
2693
+
2694
+ function syncPortPreviewTabs(portStats, canonicalPort, canonicalTabId) {
2695
+ const ports = normalizePortStats(portStats);
2696
+ const hiddenPort = Number.isInteger(canonicalPort) && canonicalPort > 0 ? canonicalPort : null;
2697
+ const visiblePorts = hiddenPort == null
2698
+ ? ports
2699
+ : ports.filter((p) => p.port !== hiddenPort);
2700
+ const live = new Set(visiblePorts.map((p) => 'port:' + p.port));
2701
+ let activeWasRemoved = false;
2702
+ let activeWasCanonicalAlias = false;
2703
+
2704
+ for (const [id, tab] of previewTabs) {
2705
+ if (tab.type === 'port' && !live.has(id)) {
2706
+ previewTabs.delete(id);
2707
+ if (activePreviewTabId === id) {
2708
+ activeWasRemoved = true;
2709
+ activeWasCanonicalAlias = hiddenPort != null && tab.port === hiddenPort;
2710
+ }
2711
+ }
2712
+ }
2713
+
2714
+ for (const entry of visiblePorts) {
2715
+ const id = 'port:' + entry.port;
2716
+ const existed = previewTabs.has(id);
2717
+ ensurePreviewTab({
2718
+ id,
2719
+ type: 'port',
2720
+ port: entry.port,
2721
+ pid: entry.pid,
2722
+ label: ':' + entry.port,
2723
+ title: 'Port ' + entry.port,
2724
+ url: portPreviewUrl(entry.port),
2725
+ running: true,
2726
+ }, { focusNew: !existed });
2727
+ }
2728
+
2729
+ if (activeWasRemoved || !activePreviewTabId || !previewTabs.has(activePreviewTabId)) {
2730
+ activatePreviewTab(activeWasCanonicalAlias && canonicalTabId ? canonicalTabId : firstAppTabId());
2731
+ } else {
2732
+ renderPreviewTabs();
2733
+ }
2734
+ }
2735
+
2736
+ function setPreviewUrlBar(value) {
1619
2737
  const urlBar = document.getElementById('urlBar');
1620
- if (urlBar) urlBar.value = url;
1621
- // If the user had collapsed the preview because nothing was up,
1622
- // surface it automatically when the first backend comes online.
1623
- if (kind !== 'none' && layout === 'terminal-only') setLayout('split');
2738
+ if (urlBar) urlBar.value = value;
2739
+ }
2740
+
2741
+ function showAppPreview() {
2742
+ ensurePreviewTab(appTabFor(previewBackendKind));
2743
+ activatePreviewTab(firstAppTabId());
2744
+ }
2745
+
2746
+ async function showMarkdownPreview(path, markdown) {
2747
+ const id = 'markdown:' + path;
2748
+ ensurePreviewTab({
2749
+ id,
2750
+ type: 'markdown',
2751
+ label: shortPathLabel(path),
2752
+ title: path,
2753
+ path,
2754
+ markdown: markdown || '',
2755
+ running: false,
2756
+ });
2757
+ activatePreviewTab(id);
2758
+ if (activePreviewTabId !== id) return;
2759
+ await paintMarkdownPreview(markdown || '');
2760
+ }
2761
+
2762
+ async function paintMarkdownPreview(markdown) {
2763
+ const seq = ++markdownRenderSeq;
2764
+ const html = await renderMarkdown(markdown || '');
2765
+ const active = activePreviewTabId ? previewTabs.get(activePreviewTabId) : null;
2766
+ if (seq === markdownRenderSeq && active?.type === 'markdown' && markdownPreviewBody) {
2767
+ markdownPreviewBody.innerHTML = html;
2768
+ }
2769
+ }
2770
+
2771
+ async function renderMarkdown(markdown) {
2772
+ try {
2773
+ const renderer = await ensureMarkdownRenderer();
2774
+ const raw = renderer.marked(markdown, { gfm: true, breaks: false });
2775
+ return renderer.purifier.sanitize(raw, { USE_PROFILES: { html: true } });
2776
+ } catch (e) {
2777
+ return '<pre><code>' + escapeHtml(markdown) + '</code></pre>';
2778
+ }
2779
+ }
2780
+
2781
+ function ensureMarkdownRenderer() {
2782
+ if (markdownRenderer) return Promise.resolve(markdownRenderer);
2783
+ if (markdownRendererPromise) return markdownRendererPromise;
2784
+ markdownRendererPromise = Promise.all([
2785
+ import('https://cdn.jsdelivr.net/npm/marked@12.0.2/+esm'),
2786
+ import('https://cdn.jsdelivr.net/npm/dompurify@3.1.7/+esm'),
2787
+ ]).then(([markedModule, purifyModule]) => {
2788
+ const marked = markedModule.marked || markedModule.parse || markedModule.default;
2789
+ const createDOMPurify = purifyModule.default || purifyModule.DOMPurify || purifyModule;
2790
+ const purifier = typeof createDOMPurify === 'function'
2791
+ ? createDOMPurify(window)
2792
+ : createDOMPurify;
2793
+ if (typeof marked !== 'function' || !purifier || typeof purifier.sanitize !== 'function') {
2794
+ throw new Error('markdown renderer unavailable');
2795
+ }
2796
+ markdownRenderer = { marked, purifier };
2797
+ return markdownRenderer;
2798
+ });
2799
+ return markdownRendererPromise;
2800
+ }
2801
+
2802
+ function escapeHtml(s) {
2803
+ const div = document.createElement('div');
2804
+ div.textContent = String(s);
2805
+ return div.innerHTML;
2806
+ }
2807
+
2808
+ function previewBackendPort(kind, stats) {
2809
+ const raw = kind === 'vite' ? stats?.vite?.port : null;
2810
+ const n = Number(raw);
2811
+ return Number.isInteger(n) && n > 0 ? n : null;
2812
+ }
2813
+
2814
+ function applyPreviewTarget(kind, stats) {
2815
+ const previousKind = previewBackendKind;
2816
+ previewBackendKind = kind === 'worker' || kind === 'vite' ? kind : 'none';
2817
+ const appKind = previewBackendKind === 'worker' ? 'worker' : 'vite';
2818
+ const appTab = ensurePreviewTab(appTabFor(appKind));
2819
+ const shouldFocusApp = previousKind !== previewBackendKind && previewBackendKind !== 'none';
2820
+ if (shouldFocusApp) activatePreviewTab(appTab.id);
2821
+ if (!activePreviewTabId) activatePreviewTab(appTab.id);
2822
+ syncPortPreviewTabs(stats?.ports, previewBackendPort(previewBackendKind, stats), appTab.id);
1624
2823
  }
1625
2824
 
1626
2825
  function reloadPreview() {
2826
+ const active = activePreviewTabId ? previewTabs.get(activePreviewTabId) : null;
2827
+ if (active?.type === 'markdown') {
2828
+ Editor.refreshMarkdownPreview().catch(() => {});
2829
+ return;
2830
+ }
1627
2831
  // Refreshing without a running backend should re-poll the
1628
2832
  // placeholder so users see dev-server status update promptly.
1629
- if (previewFrame.src === 'about:blank' ||
1630
- (!previewFrame.src.includes('/preview') && !previewFrame.src.includes('/worker'))) {
1631
- previewFrame.src = previewUrlFor(
1632
- (activePreview === 'none' || activePreview === null) ? 'vite' : activePreview
1633
- );
2833
+ if (!active || previewFrame.src === 'about:blank') {
2834
+ activatePreviewTab(firstAppTabId());
1634
2835
  } else {
1635
2836
  try { previewFrame.contentWindow?.location.reload(); }
1636
2837
  catch { previewFrame.src = previewFrame.src; }
1637
2838
  }
1638
2839
  }
1639
2840
 
2841
+ applyPreviewTarget('none', null);
2842
+
1640
2843
  // ── Pane resize manager ──
1641
2844
  //
1642
- // Owns the 3 resize handles in editor mode + 1 in split mode:
1643
- // H1 tree ↔ editor-stack (col, in editor mode) — TreeResizeHandle in FileTree IIFE
1644
- // H2 editor-stack ↔ preview (col, in editor + split) — #resizeHandle (mode-aware)
1645
- // V1 editor ↔ terminal (row, in editor mode) — #editorTerminalResizeHandle
2845
+ // Owns the three workspace resize handles:
2846
+ // H1 tree ↔ center stack (col) — TreeResizeHandle in FileTree IIFE
2847
+ // H2 center stack ↔ preview (col) — #resizeHandle
2848
+ // V1 top surface ↔ terminal (row) — #editorTerminalResizeHandle
1646
2849
  //
1647
2850
  // Each handle persists its dimension to localStorage so a reload
1648
2851
  // restores the user's layout. Keyed per-session.
@@ -1691,41 +2894,25 @@
1691
2894
  applyMonacoLayout();
1692
2895
  }
1693
2896
 
1694
- // Map middlePct → flex basis on the two children that actually
1695
- // compete for horizontal space in each mode:
1696
- // - split mode: .panel-terminal vs .panel-preview
1697
- // - editor mode: .panel-left-stack vs .panel-preview
1698
- // In editor mode .panel-left-stack is flex:1 (CSS rule); we
1699
- // override with explicit basis. Same for .panel-terminal in split.
2897
+ // Map middlePct to the center workspace and preview columns.
1700
2898
  function applyMiddlePreviewFlex() {
1701
2899
  const stack = document.getElementById('leftStack');
1702
2900
  const term = mainPanel.querySelector('.panel-terminal');
1703
2901
  const prev = mainPanel.querySelector('.panel-preview');
1704
2902
  const middlePct = Math.max(20, Math.min(80, dims.middlePct));
1705
- if (layout === 'editor') {
1706
- // stack drives middle width; terminal is INSIDE stack.
1707
- if (stack) stack.style.flex = middlePct + ' 0 0%';
1708
- if (prev) prev.style.flex = (100 - middlePct) + ' 0 0%';
1709
- if (term) term.style.flex = ''; // unset; vertical handler owns it
1710
- } else if (layout === 'split') {
1711
- if (term) term.style.flex = middlePct + ' 0 0%';
1712
- if (prev) prev.style.flex = (100 - middlePct) + ' 0 0%';
1713
- if (stack) stack.style.flex = '';
1714
- } else {
1715
- // Other modes (terminal-only / preview-only) don't use either
1716
- // handle visibly; clear inline so CSS defaults take effect.
1717
- if (term) term.style.flex = '';
1718
- if (prev) prev.style.flex = '';
1719
- if (stack) stack.style.flex = '';
1720
- }
2903
+ if (stack) stack.style.flex = middlePct + ' 0 0%';
2904
+ if (prev) prev.style.flex = (100 - middlePct) + ' 0 0%';
2905
+ if (term) term.style.flex = '';
1721
2906
  }
1722
2907
 
1723
2908
  function applyEditorTerminalFlex() {
1724
2909
  if (layout !== 'editor') return;
1725
- const editor = document.getElementById('editorPanel');
2910
+ const topSurface = workSurface === 'agent'
2911
+ ? document.getElementById('agentPanel')
2912
+ : document.getElementById('editorPanel');
1726
2913
  const term = mainPanel.querySelector('.panel-terminal');
1727
2914
  const editorPct = Math.max(10, Math.min(90, dims.editorPct));
1728
- if (editor) editor.style.flex = editorPct + ' 0 0%';
2915
+ if (topSurface) topSurface.style.flex = editorPct + ' 0 0%';
1729
2916
  if (term) term.style.flex = (100 - editorPct) + ' 0 0%';
1730
2917
  }
1731
2918
 
@@ -1813,9 +3000,7 @@
1813
3000
  if (tree) tree.style.width = dims.treeWidth + 'px';
1814
3001
  }
1815
3002
 
1816
- // Re-apply dims any time setLayout() switches modes — the inline
1817
- // flex values are mode-specific (split vs editor have different
1818
- // pane structures).
3003
+ // Re-apply dims any time setLayout() switches surfaces.
1819
3004
  function onLayoutChange() {
1820
3005
  applyMiddlePreviewFlex();
1821
3006
  applyEditorTerminalFlex();
@@ -1852,7 +3037,7 @@
1852
3037
  let kind = 'none';
1853
3038
  if (s.wrangler?.running) kind = 'worker';
1854
3039
  else if (s.vite?.running) kind = 'vite';
1855
- applyPreviewTarget(kind);
3040
+ applyPreviewTarget(kind, s);
1856
3041
 
1857
3042
  if (s.wrangler?.running) {
1858
3043
  viteStatus.textContent = 'worker running (' + (s.wrangler.name || '?') + ')';
@@ -2341,7 +3526,10 @@
2341
3526
  // Restore persisted pane dims before activating layout so the first paint
2342
3527
  // shows the user's prior arrangement.
2343
3528
  PaneResizer.restoreDims();
2344
- setLayout('editor');
3529
+ const initialMode = new URLSearchParams(location.search).get('agent') === '1'
3530
+ ? 'agent'
3531
+ : 'editor';
3532
+ setLayout(initialMode);
2345
3533
  connect();
2346
3534
  </script>
2347
3535
  </body>