@oharness/server 0.8.0 → 0.9.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.
@@ -33,8 +33,25 @@ code,pre,.mono{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,
33
33
  /* sidebar */
34
34
  aside{
35
35
  width:236px;flex:0 0 236px;background:var(--panel);
36
- display:flex;flex-direction:column;overflow:hidden;
36
+ display:flex;flex-direction:column;overflow:hidden;transition:flex-basis .18s,width .18s;
37
37
  }
38
+ /* Folded away on a desktop. Zero width rather than display:none, for the
39
+ slide, and so nothing inside has to be rebuilt when it comes back. */
40
+ body.nav-collapsed>aside{width:0;flex-basis:0}
41
+ #nav-collapse,#nav-expand{
42
+ width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:var(--dim);
43
+ cursor:pointer;padding:0;display:grid;place-items:center;flex:0 0 auto;-webkit-app-region:no-drag;
44
+ }
45
+ #nav-collapse{margin-left:auto}
46
+ #nav-collapse:hover,#nav-expand:hover{background:var(--hover);color:var(--fg)}
47
+ #nav-collapse svg,#nav-expand svg{width:16px;height:16px;display:block;shape-rendering:geometricPrecision}
48
+ #nav-expand{display:none;position:absolute;z-index:5;top:7px;left:8px}
49
+ body.nav-collapsed #nav-expand{display:grid}
50
+ body.nav-collapsed #tabs{padding-left:44px}
51
+ /* The traffic lights sit where the sidebar's corner was; step past them. */
52
+ body.inset-titlebar.nav-collapsed #nav-expand{left:80px}
53
+ body.inset-titlebar.nav-collapsed #tabs{padding-left:116px;-webkit-app-region:drag}
54
+ body.inset-titlebar.nav-collapsed #tabs>*{-webkit-app-region:no-drag}
38
55
  aside header{padding:15px 14px 8px;display:flex;gap:8px;align-items:center}
39
56
  /* The desktop shell hides the title bar, so on macOS the traffic lights float
40
57
  * over this corner. The wordmark moves below them, and the strip they sit in
@@ -531,8 +548,14 @@ details.provider>summary .vmark.filled,.pp-item .vmark.filled{padding:0}
531
548
  .tab .dot{width:6px;height:6px;border-radius:50%;background:var(--brand);flex:0 0 auto}
532
549
  .tab .x{
533
550
  width:18px;height:18px;padding:0;border:0;border-radius:5px;background:transparent;color:inherit;
534
- display:grid;place-items:center;cursor:pointer;opacity:0;flex:0 0 auto;
535
- }
551
+ display:none;place-items:center;cursor:pointer;opacity:0;flex:0 0 auto;
552
+ }
553
+ /* A tab's controls take no room until the pointer or focus is on it: an idle
554
+ tab spends its width on its title, and a hovered one gives some back to its
555
+ buttons, where the title then ends in an ellipsis. Hidden rather than only
556
+ transparent — a transparent button still holds its space. A running
557
+ browser keeps its button, because its dot is what says so. */
558
+ .tab:hover .x,.tab:focus-within .x{display:grid}
536
559
  /* The tab's browser button's size. Much smaller and the 1.5px strokes thin
537
560
  below a pixel and blur; the full 16px outweighs the tab's own text. */
538
561
  .tab .x svg{width:14px;height:14px}
@@ -543,6 +566,8 @@ details.provider>summary .vmark.filled,.pp-item .vmark.filled{padding:0}
543
566
  display:grid;place-items:center;background:transparent;color:inherit;cursor:pointer;
544
567
  opacity:0;flex:0 0 auto;
545
568
  }
569
+ .tab .tab-browser{display:none}
570
+ .tab:hover .tab-browser,.tab:focus-within .tab-browser,.tab .tab-browser.running{display:grid}
546
571
  .tab:hover .tab-browser,.tab.on .tab-browser,.tab-browser.running{opacity:.62}
547
572
  .tab-browser:hover,.tab-browser.on{opacity:1;background:var(--sel)}
548
573
  .tab-browser svg{width:14px;height:14px}
@@ -799,6 +824,140 @@ body.panelled #composer,body.panelled #jump,body.panelled #changes-btn{display:n
799
824
  #diff pre.diff{padding:8px 0}
800
825
  .d-add,.d-del,.d-same{padding:0 12px}
801
826
  .hunk{color:var(--dim);background:var(--field);padding:3px 12px;font-size:11px}
