copilot-proxy-web 1.0.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.
@@ -0,0 +1,2850 @@
1
+ <!doctype html>
2
+ <html lang="zh-Hant">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Copilot Proxy Web</title>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css" />
8
+ <style>
9
+ :root {
10
+ color-scheme: light;
11
+ }
12
+ body, html {
13
+ margin: 0;
14
+ height: 100%;
15
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
16
+ background: #f7f7f7;
17
+ color: #111;
18
+ }
19
+ body {
20
+ display: flex;
21
+ flex-direction: column;
22
+ min-height: 100vh;
23
+ }
24
+ header {
25
+ padding: 12px 16px;
26
+ background: #111;
27
+ color: #fff;
28
+ font-size: 14px;
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: space-between;
32
+ gap: 12px;
33
+ }
34
+ #lang-select {
35
+ background: #1c1c1c;
36
+ color: #fff;
37
+ border: 1px solid #333;
38
+ border-radius: 4px;
39
+ font-size: 12px;
40
+ padding: 2px 6px;
41
+ }
42
+ #client-ip {
43
+ font-size: 12px;
44
+ color: #ccc;
45
+ margin-right: 2px;
46
+ }
47
+ .session-option {
48
+ display: flex;
49
+ align-items: center;
50
+ gap: 8px;
51
+ font-size: 12px;
52
+ color: #333;
53
+ margin: 6px 0 4px;
54
+ }
55
+ .session-option input {
56
+ width: 16px;
57
+ height: 16px;
58
+ }
59
+ .session-option .option-text {
60
+ display: inline-flex;
61
+ align-items: baseline;
62
+ gap: 6px;
63
+ }
64
+ .session-option .option-note {
65
+ color: #777;
66
+ margin-left: 6px;
67
+ font-size: 11px;
68
+ }
69
+ #app-version {
70
+ font-size: 12px;
71
+ color: #888;
72
+ }
73
+ #auth-logout {
74
+ padding: 5px 10px;
75
+ font-size: 12px;
76
+ border: 1px solid #444;
77
+ background: #1c1c1c;
78
+ color: #fff;
79
+ cursor: pointer;
80
+ border-radius: 4px;
81
+ }
82
+ #auth-logout.hidden {
83
+ display: none;
84
+ }
85
+ #lang-label {
86
+ font-size: 12px;
87
+ color: #ccc;
88
+ }
89
+ .header-right {
90
+ display: flex;
91
+ align-items: center;
92
+ gap: 10px;
93
+ }
94
+ main {
95
+ display: flex;
96
+ flex-direction: column;
97
+ gap: 12px;
98
+ padding: 12px 16px 16px;
99
+ flex: 1 1 auto;
100
+ box-sizing: border-box;
101
+ min-height: 0;
102
+ }
103
+ #terminal {
104
+ flex: 1 1 auto;
105
+ background: #111;
106
+ border: 1px solid #ddd;
107
+ overflow: hidden;
108
+ font-size: 13px;
109
+ line-height: 1.4;
110
+ display: flex;
111
+ flex-direction: column;
112
+ min-height: 0;
113
+ }
114
+ .xterm-tabs {
115
+ display: flex;
116
+ align-items: center;
117
+ gap: 8px;
118
+ padding: 6px 8px;
119
+ background: #0f0f0f;
120
+ border-bottom: 1px solid #222;
121
+ }
122
+ .xterm-tab-list { display:flex; gap:6px; align-items:center; flex:1; }
123
+ .xterm-tab {
124
+ padding: 6px 10px;
125
+ background: #111;
126
+ color: #ddd;
127
+ border: 1px solid #222;
128
+ border-bottom: none;
129
+ cursor: pointer;
130
+ font-size: 13px;
131
+ }
132
+ .xterm-tab.active {
133
+ background: #fff;
134
+ color: #111;
135
+ border-color: #ddd;
136
+ }
137
+ .xterm-tab.stopped {
138
+ opacity: 0.6;
139
+ }
140
+ .xterm-tab.stopped::after {
141
+ content: " stopped";
142
+ font-size: 11px;
143
+ margin-left: 6px;
144
+ color: #b55;
145
+ }
146
+ .xterm-tab .xterm-tab-close {
147
+ margin-left:8px; font-weight:700; cursor:pointer;
148
+ }
149
+ .xterm-new {
150
+ padding:6px 10px; background:#1a1a1a; color:#fff; border:1px solid #222; cursor:pointer;
151
+ }
152
+ #term-container {
153
+ flex: 1 1 auto;
154
+ width: 100%;
155
+ min-height: 0;
156
+ overflow: hidden;
157
+ display: flex;
158
+ }
159
+ #term-top {
160
+ flex: 1 1 auto;
161
+ display: flex;
162
+ flex-direction: column;
163
+ min-height: 0;
164
+ overflow: hidden;
165
+ }
166
+ #term-container > .terminal {
167
+ flex: 1 1 auto;
168
+ min-height: 0;
169
+ padding-bottom: 0;
170
+ }
171
+ #term-container.hidden { display: none; }
172
+ #conversation {
173
+ display: none;
174
+ flex: 1;
175
+ width: 100%;
176
+ border-top: 1px solid #222;
177
+ background: #fafafa;
178
+ color: #111;
179
+ overflow: auto;
180
+ box-sizing: border-box;
181
+ padding: 10px 10px 20px;
182
+ font-size: 12px;
183
+ line-height: 1.5;
184
+ min-height: 0;
185
+ }
186
+ #conversation.active { display: block; }
187
+ .conversation-item {
188
+ padding: 8px 10px;
189
+ border: 1px solid #ddd;
190
+ border-radius: 6px;
191
+ background: #fff;
192
+ margin-bottom: 8px;
193
+ }
194
+ .conversation-meta {
195
+ font-size: 11px;
196
+ color: #666;
197
+ margin-bottom: 6px;
198
+ }
199
+ .conversation-body {
200
+ white-space: normal;
201
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
202
+ font-size: 12px;
203
+ }
204
+ .conversation-body p {
205
+ margin: 0 0 8px;
206
+ }
207
+ .conversation-body code {
208
+ background: #f0f0f0;
209
+ padding: 0 4px;
210
+ border-radius: 3px;
211
+ }
212
+ .conversation-body pre {
213
+ background: #0f0f0f;
214
+ color: #f0f0f0;
215
+ padding: 10px;
216
+ border-radius: 6px;
217
+ overflow: auto;
218
+ }
219
+ .conversation-body pre code {
220
+ background: transparent;
221
+ padding: 0;
222
+ color: inherit;
223
+ }
224
+ .conversation-pre {
225
+ background: #0f0f0f;
226
+ color: #f0f0f0;
227
+ padding: 10px;
228
+ border-radius: 6px;
229
+ white-space: pre-wrap;
230
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
231
+ font-size: 12px;
232
+ }
233
+ .conversation-item.role-user {
234
+ border-color: #c8d7ff;
235
+ background: #f7f9ff;
236
+ }
237
+ .conversation-item.role-assistant {
238
+ border-color: #e6e6e6;
239
+ background: #fff;
240
+ }
241
+ .view-toggle {
242
+ padding: 6px 10px;
243
+ background: #1a1a1a;
244
+ color: #fff;
245
+ border: 1px solid #222;
246
+ cursor: pointer;
247
+ font-size: 12px;
248
+ }
249
+ .view-toggle.active {
250
+ background: #fff;
251
+ color: #111;
252
+ border-color: #ddd;
253
+ }
254
+ .view-toggle.toggle-inline {
255
+ display: inline-flex;
256
+ align-items: center;
257
+ gap: 6px;
258
+ padding: 6px 8px;
259
+ }
260
+ .view-toggle.toggle-inline input {
261
+ width: 14px;
262
+ height: 14px;
263
+ }
264
+ #input {
265
+ min-height: 120px;
266
+ max-height: 200px;
267
+ resize: vertical;
268
+ padding: 10px;
269
+ border: 1px solid #bbb;
270
+ font-family: inherit;
271
+ font-size: 13px;
272
+ box-sizing: border-box;
273
+ background: #fff;
274
+ flex: 1 1 auto;
275
+ min-width: 0;
276
+ }
277
+ .input-compose {
278
+ display: flex;
279
+ gap: 10px;
280
+ align-items: stretch;
281
+ flex: 0 0 auto;
282
+ width: 100%;
283
+ box-sizing: border-box;
284
+ }
285
+ .input-side {
286
+ --input-action-gap: 8px;
287
+ --input-btn-size: 52px;
288
+ width: calc((var(--input-btn-size) * 3) + (var(--input-action-gap) * 2));
289
+ flex: 0 0 calc((var(--input-btn-size) * 3) + (var(--input-action-gap) * 2));
290
+ display: flex;
291
+ flex-direction: column;
292
+ gap: var(--input-action-gap);
293
+ }
294
+ .input-actions {
295
+ display: grid;
296
+ gap: var(--input-action-gap);
297
+ }
298
+ .input-actions.primary {
299
+ grid-template-columns: 1fr 1fr;
300
+ }
301
+ .input-actions.primary button {
302
+ min-height: var(--input-btn-size);
303
+ }
304
+ .input-actions.keypad-grid {
305
+ grid-template-columns: repeat(3, var(--input-btn-size));
306
+ margin-left: 0;
307
+ }
308
+ .input-actions.keypad-grid button {
309
+ width: var(--input-btn-size);
310
+ height: var(--input-btn-size);
311
+ aspect-ratio: auto;
312
+ padding: 0;
313
+ display: inline-flex;
314
+ align-items: center;
315
+ justify-content: center;
316
+ }
317
+ .input-actions button {
318
+ width: 100%;
319
+ }
320
+ .row {
321
+ display: grid;
322
+ grid-template-columns: 380px minmax(0, 1fr);
323
+ grid-template-rows: 40px 40px;
324
+ column-gap: 6px;
325
+ row-gap: 6px;
326
+ align-items: stretch;
327
+ flex: 0 0 auto;
328
+ }
329
+ .status-panel {
330
+ grid-column: 1;
331
+ grid-row: 1 / span 2;
332
+ min-width: 0;
333
+ display: grid;
334
+ grid-template-rows: 40px 40px;
335
+ gap: 6px;
336
+ font-size: 12px;
337
+ line-height: 1.25;
338
+ }
339
+ .status-line1,
340
+ .status-line2 {
341
+ display: flex;
342
+ gap: 18px;
343
+ align-items: center;
344
+ min-height: 40px;
345
+ height: 40px;
346
+ white-space: nowrap;
347
+ box-sizing: border-box;
348
+ }
349
+ .controls-panel {
350
+ min-width: 0;
351
+ grid-column: 2;
352
+ grid-row: 1 / span 2;
353
+ display: grid;
354
+ grid-template-rows: 40px 40px;
355
+ row-gap: 6px;
356
+ align-items: stretch;
357
+ font-size: 12px;
358
+ line-height: 1.25;
359
+ }
360
+ .controls-row {
361
+ min-width: 0;
362
+ display: flex;
363
+ gap: 8px;
364
+ align-items: center;
365
+ min-height: 40px;
366
+ height: 40px;
367
+ flex-wrap: nowrap;
368
+ overflow: hidden;
369
+ box-sizing: border-box;
370
+ }
371
+ .controls-row-top > * ,
372
+ .controls-row-bottom > * {
373
+ align-self: center;
374
+ }
375
+ .keypad {
376
+ display: inline-flex;
377
+ align-items: center;
378
+ gap: 6px;
379
+ margin-left: 4px;
380
+ }
381
+ @media (max-width: 980px) {
382
+ .input-compose {
383
+ flex-direction: column;
384
+ }
385
+ .input-side {
386
+ --input-btn-size: 48px;
387
+ width: 100%;
388
+ flex: 0 0 auto;
389
+ }
390
+ .input-actions.keypad-grid {
391
+ justify-content: center;
392
+ }
393
+ .row {
394
+ grid-template-columns: 1fr;
395
+ grid-template-rows: auto auto;
396
+ row-gap: 8px;
397
+ }
398
+ .status-panel {
399
+ grid-column: 1;
400
+ grid-row: 1;
401
+ grid-template-rows: auto auto;
402
+ }
403
+ .controls-panel {
404
+ grid-column: 1;
405
+ grid-row: 2;
406
+ grid-template-rows: auto auto;
407
+ }
408
+ .controls-row {
409
+ flex-wrap: wrap;
410
+ min-height: 0;
411
+ }
412
+ }
413
+ button {
414
+ padding: 8px 12px;
415
+ border: 1px solid #111;
416
+ background: #111;
417
+ color: #fff;
418
+ font-size: 13px;
419
+ cursor: pointer;
420
+ }
421
+ button:disabled {
422
+ opacity: 0.5;
423
+ cursor: not-allowed;
424
+ }
425
+ select {
426
+ padding: 7px 8px;
427
+ border: 1px solid #bbb;
428
+ background: #fff;
429
+ font-size: 12px;
430
+ }
431
+ #status, #last-send, #mode, #enter-seq, #enter-hex {
432
+ font-size: 12px;
433
+ color: #555;
434
+ }
435
+ #session-info {
436
+ display: flex;
437
+ align-items: center;
438
+ min-height: 40px;
439
+ height: 40px;
440
+ font-size: 12px;
441
+ line-height: 1.2;
442
+ color: #222;
443
+ white-space: nowrap;
444
+ box-sizing: border-box;
445
+ }
446
+ .session-info-more {
447
+ color: #555;
448
+ cursor: help;
449
+ user-select: none;
450
+ }
451
+ #status {
452
+ display: inline-flex;
453
+ align-items: center;
454
+ gap: 6px;
455
+ }
456
+ #mode {
457
+ margin-left: 6px;
458
+ padding-left: 10px;
459
+ border-left: 1px solid #bdbdbd;
460
+ font-weight: 600;
461
+ }
462
+ #enter-seq {
463
+ margin-left: 12px;
464
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
465
+ }
466
+ #enter-hex {
467
+ margin-left: 12px;
468
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
469
+ }
470
+ .session-tab-stopped {
471
+ border-color: #b55;
472
+ color: #b55;
473
+ }
474
+ .session-start {
475
+ margin-left: 6px;
476
+ font-size: 11px;
477
+ color: #111;
478
+ background: #ffd166;
479
+ border-radius: 4px;
480
+ padding: 1px 4px;
481
+ cursor: pointer;
482
+ }
483
+ .session-state {
484
+ margin-left: 6px;
485
+ font-size: 11px;
486
+ padding: 1px 4px;
487
+ border-radius: 4px;
488
+ }
489
+ .session-state.running {
490
+ color: #0a4;
491
+ background: #e7f7ee;
492
+ border: 1px solid #bfe7d1;
493
+ }
494
+ .session-state.stopped {
495
+ color: #b55;
496
+ background: #fdecec;
497
+ border: 1px solid #f3c2c2;
498
+ }
499
+ .status-dot {
500
+ display: inline-block;
501
+ width: 8px;
502
+ height: 8px;
503
+ border-radius: 50%;
504
+ margin-right: 6px;
505
+ background: #999;
506
+ }
507
+ .size-controls {
508
+ display: inline-flex;
509
+ align-items: center;
510
+ gap: 6px;
511
+ font-size: 12px;
512
+ }
513
+ .size-controls input {
514
+ width: 64px;
515
+ height: 36px;
516
+ padding: 0 8px;
517
+ border: 1px solid #bbb;
518
+ border-radius: 4px;
519
+ font-size: 12px;
520
+ line-height: 1;
521
+ box-sizing: border-box;
522
+ }
523
+ #hooks-detail {
524
+ font-size: 12px;
525
+ color: #555;
526
+ line-height: 1.25;
527
+ display: inline-flex;
528
+ align-items: center;
529
+ min-height: 40px;
530
+ }
531
+ #hooks-info {
532
+ white-space: nowrap;
533
+ overflow: hidden;
534
+ text-overflow: ellipsis;
535
+ max-width: 100%;
536
+ }
537
+ #hooks-error {
538
+ font-size: 12px;
539
+ line-height: 1.25;
540
+ color: #555;
541
+ white-space: nowrap;
542
+ }
543
+ #hooks-list {
544
+ white-space: pre-wrap;
545
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
546
+ margin-top: 6px;
547
+ }
548
+ .controls-panel button,
549
+ .controls-panel select {
550
+ height: 40px;
551
+ padding-top: 0;
552
+ padding-bottom: 0;
553
+ box-sizing: border-box;
554
+ font-size: 12px;
555
+ line-height: 1;
556
+ }
557
+ #auth-overlay {
558
+ position: fixed;
559
+ inset: 0;
560
+ background: rgba(0, 0, 0, 0.5);
561
+ display: none;
562
+ align-items: center;
563
+ justify-content: center;
564
+ z-index: 999;
565
+ }
566
+ #auth-overlay.show {
567
+ display: flex;
568
+ }
569
+ .auth-card {
570
+ background: #fff;
571
+ border: 1px solid #ddd;
572
+ padding: 16px;
573
+ min-width: 240px;
574
+ display: flex;
575
+ gap: 8px;
576
+ flex-direction: column;
577
+ font-size: 13px;
578
+ }
579
+ .auth-title {
580
+ font-weight: 600;
581
+ }
582
+ #auth-ip {
583
+ font-size: 12px;
584
+ color: #666;
585
+ }
586
+ #auth-token {
587
+ padding: 8px;
588
+ border: 1px solid #bbb;
589
+ font-family: inherit;
590
+ }
591
+ .modal-overlay {
592
+ position: fixed;
593
+ inset: 0;
594
+ background: rgba(0, 0, 0, 0.4);
595
+ display: none;
596
+ align-items: center;
597
+ justify-content: center;
598
+ z-index: 1000;
599
+ }
600
+ .modal-overlay.show {
601
+ display: flex;
602
+ }
603
+ .modal {
604
+ width: min(520px, 92vw);
605
+ background: #fff;
606
+ border: 1px solid #ddd;
607
+ border-radius: 8px;
608
+ padding: 16px;
609
+ box-shadow: 0 10px 30px rgba(0,0,0,0.15);
610
+ }
611
+ .modal h3 {
612
+ margin: 0 0 12px 0;
613
+ font-size: 16px;
614
+ }
615
+ .modal label {
616
+ display: block;
617
+ font-size: 12px;
618
+ color: #555;
619
+ margin: 8px 0 4px;
620
+ }
621
+ .modal input,
622
+ .modal textarea {
623
+ width: 100%;
624
+ padding: 8px;
625
+ font-size: 13px;
626
+ border: 1px solid #bbb;
627
+ border-radius: 4px;
628
+ box-sizing: border-box;
629
+ }
630
+ .modal .session-option {
631
+ display: flex;
632
+ align-items: center;
633
+ gap: 8px;
634
+ }
635
+ .modal .session-option input {
636
+ width: 16px;
637
+ height: 16px;
638
+ padding: 0;
639
+ margin: 0;
640
+ }
641
+ .modal textarea {
642
+ resize: vertical;
643
+ min-height: 72px;
644
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
645
+ }
646
+ .modal-actions {
647
+ margin-top: 12px;
648
+ display: flex;
649
+ gap: 8px;
650
+ justify-content: flex-end;
651
+ }
652
+ .modal-actions button.secondary {
653
+ background: #fff;
654
+ color: #111;
655
+ }
656
+ .modal-examples {
657
+ margin-top: 8px;
658
+ display: flex;
659
+ flex-wrap: wrap;
660
+ gap: 6px;
661
+ }
662
+ .modal-examples-title {
663
+ width: 100%;
664
+ font-size: 12px;
665
+ color: #444;
666
+ }
667
+ .example-btn {
668
+ padding: 6px 8px;
669
+ border: 1px solid #bbb;
670
+ background: #f3f3f3;
671
+ color: #111;
672
+ font-size: 12px;
673
+ cursor: pointer;
674
+ }
675
+ #ws-help {
676
+ margin-top: 10px;
677
+ padding: 6px 8px;
678
+ border: 1px dashed #c9c9c9;
679
+ border-radius: 6px;
680
+ background: #fafafa;
681
+ color: #111;
682
+ font-size: 12px;
683
+ }
684
+ #ws-help summary {
685
+ cursor: pointer;
686
+ color: #333;
687
+ }
688
+ #ws-help pre {
689
+ margin: 8px 0 0;
690
+ padding: 8px;
691
+ background: #f2f2f2;
692
+ border-radius: 6px;
693
+ overflow-x: auto;
694
+ }
695
+ #ws-quick {
696
+ margin: 6px 0 8px;
697
+ padding: 6px 8px;
698
+ border: 1px solid #d6d6d6;
699
+ border-radius: 6px;
700
+ background: #f7f7f7;
701
+ color: #111;
702
+ font-size: 12px;
703
+ display: flex;
704
+ align-items: center;
705
+ gap: 8px;
706
+ }
707
+ #ws-quick.hidden {
708
+ display: none;
709
+ }
710
+ #ws-quick code {
711
+ white-space: nowrap;
712
+ overflow-x: auto;
713
+ display: inline-block;
714
+ max-width: 100%;
715
+ }
716
+ #ws-quick-copy {
717
+ margin-left: auto;
718
+ padding: 4px 6px;
719
+ font-size: 11px;
720
+ border: 1px solid #bbb;
721
+ background: #fff;
722
+ color: #111;
723
+ cursor: pointer;
724
+ }
725
+ #ws-quick-copy:hover {
726
+ background: #ececec;
727
+ }
728
+ #ws-quick-more {
729
+ padding: 4px 6px;
730
+ font-size: 11px;
731
+ border: 1px solid #bbb;
732
+ background: #fff;
733
+ color: #111;
734
+ cursor: pointer;
735
+ }
736
+ #ws-quick-more:hover {
737
+ background: #ececec;
738
+ }
739
+ #xterm-debug-copy {
740
+ padding: 4px 6px;
741
+ font-size: 11px;
742
+ border: 1px solid #bbb;
743
+ background: #fff;
744
+ color: #111;
745
+ cursor: pointer;
746
+ }
747
+ #xterm-debug-copy:hover {
748
+ background: #ececec;
749
+ }
750
+ #xterm-debug-reset {
751
+ padding: 4px 6px;
752
+ font-size: 11px;
753
+ border: 1px solid #bbb;
754
+ background: #fff;
755
+ color: #111;
756
+ cursor: pointer;
757
+ }
758
+ #xterm-debug-reset:hover {
759
+ background: #ececec;
760
+ }
761
+ #ws-more {
762
+ margin: 0 0 8px;
763
+ padding: 8px;
764
+ border: 1px dashed #d6d6d6;
765
+ border-radius: 6px;
766
+ background: #f9f9f9;
767
+ color: #111;
768
+ font-size: 12px;
769
+ }
770
+ #ws-more.hidden {
771
+ display: none;
772
+ }
773
+ .ws-more-row {
774
+ display: flex;
775
+ align-items: center;
776
+ gap: 8px;
777
+ margin: 6px 0;
778
+ }
779
+ .ws-more-title {
780
+ font-weight: 600;
781
+ margin-top: 6px;
782
+ }
783
+ .ws-more-note {
784
+ color: #333;
785
+ font-size: 11px;
786
+ }
787
+ .ws-more-row code {
788
+ white-space: nowrap;
789
+ overflow-x: auto;
790
+ display: inline-block;
791
+ max-width: 100%;
792
+ }
793
+ .ws-more-copy {
794
+ margin-left: auto;
795
+ padding: 4px 6px;
796
+ font-size: 11px;
797
+ border: 1px solid #bbb;
798
+ background: #fff;
799
+ color: #111;
800
+ cursor: pointer;
801
+ }
802
+ .ws-more-copy:hover {
803
+ background: #ececec;
804
+ }
805
+ </style>
806
+ </head>
807
+ <body>
808
+ <header>
809
+ <span id="app-title" data-i18n="app_title">Copilot Proxy Web</span>
810
+ <div class="header-right">
811
+ <div id="client-ip"></div>
812
+ <div id="app-version"></div>
813
+ <button id="auth-logout" class="hidden">Logout</button>
814
+ <label id="lang-label" for="lang-select" data-i18n="lang_label">Lang</label>
815
+ <select id="lang-select">
816
+ <option value="en">EN</option>
817
+ <option value="zh-TW">中文</option>
818
+ </select>
819
+ </div>
820
+ </header>
821
+ <main>
822
+ <div id="auth-overlay">
823
+ <div class="auth-card">
824
+ <div class="auth-title" data-i18n="auth_title">需要 Token</div>
825
+ <input id="auth-token" type="password" placeholder="輸入 token" data-i18n-placeholder="auth_placeholder" />
826
+ <div id="auth-ip"></div>
827
+ <div id="auth-error" style="color:#b55; font-size:12px; min-height:16px;"></div>
828
+ <button id="auth-save" data-i18n="auth_confirm">確認</button>
829
+ </div>
830
+ </div>
831
+ <div id="session-modal" class="modal-overlay">
832
+ <div class="modal">
833
+ <h3 id="session-modal-title" data-i18n="session_modal_title">Create session</h3>
834
+ <div id="session-name-row">
835
+ <label for="session-name" data-i18n="session_tab_name">Tab name</label>
836
+ <input id="session-name" type="text" placeholder="default" data-i18n-placeholder="session_tab_placeholder" />
837
+ </div>
838
+ <label for="session-cmd" data-i18n="session_command">Command</label>
839
+ <textarea id="session-cmd" rows="3" placeholder="copilot --model gpt-5-mini" data-i18n-placeholder="session_command_placeholder"></textarea>
840
+ <label class="session-option">
841
+ <input id="session-shell-interactive" type="checkbox" checked />
842
+ <span class="option-text">
843
+ <span data-i18n="session_shell_interactive">Interactive shell</span>
844
+ <span class="option-note" data-i18n="session_shell_note">loads shell rc files</span>
845
+ </span>
846
+ </label>
847
+ <label class="session-option">
848
+ <span class="option-text" data-i18n="session_conversation_profile">Conversation profile</span>
849
+ <select id="session-conversation-profile">
850
+ <option value="copilot" data-i18n="session_profile_copilot">GitHub Copilot CLI</option>
851
+ <option value="none" data-i18n="session_profile_none">None</option>
852
+ </select>
853
+ </label>
854
+ <div class="modal-examples">
855
+ <div class="modal-examples-title" data-i18n="examples_title">Examples</div>
856
+ <button type="button" class="example-btn" data-cmd="copilot --model gpt-5-mini">copilot --model gpt-5-mini</button>
857
+ <button type="button" class="example-btn" data-cmd="cd /tmp; copilot">cd /tmp; copilot</button>
858
+ <button type="button" class="example-btn" data-cmd="tmux new -A -s copilot">tmux new -A -s copilot</button>
859
+ </div>
860
+ <div class="modal-actions">
861
+ <button id="session-cancel" class="secondary" data-i18n="cancel">Cancel</button>
862
+ <button id="session-ok" data-i18n="start">Start</button>
863
+ </div>
864
+ </div>
865
+ </div>
866
+ <div id="terminal">
867
+ <div id="term-top">
868
+ <div id="ws-quick" class="hidden">
869
+ <span data-i18n="ws_cli_label">WS CLI</span>
870
+ <code id="ws-quick-cmd">npx copilot-proxy-web wc --url ws://HOST/ws --sessionId default</code>
871
+ <button id="ws-quick-copy" data-i18n="copy">Copy</button>
872
+ <button id="ws-quick-more" data-i18n="more">More</button>
873
+ <button id="xterm-debug-copy" data-i18n="debug_copy">Copy xterm debug</button>
874
+ <button id="xterm-debug-reset" data-i18n="debug_reset">Reset xterm debug</button>
875
+ </div>
876
+ <div id="ws-more" class="hidden">
877
+ <div class="ws-more-title" data-i18n="raw_ws">Raw WS</div>
878
+ <div class="ws-more-row">
879
+ <code id="ws-raw-url">ws://HOST/ws?sessionId=default</code>
880
+ <button class="ws-more-copy" data-copy="raw" data-i18n="copy">Copy</button>
881
+ </div>
882
+ <div class="ws-more-row">
883
+ <code id="ws-raw-cli">npx copilot-proxy-web wc --url ws://HOST/ws --sessionId default</code>
884
+ <button class="ws-more-copy" data-copy="raw-cli" data-i18n="copy">Copy</button>
885
+ </div>
886
+ <div class="ws-more-row">
887
+ <code id="ws-wscat-cmd">wscat -c ws://HOST/ws?sessionId=default -s "auth.&lt;base64url(TOKEN)&gt;"</code>
888
+ <button class="ws-more-copy" data-copy="wscat" data-i18n="copy">Copy</button>
889
+ </div>
890
+
891
+ <div class="ws-more-title" style="margin-top:10px;" data-i18n="cloudflare">Cloudflare</div>
892
+ <div class="ws-more-note" data-i18n="access_token_note">Access Token: Zero Trust Dashboard → Access → Service credentials → Service Tokens → Create → Client ID / Client Secret</div>
893
+ <div class="ws-more-row">
894
+ <code id="ws-cf-cmd">npx copilot-proxy-web wc --url wss://YOUR_HOST/ws --sessionId default --cf-access-id YOUR_ACCESS_ID --cf-access-secret YOUR_ACCESS_SECRET</code>
895
+ <button class="ws-more-copy" data-copy="cf" data-i18n="copy">Copy</button>
896
+ </div>
897
+ <div class="ws-more-row">
898
+ <code id="ws-cf-wscat">wscat -c wss://YOUR_HOST/ws?sessionId=default -H "CF-Access-Client-Id: YOUR_ACCESS_ID" -H "CF-Access-Client-Secret: YOUR_ACCESS_SECRET"</code>
899
+ <button class="ws-more-copy" data-copy="cf-wscat" data-i18n="copy">Copy</button>
900
+ </div>
901
+ </div>
902
+ <div id="xterm-tabs" class="xterm-tabs">
903
+ <div id="xterm-tab-list" class="xterm-tab-list"></div>
904
+ <button id="conv-toggle" class="view-toggle" data-i18n="conversation">Conversation</button>
905
+ <label class="view-toggle toggle-inline">
906
+ <input id="input-mode-api" type="checkbox" checked />
907
+ <span data-i18n="input_mode_api">API input</span>
908
+ </label>
909
+ <button id="xterm-new" class="xterm-new">+</button>
910
+ </div>
911
+ <div id="term-container"></div>
912
+ <div id="conversation"></div>
913
+ </div>
914
+ </div>
915
+ <div class="input-compose">
916
+ <textarea id="input" placeholder="輸入訊息(Shift+Enter 換行,Enter 送出)" data-i18n-placeholder="input_placeholder"></textarea>
917
+ <div class="input-side">
918
+ <div class="input-actions primary">
919
+ <button id="send" data-i18n="send">送出</button>
920
+ <button id="submit" data-i18n="submit">提交</button>
921
+ </div>
922
+ <div class="input-actions keypad-grid" aria-label="Keypad">
923
+ <button id="key-esc" class="secondary" title="ESC">ESC</button>
924
+ <button id="key-up" class="secondary" title="Up">↑</button>
925
+ <button id="key-del" class="secondary" title="Backspace">BS</button>
926
+ <button id="key-left" class="secondary" title="Left">←</button>
927
+ <button id="key-down" class="secondary" title="Down">↓</button>
928
+ <button id="key-right" class="secondary" title="Right">→</button>
929
+ </div>
930
+ </div>
931
+ </div>
932
+ <div class="row">
933
+ <div class="status-panel">
934
+ <div id="session-info"></div>
935
+ <div class="status-line2">
936
+ <div id="status"><span class="status-dot"></span><span data-i18n="status_connecting">連線中...</span></div>
937
+ <div id="mode">MODE: INPUT</div>
938
+ </div>
939
+ </div>
940
+ <div class="controls-panel">
941
+ <div class="controls-row controls-row-top">
942
+ <details id="hooks-detail">
943
+ <summary id="hooks-info" data-i18n="hooks_none">hooks: (none)</summary>
944
+ <div id="hooks-list"></div>
945
+ </details>
946
+ <div id="hooks-error"></div>
947
+ <button id="hooks-clear" data-i18n="hooks_clear">清除 Hooks</button>
948
+ <div id="last-send"></div>
949
+ <div id="enter-seq"></div>
950
+ <div id="enter-hex"></div>
951
+ </div>
952
+ <div class="controls-row controls-row-bottom">
953
+ <div class="size-controls">
954
+ <label for="pty-cols" data-i18n="cols">Cols</label>
955
+ <input id="pty-cols" type="number" min="10" max="400" step="1" />
956
+ <label for="pty-rows" data-i18n="rows">Rows</label>
957
+ <input id="pty-rows" type="number" min="5" max="200" step="1" />
958
+ <button id="size-apply" data-i18n="apply">Apply</button>
959
+ <select id="size-mode" aria-label="Size mode">
960
+ <option value="pty" data-i18n="mode_pty">Mode: PTY</option>
961
+ <option value="web" data-i18n="mode_web">Mode: WEB</option>
962
+ </select>
963
+ </div>
964
+ <select id="submit-mode" aria-label="提交模式">
965
+ <option value="auto" data-i18n="submit_auto">Auto (Terminal Enter)</option>
966
+ <option value="lf" data-i18n="submit_lf">Enter (LF)</option>
967
+ <option value="cr" data-i18n="submit_cr">Enter (CR)</option>
968
+ <option value="crlf" data-i18n="submit_crlf">Enter (CRLF)</option>
969
+ <option value="doublecr" data-i18n="submit_doublecr">Enter x2 (CR)</option>
970
+ <option value="shifttabenter" data-i18n="submit_shifttabenter">Shift+Tab + Enter</option>
971
+ <option value="ctrlenter" data-i18n="submit_ctrlenter">Ctrl+Enter</option>
972
+ </select>
973
+ </div>
974
+ </div>
975
+ </div>
976
+ <details id="ws-help" style="display:none"></details>
977
+ </main>
978
+
979
+ <script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
980
+ <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
981
+ <script>
982
+ const termEl = document.getElementById("terminal");
983
+ const inputEl = document.getElementById("input");
984
+ const sendBtn = document.getElementById("send");
985
+ const submitBtn = document.getElementById("submit");
986
+ const keyUpBtn = document.getElementById("key-up");
987
+ const keyDownBtn = document.getElementById("key-down");
988
+ const keyLeftBtn = document.getElementById("key-left");
989
+ const keyRightBtn = document.getElementById("key-right");
990
+ const keyEscBtn = document.getElementById("key-esc");
991
+ const keyDelBtn = document.getElementById("key-del");
992
+ const submitModeEl = document.getElementById("submit-mode");
993
+ const sessionInfoEl = document.getElementById("session-info");
994
+ const hooksInfoEl = document.getElementById("hooks-info");
995
+ const hooksListEl = document.getElementById("hooks-list");
996
+ const hooksErrorEl = document.getElementById("hooks-error");
997
+ const hooksClearBtn = document.getElementById("hooks-clear");
998
+ const ptyColsEl = document.getElementById("pty-cols");
999
+ const ptyRowsEl = document.getElementById("pty-rows");
1000
+ const sizeApplyBtn = document.getElementById("size-apply");
1001
+ const sizeModeEl = document.getElementById("size-mode");
1002
+ const authOverlayEl = document.getElementById("auth-overlay");
1003
+ const authTokenEl = document.getElementById("auth-token");
1004
+ const authSaveBtn = document.getElementById("auth-save");
1005
+ const authErrorEl = document.getElementById("auth-error");
1006
+ const authIpEl = document.getElementById("auth-ip");
1007
+ const clientIpEl = document.getElementById("client-ip");
1008
+ const appVersionEl = document.getElementById("app-version");
1009
+ const authLogoutBtn = document.getElementById("auth-logout");
1010
+ const sessionModalEl = document.getElementById("session-modal");
1011
+ const sessionModalTitleEl = document.getElementById("session-modal-title");
1012
+ const sessionNameRowEl = document.getElementById("session-name-row");
1013
+ const sessionNameEl = document.getElementById("session-name");
1014
+ const sessionCmdEl = document.getElementById("session-cmd");
1015
+ const sessionShellInteractiveEl = document.getElementById("session-shell-interactive");
1016
+ const sessionConversationProfileEl = document.getElementById("session-conversation-profile");
1017
+ const inputModeApiEl = document.getElementById("input-mode-api");
1018
+ const termContainerEl = document.getElementById("term-container");
1019
+ const sessionOkEl = document.getElementById("session-ok");
1020
+ const sessionCancelEl = document.getElementById("session-cancel");
1021
+ document.querySelectorAll(".example-btn").forEach((btn) => {
1022
+ btn.addEventListener("click", () => {
1023
+ sessionCmdEl.value = btn.dataset.cmd || "";
1024
+ sessionCmdEl.focus();
1025
+ });
1026
+ });
1027
+ const statusEl = document.getElementById("status");
1028
+ const statusTextEl = statusEl.querySelector("span[data-i18n]") || statusEl;
1029
+ const lastSendEl = document.getElementById("last-send");
1030
+ const modeEl = document.getElementById("mode");
1031
+ const enterSeqEl = document.getElementById("enter-seq");
1032
+ const enterHexEl = document.getElementById("enter-hex");
1033
+ const wsQuickEl = document.getElementById("ws-quick");
1034
+ const wsQuickCmdEl = document.getElementById("ws-quick-cmd");
1035
+ const wsQuickCopyEl = document.getElementById("ws-quick-copy");
1036
+ const wsQuickMoreEl = document.getElementById("ws-quick-more");
1037
+ const xtermDebugCopyEl = document.getElementById("xterm-debug-copy");
1038
+ const xtermDebugResetEl = document.getElementById("xterm-debug-reset");
1039
+ const wsMoreEl = document.getElementById("ws-more");
1040
+ const wsRawUrlEl = document.getElementById("ws-raw-url");
1041
+ const wsRawCliEl = document.getElementById("ws-raw-cli");
1042
+ const wsWscatCmdEl = document.getElementById("ws-wscat-cmd");
1043
+ const wsCfCmdEl = document.getElementById("ws-cf-cmd");
1044
+ const wsCfWscatEl = document.getElementById("ws-cf-wscat");
1045
+ const convToggleEl = document.getElementById("conv-toggle");
1046
+ const conversationEl = document.getElementById("conversation");
1047
+ const termContainer = document.getElementById("term-container");
1048
+ const statusDot = statusEl.querySelector(".status-dot");
1049
+ let isComposing = false;
1050
+ let termFocused = false;
1051
+ let lastEnterSeq = "\r";
1052
+ let lastEnterSeqRaw = "";
1053
+ const LANG_KEY = "copilot_proxy_lang";
1054
+ const i18n = {
1055
+ en: {
1056
+ app_title: "Copilot Proxy Web",
1057
+ lang_label: "Lang",
1058
+ auth_title: "Token Required",
1059
+ auth_placeholder: "Enter token",
1060
+ auth_confirm: "Confirm",
1061
+ session_modal_title: "Create session",
1062
+ session_tab_name: "Tab name",
1063
+ session_tab_placeholder: "default",
1064
+ session_command: "Command",
1065
+ session_command_placeholder: "copilot --model gpt-5-mini",
1066
+ session_shell_interactive: "Interactive shell",
1067
+ session_shell_note: "loads shell rc files",
1068
+ input_mode_api: "API input",
1069
+ session_conversation_profile: "Conversation profile",
1070
+ session_profile_copilot: "GitHub Copilot CLI",
1071
+ session_profile_none: "None",
1072
+ examples_title: "Examples",
1073
+ cancel: "Cancel",
1074
+ start: "Start",
1075
+ input_placeholder: "Type here (Shift+Enter newline, Enter send)",
1076
+ send: "Send",
1077
+ submit: "Submit",
1078
+ hooks_none: "hooks: (none)",
1079
+ hooks_clear: "Clear Hooks",
1080
+ cols: "Cols",
1081
+ rows: "Rows",
1082
+ apply: "Apply",
1083
+ mode_pty: "Mode: PTY",
1084
+ mode_web: "Mode: WEB",
1085
+ submit_auto: "Auto (Terminal Enter)",
1086
+ submit_lf: "Enter (LF)",
1087
+ submit_cr: "Enter (CR)",
1088
+ submit_crlf: "Enter (CRLF)",
1089
+ submit_doublecr: "Enter x2 (CR)",
1090
+ submit_shifttabenter: "Shift+Tab + Enter",
1091
+ submit_ctrlenter: "Ctrl+Enter",
1092
+ status_connecting: "Connecting...",
1093
+ ws_cli_label: "WS CLI",
1094
+ copy: "Copy",
1095
+ copied: "Copied",
1096
+ more: "More",
1097
+ less: "Less",
1098
+ debug_copy: "Copy xterm debug",
1099
+ debug_reset: "Reset xterm debug",
1100
+ raw_ws: "Raw WS",
1101
+ cloudflare: "Cloudflare",
1102
+ access_token_note: "Access Token: Zero Trust Dashboard → Access → Service credentials → Service Tokens → Create → Client ID / Client Secret",
1103
+ status_connected: "Connected ({id})",
1104
+ status_disconnected: "Disconnected",
1105
+ status_disconnected_with_id: "Disconnected ({id})",
1106
+ status_no_session: "No session",
1107
+ prompt_create_session: "Create session",
1108
+ prompt_command_tab: "Command to run in this tab",
1109
+ prompt_create_default: "Create default session",
1110
+ },
1111
+ "zh-TW": {
1112
+ app_title: "Copilot Proxy Web",
1113
+ lang_label: "語言",
1114
+ auth_title: "需要 Token",
1115
+ auth_placeholder: "輸入 token",
1116
+ auth_confirm: "確認",
1117
+ session_modal_title: "建立 Session",
1118
+ session_tab_name: "Tab 名稱",
1119
+ session_tab_placeholder: "default",
1120
+ session_command: "Command",
1121
+ session_command_placeholder: "copilot --model gpt-5-mini",
1122
+ session_shell_interactive: "互動式 Shell",
1123
+ session_shell_note: "載入 shell 設定檔",
1124
+ input_mode_api: "API 輸入",
1125
+ session_conversation_profile: "對話 profile",
1126
+ session_profile_copilot: "GitHub Copilot CLI",
1127
+ session_profile_none: "不使用",
1128
+ examples_title: "範例",
1129
+ cancel: "取消",
1130
+ start: "開始",
1131
+ input_placeholder: "輸入訊息(Shift+Enter 換行,Enter 送出)",
1132
+ send: "送出",
1133
+ submit: "提交",
1134
+ hooks_none: "hooks: (none)",
1135
+ hooks_clear: "清除 Hooks",
1136
+ cols: "Cols",
1137
+ rows: "Rows",
1138
+ apply: "套用",
1139
+ mode_pty: "Mode: PTY",
1140
+ mode_web: "Mode: WEB",
1141
+ submit_auto: "Auto (Terminal Enter)",
1142
+ submit_lf: "Enter (LF)",
1143
+ submit_cr: "Enter (CR)",
1144
+ submit_crlf: "Enter (CRLF)",
1145
+ submit_doublecr: "Enter x2 (CR)",
1146
+ submit_shifttabenter: "Shift+Tab + Enter",
1147
+ submit_ctrlenter: "Ctrl+Enter",
1148
+ status_connecting: "連線中...",
1149
+ ws_cli_label: "WS CLI",
1150
+ copy: "複製",
1151
+ copied: "已複製",
1152
+ more: "更多",
1153
+ less: "收合",
1154
+ debug_copy: "複製 xterm debug",
1155
+ debug_reset: "清空 xterm debug",
1156
+ raw_ws: "Raw WS",
1157
+ cloudflare: "Cloudflare",
1158
+ access_token_note: "Access Token:Zero Trust Dashboard → Access → Service credentials → Service Tokens → Create → Client ID / Client Secret",
1159
+ status_connected: "已連線 ({id})",
1160
+ status_disconnected: "已斷線",
1161
+ status_disconnected_with_id: "已斷線 ({id})",
1162
+ status_no_session: "尚未建立 session",
1163
+ prompt_create_session: "建立 Session",
1164
+ prompt_command_tab: "在此 tab 執行的命令",
1165
+ prompt_create_default: "建立 default session",
1166
+ },
1167
+ };
1168
+ function getLang() {
1169
+ const saved = localStorage.getItem(LANG_KEY);
1170
+ if (saved) return saved;
1171
+ const nav = (navigator.language || "en").toLowerCase();
1172
+ return nav.startsWith("zh") ? "zh-TW" : "en";
1173
+ }
1174
+ let lang = getLang();
1175
+ function t(key, vars = {}) {
1176
+ const dict = i18n[lang] || i18n.en;
1177
+ const raw = dict[key] || i18n.en[key] || key;
1178
+ return raw.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? "");
1179
+ }
1180
+ function applyI18n() {
1181
+ document.documentElement.lang = lang;
1182
+ document.querySelectorAll("[data-i18n]").forEach((el) => {
1183
+ const key = el.getAttribute("data-i18n");
1184
+ el.textContent = t(key);
1185
+ });
1186
+ document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
1187
+ const key = el.getAttribute("data-i18n-placeholder");
1188
+ el.setAttribute("placeholder", t(key));
1189
+ });
1190
+ const select = document.getElementById("lang-select");
1191
+ if (select) select.value = lang;
1192
+ const moreBtn = document.getElementById("ws-quick-more");
1193
+ const morePanel = document.getElementById("ws-more");
1194
+ if (moreBtn && morePanel) {
1195
+ moreBtn.textContent = morePanel.classList.contains("hidden") ? t("more") : t("less");
1196
+ }
1197
+ }
1198
+ const SIZE_MODE_KEY = "copilot_proxy_size_mode";
1199
+ let sizeMode = localStorage.getItem(SIZE_MODE_KEY) || "pty";
1200
+ sizeModeEl.value = sizeMode;
1201
+ const langSelectEl = document.getElementById("lang-select");
1202
+ applyI18n();
1203
+ if (langSelectEl) {
1204
+ langSelectEl.value = lang;
1205
+ langSelectEl.addEventListener("change", (e) => {
1206
+ lang = e.target.value || "en";
1207
+ localStorage.setItem(LANG_KEY, lang);
1208
+ applyI18n();
1209
+ updateWsQuickCmd();
1210
+ if (currentSessionId && currentWsSessionId === currentSessionId && ws && ws.readyState === WebSocket.OPEN) {
1211
+ setStatus(t("status_connected", { id: currentSessionId }), "#2ecc71");
1212
+ }
1213
+ });
1214
+ }
1215
+
1216
+ const term = new Terminal({
1217
+ cursorBlink: true,
1218
+ scrollback: 2000,
1219
+ fontSize: 13,
1220
+ fontFamily:
1221
+ "ui-monospace, SFMono-Regular, Menlo, Consolas, Liberation Mono, monospace",
1222
+ theme: {
1223
+ background: "#111111",
1224
+ foreground: "#e6e6e6",
1225
+ },
1226
+ });
1227
+ // mount the xterm into the term-container so tabs can sit above
1228
+ term.open(termContainer);
1229
+ const fitAddon = new FitAddon.FitAddon();
1230
+ term.loadAddon(fitAddon);
1231
+ const DEBUG_XTERM_KEY = "cp_debug_xterm";
1232
+ let debugXtermEnabled = (() => {
1233
+ try {
1234
+ const v = localStorage.getItem(DEBUG_XTERM_KEY);
1235
+ return v === "1" || v === "true" || v === "on";
1236
+ } catch {
1237
+ return false;
1238
+ }
1239
+ })();
1240
+ const xtermDebugLines = [];
1241
+ const XTERM_DEBUG_MAX_LINES = 2000;
1242
+ function syncDebugModeUI() {
1243
+ const show = Boolean(debugXtermEnabled);
1244
+ if (xtermDebugCopyEl) xtermDebugCopyEl.style.display = show ? "" : "none";
1245
+ if (xtermDebugResetEl) xtermDebugResetEl.style.display = show ? "" : "none";
1246
+ }
1247
+ function enableDebugMode() {
1248
+ try {
1249
+ localStorage.setItem(DEBUG_XTERM_KEY, "1");
1250
+ } catch {
1251
+ // ignore storage failures
1252
+ }
1253
+ debugXtermEnabled = true;
1254
+ syncDebugModeUI();
1255
+ }
1256
+ function disableDebugMode() {
1257
+ try {
1258
+ localStorage.removeItem(DEBUG_XTERM_KEY);
1259
+ } catch {
1260
+ // ignore storage failures
1261
+ }
1262
+ debugXtermEnabled = false;
1263
+ syncDebugModeUI();
1264
+ }
1265
+ window.enableDebugMode = enableDebugMode;
1266
+ window.disableDebugMode = disableDebugMode;
1267
+ syncDebugModeUI();
1268
+
1269
+ function xtermDbg(event, extra = {}) {
1270
+ if (!debugXtermEnabled) return;
1271
+ const viewport = term?.element?.querySelector(".xterm-viewport");
1272
+ const scrollArea = term?.element?.querySelector(".xterm-scroll-area");
1273
+ const payload = Object.assign(
1274
+ {
1275
+ event,
1276
+ ts: new Date().toISOString(),
1277
+ sizeMode,
1278
+ cols: term?.cols,
1279
+ rows: term?.rows,
1280
+ bufferLen: term?.buffer?.active?.length,
1281
+ viewportH: viewport?.clientHeight,
1282
+ viewportScrollTop: viewport?.scrollTop,
1283
+ viewportScrollH: viewport?.scrollHeight,
1284
+ scrollAreaH: scrollArea?.style?.height,
1285
+ termContainerH: termContainerEl?.clientHeight,
1286
+ terminalH: term?.element?.clientHeight,
1287
+ },
1288
+ extra
1289
+ );
1290
+ let line = "";
1291
+ try {
1292
+ line = JSON.stringify(payload);
1293
+ } catch {
1294
+ line = String(payload?.event || "xterm-debug");
1295
+ }
1296
+ xtermDebugLines.push(`[xterm-debug] ${line}`);
1297
+ if (xtermDebugLines.length > XTERM_DEBUG_MAX_LINES) {
1298
+ xtermDebugLines.splice(0, xtermDebugLines.length - XTERM_DEBUG_MAX_LINES);
1299
+ }
1300
+ console.log(`[xterm-debug] ${line}`);
1301
+ }
1302
+
1303
+ function fitXterm(reason) {
1304
+ if (sizeMode !== "web") {
1305
+ xtermDbg("fit:skip", { reason, why: "sizeMode!=web" });
1306
+ return;
1307
+ }
1308
+ xtermDbg("fit:start", { reason });
1309
+ fitAddon.fit();
1310
+ xtermDbg("fit:done", { reason });
1311
+ }
1312
+
1313
+ function scrollXtermBottom(reason) {
1314
+ xtermDbg("scrollToBottom:start", { reason });
1315
+ term.scrollToBottom();
1316
+ xtermDbg("scrollToBottom:done", { reason });
1317
+ }
1318
+
1319
+ // In PTY mode, keep local and backend row counts aligned to avoid clipping.
1320
+ function displayRowsForPty(rows) {
1321
+ const n = Number(rows);
1322
+ if (!Number.isFinite(n)) return rows;
1323
+ if (sizeMode !== "pty") return n;
1324
+ const cssCellH = term?._core?._renderService?.dimensions?.css?.cell?.height;
1325
+ const cellH = Number(cssCellH);
1326
+ const containerH = Number(termContainerEl?.clientHeight || 0);
1327
+ if (!Number.isFinite(cellH) || cellH <= 0 || containerH <= 0) {
1328
+ return n;
1329
+ }
1330
+ // Use ceil so a fractional final row is still counted instead of clipping one line.
1331
+ const maxVisibleRows = Math.max(1, Math.ceil(containerH / cellH));
1332
+ const displayRows = Math.max(1, Math.min(n, maxVisibleRows));
1333
+ xtermDbg("pty-display-rows", { requestedRows: n, cellH, containerH, maxVisibleRows, displayRows });
1334
+ return displayRows;
1335
+ }
1336
+
1337
+ function refreshPtyLocalViewport() {
1338
+ if (sizeMode !== "pty") return null;
1339
+ const cols = Number(ptyColsEl.value);
1340
+ const rows = Number(ptyRowsEl.value);
1341
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) return null;
1342
+ const displayRows = displayRowsForPty(rows);
1343
+ term.resize(cols, displayRows);
1344
+ xtermDbg("pty-local-resize", { cols, rows, displayRows });
1345
+ return { cols, rows, displayRows };
1346
+ }
1347
+
1348
+ fitXterm("init");
1349
+
1350
+ // xterm tabs session management
1351
+ const xtermTabList = document.getElementById('xterm-tab-list');
1352
+ const xtermNewBtn = document.getElementById('xterm-new');
1353
+ let xtermSessions = new Map();
1354
+ let activeXtermId = null;
1355
+
1356
+ // bind xterm tabs to backend session IDs when possible
1357
+ function ensureXtermBoundToSession(sessionId) {
1358
+ // reuse existing tab for sessionId or create one
1359
+ for (const [k, v] of xtermSessions.entries()) {
1360
+ if (v.sessionId === sessionId) return k;
1361
+ }
1362
+ const id = sessionId || ('x' + Math.random().toString(36).slice(2,7));
1363
+ xtermSessions.set(id, { buffer: '', sessionId });
1364
+ return id;
1365
+ }
1366
+
1367
+
1368
+ function renderXtermTabs() {
1369
+ xtermTabList.innerHTML = '';
1370
+ for (const id of xtermSessions.keys()) {
1371
+ const meta = xtermSessions.get(id) || {};
1372
+ const el = document.createElement('div');
1373
+ const started = meta.sessionId ? sessionMeta.get(meta.sessionId)?.started !== false : true;
1374
+ el.className = 'xterm-tab' + (id === activeXtermId ? ' active' : '') + (started ? '' : ' stopped');
1375
+ el.textContent = meta.title || id;
1376
+ const close = document.createElement('span');
1377
+ close.className = 'xterm-tab-close';
1378
+ close.textContent = '×';
1379
+ close.addEventListener('click', (ev) => {
1380
+ ev.stopPropagation();
1381
+ // if this tab is bound to a backend session, delete that session
1382
+ if (meta.sessionId) {
1383
+ deleteSession(meta.sessionId);
1384
+ }
1385
+ xtermSessions.delete(id);
1386
+ if (activeXtermId === id) {
1387
+ activeXtermId = Array.from(xtermSessions.keys())[0] || null;
1388
+ }
1389
+ switchToXterm(activeXtermId);
1390
+ renderXtermTabs();
1391
+ });
1392
+ el.appendChild(close);
1393
+ el.addEventListener('click', () => {
1394
+ if (id === activeXtermId) return;
1395
+ // if this tab is bound to a backend session, connect to it
1396
+ if (meta.sessionId) {
1397
+ currentSessionId = meta.sessionId;
1398
+ const url = new URL(location.href);
1399
+ url.searchParams.set('sessionId', currentSessionId);
1400
+ history.replaceState(null, '', url.toString());
1401
+ const started = sessionMeta.get(meta.sessionId)?.started !== false;
1402
+ if (started) {
1403
+ connectWebSocket(currentSessionId);
1404
+ loadHooks();
1405
+ } else {
1406
+ if (ws && ws.readyState === WebSocket.OPEN) ws.close();
1407
+ setStatus(t("status_disconnected_with_id", { id: currentSessionId }), "#e67e22");
1408
+ }
1409
+ updateSessionInfo();
1410
+ }
1411
+ switchToXterm(id);
1412
+ fitXterm("xterm-tab-click");
1413
+ sendResize();
1414
+ renderXtermTabs();
1415
+ });
1416
+ xtermTabList.appendChild(el);
1417
+ }
1418
+ }
1419
+
1420
+ function rebuildXtermTabsFromSessions(sessions) {
1421
+ const prevActive = activeXtermId;
1422
+ const next = new Map();
1423
+ for (const session of sessions) {
1424
+ const existingId = session.id;
1425
+ const prior = xtermSessions.get(existingId) || {};
1426
+ next.set(existingId, {
1427
+ buffer: prior.buffer || "",
1428
+ sessionId: session.id,
1429
+ title: session.id,
1430
+ });
1431
+ }
1432
+ xtermSessions = next;
1433
+ if (!activeXtermId || !xtermSessions.has(activeXtermId)) {
1434
+ activeXtermId = sessions[0]?.id || null;
1435
+ }
1436
+ renderXtermTabs();
1437
+ if (activeXtermId && activeXtermId !== prevActive) {
1438
+ switchToXterm(activeXtermId);
1439
+ }
1440
+ }
1441
+
1442
+ function switchToXterm(id) {
1443
+ xtermDbg("switchToXterm:start", { id, activeXtermIdBefore: activeXtermId });
1444
+ if (!id) {
1445
+ term.clear();
1446
+ xtermDbg("switchToXterm:clear", {});
1447
+ return;
1448
+ }
1449
+ activeXtermId = id;
1450
+ const meta = xtermSessions.get(id);
1451
+ term.reset();
1452
+ xtermDbg("switchToXterm:reset", {
1453
+ id,
1454
+ hasBuffer: Boolean(meta && meta.buffer),
1455
+ bufferLen: meta?.buffer?.length || 0,
1456
+ });
1457
+ if (meta && meta.buffer) {
1458
+ term.write(meta.buffer, () => {
1459
+ scrollXtermBottom("switchToXterm-buffer-replay");
1460
+ requestAnimationFrame(() => {
1461
+ scrollXtermBottom("switchToXterm-buffer-replay-raf");
1462
+ });
1463
+ setTimeout(() => {
1464
+ scrollXtermBottom("switchToXterm-buffer-replay-delay");
1465
+ }, 24);
1466
+ });
1467
+ }
1468
+ }
1469
+
1470
+ xtermNewBtn.addEventListener('click', async (event) => {
1471
+ event.preventDefault();
1472
+ event.stopPropagation();
1473
+ await createSession();
1474
+ });
1475
+
1476
+ // record incoming data into active session buffer
1477
+ function recordToActiveSession(data) {
1478
+ if (!activeXtermId) return;
1479
+ const meta = xtermSessions.get(activeXtermId) || { buffer: '' };
1480
+ meta.buffer = (meta.buffer || '') + data;
1481
+ xtermSessions.set(activeXtermId, meta);
1482
+ }
1483
+
1484
+ function sendResize() {
1485
+ if (sizeMode !== "web") return;
1486
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
1487
+ const dims = term._core?._renderService?.dimensions;
1488
+ const cols = term.cols;
1489
+ const rows = term.rows;
1490
+ ws.send(JSON.stringify({ type: "resize", cols, rows }));
1491
+ }
1492
+
1493
+ window.addEventListener("resize", () => {
1494
+ if (sizeMode === "web") {
1495
+ fitXterm("window-resize");
1496
+ sendResize();
1497
+ return;
1498
+ }
1499
+ schedulePtyResizeSync("window-resize");
1500
+ });
1501
+
1502
+ let currentSessionId = new URLSearchParams(location.search).get("sessionId") || null;
1503
+ let ws;
1504
+ let currentWsSessionId = null;
1505
+ const TAB_COUNTER_KEY = "copilot_proxy_tab_counter";
1506
+ const encoder = new TextEncoder();
1507
+ const AUTH_KEY = "copilot_proxy_token";
1508
+ let authToken = sessionStorage.getItem(AUTH_KEY) || "";
1509
+ let authRequired = null;
1510
+ let currentUser = "";
1511
+ updateWsQuickCmd();
1512
+
1513
+ function encodeAuthToken(token) {
1514
+ const b64 = btoa(token).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
1515
+ return `auth.${b64}`;
1516
+ }
1517
+
1518
+ function showAuthOverlay(show) {
1519
+ authOverlayEl.classList.toggle("show", show);
1520
+ if (show) {
1521
+ if (authErrorEl) authErrorEl.textContent = "";
1522
+ setSessionUser("");
1523
+ stopConversationPoll();
1524
+ }
1525
+ }
1526
+
1527
+ function setClientIp(ip) {
1528
+ if (!ip) return;
1529
+ if (clientIpEl) clientIpEl.textContent = `IP: ${ip}`;
1530
+ if (authIpEl) authIpEl.textContent = `IP: ${ip}`;
1531
+ }
1532
+
1533
+ function setAppVersion(version) {
1534
+ if (!version || !appVersionEl) return;
1535
+ appVersionEl.textContent = `v${version}`;
1536
+ }
1537
+
1538
+ function setSessionUser(user) {
1539
+ currentUser = user || "";
1540
+ updateSessionTitle();
1541
+ }
1542
+
1543
+ function updateSessionTitle() {
1544
+ if (!sessionModalTitleEl) return;
1545
+ const baseTitle =
1546
+ sessionModalTitleEl.dataset.baseTitle || sessionModalTitleEl.textContent || t("session_modal_title");
1547
+ sessionModalTitleEl.dataset.baseTitle = baseTitle;
1548
+ if (currentUser) {
1549
+ sessionModalTitleEl.textContent = `${baseTitle} (Running user: ${currentUser})`;
1550
+ } else {
1551
+ sessionModalTitleEl.textContent = baseTitle;
1552
+ }
1553
+ }
1554
+
1555
+ function setLogoutVisible(show) {
1556
+ if (!authLogoutBtn) return;
1557
+ authLogoutBtn.classList.toggle("hidden", !show);
1558
+ }
1559
+
1560
+ function requireAuth() {
1561
+ if (authRequired === false) return true;
1562
+ if (authToken) {
1563
+ showAuthOverlay(false);
1564
+ return true;
1565
+ }
1566
+ if (authRequired === true) {
1567
+ showAuthOverlay(true);
1568
+ return false;
1569
+ }
1570
+ return true;
1571
+ }
1572
+
1573
+ function setStatus(text, color) {
1574
+ statusTextEl.textContent = text;
1575
+ statusDot.style.background = color;
1576
+ }
1577
+
1578
+ function parseTabIndex(value) {
1579
+ const match = String(value || "").trim().match(/^tab(\d{2,})$/i);
1580
+ if (!match) return 0;
1581
+ const idx = Number(match[1]);
1582
+ return Number.isFinite(idx) ? idx : 0;
1583
+ }
1584
+
1585
+ function getMaxExistingTabIndex() {
1586
+ let max = 0;
1587
+ for (const [id] of sessionMeta.entries()) {
1588
+ const idx = parseTabIndex(id);
1589
+ if (idx > max) max = idx;
1590
+ }
1591
+ return max;
1592
+ }
1593
+
1594
+ function getSavedTabCounter() {
1595
+ const raw = localStorage.getItem(TAB_COUNTER_KEY);
1596
+ const num = Number(raw);
1597
+ return Number.isFinite(num) && num > 0 ? num : 0;
1598
+ }
1599
+
1600
+ function saveTabCounter(value) {
1601
+ if (!Number.isFinite(value) || value <= 0) return;
1602
+ localStorage.setItem(TAB_COUNTER_KEY, String(Math.floor(value)));
1603
+ }
1604
+
1605
+ function nextDefaultTabName() {
1606
+ const next = Math.max(getSavedTabCounter(), getMaxExistingTabIndex()) + 1;
1607
+ return `tab${String(next).padStart(2, "0")}`;
1608
+ }
1609
+
1610
+ function rememberUsedTabName(name) {
1611
+ const idx = parseTabIndex(name);
1612
+ if (!idx) return;
1613
+ saveTabCounter(Math.max(getSavedTabCounter(), idx));
1614
+ }
1615
+
1616
+ function connectWebSocket(sessionId) {
1617
+ xtermDbg("ws-connect:start", { sessionId, currentWsSessionId, readyState: ws?.readyState });
1618
+ if (!requireAuth()) return;
1619
+ if (!sessionId) return;
1620
+ if (
1621
+ ws &&
1622
+ (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) &&
1623
+ currentWsSessionId === sessionId
1624
+ ) {
1625
+ return;
1626
+ }
1627
+ if (ws && ws.readyState === WebSocket.OPEN) {
1628
+ ws.close();
1629
+ }
1630
+ currentWsSessionId = sessionId;
1631
+ if (conversationView) startConversationSession();
1632
+ term.reset();
1633
+ // ensure there's a tab bound to this backend session and activate it
1634
+ const boundTab = ensureXtermBoundToSession(sessionId);
1635
+ activeXtermId = boundTab;
1636
+ renderXtermTabs();
1637
+ const meta = xtermSessions.get(boundTab);
1638
+ const hasBuffer = Boolean(meta && meta.buffer);
1639
+ switchToXterm(boundTab);
1640
+ const wsUrl =
1641
+ (location.protocol === "https:" ? "wss://" : "ws://") +
1642
+ location.host +
1643
+ "/ws?sessionId=" +
1644
+ encodeURIComponent(sessionId);
1645
+ const protocols = authToken ? [encodeAuthToken(authToken)] : [];
1646
+ ws = protocols.length > 0 ? new WebSocket(wsUrl, protocols) : new WebSocket(wsUrl);
1647
+ ws.binaryType = "arraybuffer";
1648
+ let skipHistoryOnce = hasBuffer;
1649
+ let initialReplayActive = true;
1650
+ let initialReplayTimer = null;
1651
+ xtermDbg("ws-connect:prepared", { sessionId, hasBuffer, skipHistoryOnce, initialReplayActive });
1652
+ // TODO: investigate xterm rendering duplication; keep disabled until root cause is clear.
1653
+ // let lastWsChunk = "";
1654
+ // let lastWsChunkTs = 0;
1655
+ // const DUP_WINDOW_MS = 250;
1656
+ // const DUP_MIN_LEN = 32;
1657
+ ws.addEventListener("open", () => {
1658
+ xtermDbg("ws-open", { sessionId, sizeMode });
1659
+ setStatus(t("status_connected", { id: sessionId }), "#2ecc71");
1660
+ sendBtn.disabled = false;
1661
+ if (sizeMode === "web") {
1662
+ fitXterm("ws-open-size-mode-web");
1663
+ sendResize();
1664
+ setTimeout(sendResize, 100);
1665
+ // Run one extra fit after layout settles to avoid partial viewport on refresh.
1666
+ setTimeout(() => {
1667
+ if (sizeMode !== "web") return;
1668
+ fitXterm("ws-open-delayed");
1669
+ sendResize();
1670
+ scrollXtermBottom("ws-open-delayed");
1671
+ }, 220);
1672
+ } else {
1673
+ const cols = Number(ptyColsEl.value);
1674
+ const rows = Number(ptyRowsEl.value);
1675
+ if (Number.isFinite(cols) && Number.isFinite(rows)) {
1676
+ schedulePtyResizeSync("ws-open-pty");
1677
+ }
1678
+ }
1679
+ });
1680
+
1681
+ ws.addEventListener("close", () => {
1682
+ xtermDbg("ws-close", { sessionId });
1683
+ if (ws && ws.readyState === WebSocket.CLOSED) {
1684
+ ws = null;
1685
+ }
1686
+ if (initialReplayTimer) {
1687
+ clearTimeout(initialReplayTimer);
1688
+ initialReplayTimer = null;
1689
+ }
1690
+ if (currentWsSessionId === sessionId) {
1691
+ currentWsSessionId = null;
1692
+ }
1693
+ setStatus(t("status_disconnected"), "#e74c3c");
1694
+ sendBtn.disabled = true;
1695
+ if (!authToken && authRequired !== false) {
1696
+ authRequired = true;
1697
+ showAuthOverlay(true);
1698
+ }
1699
+ });
1700
+
1701
+ ws.addEventListener("message", (event) => {
1702
+ xtermDbg("ws-message:raw", {
1703
+ sessionId,
1704
+ type: typeof event.data,
1705
+ len: typeof event.data === "string" ? event.data.length : event.data?.byteLength || 0,
1706
+ skipHistoryOnce,
1707
+ });
1708
+ if (skipHistoryOnce) {
1709
+ skipHistoryOnce = false;
1710
+ xtermDbg("ws-message:skipHistoryOnce", { sessionId });
1711
+ return;
1712
+ }
1713
+ const data = event.data;
1714
+ if (pendingEnter && typeof data === "string") {
1715
+ if (!pendingEnter.token || data.includes(pendingEnter.token)) {
1716
+ sendBinary(1, FOCUS_IN);
1717
+ sendBinary(1, pendingEnter.seq);
1718
+ setTimeout(() => {
1719
+ sendBinary(1, FOCUS_OUT);
1720
+ }, SUBMIT_DELAY_MS + 20);
1721
+ clearTimeout(pendingEnter.timeoutId);
1722
+ pendingEnter = null;
1723
+ }
1724
+ }
1725
+ // if (typeof data === "string" && data.length >= DUP_MIN_LEN) {
1726
+ // const now = Date.now();
1727
+ // if (data === lastWsChunk && now - lastWsChunkTs < DUP_WINDOW_MS) {
1728
+ // return;
1729
+ // }
1730
+ // lastWsChunk = data;
1731
+ // lastWsChunkTs = now;
1732
+ // } else {
1733
+ // lastWsChunk = "";
1734
+ // lastWsChunkTs = 0;
1735
+ // }
1736
+ xtermDbg("ws-message", {
1737
+ sessionId,
1738
+ type: typeof data,
1739
+ len: typeof data === "string" ? data.length : data?.byteLength || 0,
1740
+ initialReplayActive,
1741
+ skipHistoryOnce,
1742
+ });
1743
+ term.write(data, () => {
1744
+ xtermDbg("ws-write-callback", {
1745
+ sessionId,
1746
+ len: typeof data === "string" ? data.length : data?.byteLength || 0,
1747
+ initialReplayActive,
1748
+ });
1749
+ if (initialReplayActive) {
1750
+ scrollXtermBottom("ws-initial-replay");
1751
+ requestAnimationFrame(() => {
1752
+ scrollXtermBottom("ws-initial-replay-raf");
1753
+ });
1754
+ setTimeout(() => {
1755
+ scrollXtermBottom("ws-initial-replay-delay");
1756
+ }, 24);
1757
+ }
1758
+ });
1759
+ if (initialReplayTimer) clearTimeout(initialReplayTimer);
1760
+ initialReplayTimer = setTimeout(() => {
1761
+ initialReplayActive = false;
1762
+ initialReplayTimer = null;
1763
+ }, 250);
1764
+ // also record output to the active xterm session buffer
1765
+ recordToActiveSession(data);
1766
+ });
1767
+ }
1768
+
1769
+ function updateWsQuickCmd() {
1770
+ const session = currentSessionId || "default";
1771
+ if (!currentSessionId || !activeXtermId || xtermSessions.size === 0) {
1772
+ wsQuickEl.classList.add("hidden");
1773
+ wsMoreEl.classList.add("hidden");
1774
+ return;
1775
+ }
1776
+ wsQuickEl.classList.remove("hidden");
1777
+ const base =
1778
+ (location.protocol === "https:" ? "wss://" : "ws://") +
1779
+ location.host +
1780
+ "/ws";
1781
+ const tokenArg = authToken
1782
+ ? ` --token ${authToken}`
1783
+ : (authRequired === true ? " --token <AUTH_TOKEN>" : "");
1784
+ const cmd = `npx copilot-proxy-web wc --url ${base} --sessionId ${session}${tokenArg}`;
1785
+ wsQuickCmdEl.textContent = cmd;
1786
+ const rawUrl = `${base}?sessionId=${encodeURIComponent(session)}`;
1787
+ wsRawUrlEl.textContent = rawUrl;
1788
+ wsRawCliEl.textContent = `npx copilot-proxy-web wc --url ${base} --sessionId ${session}${tokenArg}`;
1789
+ if (authToken) {
1790
+ const authProto = encodeAuthToken(authToken);
1791
+ wsWscatCmdEl.textContent = `wscat -c ${rawUrl} -s "${authProto}"`;
1792
+ } else {
1793
+ wsWscatCmdEl.textContent = `wscat -c ${rawUrl}`;
1794
+ }
1795
+ const isLocal =
1796
+ location.hostname === "localhost" ||
1797
+ location.hostname === "127.0.0.1" ||
1798
+ location.hostname === "";
1799
+ const cfHost = isLocal ? "YOUR_HOST" : location.host;
1800
+ const tokenPart = authToken ? ` --token ${authToken}` : "";
1801
+ wsCfCmdEl.textContent =
1802
+ `npx copilot-proxy-web wc --url wss://${cfHost}/ws --sessionId default${tokenPart} --cf-access-id YOUR_ACCESS_ID --cf-access-secret YOUR_ACCESS_SECRET`;
1803
+ const cfAuthPart = authToken ? ` -s "${encodeAuthToken(authToken)}"` : "";
1804
+ wsCfWscatEl.textContent =
1805
+ `wscat -c wss://${cfHost}/ws?sessionId=default -H "CF-Access-Client-Id: YOUR_ACCESS_ID" -H "CF-Access-Client-Secret: YOUR_ACCESS_SECRET"${cfAuthPart}`;
1806
+ }
1807
+
1808
+ async function copyText(text, buttonEl) {
1809
+ if (!text) return;
1810
+ try {
1811
+ await navigator.clipboard.writeText(text);
1812
+ if (buttonEl) {
1813
+ const prior = buttonEl.textContent;
1814
+ buttonEl.textContent = t("copied");
1815
+ setTimeout(() => {
1816
+ buttonEl.textContent = prior;
1817
+ }, 1200);
1818
+ }
1819
+ } catch {
1820
+ const tmp = document.createElement("textarea");
1821
+ tmp.value = text;
1822
+ document.body.appendChild(tmp);
1823
+ tmp.select();
1824
+ document.execCommand("copy");
1825
+ tmp.remove();
1826
+ }
1827
+ }
1828
+
1829
+ wsQuickCopyEl.addEventListener("click", async () => {
1830
+ await copyText(wsQuickCmdEl.textContent || "", wsQuickCopyEl);
1831
+ });
1832
+ if (xtermDebugCopyEl) {
1833
+ xtermDebugCopyEl.addEventListener("click", async () => {
1834
+ const dump = xtermDebugLines.length > 0 ? xtermDebugLines.join("\n") : "[xterm-debug] (empty)";
1835
+ await copyText(dump, xtermDebugCopyEl);
1836
+ });
1837
+ }
1838
+ if (xtermDebugResetEl) {
1839
+ xtermDebugResetEl.addEventListener("click", async () => {
1840
+ xtermDebugLines.length = 0;
1841
+ try {
1842
+ await navigator.clipboard.writeText("");
1843
+ } catch {
1844
+ // ignore clipboard clear failures (permission/browser policy)
1845
+ }
1846
+ const prior = xtermDebugResetEl.textContent;
1847
+ xtermDebugResetEl.textContent = t("copied");
1848
+ setTimeout(() => {
1849
+ xtermDebugResetEl.textContent = prior;
1850
+ }, 800);
1851
+ });
1852
+ }
1853
+
1854
+ wsQuickMoreEl.addEventListener("click", () => {
1855
+ wsMoreEl.classList.toggle("hidden");
1856
+ wsQuickMoreEl.textContent = wsMoreEl.classList.contains("hidden") ? t("more") : t("less");
1857
+ });
1858
+
1859
+ document.querySelectorAll(".ws-more-copy").forEach((btn) => {
1860
+ btn.addEventListener("click", async () => {
1861
+ const kind = btn.getAttribute("data-copy");
1862
+ if (kind === "raw") await copyText(wsRawUrlEl.textContent || "", btn);
1863
+ if (kind === "raw-cli") await copyText(wsRawCliEl.textContent || "", btn);
1864
+ if (kind === "wscat") await copyText(wsWscatCmdEl.textContent || "", btn);
1865
+ if (kind === "cf") await copyText(wsCfCmdEl.textContent || "", btn);
1866
+ if (kind === "cf-wscat") await copyText(wsCfWscatEl.textContent || "", btn);
1867
+ });
1868
+ });
1869
+
1870
+ function sendBinary(type, text) {
1871
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
1872
+ const bytes = encoder.encode(text);
1873
+ const buf = new Uint8Array(bytes.length + 1);
1874
+ buf[0] = type;
1875
+ buf.set(bytes, 1);
1876
+ ws.send(buf);
1877
+ }
1878
+
1879
+ function getEchoToken(text) {
1880
+ if (!text) return "";
1881
+ const lines = String(text).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
1882
+ if (lines.length === 0) return "";
1883
+ const token = lines[lines.length - 1];
1884
+ return token.length > 120 ? token.slice(0, 120) : token;
1885
+ }
1886
+
1887
+ function makePasteSeq(text) {
1888
+ const normalized = String(text).replace(/\r?\n/g, "\n");
1889
+ return `\u001b[200~${normalized}\u001b[201~`;
1890
+ }
1891
+
1892
+ function extractEnterSeq(data) {
1893
+ if (data === "\r" || data === "\n" || data === "\r\n") return data;
1894
+ let seq = "";
1895
+ for (const ch of data) {
1896
+ if (ch === "\r" || ch === "\n") seq += ch;
1897
+ }
1898
+ return seq;
1899
+ }
1900
+
1901
+ term.onKey(({ key, domEvent }) => {
1902
+ if (domEvent.key === "Enter") {
1903
+ lastEnterSeq = "\r";
1904
+ lastEnterSeqRaw = "\r";
1905
+ const escaped = lastEnterSeq.replace(/\r/g, "\\r").replace(/\n/g, "\\n");
1906
+ enterSeqEl.textContent = `EnterSeq: ${escaped}`;
1907
+ const hex = Array.from(new TextEncoder().encode(lastEnterSeqRaw))
1908
+ .map((b) => b.toString(16).padStart(2, "0"))
1909
+ .join("");
1910
+ enterHexEl.textContent = `EnterHex: ${hex}`;
1911
+ }
1912
+ });
1913
+ setTimeout(() => {
1914
+ if (term.textarea) {
1915
+ term.textarea.addEventListener("focus", () => {
1916
+ termFocused = true;
1917
+ });
1918
+ term.textarea.addEventListener("blur", () => {
1919
+ termFocused = false;
1920
+ });
1921
+ }
1922
+ }, 0);
1923
+
1924
+ term.onData((data) => {
1925
+ // Guard against accidental key repeat floods in the browser.
1926
+ if (data.length === 1) {
1927
+ const now = Date.now();
1928
+ if (!term._repeatGuard) {
1929
+ term._repeatGuard = { ch: data, start: now, count: 1 };
1930
+ } else if (term._repeatGuard.ch === data) {
1931
+ const within = now - term._repeatGuard.start;
1932
+ term._repeatGuard.count += 1;
1933
+ if (within < 150 && term._repeatGuard.count >= 4) {
1934
+ return;
1935
+ }
1936
+ } else {
1937
+ term._repeatGuard = { ch: data, start: now, count: 1 };
1938
+ }
1939
+ }
1940
+ sendBinary(1, data);
1941
+ if (data.includes("\x0c")) {
1942
+ term.clear();
1943
+ if (activeXtermId) {
1944
+ const meta = xtermSessions.get(activeXtermId) || {};
1945
+ meta.buffer = "";
1946
+ xtermSessions.set(activeXtermId, meta);
1947
+ }
1948
+ }
1949
+ modeEl.textContent = "MODE: CONTROL";
1950
+ if (data.includes("\r") || data.includes("\n")) {
1951
+ const escaped = data.replace(/\r/g, "\\r").replace(/\n/g, "\\n");
1952
+ enterSeqEl.textContent = `EnterSeq: ${escaped}`;
1953
+ }
1954
+ });
1955
+
1956
+ let sending = false;
1957
+ const SUBMIT_DELAY_MS = 50;
1958
+ const ECHO_WAIT_MS = 600;
1959
+ let pendingEnter = null;
1960
+ let ptyResizeSyncTimer = null;
1961
+ let lastPtyResizeSync = { cols: null, rows: null };
1962
+
1963
+ const FOCUS_IN = "\u001b[I";
1964
+ const FOCUS_OUT = "\u001b[O";
1965
+
1966
+ function getSubmitSeq() {
1967
+ const mode = submitModeEl.value;
1968
+ if (mode === "auto") {
1969
+ return lastEnterSeqRaw || lastEnterSeq || "\r";
1970
+ }
1971
+ if (mode === "cr") return "\r";
1972
+ if (mode === "crlf") return "\r\n";
1973
+ if (mode === "doublecr") return "\r\r";
1974
+ if (mode === "shifttabenter") return "\u001b[Z\r";
1975
+ if (mode === "ctrlenter") return "\u001b[13;5u";
1976
+ if (mode === "lf") return "\n";
1977
+ return "\r\n";
1978
+ }
1979
+
1980
+ function sendCurrentInput() {
1981
+ if (!requireAuth()) return;
1982
+ if (!currentSessionId) return;
1983
+ const text = inputEl.value;
1984
+ if (!text.trim()) return;
1985
+ if (sending) return;
1986
+ sending = true;
1987
+ const enterSeq = getSubmitSeq();
1988
+ (async () => {
1989
+ try {
1990
+ if (inputModeApiEl && inputModeApiEl.checked) {
1991
+ const res = await fetchWithAuth(
1992
+ `/api/sessions/${encodeURIComponent(currentSessionId)}/keys`,
1993
+ {
1994
+ method: "POST",
1995
+ headers: { "Content-Type": "application/json" },
1996
+ body: JSON.stringify({
1997
+ data: FOCUS_IN + makePasteSeq(text),
1998
+ }),
1999
+ }
2000
+ );
2001
+ if (!res || !res.ok) return;
2002
+ await new Promise((resolve) => setTimeout(resolve, SUBMIT_DELAY_MS));
2003
+ submitOnly();
2004
+ setTimeout(() => {
2005
+ submitOnly();
2006
+ }, 40);
2007
+ } else {
2008
+ const res = await fetchWithAuth(
2009
+ `/api/sessions/${encodeURIComponent(currentSessionId)}/keys`,
2010
+ {
2011
+ method: "POST",
2012
+ headers: { "Content-Type": "application/json" },
2013
+ body: JSON.stringify({
2014
+ data: FOCUS_IN + makePasteSeq(text),
2015
+ }),
2016
+ }
2017
+ );
2018
+ if (!res || !res.ok) return;
2019
+ await new Promise((resolve) => setTimeout(resolve, SUBMIT_DELAY_MS));
2020
+ submitOnly();
2021
+ setTimeout(() => {
2022
+ submitOnly();
2023
+ }, 40);
2024
+ }
2025
+ lastSendEl.textContent = `已送出 ${text.length} 字 @ ${new Date().toLocaleTimeString()}`;
2026
+ inputEl.value = "";
2027
+ inputEl.focus();
2028
+ modeEl.textContent = "MODE: INPUT";
2029
+ } catch {
2030
+ // ignore
2031
+ } finally {
2032
+ sending = false;
2033
+ }
2034
+ })();
2035
+ }
2036
+
2037
+ function submitOnly() {
2038
+ const seq = getSubmitSeq();
2039
+ if (inputModeApiEl && inputModeApiEl.checked) {
2040
+ if (!currentSessionId) return;
2041
+ fetchWithAuth(`/api/sessions/${encodeURIComponent(currentSessionId)}/keys`, {
2042
+ method: "POST",
2043
+ headers: { "Content-Type": "application/json" },
2044
+ body: JSON.stringify({ data: FOCUS_IN + seq }),
2045
+ }).catch(() => {});
2046
+ } else {
2047
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
2048
+ sendBinary(1, FOCUS_IN);
2049
+ sendBinary(1, seq);
2050
+ }
2051
+ lastSendEl.textContent = `已提交 @ ${new Date().toLocaleTimeString()}`;
2052
+ modeEl.textContent = "MODE: INPUT";
2053
+ }
2054
+
2055
+ function schedulePtyResizeSync(reason) {
2056
+ if (sizeMode !== "pty") return;
2057
+ if (!currentSessionId) return;
2058
+ if (ptyResizeSyncTimer) clearTimeout(ptyResizeSyncTimer);
2059
+ ptyResizeSyncTimer = setTimeout(() => {
2060
+ const result = refreshPtyLocalViewport();
2061
+ if (!result) return;
2062
+ const { cols, rows, displayRows } = result;
2063
+ if (lastPtyResizeSync.cols === cols && lastPtyResizeSync.rows === displayRows) {
2064
+ return;
2065
+ }
2066
+ lastPtyResizeSync = { cols, rows: displayRows };
2067
+ if (displayRows !== rows) {
2068
+ ptyRowsEl.value = displayRows;
2069
+ }
2070
+ xtermDbg("pty-backend-resize", { reason, cols, requestedRows: rows, displayRows });
2071
+ resizeSession(cols, displayRows);
2072
+ }, 40);
2073
+ }
2074
+
2075
+ function sendKey(seq) {
2076
+ if (!requireAuth()) return;
2077
+ if (!currentSessionId) return;
2078
+ if (inputModeApiEl && inputModeApiEl.checked) {
2079
+ fetchWithAuth(`/api/sessions/${encodeURIComponent(currentSessionId)}/keys`, {
2080
+ method: "POST",
2081
+ headers: { "Content-Type": "application/json" },
2082
+ body: JSON.stringify({ data: seq }),
2083
+ }).catch(() => {});
2084
+ return;
2085
+ }
2086
+ sendBinary(1, seq);
2087
+ }
2088
+
2089
+ sendBtn.addEventListener("click", () => {
2090
+ sendCurrentInput();
2091
+ });
2092
+ submitBtn.addEventListener("click", () => {
2093
+ submitOnly();
2094
+ });
2095
+ if (keyUpBtn) keyUpBtn.addEventListener("click", () => sendKey("\u001b[A"));
2096
+ if (keyDownBtn) keyDownBtn.addEventListener("click", () => sendKey("\u001b[B"));
2097
+ if (keyRightBtn) keyRightBtn.addEventListener("click", () => sendKey("\u001b[C"));
2098
+ if (keyLeftBtn) keyLeftBtn.addEventListener("click", () => sendKey("\u001b[D"));
2099
+ if (keyEscBtn) keyEscBtn.addEventListener("click", () => sendKey("\u001b"));
2100
+ if (keyDelBtn) keyDelBtn.addEventListener("click", () => sendKey("\u007f"));
2101
+ if (convToggleEl) {
2102
+ convToggleEl.addEventListener("click", () => {
2103
+ setConversationView(!conversationView);
2104
+ });
2105
+ }
2106
+ window.addEventListener("hashchange", () => {
2107
+ const shouldShowConversation = wantsConversationFromHash();
2108
+ if (shouldShowConversation !== conversationView) {
2109
+ setConversationView(shouldShowConversation, { syncHash: false });
2110
+ }
2111
+ });
2112
+ if (conversationEl) {
2113
+ conversationEl.addEventListener("scroll", () => {
2114
+ conversationAutoScroll = isAtConversationBottom();
2115
+ });
2116
+ }
2117
+
2118
+ if (termContainerEl && "ResizeObserver" in window) {
2119
+ const ro = new ResizeObserver(() => {
2120
+ if (conversationView) return;
2121
+ requestAnimationFrame(() => {
2122
+ if (sizeMode === "web") {
2123
+ fitXterm("ResizeObserver");
2124
+ return;
2125
+ }
2126
+ schedulePtyResizeSync("ResizeObserver");
2127
+ });
2128
+ });
2129
+ ro.observe(termContainerEl);
2130
+ }
2131
+
2132
+ inputEl.addEventListener("keydown", (event) => {
2133
+ modeEl.textContent = "MODE: INPUT";
2134
+ if (event.key === "Enter" && !event.shiftKey && !isComposing) {
2135
+ event.preventDefault();
2136
+ sendCurrentInput();
2137
+ }
2138
+ });
2139
+ inputEl.addEventListener("compositionstart", () => {
2140
+ isComposing = true;
2141
+ });
2142
+ inputEl.addEventListener("compositionend", () => {
2143
+ isComposing = false;
2144
+ });
2145
+
2146
+ termEl.addEventListener("click", () => {
2147
+ term.focus();
2148
+ modeEl.textContent = "MODE: CONTROL";
2149
+ });
2150
+
2151
+ let sessionMeta = new Map();
2152
+ let sessionRefreshTimer = null;
2153
+
2154
+ function formatLastOutput(ts) {
2155
+ if (!ts) return "no output";
2156
+ const date = new Date(ts);
2157
+ if (Number.isNaN(date.getTime())) return "unknown";
2158
+ const pad2 = (n) => String(n).padStart(2, "0");
2159
+ const yyyy = date.getFullYear();
2160
+ const mm = pad2(date.getMonth() + 1);
2161
+ const dd = pad2(date.getDate());
2162
+ const hh = pad2(date.getHours());
2163
+ const min = pad2(date.getMinutes());
2164
+ return `${yyyy}-${mm}-${dd}\u00a0${hh}:${min}`;
2165
+ }
2166
+
2167
+ function updateSessionInfo() {
2168
+ if (!currentSessionId) {
2169
+ sessionInfoEl.textContent = "";
2170
+ updateWsQuickCmd();
2171
+ return;
2172
+ }
2173
+ const meta = sessionMeta.get(currentSessionId);
2174
+ if (!meta) {
2175
+ sessionInfoEl.textContent = "";
2176
+ updateWsQuickCmd();
2177
+ return;
2178
+ }
2179
+ const idleText = meta.idleActive ? "idle" : "active";
2180
+ const lastOutput = formatLastOutput(meta.lastOutputTs);
2181
+ sessionInfoEl.textContent = `status: ${idleText}, last: ${lastOutput}`;
2182
+ updateWsQuickCmd();
2183
+ }
2184
+
2185
+ function renderHooks(events) {
2186
+ if (!Array.isArray(events) || events.length === 0) {
2187
+ hooksInfoEl.textContent = t("hooks_none");
2188
+ hooksListEl.textContent = "";
2189
+ return;
2190
+ }
2191
+ const latest = events[events.length - 1] || {};
2192
+ const hookerLabel =
2193
+ (typeof latest.hooker === "string" && latest.hooker.trim()) ||
2194
+ (typeof latest.name === "string" && latest.name.trim()) ||
2195
+ "hooker01";
2196
+ const latestTs = latest.ts ? new Date(latest.ts).toLocaleTimeString() : "unknown";
2197
+ hooksInfoEl.textContent = `hooks: ${hookerLabel}@${latestTs}`;
2198
+ hooksListEl.textContent = events
2199
+ .slice(-10)
2200
+ .map((e) => `${e.type} @ ${new Date(e.ts).toLocaleTimeString()}`)
2201
+ .join("\n");
2202
+ }
2203
+
2204
+ function renderHookErrors(status) {
2205
+ if (!status || !Number.isFinite(status.errorCount)) {
2206
+ hooksErrorEl.textContent = "";
2207
+ return;
2208
+ }
2209
+ if (status.errorCount === 0) {
2210
+ hooksErrorEl.textContent = "hook errors: 0";
2211
+ return;
2212
+ }
2213
+ const last = status.lastError
2214
+ ? `${status.lastError.type}@${new Date(status.lastError.ts).toLocaleTimeString()}`
2215
+ : "unknown";
2216
+ hooksErrorEl.textContent = `hook errors: ${status.errorCount} (last: ${last})`;
2217
+ }
2218
+
2219
+ let conversationEvents = [];
2220
+ let conversationView = false;
2221
+ let conversationSessionId = null;
2222
+ let conversationLastSeq = 0;
2223
+ let conversationPollTimer = null;
2224
+ let conversationAutoScroll = true;
2225
+
2226
+ function wantsConversationFromHash() {
2227
+ return String(location.hash || "").toLowerCase() === "#conversation";
2228
+ }
2229
+
2230
+ function syncConversationHash(show) {
2231
+ const url = new URL(location.href);
2232
+ url.hash = show ? "conversation" : "";
2233
+ history.replaceState(null, "", url.toString());
2234
+ }
2235
+
2236
+ function escapeHtml(text) {
2237
+ return String(text)
2238
+ .replace(/&/g, "&amp;")
2239
+ .replace(/</g, "&lt;")
2240
+ .replace(/>/g, "&gt;")
2241
+ .replace(/"/g, "&quot;")
2242
+ .replace(/'/g, "&#39;");
2243
+ }
2244
+
2245
+ function renderMarkdown(raw) {
2246
+ if (!raw) return "";
2247
+ let text = escapeHtml(raw);
2248
+ text = text.replace(/```([\s\S]*?)```/g, (_, code) => {
2249
+ return `<pre><code>${code}</code></pre>`;
2250
+ });
2251
+ text = text.replace(/`([^`]+)`/g, "<code>$1</code>");
2252
+ text = text
2253
+ .split(/\n{2,}/)
2254
+ .map((para) => `<p>${para.replace(/\n/g, "<br/>")}</p>`)
2255
+ .join("");
2256
+ return text;
2257
+ }
2258
+
2259
+ function isAtConversationBottom() {
2260
+ if (!conversationEl) return true;
2261
+ const threshold = 24;
2262
+ return conversationEl.scrollHeight - conversationEl.scrollTop - conversationEl.clientHeight <= threshold;
2263
+ }
2264
+
2265
+ function renderConversation() {
2266
+ if (!conversationEl) return;
2267
+ const activeMeta = currentSessionId ? sessionMeta.get(currentSessionId) : null;
2268
+ const profile = activeMeta?.conversationProfile || "";
2269
+ const profileLabel =
2270
+ profile === "copilot" ? "GitHub Copilot CLI" : "";
2271
+ const shouldStick = conversationAutoScroll || isAtConversationBottom();
2272
+ conversationEl.innerHTML = "";
2273
+ for (const event of conversationEvents) {
2274
+ const item = document.createElement("div");
2275
+ item.className = `conversation-item role-${event.role || "assistant"}`;
2276
+ const meta = document.createElement("div");
2277
+ meta.className = "conversation-meta";
2278
+ const ts = event.ts ? new Date(event.ts).toLocaleTimeString() : "";
2279
+ const rowRange =
2280
+ event?.meta?.rows && Number.isFinite(event.meta.rows.start) && Number.isFinite(event.meta.rows.end)
2281
+ ? ` (rows: ${event.meta.rows.start}-${event.meta.rows.end})`
2282
+ : "";
2283
+ if ((event.role || "assistant") === "assistant" && profileLabel) {
2284
+ meta.textContent = `assistant (Profile: ${profileLabel})${ts ? " • " + ts : ""}${rowRange}`;
2285
+ } else {
2286
+ meta.textContent = `${event.role || "assistant"}${ts ? " • " + ts : ""}${rowRange}`;
2287
+ }
2288
+ const body = document.createElement("div");
2289
+ body.className = "conversation-body";
2290
+ const isTerminal =
2291
+ event?.role === "assistant" && event?.meta?.format === "terminal";
2292
+ if (isTerminal) {
2293
+ const pre = document.createElement("pre");
2294
+ pre.className = "conversation-pre";
2295
+ pre.textContent = event.markdown || "";
2296
+ body.appendChild(pre);
2297
+ } else {
2298
+ body.innerHTML = renderMarkdown(event.markdown || "");
2299
+ }
2300
+ item.appendChild(meta);
2301
+ item.appendChild(body);
2302
+ conversationEl.appendChild(item);
2303
+ }
2304
+ if (shouldStick) {
2305
+ conversationEl.scrollTop = conversationEl.scrollHeight;
2306
+ }
2307
+ }
2308
+
2309
+ function appendConversationEvent(event) {
2310
+ if (!event) return;
2311
+ if (event.seq && event.seq <= conversationLastSeq) return;
2312
+ conversationLastSeq = event.seq || conversationLastSeq;
2313
+ conversationEvents.push(event);
2314
+ if (conversationEvents.length > 200) {
2315
+ conversationEvents.splice(0, conversationEvents.length - 200);
2316
+ }
2317
+ renderConversation();
2318
+ }
2319
+
2320
+ async function loadConversationSnapshot() {
2321
+ if (!currentSessionId) return;
2322
+ const res = await fetchWithAuth(
2323
+ `/api/sessions/${encodeURIComponent(currentSessionId)}/conversation`
2324
+ );
2325
+ if (!res || !res.ok) return;
2326
+ const data = await res.json().catch(() => null);
2327
+ conversationEvents = data?.events || [];
2328
+ conversationLastSeq = data?.lastSeq || 0;
2329
+ renderConversation();
2330
+ }
2331
+
2332
+ function stopConversationPoll() {
2333
+ if (conversationPollTimer) {
2334
+ clearInterval(conversationPollTimer);
2335
+ conversationPollTimer = null;
2336
+ }
2337
+ }
2338
+
2339
+ async function startConversationSession() {
2340
+ if (!conversationView) return;
2341
+ if (!currentSessionId) return;
2342
+ if (conversationSessionId !== currentSessionId) {
2343
+ conversationSessionId = currentSessionId;
2344
+ conversationEvents = [];
2345
+ conversationLastSeq = 0;
2346
+ renderConversation();
2347
+ }
2348
+ await loadConversationSnapshot();
2349
+ stopConversationPoll();
2350
+ conversationPollTimer = setInterval(() => {
2351
+ loadConversationSnapshot();
2352
+ }, 2000);
2353
+ }
2354
+
2355
+ function setConversationView(show, { syncHash = true } = {}) {
2356
+ conversationView = show;
2357
+ if (convToggleEl) convToggleEl.classList.toggle("active", show);
2358
+ if (conversationEl) conversationEl.classList.toggle("active", show);
2359
+ if (termContainer) termContainer.classList.toggle("hidden", show);
2360
+ if (syncHash) syncConversationHash(show);
2361
+ if (show) {
2362
+ startConversationSession();
2363
+ } else {
2364
+ stopConversationPoll();
2365
+ setTimeout(() => {
2366
+ fitXterm("setConversationView-false");
2367
+ scrollXtermBottom("setConversationView-false");
2368
+ }, 0);
2369
+ }
2370
+ }
2371
+
2372
+ async function fetchWithAuth(url, options = {}) {
2373
+ const headers = Object.assign({}, options.headers);
2374
+ if (authToken) headers.Authorization = `Bearer ${authToken}`;
2375
+ const res = await fetch(url, Object.assign({}, options, { headers }));
2376
+ const ipHeader = res.headers.get("X-Client-IP");
2377
+ if (ipHeader) setClientIp(ipHeader);
2378
+ const versionHeader = res.headers.get("X-App-Version");
2379
+ if (versionHeader) setAppVersion(versionHeader);
2380
+ if (res.status === 401 && !authToken) {
2381
+ authRequired = true;
2382
+ showAuthOverlay(true);
2383
+ return null;
2384
+ } else if (res.ok) {
2385
+ authRequired = false;
2386
+ }
2387
+ return res;
2388
+ }
2389
+
2390
+ function promptSessionStart({
2391
+ title = t("prompt_command_tab"),
2392
+ defaultCmd = "",
2393
+ askTabName = false,
2394
+ defaultTabName = "",
2395
+ } = {}) {
2396
+ sessionModalTitleEl.textContent = title;
2397
+ sessionModalTitleEl.dataset.baseTitle = title;
2398
+ updateSessionTitle();
2399
+ sessionNameRowEl.style.display = askTabName ? "block" : "none";
2400
+ sessionNameEl.value = defaultTabName || "";
2401
+ sessionCmdEl.value = defaultCmd || "";
2402
+ if (sessionShellInteractiveEl) sessionShellInteractiveEl.checked = true;
2403
+ if (sessionConversationProfileEl) sessionConversationProfileEl.value = "none";
2404
+ sessionModalEl.classList.add("show");
2405
+ sessionCmdEl.focus();
2406
+
2407
+ return new Promise((resolve) => {
2408
+ function cleanup() {
2409
+ sessionModalEl.classList.remove("show");
2410
+ sessionOkEl.removeEventListener("click", onOk);
2411
+ sessionCancelEl.removeEventListener("click", onCancel);
2412
+ }
2413
+ function onOk() {
2414
+ const tabName = sessionNameEl.value.trim();
2415
+ const cmdLine = sessionCmdEl.value.trim();
2416
+ const conversationProfile = sessionConversationProfileEl
2417
+ ? sessionConversationProfileEl.value
2418
+ : "none";
2419
+ cleanup();
2420
+ if (!cmdLine) {
2421
+ resolve(null);
2422
+ return;
2423
+ }
2424
+ resolve({
2425
+ tabName: tabName ? tabName : undefined,
2426
+ command: cmdLine,
2427
+ shellInteractive: sessionShellInteractiveEl
2428
+ ? Boolean(sessionShellInteractiveEl.checked)
2429
+ : true,
2430
+ conversationProfile,
2431
+ });
2432
+ }
2433
+ function onCancel() {
2434
+ cleanup();
2435
+ resolve(null);
2436
+ }
2437
+ sessionOkEl.addEventListener("click", onOk);
2438
+ sessionCancelEl.addEventListener("click", onCancel);
2439
+ });
2440
+ }
2441
+
2442
+ async function startCurrentSession() {
2443
+ if (!requireAuth()) return;
2444
+ try {
2445
+ const payload = await promptSessionStart({ title: t("prompt_command_tab") });
2446
+ if (!payload) return;
2447
+ const res = await fetchWithAuth(`/api/sessions/${encodeURIComponent(currentSessionId)}/start`, {
2448
+ method: "POST",
2449
+ headers: { "Content-Type": "application/json" },
2450
+ body: JSON.stringify(payload),
2451
+ });
2452
+ if (!res || !res.ok) return;
2453
+ await loadSessions();
2454
+ connectWebSocket(currentSessionId);
2455
+ } catch {
2456
+ // ignore
2457
+ }
2458
+ }
2459
+
2460
+
2461
+ let promptedDefault = false;
2462
+
2463
+ async function createDefaultSessionIfMissing() {
2464
+ if (promptedDefault) return;
2465
+ promptedDefault = true;
2466
+ const payload = await promptSessionStart({
2467
+ title: t("prompt_create_default"),
2468
+ defaultCmd: "copilot",
2469
+ askTabName: true,
2470
+ defaultTabName: "default",
2471
+ });
2472
+ if (!payload) return;
2473
+ try {
2474
+ const res = await fetchWithAuth("/api/sessions", {
2475
+ method: "POST",
2476
+ headers: { "Content-Type": "application/json" },
2477
+ body: JSON.stringify({
2478
+ id: "default",
2479
+ autoStart: false,
2480
+ conversationProfile: payload.conversationProfile || "none",
2481
+ }),
2482
+ });
2483
+ if (!res || !res.ok) return;
2484
+ const data = await res.json();
2485
+ if (data?.session?.id) {
2486
+ currentSessionId = "default";
2487
+ const url = new URL(location.href);
2488
+ url.searchParams.set("sessionId", currentSessionId);
2489
+ history.replaceState(null, "", url.toString());
2490
+ const startRes = await fetchWithAuth(`/api/sessions/${encodeURIComponent(currentSessionId)}/start`, {
2491
+ method: "POST",
2492
+ headers: { "Content-Type": "application/json" },
2493
+ body: JSON.stringify(payload),
2494
+ });
2495
+ if (!startRes || !startRes.ok) return;
2496
+ await loadSessions();
2497
+ connectWebSocket(currentSessionId);
2498
+ }
2499
+ } catch {
2500
+ // ignore
2501
+ }
2502
+ }
2503
+
2504
+ async function loadSessions() {
2505
+ if (!requireAuth()) return;
2506
+ try {
2507
+ const res = await fetchWithAuth("/api/sessions");
2508
+ if (!res || !res.ok) return;
2509
+ const data = await res.json();
2510
+ sessionMeta = new Map();
2511
+ for (const session of data.sessions || []) {
2512
+ sessionMeta.set(session.id, session);
2513
+ }
2514
+ if (!data.sessions || data.sessions.length === 0) {
2515
+ currentSessionId = null;
2516
+ xtermSessions = new Map();
2517
+ activeXtermId = null;
2518
+ renderXtermTabs();
2519
+ term.clear();
2520
+ setStatus(t("status_no_session"), "#e67e22");
2521
+ await createDefaultSessionIfMissing();
2522
+ return;
2523
+ }
2524
+ rebuildXtermTabsFromSessions(data.sessions);
2525
+ if (!data.sessions?.find((s) => s.id === currentSessionId)) {
2526
+ currentSessionId = data.sessions[0].id;
2527
+ }
2528
+ if (conversationView) startConversationSession();
2529
+ const active = data.sessions?.find((s) => s.id === currentSessionId);
2530
+ if (active) {
2531
+ ptyColsEl.value = active.cols || active.initialCols || "";
2532
+ ptyRowsEl.value = active.rows || active.initialRows || "";
2533
+ if (sizeMode === "pty" && active.initialCols && active.initialRows) {
2534
+ schedulePtyResizeSync("loadSessions-active");
2535
+ }
2536
+ if (active.started !== false) {
2537
+ if (!ws || ws.readyState !== WebSocket.OPEN || currentWsSessionId !== currentSessionId) {
2538
+ connectWebSocket(currentSessionId);
2539
+ loadHooks();
2540
+ }
2541
+ } else {
2542
+ if (ws && ws.readyState === WebSocket.OPEN) ws.close();
2543
+ setStatus(t("status_disconnected_with_id", { id: currentSessionId }), "#e67e22");
2544
+ }
2545
+ }
2546
+ updateSessionInfo();
2547
+ } catch {
2548
+ // ignore
2549
+ }
2550
+ }
2551
+
2552
+ async function loadHooks() {
2553
+ if (!requireAuth()) return;
2554
+ if (!currentSessionId) return;
2555
+ try {
2556
+ const res = await fetchWithAuth(`/api/hooks?sessionId=${encodeURIComponent(currentSessionId)}`);
2557
+ if (!res || !res.ok) return;
2558
+ const data = await res.json();
2559
+ renderHooks(data.events || []);
2560
+ const resStatus = await fetchWithAuth(`/api/hooks/status?sessionId=${encodeURIComponent(currentSessionId)}`);
2561
+ if (!resStatus || !resStatus.ok) return;
2562
+ const status = await resStatus.json();
2563
+ renderHookErrors(status);
2564
+ } catch {
2565
+ // ignore
2566
+ }
2567
+ }
2568
+
2569
+ async function clearHooks() {
2570
+ if (!requireAuth()) return;
2571
+ try {
2572
+ const res = await fetchWithAuth(`/api/hooks/clear?sessionId=${encodeURIComponent(currentSessionId)}`, {
2573
+ method: "POST",
2574
+ });
2575
+ if (!res || !res.ok) return;
2576
+ loadHooks();
2577
+ } catch {
2578
+ // ignore
2579
+ }
2580
+ }
2581
+
2582
+ function startSessionRefresh() {
2583
+ if (sessionRefreshTimer) clearInterval(sessionRefreshTimer);
2584
+ sessionRefreshTimer = setInterval(() => {
2585
+ loadSessions();
2586
+ loadHooks();
2587
+ }, 5000);
2588
+ }
2589
+
2590
+ async function createSession() {
2591
+ if (!requireAuth()) return;
2592
+ try {
2593
+ const payload = await promptSessionStart({
2594
+ title: t("prompt_create_session"),
2595
+ defaultCmd: "copilot",
2596
+ askTabName: true,
2597
+ defaultTabName: nextDefaultTabName(),
2598
+ });
2599
+ if (!payload) return;
2600
+ const res = await fetchWithAuth("/api/sessions", {
2601
+ method: "POST",
2602
+ headers: { "Content-Type": "application/json" },
2603
+ body: JSON.stringify({
2604
+ id: payload.tabName || undefined,
2605
+ autoStart: false,
2606
+ conversationProfile: payload.conversationProfile || "none",
2607
+ }),
2608
+ });
2609
+ if (!res || !res.ok) return;
2610
+ const data = await res.json();
2611
+ if (data?.session?.id) {
2612
+ rememberUsedTabName(data.session.id);
2613
+ currentSessionId = data.session.id;
2614
+ const url = new URL(location.href);
2615
+ url.searchParams.set("sessionId", currentSessionId);
2616
+ history.replaceState(null, "", url.toString());
2617
+ // create or bind a tab to this new backend session
2618
+ const tabId = ensureXtermBoundToSession(currentSessionId);
2619
+ const title = payload.tabName ? payload.tabName : `sess:${currentSessionId}`;
2620
+ xtermSessions.set(tabId, Object.assign(xtermSessions.get(tabId) || {}, { title, sessionId: currentSessionId }));
2621
+ renderXtermTabs();
2622
+ const startRes = await fetchWithAuth(`/api/sessions/${encodeURIComponent(currentSessionId)}/start`, {
2623
+ method: "POST",
2624
+ headers: { "Content-Type": "application/json" },
2625
+ body: JSON.stringify(payload),
2626
+ });
2627
+ if (!startRes || !startRes.ok) return;
2628
+ await loadSessions();
2629
+ connectWebSocket(currentSessionId);
2630
+ }
2631
+ } catch {
2632
+ // ignore
2633
+ }
2634
+ }
2635
+
2636
+
2637
+ async function deleteSession(sessionId) {
2638
+ if (!requireAuth()) return;
2639
+ try {
2640
+ const res = await fetchWithAuth(`/api/sessions/${encodeURIComponent(sessionId)}`, {
2641
+ method: "DELETE",
2642
+ });
2643
+ if (!res || !res.ok) return;
2644
+ if (currentSessionId === sessionId) {
2645
+ currentSessionId = null;
2646
+ const url = new URL(location.href);
2647
+ url.searchParams.delete("sessionId");
2648
+ history.replaceState(null, "", url.toString());
2649
+ currentWsSessionId = null;
2650
+ if (ws) ws.close();
2651
+ }
2652
+ for (const [id, meta] of xtermSessions.entries()) {
2653
+ if (meta.sessionId === sessionId) {
2654
+ xtermSessions.delete(id);
2655
+ }
2656
+ }
2657
+ if (activeXtermId && !xtermSessions.has(activeXtermId)) {
2658
+ activeXtermId = null;
2659
+ }
2660
+ renderXtermTabs();
2661
+ updateWsQuickCmd();
2662
+ setStatus(t("status_no_session"), "#e67e22");
2663
+ await loadSessions();
2664
+ } catch {
2665
+ // ignore
2666
+ }
2667
+ }
2668
+
2669
+ hooksClearBtn.addEventListener("click", () => {
2670
+ clearHooks();
2671
+ });
2672
+
2673
+ async function resizeSession(cols, rows) {
2674
+ if (!requireAuth()) return;
2675
+ if (!currentSessionId) return;
2676
+ try {
2677
+ const res = await fetchWithAuth(`/api/sessions/${encodeURIComponent(currentSessionId)}/resize`, {
2678
+ method: "POST",
2679
+ headers: { "Content-Type": "application/json" },
2680
+ body: JSON.stringify({ cols, rows }),
2681
+ });
2682
+ if (!res || !res.ok) return;
2683
+ await loadSessions();
2684
+ } catch {
2685
+ // ignore
2686
+ }
2687
+ }
2688
+
2689
+ sizeApplyBtn.addEventListener("click", () => {
2690
+ const cols = Number(ptyColsEl.value);
2691
+ const rows = Number(ptyRowsEl.value);
2692
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
2693
+ sizeMode = "pty";
2694
+ sizeModeEl.value = "pty";
2695
+ schedulePtyResizeSync("sizeApply");
2696
+ });
2697
+
2698
+ sizeModeEl.addEventListener("change", async () => {
2699
+ const next = sizeModeEl.value === "web" ? "web" : "pty";
2700
+ sizeMode = next;
2701
+ localStorage.setItem(SIZE_MODE_KEY, sizeMode);
2702
+ if (sizeMode === "web") {
2703
+ fitXterm("sizeMode-web");
2704
+ resizeSession(term.cols, term.rows);
2705
+ return;
2706
+ }
2707
+ if (!requireAuth()) return;
2708
+ if (!currentSessionId) return;
2709
+ try {
2710
+ const res = await fetchWithAuth(`/api/sessions/${encodeURIComponent(currentSessionId)}/size`);
2711
+ if (!res || !res.ok) return;
2712
+ const data = await res.json();
2713
+ if (data?.initialCols && data?.initialRows) {
2714
+ ptyColsEl.value = data.initialCols;
2715
+ ptyRowsEl.value = data.initialRows;
2716
+ schedulePtyResizeSync("sizeMode-pty");
2717
+ }
2718
+ } catch {
2719
+ // ignore
2720
+ }
2721
+ });
2722
+
2723
+ authSaveBtn.addEventListener("click", () => {
2724
+ const value = authTokenEl.value.trim();
2725
+ if (!value) return;
2726
+ (async () => {
2727
+ try {
2728
+ const res = await fetch("/api/status", {
2729
+ headers: { Authorization: `Bearer ${value}` },
2730
+ });
2731
+ const ipHeader = res.headers.get("X-Client-IP");
2732
+ if (ipHeader) setClientIp(ipHeader);
2733
+ const versionHeader = res.headers.get("X-App-Version");
2734
+ if (versionHeader) setAppVersion(versionHeader);
2735
+ const userHeader = res.headers.get("X-App-User");
2736
+ if (userHeader) setSessionUser(userHeader);
2737
+ if (res.status === 401) {
2738
+ const remaining = res.headers.get("X-Auth-Failures-Remaining");
2739
+ if (authErrorEl) {
2740
+ authErrorEl.textContent = remaining
2741
+ ? `Token 無效,剩餘 ${remaining} 次嘗試。`
2742
+ : "Token 無效,請再試一次。";
2743
+ }
2744
+ authTokenEl.value = "";
2745
+ return;
2746
+ }
2747
+ if (res.status === 429) {
2748
+ if (authErrorEl) authErrorEl.textContent = "嘗試次數過多,請稍後再試。";
2749
+ authTokenEl.value = "";
2750
+ return;
2751
+ }
2752
+ if (res.status === 404) {
2753
+ if (authErrorEl) authErrorEl.textContent = "Token 正確,尚未建立 session。";
2754
+ } else if (!res.ok) {
2755
+ if (authErrorEl) authErrorEl.textContent = "無法驗證 token,請稍後再試。";
2756
+ return;
2757
+ }
2758
+ const data = await res.json().catch(() => null);
2759
+ if (data?.version) setAppVersion(data.version);
2760
+ if (data?.user) setSessionUser(data.user);
2761
+ authToken = value;
2762
+ authRequired = false;
2763
+ sessionStorage.setItem(AUTH_KEY, value);
2764
+ authTokenEl.value = "";
2765
+ showAuthOverlay(false);
2766
+ setLogoutVisible(true);
2767
+ loadSessions().then(() => {
2768
+ if (currentSessionId) {
2769
+ connectWebSocket(currentSessionId);
2770
+ loadHooks();
2771
+ }
2772
+ });
2773
+ startSessionRefresh();
2774
+ } catch {
2775
+ if (authErrorEl) authErrorEl.textContent = "無法驗證 token,請檢查連線。";
2776
+ }
2777
+ })();
2778
+ });
2779
+
2780
+ async function bootstrapAuth() {
2781
+ try {
2782
+ const headers = authToken ? { Authorization: `Bearer ${authToken}` } : {};
2783
+ const res = await fetch("/api/status", { headers });
2784
+ const ipHeader = res.headers.get("X-Client-IP");
2785
+ if (ipHeader) setClientIp(ipHeader);
2786
+ const versionHeader = res.headers.get("X-App-Version");
2787
+ if (versionHeader) setAppVersion(versionHeader);
2788
+ const userHeader = res.headers.get("X-App-User");
2789
+ if (userHeader) setSessionUser(userHeader);
2790
+ if (res.status === 401) {
2791
+ authRequired = true;
2792
+ showAuthOverlay(true);
2793
+ setLogoutVisible(false);
2794
+ return;
2795
+ }
2796
+ if (res.status === 429) {
2797
+ authRequired = true;
2798
+ showAuthOverlay(true);
2799
+ if (authErrorEl) authErrorEl.textContent = "嘗試次數過多,請稍後再試。";
2800
+ setLogoutVisible(false);
2801
+ return;
2802
+ }
2803
+ if (res.status === 404) {
2804
+ authRequired = false;
2805
+ if (authErrorEl) authErrorEl.textContent = "Token 正確,尚未建立 session。";
2806
+ } else if (!res.ok) {
2807
+ authRequired = null;
2808
+ return;
2809
+ }
2810
+ const data = await res.json().catch(() => null);
2811
+ if (data?.version) setAppVersion(data.version);
2812
+ if (data?.user) setSessionUser(data.user);
2813
+ authRequired = false;
2814
+ showAuthOverlay(false);
2815
+ setLogoutVisible(Boolean(authToken));
2816
+ loadSessions().then(() => {
2817
+ if (currentSessionId) {
2818
+ connectWebSocket(currentSessionId);
2819
+ loadHooks();
2820
+ }
2821
+ });
2822
+ startSessionRefresh();
2823
+ } catch {
2824
+ authRequired = null;
2825
+ showAuthOverlay(true);
2826
+ }
2827
+ }
2828
+
2829
+ if (authLogoutBtn) {
2830
+ authLogoutBtn.addEventListener("click", () => {
2831
+ authToken = "";
2832
+ authRequired = true;
2833
+ sessionStorage.removeItem(AUTH_KEY);
2834
+ setSessionUser("");
2835
+ setLogoutVisible(false);
2836
+ showAuthOverlay(true);
2837
+ if (ws && ws.readyState === WebSocket.OPEN) ws.close();
2838
+ currentWsSessionId = null;
2839
+ if (sessionRefreshTimer) {
2840
+ clearInterval(sessionRefreshTimer);
2841
+ sessionRefreshTimer = null;
2842
+ }
2843
+ });
2844
+ }
2845
+
2846
+ setConversationView(wantsConversationFromHash(), { syncHash: false });
2847
+ bootstrapAuth();
2848
+ </script>
2849
+ </body>
2850
+ </html>