@sudodevstudio/astro-ai 0.1.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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/dist/agent/agent-fallback.d.ts +50 -0
  4. package/dist/agent/agent-fallback.d.ts.map +1 -0
  5. package/dist/agent/agent-fallback.js +31 -0
  6. package/dist/agent/agent-fallback.js.map +1 -0
  7. package/dist/agent/cli-agent-fallback.d.ts +79 -0
  8. package/dist/agent/cli-agent-fallback.d.ts.map +1 -0
  9. package/dist/agent/cli-agent-fallback.js +1056 -0
  10. package/dist/agent/cli-agent-fallback.js.map +1 -0
  11. package/dist/integration/index.d.ts +28 -0
  12. package/dist/integration/index.d.ts.map +1 -0
  13. package/dist/integration/index.js +389 -0
  14. package/dist/integration/index.js.map +1 -0
  15. package/dist/resolver/astro-resolver.d.ts +28 -0
  16. package/dist/resolver/astro-resolver.d.ts.map +1 -0
  17. package/dist/resolver/astro-resolver.js +835 -0
  18. package/dist/resolver/astro-resolver.js.map +1 -0
  19. package/dist/resolver/types.d.ts +39 -0
  20. package/dist/resolver/types.d.ts.map +1 -0
  21. package/dist/resolver/types.js +2 -0
  22. package/dist/resolver/types.js.map +1 -0
  23. package/dist/shared/protocol.d.ts +150 -0
  24. package/dist/shared/protocol.d.ts.map +1 -0
  25. package/dist/shared/protocol.js +28 -0
  26. package/dist/shared/protocol.js.map +1 -0
  27. package/dist/shared/selection-context.d.ts +75 -0
  28. package/dist/shared/selection-context.d.ts.map +1 -0
  29. package/dist/shared/selection-context.js +2 -0
  30. package/dist/shared/selection-context.js.map +1 -0
  31. package/dist/shared/visual-components.d.ts +15 -0
  32. package/dist/shared/visual-components.d.ts.map +1 -0
  33. package/dist/shared/visual-components.js +2 -0
  34. package/dist/shared/visual-components.js.map +1 -0
  35. package/dist/toolbar/action-model.d.ts +20 -0
  36. package/dist/toolbar/action-model.d.ts.map +1 -0
  37. package/dist/toolbar/action-model.js +49 -0
  38. package/dist/toolbar/action-model.js.map +1 -0
  39. package/dist/toolbar/app.d.ts +3 -0
  40. package/dist/toolbar/app.d.ts.map +1 -0
  41. package/dist/toolbar/app.js +274 -0
  42. package/dist/toolbar/app.js.map +1 -0
  43. package/dist/toolbar/chat-drawer.d.ts +111 -0
  44. package/dist/toolbar/chat-drawer.d.ts.map +1 -0
  45. package/dist/toolbar/chat-drawer.js +1575 -0
  46. package/dist/toolbar/chat-drawer.js.map +1 -0
  47. package/dist/toolbar/chat-windows.d.ts +62 -0
  48. package/dist/toolbar/chat-windows.d.ts.map +1 -0
  49. package/dist/toolbar/chat-windows.js +287 -0
  50. package/dist/toolbar/chat-windows.js.map +1 -0
  51. package/dist/toolbar/contextual-actions.d.ts +24 -0
  52. package/dist/toolbar/contextual-actions.d.ts.map +1 -0
  53. package/dist/toolbar/contextual-actions.js +371 -0
  54. package/dist/toolbar/contextual-actions.js.map +1 -0
  55. package/dist/toolbar/diagnostic-actions.d.ts +22 -0
  56. package/dist/toolbar/diagnostic-actions.d.ts.map +1 -0
  57. package/dist/toolbar/diagnostic-actions.js +127 -0
  58. package/dist/toolbar/diagnostic-actions.js.map +1 -0
  59. package/dist/toolbar/layers.d.ts +26 -0
  60. package/dist/toolbar/layers.d.ts.map +1 -0
  61. package/dist/toolbar/layers.js +31 -0
  62. package/dist/toolbar/layers.js.map +1 -0
  63. package/dist/toolbar/overlay.d.ts +40 -0
  64. package/dist/toolbar/overlay.d.ts.map +1 -0
  65. package/dist/toolbar/overlay.js +619 -0
  66. package/dist/toolbar/overlay.js.map +1 -0
  67. package/dist/visual/capability-resolver.d.ts +11 -0
  68. package/dist/visual/capability-resolver.d.ts.map +1 -0
  69. package/dist/visual/capability-resolver.js +69 -0
  70. package/dist/visual/capability-resolver.js.map +1 -0
  71. package/dist/visual/command-engine.d.ts +10 -0
  72. package/dist/visual/command-engine.d.ts.map +1 -0
  73. package/dist/visual/command-engine.js +199 -0
  74. package/dist/visual/command-engine.js.map +1 -0
  75. package/dist/visual/commands.d.ts +36 -0
  76. package/dist/visual/commands.d.ts.map +1 -0
  77. package/dist/visual/commands.js +2 -0
  78. package/dist/visual/commands.js.map +1 -0
  79. package/dist/visual/patch-transactions.d.ts +57 -0
  80. package/dist/visual/patch-transactions.d.ts.map +1 -0
  81. package/dist/visual/patch-transactions.js +259 -0
  82. package/dist/visual/patch-transactions.js.map +1 -0
  83. package/dist/vite/build-ai-plugin.d.ts +11 -0
  84. package/dist/vite/build-ai-plugin.d.ts.map +1 -0
  85. package/dist/vite/build-ai-plugin.js +45 -0
  86. package/dist/vite/build-ai-plugin.js.map +1 -0
  87. package/package.json +85 -0
  88. package/src/agent/agent-fallback.ts +95 -0
  89. package/src/agent/cli-agent-fallback.ts +1382 -0
  90. package/src/integration/index.ts +475 -0
  91. package/src/resolver/astro-resolver.ts +1024 -0
  92. package/src/resolver/types.ts +47 -0
  93. package/src/shared/protocol.ts +181 -0
  94. package/src/shared/selection-context.ts +91 -0
  95. package/src/shared/visual-components.ts +16 -0
  96. package/src/toolbar/action-model.ts +67 -0
  97. package/src/toolbar/app.ts +294 -0
  98. package/src/toolbar/chat-drawer.ts +1742 -0
  99. package/src/toolbar/chat-windows.ts +331 -0
  100. package/src/toolbar/contextual-actions.ts +401 -0
  101. package/src/toolbar/diagnostic-actions.ts +151 -0
  102. package/src/toolbar/layers.ts +32 -0
  103. package/src/toolbar/overlay.ts +638 -0
  104. package/src/visual/capability-resolver.ts +83 -0
  105. package/src/visual/command-engine.ts +250 -0
  106. package/src/visual/commands.ts +37 -0
  107. package/src/visual/patch-transactions.ts +270 -0
  108. package/src/vite/build-ai-plugin.ts +46 -0
