microsoft/TypeAgent

Public

mirrored from https://github.com/microsoft/TypeAgentAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
12b0efd68e2f7b5eca596716b74196dfd25ed2c2

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

ts/examples/demo-script-viewer/index.html

1946lines · modecode

1<!doctype html>
2<!-- Copyright (c) Microsoft Corporation.
3 Licensed under the MIT License. -->
4
5<html lang="en">
6 <!--
7 Copyright (c) Microsoft Corporation.
8 Licensed under the MIT License.
9
10 Single-file HTML. Open directly in a browser; no server needed.
11-->
12 <head>
13 <meta charset="utf-8" />
14 <meta name="viewport" content="width=device-width, initial-scale=1" />
15 <title>demo-script-viewer</title>
16 <style>
17 :root,
18 body.dark {
19 --bg: #14171c;
20 --bg-elev: #1c2027;
21 --bg-elev-2: #242932;
22 --fg: #e8eaed;
23 --fg-dim: #9aa0a6;
24 --fg-mute: #6b7177;
25 --accent: #5b9aff;
26 --border: #2c313a;
27 --border-soft: #232830;
28 --modal-overlay: rgba(0, 0, 0, 0.55);
29 --shadow-modal: 0 10px 40px rgba(0, 0, 0, 0.4);
30
31 --c-narration: #5b9aff;
32 --c-narration-bg: rgba(91, 154, 255, 0.06);
33 --c-cue: #d94545;
34 --c-cue-bg: #4d1c1c;
35 --c-cue-text: #ffffff;
36 --c-command: #6bc66b;
37 --c-command-bg: rgba(107, 198, 107, 0.05);
38 --c-config: #888;
39 --c-config-bg: rgba(140, 140, 140, 0.05);
40 --c-lifecycle: #d9a040;
41 --c-lifecycle-bg: rgba(217, 160, 64, 0.06);
42 --c-recording: #d94545;
43 --c-recording-bg: rgba(217, 69, 69, 0.05);
44 --c-timing: #8a8a8a;
45 --c-timing-bg: rgba(138, 138, 138, 0.04);
46 --c-comment: #5e6571;
47 --c-unknown: #c47fff;
48 --c-unknown-bg: rgba(196, 127, 255, 0.05);
49 }
50
51 body.light {
52 --bg: #f7f8fa;
53 --bg-elev: #ffffff;
54 --bg-elev-2: #eef0f4;
55 --fg: #1f2328;
56 --fg-dim: #4d5564;
57 --fg-mute: #8a929e;
58 --accent: #2563eb;
59 --border: #dadee6;
60 --border-soft: #e8ebf0;
61 --modal-overlay: rgba(15, 23, 42, 0.35);
62 --shadow-modal: 0 12px 36px rgba(15, 23, 42, 0.18);
63
64 --c-narration: #2563eb;
65 --c-narration-bg: rgba(37, 99, 235, 0.06);
66 --c-cue: #c11d1d;
67 --c-cue-bg: #fde7e7;
68 --c-cue-text: #7a1010;
69 --c-command: #15803d;
70 --c-command-bg: rgba(21, 128, 61, 0.05);
71 --c-config: #6b7280;
72 --c-config-bg: rgba(107, 114, 128, 0.04);
73 --c-lifecycle: #b45309;
74 --c-lifecycle-bg: rgba(180, 83, 9, 0.05);
75 --c-recording: #c11d1d;
76 --c-recording-bg: rgba(193, 29, 29, 0.04);
77 --c-timing: #6b7280;
78 --c-timing-bg: rgba(107, 114, 128, 0.04);
79 --c-comment: #94a3b8;
80 --c-unknown: #7c3aed;
81 --c-unknown-bg: rgba(124, 58, 237, 0.05);
82 }
83
84 * {
85 box-sizing: border-box;
86 }
87
88 html,
89 body {
90 margin: 0;
91 padding: 0;
92 height: 100%;
93 }
94
95 body {
96 background: var(--bg);
97 color: var(--fg);
98 font-family:
99 "Segoe UI",
100 system-ui,
101 -apple-system,
102 sans-serif;
103 font-size: 14px;
104 display: flex;
105 flex-direction: column;
106 min-height: 100vh;
107 }
108
109 /* ---------- top bar ---------- */
110 header {
111 background: var(--bg-elev);
112 border-bottom: 1px solid var(--border);
113 padding: 10px 16px;
114 display: flex;
115 align-items: center;
116 justify-content: space-between;
117 gap: 16px;
118 }
119 header .doc-title {
120 display: flex;
121 align-items: baseline;
122 gap: 12px;
123 min-width: 0;
124 flex: 1;
125 }
126 header .doc-title .filename {
127 font-weight: 600;
128 letter-spacing: 0.02em;
129 white-space: nowrap;
130 overflow: hidden;
131 text-overflow: ellipsis;
132 }
133 header .doc-title .block-count {
134 font-size: 12px;
135 color: var(--fg-mute);
136 white-space: nowrap;
137 }
138 header .doc-title .unsaved {
139 color: #d9a040;
140 font-size: 12px;
141 }
142
143 /* ---------- toolbar ---------- */
144 .toolbar {
145 background: var(--bg-elev);
146 border-bottom: 1px solid var(--border);
147 padding: 8px 16px;
148 display: flex;
149 gap: 16px;
150 flex-wrap: wrap;
151 align-items: center;
152 }
153 .toolbar-group {
154 display: flex;
155 align-items: center;
156 gap: 6px;
157 padding: 4px 10px;
158 background: var(--bg-elev-2);
159 border-radius: 6px;
160 }
161 .toolbar-group .label {
162 font-size: 11px;
163 color: var(--fg-mute);
164 text-transform: uppercase;
165 letter-spacing: 0.06em;
166 margin-right: 4px;
167 }
168 .btn {
169 background: transparent;
170 color: var(--fg);
171 border: 1px solid var(--border);
172 border-radius: 4px;
173 padding: 5px 12px;
174 cursor: pointer;
175 font-size: 13px;
176 font-family: inherit;
177 }
178 .btn:hover {
179 background: var(--bg-elev-2);
180 border-color: var(--fg-mute);
181 }
182 .btn.primary {
183 background: var(--accent);
184 border-color: var(--accent);
185 color: white;
186 }
187 .btn.primary:hover {
188 background: #4e8bee;
189 }
190 .btn.danger {
191 color: #ff7a7a;
192 border-color: #5c2424;
193 }
194 .btn.danger:hover {
195 background: #2a1212;
196 }
197 .btn-icon {
198 padding: 4px 8px;
199 font-size: 12px;
200 }
201 .btn-square {
202 padding: 5px 9px;
203 font-size: 14px;
204 line-height: 1;
205 min-width: 30px;
206 }
207 .header-controls {
208 display: flex;
209 align-items: center;
210 gap: 12px;
211 }
212 .icon-btn {
213 background: transparent;
214 color: var(--fg-dim);
215 border: 1px solid var(--border);
216 border-radius: 4px;
217 padding: 5px 8px;
218 cursor: pointer;
219 display: inline-flex;
220 align-items: center;
221 justify-content: center;
222 line-height: 1;
223 font-family: inherit;
224 }
225 .icon-btn:hover {
226 background: var(--bg-elev-2);
227 color: var(--fg);
228 border-color: var(--fg-mute);
229 }
230 .icon-btn.primary {
231 background: var(--accent);
232 border-color: var(--accent);
233 color: white;
234 }
235 .icon-btn.primary:hover {
236 background: var(--accent);
237 filter: brightness(1.1);
238 }
239 .icon-btn svg {
240 display: block;
241 }
242 .icon-btn-group {
243 display: inline-flex;
244 gap: 4px;
245 }
246
247 /* Run-modal layout */
248 .run-options {
249 margin: 8px 0 16px;
250 }
251 .run-option-row {
252 display: flex;
253 align-items: center;
254 gap: 14px;
255 padding: 7px 0;
256 }
257 .run-option-label {
258 min-width: 56px;
259 color: var(--fg-dim);
260 font-size: 13px;
261 }
262 .run-option-hint {
263 color: var(--fg-mute);
264 font-size: 11px;
265 margin-left: auto;
266 font-style: italic;
267 }
268 .run-actions {
269 display: flex;
270 gap: 8px;
271 margin: 16px 0;
272 padding: 14px 0;
273 border-top: 1px solid var(--border);
274 border-bottom: 1px solid var(--border);
275 }
276 .run-actions .btn {
277 flex: 1;
278 padding: 9px 12px;
279 }
280
281 .toggle {
282 display: inline-flex;
283 background: var(--bg);
284 border: 1px solid var(--border);
285 border-radius: 4px;
286 overflow: hidden;
287 }
288 .toggle button {
289 background: transparent;
290 color: var(--fg-dim);
291 border: 0;
292 padding: 4px 12px;
293 font-size: 12px;
294 cursor: pointer;
295 font-family: inherit;
296 }
297 .toggle button.on {
298 background: var(--accent);
299 color: white;
300 }
301
302 /* ---------- main ---------- */
303 main {
304 flex: 1;
305 display: flex;
306 min-height: 0;
307 }
308 .editor {
309 flex: 1;
310 padding: 24px 32px;
311 overflow-y: auto;
312 }
313
314 /* ---------- blocks ---------- */
315 .block {
316 position: relative;
317 margin: 8px 0;
318 padding: 10px 14px;
319 background: var(--block-bg);
320 border-left: 3px solid var(--block-color);
321 border-radius: 6px;
322 transition: background 0.12s;
323 }
324 .block:hover {
325 background: color-mix(in srgb, var(--block-bg) 60%, var(--bg-elev-2));
326 }
327 .block.selected {
328 background: color-mix(in srgb, var(--block-bg) 40%, var(--bg-elev-2));
329 box-shadow: 0 0 0 1px var(--block-color);
330 }
331
332 .block-header {
333 display: flex;
334 justify-content: space-between;
335 align-items: center;
336 margin-bottom: 4px;
337 user-select: none;
338 cursor: grab;
339 }
340 .block-header:active {
341 cursor: grabbing;
342 }
343 .block.dragging {
344 opacity: 0.4;
345 }
346 .block-tag {
347 display: inline-flex;
348 align-items: center;
349 gap: 6px;
350 font-size: 10px;
351 font-weight: 700;
352 color: var(--block-color);
353 text-transform: uppercase;
354 letter-spacing: 0.08em;
355 }
356 .block-tag .subkind {
357 font-weight: 400;
358 color: var(--fg-mute);
359 text-transform: none;
360 letter-spacing: 0;
361 margin-left: 4px;
362 }
363 .block-controls {
364 display: inline-flex;
365 gap: 4px;
366 /* visibility (not display) so the header reserves space even when hidden — */
367 /* prevents block height from jumping when hover reveals the controls. */
368 visibility: hidden;
369 }
370 .block:hover .block-controls,
371 .block.selected .block-controls {
372 visibility: visible;
373 }
374 .block-content {
375 font-size: 14px;
376 line-height: 1.55;
377 color: var(--fg);
378 white-space: pre-wrap;
379 outline: none;
380 }
381 .block-content[contenteditable="true"] {
382 background: var(--bg);
383 border: 1px solid var(--accent);
384 border-radius: 4px;
385 padding: 6px 8px;
386 margin: 2px -8px;
387 }
388 .block-attrs {
389 margin-top: 8px;
390 font-size: 12px;
391 color: var(--fg-dim);
392 }
393 .attr-pill {
394 display: inline-block;
395 background: var(--bg);
396 border: 1px solid var(--border);
397 border-radius: 3px;
398 padding: 2px 8px;
399 margin-right: 4px;
400 font-family: "JetBrains Mono", "Consolas", monospace;
401 font-size: 11px;
402 }
403 .attr-pill .k {
404 color: var(--fg-mute);
405 }
406 .attr-pill .v {
407 color: var(--fg);
408 }
409
410 /* Per-kind block colors */
411 .block.narration {
412 --block-color: var(--c-narration);
413 --block-bg: var(--c-narration-bg);
414 }
415 .block.narration .block-content {
416 font-style: italic;
417 }
418 .block.cue {
419 --block-color: var(--c-cue);
420 --block-bg: var(--c-cue-bg);
421 }
422 .block.cue .block-content {
423 font-weight: 500;
424 color: var(--c-cue-text);
425 }
426 .block.cue .block-tag {
427 color: var(--c-cue-text);
428 }
429 .block.command {
430 --block-color: var(--c-command);
431 --block-bg: var(--c-command-bg);
432 }
433 .block.command .block-content {
434 font-family: "JetBrains Mono", "Consolas", monospace;
435 font-size: 13px;
436 }
437 .block.config {
438 --block-color: var(--c-config);
439 --block-bg: var(--c-config-bg);
440 }
441 .block.config .block-content {
442 color: var(--fg-dim);
443 font-family: "JetBrains Mono", "Consolas", monospace;
444 font-size: 12px;
445 }
446 .block.comment {
447 --block-color: transparent;
448 --block-bg: transparent;
449 padding-left: 22px;
450 }
451 .block.comment .block-content {
452 color: var(--c-comment);
453 font-style: italic;
454 font-size: 13px;
455 }
456 .block.comment::before {
457 content: "#";
458 position: absolute;
459 left: 6px;
460 top: 12px;
461 color: var(--c-comment);
462 font-family: monospace;
463 }
464 .block.lifecycle {
465 --block-color: var(--c-lifecycle);
466 --block-bg: var(--c-lifecycle-bg);
467 }
468 .block.recording {
469 --block-color: var(--c-recording);
470 --block-bg: var(--c-recording-bg);
471 }
472 .block.recording .block-tag::before {
473 content: "●";
474 color: var(--c-recording);
475 margin-right: 4px;
476 }
477 .block.timing {
478 --block-color: var(--c-timing);
479 --block-bg: var(--c-timing-bg);
480 }
481 .block.timing .block-content {
482 color: var(--fg-dim);
483 font-size: 13px;
484 }
485 .block.unknown {
486 --block-color: var(--c-unknown);
487 --block-bg: var(--c-unknown-bg);
488 }
489
490 /* divider + insert button */
491 .divider {
492 position: relative;
493 height: 12px;
494 transition:
495 height 0.12s ease,
496 background 0.12s ease;
497 border-radius: 4px;
498 }
499 .divider .insert-btn {
500 position: absolute;
501 top: 50%;
502 left: 50%;
503 transform: translate(-50%, -50%);
504 background: var(--bg-elev-2);
505 color: var(--fg-dim);
506 border: 1px solid var(--border);
507 border-radius: 12px;
508 width: 22px;
509 height: 22px;
510 font-size: 14px;
511 font-weight: 600;
512 cursor: pointer;
513 display: none;
514 align-items: center;
515 justify-content: center;
516 padding: 0;
517 line-height: 1;
518 }
519 .divider:hover .insert-btn {
520 display: inline-flex;
521 }
522 .insert-btn:hover {
523 background: var(--accent);
524 color: white;
525 border-color: var(--accent);
526 }
527
528 /* Drop zones — visible only while a drag is in progress so the
529 recorder doesn't see flickering dividers during normal use. */
530 body.dragging .divider {
531 height: 20px;
532 }
533 body.dragging .divider.drop-target {
534 background: var(--accent);
535 opacity: 0.7;
536 height: 24px;
537 }
538 body.dragging .insert-btn {
539 display: none !important;
540 }
541
542 /* modal */
543 .modal-overlay {
544 position: fixed;
545 inset: 0;
546 background: var(--modal-overlay);
547 display: none;
548 align-items: center;
549 justify-content: center;
550 z-index: 100;
551 }
552 .modal-overlay.shown {
553 display: flex;
554 }
555 .modal {
556 background: var(--bg-elev);
557 border: 1px solid var(--border);
558 border-radius: 8px;
559 padding: 20px 22px;
560 min-width: 520px;
561 max-width: 80vw;
562 max-height: 80vh;
563 overflow-y: auto;
564 box-shadow: var(--shadow-modal);
565 }
566 .modal h2 {
567 margin: 0 0 12px 0;
568 font-size: 16px;
569 font-weight: 600;
570 }
571 .modal pre {
572 background: var(--bg);
573 border: 1px solid var(--border);
574 border-radius: 4px;
575 padding: 12px;
576 font-family: "JetBrains Mono", "Consolas", monospace;
577 font-size: 12px;
578 white-space: pre-wrap;
579 word-break: break-all;
580 color: var(--c-command);
581 margin: 0 0 12px 0;
582 }
583 .modal .actions {
584 display: flex;
585 gap: 8px;
586 justify-content: flex-end;
587 }
588 .modal .picker-grid {
589 display: grid;
590 grid-template-columns: repeat(2, 1fr);
591 gap: 8px;
592 margin: 12px 0;
593 }
594 .modal .picker-grid button {
595 background: var(--bg);
596 border: 1px solid var(--border);
597 color: var(--fg);
598 padding: 10px 12px;
599 border-radius: 4px;
600 cursor: pointer;
601 text-align: left;
602 font-family: inherit;
603 font-size: 13px;
604 }
605 .modal .picker-grid button:hover {
606 border-color: var(--accent);
607 }
608 .modal .picker-grid button .tag {
609 display: block;
610 font-size: 10px;
611 color: var(--fg-mute);
612 text-transform: uppercase;
613 letter-spacing: 0.06em;
614 margin-bottom: 4px;
615 }
616
617 /* attributes editor inside command/config blocks */
618 .attrs-editor {
619 margin-top: 10px;
620 padding: 8px;
621 background: var(--bg);
622 border-radius: 4px;
623 border: 1px solid var(--border);
624 }
625 .attrs-editor table {
626 width: 100%;
627 border-collapse: collapse;
628 }
629 .attrs-editor td {
630 padding: 3px 6px;
631 font-size: 12px;
632 }
633 .attrs-editor td:first-child {
634 width: 30%;
635 color: var(--fg-dim);
636 font-family: monospace;
637 }
638 .attrs-editor input {
639 background: var(--bg-elev);
640 border: 1px solid var(--border);
641 color: var(--fg);
642 padding: 3px 6px;
643 border-radius: 3px;
644 width: 100%;
645 font-family: monospace;
646 font-size: 12px;
647 }
648 .attrs-editor .attr-actions {
649 display: flex;
650 gap: 4px;
651 margin-top: 8px;
652 }
653 .empty {
654 text-align: center;
655 padding: 80px 20px;
656 color: var(--fg-mute);
657 }
658 .empty p {
659 margin: 0 0 12px 0;
660 }
661 </style>
662 </head>
663 <body>
664 <header>
665 <div class="doc-title">
666 <span class="filename" id="status-name">no script loaded</span>
667 <span class="block-count" id="status-blocks"></span>
668 <span class="unsaved" id="status-dirty" style="display: none"
669 >● unsaved</span
670 >
671 </div>
672 <div class="header-controls">
673 <span class="icon-btn-group">
674 <button class="icon-btn" id="btn-new" title="New script (Ctrl+N)">
675 <svg
676 viewBox="0 0 16 16"
677 width="14"
678 height="14"
679 fill="none"
680 stroke="currentColor"
681 stroke-width="1.5"
682 stroke-linecap="round"
683 stroke-linejoin="round"
684 >
685 <path d="M3 1.5 H9 L13 5.5 V14.5 H3 Z" />
686 <path d="M9 1.5 V5.5 H13" />
687 <line x1="8" y1="9" x2="8" y2="12" />
688 <line x1="6.5" y1="10.5" x2="9.5" y2="10.5" />
689 </svg>
690 </button>
691 <button class="icon-btn" id="btn-open" title="Open script (Ctrl+O)">
692 <svg
693 viewBox="0 0 16 16"
694 width="14"
695 height="14"
696 fill="none"
697 stroke="currentColor"
698 stroke-width="1.5"
699 stroke-linecap="round"
700 stroke-linejoin="round"
701 >
702 <path d="M1.5 4.5 H6.5 L8 6 H14.5 V13 H1.5 Z" />
703 </svg>
704 </button>
705 <button class="icon-btn" id="btn-save" title="Save script (Ctrl+S)">
706 <svg
707 viewBox="0 0 16 16"
708 width="14"
709 height="14"
710 fill="none"
711 stroke="currentColor"
712 stroke-width="1.5"
713 stroke-linecap="round"
714 stroke-linejoin="round"
715 >
716 <line x1="8" y1="2" x2="8" y2="10" />
717 <polyline points="4.5,7 8,10.5 11.5,7" />
718 <line x1="2.5" y1="14" x2="13.5" y2="14" />
719 </svg>
720 </button>
721 <button class="icon-btn primary" id="btn-run" title="Run demo">
722 <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
723 <path d="M4 3 L13 8 L4 13 Z" />
724 </svg>
725 </button>
726 </span>
727 <button class="icon-btn" id="btn-theme" title="Toggle theme">☾</button>
728 </div>
729 </header>
730
731 <main>
732 <section class="editor" id="editor">
733 <div class="empty" id="empty-state">
734 <p>No script loaded.</p>
735 <p>
736 <button class="btn primary" id="btn-empty-open">Open file</button>
737 <button class="btn" id="btn-empty-new">New script</button>
738 </p>
739 </div>
740 <div id="blocks"></div>
741 </section>
742 </main>
743
744 <div class="modal-overlay" id="modal-overlay">
745 <div class="modal" id="modal-content"></div>
746 </div>
747
748 <input
749 type="file"
750 id="hidden-file-input"
751 accept=".txt"
752 style="display: none"
753 />
754
755 <script>
756 "use strict";
757
758 // ============================================================
759 // Parser — matches demo-driver.ahk behavior
760 // ============================================================
761
762 function tokenize(s) {
763 const tokens = [];
764 let buf = "";
765 let inQuotes = false;
766 let pushed = false;
767 for (let i = 0; i < s.length; i++) {
768 const c = s[i];
769 if (inQuotes) {
770 if (c === "\\" && i + 1 < s.length) {
771 const next = s[i + 1];
772 if (next === '"' || next === "\\") {
773 buf += next;
774 i++;
775 continue;
776 }
777 }
778 if (c === '"') {
779 inQuotes = false;
780 continue;
781 }
782 buf += c;
783 pushed = true;
784 continue;
785 }
786 if (c === " " || c === "\t") {
787 if (pushed) {
788 tokens.push(buf);
789 buf = "";
790 pushed = false;
791 }
792 continue;
793 }
794 if (c === '"') {
795 inQuotes = true;
796 pushed = true;
797 continue;
798 }
799 buf += c;
800 pushed = true;
801 }
802 if (pushed) tokens.push(buf);
803 return tokens;
804 }
805
806 function parseDirective(line) {
807 const body = line.slice(1);
808 const tokens = tokenize(body);
809 if (tokens.length === 0) throw new Error("Empty directive");
810 const name = tokens[0];
811 let positional = "";
812 const attrs = {};
813 for (let i = 1; i < tokens.length; i++) {
814 const t = tokens[i];
815 if (t.includes("=")) {
816 const eq = t.indexOf("=");
817 attrs[t.slice(0, eq)] = t.slice(eq + 1);
818 } else if (positional === "") {
819 positional = t;
820 }
821 }
822 return { name, positional, attrs };
823 }
824
825 const CONFIG_DIRECTIVES = new Set([
826 "defaults",
827 "voice",
828 "type-speed",
829 "type-jitter",
830 "mode",
831 "focus",
832 ]);
833 const LIFECYCLE_DIRECTIVES = new Set(["setup", "teardown"]);
834 const RECORDING_DIRECTIVES = new Set(["record-start", "record-stop"]);
835 const NARRATION_DIRECTIVES = new Set(["say", "say-block"]);
836 const TIMING_DIRECTIVES = new Set(["sleep", "pause"]);
837
838 function classifyDirective(name) {
839 if (CONFIG_DIRECTIVES.has(name)) return "config";
840 if (LIFECYCLE_DIRECTIVES.has(name)) return "lifecycle";
841 if (RECORDING_DIRECTIVES.has(name)) return "recording";
842 if (NARRATION_DIRECTIVES.has(name)) return "narration";
843 if (TIMING_DIRECTIVES.has(name)) return "timing";
844 if (name === "cue") return "cue";
845 return "unknown";
846 }
847
848 function parseScript(text) {
849 const lines = text.split(/\r?\n/);
850 const blocks = [];
851 let lastCommandIdx = -1;
852 let lastCommentIdx = -1; // append target for grouped comments
853 let commentGroupActive = false;
854
855 for (const rawLine of lines) {
856 const trimmed = rawLine.trim();
857 if (trimmed === "") {
858 commentGroupActive = false;
859 continue;
860 }
861 if (trimmed.startsWith("#")) {
862 const commentLine = trimmed.replace(/^#\s?/, "");
863 if (commentGroupActive && lastCommentIdx >= 0) {
864 blocks[lastCommentIdx].text += "\n" + commentLine;
865 blocks[lastCommentIdx].raw.push(rawLine);
866 } else {
867 blocks.push({
868 kind: "comment",
869 text: commentLine,
870 attrs: {},
871 raw: [rawLine],
872 });
873 lastCommentIdx = blocks.length - 1;
874 }
875 commentGroupActive = true;
876 continue;
877 }
878 // Any non-comment line breaks comment grouping
879 commentGroupActive = false;
880 if (!trimmed.startsWith("@")) {
881 blocks.push({
882 kind: "command",
883 text: trimmed,
884 attrs: {},
885 raw: [rawLine],
886 });
887 lastCommandIdx = blocks.length - 1;
888 continue;
889 }
890 const parsed = parseDirective(trimmed);
891 const name = parsed.name;
892
893 if (
894 name === "with" ||
895 name === "expect" ||
896 name === "wait-completion"
897 ) {
898 if (lastCommandIdx < 0) {
899 blocks.push({
900 kind: "unknown",
901 directive: name,
902 text: "(@" + name + " with no preceding command)",
903 attrs: parsed.attrs,
904 raw: [rawLine],
905 });
906 continue;
907 }
908 const target = blocks[lastCommandIdx];
909 if (name === "expect") target.attrs.expect = parsed.positional;
910 if (name === "wait-completion")
911 target.attrs["wait-completion"] = true;
912 Object.assign(target.attrs, parsed.attrs);
913 target.raw.push(rawLine);
914 continue;
915 }
916
917 if (name === "type") {
918 blocks.push({
919 kind: "command",
920 text: parsed.positional,
921 attrs: { ...parsed.attrs },
922 raw: [rawLine],
923 });
924 lastCommandIdx = blocks.length - 1;
925 continue;
926 }
927
928 const kind = classifyDirective(name);
929 blocks.push({
930 kind,
931 directive: name,
932 text: parsed.positional,
933 attrs: parsed.attrs,
934 raw: [rawLine],
935 });
936 }
937 return blocks;
938 }
939
940 // ============================================================
941 // Serializer — back to .txt
942 // ============================================================
943
944 function quoteIfNeeded(s) {
945 if (s === null || s === undefined) return "";
946 if (typeof s !== "string") s = String(s);
947 if (s === "") return '""';
948 if (/[\s"]/.test(s))
949 return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
950 return s;
951 }
952
953 function attrsToString(attrs, exclude = []) {
954 return Object.entries(attrs)
955 .filter(([k]) => !exclude.includes(k))
956 .map(([k, v]) => (v === true ? k : `${k}=${quoteIfNeeded(v)}`))
957 .join(" ");
958 }
959
960 function serializeBlock(b) {
961 switch (b.kind) {
962 case "comment":
963 // Comments may be multi-line; each line gets its own `# ` prefix.
964 return b.text
965 .split("\n")
966 .map((line) => "# " + line)
967 .join("\n");
968 case "command": {
969 const lines = [b.text];
970 if (b.attrs.expect !== undefined) {
971 const rest = attrsToString(b.attrs, ["expect"]);
972 lines.push(
973 `@expect ${quoteIfNeeded(b.attrs.expect)}${rest ? " " + rest : ""}`,
974 );
975 } else if (b.attrs["wait-completion"]) {
976 const rest = attrsToString(b.attrs, ["wait-completion"]);
977 lines.push(`@wait-completion${rest ? " " + rest : ""}`);
978 } else if (Object.keys(b.attrs).length > 0) {
979 lines.push(`@with ${attrsToString(b.attrs)}`);
980 }
981 return lines.join("\n");
982 }
983 case "cue":
984 case "narration":
985 case "lifecycle":
986 case "config":
987 case "recording":
988 case "timing":
989 case "unknown": {
990 const parts = [`@${b.directive}`];
991 if (b.text !== "" && b.text !== undefined && b.text !== null)
992 parts.push(quoteIfNeeded(b.text));
993 const attrStr = attrsToString(b.attrs);
994 if (attrStr) parts.push(attrStr);
995 return parts.join(" ");
996 }
997 default:
998 return b.raw ? b.raw.join("\n") : "";
999 }
1000 }
1001
1002 function serializeScript(blocks) {
1003 return blocks.map(serializeBlock).join("\n\n") + "\n";
1004 }
1005
1006 // ============================================================
1007 // State + render
1008 // ============================================================
1009
1010 const STATE = {
1011 filename: null,
1012 blocks: [],
1013 dirty: false,
1014 mode: "manual", // 'manual' | 'auto'
1015 hud: "on", // 'on' | 'off'
1016 cues: "on", // 'on' | 'off'
1017 selectedIdx: -1,
1018 };
1019
1020 // ============================================================
1021 // Undo / redo — snapshot-based, reset on script load
1022 // ============================================================
1023
1024 const UNDO_LIMIT = 100;
1025 const undoStack = [];
1026 const redoStack = [];
1027
1028 function snapshot() {
1029 return JSON.stringify(STATE.blocks);
1030 }
1031
1032 // Capture current state BEFORE a mutation. Coalesces no-op snapshots.
1033 function pushUndo() {
1034 const snap = snapshot();
1035 if (undoStack.length && undoStack[undoStack.length - 1] === snap)
1036 return;
1037 undoStack.push(snap);
1038 if (undoStack.length > UNDO_LIMIT) undoStack.shift();
1039 redoStack.length = 0;
1040 }
1041
1042 function undo() {
1043 if (!undoStack.length) return;
1044 redoStack.push(snapshot());
1045 STATE.blocks = JSON.parse(undoStack.pop());
1046 markDirty();
1047 renderBlocks();
1048 }
1049
1050 function redo() {
1051 if (!redoStack.length) return;
1052 undoStack.push(snapshot());
1053 STATE.blocks = JSON.parse(redoStack.pop());
1054 markDirty();
1055 renderBlocks();
1056 }
1057
1058 function resetUndo() {
1059 undoStack.length = 0;
1060 redoStack.length = 0;
1061 }
1062
1063 // Drag-to-reorder state. Tracks the source block index across the
1064 // dragstart → dragover → drop sequence; null when no drag in progress.
1065 let DRAG_SOURCE_IDX = null;
1066
1067 const $ = (sel) => document.querySelector(sel);
1068 const $$ = (sel) => Array.from(document.querySelectorAll(sel));
1069
1070 function markDirty(yes = true) {
1071 STATE.dirty = yes;
1072 $("#status-dirty").style.display = yes ? "" : "none";
1073 }
1074
1075 function renderStatus() {
1076 $("#status-name").textContent = STATE.filename || "no script loaded";
1077 const n = STATE.blocks.length;
1078 $("#status-blocks").textContent = n
1079 ? `${n} ${n === 1 ? "block" : "blocks"}`
1080 : "";
1081 }
1082
1083 const KIND_LABELS = {
1084 comment: "Comment",
1085 config: "Config",
1086 lifecycle: "Lifecycle",
1087 recording: "Recording",
1088 narration: "Narration",
1089 cue: "Cue",
1090 timing: "Timing",
1091 command: "Command",
1092 unknown: "Unknown",
1093 };
1094
1095 function blockSubkind(b) {
1096 if (b.kind === "command") {
1097 if (b.attrs.expect) return `expect "${b.attrs.expect}"`;
1098 if (b.attrs["wait-completion"]) return "wait-completion";
1099 return "";
1100 }
1101 return b.directive || "";
1102 }
1103
1104 function blockMainContent(b) {
1105 if (b.kind === "comment") return b.text;
1106 if (b.kind === "command") return b.text;
1107 if (b.kind === "narration") return '"' + b.text + '"';
1108 if (b.kind === "cue") return b.text;
1109 if (b.kind === "recording") return ""; // no payload
1110 if (b.kind === "timing" && b.directive === "pause")
1111 return "(wait for Ctrl+Right)";
1112 if (b.kind === "timing" && b.directive === "sleep") return b.text;
1113 if (b.kind === "config" && b.directive === "defaults") {
1114 return Object.entries(b.attrs)
1115 .map(([k, v]) => `${k}=${v}`)
1116 .join(" ");
1117 }
1118 let s = b.text || "";
1119 if (Object.keys(b.attrs).length) s += " " + attrsToString(b.attrs);
1120 return s.trim();
1121 }
1122
1123 function renderBlocks() {
1124 const root = $("#blocks");
1125 root.innerHTML = "";
1126 if (!STATE.blocks.length) {
1127 $("#empty-state").style.display = "";
1128 renderStatus();
1129 return;
1130 }
1131 $("#empty-state").style.display = "none";
1132
1133 // Top divider (insert at 0)
1134 root.appendChild(makeDivider(0));
1135
1136 STATE.blocks.forEach((b, idx) => {
1137 root.appendChild(makeBlock(b, idx));
1138 root.appendChild(makeDivider(idx + 1));
1139 });
1140
1141 renderStatus();
1142 }
1143
1144 function makeDivider(insertAt) {
1145 const d = document.createElement("div");
1146 d.className = "divider";
1147 d.dataset.insertAt = insertAt;
1148 d.innerHTML = `<button class="insert-btn" title="Insert block here">+</button>`;
1149 d.querySelector(".insert-btn").addEventListener("click", () =>
1150 openInsertPicker(insertAt),
1151 );
1152 // Drop target for drag-to-reorder
1153 d.addEventListener("dragover", (e) => {
1154 if (DRAG_SOURCE_IDX === null) return;
1155 e.preventDefault();
1156 e.dataTransfer.dropEffect = "move";
1157 d.classList.add("drop-target");
1158 });
1159 d.addEventListener("dragleave", () => {
1160 d.classList.remove("drop-target");
1161 });
1162 d.addEventListener("drop", (e) => {
1163 d.classList.remove("drop-target");
1164 if (DRAG_SOURCE_IDX === null) return;
1165 e.preventDefault();
1166 moveBlockTo(DRAG_SOURCE_IDX, insertAt);
1167 DRAG_SOURCE_IDX = null;
1168 document.body.classList.remove("dragging");
1169 });
1170 return d;
1171 }
1172
1173 function makeBlock(b, idx) {
1174 const div = document.createElement("div");
1175 div.className =
1176 `block ${b.kind}` + (STATE.selectedIdx === idx ? " selected" : "");
1177 div.dataset.idx = idx;
1178
1179 const subkind = blockSubkind(b);
1180 div.innerHTML = `
1181 <div class="block-header" draggable="true" title="Drag to reorder">
1182 <span class="block-tag">${KIND_LABELS[b.kind] || b.kind}
1183 ${subkind ? `<span class="subkind">${escapeHtml(subkind)}</span>` : ""}
1184 </span>
1185 <span class="block-controls">
1186 <button class="btn btn-icon" data-act="up">↑</button>
1187 <button class="btn btn-icon" data-act="down">↓</button>
1188 <button class="btn btn-icon" data-act="edit-attrs" title="Edit attributes">⚙</button>
1189 <button class="btn btn-icon danger" data-act="del">×</button>
1190 </span>
1191 </div>
1192 <div class="block-content" data-content="true">${escapeHtml(blockMainContent(b))}</div>
1193 `;
1194
1195 // Drag source — the header carries the draggable attribute so
1196 // selecting / editing block content stays clickable without triggering a drag.
1197 const header = div.querySelector(".block-header");
1198 header.addEventListener("dragstart", (e) => {
1199 DRAG_SOURCE_IDX = idx;
1200 try {
1201 e.dataTransfer.setData("text/plain", String(idx));
1202 } catch {}
1203 e.dataTransfer.effectAllowed = "move";
1204 div.classList.add("dragging");
1205 document.body.classList.add("dragging");
1206 });
1207 header.addEventListener("dragend", () => {
1208 DRAG_SOURCE_IDX = null;
1209 div.classList.remove("dragging");
1210 document.body.classList.remove("dragging");
1211 // Clear any stuck drop-target highlights
1212 document
1213 .querySelectorAll(".divider.drop-target")
1214 .forEach((d) => d.classList.remove("drop-target"));
1215 });
1216
1217 // Wait pill for commands
1218 if (
1219 b.kind === "command" &&
1220 (b.attrs.expect ||
1221 b.attrs["wait-completion"] ||
1222 Object.keys(b.attrs).length)
1223 ) {
1224 const attrEl = document.createElement("div");
1225 attrEl.className = "block-attrs";
1226 attrEl.innerHTML = "wait: " + renderAttrPills(b.attrs);
1227 div.appendChild(attrEl);
1228 } else if (Object.keys(b.attrs).length && b.kind !== "config") {
1229 const attrEl = document.createElement("div");
1230 attrEl.className = "block-attrs";
1231 attrEl.innerHTML = renderAttrPills(b.attrs);
1232 div.appendChild(attrEl);
1233 }
1234
1235 // Wire up controls
1236 div.querySelector('[data-act="up"]').addEventListener("click", (e) => {
1237 e.stopPropagation();
1238 moveBlock(idx, -1);
1239 });
1240 div
1241 .querySelector('[data-act="down"]')
1242 .addEventListener("click", (e) => {
1243 e.stopPropagation();
1244 moveBlock(idx, 1);
1245 });
1246 div.querySelector('[data-act="del"]').addEventListener("click", (e) => {
1247 e.stopPropagation();
1248 deleteBlock(idx);
1249 });
1250 div
1251 .querySelector('[data-act="edit-attrs"]')
1252 .addEventListener("click", (e) => {
1253 e.stopPropagation();
1254 openAttrsEditor(idx);
1255 });
1256
1257 const content = div.querySelector("[data-content]");
1258 content.addEventListener("click", () => startEdit(idx, content));
1259 div.addEventListener("click", () => {
1260 STATE.selectedIdx = idx;
1261 });
1262
1263 return div;
1264 }
1265
1266 function renderAttrPills(attrs) {
1267 return Object.entries(attrs)
1268 .map(([k, v]) => {
1269 const val = v === true ? "✓" : escapeHtml(String(v));
1270 return `<span class="attr-pill"><span class="k">${escapeHtml(k)}</span>=<span class="v">${val}</span></span>`;
1271 })
1272 .join("");
1273 }
1274
1275 function escapeHtml(s) {
1276 if (s === undefined || s === null) return "";
1277 return String(s).replace(
1278 /[&<>"']/g,
1279 (c) =>
1280 ({
1281 "&": "&amp;",
1282 "<": "&lt;",
1283 ">": "&gt;",
1284 '"': "&quot;",
1285 "'": "&#39;",
1286 })[c],
1287 );
1288 }
1289
1290 // ============================================================
1291 // Block edit
1292 // ============================================================
1293
1294 function startEdit(idx, contentEl) {
1295 contentEl.setAttribute("contenteditable", "true");
1296 contentEl.focus();
1297 // Place cursor at end
1298 const range = document.createRange();
1299 range.selectNodeContents(contentEl);
1300 range.collapse(false);
1301 const sel = window.getSelection();
1302 sel.removeAllRanges();
1303 sel.addRange(range);
1304
1305 const finish = (commit) => {
1306 contentEl.setAttribute("contenteditable", "false");
1307 contentEl.removeEventListener("keydown", onKey);
1308 contentEl.removeEventListener("blur", onBlur);
1309 if (commit) {
1310 saveEdit(idx, contentEl.textContent);
1311 } else {
1312 contentEl.textContent = blockMainContent(STATE.blocks[idx]);
1313 }
1314 };
1315 const onKey = (e) => {
1316 if (e.key === "Escape") {
1317 e.preventDefault();
1318 finish(false);
1319 }
1320 if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
1321 e.preventDefault();
1322 finish(true);
1323 }
1324 };
1325 const onBlur = () => finish(true);
1326 contentEl.addEventListener("keydown", onKey);
1327 contentEl.addEventListener("blur", onBlur);
1328 }
1329
1330 function saveEdit(idx, newText) {
1331 const trimmed = newText.trim();
1332 if (STATE.blocks[idx].text === trimmed) return; // no-op
1333 pushUndo();
1334 STATE.blocks[idx].text = trimmed;
1335 markDirty();
1336 renderBlocks();
1337 }
1338
1339 // ============================================================
1340 // Add / move / delete / change kind
1341 // ============================================================
1342
1343 const BLOCK_TEMPLATES = [
1344 {
1345 kind: "command",
1346 label: "Command",
1347 desc: "Type into the focused window",
1348 template: () => ({ kind: "command", text: "new command", attrs: {} }),
1349 },
1350 {
1351 kind: "narration",
1352 label: "Narration (say-block)",
1353 desc: "Spoken, blocks until done",
1354 template: () => ({
1355 kind: "narration",
1356 directive: "say-block",
1357 text: "narration text",
1358 attrs: {},
1359 }),
1360 },
1361 {
1362 kind: "narration",
1363 label: "Narration (say)",
1364 desc: "Spoken, non-blocking",
1365 template: () => ({
1366 kind: "narration",
1367 directive: "say",
1368 text: "narration text",
1369 attrs: {},
1370 }),
1371 },
1372 {
1373 kind: "cue",
1374 label: "Recorder cue",
1375 desc: "Red window, no TTS, pauses",
1376 template: () => ({
1377 kind: "cue",
1378 directive: "cue",
1379 text: "Switch to ...",
1380 attrs: {},
1381 }),
1382 },
1383 {
1384 kind: "timing",
1385 label: "Pause",
1386 desc: "Wait for Ctrl+Right",
1387 template: () => ({
1388 kind: "timing",
1389 directive: "pause",
1390 text: "",
1391 attrs: {},
1392 }),
1393 },
1394 {
1395 kind: "timing",
1396 label: "Sleep",
1397 desc: "Fixed wait",
1398 template: () => ({
1399 kind: "timing",
1400 directive: "sleep",
1401 text: "2s",
1402 attrs: {},
1403 }),
1404 },
1405 {
1406 kind: "recording",
1407 label: "Record start",
1408 desc: "Send recording start hotkey",
1409 template: () => ({
1410 kind: "recording",
1411 directive: "record-start",
1412 text: "",
1413 attrs: {},
1414 }),
1415 },
1416 {
1417 kind: "recording",
1418 label: "Record stop",
1419 desc: "Send recording stop hotkey",
1420 template: () => ({
1421 kind: "recording",
1422 directive: "record-stop",
1423 text: "",
1424 attrs: {},
1425 }),
1426 },
1427 {
1428 kind: "comment",
1429 label: "Comment",
1430 desc: "Note in the script",
1431 template: () => ({
1432 kind: "comment",
1433 text: "comment text",
1434 attrs: {},
1435 }),
1436 },
1437 ];
1438
1439 function openInsertPicker(insertAt) {
1440 const grid = BLOCK_TEMPLATES.map(
1441 (t, i) => `
1442 <button data-idx="${i}">
1443 <span class="tag">${t.label}</span>
1444 ${escapeHtml(t.desc)}
1445 </button>
1446 `,
1447 ).join("");
1448 showModal(`
1449 <h2>Insert block</h2>
1450 <div class="picker-grid">${grid}</div>
1451 <div class="actions"><button class="btn" data-close>Cancel</button></div>
1452 `);
1453 $("#modal-content")
1454 .querySelectorAll(".picker-grid button")
1455 .forEach((b) => {
1456 b.addEventListener("click", () => {
1457 const t = BLOCK_TEMPLATES[+b.dataset.idx];
1458 pushUndo();
1459 STATE.blocks.splice(insertAt, 0, t.template());
1460 markDirty();
1461 hideModal();
1462 renderBlocks();
1463 });
1464 });
1465 }
1466
1467 function moveBlock(idx, delta) {
1468 const target = idx + delta;
1469 if (target < 0 || target >= STATE.blocks.length) return;
1470 pushUndo();
1471 const [b] = STATE.blocks.splice(idx, 1);
1472 STATE.blocks.splice(target, 0, b);
1473 markDirty();
1474 renderBlocks();
1475 }
1476
1477 // Move a block to a position expressed as a divider index (0 = before first
1478 // block, N = after Nth block). Used by drag-and-drop.
1479 function moveBlockTo(fromIdx, dividerIdx) {
1480 if (dividerIdx === fromIdx || dividerIdx === fromIdx + 1) return; // no-op
1481 pushUndo();
1482 const [b] = STATE.blocks.splice(fromIdx, 1);
1483 const adjusted = dividerIdx > fromIdx ? dividerIdx - 1 : dividerIdx;
1484 STATE.blocks.splice(adjusted, 0, b);
1485 markDirty();
1486 renderBlocks();
1487 }
1488
1489 function deleteBlock(idx) {
1490 pushUndo();
1491 STATE.blocks.splice(idx, 1);
1492 markDirty();
1493 renderBlocks();
1494 }
1495
1496 // ============================================================
1497 // Attributes editor
1498 // ============================================================
1499
1500 function openAttrsEditor(idx) {
1501 const b = STATE.blocks[idx];
1502 const rows = Object.entries(b.attrs)
1503 .map(
1504 ([k, v]) => `
1505 <tr>
1506 <td>${escapeHtml(k)}</td>
1507 <td><input data-attr-key="${escapeHtml(k)}" value="${escapeHtml(v === true ? "" : String(v))}"></td>
1508 <td><button class="btn btn-icon" data-attr-del="${escapeHtml(k)}">×</button></td>
1509 </tr>
1510 `,
1511 )
1512 .join("");
1513 showModal(`
1514 <h2>Edit attributes — ${KIND_LABELS[b.kind]}${b.directive ? " / " + b.directive : ""}</h2>
1515 <table class="attrs-editor">
1516 ${rows || '<tr><td colspan="3" style="color: var(--fg-mute);">(no attributes)</td></tr>'}
1517 </table>
1518 <div class="attr-actions">
1519 <input id="new-attr-key" placeholder="new key" style="background: var(--bg); color: var(--fg); border: 1px solid var(--border); padding: 4px 8px; border-radius: 3px;">
1520 <input id="new-attr-val" placeholder="value" style="background: var(--bg); color: var(--fg); border: 1px solid var(--border); padding: 4px 8px; border-radius: 3px;">
1521 <button class="btn" id="add-attr-btn">Add</button>
1522 </div>
1523 <div class="actions" style="margin-top: 12px;">
1524 <button class="btn" data-close>Cancel</button>
1525 <button class="btn primary" id="save-attrs">Save</button>
1526 </div>
1527 `);
1528 $("#add-attr-btn").addEventListener("click", () => {
1529 const k = $("#new-attr-key").value.trim();
1530 const v = $("#new-attr-val").value.trim();
1531 if (!k) return;
1532 b.attrs[k] = v || true;
1533 openAttrsEditor(idx); // re-render
1534 });
1535 $("#save-attrs").addEventListener("click", () => {
1536 pushUndo();
1537 $$("#modal-content input[data-attr-key]").forEach((inp) => {
1538 const k = inp.dataset.attrKey;
1539 b.attrs[k] = inp.value;
1540 });
1541 markDirty();
1542 hideModal();
1543 renderBlocks();
1544 });
1545 $$("#modal-content [data-attr-del]").forEach((btn) => {
1546 btn.addEventListener("click", () => {
1547 delete b.attrs[btn.dataset.attrDel];
1548 openAttrsEditor(idx);
1549 });
1550 });
1551 }
1552
1553 // ============================================================
1554 // Modal
1555 // ============================================================
1556
1557 function showModal(html) {
1558 $("#modal-content").innerHTML = html;
1559 $("#modal-overlay").classList.add("shown");
1560 $("#modal-content")
1561 .querySelectorAll("[data-close]")
1562 .forEach((b) => b.addEventListener("click", hideModal));
1563 }
1564 function hideModal() {
1565 $("#modal-overlay").classList.remove("shown");
1566 }
1567 $("#modal-overlay").addEventListener("click", (e) => {
1568 if (e.target.id === "modal-overlay") hideModal();
1569 });
1570
1571 // ============================================================
1572 // File operations
1573 // ============================================================
1574
1575 async function openFile() {
1576 if (window.showOpenFilePicker) {
1577 try {
1578 const [handle] = await window.showOpenFilePicker({
1579 types: [
1580 {
1581 description: "Demo script",
1582 accept: { "text/plain": [".txt"] },
1583 },
1584 ],
1585 multiple: false,
1586 });
1587 const file = await handle.getFile();
1588 const text = await file.text();
1589 loadScript(file.name, text);
1590 STATE._handle = handle;
1591 } catch {
1592 /* user cancelled */
1593 }
1594 } else {
1595 $("#hidden-file-input").click();
1596 }
1597 }
1598
1599 $("#hidden-file-input").addEventListener("change", async (e) => {
1600 const file = e.target.files[0];
1601 if (!file) return;
1602 const text = await file.text();
1603 loadScript(file.name, text);
1604 });
1605
1606 async function saveFile() {
1607 const text = serializeScript(STATE.blocks);
1608 if (STATE._handle && STATE._handle.createWritable) {
1609 try {
1610 const writable = await STATE._handle.createWritable();
1611 await writable.write(text);
1612 await writable.close();
1613 markDirty(false);
1614 return;
1615 } catch {
1616 /* fall through to download */
1617 }
1618 }
1619 // Fallback: download
1620 const blob = new Blob([text], { type: "text/plain" });
1621 const url = URL.createObjectURL(blob);
1622 const a = document.createElement("a");
1623 a.href = url;
1624 a.download = STATE.filename || "demo-script.txt";
1625 a.click();
1626 URL.revokeObjectURL(url);
1627 markDirty(false);
1628 }
1629
1630 function loadScript(name, text) {
1631 STATE.filename = name;
1632 STATE.blocks = parseScript(text);
1633 STATE._handle = null;
1634 resetUndo();
1635 markDirty(false);
1636 STATE.selectedIdx = -1;
1637 renderBlocks();
1638 }
1639
1640 function newScript() {
1641 if (STATE.dirty && !confirm("Discard unsaved changes?")) return;
1642 STATE.filename = "untitled.txt";
1643 STATE.blocks = [
1644 {
1645 kind: "config",
1646 directive: "defaults",
1647 text: "",
1648 attrs: {
1649 timeout: "30s",
1650 "on-timeout": "warn",
1651 "type-speed": "30",
1652 voice: "Microsoft Aria Natural",
1653 },
1654 },
1655 {
1656 kind: "config",
1657 directive: "focus",
1658 text: "Windows Terminal",
1659 attrs: {},
1660 },
1661 ];
1662 STATE._handle = null;
1663 resetUndo();
1664 markDirty(false);
1665 renderBlocks();
1666 }
1667
1668 // ============================================================
1669 // Run-command builder
1670 // ============================================================
1671
1672 const AHK_PATH = "C:\\Program Files\\AutoHotkey\\v2\\AutoHotkey64.exe";
1673 const DRIVER_PATH =
1674 "D:\\repos\\TypeAgent\\ts\\packages\\copilot-plugin\\demo\\driver\\demo-driver.ahk";
1675
1676 function buildRunCommand() {
1677 const scriptPath = STATE.filename || "<script.txt>";
1678 const args = [`"${DRIVER_PATH}"`, `"${scriptPath}"`];
1679 args.push(`--mode ${STATE.mode}`);
1680 if (STATE.hud === "off") args.push("--no-hud");
1681 return `& "${AHK_PATH}" ${args.join(" ")}`;
1682 }
1683
1684 function findLifecycleScript(directive) {
1685 const b = STATE.blocks.find(
1686 (b) => b.kind === "lifecycle" && b.directive === directive,
1687 );
1688 return b ? b.text : null;
1689 }
1690
1691 function buildLifecycleCommand(directive) {
1692 const path = findLifecycleScript(directive);
1693 if (!path) return null;
1694 const demoName = (STATE.filename || "demo").replace(/\.txt$/, "");
1695 return `pwsh -NoProfile -ExecutionPolicy Bypass -File "${path}" -DemoName "${demoName}"`;
1696 }
1697
1698 function showCommandModal(title, cmd, fallback) {
1699 if (!cmd) {
1700 showModal(
1701 `<h2>${title}</h2><p style="color: var(--fg-dim);">${fallback}</p><div class="actions"><button class="btn" data-close>Close</button></div>`,
1702 );
1703 return;
1704 }
1705 showModal(`
1706 <h2>${title}</h2>
1707 <p style="color: var(--fg-dim); font-size: 12px;">Run this in a PowerShell window:</p>
1708 <pre id="cmd-pre">${escapeHtml(cmd)}</pre>
1709 <div class="actions">
1710 <button class="btn" data-close>Close</button>
1711 <button class="btn primary" id="copy-cmd">Copy command</button>
1712 </div>
1713 `);
1714 $("#copy-cmd").addEventListener("click", async () => {
1715 try {
1716 await navigator.clipboard.writeText(cmd);
1717 $("#copy-cmd").textContent = "Copied ✓";
1718 setTimeout(() => {
1719 $("#copy-cmd").textContent = "Copy command";
1720 }, 1500);
1721 } catch {
1722 $("#copy-cmd").textContent = "Copy failed";
1723 }
1724 });
1725 }
1726
1727 // ============================================================
1728 // Header wiring
1729 // ============================================================
1730
1731 $("#btn-new").addEventListener("click", newScript);
1732 $("#btn-open").addEventListener("click", openFile);
1733 $("#btn-save").addEventListener("click", saveFile);
1734 $("#btn-run").addEventListener("click", openRunModal);
1735
1736 // ============================================================
1737 // Run modal — consolidates mode/HUD/cues toggles plus setup/run/teardown
1738 // ============================================================
1739
1740 function openRunModal() {
1741 const html = `
1742 <h2>Run demo</h2>
1743 <div class="run-options">
1744 <div class="run-option-row">
1745 <span class="run-option-label">Mode</span>
1746 <span class="toggle" data-pref="mode">
1747 <button data-value="manual" class="${STATE.mode === "manual" ? "on" : ""}">Manual</button>
1748 <button data-value="auto" class="${STATE.mode === "auto" ? "on" : ""}">Auto</button>
1749 </span>
1750 <span class="run-option-hint">Auto advances on turn-complete</span>
1751 </div>
1752 <div class="run-option-row">
1753 <span class="run-option-label">HUD</span>
1754 <span class="toggle" data-pref="hud">
1755 <button data-value="on" class="${STATE.hud === "on" ? "on" : ""}">On</button>
1756 <button data-value="off" class="${STATE.hud === "off" ? "on" : ""}">Off</button>
1757 </span>
1758 <span class="run-option-hint">Off for final recordings</span>
1759 </div>
1760 <div class="run-option-row">
1761 <span class="run-option-label">Cues</span>
1762 <span class="toggle" data-pref="cues">
1763 <button data-value="on" class="${STATE.cues === "on" ? "on" : ""}">On</button>
1764 <button data-value="off" class="${STATE.cues === "off" ? "on" : ""}">Off</button>
1765 </span>
1766 <span class="run-option-hint">Recorder beats during the demo</span>
1767 </div>
1768 </div>
1769 <div class="run-actions">
1770 <button class="btn" data-action="setup">Setup</button>
1771 <button class="btn" data-action="teardown">Teardown</button>
1772 <button class="btn primary" data-action="run">Run ▶</button>
1773 </div>
1774 <div class="actions">
1775 <button class="btn" data-close>Close</button>
1776 </div>
1777 `;
1778 showModal(html);
1779 const modal = $("#modal-content");
1780
1781 // Wire the in-modal toggles to STATE
1782 modal.querySelectorAll(".toggle[data-pref]").forEach((t) => {
1783 const pref = t.dataset.pref;
1784 t.querySelectorAll("button").forEach((b) => {
1785 b.addEventListener("click", () => {
1786 t.querySelectorAll("button").forEach((o) =>
1787 o.classList.remove("on"),
1788 );
1789 b.classList.add("on");
1790 STATE[pref] = b.dataset.value;
1791 });
1792 });
1793 });
1794
1795 // Action buttons: close this modal, then show the corresponding command
1796 modal
1797 .querySelector('[data-action="setup"]')
1798 .addEventListener("click", () => {
1799 hideModal();
1800 showCommandModal(
1801 "Setup",
1802 buildLifecycleCommand("setup"),
1803 "No @setup directive in this script.",
1804 );
1805 });
1806 modal
1807 .querySelector('[data-action="teardown"]')
1808 .addEventListener("click", () => {
1809 hideModal();
1810 showCommandModal(
1811 "Teardown",
1812 buildLifecycleCommand("teardown"),
1813 "No @teardown directive in this script.",
1814 );
1815 });
1816 modal
1817 .querySelector('[data-action="run"]')
1818 .addEventListener("click", () => {
1819 hideModal();
1820 showCommandModal("Run demo", buildRunCommand(), "");
1821 });
1822 }
1823
1824 // ============================================================
1825 // Theme preference
1826 // ============================================================
1827
1828 const PREFS_KEY = "demo-script-viewer.prefs";
1829
1830 function loadPrefs() {
1831 try {
1832 return JSON.parse(localStorage.getItem(PREFS_KEY) || "{}");
1833 } catch {
1834 return {};
1835 }
1836 }
1837 function savePrefs(p) {
1838 try {
1839 localStorage.setItem(PREFS_KEY, JSON.stringify(p));
1840 } catch {
1841 /* ignore */
1842 }
1843 }
1844
1845 function applyTheme(theme) {
1846 document.body.classList.remove("dark", "light");
1847 document.body.classList.add(theme);
1848 $("#btn-theme").textContent = theme === "dark" ? "☾" : "☀";
1849 $("#btn-theme").title = `Theme: ${theme} — click to switch`;
1850 }
1851
1852 (function initPrefs() {
1853 const prefs = loadPrefs();
1854 const theme =
1855 prefs.theme ||
1856 (window.matchMedia &&
1857 window.matchMedia("(prefers-color-scheme: light)").matches
1858 ? "light"
1859 : "dark");
1860 applyTheme(theme);
1861 })();
1862
1863 $("#btn-theme").addEventListener("click", () => {
1864 const next = document.body.classList.contains("dark")
1865 ? "light"
1866 : "dark";
1867 applyTheme(next);
1868 const prefs = loadPrefs();
1869 prefs.theme = next;
1870 savePrefs(prefs);
1871 });
1872
1873 function isEditingTextField(el) {
1874 if (!el) return false;
1875 const tag = (el.tagName || "").toLowerCase();
1876 return el.isContentEditable || tag === "input" || tag === "textarea";
1877 }
1878
1879 document.addEventListener("keydown", (e) => {
1880 if (!(e.ctrlKey || e.metaKey)) return;
1881 const key = e.key.toLowerCase();
1882 const editing = isEditingTextField(e.target);
1883
1884 // Ctrl+S — save. Always honored: commit any in-progress edit first by
1885 // blurring the active element so saveEdit fires before serialize.
1886 if (key === "s" && !e.shiftKey) {
1887 e.preventDefault();
1888 if (
1889 editing &&
1890 document.activeElement &&
1891 document.activeElement.blur
1892 ) {
1893 document.activeElement.blur();
1894 }
1895 saveFile();
1896 return;
1897 }
1898
1899 // Ctrl+O — open file picker.
1900 if (key === "o" && !e.shiftKey) {
1901 e.preventDefault();
1902 if (
1903 editing &&
1904 document.activeElement &&
1905 document.activeElement.blur
1906 ) {
1907 document.activeElement.blur();
1908 }
1909 openFile();
1910 return;
1911 }
1912
1913 // Ctrl+Z — undo. Skip when editing text so the browser's own text-undo
1914 // handles in-place edits; block-level undo applies when not focused on
1915 // contenteditable.
1916 if (key === "z" && !e.shiftKey) {
1917 if (editing) return;
1918 e.preventDefault();
1919 undo();
1920 return;
1921 }
1922
1923 // Ctrl+Y or Ctrl+Shift+Z — redo.
1924 if (key === "y" || (key === "z" && e.shiftKey)) {
1925 if (editing) return;
1926 e.preventDefault();
1927 redo();
1928 return;
1929 }
1930 });
1931
1932 // ============================================================
1933 // Empty-state buttons
1934 // ============================================================
1935
1936 $("#btn-empty-open").addEventListener("click", openFile);
1937 $("#btn-empty-new").addEventListener("click", newScript);
1938
1939 // ============================================================
1940 // Init — show empty state until the user opens or creates a script.
1941 // ============================================================
1942
1943 renderBlocks();
1944 </script>
1945 </body>
1946</html>
1947