827
+ /*
828
+ * The files pane: the same column as #review, a little wider because it holds
829
+ * two — the tree, and the file picked from it.
830
+ */
831
+ #explorer{
832
+ width:0;flex:0 0 0;background:var(--panel);
833
+ display:flex;flex-direction:column;overflow:hidden;transition:flex-basis .18s,width .18s;
834
+ /* Above the transcript column, whose composer bar can spill sideways when
835
+ the window is narrow enough to squeeze it. */
836
+ position:relative;z-index:2;
837
+ }
838
+ #explorer.open{width:var(--explorer-width,min(52vw,860px));flex:0 0 var(--explorer-width,min(52vw,860px));overflow:clip;overflow-clip-margin:6px}
839
+ #explorer-head{display:flex;align-items:center;gap:8px;padding:10px 10px 6px 16px;font-size:12.5px}
840
+ #explorer-title{font-weight:600;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
841
+ #explorer-actions{display:flex;gap:2px}
842
+ #explorer-actions button{
843
+ width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:var(--dim);
844
+ cursor:pointer;padding:0;display:grid;place-items:center;
845
+ }
846
+ #explorer-actions button:hover{background:var(--hover);color:var(--fg)}
847
+ #explorer-actions svg{width:15px;height:15px}
848
+ #explorer-body{flex:1;display:flex;min-height:0}
849
+ #explorer-tree{width:var(--explorer-tree-width,min(40%,260px));flex:0 0 auto;overflow:auto;padding:2px 6px 12px}
850
+ /* The line between the tree and the file, and the handle that moves it: a
851
+ gap wide enough to grab, drawn only while it is being pointed at. */
852
+ #explorer-split{flex:0 0 7px;margin:0 -3px;position:relative;z-index:1;cursor:col-resize;touch-action:none;outline:0}
853
+ #explorer-split::before{
854
+ content:"";position:absolute;left:2px;top:50%;width:3px;height:34px;border-radius:999px;
855
+ background:var(--dim);opacity:0;transform:translateY(-50%) scaleY(.65);transition:opacity .12s,transform .16s;
856
+ }
857
+ .explorer-row{
858
+ display:flex;align-items:center;gap:5px;width:100%;border:0;background:transparent;color:var(--fg);
859
+ font:inherit;font-size:12.5px;text-align:left;padding:4px 8px;border-radius:6px;cursor:pointer;
860
+ white-space:nowrap;
861
+ }
862
+ .explorer-row:hover{background:var(--hover)}
863
+ .explorer-row.on{background:var(--sel)}
864
+ .explorer-row svg{width:14px;height:14px;flex:0 0 auto;color:var(--dim)}
865
+ .explorer-caret{width:12px;flex:0 0 12px;display:grid;place-items:center}
866
+ .explorer-caret svg{width:11px;height:11px}
867
+ .explorer-name{overflow:hidden;text-overflow:ellipsis;min-width:0}
868
+ #explorer-view{flex:1;min-width:0;display:flex;flex-direction:column;background:var(--bg)}
869
+ .explorer-hint{padding:16px;font-size:12.5px;margin:0}
870
+ .explorer-bar{display:flex;align-items:center;gap:8px;padding:8px 12px;font-size:12px}
871
+ .explorer-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}
872
+ .explorer-size{color:var(--dim);font-size:11px;flex:0 0 auto}
873
+ .explorer-btn{
874
+ border:0;background:var(--field);color:var(--fg);border-radius:6px;padding:3px 9px;
875
+ font:inherit;font-size:11.5px;cursor:pointer;flex:0 0 auto;
876
+ }
877
+ .explorer-btn:hover{background:var(--sel)}
878
+ .explorer-content{flex:1;overflow:auto;min-height:0}
879
+ .explorer-code{display:flex;align-items:flex-start;min-width:max-content}
880
+ .explorer-code pre{margin:0;font-size:12.5px;line-height:1.55;padding:8px 12px 16px}
881
+ .explorer-gutter{color:var(--dim);text-align:right;user-select:none;opacity:.7;padding-right:10px!important;position:sticky;left:0;background:var(--bg)}
882
+ .explorer-src{overflow:visible!important;padding-left:4px!important}
883
+ .explorer-md{padding:12px 20px 24px;max-width:780px}
884
+ .explorer-img{display:block;max-width:calc(100% - 32px);margin:16px;image-rendering:auto}
885
+ .explorer-video{display:block;max-width:calc(100% - 32px);max-height:calc(100% - 32px);margin:16px;background:#000;border-radius:8px}
886
+ .explorer-audio{display:block;width:calc(100% - 32px);margin:16px}
887
+ .explorer-content.explorer-fill{overflow:hidden;display:flex}
888
+ .explorer-pdf{flex:1;width:100%;height:100%;border:0;background:var(--bg)}
889
+ /* Resize handles: an invisible strip on an edge, with a short pill drawn on
890
+ hover or while dragging — the same look as the browser pane's handle. */
891
+ .resizer-col{position:absolute;z-index:4;left:-6px;top:0;bottom:0;width:15px;cursor:col-resize;outline:0;touch-action:none}
892
+ .resizer-col::before{
893
+ content:"";position:absolute;left:6px;top:50%;width:3px;height:34px;border-radius:999px;
894
+ background:var(--dim);opacity:0;transform:translateY(-50%) scaleY(.65);transition:opacity .12s,transform .16s;
895
+ }
896
+ .resizer-row{position:absolute;z-index:4;left:0;right:0;top:-6px;height:13px;cursor:row-resize;outline:0;touch-action:none}
897
+ .resizer-row::before{
898
+ content:"";position:absolute;top:5px;left:50%;height:3px;width:34px;border-radius:999px;
899
+ background:var(--dim);opacity:0;transform:translateX(-50%) scaleX(.65);transition:opacity .12s,transform .16s;
900
+ }
901
+ .resizer-col:hover::before,.resizer-col:focus-visible::before,
902
+ #explorer-split:hover::before,#explorer-split:focus-visible::before,body.dragging-x #explorer-split::before{
903
+ opacity:.55;transform:translateY(-50%) scaleY(1);
904
+ }
905
+ .resizer-row:hover::before,.resizer-row:focus-visible::before,body.dragging-y .resizer-row::before{
906
+ opacity:.55;transform:translateX(-50%) scaleX(1);
907
+ }
908
+ body.dragging{user-select:none;-webkit-user-select:none}
909
+ body.dragging-x{cursor:col-resize}
910
+ body.dragging-y{cursor:row-resize}
911
+ body.dragging #explorer{transition:none}
912
+ body.dragging iframe{pointer-events:none}
913
+ /*
914
+ * The terminal, under the composer and as wide as the conversation. Its
915
+ * height is whatever it was last dragged to.
916
+ */
917
+ #terminal{
918
+ flex:0 0 var(--terminal-height,clamp(180px,34vh,420px));min-height:120px;position:relative;
919
+ display:flex;flex-direction:column;background:var(--panel);
920
+ }
921
+ #terminal[hidden]{display:none}
922
+ #terminal-head{display:flex;align-items:center;gap:4px;padding:5px 8px 3px 8px;font-size:12px;flex:0 0 auto;min-width:0}
923
+ #terminal-tabs{display:flex;gap:2px;overflow-x:auto;min-width:0;flex:0 1 auto;scrollbar-width:none}
924
+ #terminal-tabs::-webkit-scrollbar{display:none}
925
+ .terminal-tab{
926
+ display:flex;align-items:center;gap:6px;height:26px;padding:0 4px 0 8px;border-radius:6px;
927
+ color:var(--dim);cursor:pointer;flex:0 0 auto;max-width:180px;user-select:none;
928
+ }
929
+ .terminal-tab:hover{background:var(--hover);color:var(--fg)}
930
+ .terminal-tab.on{background:var(--bg);color:var(--fg)}
931
+ .terminal-tab>svg{width:13px;height:13px;flex:0 0 auto}
932
+ .terminal-tab-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
933
+ .terminal-tab-x{
934
+ width:18px;height:18px;border:0;border-radius:4px;background:transparent;color:inherit;padding:0;
935
+ display:grid;place-items:center;cursor:pointer;opacity:0;flex:0 0 auto;
936
+ }
937
+ .terminal-tab:hover .terminal-tab-x,.terminal-tab.on .terminal-tab-x{opacity:.6}
938
+ .terminal-tab-x:hover{opacity:1;background:var(--sel)}
939
+ .terminal-tab-x svg{width:12px;height:12px}
940
+ #terminal-where{flex:1;min-width:0;text-align:right;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 6px}
941
+ #terminal-new,#terminal-hide{
942
+ width:26px;height:26px;border:0;border-radius:6px;background:transparent;color:var(--dim);
943
+ cursor:pointer;padding:0;display:grid;place-items:center;flex:0 0 auto;
944
+ }
945
+ #terminal-new:hover,#terminal-hide:hover{background:var(--hover);color:var(--fg)}
946
+ #terminal-new svg,#terminal-hide svg{width:14px;height:14px}
947
+ #terminal-body{flex:1;min-height:0;position:relative;background:var(--bg)}
948
+ .terminal-host{position:absolute;inset:4px 0 6px 12px}
949
+ .terminal-host[hidden]{display:none}
950
+ .terminal-host .xterm{height:100%}
951
+ .terminal-host .xterm-viewport{background:transparent!important}
952
+ .tab-tool{
953
+ width:22px;height:22px;border:0;border-radius:6px;background:transparent;color:inherit;
954
+ display:grid;place-items:center;padding:0;cursor:pointer;flex:0 0 auto;opacity:0;
955
+ }
956
+ .tab .tab-tool{display:none}
957
+ .tab:hover .tab-tool,.tab:focus-within .tab-tool{display:grid}
958
+ .tab:hover .tab-tool,.tab.on .tab-tool{opacity:.62}
959
+ .tab-tool:hover,.tab-tool.on{opacity:1;background:var(--sel)}
960
+ .tab-tool svg{width:14px;height:14px}
802
961
  #changes-btn{