@@ -0,0 +1,401 @@
1
+ import type { SelectionContext } from '../shared/selection-context.js';
2
+ import type { DeterministicVisualCommand } from '../visual/commands.js';
3
+ import { EDITOR_LAYERS } from './layers.js';
4
+ import {
5
+ contextualActions,
6
+ type ContextualActionId,
7
+ middleTruncatePath,
8
+ } from './action-model.js';
9
+
10
+ export type ContextualActionCallbacks = {
11
+ onCommand(command: DeterministicVisualCommand): void;
12
+ onAskAI(contexts: SelectionContext[]): void;
13
+ onClear(): void;
14
+ /** Delivers a real click to the selected element. Returns false if it is gone. */
15
+ onClickThrough?(target: HTMLElement): boolean;
16
+ };
17
+
18
+ type AnchorPoint = { x: number; y: number };
19
+
20
+ export class ContextualActionBar {
21
+ readonly #host: HTMLDivElement;
22
+ readonly #root: ShadowRoot;
23
+ readonly #callbacks: ContextualActionCallbacks;
24
+ #context: SelectionContext | undefined;
25
+ #contexts: SelectionContext[] = [];
26
+ #target: HTMLElement | undefined;
27
+ #point: AnchorPoint | undefined;
28
+ #activeDetail: ContextualActionId | undefined;
29
+
30
+ constructor(callbacks: ContextualActionCallbacks) {
31
+ this.#callbacks = callbacks;
32
+ this.#host = document.createElement('div');
33
+ this.#host.dataset.astroAiUi = 'actions';
34
+ Object.assign(this.#host.style, {
35
+ display: 'none',
36
+ position: 'fixed',
37
+ inset: 'auto',
38
+ zIndex: String(EDITOR_LAYERS.actionBar),
39
+ });
40
+ this.#root = this.#host.attachShadow({ mode: 'open' });
41
+ this.#root.append(createStyle());
42
+ document.documentElement.append(this.#host);
43
+ document.addEventListener('keydown', this.#onKeyDown, true);
44
+ }
45
+
46
+ show(context: SelectionContext, target: HTMLElement, point?: AnchorPoint): void {
47
+ this.#context = context;
48
+ this.#contexts = [context];
49
+ this.#target = target;
50
+ this.#point = point;
51
+ this.#activeDetail = undefined;
52
+ this.#render();
53
+ this.#host.style.display = 'block';
54
+ requestAnimationFrame(() => this.reposition());
55
+ }
56
+
57
+ showMultiple(contexts: SelectionContext[], target: HTMLElement): void {
58
+ this.#context = undefined;
59
+ this.#contexts = [...contexts];
60
+ this.#target = target;
61
+ this.#point = undefined;
62
+ this.#activeDetail = undefined;
63
+ this.#render();
64
+ this.#host.style.display = 'block';
65
+ requestAnimationFrame(() => this.reposition());
66
+ }
67
+
68
+ hide(): void {
69
+ this.#context = undefined;
70
+ this.#contexts = [];
71
+ this.#target = undefined;
72
+ this.#point = undefined;
73
+ this.#activeDetail = undefined;
74
+ this.#host.style.display = 'none';
75
+ this.#root.querySelector('section')?.remove();
76
+ }
77
+
78
+ reposition(): void {
79
+ if (this.#contexts.length === 0 || this.#target === undefined) return;
80
+ const targetRect = this.#target.getBoundingClientRect();
81
+ const barRect = this.#host.getBoundingClientRect();
82
+ const gap = 8;
83
+ const margin = 8;
84
+ let left: number;
85
+ let top: number;
86
+
87
+ if (this.#point !== undefined) {
88
+ left = this.#point.x + gap;
89
+ top = this.#point.y + gap;
90
+ } else {
91
+ left = targetRect.left;
92
+ top = targetRect.bottom + gap;
93
+ if (top + barRect.height > window.innerHeight - margin) {
94
+ top = targetRect.top - barRect.height - gap;
95
+ }
96
+ if (top < margin) {
97
+ left = targetRect.right + gap;
98
+ top = targetRect.top;
99
+ }
100
+ if (left + barRect.width > window.innerWidth - margin && targetRect.left > barRect.width + gap) {
101
+ left = targetRect.left - barRect.width - gap;
102
+ }
103
+ }
104
+
105
+ left = clamp(left, margin, Math.max(margin, window.innerWidth - barRect.width - margin));
106
+ top = clamp(top, margin, Math.max(margin, window.innerHeight - barRect.height - margin));
107
+ this.#host.style.left = `${left}px`;
108
+ this.#host.style.top = `${top}px`;
109
+ }
110
+
111
+ destroy(): void {
112
+ document.removeEventListener('keydown', this.#onKeyDown, true);
113
+ this.#host.remove();
114
+ }
115
+
116
+ #render(): void {
117
+ const previous = this.#root.querySelector('section');
118
+ previous?.remove();
119
+ if (this.#contexts.length === 0) return;
120
+
121
+ const shell = element('section', 'action-shell');
122
+ shell.setAttribute('role', 'toolbar');
123
+ shell.setAttribute('aria-label', this.#contexts.length === 1
124
+ ? 'Actions for selected source element'
125
+ : `Actions for ${this.#contexts.length} selected source elements`);
126
+ const row = element('div', 'action-row');
127
+ if (this.#contexts.length > 1) {
128
+ const count = element('span', 'selection-count');
129
+ count.textContent = `${this.#contexts.length} selected`;
130
+ row.append(count, button('Ask AI', () => this.#callbacks.onAskAI(this.#contexts)));
131
+ } else if (this.#context !== undefined) {
132
+ for (const action of contextualActions(this.#context)) {
133
+ const control = button(action.label, () => this.#activate(action.id));
134
+ control.dataset.action = action.id;
135
+ control.setAttribute('aria-pressed', String(this.#activeDetail === action.id));
136
+ row.append(control);
137
+ }
138
+ }
139
+ const close = button('×', this.#callbacks.onClear);
140
+ close.className = 'close';
141
+ close.title = 'Clear selection (Escape)';
142
+ close.setAttribute('aria-label', 'Clear selection');
143
+ row.append(close);
144
+ shell.append(row);
145
+ const detail = this.#detail();
146
+ if (detail !== undefined) shell.append(detail);
147
+ this.#root.append(shell);
148
+ requestAnimationFrame(() => this.reposition());
149
+ }
150
+
151
+ #activate(action: ContextualActionId): void {
152
+ if (this.#context === undefined) return;
153
+ if (action === 'click') {
154
+ const target = this.#target;
155
+ // The click can navigate or re-render the page, which would leave the bar
156
+ // anchored to a detached element, so dismiss it first.
157
+ if (target !== undefined) {
158
+ this.#callbacks.onClear();
159
+ this.#callbacks.onClickThrough?.(target);
160
+ }
161
+ return;
162
+ }
163
+ if (action === 'ask-ai') {
164
+ this.#callbacks.onAskAI([this.#context]);
165
+ return;
166
+ }
167
+ if (action === 'remove') {
168
+ this.#callbacks.onCommand({ kind: 'remove-source-node', nodeId: this.#context.selectedNode.nodeId });
169
+ return;
170
+ }
171
+ this.#activeDetail = this.#activeDetail === action ? undefined : action;
172
+ this.#render();
173
+ }
174
+
175
+ #detail(): HTMLElement | undefined {
176
+ if (this.#context === undefined || this.#activeDetail === undefined) return undefined;
177
+ if (this.#activeDetail === 'edit') return this.#editDetail(this.#context);
178
+ if (this.#activeDetail === 'props') return this.#propsDetail(this.#context);
179
+ if (this.#activeDetail === 'move') return this.#moveDetail(this.#context);
180
+ if (this.#activeDetail === 'source') return sourceDetail(this.#context);
181
+ return undefined;
182
+ }
183
+
184
+ #editDetail(context: SelectionContext): HTMLElement {
185
+ const form = element('form', 'detail');
186
+ const label = document.createElement('label');
187
+ label.textContent = 'Literal source text';
188
+ const input = document.createElement('input');
189
+ input.value = context.selectedNode.literalText ?? '';
190
+ label.append(input);
191
+ const submit = button('Apply locally');
192
+ submit.type = 'submit';
193
+ form.append(label, submit);
194
+ form.addEventListener('submit', (event) => {
195
+ event.preventDefault();
196
+ this.#callbacks.onCommand({
197
+ kind: 'edit-literal-text',
198
+ nodeId: context.selectedNode.nodeId,
199
+ text: input.value,
200
+ });
201
+ });
202
+ requestAnimationFrame(() => input.focus());
203
+ return form;
204
+ }
205
+
206
+ #propsDetail(context: SelectionContext): HTMLElement {
207
+ const detail = element('div', 'detail');
208
+ for (const prop of context.capabilities.editableProps) {
209
+ const form = element('form', 'prop-row');
210
+ const label = document.createElement('label');
211
+ label.textContent = prop.name;
212
+ const input = prop.allowedValues === undefined
213
+ ? document.createElement('input')
214
+ : document.createElement('select');
215
+ if (input instanceof HTMLSelectElement) {
216
+ for (const value of prop.allowedValues ?? []) {
217
+ const option = document.createElement('option');
218
+ option.value = String(value);
219
+ option.textContent = String(value);
220
+ option.selected = value === prop.value;
221
+ input.append(option);
222
+ }
223
+ } else if (prop.type === 'boolean') {
224
+ input.type = 'checkbox';
225
+ input.checked = Boolean(prop.value);
226
+ } else {
227
+ input.type = prop.type === 'number' ? 'number' : 'text';
228
+ input.value = String(prop.value);
229
+ }
230
+ const apply = button('Apply');
231
+ apply.type = 'submit';
232
+ label.append(input);
233
+ form.append(label, apply);
234
+ form.addEventListener('submit', (event) => {
235
+ event.preventDefault();
236
+ let value: string | number | boolean;
237
+ if (prop.type === 'boolean') {
238
+ value = input instanceof HTMLInputElement ? input.checked : input.value === 'true';
239
+ } else if (prop.type === 'number') {
240
+ value = Number(input.value);
241
+ } else {
242
+ value = input.value;
243
+ }
244
+ this.#callbacks.onCommand({
245
+ kind: 'set-literal-prop',
246
+ nodeId: context.selectedNode.nodeId,
247
+ prop: prop.name,
248
+ value,
249
+ });
250
+ });
251
+ detail.append(form);
252
+ }
253
+ return detail;
254
+ }
255
+
256
+ #moveDetail(context: SelectionContext): HTMLElement {
257
+ const detail = element('div', 'detail move-row');
258
+ const previous = button('Move before', () => this.#callbacks.onCommand({
259
+ kind: 'reorder-sibling',
260
+ nodeId: context.selectedNode.nodeId,
261
+ direction: 'previous',
262
+ }));
263
+ previous.disabled = context.capabilities.reorderTargets.previous === undefined;
264
+ const next = button('Move after', () => this.#callbacks.onCommand({
265
+ kind: 'reorder-sibling',
266
+ nodeId: context.selectedNode.nodeId,
267
+ direction: 'next',
268
+ }));
269
+ next.disabled = context.capabilities.reorderTargets.next === undefined;
270
+ detail.append(previous, next);
271
+ if (context.capabilities.movable && !context.capabilities.reorderable) {
272
+ const note = element('p', 'muted');
273
+ note.textContent = context.capabilities.allowedParentSlots.length === 0
274
+ ? 'No compatible declared target slot is available in this view.'
275
+ : `Compatible declared slots: ${context.capabilities.allowedParentSlots.join(', ')}. Drag onto a registered target component to move structurally.`;
276
+ detail.append(note);
277
+ }
278
+ return detail;
279
+ }
280
+
281
+ readonly #onKeyDown = (event: KeyboardEvent): void => {
282
+ if (event.key !== 'Escape' || this.#contexts.length === 0) return;
283
+ event.preventDefault();
284
+ event.stopImmediatePropagation();
285
+ if (this.#activeDetail !== undefined) {
286
+ this.#activeDetail = undefined;
287
+ this.#render();
288
+ } else {
289
+ this.#callbacks.onClear();
290
+ }
291
+ };
292
+ }
293
+
294
+ function sourceDetail(context: SelectionContext): HTMLElement {
295
+ const detail = element('div', 'detail source-detail');
296
+ const name = context.selectedNode.componentName ?? context.selectedNode.tagName ?? 'Astro node';
297
+ const source = context.selectedNode.source;
298
+ const path = element('code', 'source-path');
299
+ path.textContent = middleTruncatePath(source.file);
300
+ path.title = source.file;
301
+ detail.append(
302
+ definition('Element', name),
303
+ definitionNode('Source', path),
304
+ definition('Position', `${source.start.line}:${source.start.column}`),
305
+ definition('Kind', humanize(context.capabilities.sourceKind)),
306
+ );
307
+ const ancestry = context.parentComponents.length === 0
308
+ ? 'Native Astro template'
309
+ : context.parentComponents.map(({ name: parent }) => parent).join(' → ');
310
+ detail.append(definition('Ancestry', ancestry));
311
+ let provenance = context.capabilities.dataProvenance.description;
312
+ const declaredAt = context.capabilities.dataProvenance.declaredAt;
313
+ if (declaredAt !== undefined) {
314
+ provenance += ` Declaration: ${declaredAt.file}:${declaredAt.start.line}:${declaredAt.start.column}.`;
315
+ }
316
+ detail.append(definition('Provenance', provenance));
317
+ if (context.capabilities.repeatContext !== undefined) {
318
+ const warning = element('p', 'warning');
319
+ warning.textContent = 'Repeated template: this source edit affects every rendered instance.';
320
+ detail.append(warning);
321
+ }
322
+ const actions = element('div', 'source-buttons');
323
+ const copy = button('Copy path', () => {
324
+ void navigator.clipboard.writeText(source.file).then(
325
+ () => { copy.textContent = 'Copied'; },
326
+ () => { copy.textContent = 'Copy failed'; },
327
+ );
328
+ });
329
+ const open = button('Open in editor', () => {
330
+ const file = `${source.file}:${source.start.line}:${source.start.column}`;
331
+ void fetch(`/__open-in-editor?file=${encodeURIComponent(file)}`);
332
+ });
333
+ actions.append(copy, open);
334
+ detail.append(actions);
335
+ return detail;
336
+ }
337
+
338
+ function createStyle(): HTMLStyleElement {
339
+ const style = document.createElement('style');
340
+ style.textContent = `
341
+ :host { color: #f8fafc; font: 12px/1.4 ui-sans-serif, system-ui, sans-serif; }
342
+ .action-shell { background: #14171d; border: 1px solid #3f4552; border-radius: 9px; box-shadow: 0 12px 35px rgb(0 0 0 / .38); box-sizing: border-box; max-width: min(430px, calc(100vw - 16px)); min-width: 260px; padding: 6px; }
343
+ .action-row, .move-row, .source-buttons { align-items: center; display: flex; flex-wrap: wrap; gap: 5px; }
344
+ .selection-count { color: #ddd6fe; font-weight: 700; padding: 0 7px; }
345
+ button { background: #252a34; border: 1px solid #444b59; border-radius: 5px; color: #f8fafc; cursor: pointer; padding: 6px 8px; }
346
+ button:hover:not(:disabled), button[aria-pressed='true'] { background: #4c1d95; border-color: #8b5cf6; }
347
+ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid #a78bfa; outline-offset: 2px; }
348
+ button:disabled { cursor: not-allowed; opacity: .4; }
349
+ .close { margin-left: auto; }
350
+ .detail { border-top: 1px solid #343a46; display: grid; gap: 8px; margin-top: 6px; max-height: min(360px, calc(100vh - 80px)); overflow: auto; padding: 10px 6px 5px; }
351
+ label { display: grid; gap: 4px; }
352
+ input, select { background: #0d1015; border: 1px solid #444b59; border-radius: 5px; box-sizing: border-box; color: white; min-width: 0; padding: 7px; }
353
+ input:not([type='checkbox']), select { width: 100%; }
354
+ .prop-row { align-items: end; display: grid; gap: 7px; grid-template-columns: minmax(150px, 1fr) auto; }
355
+ dl { display: grid; gap: 2px; margin: 0; }
356
+ dt { color: #939cab; font-size: 10px; text-transform: uppercase; }
357
+ dd { margin: 0; overflow-wrap: anywhere; }
358
+ .source-path { color: #c4b5fd; }
359
+ .warning { background: #422006; border: 1px solid #854d0e; border-radius: 5px; color: #fde68a; margin: 0; padding: 7px; }
360
+ .muted { color: #aeb6c5; margin: 0; }
361
+ `;
362
+ return style;
363
+ }
364
+
365
+ function definition(term: string, description: string): HTMLDListElement {
366
+ const value = document.createElement('span');
367
+ value.textContent = description;
368
+ return definitionNode(term, value);
369
+ }
370
+
371
+ function definitionNode(term: string, description: Node): HTMLDListElement {
372
+ const list = document.createElement('dl');
373
+ const dt = document.createElement('dt');
374
+ dt.textContent = term;
375
+ const dd = document.createElement('dd');
376
+ dd.append(description);
377
+ list.append(dt, dd);
378
+ return list;
379
+ }
380
+
381
+ function element<K extends keyof HTMLElementTagNameMap>(tag: K, className: string): HTMLElementTagNameMap[K] {
382
+ const output = document.createElement(tag);
383
+ output.className = className;
384
+ return output;
385
+ }
386
+
387
+ function button(label: string, onClick?: () => void): HTMLButtonElement {
388
+ const output = document.createElement('button');
389
+ output.type = 'button';
390
+ output.textContent = label;
391
+ if (onClick !== undefined) output.addEventListener('click', onClick);
392
+ return output;
393
+ }
394
+
395
+ function humanize(value: string): string {
396
+ return value.replaceAll('-', ' ');
397
+ }
398
+
399
+ function clamp(value: number, minimum: number, maximum: number): number {
400
+ return Math.min(maximum, Math.max(minimum, value));
401
+ }
@@ -0,0 +1,151 @@
1
+ import type { AgentExternalContext } from '../shared/protocol.js';
2
+
3
+ type DiagnosticActionCallbacks = {
4
+ onFix(context: AgentExternalContext): void;
5
+ };
6
+
7
+ type AuditEntry = {
8
+ auditedElement?: Element;
9
+ rule?: { title?: string; message?: string; description?: string };
10
+ card?: HTMLElement & { shadowRoot: ShadowRoot | null };
11
+ };
12
+
13
+ type AuditWindow = HTMLElement & { audits?: AuditEntry[] };
14
+
15
+ const ACTION_ATTRIBUTE = 'data-astro-ai-fix-action';
16
+
17
+ /**
18
+ * Feature-detected bridge to development diagnostics owned by Vite and Astro.
19
+ * Both surfaces use open shadow roots in the supported Astro version. If that
20
+ * internal structure changes, scans simply find nothing and leave the host UI
21
+ * untouched.
22
+ */
23
+ export class DiagnosticActionBridge {
24
+ readonly #callbacks: DiagnosticActionCallbacks;
25
+ readonly #observer: MutationObserver;
26
+ readonly #actions = new Set<HTMLButtonElement>();
27
+
28
+ constructor(callbacks: DiagnosticActionCallbacks) {
29
+ this.#callbacks = callbacks;
30
+ this.#observer = new MutationObserver(() => this.scan());
31
+ this.#observer.observe(document.documentElement, { childList: true, subtree: true });
32
+ this.scan();
33
+ }
34
+
35
+ scan(): void {
36
+ this.#scanViteErrors();
37
+ this.#scanAstroAudits();
38
+ }
39
+
40
+ destroy(): void {
41
+ this.#observer.disconnect();
42
+ for (const action of this.#actions) action.remove();
43
+ this.#actions.clear();
44
+ }
45
+
46
+ #scanViteErrors(): void {
47
+ for (const overlay of document.querySelectorAll<HTMLElement>('vite-error-overlay')) {
48
+ const root = overlay.shadowRoot;
49
+ const windowElement = root?.querySelector<HTMLElement>('.window');
50
+ if (root == null || windowElement == null || root.querySelector(`[${ACTION_ATTRIBUTE}]`) !== null) {
51
+ continue;
52
+ }
53
+ const title = root.querySelector('.message-body')?.textContent?.trim() || 'Development error';
54
+ const rawFile = root.querySelector('.file')?.textContent?.trim();
55
+ const source = parseSourceReference(rawFile);
56
+ const frame = root.querySelector('.frame')?.textContent?.trim();
57
+ const message = [title, frame].filter(Boolean).join('\n\n');
58
+ const action = this.#createAction('error', () => {
59
+ this.#callbacks.onFix({
60
+ kind: 'error',
61
+ title,
62
+ message,
63
+ ...(source.file === undefined ? {} : { file: normalizeProjectFile(source.file) }),
64
+ ...(source.line === undefined ? {} : { line: source.line }),
65
+ });
66
+ });
67
+ windowElement.insertBefore(action, windowElement.querySelector('.tip'));
68
+ }
69
+ }
70
+
71
+ #scanAstroAudits(): void {
72
+ const toolbar = document.querySelector<HTMLElement>('astro-dev-toolbar');
73
+ const toolbarRoot = toolbar?.shadowRoot;
74
+ const auditCanvas = toolbarRoot?.querySelector<HTMLElement>(
75
+ 'astro-dev-toolbar-app-canvas[data-app-id="astro:audit"]',
76
+ );
77
+ const auditWindow = auditCanvas?.shadowRoot?.querySelector<AuditWindow>(
78
+ 'astro-dev-toolbar-audit-window',
79
+ );
80
+ for (const audit of auditWindow?.audits ?? []) {
81
+ const root = audit.card?.shadowRoot;
82
+ const detail = root?.querySelector<HTMLElement>('.extended-info');
83
+ if (root == null || detail == null || root.querySelector(`[${ACTION_ATTRIBUTE}]`) !== null) {
84
+ continue;
85
+ }
86
+ const title = audit.rule?.title ?? detail.querySelector('.audit-title')?.textContent?.trim() ?? 'Audit issue';
87
+ const message = [
88
+ audit.rule?.message ?? detail.querySelector('.audit-message')?.textContent?.trim(),
89
+ audit.rule?.description ?? detail.querySelector('.audit-description')?.textContent?.trim(),
90
+ ].filter(Boolean).join('\n\n');
91
+ const source = parseSourceReference(
92
+ audit.auditedElement?.getAttribute('data-astro-ai-source') ?? undefined,
93
+ );
94
+ const action = this.#createAction('audit', () => {
95
+ this.#callbacks.onFix({
96
+ kind: 'audit',
97
+ title,
98
+ message: message || title,
99
+ ...(source.file === undefined ? {} : { file: source.file }),
100
+ ...(source.line === undefined ? {} : { line: source.line }),
101
+ });
102
+ });
103
+ detail.append(action);
104
+ }
105
+ }
106
+
107
+ #createAction(kind: 'error' | 'audit', onClick: () => void): HTMLButtonElement {
108
+ const action = document.createElement('button');
109
+ action.setAttribute(ACTION_ATTRIBUTE, kind);
110
+ action.type = 'button';
111
+ action.textContent = '✦ Fix with AI';
112
+ Object.assign(action.style, {
113
+ background: kind === 'error' ? '#7f1d1d' : '#4c1d95',
114
+ border: `1px solid ${kind === 'error' ? '#ef4444' : '#8b5cf6'}`,
115
+ borderRadius: '7px',
116
+ color: '#fff',
117
+ cursor: 'pointer',
118
+ display: 'inline-flex',
119
+ font: '600 13px/1.2 ui-sans-serif, system-ui, sans-serif',
120
+ margin: '12px 0',
121
+ padding: '9px 12px',
122
+ });
123
+ action.addEventListener('click', (event) => {
124
+ event.preventDefault();
125
+ event.stopPropagation();
126
+ onClick();
127
+ });
128
+ this.#actions.add(action);
129
+ return action;
130
+ }
131
+ }
132
+
133
+ export function parseSourceReference(value?: string): { file?: string; line?: number } {
134
+ if (value === undefined || value.trim() === '') return {};
135
+ const match = value.trim().match(/^(.*?):(\d+)(?::\d+)?$/);
136
+ if (match === null) return { file: value.trim() };
137
+ const file = match[1]?.trim();
138
+ const line = match[2] === undefined ? undefined : Number(match[2]);
139
+ return {
140
+ ...(file === undefined || file === '' ? {} : { file }),
141
+ ...(line === undefined || !Number.isFinite(line) ? {} : { line }),
142
+ };
143
+ }
144
+
145
+ function normalizeProjectFile(file: string): string {
146
+ const root = (window as Window & { __astro_dev_toolbar__?: { root?: string } })
147
+ .__astro_dev_toolbar__?.root;
148
+ if (root === undefined) return file;
149
+ const normalizedRoot = root.endsWith('/') ? root : `${root}/`;
150
+ return file.startsWith(normalizedRoot) ? file.slice(normalizedRoot.length) : file;
151
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The editor's stacking order, in one place.
3
+ *
4
+ * These sit at the top of the z-index range so they clear application UI; what
5
+ * matters is their order relative to each other. Chat windows own a band so the
6
+ * focused window can rise above its siblings while every window still stays
7
+ * above the selection overlays that point at the page.
8
+ */
9
+ export const EDITOR_LAYERS = {
10
+ actionBar: 2147483647,
11
+ chatWindowTop: 2147483646,
12
+ chatWindowBase: 2147483640,
13
+ insertionControl: 2147483638,
14
+ marquee: 2147483638,
15
+ selectionOutline: 2147483637,
16
+ hoverOutline: 2147483636,
17
+ selectionConnector: 2147483635,
18
+ } as const;
19
+
20
+ /** How many chat windows can be stacked with a distinct z-index. */
21
+ export const MAX_STACKED_CHAT_WINDOWS =
22
+ EDITOR_LAYERS.chatWindowTop - EDITOR_LAYERS.chatWindowBase + 1;
23
+
24
+ /**
25
+ * Places a chat window in its band. The focused window always takes the top
26
+ * slot; the rest keep their relative order below it.
27
+ */
28
+ export function chatWindowLayer(focused: boolean, rank: number): number {
29
+ if (focused) return EDITOR_LAYERS.chatWindowTop;
30
+ const offset = Math.min(Math.max(rank, 0), MAX_STACKED_CHAT_WINDOWS - 2);
31
+ return EDITOR_LAYERS.chatWindowBase + offset;
32
+ }