803
962
  border:0;background:var(--panel);color:var(--fg);border-radius:7px;
804
963
  padding:5px 11px;font:inherit;font-size:12px;cursor:pointer;
@@ -2152,6 +2311,40 @@ pre.diff{margin:0;font-size:12px;line-height:1.5;overflow-x:auto}
2152
2311
  * between two paragraphs — and the box is kept for what opens under it. The
2153
2312
  * chevron trails the words, where it points at what a click will show. */
2154
2313
  details.work{margin:0 0 12px}
2314
+ /* What a media tool made, under the steps that made it. No frame: the image is
2315
+ its own edge, and a border would only box it in. */
2316
+ .media-strip{display:flex;flex-wrap:wrap;align-items:flex-start;gap:12px;margin:0 0 14px}
2317
+ .media-item{margin:0;display:flex;flex-direction:column;gap:5px;max-width:100%}
2318
+ /* Sized by the picture itself, so the corners are the picture's corners: a
2319
+ box of fixed proportions around it showed as a panel-coloured frame
2320
+ wherever the two shapes differed. */
2321
+ .media-item img,.media-item video{display:block;width:auto;height:auto;max-width:min(360px,100%);max-height:440px;border-radius:12px;cursor:zoom-in}
2322
+ .media-item video{max-width:min(480px,100%);cursor:auto}
2323
+ /* A clip, drawn by the page: a filled card, no outline, like everything else
2324
+ that sits on the conversation. */
2325
+ .media-item.audio{width:min(360px,100%)}
2326
+ .media-item audio{display:none}
2327
+ .audio-card{display:flex;align-items:center;gap:12px;padding:10px 14px 10px 10px;border-radius:14px;background:var(--panel)}
2328
+ .audio-play{
2329
+ width:36px;height:36px;flex:0 0 auto;border:0;border-radius:50%;padding:0;
2330
+ display:grid;place-items:center;background:var(--fg);color:var(--bg);cursor:pointer;
2331
+ }
2332
+ .audio-play:hover{opacity:.85}
2333
+ .audio-play svg{width:14px;height:14px}
2334
+ .audio-play svg path{fill:currentColor}
2335
+ .audio-card.playing .audio-play svg path{fill:none;stroke-width:2.2}
2336
+ .audio-meta{flex:1;min-width:0;display:flex;flex-direction:column;gap:7px}
2337
+ .audio-top{display:flex;align-items:baseline;justify-content:space-between;gap:10px;font-size:12.5px}
2338
+ .audio-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--fg);cursor:pointer}
2339
+ .audio-name:hover{text-decoration:underline;text-underline-offset:2px}
2340
+ .audio-time{flex:0 0 auto;color:var(--dim);font-size:11.5px;font-variant-numeric:tabular-nums}
2341
+ /* Thin to look at, tall to hit: the pseudo-element widens the target. */
2342
+ .audio-track{position:relative;height:4px;border-radius:2px;background:var(--hover);cursor:pointer}
2343
+ .audio-track::before{content:"";position:absolute;left:0;right:0;top:-7px;bottom:-7px}
2344
+ .audio-track:focus-visible{outline:none;background:var(--sel)}
2345
+ .audio-fill{height:100%;width:0;border-radius:2px;background:var(--fg);pointer-events:none}
2346
+ .media-item figcaption{max-width:360px;font-size:11.5px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}
2347
+ .media-item figcaption:hover{color:var(--fg)}
2155
2348
  details.work>summary{
2156
2349
  padding:3px 0;cursor:pointer;font-size:13.5px;color:var(--dim);list-style:none;
2157
2350
  display:flex;gap:6px;align-items:center;width:max-content;max-width:100%;
@@ -2684,6 +2877,9 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
2684
2877
  body>aside.open{transform:none;box-shadow:0 0 0 1px #0000000a,0 12px 40px #00000040}
2685
2878
  body.nav-open #scrim{display:block}
2686
2879
  #nav-close{display:grid}
2880
+ #nav-collapse,body.nav-collapsed #nav-expand{display:none}
2881
+ body.nav-collapsed>aside{width:min(280px,84vw);flex-basis:auto}
2882
+ body.nav-collapsed #tabs{padding-left:12px}
2687
2883
  #burger{display:grid}
2688
2884
  #top{padding:6px 10px}
2689
2885
  #top:has(#changes-btn:not([hidden])){padding:6px 10px}
@@ -2695,6 +2891,31 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
2695
2891
  }
2696
2892
  #review-head{padding:10px 10px 8px 16px}
2697
2893
  #review-close{display:grid}
2894
+ #explorer.open{position:fixed;inset:0;z-index:50;width:100%;height:100%;flex:none;transition:none}
2895
+ #explorer-head{padding-top:max(10px,env(safe-area-inset-top))}
2896
+ #explorer-body{flex-direction:column}
2897
+ #explorer-resizer{display:none}
2898
+ #explorer-tree{width:auto;height:var(--explorer-tree-height,38%);max-height:none}
2899
+ /* Stacked, so the handle between them drags up and down — and is a strip
2900
+ a thumb can find rather than a hairline. */
2901
+ #explorer-split{flex:0 0 18px;margin:0;cursor:row-resize}
2902
+ #explorer-split::before{left:50%;top:7px;width:34px;height:4px;opacity:.4;transform:translateX(-50%)}
2903
+ body.dragging-y #explorer-split::before{opacity:.7;transform:translateX(-50%)}
2904
+ .explorer-row{padding-top:8px;padding-bottom:8px}
2905
+ #explorer-actions button{width:34px;height:34px}
2906
+ /* A phone has room for the conversation or a shell, and the shell needs the
2907
+ keyboard: while it is open the composer steps aside, and the handle on
2908
+ top is a visible grip. */
2909
+ body.terminal-open #composer,body.terminal-open #jump{display:none}
2910
+ #terminal{flex-basis:var(--terminal-height,55%);padding-bottom:env(safe-area-inset-bottom)}
2911
+ #terminal-resizer{top:0;height:18px}
2912
+ #terminal-resizer::before{top:6px;opacity:.4;transform:translateX(-50%)}
2913
+ #terminal-head{padding-top:14px}
2914
+ #terminal-new,#terminal-hide{width:34px;height:34px}
2915
+ .terminal-tab{height:32px}
2916
+ .terminal-tab-x{opacity:.6;width:24px;height:24px}
2917
+ #terminal-where{display:none}
2918
+ .terminal-host{inset:4px 0 6px 6px}
2698
2919
  #files{max-height:40%}
2699
2920
  #shelf-bar{flex-wrap:wrap;row-gap:4px}
2700
2921
 
@@ -2767,6 +2988,8 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
2767
2988
  .turn{margin-bottom:14px}
2768
2989
  .act{padding:6px 10px}
2769
2990
  .tab .x{opacity:.55;width:24px;height:24px}
2991
+ /* No hover to reveal them on a touch screen. */
2992
+ .tab .x,.tab .tab-tool,.tab .tab-browser{display:grid}
2770
2993
  #tab-add{width:32px;height:32px}
2771
2994
  .tab{height:34px}
2772
2995
  .nav-item,.session,.fold,.project{height:38px}
@@ -1 +1 @@
1
- {"version":3,"file":"style.js","sourceRoot":"","sources":["../../src/page/style.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,0EAA0E;AAC1E,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;EAC7B,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAouFV,CAAC"}
1
+ {"version":3,"file":"style.js","sourceRoot":"","sources":["../../src/page/style.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,0EAA0E;AAC1E,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;EAC7B,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAm8FV,CAAC"}
package/dist/page.js CHANGED
@@ -55,6 +55,7 @@ ${ANALYTICS_STUB}
55
55
  </svg>
56
56
  </span>
57
57
  <b class="wordmark">OHarness</b>
58
+ <button id="nav-collapse" type="button" data-t-title="nav.collapse" data-t-aria-label="nav.collapse"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.25" y="3.25" width="11.5" height="9.5" rx="2.5"/><path d="M6.25 3.25v9.5"/></svg></button>
58
59
  <button id="nav-close" type="button" aria-label="Close"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>
59
60
  </header>
60
61
  <button id="project" class="project" type="button">
@@ -103,6 +104,8 @@ ${ANALYTICS_STUB}
103
104
 
104
105
  <div id="split">
105
106
  <div id="main">
107
+ <!-- Shown only while the sidebar is folded away: the way back to it. -->
108
+ <button id="nav-expand" type="button" data-t-title="nav.expand" data-t-aria-label="nav.expand"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.25" y="3.25" width="11.5" height="9.5" rx="2.5"/><path d="M6.25 3.25v9.5"/></svg></button>
106
109
  <div id="tabs"></div>
107
110
  <div id="log">
108
111
  <div id="empty">
@@ -199,6 +202,19 @@ ${ANALYTICS_STUB}
199
202
  <span id="ctxtext"></span>
200
203
  </div>
201
204
  </form>
205
+ <!-- The embedded terminal: under the conversation, as tall as it is
206
+ dragged. The shell belongs to the project, not to this panel, so
207
+ hiding the panel leaves it running. -->
208
+ <div id="terminal" hidden>
209
+ <div id="terminal-resizer" class="resizer-row" role="separator" aria-orientation="horizontal" tabindex="0" data-t-aria-label="terminal.resize"></div>
210
+ <div id="terminal-head">
211
+ <div id="terminal-tabs" role="tablist"></div>
212
+ <button id="terminal-new" type="button" data-t-title="terminal.new" data-t-aria-label="terminal.new"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M8 3.5v9M3.5 8h9"/></svg></button>
213
+ <span id="terminal-where"></span>
214
+ <button id="terminal-hide" type="button" data-t-title="terminal.hide" data-t-aria-label="terminal.hide"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7"/></svg></button>
215
+ </div>
216
+ <div id="terminal-body"></div>
217
+ </div>
202
218
  </div>
203
219
  <div id="review">
204
220
  <div id="review-head">
@@ -209,6 +225,24 @@ ${ANALYTICS_STUB}
209
225
  <div id="files"></div>
210
226
  <div id="diff"></div>
211
227
  </div>
228
+ <!-- A project's own files: the tree on one side, the file on the other.
229
+ Shaped and placed like #review, which is the same kind of column. -->
230
+ <div id="explorer">
231
+ <div id="explorer-resizer" class="resizer-col" role="separator" aria-orientation="vertical" tabindex="0" data-t-aria-label="files.resize"></div>
232
+ <div id="explorer-head">
233
+ <span id="explorer-title"></span>
234
+ <span id="explorer-actions">
235
+ <button id="explorer-terminal" type="button" data-t-title="files.terminal" data-t-aria-label="files.terminal"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5.5 6.5 8 4 10.5"/><path d="M8.5 10.5H12"/></svg></button>
236
+ <button id="explorer-refresh" type="button" data-t-title="files.refresh" data-t-aria-label="files.refresh"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13.2 5.4A5.6 5.6 0 1 0 13.5 10"/><path d="M13.2 2.5v2.9h-2.9"/></svg></button>
237
+ <button id="explorer-close" type="button" data-t-title="files.close" data-t-aria-label="files.close"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7"/></svg></button>
238
+ </span>
239
+ </div>
240
+ <div id="explorer-body">
241
+ <div id="explorer-tree" role="tree"></div>
242
+ <div id="explorer-split" role="separator" tabindex="0" data-t-aria-label="files.resize"></div>
243
+ <div id="explorer-view"></div>
244
+ </div>
245
+ </div>
212
246
  <!-- The agent's browser, beside the transcript rather than over it.
213
247
  A sibling of #main for the same reason #review is one: the split is a
214
248
  row of columns, and a pane nested inside #main would stack under the
@@ -221,7 +255,7 @@ ${ANALYTICS_STUB}
221
255
  <div id="browser-head">
222
256
  <div id="browser-context">
223
257
  <div id="browser-name-line">
224
- <span id="browser-mark" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="1.75" y="2.5" width="12.5" height="11" rx="2"/><path d="M1.75 6h12.5"/><circle cx="4.25" cy="4.25" r=".55" fill="currentColor" stroke="none"/></svg></span>
258
+ <span id="browser-mark" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="1.75" y="2.75" width="12.5" height="10.5" rx="2"/><path d="M1.75 6h12.5"/><path d="M4.1 4.4h.01M5.9 4.4h.01"/></svg></span>
225
259
  <span id="browser-title" data-t="nav.browser"></span>
226
260
  <span id="browser-status" hidden><i></i><span data-t="browser.live"></span></span>
227
261
  </div>
package/dist/page.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"page.js","sourceRoot":"","sources":["../src/page.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AACxC,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAEpD;;;;;;;;;;;;;;GAcG;AAGH;;;;;;;GAOG;AACH,MAAM,OAAO,GAAG;;;;;yCAKyB,CAAC;AAE1C,MAAM,CAAC,MAAM,IAAI,GAAG;;;;;;;4CAOwB,kBAAkB,CAAC,OAAO,CAAC;SAC9D,KAAK;EACZ,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAwWK,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;qBACvB,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;EAC1C,aAAa;EACb,UAAU;EACV,gBAAgB;;;QAGV,CAAC"}
1
+ {"version":3,"file":"page.js","sourceRoot":"","sources":["../src/page.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AACxC,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAEpD;;;;;;;;;;;;;;GAcG;AAGH;;;;;;;GAOG;AACH,MAAM,OAAO,GAAG;;;;;yCAKyB,CAAC;AAE1C,MAAM,CAAC,MAAM,IAAI,GAAG;;;;;;;4CAOwB,kBAAkB,CAAC,OAAO,CAAC;SAC9D,KAAK;EACZ,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBA0YK,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;qBACvB,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;EAC1C,aAAa;EACb,UAAU;EACV,gBAAgB;;;QAGV,CAAC"}
package/dist/server.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomUUID, timingSafeEqual } from "node:crypto";
3
+ import { createReadStream } from "node:fs";
3
4
  import { readFile, stat } from "node:fs/promises";
4
5
  import path from "node:path";
5
6
  import { homedir } from "node:os";
@@ -28,7 +29,8 @@ import { readPrefs, writePrefs } from "./prefs.js";
28
29
  import { sortModelsForDisplay } from "./model-order.js";
29
30
  import { loadModelsDev, releaseDateOf, releaseDates } from "@oharness/engine/providers/models-dev.ts";
30
31
  import { listDirectories, pickDirectory, pickFiles, revealDirectory } from "./pick.js";
31
- import { browseFiles, searchFiles, within } from "./files.js";
32
+ import { TerminalHub, terminalAsset } from "./terminal.js";
33
+ import { PREVIEW_MEDIA, browseFiles, previewFile, resolveFile, searchFiles, within } from "./files.js";
32
34
  import { analyticsState } from "./analytics.js";
33
35
  import { PAGE } from "./page.js";
34
36
  import { browserState, parseInputEvent, streamFrames } from "./browser-pane.js";
@@ -433,6 +435,7 @@ export async function startWebServer(opts) {
433
435
  broadcast(announce(entry));
434
436
  notify(entry);
435
437
  });
438
+ const terminals = new TerminalHub();
436
439
  const server = createServer((req, res) => {
437
440
  void handle(req, res).catch((err) => {
438
441
  send(res, 500, { error: errorMessage(err) });
@@ -1069,6 +1072,137 @@ export async function startWebServer(opts) {
1069
1072
  const result = await revealDirectory(dir);
1070
1073
  return send(res, "error" in result ? 500 : 200, result);
1071
1074
  }
1075
+ /*
1076
+ * The files pane: a project's tree one folder at a time, one file's
1077
+ * contents, and an image's bytes. Addressed by the project's folder
1078
+ * rather than a session, because the pane opens from the sidebar's
1079
+ * project menu too, where there may be no session in that tree at all.
1080
+ */
1081
+ // The embedded terminal. One shell per project folder; see terminal.ts.
1082
+ case "POST /term/open": {
1083
+ const body = await readJson(req);
1084
+ const root = await projectRoot(typeof body.cwd === "string" ? body.cwd : null);
1085
+ if (!root)
1086
+ return send(res, 400, { error: "not a folder" });
1087
+ const replace = typeof body.replace === "string" ? body.replace : undefined;
1088
+ const result = await terminals.open(root, { cols: body.cols, rows: body.rows }, replace);
1089
+ return send(res, "error" in result ? 500 : 200, result);
1090
+ }
1091
+ case "GET /term/list": {
1092
+ const root = await projectRoot(url.searchParams.get("cwd"));
1093
+ if (!root)
1094
+ return send(res, 400, { error: "not a folder" });
1095
+ return send(res, 200, { terminals: terminals.list(root) });
1096
+ }
1097
+ case "GET /term/stream": {
1098
+ if (!terminals.stream(url.searchParams.get("id") ?? "", res)) {
1099
+ return send(res, 404, { error: "no such terminal" });
1100
+ }
1101
+ return;
1102
+ }
1103
+ case "POST /term/input": {
1104
+ const body = await readJson(req);
1105
+ const ok = terminals.input(String(body.id ?? ""), body.data);
1106
+ return send(res, ok ? 200 : 404, ok ? { ok } : { error: "no such terminal" });
1107
+ }
1108
+ case "POST /term/resize": {
1109
+ const body = await readJson(req);
1110
+ const ok = terminals.resize(String(body.id ?? ""), body.cols, body.rows);
1111
+ return send(res, ok ? 200 : 404, ok ? { ok } : { error: "no such terminal" });
1112
+ }
1113
+ case "POST /term/close": {
1114
+ const body = await readJson(req);
1115
+ terminals.close(String(body.id ?? ""));
1116
+ return send(res, 200, { ok: true });
1117
+ }
1118
+ case "GET /vendor/xterm.js":
1119
+ case "GET /vendor/xterm.css":
1120
+ case "GET /vendor/xterm-fit.js": {
1121
+ const file = terminalAsset(url.pathname);
1122
+ if (!file)
1123
+ return send(res, 404, { error: "terminal assets are not installed" });
1124
+ const data = await readFile(file);
1125
+ res.writeHead(200, {
1126
+ "content-type": url.pathname.endsWith(".css") ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8",
1127
+ "content-length": data.byteLength,
1128
+ "cache-control": "private, max-age=3600",
1129
+ });
1130
+ res.end(data);
1131
+ return;
1132
+ }
1133
+ case "GET /files/tree": {
1134
+ const root = await projectRoot(url.searchParams.get("cwd"));
1135
+ if (!root)
1136
+ return send(res, 400, { error: "not a folder" });
1137
+ return send(res, 200, await browseFiles(root, url.searchParams.get("dir") ?? "", { all: true }));
1138
+ }
1139
+ case "GET /files/read": {
1140
+ const root = await projectRoot(url.searchParams.get("cwd"));
1141
+ if (!root)
1142
+ return send(res, 400, { error: "not a folder" });
1143
+ const result = await previewFile(root, url.searchParams.get("path") ?? "");
1144
+ return send(res, "error" in result ? 404 : 200, result);
1145
+ }
1146
+ case "GET /files/raw": {
1147
+ const root = await projectRoot(url.searchParams.get("cwd"));
1148
+ const full = root && resolveFile(root, url.searchParams.get("path") ?? "");
1149
+ const media = full && PREVIEW_MEDIA[path.extname(full).toLowerCase()];
1150
+ if (!full || !media)
1151
+ return send(res, 415, { error: "not previewable" });
1152
+ let size;
1153
+ try {
1154
+ const info = await stat(full);
1155
+ if (!info.isFile())
1156
+ return send(res, 404, { error: "not a file" });
1157
+ size = info.size;
1158
+ }
1159
+ catch {
1160
+ return send(res, 404, { error: "no such file" });
1161
+ }
1162
+ // A video is seeked by asking for the bytes at the new position, and
1163
+ // one too long to buffer whole would not play at all without this.
1164
+ const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "");
1165
+ let first = 0;
1166
+ let last = size - 1;
1167
+ if (range && (range[1] || range[2])) {
1168
+ if (range[1]) {
1169
+ first = Number(range[1]);
1170
+ if (range[2])
1171
+ last = Math.min(Number(range[2]), size - 1);
1172
+ }
1173
+ else {
1174
+ first = Math.max(0, size - Number(range[2]));
1175
+ }
1176
+ if (first > last || first >= size) {
1177
+ res.writeHead(416, { "content-range": `bytes */${size}` });
1178
+ res.end();
1179
+ return;
1180
+ }
1181
+ }
1182
+ const partial = Boolean(range && (range[1] || range[2]));
1183
+ res.writeHead(partial ? 206 : 200, {
1184
+ "content-type": media.type,
1185
+ "content-length": last - first + 1,
1186
+ "accept-ranges": "bytes",
1187
+ ...(partial ? { "content-range": `bytes ${first}-${last}/${size}` } : {}),
1188
+ "x-content-type-options": "nosniff",
1189
+ // Served inert: this is same-origin to a page that holds the session
1190
+ // token, and an SVG is a document that can script. A PDF is left
1191
+ // unsandboxed, because the browser's viewer will not open inside a
1192
+ // sandbox; the viewer runs in its own process, apart from the page.
1193
+ ...(media.kind === "pdf"
1194
+ ? {}
1195
+ : { "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox" }),
1196
+ "cache-control": "private, no-store",
1197
+ });
1198
+ if (req.method === "HEAD" || size === 0)
1199
+ return void res.end();
1200
+ const stream = createReadStream(full, { start: first, end: last });
1201
+ stream.on("error", () => res.destroy());
1202
+ res.on("close", () => stream.destroy());
1203
+ stream.pipe(res);
1204
+ return;
1205
+ }
1072
1206
  case "POST /pick": {
1073
1207
  const body = await readJson(req);
1074
1208
  // The dialog opens on this machine's desktop, which is the same
@@ -3603,6 +3737,7 @@ export async function startWebServer(opts) {
3603
3737
  unsubscribe();
3604
3738
  for (const harness of harnesses.values())
3605
3739
  harness.cloudConnectors.close();
3740
+ terminals.closeAll();
3606
3741
  for (const client of clients)
3607
3742
  client.end();
3608
3743
  clients.clear();
@@ -3946,6 +4081,13 @@ async function readBody(req, limit) {
3946
4081
  * shape. Anything else is inlined as text under its filename — a base64 blob
3947
4082
  * of a zip would burn context and tell the model nothing.
3948
4083
  */
4084
+ /** A project folder named by the page, resolved, or undefined if it is not one. */
4085
+ async function projectRoot(dir) {
4086
+ if (!dir)
4087
+ return undefined;
4088
+ const full = path.resolve(dir);
4089
+ return (await isDirectory(full)) ? full : undefined;
4090
+ }
3949
4091
  /** Whether a chosen path is a folder; anything unreadable is treated as a file. */
3950
4092
  async function isDirectory(target) {
3951
4093
  try {