>1&1,d=o&1;h!==0||d!==0?T.setTransform(new DOMMatrix().translateSelf(-h,-d)):T.setTransform(new DOMMatrix),i5.fillStyle=T,i5.fillRect(t,r,s,a)}function Wt(i5,e,t,r,s,a,o,n,L){;i5.save(),i5.beginPath(),i5.rect(t,r,s,a),i5.clip(),i5.beginPath();let T;if(typeof e=="function"){let _=.15/a*s;T=e(.15,_)}else T=e;let h={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(let d of T.split(" ")){let _=d[0];if(_==="Z"){;i5.closePath(),h.lastCommand=_;continue}let p=je[_];if(!p){n.error(`Could not find drawing instructions for "${_}"`);continue}let c=d.substring(1).split(",");!c[0]||!c[1]||(p(i5,Je(c,s,a,t,r,true,o,0,0,false),h),h.lastCommand=_)};L!==void 0?(i5.strokeStyle=i5.fillStyle,i5.lineWidth=o*L,i5.stroke()):i5.fill(),i5.closePath(),i5.restore()}function Vt(i5,e,t,r,s,a){i5.beginPath();for(let o of e.split(" ")){let n=o[0];if(n==="Z"){i5.closePath();continue}let L=o.substring(1).split(",");if(!L[0]||!L[1])continue;let T=t+parseFloat(L[0])*s,h=r+parseFloat(L[1])*a;n==="M"?i5.moveTo(T,h):n==="L"&&i5.lineTo(T,h)}i5.clip()}function $t(i5,e,t,r,s,a,o,n,L){let T=new Path2D;T.rect(t,r,s,a),i5.clip(T),i5.beginPath();let h=o/12;i5.lineWidth=n*h;let d={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(let _ of e.d.split(" ")){let p=_[0];if(p==="Z"){;i5.closePath(),d.lastCommand=p;continue}let c=je[p];if(!c){L.error(`Could not find drawing instructions for "${p}"`);continue}let u=_.substring(1).split(",");!u[0]||!u[1]||(c(i5,Je(u,s,a,t,r,false,n,(e.leftPadding??0)*(h/2),(e.rightPadding??0)*(h/2)),d),d.lastCommand=p)};e.type===1?(i5.strokeStyle=i5.fillStyle,i5.stroke()):i5.fill(),i5.closePath()}function Ye(i5,e,t=0){return Math.max(Math.min(i5,e),t)}var je={C:(i5,e,t)=>{;i5.bezierCurveTo(e[0],e[1],e[2],e[3],e[4],e[5]),t.lastControlX=e[2],t.lastControlY=e[3],t.currentX=e[4],t.currentY=e[5]},L:(i5,e,t)=>{;i5.lineTo(e[0],e[1]),t.lastControlX=t.currentX=e[0],t.lastControlY=t.currentY=e[1]},M:(i5,e,t)=>{;i5.moveTo(e[0],e[1]),t.lastControlX=t.currentX=e[0],t.lastControlY=t.currentY=e[1]},Q:(i5,e,t)=>{;i5.quadraticCurveTo(e[0],e[1],e[2],e[3]),t.lastControlX=e[0],t.lastControlY=e[1],t.currentX=e[2],t.currentY=e[3]},T:(i5,e,t)=>{let r,s;t.lastCommand==="Q"||t.lastCommand==="T"?(r=2*t.currentX-t.lastControlX,s=2*t.currentY-t.lastControlY):(r=t.currentX,s=t.currentY),i5.quadraticCurveTo(r,s,e[0],e[1]),t.lastControlX=r,t.lastControlY=s,t.currentX=e[0],t.currentY=e[1]}};function Je(i5,e,t,r,s,a,o,n=0,L=0,T=true){let h=i5.map(d=>parseFloat(d)||parseInt(d));if(h.length<2)throw new Error("Too few arguments for instruction");for(let d=0;da){;s-t<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),this._start();return}s=a}this.clear()}},Ce=class extends X0{_requestCallback(e){return setTimeout(()=>e(this._createDeadline(16)))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){let t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}},Qe=class extends X0{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}},et="requestIdleCallback"in globalThis?Qe:Ce;var s0=class i{constructor(){this.fg=0;this.bg=0;this.extended=new P0}static toColorRGB(e){return[e>>>16&255,e>>>8&255,e&255]}static fromColorRGB(e){return(e[0]&255)<<16|(e[1]&255)<<8|e[2]&255}clone(){let e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return this.fg&67108864}isBold(){return this.fg&134217728}isUnderline(){return this.hasExtendedAttrs()&&this.extended.underlineStyle!==0?1:this.fg&268435456}isBlink(){return this.fg&536870912}isInvisible(){return this.fg&1073741824}isItalic(){return this.bg&67108864}isDim(){return this.bg&134217728}isStrikethrough(){return this.fg&2147483648}isProtected(){return this.bg&536870912}isOverline(){return this.bg&1073741824}getFgColorMode(){return this.fg&50331648}getBgColorMode(){return this.bg&50331648}isFgRGB(){return(this.fg&50331648)===50331648}isBgRGB(){return(this.bg&50331648)===50331648}isFgPalette(){return(this.fg&50331648)===16777216||(this.fg&50331648)===33554432}isBgPalette(){return(this.bg&50331648)===16777216||(this.bg&50331648)===33554432}isFgDefault(){return(this.fg&50331648)===0}isBgDefault(){return(this.bg&50331648)===0}isAttributeDefault(){return this.fg===0&&this.bg===0}getFgColor(){switch(this.fg&50331648){case 16777216:case 33554432:return this.fg&255;case 50331648:return this.fg&16777215;default:return-1}}getBgColor(){switch(this.bg&50331648){case 16777216:case 33554432:return this.bg&255;case 50331648:return this.bg&16777215;default:return-1}}hasExtendedAttrs(){return this.bg&268435456}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(this.bg&268435456&&~this.extended.underlineColor)switch(this.extended.underlineColor&50331648){case 16777216:case 33554432:return this.extended.underlineColor&255;case 50331648:return this.extended.underlineColor&16777215;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return this.bg&268435456&&~this.extended.underlineColor?this.extended.underlineColor&50331648:this.getFgColorMode()}isUnderlineColorRGB(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===50331648:this.isFgRGB()}isUnderlineColorPalette(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===16777216||(this.extended.underlineColor&50331648)===33554432:this.isFgPalette()}isUnderlineColorDefault(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===0:this.isFgDefault()}getUnderlineStyle(){return this.fg&268435456?this.bg&268435456?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}},P0=class i2{constructor(e=0,t=0){this._ext=0;this._urlId=0;this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){;this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){;this._ext&=-67108864,this._ext|=e&67108863}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){;this._ext&=536870911,this._ext|=e<<29&3758096384}clone(){return new i2(this._ext,this._urlId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0}};var U=class{constructor(){this._listeners=[];this._disposed=false}get event(){return this._event?this._event:(this._event=(e,t,r)=>{if(this._disposed)return M(()=>{});let s={fn:e,thisArgs:t};this._listeners.push(s);let a=M(()=>{let o=this._listeners.indexOf(s);o!==-1&&this._listeners.splice(o,1)});return r&&(Array.isArray(r)?r.push(a):r.add(a)),a},this._event)}fire(e){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{let{fn:t,thisArgs:r}=this._listeners[0];t.call(r,e);return}default:{let t=this._listeners.slice();for(let{fn:r,thisArgs:s}of t)r.call(s,e)}}}dispose(){this._disposed||(this._disposed=true,this._listeners.length=0)}},c0;(s=>{function i5(a,o){return a(n=>o.fire(n))}s.forward=i5;function e(a,o){return(n,L,T)=>a(h=>n.call(L,o(h)),void 0,T)}s.map=e;function t(...a){return(o,n,L)=>{let T=new R0;for(let h of a)T.add(h(d=>o.call(n,d)));return L&&(Array.isArray(L)?L.push(T):L.add(T)),T}}s.any=t;function r(a,o,n){return o(n),a(L=>o(L))}s.runAndSubscribe=r})(c0||={});var tt={texturePage:0,texturePosition:{x:0,y:0},texturePositionClipSpace:{x:0,y:0},offset:{x:0,y:0},size:{x:0,y:0},sizeClipSpace:{x:0,y:0}},B0=2;var w0,J=class i3{constructor(e,t,r,s){this._document=e;this._config=t;this._unicodeService=r;this._logService=s;this._didWarmUp=false;this._cacheMap=new D0;this._cacheMapCombined=new D0;this._pages=[];this._activePages=[];this._workBoundingBox={top:0,left:0,bottom:0,right:0};this._workAttributeData=new s0;this._textureSize=512;this._onAddTextureAtlasCanvas=new U;this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;this._onRemoveTextureAtlasCanvas=new U;this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;this._requestClearModel=false;this._clearModelGeneration=0;this._createNewPage(),this._tmpCanvas=it(e,this._config.deviceCellWidth*4+B0*2,this._config.deviceCellHeight+B0*2),this._tmpCtx=g(this._tmpCanvas.getContext("2d",{alpha:this._config.allowTransparency,willReadFrequently:true}))}get pages(){return this._pages}dispose(){this._tmpCanvas.remove();for(let e of this.pages)e.canvas.remove();this._onAddTextureAtlasCanvas.dispose(),this._onRemoveTextureAtlasCanvas.dispose()}warmUp(){this._didWarmUp||(this._doWarmUp(),this._didWarmUp=true)}_doWarmUp(){let e=new et(this._logService);for(let t=33;t<126;t++)e.enqueue(()=>{if(!this._cacheMap.get(t,0,0,0)){let r=this._drawToCache(t,0,0,0,false,void 0);this._cacheMap.set(t,0,0,0,r)}})}beginFrame(){let e=this._requestClearModel;return this._requestClearModel=false,e}clearTexture(){if(this._pages.some(e=>e._glyphs.length>0||e.currentRow.x!==0||e.currentRow.y!==0)){this._evictAllPages();this._createNewPage()}}_evictAllPages(){for(let e of this._pages)this._onRemoveTextureAtlasCanvas.fire(e.canvas),e.canvas.remove();this._pages.length=0,this._activePages.length=0,this._overflowSizePage=void 0,this._cacheMap.clear(),this._cacheMapCombined.clear(),this._didWarmUp=false,this._requestClearModel=true,this._clearModelGeneration++}_createNewPage(){if(i3.maxAtlasPages&&this._pages.length>=i3.maxAtlasPages){let t=this._pages.filter(T=>T.canvas.width*2<=(i3.maxTextureSize||4096)).sort((T,h)=>h.canvas.width!==T.canvas.width?h.canvas.width-T.canvas.width:h.percentageUsed-T.percentageUsed),r=-1,s=0;for(let T=0;TT.canvas.width!==a[0].canvas.width))this._evictAllPages();else{let o=a.map(T=>T.glyphs[0].texturePage).sort((T,h)=>T-h),n=this.pages.length-a.length,L=this._mergePages(a,n);L.version=++u0.nextVersion;for(let T=o.length-1;T>=0;T--)this._deletePage(o[T]);this.pages.push(L),this._requestClearModel=true,this._clearModelGeneration++,this._onAddTextureAtlasCanvas.fire(L.canvas)}}let e=new u0(this._document,this._textureSize);return this._pages.push(e),this._activePages.push(e),this._onAddTextureAtlasCanvas.fire(e.canvas),e}_mergePages(e,t){let r=e[0].canvas.width*2,s=new u0(this._document,r,e);for(let[a,o]of e.entries()){let n=a*o.canvas.width%r,L=Math.floor(a/2)*o.canvas.height;s.ctx.drawImage(o.canvas,n,L);for(let h of o.glyphs)h.texturePage=t,h.sizeClipSpace.x=h.size.x/r,h.sizeClipSpace.y=h.size.y/r,h.texturePosition.x+=n,h.texturePosition.y+=L,h.texturePositionClipSpace.x=h.texturePosition.x/r,h.texturePositionClipSpace.y=h.texturePosition.y/r;this._onRemoveTextureAtlasCanvas.fire(o.canvas);let T=this._activePages.indexOf(o);T!==-1&&this._activePages.splice(T,1)}return s}_deletePage(e){this._pages.splice(e,1);for(let t=e;t=this._config.colors.ansi.length)throw new Error("No color found for idx "+e);return this._config.colors.ansi[e]}_getBackgroundColor(e,t,r,s){if(this._config.allowTransparency)return K;let a;switch(e){case 16777216:case 33554432:a=this._getColorFromAnsiIndex(t);break;case 50331648:let o=s0.toColorRGB(t);a=Z.toColor(o[0],o[1],o[2]);break;case 0:default:r?a=I0.opaque(this._config.colors.foreground):a=this._config.colors.background;break}return this._config.allowTransparency||(a=I0.opaque(a)),a}_getForegroundColor(e,t,r,s,a,o,n,L,T,h){let d=this._getMinimumContrastColor(e,t,r,s,a,o,n,T,L,h);if(d)return d;let _;switch(a){case 16777216:case 33554432:;this._config.drawBoldTextInBrightColors&&T&&o<8&&(o+=8),_=this._getColorFromAnsiIndex(o);break;case 50331648:let p=s0.toColorRGB(o);_=Z.toColor(p[0],p[1],p[2]);break;case 0:default:n?_=this._config.colors.background:_=this._config.colors.foreground}return this._config.allowTransparency&&(_=I0.opaque(_)),L&&(_=I0.multiplyOpacity(_,Ke)),_}_resolveBackgroundRgba(e,t,r){switch(e){case 16777216:case 33554432:return this._getColorFromAnsiIndex(t).rgba;case 50331648:return t<<8;case 0:default:return r?this._config.colors.foreground.rgba:this._config.colors.background.rgba}}_resolveForegroundRgba(e,t,r,s){switch(e){case 16777216:case 33554432:return this._config.drawBoldTextInBrightColors&&s&&t<8&&(t+=8),this._getColorFromAnsiIndex(t).rgba;case 50331648:return t<<8;case 0:default:return r?this._config.colors.background.rgba:this._config.colors.foreground.rgba}}_getMinimumContrastColor(e,t,r,s,a,o,n,L,T,h){if(this._config.minimumContrastRatio===1||h)return;let d=this._getContrastCache(T),_=d.getColor(e,s);if(_!==void 0)return _??void 0;let p=this._resolveBackgroundRgba(t,r,n),c=this._resolveForegroundRgba(a,o,n,L),u=d0.ensureContrastRatio(p,c,this._config.minimumContrastRatio/(T?2:1));if(!u){d.setColor(e,s,null);return}let C=Z.toColor(u>>24&255,u>>16&255,u>>8&255);return d.setColor(e,s,C),C}_getContrastCache(e){return e?this._config.colors.halfContrastCache:this._config.colors.contrastCache}_drawToCache(e,t,r,s,a,o){let n=typeof e=="number"?String.fromCharCode(e):e;o&&this._tmpCanvas.parentElement!==o&&(this._tmpCanvas.style.display="none",o.append(this._tmpCanvas));let L=Math.min(this._config.deviceCellWidth*Math.max(n.length,2)+B0*2,this._config.deviceMaxTextureSize);this._tmpCanvas.width{orcaProbeWeight=String(orcaProbeWeight);let orcaActualWeightToken=this._tmpCtx.font.match(/(?:^|\s)(normal|bold|[1-9]\d{0,3})(?=\s+\d+(?:\.\d+)?px(?:\s|$))/)?.[1]??"400",orcaActualWeight=orcaActualWeightToken==="normal"?"400":orcaActualWeightToken==="bold"?"700":orcaActualWeightToken;if(/^(?:[1-8]\d{2}|900)$/.test(orcaProbeWeight)&&orcaActualWeight!==orcaProbeWeight){this.fontProbeMismatchCount=(this.fontProbeMismatchCount||0)+1;this.fontProbeLastMismatch={desired:orcaProbeWeight,actual:this._tmpCtx.font};try{globalThis.__orcaAtlasFontProbe&&globalThis.__orcaAtlasFontProbe(this.fontProbeLastMismatch)}catch(orcaFontProbeError){}}})(Y),this._tmpCtx.textBaseline=K0;let A0=n.length===1&&W0(n.charCodeAt(0)),t0=n.length===1&&ke(n.charCodeAt(0)),L0=this._getForegroundColor(t,E,F,r,R,m,_,p,d,V0(n.charCodeAt(0)));this._tmpCtx.fillStyle=L0.css;let A=t0?0:B0*2,n0=false;if(this._config.customGlyphs!==false){let b=this._workAttributeData.getUnderlineVariantOffset();n0=qe(this._tmpCtx,n,A,A,this._config.deviceCellWidth,this._config.deviceCellHeight,this._config.deviceCharWidth,this._config.deviceCharHeight,this._config.fontSize,this._config.devicePixelRatio,this._logService,y.css,b)}let a0=!A0,$;if(typeof e=="number"?$=this._unicodeService.wcwidth(e):$=this._unicodeService.getStringCellWidth(e),u){this._tmpCtx.save();let b=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/15)),D=b%2===1?.5:0;if(this._tmpCtx.lineWidth=b,this._workAttributeData.isUnderlineColorDefault())this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle;else if(this._workAttributeData.isUnderlineColorRGB())a0=false,this._tmpCtx.strokeStyle=`rgb(${s0.toColorRGB(this._workAttributeData.getUnderlineColor()).join(",")})`;else{a0=false;let r0=this._workAttributeData.getUnderlineColor();this._config.drawBoldTextInBrightColors&&this._workAttributeData.isBold()&&r0<8&&(r0+=8),this._tmpCtx.strokeStyle=this._getColorFromAnsiIndex(r0).css};this._tmpCtx.fillStyle=this._tmpCtx.strokeStyle,this._tmpCtx.beginPath();let i0=A,G=Math.ceil(A+this._config.deviceCharHeight)-D-(a?b*2:0),he=G+b*2,f0=this._workAttributeData.getUnderlineVariantOffset(),C0=0,v0=0;for(let r0=0;r0<$;r0++){let y0=false;this._tmpCtx.save(),C0=G,v0=he;let j=i0+r0*this._config.deviceCellWidth,Q0=i0+(r0+1)*this._config.deviceCellWidth;switch(this._workAttributeData.extended.underlineStyle){case 2:;this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(Q0,G),this._tmpCtx.moveTo(j,he),this._tmpCtx.lineTo(Q0,he);break;case 3:;C0=this._config.deviceCharHeight+1,v0=C0+3*this._config.devicePixelRatio;let ye=new Path2D;ye.rect(j,C0,this._config.deviceCellWidth,v0-C0),this._tmpCtx.clip(ye);let Qt=this._config.deviceCellWidth,mt=v0-C0,ft=Qt/6,Et=mt/3,bt=[[0,2,1,3,2.4,3,0,.6],[5.5,0,2.5,3,1.1,3,4.1,0],[4,0,6,2,6,.6,5.4,0]];for(let _e of bt){this._tmpCtx.beginPath();for(let S0=0;S0<_e.length;S0+=2){let xe=j+_e[S0]*ft,Ne=v0-_e[S0+1]*Et;S0===0?this._tmpCtx.moveTo(xe,Ne):this._tmpCtx.lineTo(xe,Ne)};this._tmpCtx.closePath(),this._tmpCtx.fill()}y0=true;break;case 4:let Z0=f0===0?0:f0>=b?b*2-f0:b-f0;!(f0>=b)===false||Z0===0?(this._tmpCtx.setLineDash([Math.round(b),Math.round(b)]),this._tmpCtx.moveTo(j+Z0,G),this._tmpCtx.lineTo(Q0,G)):(this._tmpCtx.setLineDash([Math.round(b),Math.round(b)]),this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(j+Z0,G),this._tmpCtx.moveTo(j+Z0+b,G),this._tmpCtx.lineTo(Q0,G)),f0=We(Q0-j,b,f0);break;case 5:let vt=.6,Ot=.3,de=Q0-j,Se=Math.floor(vt*de),Re=Math.floor(Ot*de),It=de-Se-Re;this._tmpCtx.setLineDash([Se,Re,It]),this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(Q0,G);break;case 1:default:;this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(Q0,G);break};y0||this._tmpCtx.stroke(),this._tmpCtx.restore()}if(this._tmpCtx.restore(),!n0&&this._config.fontSize>=12&&!this._config.allowTransparency&&n!==" "){;this._tmpCtx.save(),this._tmpCtx.textBaseline="alphabetic";let r0=this._tmpCtx.measureText(n);if(this._tmpCtx.restore(),"actualBoundingBoxDescent"in r0&&r0.actualBoundingBoxDescent>0){this._tmpCtx.save();let y0=new Path2D;y0.rect(i0,C0-Math.ceil(b/2),this._config.deviceCellWidth*$,v0-C0+Math.ceil(b/2)),this._tmpCtx.clip(y0),this._tmpCtx.lineWidth=this._config.devicePixelRatio*3,this._tmpCtx.strokeStyle=y.css,this._tmpCtx.strokeText(n,A,A+this._config.deviceCharHeight),this._tmpCtx.restore()}}}if(Q){let b=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/15)),D=b%2===1?.5:0;this._tmpCtx.lineWidth=b,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(A,A+D),this._tmpCtx.lineTo(A+this._config.deviceCharWidth*$,A+D),this._tmpCtx.stroke()}if(n0||this._tmpCtx.fillText(n,A,A+this._config.deviceCharHeight),n==="_"&&!this._config.allowTransparency){let b=fe(this._tmpCtx.getImageData(A,A,this._config.deviceCellWidth,this._config.deviceCellHeight),y,L0,a0);if(b)for(let D=1;D<=5&&(this._tmpCtx.save(),this._tmpCtx.fillStyle=y.css,this._tmpCtx.fillRect(0,0,this._tmpCanvas.width,this._tmpCanvas.height),this._tmpCtx.restore(),this._tmpCtx.fillText(n,A,A+this._config.deviceCharHeight-D),b=fe(this._tmpCtx.getImageData(A,A,this._config.deviceCellWidth,this._config.deviceCellHeight),y,L0,a0),!!b);D++);}if(C){let b=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/10)),D=this._tmpCtx.lineWidth%2===1?.5:0;this._tmpCtx.lineWidth=b,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(A,A+Math.floor(this._config.deviceCharHeight/2)-D),this._tmpCtx.lineTo(A+this._config.deviceCharWidth*$,A+Math.floor(this._config.deviceCharHeight/2)-D),this._tmpCtx.stroke()}this._tmpCtx.restore();let q=this._tmpCtx.getImageData(0,0,this._tmpCanvas.width,this._tmpCanvas.height),g0;if(this._config.allowTransparency?g0=Gt(q):g0=fe(q,y,L0,a0),g0)return tt;let I=this._findGlyphBoundingBox(q,this._workBoundingBox,L,t0,n0,A),O,v;for(;;){if(this._activePages.length===0){let b=this._createNewPage();O=b,v=b.currentRow,v.height=I.size.y;break};O=this._activePages[this._activePages.length-1],v=O.currentRow;for(let b of this._activePages)I.size.y<=b.currentRow.height&&(O=b,v=b.currentRow);for(let b=this._activePages.length-1;b>=0;b--)for(let D of this._activePages[b].fixedRows)D.height<=v.height&&I.size.y<=D.height&&(O=this._activePages[b],v=D);if(I.size.x>this._textureSize){;this._overflowSizePage||(i3.maxAtlasPages&&this._pages.length>=i3.maxAtlasPages&&this._evictAllPages(),this._overflowSizePage=new u0(this._document,this._config.deviceMaxTextureSize),this.pages.push(this._overflowSizePage),this._requestClearModel=true,this._clearModelGeneration++,this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas)),O=this._overflowSizePage,v=this._overflowSizePage.currentRow,v.x+I.size.x>=O.canvas.width&&(v.x=0,v.y+=v.height,v.height=0);break}if(v.y+I.size.y>=O.canvas.height||v.height>I.size.y+2){let b=false;if(O.currentRow.y+O.currentRow.height+I.size.y>=O.canvas.height){let D;for(let i0 of this._activePages)if(i0.currentRow.y+i0.currentRow.height+I.size.y=i3.maxAtlasPages&&v.y+I.size.y<=O.canvas.height&&v.height>=I.size.y&&v.x+I.size.x<=O.canvas.width)b=true;else{let i0=this._createNewPage();O=i0,v=i0.currentRow,v.height=I.size.y,b=true}}b||(O.currentRow.height>0&&O.fixedRows.push(O.currentRow),v={x:0,y:O.currentRow.y+O.currentRow.height,height:I.size.y},O.fixedRows.push(v),O.currentRow={x:0,y:v.y+v.height,height:0})}if(v.x+I.size.x<=O.canvas.width)break;v===O.currentRow?(v.x=0,v.y+=v.height,v.height=0):O.fixedRows.splice(O.fixedRows.indexOf(v),1)}return I.texturePage=this._pages.indexOf(O),I.texturePosition.x=v.x,I.texturePosition.y=v.y,I.texturePositionClipSpace.x=v.x/O.canvas.width,I.texturePositionClipSpace.y=v.y/O.canvas.height,I.sizeClipSpace.x/=O.canvas.width,I.sizeClipSpace.y/=O.canvas.height,v.height=Math.max(v.height,I.size.y),v.x+=I.size.x,O.ctx.putImageData(q,I.texturePosition.x-this._workBoundingBox.left,I.texturePosition.y-this._workBoundingBox.top,this._workBoundingBox.left,this._workBoundingBox.top,I.size.x,I.size.y),O.addGlyph(I),O.version=++u0.nextVersion,I}_findGlyphBoundingBox(e,t,r,s,a,o){t.top=0;let n=s?this._config.deviceCellHeight:this._tmpCanvas.height,L=s?this._config.deviceCellWidth:r,T=false;for(let h=0;h=o;h--){for(let d=0;d=0;h--){for(let d=0;d>>24,a=e.rgba>>>16&255,o=e.rgba>>>8&255,n=t.rgba>>>24,L=t.rgba>>>16&255,T=t.rgba>>>8&255,h=Math.floor((Math.abs(s-n)+Math.abs(a-L)+Math.abs(o-T))/12),d=true;for(let _=0;_0)return false;return true}function it(i5,e,t){let r=i5.createElement("canvas");return r.width=e,r.height=t,r}function rt(i5,e,t,r,s,a,o,n,L=true){let T={foreground:a.foreground,background:a.background,cursor:K,cursorAccent:K,selectionForeground:K,selectionBackgroundTransparent:K,selectionBackgroundOpaque:K,selectionInactiveBackgroundTransparent:K,selectionInactiveBackgroundOpaque:K,overviewRulerBorder:K,scrollbarSliderBackground:K,scrollbarSliderHoverBackground:K,scrollbarSliderActiveBackground:K,ansi:a.ansi.slice(),contrastCache:a.contrastCache,halfContrastCache:a.halfContrastCache};return{customGlyphs:L,devicePixelRatio:o,deviceMaxTextureSize:n,letterSpacing:s.letterSpacing,lineHeight:s.lineHeight,deviceCellWidth:i5,deviceCellHeight:e,deviceCharWidth:t,deviceCharHeight:r,fontFamily:s.fontFamily,fontSize:s.fontSize,fontWeight:s.fontWeight,fontWeightBold:s.fontWeightBold,allowTransparency:s.allowTransparency,drawBoldTextInBrightColors:s.drawBoldTextInBrightColors,minimumContrastRatio:s.minimumContrastRatio,colors:T}}function Ee(i5,e){for(let t=0;t=0){if(Ee(u.config,h))return u.atlas;u.ownedBy.length===1?(u.atlas.dispose(),e0.splice(c,1)):u.ownedBy.splice(C,1);break}}for(let c=0;c{;this._renderCallback(),this._animationFrame=void 0})))}_restartInterval(e=600){;this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout=this._coreBrowserService.window.setTimeout(()=>{if(this._animationTimeRestarted){let t=600-(Date.now()-this._animationTimeRestarted);if(this._animationTimeRestarted=void 0,t>0){this._restartInterval(t);return}};this.isCursorVisible=false,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{;this._renderCallback(),this._animationFrame=void 0}),this._blinkInterval=this._coreBrowserService.window.setInterval(()=>{if(this._animationTimeRestarted){let t=600-(Date.now()-this._animationTimeRestarted);this._animationTimeRestarted=void 0,this._restartInterval(t);return};this.isCursorVisible=!this.isCursorVisible,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{;this._renderCallback(),this._animationFrame=void 0})},600)},e)}pause(){;this.isCursorVisible=true,this._isIdlePaused=false,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}resume(){;this.pause(),this._animationTimeRestarted=void 0,this._restartInterval(),this._resetIdleTimer(),this.restartBlinkAnimation()}_resetIdleTimer(){;this._isIdlePaused=false,this._idleTimeout&&this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_stopBlinkingDueToIdle(){;this.isCursorVisible=true,this._isIdlePaused=true,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0,this._renderCallback()}};function ve(i5,e,t){let r=new e.ResizeObserver(s=>{let a=s.find(L=>L.target===i5);if(!a)return;if(!("devicePixelContentBoxSize"in a)){;r?.disconnect(),r=void 0;return}let o=a.devicePixelContentBoxSize[0].inlineSize,n=a.devicePixelContentBoxSize[0].blockSize;o>0&&n>0&&t(o,n)});try{r.observe(i5,{box:["device-pixel-content-box"]})}catch{;r.disconnect(),r=void 0}return M(()=>r?.disconnect())}function nt(i5){return i5>65535?(i5-=65536,String.fromCharCode((i5>>10)+55296)+String.fromCharCode(i5%1024+56320)):String.fromCharCode(i5)}var j0=class i4 extends s0{constructor(){super(...arguments);this.content=0;this.fg=0;this.bg=0;this.extended=new P0;this.combinedData=""}static fromCharData(t){let r=new i4;return r.setFromCharData(t),r}isCombined(){return this.content&2097152}getWidth(){return this.content>>22}getChars(){return this.content&2097152?this.combinedData:this.content&2097151?nt(this.content&2097151):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):this.content&2097151}setFromCharData(t){;this.fg=t[0],this.bg=0;let r=false;if(t[1].length>2)r=true;else if(t[1].length===2){let s=t[1].charCodeAt(0);if(55296<=s&&s<=56319){let a=t[1].charCodeAt(1);56320<=a&&a<=57343?this.content=(s-55296)*1024+a-56320+65536|t[2]<<22:r=true}else r=true}else this.content=t[1].charCodeAt(0)|t[2]<<22;r&&(this.combinedData=t[1],this.content=2097152|t[2]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(t){if(this.getFgColorMode()!==t.getFgColorMode()||this.getFgColor()!==t.getFgColor()||this.getBgColorMode()!==t.getBgColorMode()||this.getBgColor()!==t.getBgColor()||this.isInverse()!==t.isInverse()||this.isBold()!==t.isBold()||this.isUnderline()!==t.isUnderline())return false;if(this.isUnderline()){if(this.getUnderlineStyle()!==t.getUnderlineStyle())return false;let r=this.isUnderlineColorDefault(),s=t.isUnderlineColorDefault();if(!(r&&s)&&(r!==s||this.getUnderlineColor()!==t.getUnderlineColor()||this.getUnderlineColorMode()!==t.getUnderlineColorMode()))return false}return!(this.isOverline()!==t.isOverline()||this.isBlink()!==t.isBlink()||this.isInvisible()!==t.isInvisible()||this.isItalic()!==t.isItalic()||this.isDim()!==t.isDim()||this.isStrikethrough()!==t.isStrikethrough())}};var J0=class extends W{constructor(t,r,s){super();this._renderCallback=t;this._coreBrowserService=r;this._optionsService=s;this._intervalDuration=0;this._blinkOn=true;this._needsBlinkInViewport=false;this._isViewportVisible=true;this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",a=>{this.setIntervalDuration(a)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register(M(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(t){this._needsBlinkInViewport!==t&&(this._needsBlinkInViewport=t,this._updateIntervalState())}setViewportVisible(t){this._isViewportVisible!==t&&(this._isViewportVisible=t,this._updateIntervalState())}setIntervalDuration(t){t!==this._intervalDuration&&(this._intervalDuration=t,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(this._interval!==void 0)return;let r=this._blinkOn;this._blinkOn=true,this._interval=this._coreBrowserService.window.setInterval(()=>{;this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),r||this._renderCallback();return};this._clearInterval(),this._blinkOn||(this._blinkOn=true,this._renderCallback())}_clearInterval(){this._interval!==void 0&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}};var te=new Float32Array([2,0,0,0,0,-2,0,0,0,0,1,0,-1,1,0,1]);function ie(i5,e,t,r){let s=g(i5.createProgram());if(i5.attachShader(s,g(at(i5,i5.VERTEX_SHADER,e,r))),i5.attachShader(s,g(at(i5,i5.FRAGMENT_SHADER,t,r))),i5.linkProgram(s),i5.getProgramParameter(s,i5.LINK_STATUS))return s;r.error(i5.getProgramInfoLog(s)),i5.deleteProgram(s)}function at(i5,e,t,r){let s=g(i5.createShader(e));if(i5.shaderSource(s,t),i5.compileShader(s),i5.getShaderParameter(s,i5.COMPILE_STATUS))return s;r.error(i5.getShaderInfoLog(s)),i5.deleteShader(s)}function ot(i5,e){let t=Math.min(i5.length*2,e),r=new Float32Array(t);for(let s=0;sn.deleteProgram(this._program))),this._projectionLocation=g(n.getUniformLocation(this._program,"u_projection"));let L=this._vertexArrayObject=n.createVertexArray();this._register(M(()=>n.deleteVertexArray(L))),n.bindVertexArray(L);let T=new Float32Array([0,0,1,0,0,1,1,1]),h=n.createBuffer();this._register(M(()=>n.deleteBuffer(h))),n.bindBuffer(n.ARRAY_BUFFER,h),n.bufferData(n.ARRAY_BUFFER,T,n.STATIC_DRAW),n.enableVertexAttribArray(3),n.vertexAttribPointer(3,2,this._gl.FLOAT,!1,0,0);let d=new Uint8Array([0,1,2,3]),_=n.createBuffer();this._register(M(()=>n.deleteBuffer(_))),n.bindBuffer(n.ELEMENT_ARRAY_BUFFER,_),n.bufferData(n.ELEMENT_ARRAY_BUFFER,d,n.STATIC_DRAW),this._attributesBuffer=g(n.createBuffer()),this._register(M(()=>n.deleteBuffer(this._attributesBuffer))),n.bindBuffer(n.ARRAY_BUFFER,this._attributesBuffer),n.enableVertexAttribArray(0),n.vertexAttribPointer(0,2,n.FLOAT,!1,Me,0),n.vertexAttribDivisor(0,1),n.enableVertexAttribArray(1),n.vertexAttribPointer(1,2,n.FLOAT,!1,Me,2*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(1,1),n.enableVertexAttribArray(2),n.vertexAttribPointer(2,4,n.FLOAT,!1,Me,4*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(2,1),this._updateCachedColors(a.colors),this._register(this._themeService.onChangeColors(p=>{this._updateCachedColors(p),this._updateViewportRectangle()}))}renderBackgrounds(){this._renderVertices(this._vertices)}renderCursor(){this._renderVertices(this._verticesCursor)}_renderVertices(t){let r=this._gl;r.useProgram(this._program),r.bindVertexArray(this._vertexArrayObject),r.uniformMatrix4fv(this._projectionLocation,!1,te),r.bindBuffer(r.ARRAY_BUFFER,this._attributesBuffer),r.bufferData(r.ARRAY_BUFFER,t.attributes,r.DYNAMIC_DRAW),r.drawElementsInstanced(this._gl.TRIANGLE_STRIP,4,r.UNSIGNED_BYTE,0,t.count)}handleResize(){this._updateViewportRectangle()}setDimensions(t){this._dimensions=t}_updateCachedColors(t){this._bgFloat=this._colorToFloat32Array(t.background),this._cursorFloat=this._colorToFloat32Array(t.cursor)}_updateViewportRectangle(){this._addRectangleFloat(this._vertices.attributes,0,0,0,this._terminal.cols*this._dimensions.device.cell.width,this._terminal.rows*this._dimensions.device.cell.height,this._bgFloat)}updateBackgrounds(t){let r=this._terminal,s=this._vertices,a=1,o,n,L,T,h,d,_,p,c,u,C;for(o=0;o>24&255)/255,_t=(p0>>16&255)/255,ct=(p0>>8&255)/255,ut=1,this._addRectangle(t.attributes,r,Lt,ht,(n-o)*this._dimensions.device.cell.width,this._dimensions.device.cell.height,dt,_t,ct,ut)}_addRectangle(t,r,s,a,o,n,L,T,h,d){t[r]=s/this._dimensions.device.canvas.width,t[r+1]=a/this._dimensions.device.canvas.height,t[r+2]=o/this._dimensions.device.canvas.width,t[r+3]=n/this._dimensions.device.canvas.height,t[r+4]=L,t[r+5]=T,t[r+6]=h,t[r+7]=d}_addRectangleFloat(t,r,s,a,o,n,L){t[r]=s/this._dimensions.device.canvas.width,t[r+1]=a/this._dimensions.device.canvas.height,t[r+2]=o/this._dimensions.device.canvas.width,t[r+3]=n/this._dimensions.device.canvas.height,t[r+4]=L[0],t[r+5]=L[1],t[r+6]=L[2],t[r+7]=L[3]}_colorToFloat32Array(t){return new Float32Array([(t.rgba>>24&255)/255,(t.rgba>>16&255)/255,(t.rgba>>8&255)/255,(t.rgba&255)/255])}};var oe=class extends W{constructor(t,r,s,a,o,n,L,T){super();this._container=r;this._alpha=o;this._coreBrowserService=n;this._optionsService=L;this._themeService=T;this._deviceCharWidth=0;this._deviceCharHeight=0;this._deviceCellWidth=0;this._deviceCellHeight=0;this._deviceCharLeft=0;this._deviceCharTop=0;this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add(`xterm-${s}-layer`),this._canvas.style.zIndex=a.toString(),this._initCanvas(),this._container.appendChild(this._canvas),this._register(this._themeService.onChangeColors(h=>{this._refreshCharAtlas(t,h),this.reset(t)})),this._register(M(()=>{this._canvas.remove()}))}_initCanvas(){this._ctx=g(this._canvas.getContext("2d",{alpha:this._alpha})),this._alpha||this._clearAll()}handleBlur(t){}handleFocus(t){}handleCursorMove(t){}handleGridChanged(t,r,s){}handleSelectionChanged(t,r,s,a=!1){}_setTransparency(t,r){if(r===this._alpha)return;let s=this._canvas;this._alpha=r,this._canvas=this._canvas.cloneNode(),this._initCanvas(),this._container.replaceChild(this._canvas,s),this._refreshCharAtlas(t,this._themeService.colors),this.handleGridChanged(t,0,t.rows-1)}_refreshCharAtlas(t,r){this._deviceCharWidth<=0&&this._deviceCharHeight<=0||(this._charAtlas=Y0(t,this._optionsService.rawOptions,r,this._deviceCellWidth,this._deviceCellHeight,this._deviceCharWidth,this._deviceCharHeight,this._coreBrowserService.dpr,2048),this._charAtlas.warmUp())}resize(t,r){this._deviceCellWidth=r.device.cell.width,this._deviceCellHeight=r.device.cell.height,this._deviceCharWidth=r.device.char.width,this._deviceCharHeight=r.device.char.height,this._deviceCharLeft=r.device.char.left,this._deviceCharTop=r.device.char.top,this._canvas.width=r.device.canvas.width,this._canvas.height=r.device.canvas.height,this._canvas.style.width=`${r.css.canvas.width}px`,this._canvas.style.height=`${r.css.canvas.height}px`,this._alpha||this._clearAll(),this._refreshCharAtlas(t,this._themeService.colors)}_fillBottomLineAtCells(t,r,s=1){this._ctx.fillRect(t*this._deviceCellWidth,(r+1)*this._deviceCellHeight-this._coreBrowserService.dpr-1,s*this._deviceCellWidth,this._coreBrowserService.dpr)}_clearAll(){this._alpha?this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(0,0,this._canvas.width,this._canvas.height))}_clearCells(t,r,s,a){this._alpha?this._ctx.clearRect(t*this._deviceCellWidth,r*this._deviceCellHeight,s*this._deviceCellWidth,a*this._deviceCellHeight):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(t*this._deviceCellWidth,r*this._deviceCellHeight,s*this._deviceCellWidth,a*this._deviceCellHeight))}_fillCharTrueColor(t,r,s,a){this._ctx.font=this._getFont(t,!1,!1),this._ctx.textBaseline=K0,this._clipCell(s,a,r.getWidth()),this._ctx.fillText(r.getChars(),s*this._deviceCellWidth+this._deviceCharLeft,a*this._deviceCellHeight+this._deviceCharTop+this._deviceCharHeight)}_clipCell(t,r,s){this._ctx.beginPath(),this._ctx.rect(t*this._deviceCellWidth,r*this._deviceCellHeight,s*this._deviceCellWidth,this._deviceCellHeight),this._ctx.clip()}_getFont(t,r,s){let a=r?t.options.fontWeightBold:t.options.fontWeight;return`${s?"italic":""} ${a} ${t.options.fontSize*this._coreBrowserService.dpr}px ${t.options.fontFamily}`}};var Te=class extends oe{constructor(e,t,r,s,a,o,n){super(r,e,"link",t,!0,a,o,n),this._register(s.onShowLinkUnderline(L=>this._handleShowLinkUnderline(L))),this._register(s.onHideLinkUnderline(L=>this._handleHideLinkUnderline(L)))}resize(e,t){super.resize(e,t),this._state=void 0}reset(e){this._clearCurrentLink()}_clearCurrentLink(){if(this._state){this._clearCells(this._state.x1,this._state.y1,this._state.cols-this._state.x1,1);let e=this._state.y2-this._state.y1-1;e>0&&this._clearCells(0,this._state.y1+1,this._state.cols,e),this._clearCells(0,this._state.y2,this._state.x2,1),this._state=void 0}}_handleShowLinkUnderline(e){if(e.fg===257?this._ctx.fillStyle=this._themeService.colors.background.css:e.fg!==void 0&&st(e.fg)?this._ctx.fillStyle=this._themeService.colors.ansi[e.fg].css:this._ctx.fillStyle=this._themeService.colors.foreground.css,e.y1===e.y2)this._fillBottomLineAtCells(e.x1,e.y1,e.x2-e.x1);else{this._fillBottomLineAtCells(e.x1,e.y1,e.cols-e.x1);for(let t=e.y1+1;tthis._handleColorChange())),this._cellColorResolver=new G0(this._terminal,this._optionsService,this._model.selection,this._decorationService,this._coreBrowserService,this._themeService),this._core=this._terminal._core,this._renderLayers=[new Te(this._core.screenElement,2,this._terminal,this._core.linkifier,this._coreBrowserService,T,this._themeService)],this.dimensions=Ue(),this._devicePixelRatio=this._coreBrowserService.dpr,this._updateDimensions(),this._updateCursorBlink(),this._register(T.onOptionChange(()=>this._handleOptionsChanged())),this._textBlinkStateManager=this._register(new J0(()=>this._requestRedrawViewport(),this._coreBrowserService,this._optionsService)),this._resetBlinkingRowState(),this._deviceMaxTextureSize=this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE),this._register(le(this._canvas,"webglcontextlost",c=>{this._logService.debug("webglcontextlost event received"),c.preventDefault(),this._contextRestorationTimeout=setTimeout(()=>{this._contextRestorationTimeout=void 0,this._logService.warn("webgl context not restored; firing onContextLoss"),this._onContextLoss.fire(c)},3e3)})),this._register(le(this._canvas,"webglcontextrestored",c=>{this._logService.warn("webglcontextrestored event received"),clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0,be(this._terminal),this._initializeWebGLState(),this._requestRedrawViewport()})),this._observerDisposable.value=ve(this._canvas,this._coreBrowserService.window,(c,u)=>this._setCanvasDevicePixelDimensions(c,u)),this._register(this._coreBrowserService.onWindowChange(c=>{this._observerDisposable.value=ve(this._canvas,c,(u,C)=>this._setCanvasDevicePixelDimensions(u,C))})),this._register(le(this._coreBrowserService.mainDocument,"mousedown",()=>this._cursorBlinkStateManager.value?.restartBlinkAnimation())),this._core.screenElement.appendChild(this._canvas),[this._rectangleRenderer.value,this._glyphRenderer.value]=this._initializeWebGLState(),this._isAttached=this._core.screenElement.isConnected,this._register(M(()=>{clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0;for(let c of this._renderLayers)c.dispose();this._canvas.parentElement?.removeChild(this._canvas),be(this._terminal)}))}get textureAtlas(){return this._charAtlas?.pages[0].canvas}_handleColorChange(){this._refreshCharAtlas(),this._clearModel(!0)}handleDevicePixelRatioChange(){this._devicePixelRatio!==this._coreBrowserService.dpr&&(this._devicePixelRatio=this._coreBrowserService.dpr,this.handleResize(this._terminal.cols,this._terminal.rows))}handleResize(t,r){this._updateDimensions(),this._model.resize(this._terminal.cols,this._terminal.rows),this._resetBlinkingRowState();for(let s of this._renderLayers)s.resize(this._terminal,this.dimensions);this._canvas.width=this.dimensions.device.canvas.width,this._canvas.height=this.dimensions.device.canvas.height,this._canvas.style.width=`${this.dimensions.css.canvas.width}px`,this._canvas.style.height=`${this.dimensions.css.canvas.height}px`,this._core.screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._core.screenElement.style.height=`${this.dimensions.css.canvas.height}px`,this._rectangleRenderer.value?.setDimensions(this.dimensions),this._rectangleRenderer.value?.handleResize(),this._glyphRenderer.value?.setDimensions(this.dimensions),this._glyphRenderer.value?.handleResize(),this._refreshCharAtlas(),this._clearModel(!1),this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0})}handleCharSizeChanged(){this.handleResize(this._terminal.cols,this._terminal.rows)}handleBlur(){for(let t of this._renderLayers)t.handleBlur(this._terminal);this._cursorBlinkStateManager.value?.pause(),this._requestRedrawViewport()}handleFocus(){for(let t of this._renderLayers)t.handleFocus(this._terminal);this._cursorBlinkStateManager.value?.resume(),this._requestRedrawViewport()}handleViewportVisibilityChange(t){this._textBlinkStateManager.setViewportVisible(t)}handleSelectionChanged(t,r,s){for(let a of this._renderLayers)a.handleSelectionChanged(this._terminal,t,r,s);this._model.selection.update(this._core,t,r,s),this._requestRedrawViewport()}handleCursorMove(){for(let t of this._renderLayers)t.handleCursorMove(this._terminal);this._cursorBlinkStateManager.value?.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._refreshCharAtlas(),this._updateCursorBlink()}_initializeWebGLState(){return this._rectangleRenderer.value=new ae(this._terminal,this._gl,this.dimensions,this._themeService,this._logService),this._glyphRenderer.value=new re(this._terminal,this._gl,this.dimensions,this._optionsService,this._logService),this.handleCharSizeChanged(),[this._rectangleRenderer.value,this._glyphRenderer.value]}_refreshCharAtlas(){if(this.dimensions.device.char.width<=0&&this.dimensions.device.char.height<=0){this._isAttached=!1;return}let t=Y0(this._terminal,this._optionsService.rawOptions,this._themeService.colors,this.dimensions.device.cell.width,this.dimensions.device.cell.height,this.dimensions.device.char.width,this.dimensions.device.char.height,this._coreBrowserService.dpr,this._deviceMaxTextureSize,this._customGlyphs);this._charAtlas!==t&&(this._onChangeTextureAtlas.fire(t.pages[0].canvas),this._charAtlasDisposable.value=De(c0.forward(t.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas),c0.forward(t.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas))),this._charAtlas=t,this._charAtlas.warmUp(),this._glyphRenderer.value?.setAtlas(this._charAtlas)}_clearModel(t){this._model.clear(),t&&this._glyphRenderer.value?.clear()}clearTextureAtlas(){this._charAtlas?.clearTexture(),this._clearModel(!0),this._requestRedrawViewport()}clear(){this._clearModel(!0);for(let t of this._renderLayers)t.reset(this._terminal);this._resetBlinkingRowState(),this._textBlinkStateManager.setNeedsBlinkInViewport(!1),this._cursorBlinkStateManager.value?.restartBlinkAnimation(),this._updateCursorBlink()}renderRows(t,r){if(!this._isAttached)if(this._core.screenElement?.isConnected&&this._charSizeService.width&&this._charSizeService.height)this._updateDimensions(),this._refreshCharAtlas(),this._isAttached=!0;else return;for(let o of this._renderLayers)o.handleGridChanged(this._terminal,t,r);if(!this._glyphRenderer.value||!this._rectangleRenderer.value)return;this._glyphRenderer.value.beginFrame()?(this._clearModel(!0),this._updateModel(0,this._terminal.rows-1)):this._updateModel(t,r);let s=!1,a=0;for(;this._charAtlas&&this._glyphRenderer.value.beginFrame()&&a++<32;)s=!0,this._clearModel(!0),this._updateModel(0,this._terminal.rows-1);s&&this._glyphRenderer.value.invalidateAtlasTextures(),this._rectangleRenderer.value.renderBackgrounds(),this._glyphRenderer.value.render(this._model),(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible)&&this._rectangleRenderer.value.renderCursor()}_updateCursorBlink(){this._coreService.decPrivateModes.cursorBlink??this._terminal.options.cursorBlink?this._cursorBlinkStateManager.value=new q0(()=>{this._requestRedrawCursor()},this._coreBrowserService):this._cursorBlinkStateManager.clear(),this._requestRedrawCursor()}_updateModel(t,r){let s=this._core,a=this._workCell,o,n,L,T,h,d,_,p,c,u,C,Q,m,R,F,E,y;t=pt(t,s.rows-1,0),r=pt(r,s.rows-1,0);let Y=this._coreService.decPrivateModes.cursorStyle??s.options.cursorStyle??"block",X=this._terminal.buffer.active.baseY+this._terminal.buffer.active.cursorY,A0=X-s.buffer.ydisp,t0=Math.min(this._terminal.buffer.active.cursorX,s.cols-1),L0=-1,A=this._coreService.isCursorInitialized&&!this._coreService.isCursorHidden&&(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible);this._model.cursor=void 0;let n0=!1;for(n=t;n<=r;n++){L=n+s.buffer.ydisp;let a0=s.buffer.lines.get(L);if(!a0){for(this._model.lineLengths[n]=0,E=0;E=_,c=E,h.length>0&&E===h[0][0]&&p){u=h.shift();let q=this._model.selection.isCellSelected(this._terminal,u[0],L);for(F=u[0]+1;F=u[1],p?(d=!0,a=new ge(a,T.translateToString(!0,u[0],u[1]),u[1]-u[0]),c=u[1]-1):_=u[1]}if(Q=a.getChars(),m=a.getCode(),F=(n*s.cols+E)*4,!$&&a.isBlink()&&($=!0),this._cellColorResolver.resolve(a,E,L,this.dimensions.device.cell.width,this.dimensions.device.cell.height),A&&L===X&&(E===t0&&(this._model.cursor={x:t0,y:A0,width:a.getWidth(),style:this._coreBrowserService.isFocused?Y:s.options.cursorInactiveStyle,cursorWidth:s.options.cursorWidth,dpr:this._devicePixelRatio},L0=t0+a.getWidth()-1),E>=t0&&E<=L0&&(this._coreBrowserService.isFocused&&Y==="block"||this._coreBrowserService.isFocused===!1&&s.options.cursorInactiveStyle==="block")&&(this._cellColorResolver.result.fg=50331648|this._themeService.colors.cursorAccent.rgba>>8&16777215,this._cellColorResolver.result.bg=50331648|this._themeService.colors.cursor.rgba>>8&16777215)),this._textBlinkStateManager.isEnabled&&!this._textBlinkStateManager.isBlinkOn&&a.isBlink()&&(this._cellColorResolver.result.fg|=1073741824),m!==0&&(this._model.lineLengths[n]=E+1),!(this._model.cells[F]===m&&this._model.cells[F+1]===this._cellColorResolver.result.bg&&this._model.cells[F+2]===this._cellColorResolver.result.fg&&this._model.cells[F+3]===this._cellColorResolver.result.ext)&&(n0=!0,Q.length>1&&(m|=lt),this._model.cells[F]=m,this._model.cells[F+1]=this._cellColorResolver.result.bg,this._model.cells[F+2]=this._cellColorResolver.result.fg,this._model.cells[F+3]=this._cellColorResolver.result.ext,R=a.getWidth(),this._glyphRenderer.value.updateCell(E,n,m,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext,Q,R,o),d)){for(a=this._workCell,E++;E<=c;E++)y=(n*s.cols+E)*4,this._nullModelCell(E,n,y,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext);E--}}this._setRowBlinkState(n,$)}n0&&this._rectangleRenderer.value.updateBackgrounds(this._model),this._rectangleRenderer.value.updateCursor(this._model),this._updateTextBlinkState()}_nullModelCell(t,r,s,a,o,n){this._glyphRenderer.value.updateCell(t,r,0,a,o,n,"",0,0),this._model.cells[s]=0,this._model.cells[s+1]=a,this._model.cells[s+2]=o,this._model.cells[s+3]=n}_resetBlinkingRowState(){this._rowHasBlinkingCells=new Array(this._terminal.rows).fill(!1),this._rowHasBlinkingCellsCount=0}_setRowBlinkState(t,r){this._rowHasBlinkingCells[t]!==r&&(this._rowHasBlinkingCells[t]=r,this._rowHasBlinkingCellsCount+=r?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}_updateDimensions(){!this._charSizeService.width||!this._charSizeService.height||(this.dimensions.device.char.width=Math.floor(this._charSizeService.width*this._devicePixelRatio),this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*this._devicePixelRatio),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.top=this._optionsService.rawOptions.lineHeight===1?0:Math.round((this.dimensions.device.cell.height-this.dimensions.device.char.height)/2),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.char.left=Math.floor(this._optionsService.rawOptions.letterSpacing/2),this.dimensions.device.canvas.height=this._terminal.rows*this.dimensions.device.cell.height,this.dimensions.device.canvas.width=this._terminal.cols*this.dimensions.device.cell.width,this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/this._devicePixelRatio),this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/this._devicePixelRatio),this.dimensions.css.cell.height=this.dimensions.device.cell.height/this._devicePixelRatio,this.dimensions.css.cell.width=this.dimensions.device.cell.width/this._devicePixelRatio)}_setCanvasDevicePixelDimensions(t,r){this._canvas.width===t&&this._canvas.height===r||(this._canvas.width=t,this._canvas.height=r,this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0}))}_requestRedrawViewport(){this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1})}_requestRedrawCursor(){let t=this._terminal.buffer.active.cursorY;this._onRequestRedraw.fire({start:t,end:t})}},ge=class extends s0{constructor(t,r,s){super();this.content=0;this.combinedData="";this.fg=t.fg,this.bg=t.bg,this.combinedData=r,this._width=s}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(t){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}};function pt(i,e,t=0){return Math.max(Math.min(i,e),t)}var Ct=class extends W{constructor(t){if(ue&&He()<16){let r={antialias:!1,depth:!1,preserveDrawingBuffer:!0};if(!document.createElement("canvas").getContext("webgl2",r))throw new Error("WebGL2 is only supported on Safari 16 and above")}super();this._onChangeTextureAtlas=this._register(new U);this.onChangeTextureAtlas=this._onChangeTextureAtlas.event;this._onAddTextureAtlasCanvas=this._register(new U);this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;this._onRemoveTextureAtlasCanvas=this._register(new U);this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;this._onContextLoss=this._register(new U);this.onContextLoss=this._onContextLoss.event;this._customGlyphs=t?.customGlyphs??!0,this._preserveDrawingBuffer=t?.preserveDrawingBuffer}activate(t){let r=t._core;if(!t.element){this._register(r.onWillOpen(()=>this.activate(t)));return}this._terminal=t;let s=r.coreService,a=r.optionsService,o=r,n=o._renderService,L=o._characterJoinerService,T=o._charSizeService,h=o._coreBrowserService,d=o._decorationService,_=o._logService,p=o._themeService;this._renderer=this._register(new Le(t,L,T,h,s,d,_,a,p,this._customGlyphs,this._preserveDrawingBuffer)),this._register(c0.forward(this._renderer.onContextLoss,this._onContextLoss)),this._register(c0.forward(this._renderer.onChangeTextureAtlas,this._onChangeTextureAtlas)),this._register(c0.forward(this._renderer.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas)),this._register(c0.forward(this._renderer.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas)),n.setRenderer(this._renderer),this._register(M(()=>{if(this._terminal._core._store._isDisposed)return;let c=this._terminal._core._renderService;c.setRenderer(this._terminal._core._createRenderer()),c.handleResize(t.cols,t.rows)}))}get textureAtlas(){return this._renderer?.textureAtlas}clearTextureAtlas(){this._renderer?.clearTextureAtlas()}};export{Ct as WebglAddon};
-//# sourceMappingURL=addon-webgl.mjs.map
+}`,l0=8,Me=l0*Float32Array.BYTES_PER_ELEMENT,e1=20*l0,ne=class{constructor(){;this.attributes=new Float32Array(e1),this.count=0}},p0=0,Lt=0,ht=0,dt=0,_t=0,ct=0,ut=0,ae=class extends W{constructor(t,r,s,a,o){super();this._terminal=t;this._gl=r;this._dimensions=s;this._themeService=a;this._logService=o;this._vertices=new ne;this._verticesCursor=new ne;let n=this._gl;this._program=g(ie(n,jt,Jt,this._logService)),this._register(M(()=>n.deleteProgram(this._program))),this._projectionLocation=g(n.getUniformLocation(this._program,"u_projection"));let L=this._vertexArrayObject=n.createVertexArray();this._register(M(()=>n.deleteVertexArray(L))),n.bindVertexArray(L);let T=new Float32Array([0,0,1,0,0,1,1,1]),h=n.createBuffer();this._register(M(()=>n.deleteBuffer(h))),n.bindBuffer(n.ARRAY_BUFFER,h),n.bufferData(n.ARRAY_BUFFER,T,n.STATIC_DRAW),n.enableVertexAttribArray(3),n.vertexAttribPointer(3,2,this._gl.FLOAT,false,0,0);let d=new Uint8Array([0,1,2,3]),_=n.createBuffer();this._register(M(()=>n.deleteBuffer(_))),n.bindBuffer(n.ELEMENT_ARRAY_BUFFER,_),n.bufferData(n.ELEMENT_ARRAY_BUFFER,d,n.STATIC_DRAW),this._attributesBuffer=g(n.createBuffer()),this._register(M(()=>n.deleteBuffer(this._attributesBuffer))),n.bindBuffer(n.ARRAY_BUFFER,this._attributesBuffer),n.enableVertexAttribArray(0),n.vertexAttribPointer(0,2,n.FLOAT,false,Me,0),n.vertexAttribDivisor(0,1),n.enableVertexAttribArray(1),n.vertexAttribPointer(1,2,n.FLOAT,false,Me,2*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(1,1),n.enableVertexAttribArray(2),n.vertexAttribPointer(2,4,n.FLOAT,false,Me,4*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(2,1),this._updateCachedColors(a.colors),this._register(this._themeService.onChangeColors(p=>{;this._updateCachedColors(p),this._updateViewportRectangle()}))}renderBackgrounds(){this._renderVertices(this._vertices)}renderCursor(){this._renderVertices(this._verticesCursor)}_renderVertices(t){let r=this._gl;r.useProgram(this._program),r.bindVertexArray(this._vertexArrayObject),r.uniformMatrix4fv(this._projectionLocation,false,te),r.bindBuffer(r.ARRAY_BUFFER,this._attributesBuffer),r.bufferData(r.ARRAY_BUFFER,t.attributes,r.DYNAMIC_DRAW),r.drawElementsInstanced(this._gl.TRIANGLE_STRIP,4,r.UNSIGNED_BYTE,0,t.count)}handleResize(){this._updateViewportRectangle()}setDimensions(t){this._dimensions=t}_updateCachedColors(t){;this._bgFloat=this._colorToFloat32Array(t.background),this._cursorFloat=this._colorToFloat32Array(t.cursor)}_updateViewportRectangle(){this._addRectangleFloat(this._vertices.attributes,0,0,0,this._terminal.cols*this._dimensions.device.cell.width,this._terminal.rows*this._dimensions.device.cell.height,this._bgFloat)}updateBackgrounds(t){let r=this._terminal,s=this._vertices,a=1,o,n,L,T,h,d,_,p,c,u,C;for(o=0;o>24&255)/255,_t=(p0>>16&255)/255,ct=(p0>>8&255)/255,ut=1,this._addRectangle(t.attributes,r,Lt,ht,(n-o)*this._dimensions.device.cell.width,this._dimensions.device.cell.height,dt,_t,ct,ut)}_addRectangle(t,r,s,a,o,n,L,T,h,d){;t[r]=s/this._dimensions.device.canvas.width,t[r+1]=a/this._dimensions.device.canvas.height,t[r+2]=o/this._dimensions.device.canvas.width,t[r+3]=n/this._dimensions.device.canvas.height,t[r+4]=L,t[r+5]=T,t[r+6]=h,t[r+7]=d}_addRectangleFloat(t,r,s,a,o,n,L){;t[r]=s/this._dimensions.device.canvas.width,t[r+1]=a/this._dimensions.device.canvas.height,t[r+2]=o/this._dimensions.device.canvas.width,t[r+3]=n/this._dimensions.device.canvas.height,t[r+4]=L[0],t[r+5]=L[1],t[r+6]=L[2],t[r+7]=L[3]}_colorToFloat32Array(t){return new Float32Array([(t.rgba>>24&255)/255,(t.rgba>>16&255)/255,(t.rgba>>8&255)/255,(t.rgba&255)/255])}};var oe=class extends W{constructor(t,r,s,a,o,n,L,T){super();this._container=r;this._alpha=o;this._coreBrowserService=n;this._optionsService=L;this._themeService=T;this._deviceCharWidth=0;this._deviceCharHeight=0;this._deviceCellWidth=0;this._deviceCellHeight=0;this._deviceCharLeft=0;this._deviceCharTop=0;this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add(`xterm-${s}-layer`),this._canvas.style.zIndex=a.toString(),this._initCanvas(),this._container.appendChild(this._canvas),this._register(this._themeService.onChangeColors(h=>{;this._refreshCharAtlas(t,h),this.reset(t)})),this._register(M(()=>{this._canvas.remove()}))}_initCanvas(){;this._ctx=g(this._canvas.getContext("2d",{alpha:this._alpha})),this._alpha||this._clearAll()}handleBlur(t){}handleFocus(t){}handleCursorMove(t){}handleGridChanged(t,r,s){}handleSelectionChanged(t,r,s,a=false){}_setTransparency(t,r){if(r===this._alpha)return;let s=this._canvas;this._alpha=r,this._canvas=this._canvas.cloneNode(),this._initCanvas(),this._container.replaceChild(this._canvas,s),this._refreshCharAtlas(t,this._themeService.colors),this.handleGridChanged(t,0,t.rows-1)}_refreshCharAtlas(t,r){;this._deviceCharWidth<=0&&this._deviceCharHeight<=0||(this._charAtlas=Y0(t,this._optionsService.rawOptions,r,this._deviceCellWidth,this._deviceCellHeight,this._deviceCharWidth,this._deviceCharHeight,this._coreBrowserService.dpr,2048),this._charAtlas.warmUp())}resize(t,r){;this._deviceCellWidth=r.device.cell.width,this._deviceCellHeight=r.device.cell.height,this._deviceCharWidth=r.device.char.width,this._deviceCharHeight=r.device.char.height,this._deviceCharLeft=r.device.char.left,this._deviceCharTop=r.device.char.top,this._canvas.width=r.device.canvas.width,this._canvas.height=r.device.canvas.height,this._canvas.style.width=`${r.css.canvas.width}px`,this._canvas.style.height=`${r.css.canvas.height}px`,this._alpha||this._clearAll(),this._refreshCharAtlas(t,this._themeService.colors)}_fillBottomLineAtCells(t,r,s=1){this._ctx.fillRect(t*this._deviceCellWidth,(r+1)*this._deviceCellHeight-this._coreBrowserService.dpr-1,s*this._deviceCellWidth,this._coreBrowserService.dpr)}_clearAll(){this._alpha?this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(0,0,this._canvas.width,this._canvas.height))}_clearCells(t,r,s,a){this._alpha?this._ctx.clearRect(t*this._deviceCellWidth,r*this._deviceCellHeight,s*this._deviceCellWidth,a*this._deviceCellHeight):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(t*this._deviceCellWidth,r*this._deviceCellHeight,s*this._deviceCellWidth,a*this._deviceCellHeight))}_fillCharTrueColor(t,r,s,a){;this._ctx.font=this._getFont(t,false,false),this._ctx.textBaseline=K0,this._clipCell(s,a,r.getWidth()),this._ctx.fillText(r.getChars(),s*this._deviceCellWidth+this._deviceCharLeft,a*this._deviceCellHeight+this._deviceCharTop+this._deviceCharHeight)}_clipCell(t,r,s){;this._ctx.beginPath(),this._ctx.rect(t*this._deviceCellWidth,r*this._deviceCellHeight,s*this._deviceCellWidth,this._deviceCellHeight),this._ctx.clip()}_getFont(t,r,s){let a=r?t.options.fontWeightBold:t.options.fontWeight;return`${s?"italic":""} ${a} ${t.options.fontSize*this._coreBrowserService.dpr}px ${t.options.fontFamily}`}};var Te=class extends oe{constructor(e,t,r,s,a,o,n){;super(r,e,"link",t,true,a,o,n),this._register(s.onShowLinkUnderline(L=>this._handleShowLinkUnderline(L))),this._register(s.onHideLinkUnderline(L=>this._handleHideLinkUnderline(L)))}resize(e,t){;super.resize(e,t),this._state=void 0}reset(e){this._clearCurrentLink()}_clearCurrentLink(){if(this._state){this._clearCells(this._state.x1,this._state.y1,this._state.cols-this._state.x1,1);let e=this._state.y2-this._state.y1-1;e>0&&this._clearCells(0,this._state.y1+1,this._state.cols,e),this._clearCells(0,this._state.y2,this._state.x2,1),this._state=void 0}}_handleShowLinkUnderline(e){if(e.fg===257?this._ctx.fillStyle=this._themeService.colors.background.css:e.fg!==void 0&&st(e.fg)?this._ctx.fillStyle=this._themeService.colors.ansi[e.fg].css:this._ctx.fillStyle=this._themeService.colors.foreground.css,e.y1===e.y2)this._fillBottomLineAtCells(e.x1,e.y1,e.x2-e.x1);else{this._fillBottomLineAtCells(e.x1,e.y1,e.cols-e.x1);for(let t=e.y1+1;tthis._handleColorChange())),this._cellColorResolver=new G0(this._terminal,this._optionsService,this._model.selection,this._decorationService,this._coreBrowserService,this._themeService),this._core=this._terminal._core,this._renderLayers=[new Te(this._core.screenElement,2,this._terminal,this._core.linkifier,this._coreBrowserService,T,this._themeService)],this.dimensions=Ue(),this._devicePixelRatio=this._coreBrowserService.dpr,this._updateDimensions(),this._updateCursorBlink(),this._register(T.onOptionChange(()=>this._handleOptionsChanged())),this._textBlinkStateManager=this._register(new J0(()=>this._requestRedrawViewport(),this._coreBrowserService,this._optionsService)),this._resetBlinkingRowState(),this._deviceMaxTextureSize=this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE),this._register(le(this._canvas,"webglcontextlost",c=>{;this._logService.debug("webglcontextlost event received"),c.preventDefault(),this._contextRestorationTimeout=setTimeout(()=>{;this._contextRestorationTimeout=void 0,this._logService.warn("webgl context not restored; firing onContextLoss"),this._onContextLoss.fire(c)},3e3)})),this._register(le(this._canvas,"webglcontextrestored",c=>{;this._logService.warn("webglcontextrestored event received"),clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0,be(this._terminal),this._initializeWebGLState(),this._requestRedrawViewport()})),this._observerDisposable.value=ve(this._canvas,this._coreBrowserService.window,(c,u)=>this._setCanvasDevicePixelDimensions(c,u)),this._register(this._coreBrowserService.onWindowChange(c=>{this._observerDisposable.value=ve(this._canvas,c,(u,C)=>this._setCanvasDevicePixelDimensions(u,C))})),this._register(le(this._coreBrowserService.mainDocument,"mousedown",()=>this._cursorBlinkStateManager.value?.restartBlinkAnimation())),this._core.screenElement.appendChild(this._canvas),[this._rectangleRenderer.value,this._glyphRenderer.value]=this._initializeWebGLState(),this._isAttached=this._core.screenElement.isConnected,this._register(M(()=>{;clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0;for(let c of this._renderLayers)c.dispose();this._canvas.parentElement?.removeChild(this._canvas),be(this._terminal)}))}get textureAtlas(){return this._charAtlas?.pages[0].canvas}_handleColorChange(){;this._refreshCharAtlas(),this._clearModel(true)}handleDevicePixelRatioChange(){this._devicePixelRatio!==this._coreBrowserService.dpr&&(this._devicePixelRatio=this._coreBrowserService.dpr,this.handleResize(this._terminal.cols,this._terminal.rows))}handleResize(t,r){;this._updateDimensions(),this._model.resize(this._terminal.cols,this._terminal.rows),this._resetBlinkingRowState();for(let s of this._renderLayers)s.resize(this._terminal,this.dimensions);this._canvas.width=this.dimensions.device.canvas.width,this._canvas.height=this.dimensions.device.canvas.height,this._canvas.style.width=`${this.dimensions.css.canvas.width}px`,this._canvas.style.height=`${this.dimensions.css.canvas.height}px`,this._core.screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._core.screenElement.style.height=`${this.dimensions.css.canvas.height}px`,this._rectangleRenderer.value?.setDimensions(this.dimensions),this._rectangleRenderer.value?.handleResize(),this._glyphRenderer.value?.setDimensions(this.dimensions),this._glyphRenderer.value?.handleResize(),this._refreshCharAtlas(),this._clearModel(false),this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:true})}handleCharSizeChanged(){this.handleResize(this._terminal.cols,this._terminal.rows)}handleBlur(){for(let t of this._renderLayers)t.handleBlur(this._terminal);this._cursorBlinkStateManager.value?.pause(),this._requestRedrawViewport()}handleFocus(){for(let t of this._renderLayers)t.handleFocus(this._terminal);this._cursorBlinkStateManager.value?.resume(),this._requestRedrawViewport()}handleViewportVisibilityChange(t){this._textBlinkStateManager.setViewportVisible(t)}handleSelectionChanged(t,r,s){for(let a of this._renderLayers)a.handleSelectionChanged(this._terminal,t,r,s);this._model.selection.update(this._core,t,r,s),this._requestRedrawViewport()}handleCursorMove(){for(let t of this._renderLayers)t.handleCursorMove(this._terminal);this._cursorBlinkStateManager.value?.restartBlinkAnimation()}_handleOptionsChanged(){;this._updateDimensions(),this._refreshCharAtlas(),this._updateCursorBlink()}_initializeWebGLState(){return this._rectangleRenderer.value=new ae(this._terminal,this._gl,this.dimensions,this._themeService,this._logService),this._glyphRenderer.value=new re(this._terminal,this._gl,this.dimensions,this._optionsService,this._logService),this.handleCharSizeChanged(),[this._rectangleRenderer.value,this._glyphRenderer.value]}_refreshCharAtlas(){if(this.dimensions.device.char.width<=0&&this.dimensions.device.char.height<=0){this._isAttached=false;return}let t=Y0(this._terminal,this._optionsService.rawOptions,this._themeService.colors,this.dimensions.device.cell.width,this.dimensions.device.cell.height,this.dimensions.device.char.width,this.dimensions.device.char.height,this._coreBrowserService.dpr,this._deviceMaxTextureSize,this._customGlyphs);this._charAtlas!==t&&(this._onChangeTextureAtlas.fire(t.pages[0].canvas),this._charAtlasDisposable.value=De(c0.forward(t.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas),c0.forward(t.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas))),this._charAtlas=t,this._charAtlas.warmUp(),this._glyphRenderer.value?.setAtlas(this._charAtlas)}_clearModel(t){;this._model.clear(),t&&this._glyphRenderer.value?.clear()}clearTextureAtlas(){;this._charAtlas?.clearTexture(),this._clearModel(true),this._requestRedrawViewport()}clear(){this._clearModel(true);for(let t of this._renderLayers)t.reset(this._terminal);this._resetBlinkingRowState(),this._textBlinkStateManager.setNeedsBlinkInViewport(false),this._cursorBlinkStateManager.value?.restartBlinkAnimation(),this._updateCursorBlink()}renderRows(t,r){if(!this._isAttached)if(this._core.screenElement?.isConnected&&this._charSizeService.width&&this._charSizeService.height)this._updateDimensions(),this._refreshCharAtlas(),this._isAttached=true;else return;for(let o of this._renderLayers)o.handleGridChanged(this._terminal,t,r);if(!this._glyphRenderer.value||!this._rectangleRenderer.value)return;this._glyphRenderer.value.beginFrame()?(this._clearModel(true),this._updateModel(0,this._terminal.rows-1)):this._updateModel(t,r);let s=false,a=0;for(;this._charAtlas&&a++<32&&this._glyphRenderer.value.beginFrame();)s=true,this._clearModel(true),this._updateModel(0,this._terminal.rows-1);s&&this._glyphRenderer.value.invalidateAtlasTextures(),this._rectangleRenderer.value.renderBackgrounds(),this._glyphRenderer.value.render(this._model),(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible)&&this._rectangleRenderer.value.renderCursor()}_updateCursorBlink(){;this._coreService.decPrivateModes.cursorBlink??this._terminal.options.cursorBlink?this._cursorBlinkStateManager.value=new q0(()=>{this._requestRedrawCursor()},this._coreBrowserService):this._cursorBlinkStateManager.clear(),this._requestRedrawCursor()}_updateModel(t,r){let s=this._core,a=this._workCell,o,n,L,T,h,d,_,p,c,u,C,Q,m,R,F,E,y;t=pt(t,s.rows-1,0),r=pt(r,s.rows-1,0);let Y=this._coreService.decPrivateModes.cursorStyle??s.options.cursorStyle??"block",X=this._terminal.buffer.active.baseY+this._terminal.buffer.active.cursorY,A0=X-s.buffer.ydisp,t0=Math.min(this._terminal.buffer.active.cursorX,s.cols-1),L0=-1,A=this._coreService.isCursorInitialized&&!this._coreService.isCursorHidden&&(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible);this._model.cursor=void 0;let n0=false;for(n=t;n<=r;n++){L=n+s.buffer.ydisp;let a0=s.buffer.lines.get(L);if(!a0){for(this._model.lineLengths[n]=0,E=0;E=_,c=E,h.length>0&&E===h[0][0]&&p){u=h.shift();let q=this._model.selection.isCellSelected(this._terminal,u[0],L);for(F=u[0]+1;F=u[1],p?(d=true,a=new ge(a,T.translateToString(true,u[0],u[1]),u[1]-u[0]),c=u[1]-1):_=u[1]}if(Q=a.getChars(),m=a.getCode(),F=(n*s.cols+E)*4,!$&&a.isBlink()&&($=true),this._cellColorResolver.resolve(a,E,L,this.dimensions.device.cell.width,this.dimensions.device.cell.height),A&&L===X&&(E===t0&&(this._model.cursor={x:t0,y:A0,width:a.getWidth(),style:this._coreBrowserService.isFocused?Y:s.options.cursorInactiveStyle,cursorWidth:s.options.cursorWidth,dpr:this._devicePixelRatio},L0=t0+a.getWidth()-1),E>=t0&&E<=L0&&(this._coreBrowserService.isFocused&&Y==="block"||this._coreBrowserService.isFocused===false&&s.options.cursorInactiveStyle==="block")&&(this._cellColorResolver.result.fg=50331648|this._themeService.colors.cursorAccent.rgba>>8&16777215,this._cellColorResolver.result.bg=50331648|this._themeService.colors.cursor.rgba>>8&16777215)),this._textBlinkStateManager.isEnabled&&!this._textBlinkStateManager.isBlinkOn&&a.isBlink()&&(this._cellColorResolver.result.fg|=1073741824),m!==0&&(this._model.lineLengths[n]=E+1),!(this._model.cells[F]===m&&this._model.cells[F+1]===this._cellColorResolver.result.bg&&this._model.cells[F+2]===this._cellColorResolver.result.fg&&this._model.cells[F+3]===this._cellColorResolver.result.ext)&&(n0=true,Q.length>1&&(m|=lt),this._model.cells[F]=m,this._model.cells[F+1]=this._cellColorResolver.result.bg,this._model.cells[F+2]=this._cellColorResolver.result.fg,this._model.cells[F+3]=this._cellColorResolver.result.ext,R=a.getWidth(),this._glyphRenderer.value.updateCell(E,n,m,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext,Q,R,o),d)){for(a=this._workCell,E++;E<=c;E++)y=(n*s.cols+E)*4,this._nullModelCell(E,n,y,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext);E--}}this._setRowBlinkState(n,$)};n0&&this._rectangleRenderer.value.updateBackgrounds(this._model),this._rectangleRenderer.value.updateCursor(this._model),this._updateTextBlinkState()}_nullModelCell(t,r,s,a,o,n){;this._glyphRenderer.value.updateCell(t,r,0,a,o,n,"",0,0),this._model.cells[s]=0,this._model.cells[s+1]=a,this._model.cells[s+2]=o,this._model.cells[s+3]=n}_resetBlinkingRowState(){;this._rowHasBlinkingCells=new Array(this._terminal.rows).fill(false),this._rowHasBlinkingCellsCount=0}_setRowBlinkState(t,r){this._rowHasBlinkingCells[t]!==r&&(this._rowHasBlinkingCells[t]=r,this._rowHasBlinkingCellsCount+=r?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}_updateDimensions(){!this._charSizeService.width||!this._charSizeService.height||(this.dimensions.device.char.width=Math.floor(this._charSizeService.width*this._devicePixelRatio),this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*this._devicePixelRatio),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.top=this._optionsService.rawOptions.lineHeight===1?0:Math.round((this.dimensions.device.cell.height-this.dimensions.device.char.height)/2),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.char.left=Math.floor(this._optionsService.rawOptions.letterSpacing/2),this.dimensions.device.canvas.height=this._terminal.rows*this.dimensions.device.cell.height,this.dimensions.device.canvas.width=this._terminal.cols*this.dimensions.device.cell.width,this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/this._devicePixelRatio),this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/this._devicePixelRatio),this.dimensions.css.cell.height=this.dimensions.device.cell.height/this._devicePixelRatio,this.dimensions.css.cell.width=this.dimensions.device.cell.width/this._devicePixelRatio)}_setCanvasDevicePixelDimensions(t,r){;this._canvas.width===t&&this._canvas.height===r||(this._canvas.width=t,this._canvas.height=r,this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:true}))}_requestRedrawViewport(){this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1})}_requestRedrawCursor(){let t=this._terminal.buffer.active.cursorY;this._onRequestRedraw.fire({start:t,end:t})}},ge=class extends s0{constructor(t,r,s){super();this.content=0;this.combinedData="";this.fg=t.fg,this.bg=t.bg,this.combinedData=r,this._width=s}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(t){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}};function pt(i5,e,t=0){return Math.max(Math.min(i5,e),t)}var Ct=class extends W{constructor(t){if(ue&&He()<16){let r={antialias:false,depth:false,preserveDrawingBuffer:true};if(!document.createElement("canvas").getContext("webgl2",r))throw new Error("WebGL2 is only supported on Safari 16 and above")}super();this._onChangeTextureAtlas=this._register(new U);this.onChangeTextureAtlas=this._onChangeTextureAtlas.event;this._onAddTextureAtlasCanvas=this._register(new U);this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;this._onRemoveTextureAtlasCanvas=this._register(new U);this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;this._onContextLoss=this._register(new U);this.onContextLoss=this._onContextLoss.event;this._customGlyphs=t?.customGlyphs??true,this._preserveDrawingBuffer=t?.preserveDrawingBuffer}activate(t){let r=t._core;if(!t.element){this._register(r.onWillOpen(()=>this.activate(t)));return}this._terminal=t;let s=r.coreService,a=r.optionsService,o=r,n=o._renderService,L=o._characterJoinerService,T=o._charSizeService,h=o._coreBrowserService,d=o._decorationService,_=o._logService,p=o._themeService;this._renderer=this._register(new Le(t,L,T,h,s,d,_,a,p,this._customGlyphs,this._preserveDrawingBuffer)),this._register(c0.forward(this._renderer.onContextLoss,this._onContextLoss)),this._register(c0.forward(this._renderer.onChangeTextureAtlas,this._onChangeTextureAtlas)),this._register(c0.forward(this._renderer.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas)),this._register(c0.forward(this._renderer.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas)),n.setRenderer(this._renderer),this._register(M(()=>{if(this._terminal._core._store._isDisposed)return;let c=this._terminal._core._renderService;c.setRenderer(this._terminal._core._createRenderer()),c.handleResize(t.cols,t.rows)}))}get textureAtlas(){return this._renderer?.textureAtlas}clearTextureAtlas(){this._renderer?.clearTextureAtlas()}};export{Ct as WebglAddon};
-diff --git a/src/GlyphRenderer.ts b/src/GlyphRenderer.ts
-index be1990c2a61a0ff5a37622ef7b6d8772e7a1056c..c19e24f7963cfcfbca1cea4bf7f808bf534a19e7 100644
+diff -ruN a/src/GlyphRenderer.ts b/src/GlyphRenderer.ts
--- a/src/GlyphRenderer.ts
+++ b/src/GlyphRenderer.ts
-@@ -74,7 +74,11 @@ out vec4 outColor;
+@@ -74,7 +74,11 @@
void main() {
if (v_texpage == 0) {
outColor = texture(u_texture[0], v_texcoord);
@@ -88,19 +85,19 @@ index be1990c2a61a0ff5a37622ef7b6d8772e7a1056c..c19e24f7963cfcfbca1cea4bf7f808bf
+ }
}`);
}
-
-@@ -100,6 +104,8 @@ export class GlyphRenderer extends Disposable {
+
+@@ -100,6 +104,8 @@
private readonly _attributesBuffer: WebGLBuffer;
-
+
private _atlas: ITextureAtlas | undefined;
+ private _lastSeenClearModelGeneration: number = 0;
+ private _pageOverflowWarned: boolean = false;
private _activeBuffer: number = 0;
private readonly _vertices: IVertices = {
count: 0,
-@@ -214,7 +220,15 @@ export class GlyphRenderer extends Disposable {
+@@ -214,7 +220,15 @@
}
-
+
public beginFrame(): boolean {
- return this._atlas ? this._atlas.beginFrame() : true;
+ if (!this._atlas) {
@@ -113,9 +110,9 @@ index be1990c2a61a0ff5a37622ef7b6d8772e7a1056c..c19e24f7963cfcfbca1cea4bf7f808bf
+ this._lastSeenClearModelGeneration = generation;
+ return true;
}
-
+
public updateCell(x: number, y: number, code: number, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void {
-@@ -363,7 +377,14 @@ export class GlyphRenderer extends Disposable {
+@@ -363,7 +377,14 @@
// Bind the atlas page texture if they have changed. AtlasPage.version is globally
// monotonic, so a page object swap at the same index (which happens after a page merge)
// is detected by the same comparison.
@@ -131,9 +128,9 @@ index be1990c2a61a0ff5a37622ef7b6d8772e7a1056c..c19e24f7963cfcfbca1cea4bf7f808bf
if (this._atlas.pages[i].version !== this._atlasTextures[i].version) {
this._bindAtlasPageTexture(gl, this._atlas, i);
}
-@@ -374,7 +395,12 @@ export class GlyphRenderer extends Disposable {
+@@ -374,7 +395,12 @@
}
-
+
public setAtlas(atlas: ITextureAtlas): void {
- this._atlas = atlas;
+ if (this._atlas !== atlas) {
@@ -144,22 +141,21 @@ index be1990c2a61a0ff5a37622ef7b6d8772e7a1056c..c19e24f7963cfcfbca1cea4bf7f808bf
+ }
this.invalidateAtlasTextures();
}
-
-diff --git a/src/TextureAtlas.ts b/src/TextureAtlas.ts
-index 1c3e08a1cb14208e3d8b6dd726c79285200b9df4..04703e830ecc907f91c67c1524b326729342a28d 100644
+
+diff -ruN a/src/TextureAtlas.ts b/src/TextureAtlas.ts
--- a/src/TextureAtlas.ts
+++ b/src/TextureAtlas.ts
-@@ -133,6 +133,7 @@ export class TextureAtlas implements ITextureAtlas {
+@@ -133,6 +133,7 @@
}
-
+
private _requestClearModel = false;
+ public clearModelGeneration = 0;
public beginFrame(): boolean {
const result = this._requestClearModel;
this._requestClearModel = false;
-@@ -140,15 +141,38 @@ export class TextureAtlas implements ITextureAtlas {
+@@ -140,15 +141,38 @@
}
-
+
public clearTexture(): void {
- if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) {
+ // Guard on real content, not pages[0].currentRow: after a page merge a
@@ -196,9 +192,9 @@ index 1c3e08a1cb14208e3d8b6dd726c79285200b9df4..04703e830ecc907f91c67c1524b32672
+ this._requestClearModel = true;
+ this.clearModelGeneration++;
}
-
+
private _createNewPage(): AtlasPage {
-@@ -156,7 +180,7 @@ export class TextureAtlas implements ITextureAtlas {
+@@ -156,7 +180,7 @@
// microtask to ensure it does not interrupt textures that will be rendered in the current
// animation frame which would result in blank rendered areas. This is actually not that
// expensive relative to drawing the glyphs, so there is no need to wait for an idle callback.
@@ -207,7 +203,7 @@ index 1c3e08a1cb14208e3d8b6dd726c79285200b9df4..04703e830ecc907f91c67c1524b32672
// Find the set of the largest 4 images, below the maximum size, with the highest
// percentages used
const pagesBySize = this._pages.filter(e => {
-@@ -184,31 +208,30 @@ export class TextureAtlas implements ITextureAtlas {
+@@ -184,31 +208,30 @@
// Only proceed with merge if we have exactly 4 same-sized pages. If not, we cannot
// effectively reduce page count and merging would cause issues.
if (mergingPages.length < 4 || mergingPages.some(p => p.canvas.width !== mergingPages[0].canvas.width)) {
@@ -217,37 +213,37 @@ index 1c3e08a1cb14208e3d8b6dd726c79285200b9df4..04703e830ecc907f91c67c1524b32672
- this._onAddTextureAtlasCanvas.fire(newPage.canvas);
- return newPage;
- }
--
-- const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a - b);
-- const mergedPageIndex = this.pages.length - mergingPages.length;
+ // No merge can reduce the page count; evict and rebuild instead of
+ // adding a page at an index the renderer has no texture slot for.
+ this._evictAllPages();
+ } else {
+ const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a - b);
+ const mergedPageIndex = this.pages.length - mergingPages.length;
-
-- // Merge into the new page
-- const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
-- mergedPage.version = ++AtlasPage.nextVersion;
+
+- const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a - b);
+- const mergedPageIndex = this.pages.length - mergingPages.length;
+ // Merge into the new page
+ const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
+ mergedPage.version = ++AtlasPage.nextVersion;
-
-- // Delete the pages, shifting glyph texture pages as needed
-- for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
-- this._deletePage(sortedMergingPagesIndexes[i]);
-- }
+
+- // Merge into the new page
+- const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
+- mergedPage.version = ++AtlasPage.nextVersion;
+ // Delete the pages, shifting glyph texture pages as needed
+ for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
+ this._deletePage(sortedMergingPagesIndexes[i]);
+ }
-
-- // Add the new merged page to the end
-- this.pages.push(mergedPage);
+
+- // Delete the pages, shifting glyph texture pages as needed
+- for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
+- this._deletePage(sortedMergingPagesIndexes[i]);
+- }
+ // Add the new merged page to the end
+ this.pages.push(mergedPage);
-
+
+- // Add the new merged page to the end
+- this.pages.push(mergedPage);
+-
- // Request the model to be cleared to refresh all texture pages.
- this._requestClearModel = true;
- this._onAddTextureAtlasCanvas.fire(mergedPage.canvas);
@@ -257,9 +253,36 @@ index 1c3e08a1cb14208e3d8b6dd726c79285200b9df4..04703e830ecc907f91c67c1524b32672
+ this._onAddTextureAtlasCanvas.fire(mergedPage.canvas);
+ }
}
-
+
// All new atlas pages are created small as they are highly dynamic
-@@ -815,11 +838,16 @@ export class TextureAtlas implements ITextureAtlas {
+@@ -516,6 +539,26 @@
+ const fontStyle = italic ? 'italic' : '';
+ this._tmpCtx.font =
+ `${fontStyle} ${fontWeight} ${this._config.fontSize * this._config.devicePixelRatio}px ${this._config.fontFamily}`;
++ // Orca font probe: canvas silently keeps its previous font when an assignment
++ // fails to parse, which rasterizes glyphs at a stale weight (bold-collapse
++ // family). Normalize Canvas font serialization: Chromium omits 400 and emits
++ // the keyword bold for 700.
++ {
++ const desiredWeight = String(fontWeight);
++ const actualWeightToken = this._tmpCtx.font.match(
++ /(?:^|\s)(normal|bold|[1-9]\d{0,3})(?=\s+\d+(?:\.\d+)?px(?:\s|$))/
++ )?.[1] ?? '400';
++ const actualWeight = actualWeightToken === 'normal'
++ ? '400'
++ : actualWeightToken === 'bold'
++ ? '700'
++ : actualWeightToken;
++ if (/^(?:[1-8]\d{2}|900)$/.test(desiredWeight) && actualWeight !== desiredWeight) {
++ (this as any).fontProbeMismatchCount = ((this as any).fontProbeMismatchCount || 0) + 1;
++ (this as any).fontProbeLastMismatch = { desired: desiredWeight, actual: this._tmpCtx.font };
++ try { (globalThis as any).__orcaAtlasFontProbe?.((this as any).fontProbeLastMismatch); } catch { /* diagnostics only */ }
++ }
++ }
+ this._tmpCtx.textBaseline = TEXT_BASELINE;
+
+ const powerlineGlyph = chars.length === 1 && isPowerlineGlyph(chars.charCodeAt(0));
+@@ -815,11 +858,16 @@
// Create a new page for oversized glyphs as they come up
if (rasterizedGlyph.size.x > this._textureSize) {
if (!this._overflowSizePage) {
@@ -269,32 +292,30 @@ index 1c3e08a1cb14208e3d8b6dd726c79285200b9df4..04703e830ecc907f91c67c1524b32672
+ }
this._overflowSizePage = new AtlasPage(this._document, this._config.deviceMaxTextureSize);
this.pages.push(this._overflowSizePage);
-
+
// Request the model to be cleared to refresh all texture pages.
this._requestClearModel = true;
+ this.clearModelGeneration++;
this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas);
}
activePage = this._overflowSizePage;
-diff --git a/src/Types.ts b/src/Types.ts
-index 294582e3721d4614f6b14198229e67dfa820baec..bc708d9b6d2cbeb206bd0b13325162c0765bf8fb 100644
+diff -ruN a/src/Types.ts b/src/Types.ts
--- a/src/Types.ts
+++ b/src/Types.ts
-@@ -59,6 +59,9 @@ export interface ICharAtlasConfig {
+@@ -59,6 +59,9 @@
export interface ITextureAtlas extends IDisposable {
readonly pages: { canvas: HTMLCanvasElement, version: number }[];
-
+
+ // Each renderer must observe invalidation independently because atlases are shared.
+ readonly clearModelGeneration: number;
+
onAddTextureAtlasCanvas: IEvent;
onRemoveTextureAtlasCanvas: IEvent;
-
-diff --git a/src/WebglRenderer.ts b/src/WebglRenderer.ts
-index a951efba5c75e82735cd39b6b22c4b5d5fad5928..7cc90292d352b134e7daf2bb7857da8f3f34cb9d 100644
+
+diff -ruN a/src/WebglRenderer.ts b/src/WebglRenderer.ts
--- a/src/WebglRenderer.ts
+++ b/src/WebglRenderer.ts
-@@ -386,7 +386,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
+@@ -386,7 +386,10 @@
// page's version, so re-run the update and force a full texture rebind.
let merged = false;
let mergeRetries = 0;
diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch
index c5c35191f35..f8ddaae9104 100644
--- a/config/patches/node-pty@1.1.0.patch
+++ b/config/patches/node-pty@1.1.0.patch
@@ -2,13 +2,21 @@ diff --git a/binding.gyp b/binding.gyp
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
--- a/binding.gyp
+++ b/binding.gyp
-@@ -1,13 +1,10 @@
+@@ -1,13 +1,18 @@
{
'target_defaults': {
'dependencies': [
- "` forces the label so
+ * the glibc/musl distinction is recorded from the container rather than detected.
+ *
+ * Usage:
+ * node config/scripts/build-orcad-prebuilds.mjs [--slot=linux-x64-musl]
+ * node config/scripts/build-orcad-prebuilds.mjs --require-slots # release gate
+ */
+import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, join } from 'node:path'
+import process from 'node:process'
+import { spawnSync } from 'node:child_process'
+
+const require = createRequire(import.meta.url)
+const ROOT = join(import.meta.dirname, '..', '..')
+const PREBUILDS_DIR = join(ROOT, 'out', 'orcad', 'prebuilds')
+
+/** Every slot a shipped matrix must fill. The single source of truth for the matrix. */
+export const MATRIX_SLOTS = [
+ 'linux-x64-glibc',
+ 'linux-arm64-glibc',
+ 'linux-x64-musl',
+ 'linux-arm64-musl',
+ 'darwin-x64',
+ 'darwin-arm64'
+]
+
+/**
+ * Why the report header and not `ldd`: `glibcVersionRuntime` is present only when the
+ * process is linked against glibc, and musl images have no `ldd` worth parsing.
+ */
+export function detectLibc(platform = process.platform, header = readReportHeader()) {
+ if (platform !== 'linux') {
+ return 'none'
+ }
+ return header && typeof header === 'object' && 'glibcVersionRuntime' in header ? 'glibc' : 'musl'
+}
+
+function readReportHeader() {
+ try {
+ return process.report?.getReport?.()?.header
+ } catch {
+ return undefined
+ }
+}
+
+export function slotName(argv = process.argv, platform = process.platform, arch = process.arch) {
+ const forced = argv.find((arg) => arg.startsWith('--slot='))
+ if (forced) {
+ return forced.slice('--slot='.length)
+ }
+ const libc = detectLibc(platform)
+ return libc === 'none' ? `${platform}-${arch}` : `${platform}-${arch}-${libc}`
+}
+
+/**
+ * The patch is what holds the Ubuntu 20.04 floor. Compiling without it produces a binary
+ * that loads fine on the build host and dies on the target — the exact failure the matrix
+ * exists to prevent, now baked into a shipped artifact instead of a first-connect error.
+ */
+export function assertNodePtyPatchApplied(nodePtyDir) {
+ const bindingGyp = readFileSync(join(nodePtyDir, 'binding.gyp'), 'utf8')
+ const ptySource = readFileSync(join(nodePtyDir, 'src', 'unix', 'pty.cc'), 'utf8')
+ const missing = []
+ if (!bindingGyp.includes('--no-as-needed,-l:libutil.so.1')) {
+ missing.push("binding.gyp is missing the '--no-as-needed,-l:libutil.so.1' ldflag")
+ }
+ if (!ptySource.includes('.symver openpty,openpty@')) {
+ missing.push('src/unix/pty.cc is missing the .symver glibc pins')
+ }
+ if (missing.length > 0) {
+ throw new Error(
+ [
+ '[orcad-prebuilds] refusing to build: config/patches/node-pty@1.1.0.patch is not applied.',
+ ...missing.map((line) => ` - ${line}`),
+ 'A prebuilt compiled without it will not load on Ubuntu 20.04 (see',
+ 'docs/reference/linux-glibc-compatibility.md and #9902). Run `pnpm install` to apply patches.'
+ ].join('\n')
+ )
+ }
+}
+
+export function readManifest(prebuildsDir) {
+ try {
+ return JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8'))
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Why merge rather than overwrite: CI builds one slot per container and merges the trees.
+ * A manifest that records only the last slot would erase every other container's record,
+ * and `--require-slots` would then reject a complete matrix.
+ */
+export function mergeManifest(existing, next) {
+ const slots = new Set([...(existing?.slots ?? []), next.slot])
+ return {
+ module: 'node-pty',
+ version: next.version,
+ nodeAbi: next.nodeAbi,
+ slots: [...slots].sort()
+ }
+}
+
+function nodePtyDir() {
+ return dirname(require.resolve('node-pty/package.json'))
+}
+
+function compileNodePty(dir) {
+ const built = join(dir, 'build', 'Release', 'pty.node')
+ if (existsSync(built)) {
+ console.log(`[orcad-prebuilds] reusing existing build at ${built}`)
+ return built
+ }
+ console.log('[orcad-prebuilds] compiling node-pty from patched source ...')
+ const result = spawnSync(
+ process.platform === 'win32' ? 'npx.cmd' : 'npx',
+ ['node-gyp', 'rebuild'],
+ {
+ cwd: dir,
+ stdio: 'inherit',
+ env: process.env,
+ windowsHide: true
+ }
+ )
+ if (result.status !== 0) {
+ throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.status})`)
+ }
+ if (!existsSync(built)) {
+ throw new Error(`[orcad-prebuilds] node-gyp succeeded but ${built} is missing`)
+ }
+ return built
+}
+
+function requireSlots() {
+ const manifest = readManifest(PREBUILDS_DIR)
+ const have = new Set(manifest?.slots ?? [])
+ const missing = MATRIX_SLOTS.filter((slot) => !have.has(slot))
+ if (missing.length > 0) {
+ console.error(
+ `[orcad-prebuilds] matrix incomplete — missing ${missing.join(', ')}. ` +
+ 'Hosts on those slots fall back to a source build and need a C/C++ toolchain.'
+ )
+ process.exitCode = 1
+ return
+ }
+ console.log(`[orcad-prebuilds] matrix complete — ${MATRIX_SLOTS.length} slots`)
+}
+
+function build() {
+ const dir = nodePtyDir()
+ assertNodePtyPatchApplied(dir)
+ const slot = slotName()
+ const slotDir = join(PREBUILDS_DIR, slot)
+ mkdirSync(slotDir, { recursive: true })
+
+ const builtBinary = compileNodePty(dir)
+ copyFileSync(builtBinary, join(slotDir, 'pty.node'))
+ console.log(`[orcad-prebuilds] stored ${slot}/pty.node`)
+
+ // Why spawn-helper ships too: on Unix node-pty posix_spawns build/Release/spawn-helper,
+ // so a slot without it installs cleanly and then fails ENOENT the first time a user
+ // opens a terminal. Windows has no spawn-helper.
+ if (process.platform !== 'win32') {
+ const helperSource = join(dirname(builtBinary), 'spawn-helper')
+ if (!existsSync(helperSource)) {
+ throw new Error(`[orcad-prebuilds] spawn-helper missing at ${helperSource}`)
+ }
+ copyFileSync(helperSource, join(slotDir, 'spawn-helper'))
+ console.log(`[orcad-prebuilds] stored ${slot}/spawn-helper`)
+ }
+
+ // The static floor gate, applied to the artifact we are about to ship rather than only
+ // to the packaged desktop app. objdump is Linux-only, which is where the floor lives.
+ if (process.platform === 'linux') {
+ const { verifyLinuxGlibcFloor } = require('./verify-linux-glibc-floor.cjs')
+ verifyLinuxGlibcFloor(slotDir)
+ }
+
+ const manifest = mergeManifest(readManifest(PREBUILDS_DIR), {
+ slot,
+ version: require('node-pty/package.json').version,
+ nodeAbi: process.versions.modules
+ })
+ writeFileSync(join(PREBUILDS_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
+ console.log(
+ `[orcad-prebuilds] manifest: node-pty ${manifest.version}, ABI ${manifest.nodeAbi}, slots ${manifest.slots.join(', ')}`
+ )
+}
+
+if (process.argv[1] && process.argv[1].endsWith('build-orcad-prebuilds.mjs')) {
+ if (process.argv.includes('--require-slots')) {
+ requireSlots()
+ } else {
+ build()
+ }
+}
diff --git a/config/scripts/build-orcad-prebuilds.test.mjs b/config/scripts/build-orcad-prebuilds.test.mjs
new file mode 100644
index 00000000000..c91c7e6cc9d
--- /dev/null
+++ b/config/scripts/build-orcad-prebuilds.test.mjs
@@ -0,0 +1,123 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ assertNodePtyPatchApplied,
+ detectLibc,
+ MATRIX_SLOTS,
+ mergeManifest,
+ readManifest,
+ slotName
+} from './build-orcad-prebuilds.mjs'
+
+const PATCHED_BINDING_GYP =
+ "'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']"
+const PATCHED_PTY_CC = '__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);'
+
+const dirs = []
+const stage = (bindingGyp, ptyCc) => {
+ const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-src-'))
+ dirs.push(dir)
+ mkdirSync(join(dir, 'src', 'unix'), { recursive: true })
+ writeFileSync(join(dir, 'binding.gyp'), bindingGyp)
+ writeFileSync(join(dir, 'src', 'unix', 'pty.cc'), ptyCc)
+ return dir
+}
+afterEach(() => {
+ for (const dir of dirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
+
+describe('assertNodePtyPatchApplied', () => {
+ it('accepts a tree with both halves of the glibc-floor fix', () => {
+ expect(() =>
+ assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, PATCHED_PTY_CC))
+ ).not.toThrow()
+ })
+
+ it('refuses to build when the ldflags half is missing', () => {
+ // The .symver pins alone let gcc's --as-needed drop libutil/libpthread from
+ // DT_NEEDED, which loads on the build host and fails on Ubuntu 20.04 — #9902 again,
+ // this time baked into a shipped prebuilt.
+ expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", PATCHED_PTY_CC))).toThrow(
+ /--no-as-needed,-l:libutil\.so\.1/
+ )
+ })
+
+ it('refuses to build when the .symver pins are missing', () => {
+ expect(() => assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, '// upstream'))).toThrow(
+ /\.symver glibc pins/
+ )
+ })
+
+ it('names the patch and the doc so the fix is findable', () => {
+ expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", '// upstream'))).toThrow(
+ /config\/patches\/node-pty@1\.1\.0\.patch/
+ )
+ })
+})
+
+describe('slot naming', () => {
+ it('covers every platform orcad ships to', () => {
+ expect([...MATRIX_SLOTS].sort()).toEqual([
+ 'darwin-arm64',
+ 'darwin-x64',
+ 'linux-arm64-glibc',
+ 'linux-arm64-musl',
+ 'linux-x64-glibc',
+ 'linux-x64-musl'
+ ])
+ })
+
+ it('lets CI force the label so the container decides glibc vs musl', () => {
+ // Detection inside a container that happens to run a differently-linked Node would
+ // file the build under the wrong slot. The forced label must beat detection outright,
+ // so assert against one detection could never produce for this platform/arch.
+ expect(slotName(['node', 'x', '--slot=linux-x64-glibc'], 'linux', 'arm64')).toBe(
+ 'linux-x64-glibc'
+ )
+ })
+
+ it('omits the libc dimension off Linux', () => {
+ expect(slotName([], 'darwin', 'arm64')).toBe('darwin-arm64')
+ })
+
+ it('reads glibc from the report header and musl from its absence', () => {
+ expect(detectLibc('linux', { glibcVersionRuntime: '2.31' })).toBe('glibc')
+ expect(detectLibc('linux', {})).toBe('musl')
+ expect(detectLibc('darwin', { glibcVersionRuntime: '2.31' })).toBe('none')
+ })
+})
+
+describe('mergeManifest', () => {
+ it('accumulates slots across the per-container CI runs that build them', () => {
+ // Overwriting would erase every other container's record, and the release gate would
+ // then reject a matrix that is actually complete.
+ const first = mergeManifest(null, { slot: 'linux-x64-glibc', version: '1.1.0', nodeAbi: '127' })
+ const second = mergeManifest(first, {
+ slot: 'linux-arm64-musl',
+ version: '1.1.0',
+ nodeAbi: '127'
+ })
+
+ expect(second.slots).toEqual(['linux-arm64-musl', 'linux-x64-glibc'])
+ expect(second).toMatchObject({ module: 'node-pty', version: '1.1.0', nodeAbi: '127' })
+ })
+
+ it('does not duplicate a slot rebuilt twice', () => {
+ const once = mergeManifest(null, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' })
+ expect(
+ mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots
+ ).toEqual(['darwin-arm64'])
+ })
+})
+
+describe('readManifest', () => {
+ it('returns null instead of throwing when no matrix has been built', () => {
+ const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-manifest-'))
+ dirs.push(dir)
+ expect(readManifest(dir)).toBeNull()
+ })
+})
diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs
index 05948acd529..69c12f0ecc9 100644
--- a/config/scripts/build-orcad.mjs
+++ b/config/scripts/build-orcad.mjs
@@ -9,10 +9,25 @@
*/
import { fork, spawnSync } from 'node:child_process'
import { build } from 'esbuild'
-import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
+import { createHash } from 'node:crypto'
+import {
+ chmodSync,
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync
+} from 'node:fs'
import { arch, platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import process from 'node:process'
+import {
+ ORCAD_VERSION,
+ ORCAD_VERSION_FILENAME,
+ orcadArtifactFilenames
+} from '../../src/shared/orcad-artifacts.ts'
const ROOT = join(import.meta.dirname, '..', '..')
const OUT_DIR = join(ROOT, 'out', 'orcad')
@@ -22,6 +37,11 @@ const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
// looks for it in the app root. A deployment has no desktop out/main to fall back to.
const WATCHER_ENTRY = join(ROOT, 'src/main/ipc/parcel-watcher-process-entry.ts')
const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js')
+// Why beside orcad.js: orcad forks the terminal daemon so PTYs outlive the runtime process,
+// and `getDaemonEntryPath()` probes the app root for this exact filename. Without it every
+// orcad restart would SIGKILL every running terminal.
+const DAEMON_ENTRY = join(ROOT, 'src/main/daemon/daemon-entry.ts')
+const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js')
const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}`
const OUT_FILE = join(OUT_DIR, 'orcad.js')
const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME)
@@ -64,20 +84,31 @@ if (process.platform !== 'win32') {
chmodSync(AGENT_BROWSER_OUTPUT, 0o755)
}
-await build({
- entryPoints: [WATCHER_ENTRY],
- bundle: true,
- platform: 'node',
- target: 'node18',
- format: 'cjs',
- outfile: WATCHER_OUT_FILE,
- external: EXTERNAL,
- plugins: [externalNativeAddons],
- minify: true,
- sourcemap: false,
- define: { 'process.env.NODE_ENV': '"production"' },
- logLevel: 'error'
-})
+/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source
+ * directory under `outdir`, and both children must land flat beside orcad.js — that is where
+ * their runtime resolvers look for them. */
+function buildForkedChild(entryPoint, outfile) {
+ return build({
+ entryPoints: [entryPoint],
+ bundle: true,
+ platform: 'node',
+ target: 'node18',
+ format: 'cjs',
+ outfile,
+ external: EXTERNAL,
+ plugins: [externalNativeAddons],
+ metafile: true,
+ minify: true,
+ sourcemap: false,
+ define: { 'process.env.NODE_ENV': '"production"' },
+ logLevel: 'error'
+ })
+}
+
+const childResults = await Promise.all([
+ buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE),
+ buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE)
+])
const result = await build({
entryPoints: [ENTRY],
@@ -95,29 +126,36 @@ const result = await build({
logLevel: 'error'
})
-const output = Object.values(result.metafile.outputs).find((o) => o.entryPoint)
+const output = Object.values(result.metafile.outputs).find(
+ (o) => o.entryPoint === 'src/main/orcad/main.ts'
+)
// Why check `original` and not just `path`: when electron is bundleable, esbuild
// rewrites `path` to the resolved file under node_modules and the naive check passes
// while the package is very much in the bundle.
-const electronImporters = new Set()
-for (const [file, info] of Object.entries(result.metafile.inputs)) {
- for (const imported of info.imports ?? []) {
- const specifier = imported.original ?? imported.path
- if (specifier === 'electron' || specifier.startsWith('electron/')) {
- electronImporters.add(file)
- }
- }
-}
-const sqliteImporters = new Set()
-for (const [file, info] of Object.entries(result.metafile.inputs)) {
- for (const imported of info.imports ?? []) {
- const specifier = imported.original ?? imported.path
- if (specifier === 'node:sqlite') {
- sqliteImporters.add(file)
+// Why both metafiles: the forked children ship in the same deployment and run under the
+// same plain Node. A daemon-entry that reached electron would fail at fork time, on the
+// path whose whole point is that terminals survive.
+function collectImporters(metafiles, matches) {
+ const importers = new Set()
+ for (const metafile of metafiles) {
+ for (const [file, info] of Object.entries(metafile.inputs)) {
+ for (const imported of info.imports ?? []) {
+ if (matches(imported.original ?? imported.path)) {
+ importers.add(file)
+ }
+ }
}
}
+ return importers
}
+const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)]
+const electronImporters = collectImporters(
+ metafiles,
+ (specifier) => specifier === 'electron' || specifier.startsWith('electron/')
+)
+const sqliteImporters = collectImporters(metafiles, (specifier) => specifier === 'node:sqlite')
+
const graphErrors = []
if (electronImporters.size > 0) {
graphErrors.push(
@@ -147,23 +185,53 @@ if (graphErrors.length > 0) {
// dynamic require, a missing native, a top-level throw. The plain-node-entry-guard
// smoke-loads its entries for exactly this reason, and orcad cannot join that guard
// because it is an esbuild artifact rather than a rollup input.
+ // Why an exit code and not a message match: these bundles are minified onto one line, so
+ // Node's uncaught-exception report echoes that whole line — which contains every string
+ // literal in the bundle. A crash therefore "matches" any expected message, and a textual
+ // assertion passes against a bundle that never loaded.
const smoke = spawnSync(process.execPath, [OUT_FILE, '--orcad-smoke-load-check'], {
encoding: 'utf8',
timeout: 60_000
})
const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}`
- if (
- smoke.error ||
- smoke.signal ||
- !/Unknown argument: --orcad-smoke-load-check/.test(smokeOutput)
- ) {
+ if (smoke.error || smoke.signal || smoke.status !== 0) {
console.error(
`[build-orcad] the bundle did not load under plain Node.\n` +
- `Expected argv rejection, got signal=${smoke.signal ?? 'none'} ` +
+ `Expected a clean load-check exit, got status=${smoke.status ?? 'none'} ` +
+ `signal=${smoke.signal ?? 'none'} ` +
`error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}`
)
process.exitCode = 1
}
+ // Why require + parseArgs and not a real daemon: requiring the bundle evaluates every
+ // top-level import, and calling its exported argv parser proves the entry's own code is
+ // there rather than a graph that merely resolved. Booting one would need a socket, a
+ // token and a PTY — `smoke:orcad-terminal` does that end to end, through orcad.
+ // The verdict is carried by the exit code for the same minification reason as above.
+ const daemonSmoke = spawnSync(
+ process.execPath,
+ [
+ '-e',
+ `const mod = require(${JSON.stringify(DAEMON_OUT_FILE)})\n` +
+ `if (typeof mod.parseArgs !== 'function') { process.exit(3) }\n` +
+ `try { mod.parseArgs([]); process.exit(4) } catch { process.exit(0) }`
+ ],
+ {
+ encoding: 'utf8',
+ timeout: 60_000,
+ env: { ...process.env, ORCA_DAEMON_ENTRY_LOAD_CHECK: '1' }
+ }
+ )
+ const daemonSmokeOutput = `${daemonSmoke.stdout ?? ''}${daemonSmoke.stderr ?? ''}`
+ if (daemonSmoke.error || daemonSmoke.signal || daemonSmoke.status !== 0) {
+ console.error(
+ `[build-orcad] the daemon child did not load under plain Node.\n` +
+ `Expected a clean load check, got status=${daemonSmoke.status ?? 'none'} ` +
+ `signal=${daemonSmoke.signal ?? 'none'} ` +
+ `error=${daemonSmoke.error?.message ?? 'none'}\n${daemonSmokeOutput.slice(0, 2000)}`
+ )
+ process.exitCode = 1
+ }
const watcherFailure = await smokeLoadWatcherChild()
if (watcherFailure) {
console.error(
@@ -173,9 +241,26 @@ if (graphErrors.length > 0) {
}
}
+// Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on
+// this string, so two different builds carrying one version would share a directory — and an
+// already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale
+// bytes while reporting the new version.
if (process.exitCode !== 1) {
+ const hash = createHash('sha256')
+ for (const filename of orcadArtifactFilenames()) {
+ const artifactPath = join(OUT_DIR, filename)
+ if (!existsSync(artifactPath)) {
+ throw new Error(
+ `orcad declares ${filename} in ORCAD_ARTIFACTS but never emitted it. Add the build ` +
+ 'step, or drop it from src/shared/orcad-artifacts.ts.'
+ )
+ }
+ hash.update(readFileSync(artifactPath))
+ }
+ const fullVersion = `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}`
+ writeFileSync(join(OUT_DIR, ORCAD_VERSION_FILENAME), fullVersion)
console.log(
- `[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.`
+ `[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.`
)
}
diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs
index 27c17b3d15c..9d3c9020706 100644
--- a/config/scripts/build-windows-cli-launcher.mjs
+++ b/config/scripts/build-windows-cli-launcher.mjs
@@ -1,41 +1,24 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
-import { existsSync, mkdirSync } from 'node:fs'
+import { existsSync, mkdirSync, statSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
-if (process.platform !== 'win32') {
- // Why: electron-builder treats a skipped native build like success and can
- // continue toward a Windows package whose declared orca.exe does not exist.
- throw new Error(
- 'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
- )
-}
-
-const repoRoot = resolve(import.meta.dirname, '../..')
-const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
-const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
-const compilerPath = findFrameworkCompiler(process.env)
-
-if (!compilerPath) {
- throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
-}
-
-mkdirSync(dirname(outputPath), { recursive: true })
-const result = spawnSync(
- compilerPath,
- ['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
- { cwd: repoRoot, stdio: 'inherit' }
-)
-
-if (result.signal) {
- process.kill(process.pid, result.signal)
-}
-if (result.error) {
- throw result.error
-}
-if (result.status !== 0) {
- process.exit(result.status ?? 1)
+export function shouldReuseCompiledWindowsCliLauncher(
+ outputPath,
+ sourcePath,
+ { reuseCached = false } = {}
+) {
+ if (!existsSync(outputPath)) {
+ return false
+ }
+ // Why reuseCached: Actions cache keys already hash the C# source, but restore
+ // does not preserve mtimes, so a hit would look stale and recompile anyway.
+ if (reuseCached) {
+ return true
+ }
+ return statSync(outputPath).mtimeMs >= statSync(sourcePath).mtimeMs
}
function defaultOutputPath(projectRoot) {
@@ -58,3 +41,47 @@ function readArg(name) {
const index = process.argv.indexOf(name)
return index !== -1 ? process.argv[index + 1] : undefined
}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ if (process.platform !== 'win32') {
+ // Why: electron-builder treats a skipped native build like success and can
+ // continue toward a Windows package whose declared orca.exe does not exist.
+ throw new Error(
+ 'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
+ )
+ }
+
+ const repoRoot = resolve(import.meta.dirname, '../..')
+ const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
+ const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
+ const compilerPath = findFrameworkCompiler(process.env)
+
+ if (!compilerPath) {
+ throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
+ }
+
+ mkdirSync(dirname(outputPath), { recursive: true })
+ if (
+ shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, {
+ reuseCached: process.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER === '1'
+ })
+ ) {
+ console.log(`[native-build] reusing Windows CLI launcher at ${outputPath}`)
+ process.exit(0)
+ }
+ const result = spawnSync(
+ compilerPath,
+ ['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
+ { cwd: repoRoot, stdio: 'inherit' }
+ )
+
+ if (result.signal) {
+ process.kill(process.pid, result.signal)
+ }
+ if (result.error) {
+ throw result.error
+ }
+ if (result.status !== 0) {
+ process.exit(result.status ?? 1)
+ }
+}
diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs
index 4bb219a5687..82332897715 100644
--- a/config/scripts/build-windows-cli-launcher.test.mjs
+++ b/config/scripts/build-windows-cli-launcher.test.mjs
@@ -5,12 +5,15 @@ import {
mkdtempSync,
readFileSync,
rmSync,
+ statSync,
+ utimesSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
+import { shouldReuseCompiledWindowsCliLauncher } from './build-windows-cli-launcher.mjs'
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
@@ -37,6 +40,33 @@ function itWindows(name, test) {
}
describe('Windows CLI launcher', () => {
+ it('reuses a compiled launcher that is at least as new as the C# source', () => {
+ const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-'))
+ try {
+ const sourcePath = join(root, 'OrcaCliLauncher.cs')
+ const outputPath = join(root, '.build', 'orca.exe')
+ mkdirSync(join(root, '.build'))
+ writeFileSync(sourcePath, 'source\n')
+ writeFileSync(outputPath, 'binary\n')
+ const later = new Date(statSync(sourcePath).mtimeMs + 1_000)
+ utimesSync(outputPath, later, later)
+
+ expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(true)
+ writeFileSync(sourcePath, 'changed\n')
+ const sourceLater = new Date(statSync(outputPath).mtimeMs + 1_000)
+ utimesSync(sourcePath, sourceLater, sourceLater)
+ expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(false)
+ expect(
+ shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, { reuseCached: true })
+ ).toBe(true)
+ expect(shouldReuseCompiledWindowsCliLauncher(join(root, 'missing.exe'), sourcePath)).toBe(
+ false
+ )
+ } finally {
+ removeFixtureTree(root)
+ }
+ })
+
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
try {
diff --git a/config/scripts/build-windows-process-tree-relay-addon.mjs b/config/scripts/build-windows-process-tree-relay-addon.mjs
index 01045eadfbf..3e43beb359e 100644
--- a/config/scripts/build-windows-process-tree-relay-addon.mjs
+++ b/config/scripts/build-windows-process-tree-relay-addon.mjs
@@ -26,9 +26,11 @@ import {
mkdirSync,
openSync,
readFileSync,
- readSync
+ readSync,
+ writeFileSync
} from 'node:fs'
-import { join, resolve } from 'node:path'
+import { createRequire } from 'node:module'
+import { dirname, join, resolve } from 'node:path'
import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
const ROOT = resolve(import.meta.dirname, '..', '..')
@@ -67,13 +69,16 @@ function assertPatchApplied() {
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
)
}
- if (!bindingGyp.includes("require.resolve('node-addon-api/node_addon_api.gyp')")) {
+ if (bindingGyp.includes('node_addon_api.gyp')) {
throw new Error(
- 'binding.gyp still uses require("node-addon-api").targets. That path is ' +
- 'cwd-relative and misses node_addon_api.gyp under pnpm on Windows. ' +
+ 'binding.gyp still depends on node_addon_api.gyp. pnpm and node-gyp rewrite that ' +
+ 'project path incorrectly on Windows. ' +
'pnpm did not apply config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
)
}
+ if (!bindingGyp.includes('"include_dirs": ["deps/node-addon-api"]')) {
+ throw new Error('binding.gyp does not use the staged node-addon-api headers.')
+ }
const processCc = readFileSync(join(PACKAGE_DIR, 'src', 'process.cc'), 'utf8')
if (processCc.includes('process_count < 1024')) {
throw new Error(
@@ -83,6 +88,58 @@ function assertPatchApplied() {
}
}
+// pnpm can materialize this CRLF package without applying its patch. Repair the
+// load-bearing build settings before node-gyp so the release build stays safe.
+function applyWindowsProcessTreeBuildFixes() {
+ const bindingPath = join(PACKAGE_DIR, 'binding.gyp')
+ const processPath = join(PACKAGE_DIR, 'src', 'process.cc')
+ const nodeAddonApiDir = dirname(
+ createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')
+ )
+ const stagedHeaderDir = join(PACKAGE_DIR, 'deps', 'node-addon-api')
+ let bindingGyp = readFileSync(bindingPath, 'utf8')
+ let processCc = readFileSync(processPath, 'utf8')
+ const originalBinding = bindingGyp
+ const originalProcess = processCc
+
+ for (const dynamicDependency of [
+ String.raw` {
)
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
const expectedRegressionFiles = [
- 'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
@@ -126,10 +125,14 @@ describe('computer-use e2e workflow', () => {
const job = workflow.jobs['mac-native-owner-smoke']
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
+ const install = job.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
expect(job.if).toBe("github.event_name == 'pull_request'")
expect(job['runs-on']).toBe('macos-15')
expect(checkout.with['persist-credentials']).toBe(false)
+ expect(install.with['native-runtime']).toBe('electron')
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
const cleanupRun = runs.find((run) =>
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs
index b570ac0e7ae..424b671b11e 100644
--- a/config/scripts/ensure-native-runtime.mjs
+++ b/config/scripts/ensure-native-runtime.mjs
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync, readFileSync } from 'node:fs'
import { release } from 'node:os'
-import { basename, resolve } from 'node:path'
+import { basename, dirname, resolve } from 'node:path'
const require = createRequire(import.meta.url)
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
@@ -67,7 +67,12 @@ function ensureNodeRuntime() {
if (!initial.ok) {
printCheckError(initial)
}
- runPnpm(['rebuild', 'node-pty'])
+ const failedModules = initial.failures.map((failure) => failure.moduleName)
+ const rebuildModules = [
+ 'node-pty',
+ ...failedModules.filter((moduleName) => moduleName !== 'node-pty')
+ ]
+ rebuildNodeRuntimeModules(rebuildModules)
verifyNodeRuntimeAfterRebuild()
return
}
@@ -77,7 +82,7 @@ function ensureNodeRuntime() {
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
)
printCheckError(initial)
- runPnpm(['rebuild', ...failedModules])
+ rebuildNodeRuntimeModules(failedModules)
verifyNodeRuntimeAfterRebuild()
}
@@ -312,14 +317,10 @@ function getPatchedNodePtyRebuildReason() {
return null
}
- // Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
- // patch only lands in the source-built build/Release artifacts.
+ // Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix and
+ // Windows patches only land in the source-built build/Release artifacts.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
- const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
- // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
- if (process.platform === 'darwin') {
- artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
- }
+ const artifactPaths = patchedNodePtyArtifactPaths(nodePtyDir)
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
@@ -329,11 +330,24 @@ function getPatchedNodePtyRebuildReason() {
return 'Patched node-pty build artifacts are missing; rebuilding native deps.'
}
-function requiresPatchedNodePtySourceBuild() {
+function patchedNodePtyArtifactPaths(nodePtyDir) {
if (process.platform === 'win32') {
- return false
+ const releaseDir = resolve(nodePtyDir, 'build', 'Release')
+ return [
+ resolve(releaseDir, 'conpty.node'),
+ ...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) => resolve(releaseDir, 'conpty', filename))
+ ]
}
+ const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
+ // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
+ if (process.platform === 'darwin') {
+ artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
+ }
+ return artifactPaths
+}
+
+function requiresPatchedNodePtySourceBuild() {
const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch')
if (!existsSync(nodePtyPatchPath)) {
return false
@@ -351,16 +365,32 @@ function getWindowsBuildNumber() {
return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0
}
-function runPnpm(args) {
+function rebuildNodeRuntimeModules(moduleNames) {
+ for (const moduleName of moduleNames) {
+ const moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
+ console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
+ runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
+ if (moduleName === 'node-pty' && process.platform === 'win32') {
+ runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')])
+ }
+ }
+}
+
+function runPnpm(args, { cwd = projectDir } = {}) {
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
+ const env =
+ process.platform === 'linux' && args.includes('node-gyp')
+ ? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() }
+ : process.env
const result = spawnSync(command, args, {
- cwd: projectDir,
+ cwd,
stdio: 'inherit',
- shell: process.platform === 'win32'
+ shell: process.platform === 'win32',
+ env
})
if (result.error || result.status !== 0) {
- console.error(`[native-runtime] ${command} ${args.join(' ')} failed.`)
+ console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`)
if (result.error) {
console.error(formatError(result.error))
}
diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs
index d1ea104b863..ea6e876e619 100644
--- a/config/scripts/ensure-native-runtime.test.mjs
+++ b/config/scripts/ensure-native-runtime.test.mjs
@@ -29,6 +29,7 @@ describe('ensure-native-runtime', () => {
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeFakeNativeModules(projectDir)
+ writeNodePtyPatchFile(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
@@ -42,7 +43,11 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
const log = readFileSync(logPath, 'utf8')
- expect(log).toContain('pnpm rebuild node-pty\n')
+ expect(log).toContain('pnpm exec node-gyp rebuild\n')
+ expect(log).toContain(join('node_modules', 'node-pty'))
+ if (process.platform === 'linux') {
+ expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m)
+ }
expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/),
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/)
@@ -52,6 +57,41 @@ describe('ensure-native-runtime', () => {
}
})
+ it.skipIf(process.platform !== 'win32')(
+ 'rebuilds other failed Windows addons with patched node-pty',
+ () => {
+ const projectDir = mkTempProject()
+
+ try {
+ const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
+ const logPath = join(projectDir, 'native-runtime.log')
+ const markerPath = join(projectDir, 'rebuilt.marker')
+ const binDir = join(projectDir, 'bin')
+ copyFileSync(sourceScriptPath, scriptPath)
+ writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true })
+ writeNodePtyPatchFile(projectDir)
+ writeFakePnpm(binDir)
+
+ const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
+ cwd: projectDir,
+ encoding: 'utf8',
+ env: envWithPrependedPath(binDir, {
+ ORCA_NATIVE_TEST_LOG: logPath,
+ ORCA_NATIVE_TEST_MARKER: markerPath
+ })
+ })
+
+ expect(result.status, result.stderr).toBe(0)
+ const log = readFileSync(logPath, 'utf8')
+ expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2)
+ expect(log).toContain(join('node_modules', 'node-pty'))
+ expect(log).toContain(join('node_modules', 'windows-native-registry'))
+ } finally {
+ rmSync(projectDir, { recursive: true, force: true })
+ }
+ }
+ )
+
it.skipIf(process.platform === 'win32')(
'rebuilds patched node-pty artifacts even when the Node load check passes',
() => {
@@ -80,7 +120,7 @@ describe('ensure-native-runtime', () => {
expect(result.stderr).toContain(
'Patched node-pty build artifacts are missing; rebuilding native deps.'
)
- expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
+ expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -114,7 +154,7 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active")
- expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
+ expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -148,7 +188,7 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
- expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
+ expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -178,9 +218,15 @@ function envWithPrependedPath(binDir, extraEnv) {
}
}
-function writeFakeNativeModules(projectDir) {
+function writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker = false } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
+ writeFileSync(
+ join(nodePtyDir, 'package.json'),
+ '{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
+ )
+ mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
+ writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
@@ -197,15 +243,29 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
if (!markerExists) {
throw new Error('ABI mismatch sentinel')
}
+ return {
+ dir: '../build/Release/',
+ module: {
+ listJobProcessIds() {},
+ terminateJob() {},
+ assignCurrentProcessToJob() {}
+ }
+ }
}
`
)
- writeFakeWindowsRegistry(projectDir)
+ writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker })
}
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
+ writeFileSync(
+ join(nodePtyDir, 'package.json'),
+ '{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
+ )
+ mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
+ writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
@@ -218,23 +278,40 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
const dir = ${JSON.stringify(nativeDir)} ??
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
- return { dir, module: {} }
+ return {
+ dir,
+ module: {
+ listJobProcessIds: () => [],
+ terminateJob: () => true,
+ assignCurrentProcessToJob: () => true
+ }
+ }
}
`
)
writeFakeWindowsRegistry(projectDir)
}
-function writeFakeWindowsRegistry(projectDir) {
+function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) {
if (process.platform !== 'win32') {
return
}
const registryDir = join(projectDir, 'node_modules', 'windows-native-registry')
mkdirSync(registryDir, { recursive: true })
writeFileSync(
- join(registryDir, 'index.js'),
- 'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n'
+ join(registryDir, 'package.json'),
+ '{"name":"windows-native-registry","version":"3.2.2","main":"index.js"}\n'
)
+ const markerGate = requiresMarker
+ ? `if (!require('node:fs').existsSync(process.env.ORCA_NATIVE_TEST_MARKER)) { throw new Error('registry ABI mismatch sentinel') }`
+ : ''
+ writeFileSync(
+ join(registryDir, 'index.js'),
+ `exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => { ${markerGate}; return {} }\n`
+ )
+ const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
+ mkdirSync(processTreeDir, { recursive: true })
+ writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
}
function writeNodePtyPatchFile(projectDir) {
@@ -245,6 +322,13 @@ function writeNodePtyPatchFile(projectDir) {
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
+ if (process.platform === 'win32') {
+ writeFileSync(join(buildDir, 'conpty.node'), '')
+ mkdirSync(join(buildDir, 'conpty'), { recursive: true })
+ writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
+ writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
+ return
+ }
writeFileSync(join(buildDir, 'pty.node'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
@@ -260,6 +344,15 @@ function writeFakePnpm(binDir) {
const { appendFileSync, writeFileSync } = require('node:fs')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`)
+appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`)
+appendFileSync(
+ process.env.ORCA_NATIVE_TEST_LOG,
+ \`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
+)
+appendFileSync(
+ process.env.ORCA_NATIVE_TEST_LOG,
+ \`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`
+)
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
`
)
diff --git a/config/scripts/install-node-dependencies-action.test.mjs b/config/scripts/install-node-dependencies-action.test.mjs
index d989221c11a..a90747513a1 100644
--- a/config/scripts/install-node-dependencies-action.test.mjs
+++ b/config/scripts/install-node-dependencies-action.test.mjs
@@ -47,6 +47,33 @@ function executeInstallScript(fixture) {
}
describe('install-node-dependencies action', () => {
+ it('skips the lockfile diff when a job container has no Git metadata', () => {
+ const fixture = createFixture()
+ try {
+ rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
+
+ const result = executeInstallScript(fixture)
+ expect(result.status, result.stderr || result.stdout).toBe(0)
+ expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
+ } finally {
+ rmSync(fixture.root, { recursive: true, force: true })
+ }
+ })
+
+ it('skips the lockfile diff for a bare repository', () => {
+ const fixture = createFixture()
+ try {
+ rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
+ expect(run('git', ['init', '--bare', '-q'], { cwd: fixture.workspace }).status).toBe(0)
+
+ const result = executeInstallScript(fixture)
+ expect(result.status, result.stderr || result.stdout).toBe(0)
+ expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
+ } finally {
+ rmSync(fixture.root, { recursive: true, force: true })
+ }
+ })
+
it.each([
['package.json', '{"name":"changed"}\n'],
['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n']
diff --git a/config/scripts/locale-ja-phrase-fixes.mjs b/config/scripts/locale-ja-phrase-fixes.mjs
index 504c5d25889..8c80544eba2 100644
--- a/config/scripts/locale-ja-phrase-fixes.mjs
+++ b/config/scripts/locale-ja-phrase-fixes.mjs
@@ -37,7 +37,13 @@ const RELOCALIZED_GENERIC_TERMS = [
export const JA_PHRASE_FIXES = [
...RELOCALIZED_GENERIC_TERMS,
- { pattern: /エージェント/g, replacement: 'Agent', whenEnIncludes: 'agent' },
+ {
+ pattern: /エージェント/g,
+ replacement: 'Agent',
+ whenEnIncludes: 'agent',
+ // Skills filters and metadata are Japanese UI labels, not agent product prose.
+ skipKeyPrefixes: ['auto.components.skills.']
+ },
{ pattern: /解雇/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
{ pattern: /却下/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
{ pattern: /代理人/g, replacement: 'Agent', whenEnIncludes: 'agent' },
diff --git a/config/scripts/locale-translation-policy.ja-relocalization.test.mjs b/config/scripts/locale-translation-policy.ja-relocalization.test.mjs
index c91909ad69d..26ff48c878c 100644
--- a/config/scripts/locale-translation-policy.ja-relocalization.test.mjs
+++ b/config/scripts/locale-translation-policy.ja-relocalization.test.mjs
@@ -79,6 +79,16 @@ describe('locale-translation-policy ja relocalization', () => {
expect(ja('Open the browser', 'ブラウザーを開く')).toBe('ブラウザを開く')
})
+ it('preserves translated Japanese Skills labels', () => {
+ expect(
+ ja(
+ 'Filter by agent',
+ 'エージェントで絞り込み',
+ 'auto.components.skills.SkillsPage.filterProvider'
+ )
+ ).toBe('エージェントで絞り込み')
+ })
+
it('leaves ranges and token samples out of the ellipsis rule', () => {
expect(ja('Compare main...HEAD', 'main...HEAD を比較')).toBe('main...HEAD を比較')
})
diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs
index eb1a7ea0b92..9fd4350ee45 100644
--- a/config/scripts/locale-translation-policy.mjs
+++ b/config/scripts/locale-translation-policy.mjs
@@ -351,12 +351,15 @@ function phraseFixMatchesEnglish(enValue, fix) {
return enValue.toLowerCase().includes(fix.whenEnIncludes.toLowerCase())
}
-function applyPhraseFixes(enValue, localeValue, locale) {
+function applyPhraseFixes(enValue, localeValue, locale, key = '') {
let result = localeValue
for (const fix of LOCALE_PHRASE_FIXES[locale] ?? []) {
if (!phraseFixMatchesEnglish(enValue, fix)) {
continue
}
+ if (fix.skipKeyPrefixes?.some((prefix) => key.startsWith(prefix))) {
+ continue
+ }
result = result.replace(fix.pattern, fix.replacement)
}
return result
@@ -367,7 +370,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) {
if (keyOverride) {
// Why: exact key overrides can still carry stale MT output, so glossary repairs remain the final gate.
let result = applyBrandMistranslationFixes(enValue, keyOverride, locale, key)
- result = applyPhraseFixes(enValue, result, locale)
+ result = applyPhraseFixes(enValue, result, locale, key)
if (['zh', 'ja', 'ko'].includes(locale)) {
result = applyCjkLatinTermSpacing(result, locale)
}
@@ -377,7 +380,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) {
const valueOverride = LOCALE_VALUE_OVERRIDES[locale]?.[enValue]
if (valueOverride) {
let result = applyBrandMistranslationFixes(enValue, valueOverride, locale, key)
- result = applyPhraseFixes(enValue, result, locale)
+ result = applyPhraseFixes(enValue, result, locale, key)
if (['zh', 'ja', 'ko'].includes(locale)) {
result = applyCjkLatinTermSpacing(result, locale)
}
@@ -398,7 +401,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) {
}
result = applyBrandMistranslationFixes(enValue, result, locale, key)
- result = applyPhraseFixes(enValue, result, locale)
+ result = applyPhraseFixes(enValue, result, locale, key)
if (['zh', 'ja', 'ko'].includes(locale)) {
result = applyCjkLatinTermSpacing(result, locale)
}
diff --git a/config/scripts/node-pty-job-ownership.cjs b/config/scripts/node-pty-job-ownership.cjs
index 35884c1414a..5ad578fd74a 100644
--- a/config/scripts/node-pty-job-ownership.cjs
+++ b/config/scripts/node-pty-job-ownership.cjs
@@ -1,10 +1,6 @@
'use strict'
-const NODE_PTY_JOB_EXPORTS = [
- 'listJobProcessIds',
- 'terminateJob',
- 'assignCurrentProcessToJob'
-]
+const NODE_PTY_JOB_EXPORTS = ['listJobProcessIds', 'terminateJob', 'assignCurrentProcessToJob']
function assertNodePtyJobOwnership({ nativeName, native, platform = process.platform }) {
if (platform !== 'win32' || nativeName !== 'conpty') {
diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs
index ef9745e476c..64b2f96505a 100644
--- a/config/scripts/package-electron-runtime-contract.test.mjs
+++ b/config/scripts/package-electron-runtime-contract.test.mjs
@@ -17,11 +17,11 @@ describe('Electron runtime package contract', () => {
'utf8'
)
- expect(patch).toContain('diff --git a/src/Types.ts b/src/Types.ts')
expect(patch).toContain('readonly clearModelGeneration: number')
expect(patch).toContain('const generation = this._atlas.clearModelGeneration')
expect(patch).toContain('this.clearModelGeneration++')
expect(patch).toContain('this._atlas._clearModelGeneration||0')
+ expect(patch.match(/\^\(\?:\[1-8\]\\d\{2\}\|900\)\$/g)).toHaveLength(3)
})
it('keeps root postinstall as the single Electron binary install owner', () => {
@@ -467,10 +467,11 @@ describe('Electron runtime package contract', () => {
expect(copyStep.run).toContain('git add "$CASK_PATH"')
})
- it('installs the Electron package binary in PR checks without changing native module ABI', () => {
- const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
- const parsedWorkflow = parse(prWorkflow)
- const installStep = parsedWorkflow.jobs.test.steps.find(
+ it('installs the Electron package binary in the shared unit-test workflow', () => {
+ const unitTestWorkflow = parse(
+ readFileSync(join(projectDir, '.github/workflows/unit-tests.yml'), 'utf8')
+ )
+ const installStep = unitTestWorkflow.jobs.test.steps.find(
(step) => step.name === 'Install Electron package binary for tests'
)
diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs
index d19eee52454..c51b1c18a5c 100644
--- a/config/scripts/pr-code-change-scope.mjs
+++ b/config/scripts/pr-code-change-scope.mjs
@@ -16,6 +16,196 @@ const DOCS_ONLY_FILES = new Set([
const DOCS_ONLY_PREFIXES = ['docs/', '.github/ISSUE_TEMPLATE/']
+export const PR_CHECK_JOBS = [
+ 'static_analysis',
+ 'typecheck',
+ 'git_compatibility',
+ 'xterm_patch_sync',
+ 'shell_contracts',
+ 'test',
+ 'orcad_browser',
+ 'cross-version-wire',
+ 'managed_hook_node18',
+ 'package',
+ 'package_windows'
+]
+
+const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test'])
+
+const GLOBAL_FORCE_PREFIXES = [
+ '.github/workflows/pr.yml',
+ '.github/actions/install-node-dependencies/',
+ 'config/scripts/pr-code-change-scope'
+]
+
+const GLOBAL_FORCE_FILES = new Set(['package.json', 'pnpm-lock.yaml'])
+
+const GIT_COMPAT_PREFIXES = [
+ 'src/shared/git-',
+ 'src/shared/review-head-tracking-ref',
+ 'src/main/git/',
+ 'src/relay/git-',
+ 'config/scripts/git-binary-compatibility'
+]
+
+const XTERM_PREFIXES = [
+ 'config/patches/xterm-upstream.json',
+ 'config/patches/@xterm',
+ 'config/patches/xterm-src/',
+ 'config/scripts/regenerate-xterm-patches'
+]
+
+const SHELL_PREFIXES = [
+ 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec',
+ 'src/main/daemon/shell-ready',
+ 'src/main/daemon/daemon-bash-shell-ready',
+ 'src/main/daemon/daemon-shell-ready-wrapper',
+ 'src/main/daemon/node-pty-fd-leak',
+ 'src/main/providers/local-pty-shell-ready',
+ 'src/main/providers/__tests__/shell-ready-framework-example',
+ 'src/main/pty/',
+ 'src/main/shell-templates',
+ 'src/main/shell-startup-',
+ 'src/main/shell-wrapper-',
+ 'src/main/terminal-history-fish',
+ 'src/main/zsh-',
+ 'src/renderer/src/components/terminal-pane/fish-color-scheme',
+ 'src/shared/fish-',
+ 'src/shared/pty-reply-echo-shapes',
+ 'src/shared/startup-shell-portability',
+ 'src/shared/posix-command-path-lookup',
+ 'config/patches/node-pty@',
+ 'config/scripts/ensure-native-runtime',
+ 'config/scripts/node-pty-job-ownership'
+]
+
+const ORCAD_BROWSER_PREFIXES = [
+ 'src/main/orcad/external-chromium-',
+ 'src/main/orcad/orcad-browser-provider',
+ 'src/main/orcad/orcad-agent-browser-binary',
+ 'src/main/orcad/electron-serve-browser-process'
+]
+
+const CROSS_VERSION_WIRE_PREFIXES = [
+ 'tests/e2e/cross-version-wire/',
+ 'src/shared/protocol-version',
+ 'src/shared/terminal-stream-protocol',
+ 'src/shared/browser-client-host-protocol',
+ 'src/shared/browser-network-tunnel-protocol',
+ 'src/shared/browser-client-host-placement',
+ 'src/main/runtime/rpc/dispatcher',
+ 'src/main/runtime/rpc/methods/browser-tab-create-schema',
+ 'src/main/runtime/rpc/methods/terminal',
+ 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer'
+]
+
+const MANAGED_HOOK_PREFIXES = [
+ 'config/scripts/smoke-managed-hook-runtime-node18',
+ 'config/scripts/build-relay',
+ 'src/relay/',
+ 'src/shared/agent-hook',
+ 'src/main/agent-hooks/'
+]
+
+const NATIVE_RUNTIME_PREFIXES = [
+ 'config/scripts/ensure-native-runtime',
+ 'config/scripts/rebuild-native-deps',
+ 'config/scripts/node-pty-job-ownership',
+ 'config/scripts/electron-builder-native-rebuild',
+ 'config/patches/node-pty@',
+ 'config/patches/@vscode__windows-process-tree'
+]
+
+const NATIVE_CACHE_FILES = new Set([
+ 'package.json',
+ 'pnpm-lock.yaml',
+ '.github/actions/install-node-dependencies/action.yml',
+ 'config/scripts/ensure-native-runtime.mjs',
+ 'config/scripts/rebuild-native-deps.mjs'
+])
+
+const NATIVE_CACHE_PREFIXES = [
+ 'config/patches/node-pty@',
+ 'config/patches/@vscode__windows-process-tree'
+]
+
+const SHARED_PACKAGE_PREFIXES = [
+ 'electron.vite.config.ts',
+ 'config/electron-builder',
+ 'config/packaged-runtime',
+ 'config/build-plugins/',
+ 'config/scripts/build-',
+ 'config/scripts/smoke-packaged',
+ 'config/scripts/install-electron-package-binary',
+ 'config/scripts/verify-packaged',
+ 'config/scripts/verify-linux-glibc',
+ 'config/scripts/run-electron-vite',
+ 'skills/',
+ 'skill-guides/',
+ 'resources/build/',
+ 'resources/onboarding/',
+ 'resources/plugins/',
+ 'resources/skills/',
+ ...NATIVE_RUNTIME_PREFIXES
+]
+
+const LINUX_PACKAGE_PREFIXES = [
+ ...SHARED_PACKAGE_PREFIXES,
+ 'native/computer-use-linux/',
+ 'resources/linux/',
+ 'config/scripts/run-headless-serve'
+]
+
+const WINDOWS_PACKAGE_PREFIXES = [
+ ...SHARED_PACKAGE_PREFIXES,
+ 'native/windows-cli-launcher/',
+ 'native/computer-use-windows/',
+ 'resources/win32/',
+ 'config/scripts/build-windows-cli-launcher',
+ 'config/scripts/windows-pty-native-capability',
+ 'tests/tools/windows-pty-native-capability-smoke/'
+]
+
+const LINUX_PACKAGE_TESTS = [
+ 'src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts',
+ 'src/main/browser/browser-route-tcp-egress.electron.test.ts',
+ 'src/main/browser/browser-route-webrtc-egress.electron.test.ts',
+ 'src/main/browser/browser-route-h3-egress.electron.test.ts',
+ 'src/main/browser/browser-route-dns-prefetch.electron.test.ts'
+]
+
+const WINDOWS_PACKAGE_TESTS = [
+ ...LINUX_PACKAGE_TESTS,
+ 'config/scripts/rebuild-native-deps.test.mjs',
+ 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts',
+ 'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts',
+ 'src/shared/child-process/windows-command-line.win32.test.ts',
+ 'src/main/agent-hooks/windows-hook-payload-delivery.test.ts',
+ 'src/main/windows/windows-pty-job.win32.test.ts',
+ 'src/main/windows/windows-host-job.win32.test.ts',
+ 'src/main/wsl/wsl-runner.test.ts',
+ 'src/main/wsl/wsl-guest-environment.test.ts',
+ 'src/main/wsl/wsl-invocation-boundary.test.ts',
+ 'src/main/wsl/wsl-executable-path.win32.test.ts',
+ 'src/main/wsl/wsl-w1-w3-contract.test.ts',
+ 'src/shared/source-scan/source-tree-scan.test.ts',
+ 'src/main/cli/wsl-cli-powershell-boundary.test.ts',
+ 'src/main/cursor/hook-service.test.ts',
+ 'src/main/orca-profiles/profile-index-store.test.ts',
+ 'src/main/runtime/repo-worktree-admin-fingerprint.test.ts',
+ 'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts',
+ 'src/shared/secure-file-fsync-flags.test.ts',
+ 'src/main/ipc/pty-codex-account-attribution.test.ts',
+ 'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts'
+]
+
+const DESKTOP_IRRELEVANT_PREFIXES = [
+ 'mobile/',
+ '.github/workflows/mobile.yml',
+ '.github/workflows/mobile-ios-release.yml',
+ '.github/workflows/mobile-android-release.yml'
+]
+
export function isDocsOnlyPath(file) {
if (DOCS_ONLY_FILES.has(file)) {
return true
@@ -32,10 +222,91 @@ export function shouldRunPrChecks(changedFiles) {
if (changedFiles.length === 0) {
return true
}
- return changedFiles.some((file) => !isDocsOnlyPath(file))
+ return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file))
+}
+
+export function classifyPrJobs(changedFiles) {
+ const emptyDiff = changedFiles.length === 0
+ const shouldRun = shouldRunPrChecks(changedFiles)
+ const forceAll = emptyDiff || changedFiles.some(isGlobalForcePath)
+ const jobs = Object.fromEntries(
+ PR_CHECK_JOBS.map((job) => [
+ job,
+ shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles))
+ ])
+ )
+ return {
+ should_run: shouldRun,
+ native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)),
+ ...jobs
+ }
+}
+
+function jobDetector(job) {
+ switch (job) {
+ case 'git_compatibility':
+ return (files) => files.some((file) => matchesPrefix(file, GIT_COMPAT_PREFIXES))
+ case 'xterm_patch_sync':
+ return (files) => files.some((file) => matchesPrefix(file, XTERM_PREFIXES))
+ case 'shell_contracts':
+ return (files) => files.some((file) => matchesPrefix(file, SHELL_PREFIXES))
+ case 'orcad_browser':
+ return (files) => files.some((file) => matchesPrefix(file, ORCAD_BROWSER_PREFIXES))
+ case 'cross-version-wire':
+ return (files) => files.some((file) => matchesPrefix(file, CROSS_VERSION_WIRE_PREFIXES))
+ case 'managed_hook_node18':
+ return (files) => files.some((file) => matchesPrefix(file, MANAGED_HOOK_PREFIXES))
+ case 'package':
+ return (files) => files.some(isLinuxPackagePath)
+ case 'package_windows':
+ return (files) => files.some(isWindowsPackagePath)
+ default:
+ return () => true
+ }
+}
+
+function isLinuxPackagePath(file) {
+ return LINUX_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, LINUX_PACKAGE_PREFIXES)
+}
+
+function isWindowsPackagePath(file) {
+ return WINDOWS_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, WINDOWS_PACKAGE_PREFIXES)
+}
+
+function isProductBundlePath(file, extraPrefixes) {
+ if (isTestFile(file)) {
+ return false
+ }
+ if (file.startsWith('src/')) {
+ return true
+ }
+ return matchesPrefix(file, extraPrefixes)
+}
+
+function isTestFile(file) {
+ return /\.(?:test|spec)\.(?:js|cjs|mjs|ts|tsx)$/.test(file) || file.includes('/__tests__/')
+}
+
+function isDesktopIrrelevantPath(file) {
+ return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES)
+}
+
+function isNativeCacheInputPath(file) {
+ return NATIVE_CACHE_FILES.has(file) || matchesPrefix(file, NATIVE_CACHE_PREFIXES)
+}
+
+function isGlobalForcePath(file) {
+ return GLOBAL_FORCE_FILES.has(file) || matchesPrefix(file, GLOBAL_FORCE_PREFIXES)
+}
+
+function matchesPrefix(file, prefixes) {
+ return prefixes.some((prefix) => file === prefix || file.startsWith(prefix))
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const files = readFileSync(0, 'utf8').split('\n').filter(Boolean)
- process.stdout.write(shouldRunPrChecks(files) ? 'true\n' : 'false\n')
+ const classification = classifyPrJobs(files)
+ for (const [name, value] of Object.entries(classification)) {
+ process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`)
+ }
}
diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs
index f71d6c42934..ea83796e6cb 100644
--- a/config/scripts/pr-code-change-scope.test.mjs
+++ b/config/scripts/pr-code-change-scope.test.mjs
@@ -1,13 +1,18 @@
+import { spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
-import { isDocsOnlyPath, shouldRunPrChecks } from './pr-code-change-scope.mjs'
+import {
+ classifyPrJobs,
+ isDocsOnlyPath,
+ PR_CHECK_JOBS,
+ shouldRunPrChecks
+} from './pr-code-change-scope.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
-const gatedIf = "needs.code_paths.outputs.should_run == 'true'"
const expensiveJobs = [
'static_analysis',
'typecheck',
@@ -15,12 +20,34 @@ const expensiveJobs = [
'xterm_patch_sync',
'shell_contracts',
'test',
+ 'orcad_browser',
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
]
+const ALWAYS_ON = ['static_analysis', 'typecheck', 'test']
+
+function expectedJobs(overrides, { alwaysOn = true } = {}) {
+ return Object.fromEntries(
+ PR_CHECK_JOBS.map((job) => [
+ job,
+ (alwaysOn && ALWAYS_ON.includes(job)) || Boolean(overrides[job])
+ ])
+ )
+}
+
+function expectClassification(files, overrides) {
+ const result = classifyPrJobs(files)
+ const shouldRun = shouldRunPrChecks(files)
+ expect(result.should_run).toBe(shouldRun)
+ expect(result).toMatchObject({
+ should_run: shouldRun,
+ ...expectedJobs(overrides, { alwaysOn: shouldRun })
+ })
+}
+
describe('docs-only path classification', () => {
it('treats the WeChat README PR files as docs-only', () => {
expect(
@@ -54,9 +81,170 @@ describe('docs-only path classification', () => {
it('runs PR Checks when the diff is empty rather than skipping by accident', () => {
expect(shouldRunPrChecks([])).toBe(true)
})
+
+ it('does not start desktop PR Checks for mobile-only diffs', () => {
+ expect(shouldRunPrChecks(['mobile/src/App.tsx', 'mobile/package.json'])).toBe(false)
+ })
})
-describe('PR Checks docs-only skip wiring', () => {
+describe('per-job path classification', () => {
+ it('runs every expensive job on an empty diff rather than skipping by accident', () => {
+ const result = classifyPrJobs([])
+ expect(result.should_run).toBe(true)
+ for (const job of PR_CHECK_JOBS) {
+ expect(result[job], job).toBe(true)
+ }
+ })
+
+ it('skips every expensive job for docs-only diffs', () => {
+ expectClassification(['README.md', 'docs/readme/README.zh-CN.md'], {})
+ })
+
+ it('runs packaging and always-on jobs for product source, not git/xterm/shell lanes', () => {
+ expectClassification(['src/renderer/src/components/tab-bar/TabBar.tsx'], {
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs Git compatibility only when git capability inputs change', () => {
+ expectClassification(['src/shared/git-capability-cache.ts'], {
+ git_compatibility: true,
+ package: true,
+ package_windows: true
+ })
+ expectClassification(['src/shared/git-binary-compatibility.test.ts'], {
+ git_compatibility: true
+ })
+ })
+
+ it('runs xterm patch sync only when xterm inputs change', () => {
+ expectClassification(['config/patches/xterm-upstream.json'], {
+ xterm_patch_sync: true
+ })
+ expectClassification(['config/patches/@xterm__xterm@6.1.0-beta.287.patch'], {
+ xterm_patch_sync: true
+ })
+ })
+
+ it('runs native package jobs only for the platform that ships the changed native', () => {
+ expectClassification(['native/windows-cli-launcher/OrcaCliLauncher.cs'], {
+ package_windows: true
+ })
+ expectClassification(['native/computer-use-linux/runtime.py'], {
+ package: true
+ })
+ expectClassification(['native/computer-use-macos/Package.swift'], {})
+ })
+
+ it('runs shell contracts when live-shell inputs change', () => {
+ expectClassification(['src/main/daemon/shell-ready.ts'], {
+ shell_contracts: true,
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs shell contracts when wrapper templates or live-shell fixtures change', () => {
+ expectClassification(['src/main/shell-templates.ts'], {
+ shell_contracts: true,
+ package: true,
+ package_windows: true
+ })
+ expectClassification(['src/main/shell-startup-launch-intent-fixtures.ts'], {
+ shell_contracts: true,
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs orcad browser when Chrome launch, session, or tab modules change', () => {
+ for (const file of [
+ 'src/main/orcad/external-chromium-browser-session.ts',
+ 'src/main/orcad/external-chromium-command-arguments.ts',
+ 'src/main/orcad/external-chromium-tab-registry.ts',
+ 'src/main/orcad/external-chromium-tab-projection.ts'
+ ]) {
+ expectClassification([file], {
+ orcad_browser: true,
+ package: true,
+ package_windows: true
+ })
+ }
+ expectClassification(['src/main/orcad/orcad-native-preflight.ts'], {
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs cross-version wire checks for every working-tree wire module', () => {
+ for (const file of [
+ 'src/shared/protocol-version.ts',
+ 'src/shared/terminal-stream-protocol.ts',
+ 'src/main/runtime/rpc/dispatcher.ts',
+ 'src/main/runtime/rpc/methods/browser-tab-create-schema.ts',
+ 'src/main/runtime/rpc/methods/terminal.ts',
+ 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts'
+ ]) {
+ expectClassification([file], {
+ 'cross-version-wire': true,
+ package: true,
+ package_windows: true
+ })
+ }
+ expectClassification(
+ ['tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts'],
+ { 'cross-version-wire': true }
+ )
+ })
+
+ it('runs workflow-self-change and lockfile diffs as force-all', () => {
+ const result = classifyPrJobs(['.github/workflows/pr.yml'])
+ expect(result.should_run).toBe(true)
+ for (const job of PR_CHECK_JOBS) {
+ expect(result[job], job).toBe(true)
+ }
+ expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true)
+ })
+
+ it('primes native caches only when their immutable inputs change', () => {
+ expect(classifyPrJobs([]).native_cache_changed).toBe(true)
+ expect(classifyPrJobs(['README.md']).native_cache_changed).toBe(false)
+ expect(classifyPrJobs(['src/main/index.ts']).native_cache_changed).toBe(false)
+ for (const file of [
+ 'package.json',
+ 'pnpm-lock.yaml',
+ '.github/actions/install-node-dependencies/action.yml',
+ 'config/scripts/ensure-native-runtime.mjs',
+ 'config/scripts/rebuild-native-deps.mjs',
+ 'config/patches/node-pty@1.1.0.patch'
+ ]) {
+ expect(classifyPrJobs([file]).native_cache_changed, file).toBe(true)
+ }
+ })
+
+ it('keeps unit-test-only diffs out of packaging', () => {
+ expectClassification(['src/main/git/git-status.test.ts'], {
+ git_compatibility: true
+ })
+ })
+
+ it('emits GitHub output pairs from the shipped CLI', () => {
+ const result = spawnSync(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], {
+ cwd: projectDir,
+ encoding: 'utf8',
+ input: 'config/patches/xterm-upstream.json\n'
+ })
+ expect(result.status, result.stderr).toBe(0)
+ expect(result.stdout).toContain('should_run=true\n')
+ expect(result.stdout).toContain('xterm_patch_sync=true\n')
+ expect(result.stdout).toContain('git_compatibility=false\n')
+ expect(result.stdout).toContain('package=false\n')
+ expect(result.stdout).toContain('test=true\n')
+ })
+})
+
+describe('PR Checks skip wiring', () => {
it('classifies the PR range with a tested script and expands renames', () => {
const classify = prWorkflow.jobs.code_paths.steps.find(
(step) => step.name === 'Classify changed paths'
@@ -65,9 +253,12 @@ describe('PR Checks docs-only skip wiring', () => {
expect(classify.run).toContain('--no-renames')
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
- expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe(
- '${{ steps.filter.outputs.should_run }}'
- )
+ expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
+ for (const jobName of ['should_run', 'native_cache_changed', ...expensiveJobs]) {
+ expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
+ `\${{ steps.filter.outputs.${jobName} }}`
+ )
+ }
})
it('keeps the cheap root-directory guard on docs-only PRs', () => {
@@ -75,11 +266,26 @@ describe('PR Checks docs-only skip wiring', () => {
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
})
- it('skips expensive jobs unless the detector says the PR has code', () => {
- for (const jobName of expensiveJobs) {
+ it('gates each expensive job on its classifier and cache prerequisite', () => {
+ for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) {
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths'])
- expect(prWorkflow.jobs[jobName].if, jobName).toBe(gatedIf)
+ expect(prWorkflow.jobs[jobName].if, jobName).toBe(
+ `needs.code_paths.outputs.${jobName} == 'true'`
+ )
}
+ expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'test_native_cache'])
+ expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'")
+ expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'success'")
+ expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'skipped'")
+ expect(prWorkflow.jobs.test_native_cache.needs).toEqual(['code_paths'])
+ expect(prWorkflow.jobs.test_native_cache.if).toBe(
+ "needs.code_paths.outputs.native_cache_changed == 'true'"
+ )
+ expect(prWorkflow.jobs.test_native_cache.strategy).toBeUndefined()
+ const primerInstall = prWorkflow.jobs.test_native_cache.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
+ expect(primerInstall.with['node-version']).toBe('24')
})
it('skips e2e detection on docs-only PRs without dropping the draft gate', () => {
@@ -89,19 +295,23 @@ describe('PR Checks docs-only skip wiring', () => {
)
})
- it('lets verify pass when expensive jobs are skipped for docs-only PRs', () => {
+ it('lets verify pass skipped jobs the classifier turned off', () => {
const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(prWorkflow.jobs.verify.needs[0]).toBe('code_paths')
expect(verifyStep.env.SHOULD_RUN).toBe('${{ needs.code_paths.outputs.should_run }}')
- expect(verifyStep.run).toContain('"$SHOULD_RUN" != "true"')
- const docsOnlyBranch = verifyStep.run.slice(
- 0,
- verifyStep.run.indexOf('# Require success when the PR has code-relevant changes')
- )
- expect(docsOnlyBranch).toContain('if [ "$result" != "skipped" ]')
- expect(docsOnlyBranch).not.toContain('"$result" != "success"')
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
+ expect(verifyStep.run).toContain('# Require success when the PR has code-relevant changes')
+ expect(verifyStep.run).toContain('expected skipped')
+ expect(verifyStep.run).toContain('expected success')
+ for (const job of prWorkflow.jobs.verify.needs) {
+ if (job === 'code_paths' || job === 'root_directory_guard') {
+ continue
+ }
+ const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN`
+ expect(verifyStep.env[envVar]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
+ expect(verifyStep.run).toContain(`"$${envVar}"`)
+ }
})
})
diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs
index 2c1243d04fa..8cd622b300c 100644
--- a/config/scripts/pr-e2e-gate-contract.test.mjs
+++ b/config/scripts/pr-e2e-gate-contract.test.mjs
@@ -1,9 +1,14 @@
-import { existsSync, readFileSync } from 'node:fs'
+import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parse as parseJsonc } from 'jsonc-parser'
import { describe, expect, it } from 'vitest'
import { parse as parseYaml } from 'yaml'
-import { PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
+import {
+ hasSshSourceChange,
+ PR_E2E_SOURCE_ROUTES,
+ selectPrE2eSpecs,
+ SSH_SOURCE_ROUTE_IDS
+} from './pr-e2e-source-routing.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
@@ -100,17 +105,19 @@ describe('PR E2E gate contract', () => {
// job without adding it to the strict loop fails here instead of silently
// leaving that job unenforced. This is what caught GIT_COMPATIBILITY and
// SHELL_CONTRACTS being absent from an earlier hardcoded list.
- // Why lastIndexOf: the docs-only branch has its own loop that allows skipped.
const successMarker = '# Require success when the PR has code-relevant changes'
- const successLoop = verifyStep.run.slice(
- verifyStep.run.indexOf(successMarker),
- verifyStep.run.lastIndexOf('done')
- )
+ const successLoop = verifyStep.run.slice(verifyStep.run.indexOf(successMarker))
expect(successLoop.length).toBeGreaterThan(0)
+ expect(verifyStep.run).toContain('"$CODE_PATHS" != "success"')
+ expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
for (const job of prWorkflow.jobs.verify.needs) {
- const envVar = job.toUpperCase()
+ const envVar = job.replaceAll('-', '_').toUpperCase()
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
+ if (job === 'code_paths' || job === 'root_directory_guard') {
+ continue
+ }
expect(successLoop).toContain(`"$${envVar}"`)
+ expect(verifyStep.env[`${envVar}_SHOULD_RUN`]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
}
})
@@ -157,6 +164,18 @@ describe('PR E2E gate contract', () => {
expect(sshDockerRunner).toContain("'electron-headful'")
})
+ it('reuses the composite install action instead of duplicating pnpm setup', () => {
+ const installFor = (jobName) =>
+ e2eWorkflow.jobs[jobName].steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
+
+ expect(installFor('build').with['native-runtime']).toBe('node')
+ for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
+ expect(installFor(jobName).with['native-runtime'], jobName).toBe('electron')
+ }
+ })
+
it('installs zsh in every Linux lane that can run paired startup readiness', () => {
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
@@ -195,6 +214,8 @@ describe('PR E2E gate contract', () => {
'src/main/providers/ssh-',
'src/main/ipc/pty',
'src/relay/',
+ 'src/shared/ssh-',
+ 'src/renderer/src/store/slices/direct-ssh-',
'src/renderer/src/components/terminal-pane/remote-runtime-'
]
for (const authority of sshSourceAuthorities) {
@@ -203,10 +224,24 @@ describe('PR E2E gate contract', () => {
)
}
+ // Why named files rather than prefixes: these seams are single modules, and a prefix
+ // here would route their unrelated neighbours.
+ for (const file of [
+ 'src/main/runtime/public-ssh-state.ts',
+ 'src/renderer/src/startup/ssh-startup-reconnect.ts',
+ 'src/renderer/src/store/slices/ssh.ts'
+ ]) {
+ expect(selectPrE2eSpecs([file]), file).toContain(
+ 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts'
+ )
+ }
+
const mappedSpecs = [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
+ 'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
+ 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
]
@@ -235,6 +270,140 @@ describe('PR E2E gate contract', () => {
expect(changedInstall.run).toContain('openssh-client')
})
+ it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => {
+ // Why by name: none of these carry "ssh", so the SSH authorities above never reach them
+ // — a closed-tab tombstone and a dropped default-tabs marker both shipped through it.
+ for (const file of [
+ 'src/renderer/src/hooks/remote-workspace-session-merge.ts',
+ 'src/main/ipc/remote-workspace-snapshot-normalization.ts',
+ 'src/renderer/src/lib/worktree-initial-terminal-seeding.ts',
+ 'src/renderer/src/lib/worktree-default-terminal-tabs.ts',
+ 'src/shared/remote-workspace-session-projection.ts',
+ 'src/renderer/src/components/terminal/initial-terminal.ts'
+ ]) {
+ const specs = selectPrE2eSpecs([file])
+ expect(specs, file).toContain('tests/e2e/ssh-cold-activation-restore.spec.ts')
+ expect(specs, file).toContain('tests/e2e/ssh-reconnect-tab-destruction.spec.ts')
+ }
+
+ expect(
+ selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-session-merge.test.ts'])
+ ).toEqual([])
+ })
+
+ it('triggers the Docker-SSH lane from SSH source, not from a spec name', () => {
+ // The behavioural half of the invariant, and the part that actually matters: an SSH source
+ // edit is recognised as one, through the same routes that select the specs.
+ for (const file of [
+ 'src/main/ssh/connection.ts',
+ 'src/relay/pty-handler.ts',
+ 'src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts',
+ 'src/renderer/src/hooks/remote-workspace-session-merge.ts',
+ 'src/main/ipc/remote-workspace-snapshot-normalization.ts'
+ ]) {
+ expect(hasSshSourceChange([file]), file).toBe(true)
+ }
+ for (const file of [
+ 'src/main/git/git-status.ts',
+ 'src/renderer/src/components/tab-bar/BrowserTab.tsx',
+ 'src/main/ssh/connection.test.ts'
+ ]) {
+ expect(hasSshSourceChange([file]), file).toBe(false)
+ }
+
+ // Why: the signal must stay derived from the routes. A route id that no longer exists would
+ // silently narrow it to nothing.
+ for (const id of SSH_SOURCE_ROUTE_IDS) {
+ expect(
+ PR_E2E_SOURCE_ROUTES.map((route) => route.id),
+ id
+ ).toContain(id)
+ }
+
+ // Why text and not structure: a job `if:` is only ever available as a string. The strongest
+ // available assertion is that the source signal is its own disjunct, so the lane no longer
+ // depends on a spec name surviving in a route's spec list.
+ const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if
+ expect(sshLaneCondition).toContain("inputs.ssh_source_changed == 'true' ||")
+
+ expect(e2eWorkflow.on.workflow_call.inputs.ssh_source_changed.type).toBe('string')
+ expect(prWorkflow.jobs['e2e-paths'].outputs.ssh_source_changed).toBe(
+ '${{ steps.filter.outputs.ssh_source_changed }}'
+ )
+ expect(prWorkflow.jobs.e2e.with.ssh_source_changed).toBe(
+ '${{ needs.e2e-paths.outputs.ssh_source_changed }}'
+ )
+ expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --ssh-source')
+ expect(filterStep.run).toContain('ssh_source_changed=$SSH_SOURCE_CHANGED')
+ })
+
+ it('gives every Docker-gated SSH spec a lane that runs it', () => {
+ // Why this shape: the sharded lanes set no ORCA_E2E_SSH_DOCKER, so a Docker-gated spec
+ // that no runner names runs nowhere and still reports green — the silent skip this file
+ // exists to prevent. Asserting reachability rather than a literal keeps that true when
+ // the lanes move.
+ // Why these two are exempt: each needs something CI cannot give it, recorded in
+ // run-ssh-docker-e2e.mjs so the gap stays legible rather than looking like coverage.
+ const unreachableSpecs = new Set([
+ 'tests/e2e/ssh-docker-relay-perf.spec.ts',
+ 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
+ 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts'
+ ])
+ // Why comments are stripped: this file's own runner lists the two exempt specs by name in a
+ // prose comment. A substring scan over raw text would count any spec merely *discussed* in a
+ // runner as claimed by it -- the silent skip this assertion exists to catch, re-entering
+ // through the documentation.
+ const stripComments = (text) =>
+ text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
+ const laneRunners = [
+ 'run-ssh-docker-e2e.mjs',
+ 'run-ssh-docker-watcher-isolation-e2e.mjs',
+ 'run-ssh-docker-terminal-parking-e2e.mjs'
+ ].map((file) => stripComments(readFileSync(join(projectDir, 'config/scripts', file), 'utf8')))
+
+ // Why a comparison and not the bare name: preview and demo specs cite the flag in a
+ // "how to run me" comment without gating on it. Why a regex rather than one literal: an
+ // equally-valid spelling (double quotes, or a `!==` guard) would escape a fixed-string scan
+ // and the spec would silently leave the contract.
+ const dockerGateExpression = /ORCA_E2E_SSH_DOCKER\s*[!=]==\s*['"]1['"]/
+ const dockerGatedSpecs = readdirSync(join(projectDir, 'tests/e2e'))
+ .filter((file) => file.endsWith('.spec.ts'))
+ .map((file) => `tests/e2e/${file}`)
+ .filter((spec) => dockerGateExpression.test(readFileSync(join(projectDir, spec), 'utf8')))
+ expect(dockerGatedSpecs.length).toBeGreaterThan(0)
+
+ const unclaimed = dockerGatedSpecs.filter(
+ (spec) => !unreachableSpecs.has(spec) && !laneRunners.some((runner) => runner.includes(spec))
+ )
+ expect(
+ unclaimed,
+ `Docker-gated specs claimed by no lane runner: ${unclaimed.join(', ')}`
+ ).toEqual([])
+
+ // Why: an exemption that outlives its spec would quietly excuse a real gap.
+ for (const spec of unreachableSpecs) {
+ expect(dockerGatedSpecs, spec).toContain(spec)
+ // Why also assert absence from every runner: `unreachableSpecs` short-circuits the
+ // unclaimed check above, so a spec could be documented as exempt while a runner still
+ // invokes it -- an exemption that reads as coverage removal but changes nothing, and a
+ // lane that stays red for a reason the file says it excluded.
+ for (const runner of laneRunners) {
+ expect(runner.includes(spec), `${spec} is exempt but still invoked by a lane runner`).toBe(
+ false
+ )
+ }
+ }
+
+ const laneStep = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].steps.find(
+ (step) => step.name === 'Run remaining Docker SSH E2E'
+ )
+ expect(laneStep.run).toContain('test:e2e:ssh-docker')
+ // Why: the added serial tests, several budgeting 4-10 minutes each, do not fit the old 35.
+ expect(
+ e2eWorkflow.jobs['ssh-docker-watcher-isolation']['timeout-minutes']
+ ).toBeGreaterThanOrEqual(60)
+ })
+
it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => {
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs
index 04f570f0094..108a4d5ab10 100644
--- a/config/scripts/pr-e2e-source-routing.mjs
+++ b/config/scripts/pr-e2e-source-routing.mjs
@@ -18,12 +18,32 @@ export const PR_E2E_SOURCE_ROUTES = [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
+ 'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
+ 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
],
+ // Why the store/startup/shared additions: the SSH-named authorities stop at the main
+ // process and the pane component, but the reconnect ledgers and retained-payload
+ // admission that decide whether a pane rebinds live in the renderer store.
matches: (file) =>
isProductSource(file) &&
- /^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/relay\/|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
+ /^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/main\/runtime\/(?:public-ssh-state|ssh-file-explorer-chunk-read)\.ts|src\/relay\/|src\/shared\/(?:ssh-|skill-ssh-relay-contract)|src\/renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts|src\/renderer\/src\/store\/slices\/(?:ssh|direct-ssh-)|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
+ file
+ )
+ },
+ {
+ // Why a sibling route rather than more paths on ssh-terminal-source: these modules carry
+ // no "ssh" in their names, and only the two restore specs gate them. Folding them in
+ // would run the whole SSH terminal list for a tab-tombstone edit.
+ id: 'ssh-workspace-session-restore',
+ specs: [
+ 'tests/e2e/ssh-cold-activation-restore.spec.ts',
+ 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts'
+ ],
+ matches: (file) =>
+ isProductSource(file) &&
+ /^(?:src\/main\/ipc\/remote-workspace|src\/shared\/remote-workspace-|src\/renderer\/src\/hooks\/remote-workspace-|src\/renderer\/src\/lib\/worktree-(?:initial-terminal-seeding|default-terminal-tabs)\.ts|src\/renderer\/src\/components\/terminal\/initial-terminal)/.test(
file
)
},
@@ -131,6 +151,18 @@ export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) {
return [...specs].sort((left, right) => left.localeCompare(right))
}
+/** Routes whose authorities are SSH execution source, and so require the Docker-SSH lane. */
+export const SSH_SOURCE_ROUTE_IDS = ['ssh-terminal-source', 'ssh-workspace-session-restore']
+
+// Why derive this from the routes instead of a second path list: the Docker-SSH lane used to
+// trigger only because one route happened to list a startup-readiness spec, so pruning that
+// spec would have silently retired the lane. Two lists that must agree is how that drifted.
+export function hasSshSourceChange(changedPaths) {
+ return PR_E2E_SOURCE_ROUTES.filter((route) => SSH_SOURCE_ROUTE_IDS.includes(route.id)).some(
+ (route) => changedPaths.some(route.matches)
+ )
+}
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
let input = ''
process.stdin.setEncoding('utf8')
@@ -138,6 +170,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
input += chunk
}
const changedPaths = input.split(/\r?\n/).filter(Boolean)
- const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
- process.stdout.write(`${JSON.stringify(specs)}\n`)
+ if (process.argv.includes('--ssh-source')) {
+ process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`)
+ } else {
+ const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
+ process.stdout.write(`${JSON.stringify(specs)}\n`)
+ }
}
diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs
index 42540f6bcb4..29f532b9436 100644
--- a/config/scripts/pr-workflow-parallelism.test.mjs
+++ b/config/scripts/pr-workflow-parallelism.test.mjs
@@ -3,6 +3,8 @@ import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
+const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8'))
+const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8'))
const dependencyAction = parse(
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
)
@@ -47,22 +49,35 @@ describe('PR workflow parallelism', () => {
expect(workflow.permissions).toEqual({ contents: 'read' })
})
- it('shards the general test suite across Node 24 and Node 26', () => {
- expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
- expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
- Array.from({ length: 16 }, (_, index) => index + 1)
+ it('runs Node 24 on PRs and the same eight-shard suite on Node 26 daily', () => {
+ const sharedTest = unitTestWorkflow.jobs.test
+ const testStep = sharedTest.steps.find((step) => step.name === 'Test shard')
+ const installStep = sharedTest.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
)
- expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
- const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
- const installStep = workflow.jobs.test.steps.find(
+ const primerInstall = workflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
+ expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
+ expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24'])
+ expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
+ expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['26'])
+ expect(nodeNextWorkflow.on.schedule).toHaveLength(1)
+ expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull()
+ expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
+ expect(sharedTest.strategy.matrix.shard).toEqual(
+ Array.from({ length: 8 }, (_, index) => index + 1)
+ )
+ expect(sharedTest.strategy.matrix.shard_total).toEqual([8])
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
for (const testFile of nativeShellContractFiles) {
expect(testStep.run).toContain(`--exclude=${testFile}`)
}
+ expect(primerInstall.with['native-runtime']).toBe('node')
+ expect(primerInstall.with['node-version']).toBe('24')
+ expect(workflow.jobs.test.needs).toContain('test_native_cache')
})
it('runs real-shell coverage once outside the general shards', () => {
@@ -208,6 +223,8 @@ describe('PR workflow parallelism', () => {
expect(pnpmIndex).toBeLessThan(nodeIndex)
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
+ const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1]
+ expect(steps[pnpmIndex].with.version).toBe(packageManagerVersion)
expect(steps[nodeIndex].with.cache).toBe('pnpm')
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
@@ -232,22 +249,24 @@ describe('PR workflow parallelism', () => {
workflow.jobs[jobName].steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
+ const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
- for (const jobName of [
- 'static_analysis',
- 'typecheck',
- 'git_compatibility',
- 'xterm_patch_sync'
- ]) {
+ for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
+ expect(installFor('static_analysis').with['native-runtime']).toBe('node')
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
- expect(installFor('test').with['native-runtime']).toBe('node')
+ expect(sharedTestInstall.with['native-runtime']).toBe('node')
expect(installFor('package').with['native-runtime']).toBe('electron')
+ expect(installFor('package_windows').with['native-runtime']).toBe('node')
+ expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
+ expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true')
expect(
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
- ).toBe("inputs.native-runtime != 'none'")
+ ).toBe("runner.os == 'Linux' && inputs.native-runtime != 'none'")
const dependencyInstall = dependencyAction.runs.steps.find(
(step) => step.name === 'Install dependencies'
)
@@ -295,14 +314,42 @@ describe('PR workflow parallelism', () => {
// overwritten and one after the rebuild would never save a hit.
expect(installIndex).toBeLessThan(cacheIndex)
expect(cacheIndex).toBeLessThan(prepareIndex)
- expect(steps[cacheIndex].if).toBe("inputs.native-runtime != 'none'")
+ expect(steps[cacheIndex].if).toBe(
+ "inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'"
+ )
+ const restoreOnly = steps.find(
+ (step) => step.name === 'Restore compiled native modules without saving'
+ )
+ expect(restoreOnly.if).toBe(
+ "inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'"
+ )
+ expect(restoreOnly.uses).toBe('actions/cache/restore@v5')
// Native artifacts are ABI-bound: a key missing either dimension serves a build
// that cannot load, and ensure-native-runtime would recompile it anyway.
- expect(steps[cacheIndex].with.key).toContain('${{ inputs.native-runtime }}')
- expect(steps[cacheIndex].with.key).toContain('steps.requested-node.outputs.node-version')
- expect(steps[cacheIndex].with.key).toContain('config/patches/node-pty@1.1.0.patch')
- // No restore-keys: a partial-match key is exactly the ABI-mismatched build above.
- expect(steps[cacheIndex].with['restore-keys']).toBeUndefined()
+ for (const cacheStep of [steps[cacheIndex], restoreOnly]) {
+ expect(cacheStep.with.key).toContain('${{ inputs.native-runtime }}')
+ expect(cacheStep.with.key).toContain('${{ runner.os }}')
+ expect(cacheStep.with.key).toContain('${{ runner.arch }}')
+ expect(cacheStep.with.key).toContain('steps.requested-node.outputs.node-version')
+ expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope')
+ expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch')
+ expect(cacheStep.with.key).toContain(
+ 'config/patches/@vscode__windows-process-tree@0.8.0.patch'
+ )
+ expect(cacheStep.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
+ expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
+ expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
+ expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build')
+ expect(cacheStep.with.path).toContain('windows-native-registry@')
+ expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@')
+ expect(cacheStep.with['restore-keys']).toBeUndefined()
+ }
+ const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope')
+ expect(cacheScope.if).toBe("inputs.native-runtime != 'none'")
+ expect(cacheScope.run).toContain('/etc/os-release')
+ expect(dependencyAction.outputs['native-cache-scope'].value).toBe(
+ '${{ steps.native-cache-scope.outputs.scope }}'
+ )
})
it('reuses TypeScript incremental state across typecheck runs', () => {
@@ -346,6 +393,7 @@ describe('PR workflow parallelism', () => {
'shell_contracts',
'test',
'orcad_browser',
+ 'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
@@ -359,5 +407,7 @@ describe('PR workflow parallelism', () => {
// ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it.
expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}')
expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
+ expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}')
+ expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"')
})
})
diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs
index 78e25d3634c..4eca4bac33a 100644
--- a/config/scripts/rebuild-native-deps.mjs
+++ b/config/scripts/rebuild-native-deps.mjs
@@ -401,14 +401,23 @@ function getPatchedNodePtyRebuildReason() {
return null
}
- // Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can
- // load successfully in Electron while missing the patched fd/error handling.
+ // Why: Orca patches node-pty's native Unix spawn path and Windows job-object
+ // exports; upstream prebuilds can load while missing those patches.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
- const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
- // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
- if (process.platform === 'darwin') {
- artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
- }
+ const artifactPaths =
+ rebuildPlatform === 'win32'
+ ? [
+ resolve(nodePtyDir, 'build', 'Release', 'conpty.node'),
+ ...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) =>
+ resolve(nodePtyDir, 'build', 'Release', 'conpty', filename)
+ )
+ ]
+ : [
+ resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
+ ...(osPlatform() === 'darwin'
+ ? [resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')]
+ : [])
+ ]
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
@@ -422,9 +431,6 @@ function requiresPatchedNodePtySourceBuild() {
if (!onlyModules.includes('node-pty')) {
return false
}
- if (rebuildPlatform === 'win32') {
- return false
- }
if (rebuildPlatform !== osPlatform() || rebuildArch !== process.arch) {
return false
}
diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs
index b158ee57f21..5ebc39dfcaa 100644
--- a/config/scripts/rebuild-native-deps.test.mjs
+++ b/config/scripts/rebuild-native-deps.test.mjs
@@ -586,6 +586,13 @@ function writeNodePtyPatchFile(projectDir) {
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
+ if (process.platform === 'win32') {
+ writeFileSync(join(buildDir, 'conpty.node'), '')
+ mkdirSync(join(buildDir, 'conpty'), { recursive: true })
+ writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
+ writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
+ return
+ }
writeFileSync(join(buildDir, 'pty.node'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
diff --git a/config/scripts/regenerate-xterm-patches.test.mjs b/config/scripts/regenerate-xterm-patches.test.mjs
index 50c309f21b0..ac9f9a7ca52 100644
--- a/config/scripts/regenerate-xterm-patches.test.mjs
+++ b/config/scripts/regenerate-xterm-patches.test.mjs
@@ -434,6 +434,20 @@ describe('check-mode reporting', () => {
// These run without network or a build, so ordinary `pnpm test` catches the two
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
describe('committed xterm patch artifacts', () => {
+ it('normalizes Chromium font-weight serialization in every WebGL runtime copy', async () => {
+ const patch = await readFile(
+ path.join(REPO_ROOT, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
+ 'utf8'
+ )
+ expect(patch).not.toMatch(/desiredWeight !== '400'|orcaProbeWeight!=="400"/)
+ expect(
+ patch.match(/(?:actualWeightToken === 'normal'|orcaActualWeightToken==="normal")/g)
+ ).toHaveLength(3)
+ expect(
+ patch.match(/(?:actualWeight !== desiredWeight|orcaActualWeight!==orcaProbeWeight)/g)
+ ).toHaveLength(3)
+ })
+
it('records the lockfile hash pnpm derives from the patch file', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
const lockfile = await readFile(path.join(REPO_ROOT, 'pnpm-lock.yaml'), 'utf8')
diff --git a/config/scripts/release-e2e-dispatch-contract.test.mjs b/config/scripts/release-e2e-dispatch-contract.test.mjs
index 18fe76c4c44..d48c7b3fa30 100644
--- a/config/scripts/release-e2e-dispatch-contract.test.mjs
+++ b/config/scripts/release-e2e-dispatch-contract.test.mjs
@@ -10,6 +10,17 @@ const releaseWorkflow = parse(
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
describe('release E2E dispatch contract', () => {
+ it('validates immutable tags with the current golden test harness', () => {
+ const restoreStep = releaseWorkflow.jobs['terminal-rendering-golden'].steps.find(
+ (step) => step.name === 'Restore golden test harness from the workflow ref'
+ )
+
+ expect(restoreStep.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
+ expect(restoreStep.run).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
+ expect(restoreStep.run).toContain('golden-source-control-open-diff.spec.ts')
+ expect(restoreStep.run).toContain('golden-terminal-file-link.spec.ts')
+ })
+
it('dispatches tag-scoped E2E only after publication', () => {
const dispatchJob = releaseWorkflow.jobs['post-release-e2e']
const dispatchStep = dispatchJob.steps.find((step) => step.name === 'Dispatch tag-scoped E2E')
diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs
new file mode 100644
index 00000000000..a723a9a6ad0
--- /dev/null
+++ b/config/scripts/run-ssh-docker-e2e.mjs
@@ -0,0 +1,95 @@
+import { spawnSync } from 'node:child_process'
+
+const rawExtraArgs = process.argv.slice(2)
+const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
+const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
+const env = {
+ ...process.env,
+ ORCA_E2E_SSH_DOCKER: '1',
+ ORCA_E2E_WEB_CLIENT: '1'
+}
+
+// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
+const spawnOptions = {
+ stdio: 'inherit',
+ env,
+ shell: process.platform === 'win32'
+}
+
+const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
+
+if (runtime.status !== 0) {
+ process.exit(runtime.status ?? 1)
+}
+
+// Why one explicit list: these specs self-skip without ORCA_E2E_SSH_DOCKER and no sharded lane
+// sets it, so a spec in no runner runs nowhere. The gate contract proves every flag-reading
+// spec is claimed here, by the watcher-isolation or parking runner, or by a listed exclusion.
+//
+// Deliberately absent, and therefore still covered by no CI trigger:
+// ssh-docker-relay-perf.spec.ts — wall-clock latency thresholds; flaky budgets here would
+// cost the lane its credibility. NOTE: a runner script test:e2e:ssh-docker-perf exists in
+// package.json but NO workflow invokes it, so this spec currently runs in no CI lane at
+// all. Recorded as a real gap, not as coverage living somewhere else.
+// ssh-codex-display-artifacts-repro.spec.ts — installs a real remote codex binary that CI
+// runners do not have (observed as `spawn codex ENOENT`). Runs in no CI lane at all.
+// ssh-docker-bulk-open-freeze-repro.spec.ts — two reasons, both disqualifying:
+// (a) it is a perf oracle, not a correctness one: SOFT_FREEZE_LAG_MS=2500 /
+// HARD_FREEZE_LAG_MS=5000 measured by a renderer lag probe under a deliberate
+// 5-pane output flood on a 420s budget. Same rule as ssh-docker-relay-perf above.
+// (b) it is ROTTED: four call sites are out of date against terminal.ts's current
+// helpers — execInTerminal gained a ptyId parameter and splitActiveTerminalPane
+// gained a direction, so it cannot compile, let alone pass. Repairing it needs two
+// semantic decisions (which ptyId to capture, which split direction) that change
+// what the repro measures. Tracked in stablyai/orca#16764.
+//
+// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project
+// grep-inverts away.
+//
+// Known gaps in SSH e2e coverage, recorded here because nothing else names them:
+// - The job that runs this is still called `ssh-docker-watcher-isolation`, though watcher
+// isolation is now one spec of many. Renaming it changes the GitHub check name and can
+// break required-check config, so the name understates the job on purpose.
+// - E2E does not gate merges: `verify.needs` in pr.yml omits `e2e` while the suite is red on
+// main. Nothing in this lane blocks a PR yet. pr.yml's Require-successful-checks comment
+// has the exact wiring to flip it, and the gate contract asserts the current state.
+// - Five specs and one unit test are gated on env vars no workflow sets, so they run nowhere
+// and are not Docker-gated, which puts them outside this file's contract:
+// local-ssh-browser-routing (ORCA_E2E_LOCAL_SSH_BROWSER)
+// ssh-client-hosted-browser-drop-reconnect (ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER)
+// nested-runtime-ssh-lifecycle, nested-runtime-ssh-routing (ORCA_E2E_NESTED_RUNTIME_SSH)
+// ssh-localhost (ORCA_E2E_SSH_LOCALHOST)
+// ssh-browser-network-execution-route.docker.unit.test.ts (ORCA_RUN_DOCKER_SSH_BROWSER_E2E)
+// Runner scripts for the first four sit unused in package.json; no workflow calls them.
+const result = spawnSync(
+ pnpm,
+ [
+ 'exec',
+ 'playwright',
+ 'test',
+ 'tests/e2e/pty-input-write-queue-ssh.spec.ts',
+ 'tests/e2e/ssh-ai-vault-session-history.spec.ts',
+ 'tests/e2e/ssh-cold-activation-restore.spec.ts',
+ 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
+ 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
+ 'tests/e2e/ssh-external-image-preview.spec.ts',
+ 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts',
+ 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts',
+ 'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
+ 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
+ 'tests/e2e/ssh-restart-tab-accumulation.spec.ts',
+ 'tests/e2e/ssh-skill-installation.spec.ts',
+ 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts',
+ '--config',
+ 'tests/playwright.config.ts',
+ '--project',
+ 'electron-headless',
+ '--project',
+ 'electron-headful',
+ '--workers=1',
+ ...extraArgs
+ ],
+ spawnOptions
+)
+
+process.exit(result.status ?? 1)
diff --git a/config/scripts/skill-sharing-release-workflow.test.mjs b/config/scripts/skill-sharing-release-workflow.test.mjs
index d2b6adcf7d4..b6611a1aa18 100644
--- a/config/scripts/skill-sharing-release-workflow.test.mjs
+++ b/config/scripts/skill-sharing-release-workflow.test.mjs
@@ -44,6 +44,48 @@ describe('skill-sharing release workflow', () => {
expect(command).toContain('src/shared/skill-bundle-install-contract.test.ts')
})
+ it('installs the archive tool required by Electron on the Linux floor', () => {
+ const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
+ const prerequisites = stepNamed(linux, 'Install Ubuntu 20.04 prerequisites')
+
+ expect(prerequisites.run).toMatch(/apt-get install[^\n]*\bunzip\b/)
+ })
+
+ it('trusts only the checked-out workspace before container git operations', () => {
+ const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
+ const trustWorkspace = stepNamed(linux, 'Trust the checked-out workspace in the job container')
+ const restoreHarness = stepNamed(
+ linux,
+ 'Restore skill-sharing test harness from the workflow ref'
+ )
+ const safeDirectoryCommands = linux.steps
+ .filter((step) => typeof step.run === 'string' && step.run.includes('safe.directory'))
+ .map((step) => step.run)
+
+ expect(trustWorkspace.run).toBe('git config --global --add safe.directory "$GITHUB_WORKSPACE"')
+ expect(linux.steps.indexOf(trustWorkspace)).toBeLessThan(linux.steps.indexOf(restoreHarness))
+ expect(safeDirectoryCommands).toEqual([
+ 'git config --global --add safe.directory "$GITHUB_WORKSPACE"'
+ ])
+ })
+
+ it('validates immutable tags with the current skill-sharing test harness', () => {
+ for (const jobName of [
+ 'skill-sharing-release-gate',
+ 'skill-sharing-linux-floor-release-gate'
+ ]) {
+ const restore = stepNamed(
+ workflow.jobs[jobName],
+ 'Restore skill-sharing test harness from the workflow ref'
+ )
+
+ expect(restore.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
+ expect(restore.run).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
+ expect(restore.run).toContain('skill-freshness-inventory.test.ts')
+ expect(restore.run).toContain('skill-provider-runtime-roots.test.ts')
+ }
+ })
+
it('archives bounded machine-readable evidence from every platform', () => {
for (const jobName of [
'skill-sharing-release-gate',
diff --git a/config/scripts/skills-cli-package-workflow.test.mjs b/config/scripts/skills-cli-package-workflow.test.mjs
index 489437dd61f..07f7beb46ac 100644
--- a/config/scripts/skills-cli-package-workflow.test.mjs
+++ b/config/scripts/skills-cli-package-workflow.test.mjs
@@ -14,6 +14,7 @@ describe('packaged skills CLI PR gates', () => {
expect(job['runs-on']).toBe('windows-2022')
expect(buildStep.run).toBe('pnpm run build:release:parallel')
+ expect(buildStep.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.run).toContain('electron-builder')
expect(packageStep.run).toContain('--dir')
diff --git a/config/scripts/windows-process-tree-gyp-path.test.mjs b/config/scripts/windows-process-tree-gyp-path.test.mjs
index 5a3aa465737..e2e650384ec 100644
--- a/config/scripts/windows-process-tree-gyp-path.test.mjs
+++ b/config/scripts/windows-process-tree-gyp-path.test.mjs
@@ -9,26 +9,30 @@ const PATCH = readFileSync(
'utf8'
)
const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
-const ABSOLUTE_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
+const RESOLVED_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
describe('windows-process-tree node-addon-api gyp path', () => {
- it('keeps the gyp project path absolute so pnpm Windows source builds find it', () => {
- expect(PATCH).toContain(
- `+ " {
+ expect(PATCH).not.toContain('+ "../../node-addon-api')
+ expect(PATCH).toContain('+ "include_dirs": ["deps/node-addon-api"],')
+ expect(PATCH).toContain('+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],')
+ const buildScript = readFileSync(
+ join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
+ 'utf8'
)
- const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
- expect(bindingGyp).toContain(ABSOLUTE_GYP)
- expect(bindingGyp).not.toContain("require('node-addon-api').targets")
- expect(
- readFileSync(
- join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
- 'utf8'
- )
- ).toContain(ABSOLUTE_GYP)
+ expect(buildScript).toContain(
+ "for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h'])"
+ )
+ expect(buildScript).toContain("import { createRequire } from 'node:module'")
+ expect(buildScript).toContain("import { dirname, join, resolve } from 'node:path'")
+ expect(buildScript).toContain(
+ "createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')"
+ )
+ expect(buildScript).toContain('Repaired un-applied pnpm patch hunks before build.')
})
it('resolves node_addon_api.gyp to a real file from the package directory', () => {
- const resolved = execFileSync(process.execPath, ['-p', ABSOLUTE_GYP], {
+ const resolved = execFileSync(process.execPath, ['-p', RESOLVED_GYP], {
cwd: PACKAGE_DIR,
encoding: 'utf8'
}).trim()
diff --git a/config/scripts/windows-pty-native-capability-workflow.test.mjs b/config/scripts/windows-pty-native-capability-workflow.test.mjs
index 9a6b8b285cf..e1742a43bb0 100644
--- a/config/scripts/windows-pty-native-capability-workflow.test.mjs
+++ b/config/scripts/windows-pty-native-capability-workflow.test.mjs
@@ -28,8 +28,14 @@ describe('packaged Windows PTY native capability routing', () => {
it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => {
const job = workflow.jobs.package_windows
- const sourceRebuild = job.steps.find(
- (step) => step.name === 'Rebuild node-pty from patched source'
+ const install = job.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
+ const nodeCacheSave = job.steps.find(
+ (step) => step.name === 'Save compiled Node native modules'
+ )
+ const electronCache = job.steps.find(
+ (step) => step.name === 'Restore compiled Electron native modules'
)
const build = job.steps.find((step) => step.name === 'Build package inputs')
const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
@@ -37,10 +43,22 @@ describe('packaged Windows PTY native capability routing', () => {
const verify = workflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
+ const ensureNativeRuntime = readFileSync('config/scripts/ensure-native-runtime.mjs', 'utf8')
- expect(sourceRebuild.env.npm_config_build_from_source).toBe('true')
- expect(sourceRebuild.run).toBe('pnpm rebuild node-pty')
+ expect(install.with['native-runtime']).toBe('node')
+ expect(install.with['persist-native-cache']).toBe('false')
+ expect(nodeCacheSave.uses).toBe('actions/cache/save@v5')
+ expect(nodeCacheSave.with.key).toContain('-node-node')
+ expect(electronCache.with.key).toContain('-electron-node')
+ for (const cache of [nodeCacheSave, electronCache]) {
+ expect(cache.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
+ expect(cache.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
+ expect(cache.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
+ }
+ expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']")
+ expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')")
expect(build.run).toBe('pnpm run build:release:parallel')
+ expect(build.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
expect(workflow.jobs.verify.needs).toContain('package_windows')
diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json
index eb278257159..cec4e80cff5 100644
--- a/config/tsconfig.cli.json
+++ b/config/tsconfig.cli.json
@@ -5,6 +5,7 @@
"../src/shared/**/*",
"../src/main/agent-state-file-reader.ts",
"../src/main/agent-hooks/hook-stdin-contract.ts",
+ "../src/main/agent-hooks/hook-post-command.ts",
"../src/main/agent-hooks/hook-config-write-path.ts",
"../src/main/agent-hooks/hooks-json-read.ts",
"../src/main/agent-hooks/installer-utils.ts",
@@ -82,6 +83,8 @@
"../src/main/copilot/copilot-managed-script.ts",
"../src/main/copilot/copilot-remote-hook-install.ts",
"../src/main/copilot/hook-service.ts",
+ "../src/main/cursor/hook-events.ts",
+ "../src/main/cursor/hook-script.ts",
"../src/main/cursor/hook-service.ts",
"../src/main/droid/hook-service.ts",
"../src/main/gemini/hook-service.ts",
diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md
index 06be8eda659..e21aaa89228 100644
--- a/docs/STYLEGUIDE.md
+++ b/docs/STYLEGUIDE.md
@@ -137,20 +137,20 @@ Browse `src/renderer/src/components/ui/` for the full list. Most wrap a Radix UI
When a control has multiple plausible primitives, use this fork:
-| You want… | Reach for | Don't use |
-| ------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------- |
-| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
-| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
-| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
-| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
-| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
-| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
-| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
-| Single choice from a known list | `Select` | Custom listbox |
-| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
-| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
-| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
-| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
+| You want… | Reach for | Don't use |
+| ------------------------------------------------------------ | ------------------------------------------ | ------------------------------------- |
+| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
+| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
+| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
+| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
+| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
+| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
+| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
+| Single choice from a known list | `Select` | Custom listbox |
+| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
+| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
+| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
+| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
If you find yourself styling around a primitive (`` to act like a `
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
+- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md
index b015c26449a..c9bb161fe8e 100644
--- a/docs/readme/README.fr.md
+++ b/docs/readme/README.fr.md
@@ -40,7 +40,7 @@
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
-[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android :** [Télécharger l'APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
+- **Android :** [Télécharger l'APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index 3b1992448fe..cc589be1cc5 100644
--- a/docs/readme/README.ja.md
+++ b/docs/readme/README.ja.md
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
+- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index 6e53e590463..1de67052fcb 100644
--- a/docs/readme/README.ko.md
+++ b/docs/readme/README.ko.md
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [APK 0.0.44 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
+- **Android:** [APK 0.0.46 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
---
diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md
index 9f86dc2cfa3..970fcf6c8a0 100644
--- a/docs/readme/README.pt.md
+++ b/docs/readme/README.pt.md
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
-[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [Baixar APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
+- **Android:** [Baixar APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index 0c619ea260d..2e11413aa2a 100644
--- a/docs/readme/README.zh-CN.md
+++ b/docs/readme/README.zh-CN.md
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
+- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md
index 0e07c06ebf3..dc3fdf31da0 100644
--- a/docs/reference/headless-linux-server.md
+++ b/docs/reference/headless-linux-server.md
@@ -17,23 +17,59 @@ differ on other Debian-derived releases.
## Ubuntu and Debian prerequisites
-Install the AppImage runtime dependency and Xvfb:
+Install the CLI tools, Xvfb, and the shared libraries Electron links against.
+A minimal server or container image ships none of the Electron libraries, and
+`orca serve` then fails before Electron starts:
```bash
sudo apt-get update
-sudo apt-get install -y curl file jq xvfb zlib1g-dev
+sudo apt-get install -y \
+ curl file jq xvfb zlib1g-dev ca-certificates git \
+ libgtk-3-0t64 libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 libgbm1 libasound2t64 \
+ libxtst6 libcups2t64 libdrm2 libxkbcommon0 libpango-1.0-0 libcairo2 libatspi2.0-0t64 \
+ libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libxrender1 libx11-xcb1 \
+ libxcb-dri3-0 libxss1
```
-On Ubuntu 22.04, install `libfuse2` to execute the AppImage through FUSE. On
-Ubuntu 24.04 and Debian, the equivalent package may be `libfuse2t64`. FUSE is
+That command is for Ubuntu 24.04 and newer and Debian 13 and newer. Those
+releases carried out the 64-bit `time_t` transition, which renamed six of the
+packages with a `t64` suffix. On Ubuntu 20.04, Ubuntu 22.04, and Debian 12,
+substitute the unsuffixed names:
+
+- `libgtk-3-0t64` becomes `libgtk-3-0`
+- `libatk1.0-0t64` becomes `libatk1.0-0`
+- `libatk-bridge2.0-0t64` becomes `libatk-bridge2.0-0`
+- `libasound2t64` becomes `libasound2`
+- `libcups2t64` becomes `libcups2`
+- `libatspi2.0-0t64` becomes `libatspi2.0-0`
+
+The other names are identical on every supported release. The substitution is not
+symmetric, so use the list that matches the release. A `t64` name on Ubuntu 20.04,
+Ubuntu 22.04, or Debian 12 fails immediately with `E: Unable to locate package
+libgtk-3-0t64`. In the other direction the old names mostly still resolve, because
+each renamed package declares `Provides:` its unsuffixed name — except `libasound2`
+on Ubuntu 24.04, where `liboss4-salsa-asound2` in `universe` claims that name too.
+apt will not choose between two providers and exits with `E: Package 'libasound2'
+has no installation candidate`, which aborts the entire install line and leaves none
+of the libraries installed.
+
+On Ubuntu 20.04 and 22.04, install `libfuse2` to execute the AppImage through
+FUSE. On Ubuntu 24.04 and Debian 13 the package is `libfuse2t64`, though the plain
+`libfuse2` name also resolves there because nothing else provides it. FUSE is
optional: without it, use the AppImage's supported extraction path:
```bash
cd /opt/orca
./orca-linux.AppImage --appimage-extract
+sudo chmod -R a+rX /opt/orca/squashfs-root
/opt/orca/squashfs-root/AppRun serve --port 6768
```
+The `chmod` is required whenever the extraction runs as a different user than
+the server: `--appimage-extract` creates `squashfs-root` as `drwx------` owned by
+the extracting user, so anyone else — including a dedicated service user — cannot
+even traverse it, and the run fails before Electron starts.
+
Docker commonly has no FUSE device. Use `--appimage-extract` once or
`--appimage-extract-and-run`; neither requires a privileged container. The
extract-and-run wrapper can print extracted paths before Orca starts, so
@@ -144,8 +180,15 @@ AppImage, but must not be able to replace it or the rollback artifacts.
sudo useradd --system --create-home --shell /usr/sbin/nologin orca
sudo chown root:root /opt/orca /opt/orca/orca-linux.AppImage
sudo chmod 755 /opt/orca /opt/orca/orca-linux.AppImage
+# Only if you ran --appimage-extract: extraction leaves squashfs-root root-only.
+sudo chmod -R a+rX /opt/orca/squashfs-root
```
+The last line matters because the two halves of this guide combine badly without
+it. `--appimage-extract` writes `squashfs-root` as `drwx------ root root`, so the
+`orca` service user cannot read or traverse the extracted tree and the unit fails
+at startup. `chmod 755 /opt/orca` alone does not reach into it.
+
For most hosts, one `orca serve` service is enough because Orca starts Xvfb on
display `:99` when no display exists:
@@ -834,11 +877,12 @@ refuse to run there and print the command to run on the machine you want.
- GPU or DRI warnings on a VPS: keep `LIBGL_ALWAYS_SOFTWARE=1` in the service
environment.
- Chromium sandbox errors: confirm the service is running as the non-root
- `orca` user and that `/opt/orca` is readable by that user.
+ `orca` user and that `/opt/orca` is readable by that user, including
+ `/opt/orca/squashfs-root` if you extracted the AppImage.
- Clients cannot connect: make sure `--pairing-address` is an address reachable
from the client, and make sure firewalls allow the selected `--port`.
- Journal shows `Another Orca instance is already running for this userData
- profile` and the unit exits `3`: another process already owns the profile, so
+profile` and the unit exits `3`: another process already owns the profile, so
`RestartPreventExitStatus=3` leaves the unit `failed` on purpose. Find the
owner with `systemctl status orca-serve` and `pgrep -af orca`. Stop it (or
keep it and leave the unit down), then run
@@ -857,4 +901,7 @@ refuse to run there and print the command to run on the machine you want.
`sudo systemctl reset-failed orca-serve.service` first.
- Diagnosing other missing libraries: extract the AppImage without launching it
with `./orca-linux.AppImage --appimage-extract`, then run
- `ldd squashfs-root/orca` to list any shared libraries the host is missing.
+ `ldd squashfs-root/orca-ide` to list any shared libraries the host is missing.
+ The Electron binary is `orca-ide`, not `orca`; `ldd` on a path that does not
+ exist prints nothing and exits cleanly, which reads as a clean result in
+ exactly the situation where you are hunting a missing library.
diff --git a/docs/reference/linux-glibc-compatibility.md b/docs/reference/linux-glibc-compatibility.md
index 60d855c7372..a11506235fe 100644
--- a/docs/reference/linux-glibc-compatibility.md
+++ b/docs/reference/linux-glibc-compatibility.md
@@ -25,11 +25,11 @@ broke launch on Ubuntu 20.04 ([#9902](https://github.com/stablyai/orca/issues/99
The specific trap is glibc's 2.32–2.34 "libpthread/libutil merge", which moved
several long-stable functions into libc under brand-new symbol versions:
-| Symbol | New version | node-pty use |
-| ----------------- | ------------- | ----------------------- |
-| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
-| `openpty` | `GLIBC_2.34` | allocate the pty |
-| `forkpty` | `GLIBC_2.34` | fork the shell |
+| Symbol | New version | node-pty use |
+| ----------------- | ------------ | ----------------------- |
+| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
+| `openpty` | `GLIBC_2.34` | allocate the pty |
+| `forkpty` | `GLIBC_2.34` | fork the shell |
Electron itself (glibc 2.25) and the other bundled native modules
(`sherpa-onnx`, `@parcel/watcher`, both prebuilt on old glibc) stay well under
@@ -82,6 +82,32 @@ libstdc++ floor — its glibc needs are still checked. Speech-to-text therefore
needs a host with libstdc++ from GCC 11+ (Ubuntu 21.10 / 22.04 LTS or newer); the
app itself still launches on stock 20.04.
+**3. Check before loading, on hosts that ship without a compiler (`orcad`).**
+The two gates above protect the packaged desktop app, where the binary is built and
+verified by the same pipeline. `orcad` is deployed to hosts Orca never built on, so it
+adds a runtime precondition
+([`src/main/orcad/node-pty-precondition.ts`](../../src/main/orcad/node-pty-precondition.ts)),
+run from `main.ts` before anything requires `node-pty`. It loads the addon in a **child
+process**, so a binary the loader refuses — or one that aborts outright — is data rather
+than this process's death, and the operator gets a sentence naming the host's libc, its
+Node ABI, its prebuild slot and the command to run. A proven-unloadable binary exits 78
+(`EX_CONFIG`) instead of reaching the `require`; a probe that never answered is reported
+as unverifiable and boots anyway, because a silent probe is not evidence. Whatever it
+finds is published in `status.get`'s `degradations[]` under `terminal_unavailable`.
+
+**4. Ship the binary, built from patched sources.**
+[`config/scripts/build-orcad-prebuilds.mjs`](../../config/scripts/build-orcad-prebuilds.mjs)
+(`pnpm run build:orcad-prebuilds`, after `build:orcad`) compiles node-pty for the current
+host and files it under `out/orcad/prebuilds//`, where a slot is
+`linux-{x64,arm64}-{glibc,musl}` or `darwin-{x64,arm64}`. libc is part of the slot name
+because node-pty's own loader falls back to `prebuilds/-` and cannot tell
+glibc from musl — a glibc binary parked there is loaded on Alpine and dies at `dlopen`.
+The script refuses to compile a tree where `config/patches/node-pty@1.1.0.patch` is not
+applied: without the patch the prebuilt is a #9902 crash shipped as an artifact rather
+than a first-connect error. CI runs it once per slot inside the matching container
+(`--slot=` forces the label), merges the trees, and `--require-slots` fails a release with
+a hole in the matrix.
+
## Adding or upgrading a native dependency
- Prefer packages that ship prebuilt binaries compiled against an old toolchain
diff --git a/docs/reference/macos-press-and-hold.md b/docs/reference/macos-press-and-hold.md
new file mode 100644
index 00000000000..946a1ff7125
--- /dev/null
+++ b/docs/reference/macos-press-and-hold.md
@@ -0,0 +1,58 @@
+# macOS press-and-hold and key repeat
+
+macOS opens the accent picker when a key is held unless an application opts out in its preferences
+domain. That prevents held keys from repeating in terminal applications such as vim. On the first
+eligible launch, Orca writes:
+
+```sh
+defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool false
+```
+
+The write is scoped to Orca's packaged bundle domain. Bare Electron development bundles and
+non-macOS platforms are left untouched. A fresh write is conservatively treated as taking effect
+on the next launch.
+
+## Precedence and decision record
+
+Orca checks for an explicit domain value before writing. Either `true` or `false` is treated as a
+user choice and preserved. Only an unset key receives the `false` default.
+
+The decision is stored once in
+`/macos-press-and-hold-default.json`. An `applied` or
+`kept-user-preference` decision prevents future launches from touching the domain again.
+Probe and write failures remain retryable so a transient failure does not permanently disable the
+fix.
+
+`defaults read ` is used instead of
+`systemPreferences.getUserDefault`: the Electron API cannot distinguish an unset key from an
+explicit `false`. Only the missing-key exit status is interpreted as unset; spawn failures,
+timeouts, and other exit statuses leave the preference alone.
+
+## Restoring the accent picker
+
+Set the preference explicitly, then restart Orca:
+
+```sh
+defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true
+```
+
+After Orca has recorded its one-time decision, deleting the key also restores the macOS default
+without Orca recreating it:
+
+```sh
+defaults delete com.stablyai.orca ApplePressAndHoldEnabled
+```
+
+Development and prerelease channels may use a channel-suffixed Orca bundle identifier; use that
+domain instead when applicable.
+
+## Reverting
+
+Deleting the startup code is not enough. AppKit reads the persisted preference, so a code revert
+must also arrange to delete the key for users who ran an affected build.
+
+## Test coverage
+
+Unit tests cover platform guards, explicit-value preservation, retry behavior, domain ownership,
+record persistence, and subprocess exit interpretation on CI. A macOS-only test additionally pins
+the real `defaults(1)` behavior, but current PR CI does not execute tests on macOS.
diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md
new file mode 100644
index 00000000000..bbde9829514
--- /dev/null
+++ b/docs/reference/orcad-operations.md
@@ -0,0 +1,191 @@
+# Running orcad
+
+`orcad` is the Orca runtime served from plain Node. This is the contract between it and
+whatever supervises it: what it binds, what it owns on disk, who restarts what, and what its
+readiness payload actually proves.
+
+Design background: `docs/design/shipping-orcad.html` §00c and §04.
+
+## Two long-lived processes, not one
+
+A deployment is **orcad** plus **the terminal daemon**.
+
+| | orcad | terminal daemon |
+| ---------- | -------------------------------- | ------------------------------------- |
+| Started by | the supervisor | orcad, detached |
+| Owns | RPC, git, worktrees, persistence | every local PTY |
+| Lifetime | one supervised run | **outlives orcad** |
+| Endpoint | `ws://:` | `/daemon/daemon-v.sock` |
+
+The daemon outliving orcad is the property the whole peer model is recommended for
+(`docs/reference/ssh-execution-boundary.md`): daemon-backed PTYs stay `live` across a runtime
+restart, so a restart, an update or a rollback does not destroy running work. Everything
+below exists to keep that true.
+
+**Consequence for supervision:** orcad's shutdown path calls `disconnectDaemon()`, never
+`shutdownDaemon()`. A supervisor that reaps orcad's whole process group — systemd's
+`KillMode=control-group` — kills the daemon too and turns every restart back into data loss.
+Use `KillMode=mixed` (the default) or `process`, and never `--send-sigkill` on the group.
+
+## Bind policy
+
+`--bind `, **default `127.0.0.1`**.
+
+Only literal IPs are accepted; hostnames are refused because DNS would decide which
+interface got bound. `localhost` maps to `127.0.0.1`. `0.0.0.0` / `::` are the explicit
+opt-ins to network reach, and the startup log says so on every launch.
+
+The bind is **pinned**, not defaulted. Two things widen the desktop's listener on their own —
+`orca serve`'s wide default, and a startup where some device has connected before — and an
+unattended host's exposure must be exactly what the operator asked for on every launch. A
+mobile pairing offer, which normally rebinds to all interfaces, is refused while the bind is
+pinned to loopback and reports `network_exposure_failed` rather than advertising an endpoint
+nothing can reach.
+
+Under the shipping design a client reaches a remote orcad over an SSH local port-forward, so
+loopback is the correct default and the pairing credential travels over SSH.
+
+## Data root and the instance lock
+
+The data root is `$ORCA_USER_DATA`, else `$XDG_DATA_HOME/Orca`, else `~/.orca`.
+
+Before the profile index or the store is touched, orcad takes `/orcad.lock`.
+It refuses to start when:
+
+| Code | Meaning |
+| -------------------------------------- | ------------------------------------------------------------- |
+| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) |
+| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened |
+| `orcad_instance_lock_held` | another live orcad owns this root |
+| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity |
+| `orcad_data_root_unusable` | the root cannot be created, stat'd or written |
+
+A root that is merely too permissive and that we own is tightened to `0700` rather than
+refused — orcad stores credentials there unsealed (no OS keyring on this host), so the goal
+is a private root, and refusing when we could just fix it helps nobody. We refuse when the
+permissions are not ours to fix. Windows is exempt from the owner and mode checks: ACLs are
+not expressible as a POSIX mode, and `statSync().mode` there reports a synthesized one.
+
+A dead holder's record is reclaimed (PID plus process start time, so a recycled PID does not
+read as alive). A record belonging to a different identity is never reclaimed.
+
+**The lock scopes one role — who is the runtime.** It deliberately says nothing about the
+daemon, which lives under `/daemon` and fences its own endpoint with its own PID
+record. A lock that asked "is any process using this root" would refuse exactly the restarts
+a live daemon makes worthwhile.
+
+## Supervision
+
+### Who supervises orcad
+
+An external supervisor (systemd, launchd, a process manager). orcad conforms to it:
+
+- **Readiness.** One JSON line on stdout (`--json`), `type: "orca_server_ready"`, published
+ after the listener is bound and the daemon verdict is in. There is no separate readiness
+ socket; the line is the signal. Set the supervisor's start timeout generously — the daemon
+ launch has its own retries and can take tens of seconds on a cold host.
+- **Shutdown.** `SIGTERM` or `SIGINT` starts a graceful stop. A **second** signal exits
+ immediately with code 1 rather than being swallowed — a supervisor's second signal means
+ its first deadline elapsed, and waiting silently is what turns a stop into a `SIGKILL`,
+ the one teardown that skips the daemon handoff. orcad also imposes its own 15s deadline
+ and exits 1, so the failure stays attributable instead of arriving as an unlogged kill.
+- **Exit codes.**
+
+ | Code | Meaning | Supervisor should |
+ | ---- | ------------------------------------------------------------ | -------------------- |
+ | 0 | clean shutdown | restart per policy |
+ | 1 | startup or shutdown failure | restart with backoff |
+ | 78 | configuration fault (bind address, data root, instance lock) | **not** restart |
+
+ 78 is `EX_CONFIG`. Put it in systemd's `RestartPreventExitStatus`: restarting on a data
+ root owned by someone else is a restart-spin, not a recovery.
+
+- **Logs.** orcad writes human-readable diagnostics to **stderr** and its readiness contract
+ to **stdout**; the supervisor owns capture and rotation. The daemon, being detached, writes
+ its own NDJSON lifecycle log to `/logs/daemon.log` (suppressed by
+ `ORCA_DIAGNOSTICS_DISABLED=1`). Rotation of that file is not implemented — see
+ [What is not covered](#what-is-not-covered).
+
+### orcad supervising the daemon
+
+- **Launch.** Forked detached from `daemon-entry.js` beside `orcad.js`, with its own PID
+ record, token and socket under `/daemon`.
+- **Adoption before spawn.** A daemon already answering the endpoint is adopted, not
+ replaced, unless it is unhealthy, foreign, or built from a superseded bundle _and_ owns no
+ live sessions. Replacing a healthy daemon kills its PTYs, so code freshness always defers
+ to live work.
+- **Restart.** The adapter respawns the daemon on death, transparently to callers.
+- **Crash-loop containment.** At most **5 launches per 60s rolling window** per orcad run;
+ past that, launches are refused with `daemon_crash_loop` and terminals fail with that
+ message instead of the process forking forever. The window slides, so a repaired host
+ recovers without restarting orcad. An operator-initiated daemon restart clears it — that
+ is the deliberate "try again".
+- **No macOS login-session watch.** That watch retires the daemon when the spawning GUI login
+ session dies. An orcad daemon must survive its SSH session ending.
+- **Shutdown.** orcad never stops the daemon. A daemon that was never adopted retires itself
+ after its adoption window; an adopted one stays resident (see Decommissioning).
+
+### Decommissioning
+
+The daemon outliving orcad is deliberate, so stopping orcad does **not** leave the host with
+zero Orca processes. A daemon that has been adopted stays resident after its runtime
+disconnects — that is what makes the next start a reattach rather than a cold restore. To
+retire a host completely, stop orcad and then stop the daemon named by
+`health.terminalDaemon.pid`, or delete the data root and let the endpoint go stale.
+
+## Health
+
+The readiness payload carries a `health` object:
+
+```
+buildHash sha256 (16 hex) of the running orcad bundle — build identity that a version
+ string cannot give, so a rollback that did not replace the file is visible
+buildVersion ORCA_VERSION
+nodeVersion / nodeAbi process.versions.node / .modules — the ABI native addons must match
+platform / arch / pid
+terminalDaemon:
+ state live | degraded | absent
+ ownsFreshSessions whether NEW terminals are daemon-owned, i.e. survive an orcad restart
+ pid the live daemon's pid, from its own PID record
+ buildVersion the build the LIVE daemon was forked from (may legitimately predate
+ this orcad after an update — reporting orcad's version for both would
+ hide exactly that)
+ entryPath / protocolVersion
+ selfTest { ok, coverage, verdict, durationMs }
+```
+
+### What the self-test proves
+
+`selfTest` runs `checkDaemonHealth` against the daemon's socket. It is green only when the
+daemon **opened its socket, completed the protocol handshake, and ran `ptySpawnHealth` — a
+real short-lived PTY spawned inside the daemon's own process**. It therefore spans both
+processes: orcad drives it, the daemon performs it, the verdict crosses the socket.
+
+- `coverage: 'pty-spawn'` — the full round trip above.
+- `coverage: 'handshake'` — **win32 only**, where `checkPtySpawnHealth` returns without
+ spawning anything. A green verdict there covers the handshake and nothing more. It is
+ reported separately rather than folded into `ok` so nobody reads it as a PTY round trip.
+
+`state` is `live` only when the self-test passed **and** `ownsFreshSessions` is true. A
+daemon that answers but has fallen back to local spawning for new terminals is `degraded`,
+because those terminals die with orcad. A daemon that answered and then failed its spawn
+probe is also `degraded`, not `absent`: it still holds live sessions, and calling those
+exited would be the verdict `ssh-execution-boundary.md` forbids guessing.
+
+## What is not covered
+
+Named here so nothing reads as implemented that is not:
+
+- **A continuous health endpoint.** `health` is published once, in the readiness payload. A
+ supervisor's periodic liveness/readiness probe needs an HTTP or RPC surface over the same
+ `collectOrcadHealth()`; that surface does not exist yet.
+- **libc slot.** §04 asks for it in the health payload. It belongs to the native strategy
+ (plan item 5), which owns libc detection; there is no honest value to publish until then.
+- **`degradations[]`.** Plan item 2's contract, not this one.
+- **Credential administration** (list / revoke / rotate devices, expiring pending offers,
+ structured security logging) — §04, not delivered here.
+- **Pinned-port fail-closed.** A pinned `--port` still falls back to an OS-assigned port on
+ conflict.
+- **Reconciling `webClientUrl` with reachability** under the loopback default.
+- **State-schema rollback rules.**
+- **Daemon log rotation.** `/logs/daemon.log` grows unbounded.
diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md
index 33a72d68d99..9f3d8d66472 100644
--- a/docs/reference/remote-wire-compatibility.md
+++ b/docs/reference/remote-wire-compatibility.md
@@ -113,7 +113,7 @@ getting that wrong turns a skew into a false "nothing is blocked".
unverifiable, the pane was unreadable, or the agent probe did not answer in time.
A new client against an old host sees the field absent, which is why absence must read as
-*unknown* and never as *not waiting*. Collapsing absent into `null` at any hop — including a
+_unknown_ and never as _not waiting_. Collapsing absent into `null` at any hop — including a
convenience `?? null` in an RPC handler — makes an old or unreachable peer indistinguishable
from a healthy idle worker, which is the exact failure the field exists to remove.
@@ -180,7 +180,7 @@ has to be there before the first snapshot is interpreted, which is earlier than
renderer could wait on. Every other viewer — a second desktop, the web client, which installs no
page renderer at all, the dashboard pop-out, which is deliberately left unstamped — keeps tracking
the host, which is the only reason a mirrored viewer shows anything but its first snapshot
-forever. Improving what a *second* client sees still means fixing the publish, not the carve-out;
+forever. Improving what a _second_ client sees still means fixing the publish, not the carve-out;
the carve-out no longer stands in the way of it.
The two failure fields above are deliberately left on the looser `placement?.kind !== 'client'`
diff --git a/docs/reference/ssh-host-key-verification.md b/docs/reference/ssh-host-key-verification.md
index a84ea7ee4ea..73955bdc7df 100644
--- a/docs/reference/ssh-host-key-verification.md
+++ b/docs/reference/ssh-host-key-verification.md
@@ -28,11 +28,11 @@ Three corrections to the first draft:
verified. The ssh2 proxy-spawn at `:697` is effectively unreachable. Good news for migration, and
the first draft's motivating example was simply wrong.
- **Agent forwarding was overstated.** `agentForward` is gated on the user's `ForwardAgent yes`
- (`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent *auth*, whose
+ (`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent _auth_, whose
signatures bind the session id and cannot be replayed onward. The risk applies to users who opted
into `ForwardAgent`, not everyone.
- **Credential theft was understated, and the relay claim was backwards.** `isAgentFallbackError`
- treats *any* auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
+ treats _any_ auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
publickey walks the user to the password prompt (`ssh-connection.ts:844`) and the private-key
**passphrase** prompt (`:834`), and `cachedPassword` is replayed without prompting on every
reconnect (`:709`). Meanwhile the relay upload matters less than assumed — the attacker already
@@ -65,7 +65,7 @@ Two consequences to own rather than discover:
There is no `ssh`-only way out of this: `-F /dev/null` does NOT invert the exclusion, it reports
built-in defaults, so a probe built on it looks permissive on every machine. Verified against
- OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — *could*
+ OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — _could_
the site config be restricting host keys — where anything ambiguous (unreadable, an unresolvable
`Include`, the directive present at all) keeps the refusal. Only a site config that demonstrably
says nothing about host keys clears it, which is what stops the rule punishing every devcontainer,
@@ -130,16 +130,16 @@ connect, training them to dismiss the one warning that matters.
> **Corrected against a live client.** The premise above is wrong about OpenSSH, though the
> conclusion survives. `check_key_in_hostkeys` is not type-scoped at all: ANY non-marker entry for
-> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — known_hosts
+> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — `known_hosts`
> holding only `ssh-rsa` against an ed25519-only server prints `IDENTIFICATION HAS CHANGED` and
-> refuses. So ssh does not avoid the false alarm by scoping; it avoids the *situation* via
+> refuses. So ssh does not avoid the false alarm by scoping; it avoids the _situation_ via
> `order_hostkeyalgs`, and hard-fails when the situation arises anyway. Our split into `mismatch`
> and `unknown-type-known-host` therefore only chooses the wording — both refuse, which is ssh's
> action. What the ordering below buys us is what it buys ssh: the situation mostly never arises.
**But scoping alone is a downgrade vector, and this is the correction that most changes the design.**
OpenSSH is safe here only because `order_hostkeyalgs()` reorders the client's proposed host-key
-algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the *client's* order
+algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the _client's_ order
priority — so a server cannot choose a type the client deprioritised. ssh2 negotiates ed25519 first
regardless. An attacker who cannot forge the RSA key on file simply presents ed25519 and receives a
friendly first-contact prompt instead of a hard failure.
@@ -147,7 +147,7 @@ friendly first-contact prompt instead of a hard failure.
Therefore: **set ssh2's `algorithms.serverHostKey` to lead with the key types already known for that
host.** Type scoping without algorithm ordering is not a safe design.
-And when the presented type is unknown *while other types are known for this host*, that is
+And when the presented type is unknown _while other types are known for this host_, that is
`unknown-type-known-host` — never a plain TOFU prompt. It must say we already hold a different key
for this host.
@@ -193,7 +193,7 @@ means nothing is known.
### D5. Recovery must not live in the failure dialog
-A "forget this host key" button *in* the mismatch dialog is D4's rejected "trust anyway" with one
+A "forget this host key" button _in_ the mismatch dialog is D4's rejected "trust anyway" with one
extra click. Recovery lives in target settings: a separate, deliberate surface, no auto-retry, and it
shows the stored fingerprint so the user is choosing knowingly.
@@ -201,7 +201,7 @@ Offer it only when **our** store is what disagreed; when `known_hosts` disagrees
record cannot unblock the connect. Messages, written to avoid naming internals:
> **Ours disagreed** — "The host key for `build-01` changed since you last connected from Orca. If you
-> rebuilt or reprovisioned this machine, this is expected." → *Forget the saved key* / *Cancel*
+> rebuilt or reprovisioned this machine, this is expected." → _Forget the saved key_ / _Cancel_
> **`known_hosts` disagreed** — "The host key for `build-01` does not match the entry in
> `~/.ssh/known_hosts`. `ssh` and `git` will refuse this host too. Run `ssh-keygen -R build-01`." →
@@ -239,7 +239,7 @@ machines, two targets can name one machine, and a re-created target must not los
The store is a **dedicated file**, not the main persistence blob (`persistence.ts:7088`): a settings
restore or rollback must not silently reset trust. Accept and mismatch events are logged.
-`hostKeyFingerprint` is now security-relevant *and* wire-relevant — it is an isolation namespace sent
+`hostKeyFingerprint` is now security-relevant _and_ wire-relevant — it is an isolation namespace sent
to the host (`ssh-relay-session.ts:1298`, `managed-hook-owner-identity.ts:187`). It is `undefined` on
the system transport, so **no trust logic may key off it**, and its format must not change (see
Traps).
@@ -247,11 +247,11 @@ Traps).
## Phasing — ship the defence before the dialog
Review made the case that the riskiest part of this change is not the security model but the modal.
-Startup restore fires eager connects for *all* previously-active targets in parallel (`App.tsx:1041`)
+Startup restore fires eager connects for _all_ previously-active targets in parallel (`App.tsx:1041`)
with a 15s timeout, while a prompt would live 120s — N unknown hosts means N stacked dialogs
outliving the timeout that already deferred them. Runtime-owned ephemeral VMs
(`ephemeral-vm-runtime-ssh.ts:31`) dial a freshly provisioned host with a brand-new key on every
-launch. Paired-web connects run on the *host desktop* (`runtime/rpc/methods/ssh.ts:32`), so the
+launch. Paired-web connects run on the _host desktop_ (`runtime/rpc/methods/ssh.ts:32`), so the
dialog would open on someone else's screen while the web user watches a spinner.
**Phase 1 — no new modal.** Consult `known_hosts` + our store. `match` connects. `unknown` persists
@@ -301,10 +301,11 @@ Each of these makes the fix silently do nothing. All confirmed in our tree.
## Scope
**In scope, corrected:** IPv6 literals and `[host]:port` bracket parsing. Review was right that this
-is a *parser* requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
+is a _parser_ requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
`unknown`, which is the prompt-training harm D3 exists to avoid.
**Out of scope, with consequences stated:**
+
- **`CheckHostIP`** — OpenSSH defaults it off; we form candidates from the hostname only.
- **WSL** — `src/main/ssh/` has no WSL awareness; a distro's `known_hosts` is unreachable, so WSL
users get first-contact treatment for hosts they already verified.
@@ -337,7 +338,7 @@ verified by running an OpenSSH 10.2p1 client against a real `sshd` on `127.0.0.1
its verdict:
- **The bare-host fallback pass never reports a change.** With `StrictHostKeyChecking=accept-new`, a
- bare line holding a *different* key, dialed on a non-default port, made ssh connect and append a
+ bare line holding a _different_ key, dialed on a non-default port, made ssh connect and append a
new `[127.0.0.1]:2222` line — first contact, no `IDENTIFICATION HAS CHANGED`. Reporting `mismatch`
on that pass would refuse hosts ssh connects to happily, and would have looked like the cautious
choice.
@@ -355,16 +356,16 @@ subtly wrong.**
Fixed after review:
1. **Our own store was type-downgradable.** The inline lookup filtered by key type first and could
- only answer match/mismatch/unknown, so a record of a *different* type read as `unknown`. D3's
+ only answer match/mismatch/unknown, so a record of a _different_ type read as `unknown`. D3's
downgrade, applied to the records we create ourselves. Stored types now also feed the algorithm
ordering — without that the guard is only half present.
2. **We keyed on the Orca label.** `ssh -G` echoes its own argument back as `hostname` when no Host
- block matches, so for a manual target `resolved.hostname` *is* the label — the one name D2
+ block matches, so for a manual target `resolved.hostname` _is_ the label — the one name D2
forbids. We consulted no entries at all.
3. **A refused key still walked the credential ladder.** ssh2 reports a denial as a generic auth
failure, so we went on to prompt for the passphrase and hand it to the host we had just refused.
Rejections are now a typed error recognised before any fallback.
-4. **Fail-closed nearly became fail-always.** "No readable known_hosts" counted a *missing* file the
+4. **Fail-closed nearly became fail-always.** "No readable `known_hosts`" counted a _missing_ file the
same as an unreadable one, so a profile that had never connected — everyone's first run — would
have been refused, and the suite passed only because dev machines have a `known_hosts`.
5. **Ephemeral runtimes were refused for a policy they cannot satisfy.** The carve-out sat below the
@@ -404,7 +405,7 @@ tooltip, and the terminal reconnect overlay never asked for it at all. Still ope
`getPublicSshState`, so a paired-web client always sees exactly `SSH connection unavailable`.
Pre-existing, but it makes the Phase 2 dialog message unreachable there without a change. Note the
redaction is not web-only: any target owned by a paired runtime environment is redacted, so a
- *desktop* user viewing a remote-Orca-server-owned host gets the same generic string.
+ _desktop_ user viewing a remote-Orca-server-owned host gets the same generic string.
- **RPC fail-fast** becomes load-bearing the moment the dialog exists (see Phasing).
**Known gaps that Phase 1 accepts, listed so they are choices and not surprises:**
diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md
index 115cc7040a9..4b8acd87aae 100644
--- a/docs/reference/windows-process-enumeration.md
+++ b/docs/reference/windows-process-enumeration.md
@@ -15,7 +15,10 @@ since removed.
table. It wraps a Toolhelp32 snapshot from `@vscode/windows-process-tree`.
```ts
-import { readWindowsProcessTable, readWindowsProcessTableFresh } from '../windows/windows-process-table'
+import {
+ readWindowsProcessTable,
+ readWindowsProcessTableFresh
+} from '../windows/windows-process-table'
```
- `readWindowsProcessTable()` — shared TTL cache. Use for anything periodic.
@@ -31,11 +34,11 @@ survived its own teardown (#9045).
Measured on Windows 11 with 1050 processes (p50 / p95):
-| | p50 | p95 |
-| --- | --- | --- |
-| pid + ppid + name | 15.9 ms | 17.5 ms |
-| + memory + command line | 30.6 ms | 33.7 ms |
-| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
+| | p50 | p95 |
+| -------------------------------- | ------- | ------- |
+| pid + ppid + name | 15.9 ms | 17.5 ms |
+| + memory + command line | 30.6 ms | 33.7 ms |
+| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
Those CIM numbers are from a 1050-process host. The scan scales with process
count: on a 1486-process Windows SSH host it measured **1.36 s** and produced
@@ -46,6 +49,40 @@ risk — but the headroom is roughly 2x on time and 1.7x on bytes, not the ~4x t
parse, and the read rejects, so a busy host loses the table rather than
receiving a wrong one.
+## When a read wedges
+
+The vendored reader pushes every callback onto a module-global queue and drains
+that queue only when the request holding its `requestInProgress` latch
+completes. If a Toolhelp32 snapshot never comes back — an EDR hook, a restricted
+token, a worker that dies — the latch is stuck for the life of the process and
+every later call parks another closure in that queue.
+
+Two guards, and they work together:
+
+- a **3 s deadline** on each read, so a caller gets a rejection instead of a
+ promise that never settles;
+- a **sticky wedge**: once a read misses its deadline and has not called back,
+ the module refuses every further read until that read's callback fires.
+
+The wedge used to be a 30 s cooldown that let one probe through per window. That
+bounded the _rate_ of new callbacks but not the total: a permanently wedged
+reader retained one more closure every 30 s for as long as the app ran, and each
+probe also blocked its caller for the full 3 s deadline first. Gating on the
+outstanding read instead bounds retention at exactly one callback, and gives up
+nothing on recovery — a probe queued behind the latch could never have observed
+recovery anyway, whereas the stuck callback firing is the drain itself. On the
+relay's bare addon, which has no queue of its own, it is also what keeps Orca
+from re-entering `CreateToolhelp32Snapshot` while a call is still running.
+
+That last part is not just tidiness. The addon runs each read as a
+`Napi::AsyncWorker`, so a wedged read holds a libuv threadpool slot for good. On
+the relay the JS queue is not there to absorb the retries, so one probe per
+window would have pinned all four default threads inside ~2 minutes — hanging
+every async `fs` and DNS call in that process, not only the process table.
+
+A wedge does **not** engage the PowerShell fallback; see the next section for
+why only absence does.
+
## The relay has no binding, and falls back
Relay deployment installs only `node-pty` and `@parcel/watcher` on the remote
@@ -56,7 +93,7 @@ checked on a real Windows SSH host with 1486 processes:
**Installing it normally rebuilds from source, and that build fails.** The
tarball carries a `binding.gyp`, so npm runs `node-gyp rebuild` regardless of
-what is already compiled inside it. On a host that *already had* MSVC Build
+what is already compiled inside it. On a host that _already had_ MSVC Build
Tools 2022 installed, that build still failed:
```
@@ -69,7 +106,7 @@ would then break outright rather than degrade: `installNativeDeps` throws on
failure, and the toolchain-skip retry is gated to Linux.
**Skipping the build and using the shipped binary returns a truncated table.**
-Contrary to what this file used to claim, the published 0.8.0 tarball *does*
+Contrary to what this file used to claim, the published 0.8.0 tarball _does_
contain `build/Release/windows_process_tree.node` — an MSVC build directory that
looks accidentally published (`.obj` and `.tlog` files ship with it). It is
N-API, so it loads on any modern Node. But it predates our patch and still has
@@ -117,14 +154,14 @@ it as an optional relay artifact.
`config/scripts/build-windows-process-tree-relay-addon.mjs` builds it from the
source pnpm has already patched, on a Windows runner, and refuses to run if
any patch hunk is missing — the Spectre hunk fails loudly, the 1024-process
-hunk fails *silently*, and the relative gyp path dies at configure on Windows.
+hunk fails _silently_, and the relative gyp path dies at configure on Windows.
The source is checked rather than the install trusted. It also reads the PE
machine field of the output, because a cross-build that quietly emitted host
arch would ship a binary the target cannot load.
Windows arm64 cross-compiles from the x64 runner — verified on real hardware,
producing `IMAGE_FILE_MACHINE_ARM64` (0xaa64) against x64's 0x8664. It needs the
-optional *MSVC v143 ARM64 build tools* component; without it node-gyp fails with
+optional _MSVC v143 ARM64 build tools_ component; without it node-gyp fails with
`MSB8020`, which is why the addon build runs before the long packaging step.
`ORCA_REQUIRE_RELAY_NATIVE_ADDONS` is a per-arch list so a future arch can be
added best-effort before it is promoted to required.
@@ -224,7 +261,7 @@ The per-PTY job deliberately does **not** set
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Measured on Windows 11: with that flag,
releasing the handle when the shell exits also kills whatever the user left
running, so typing `exit` in a pane reaped a `start /b` server that used to
-survive. The job exists to make an *explicit* teardown exact, not to redefine
+survive. The job exists to make an _explicit_ teardown exact, not to redefine
what a clean exit means.
Reaping a dead daemon's shells (#9195, #10415) is therefore a **second, nested
@@ -249,7 +286,7 @@ kill-on-close job on the app, which is exactly what the crash-survival
guarantee forbids.
Once the shell exits, node-pty drops its handle record and closes the job, so a
-terminated tree reports `null` rather than `[]`. Null means *unverifiable* in
+terminated tree reports `null` rather than `[]`. Null means _unverifiable_ in
the sense of [`ssh-execution-boundary.md`](./ssh-execution-boundary.md) — no job
support, not a ConPTY, or no longer tracked. It is never evidence that
processes died.
@@ -292,8 +329,6 @@ the Windows CI job rebuilds from source before running the win32 suites.
it is true before asserting anything else, so an unpatched binary fails loudly
instead of passing every case vacuously. That guard is what caught this.
-`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` still
-exempts win32, on the premise that the patch is Unix-only. That premise is now
-false, but lifting the exemption also needs `pnpm rebuild` to force a source
-build — otherwise the assertion fires and the remedy does not fix it. Left as a
-follow-up rather than changed blind.
+`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` now covers
+win32 as well, and `pnpm rebuild node-pty` sets `npm_config_build_from_source`
+so the patched source build actually replaces the upstream prebuild.
diff --git a/docs/reference/windows-setup-shell.md b/docs/reference/windows-setup-shell.md
index 6c93185b602..5e7e33ca0de 100644
--- a/docs/reference/windows-setup-shell.md
+++ b/docs/reference/windows-setup-shell.md
@@ -25,7 +25,7 @@ scripts:
## Why the script declares it, not the terminal preference
-`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the
+`terminalWindowsShell` says which shell _interactive terminals_ open in. It says nothing about the
language a project's setup script is written in. Deriving the runner from it had two consequences:
- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`,
diff --git a/docs/reference/worktree-scan-fingerprint.md b/docs/reference/worktree-scan-fingerprint.md
index f82d453a82c..fa0e26dac20 100644
--- a/docs/reference/worktree-scan-fingerprint.md
+++ b/docs/reference/worktree-scan-fingerprint.md
@@ -35,7 +35,7 @@ whichever poller arrives first afterwards pays a full-fleet `git worktree list`
fan-out. The many high-frequency callers — `listTerminals` without a selector,
`showTerminal`, `getWorktreePs`, `listManagedWorktrees`,
`resolveWorktreeSelector`, orchestration authority refresh — only determine
-*who* pays, not *how often*. Steady-state subprocess volume is therefore
+_who_ pays, not _how often_. Steady-state subprocess volume is therefore
`repos / 30 s`, independent of poll rate, which is exactly the observed
~1 sweep / 30.5 s.
@@ -86,16 +86,16 @@ common directory without a subprocess (read `.git`; if it is a `gitdir:` file,
follow it and then its `commondir`; if `.git` is absent, treat `repoPath` as a
bare gitdir), then records:
-| Input | External change it catches |
-| --- | --- |
-| sorted entry names of `/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
-| existence of `repoPath` | main checkout deleted |
-| `/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
-| `/reftable` mtime + size | a tip moved under the reftable backend |
-| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
-| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
-| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
-| per entry: `locked` presence | `worktree lock` / `unlock` |
+| Input | External change it catches |
+| -------------------------------------------------- | ------------------------------------------------------------- |
+| sorted entry names of `/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
+| existence of `repoPath` | main checkout deleted |
+| `/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
+| `/reftable` mtime + size | a tip moved under the reftable backend |
+| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
+| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
+| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
+| per entry: `locked` presence | `worktree lock` / `unlock` |
| per entry: existence of the path named by `gitdir` | a worktree directory deleted with `rm -rf` (flips `prunable`) |
"per checkout" covers the main worktree and each linked worktree. Reading the
@@ -159,21 +159,21 @@ rescans. Capturing after the scan would let that mutation be masked forever.
Untouched. `invalidateWorktreeScanCacheForRepo` deletes the entry (fingerprint
included) and bumps the generation, so every event-driven path still forces a
-real scan on the next read. The fingerprint only ever *extends* an entry that
+real scan on the next read. The fingerprint only ever _extends_ an entry that
the TTL alone would have refreshed.
## Freshness budget
-| Change | Before | After |
-| --- | --- | --- |
-| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
-| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
-| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
-| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
-| External `rm -rf ` | ≤ 30 s | ≤ 30 s |
-| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
-| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
-| SSH / WSL repos, folder workspaces | unchanged | unchanged |
+| Change | Before | After |
+| --------------------------------------------------------------------- | ----------------- | ----------------- |
+| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
+| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
+| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
+| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
+| External `rm -rf ` | ≤ 30 s | ≤ 30 s |
+| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
+| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
+| SSH / WSL repos, folder workspaces | unchanged | unchanged |
The two regressions are bounded by the reconciliation interval and are both
changes Orca does not make itself.
@@ -185,10 +185,10 @@ thread" in the naive sense — but they are not equally free there. Measured on
macOS with a 1 ms interval sampling event-loop lag while each ran 30 times
against a repo with 20 linked worktrees:
-| | wall per call | main-thread stall per call | worst single stall |
-| --- | --- | --- | --- |
-| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
-| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
+| | wall per call | main-thread stall per call | worst single stall |
+| ------------------- | ------------- | -------------------------- | ------------------ |
+| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
+| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
`fs/promises` dispatches to libuv's threadpool, so ~99 % of the probe's latency
is off-thread. Spawning Git does not: `uv_spawn`, fd and pipe setup, and stdout
@@ -212,10 +212,10 @@ sparse-checkout probes already do.
reported steady state — 10 idle local repos, a caller polling at 1 Hz for 30
simulated minutes — and counts `git worktree list` invocations:
-| | `git worktree list` per 30 min | per hour |
-| --- | --- | --- |
-| TTL only (before) | 600 | 1,200 |
-| fingerprint gate (after) | 60 | 120 |
+| | `git worktree list` per 30 min | per hour |
+| ------------------------ | ------------------------------ | -------- |
+| TTL only (before) | 600 | 1,200 |
+| fingerprint gate (after) | 60 | 120 |
A 90 % reduction, with the remainder being the bounded reconciliation. Repos
with genuine external activity keep rescanning at the 30 s cadence because the
@@ -227,7 +227,7 @@ Extrapolating to the original trace's shape (10 repos, 3 h 27 min): 4,272
## Rejected alternatives
**Raise `WORKTREE_SCAN_CACHE_TTL_MS` to 5 min.** One line, same subprocess
-reduction, but it degrades *every* external-change latency to 5 min, including
+reduction, but it degrades _every_ external-change latency to 5 min, including
the common "I ran `git worktree add` in a terminal" case. The fingerprint buys
the same reduction without that regression.
diff --git a/docs/reference/wsl-command-execution.md b/docs/reference/wsl-command-execution.md
index c832ff201e5..92afcf344ad 100644
--- a/docs/reference/wsl-command-execution.md
+++ b/docs/reference/wsl-command-execution.md
@@ -26,7 +26,7 @@ exactly the case a POSIX script uses to mean a literal dollar.
Build argv with `buildWslExecArgs()` in `src/shared/wsl-login-shell-command.ts`. A test walks the
tree and fails if the `--` form reappears.
-The `--` inside `sh -s -- ` is a *shell* argument separator and is unrelated; leave it alone.
+The `--` inside `sh -s -- ` is a _shell_ argument separator and is unrelated; leave it alone.
## 2. Machine-read output must be fenced
diff --git a/docs/reference/wsl-runner-verification.md b/docs/reference/wsl-runner-verification.md
index 39cc56871a8..59667f513de 100644
--- a/docs/reference/wsl-runner-verification.md
+++ b/docs/reference/wsl-runner-verification.md
@@ -4,24 +4,24 @@ Three layers of coverage, because each catches what the others structurally cann
## 1. Unit — runs everywhere, every PR
-| Suite | Pins |
-|---|---|
-| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
-| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
-| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
-| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
+| Suite | Pins |
+| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
+| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
+| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
+| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
## 2. Ratchets — the goalposts, enforced continuously
-| Guard | Measures |
-|---|---|
-| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
-| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
-| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
-| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
-| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
+| Guard | Measures |
+| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
+| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
+| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
+| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
+| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
+| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
-Each fails on a **new** offender *and* on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
+Each fails on a **new** offender _and_ on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
## 3. Real-binary — the assertions nothing else can make
@@ -48,4 +48,4 @@ Recorded rather than implied, because a guard that looks complete is worse than
### Verifying a guard change
-Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that *reduced* the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
+Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that _reduced_ the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
diff --git a/docs/reference/xterm-patch-regeneration.md b/docs/reference/xterm-patch-regeneration.md
index dead914ef74..b2da865406d 100644
--- a/docs/reference/xterm-patch-regeneration.md
+++ b/docs/reference/xterm-patch-regeneration.md
@@ -115,7 +115,7 @@ the resulting patch is silently wrong — the failure mode is a `.mjs` that is
`forbiddenBuildScripts` in the manifest encodes this and the generator refuses
to run a build step that names one of those scripts.
-The generator also builds the *unmodified* commit first and asserts that it
+The generator also builds the _unmodified_ commit first and asserts that it
reproduces the published `lib/` byte for byte before it emits anything. A
toolchain or build-order problem therefore surfaces as an explicit "did not
reproduce the published bundles" error rather than as 7 MB of mystery diff.
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index f81dbb93041..4faa844ee4c 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
index: resolve('src/main/index.ts'),
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
+ 'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json
index 877a6d7e123..f3338fc3616 100644
--- a/mobile/.oxlintrc.json
+++ b/mobile/.oxlintrc.json
@@ -75,54 +75,18 @@
"max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }]
}
},
- {
- "files": ["src/components/mobile-rich-markdown-editor-html.ts"],
- "rules": {
- "max-lines": ["error", { "max": 648, "skipBlankLines": true, "skipComments": true }]
- }
- },
- {
- "files": ["src/components/CustomKeyModal.tsx"],
- "rules": {
- "max-lines": ["error", { "max": 645, "skipBlankLines": true, "skipComments": true }]
- }
- },
- {
- "files": ["app/pair-scan.tsx"],
- "rules": {
- "max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }]
- }
- },
{
"files": ["scripts/mock-server.ts"],
"rules": {
"max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }]
}
},
- {
- "files": ["src/terminal/terminal-accessory-keys.ts"],
- "rules": {
- "max-lines": ["error", { "max": 388, "skipBlankLines": true, "skipComments": true }]
- }
- },
{
"files": ["app/troubleshoot.tsx"],
"rules": {
"max-lines": ["error", { "max": 436, "skipBlankLines": true, "skipComments": true }]
}
},
- {
- "files": ["scripts/repro-worktree-startup-stream.ts"],
- "rules": {
- "max-lines": ["error", { "max": 326, "skipBlankLines": true, "skipComments": true }]
- }
- },
- {
- "files": ["scripts/repro-terminal-colors.ts"],
- "rules": {
- "max-lines": ["error", { "max": 319, "skipBlankLines": true, "skipComments": true }]
- }
- },
{
"files": ["app/h/*/files/*.tsx"],
"rules": {
diff --git a/mobile/app/connection-log.tsx b/mobile/app/connection-log.tsx
index e2a9b73ff7f..1ab79c50269 100644
--- a/mobile/app/connection-log.tsx
+++ b/mobile/app/connection-log.tsx
@@ -1,20 +1,35 @@
-import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
+import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { View, Text, StyleSheet, Pressable, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
-import { useRouter } from 'expo-router'
+import { useLocalSearchParams, useRouter } from 'expo-router'
import * as Clipboard from 'expo-clipboard'
import Constants from 'expo-constants'
-import { ChevronLeft, Copy, Check } from 'lucide-react-native'
+import { ChevronLeft, Copy, Check, Send } from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
import { loadHosts } from '../src/transport/host-store'
-import { connectionLogStore } from '../src/transport/connection-log-buffer'
-import { useHostClient } from '../src/transport/client-context'
+import { connectionLogStore } from '../src/transport/persisted-connection-log-store'
+import { useHostClient, useRpcClientContext } from '../src/transport/client-context'
import {
- useLastConnectedAt,
+ useConnectionPathStatus,
useReconnectAttempt
} from '../src/transport/client-context-connection-metrics'
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
+import {
+ diagnoseConnection,
+ getReportableConnectionIncidentId
+} from '../src/diagnostics/connection-diagnostics-analysis'
+import { submitConnectionDiagnostics } from '../src/diagnostics/connection-diagnostics-submission'
+import {
+ readHydratedConnectionLog,
+ readConnectionDiagnosticsSnapshot,
+ resolveDiagnosticsHostId,
+ getDiagnosticsSubmissionState,
+ updateDiagnosticsSubmissionState,
+ type DiagnosticsSubmissionStates
+} from '../src/diagnostics/connection-diagnostics-screen-data'
+import { useHostStatusGates } from '../src/transport/host-status-gates'
+import { loadHostAppVersion } from '../src/transport/host-app-version-store'
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
// Why: getSnapshot must be referentially stable when there's no data —
@@ -25,11 +40,19 @@ const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = []
// screen also *acquires* the host client — opening it kicks a dial and the
// log fills live instead of showing a stale tail.
export default function ConnectionLogScreen() {
+ const clientContext = useRpcClientContext()
const router = useRouter()
+ const params = useLocalSearchParams<{ hostId?: string }>()
const insets = useSafeAreaInsets()
+ const routeKey = useMemo(() => ({}), [params.hostId])
const [hosts, setHosts] = useState([])
- const [selectedId, setSelectedId] = useState(null)
- const [copied, setCopied] = useState(false)
+ const [manualSelection, setManualSelection] = useState<{
+ hostId: string
+ requestedHostId: string | undefined
+ routeKey: object
+ } | null>(null)
+ const [copiedHostId, setCopiedHostId] = useState(null)
+ const [submissionStates, setSubmissionStates] = useState({})
useEffect(() => {
let stale = false
@@ -38,17 +61,28 @@ export default function ConnectionLogScreen() {
return
}
setHosts(loaded)
- setSelectedId((prev) => prev ?? loaded[0]?.id ?? null)
})
return () => {
stale = true
}
}, [])
+ const selectedId = resolveDiagnosticsHostId(hosts, params.hostId, manualSelection, routeKey)
const selected = hosts.find((h) => h.id === selectedId) ?? null
- const { state } = useHostClient(selected?.id)
+ const { client, state } = useHostClient(selected?.id)
+ const { desktopAppVersion: liveDesktopAppVersion } = useHostStatusGates({
+ hostId: selected?.id,
+ client,
+ connState: state
+ })
const reconnectAttempts = useReconnectAttempt(selected?.id)
- const lastConnectedAt = useLastConnectedAt(selected?.id)
+ const { activePath, pendingPath } = useConnectionPathStatus(selected?.id)
+
+ useEffect(() => {
+ if (selectedId) {
+ void readHydratedConnectionLog(connectionLogStore, selectedId)
+ }
+ }, [selectedId])
const subscribe = useCallback(
(listener: () => void) =>
@@ -60,25 +94,93 @@ export default function ConnectionLogScreen() {
[selectedId]
)
const entries = useSyncExternalStore(subscribe, getSnapshot)
+ const diagnosis = selected
+ ? diagnoseConnection({ endpoint: selected.endpoint, state, activePath, pendingPath, entries })
+ : null
+ const incidentId = selected
+ ? getReportableConnectionIncidentId({
+ endpoint: selected.endpoint,
+ state,
+ activePath,
+ pendingPath,
+ entries
+ })
+ : null
+ const submissionKey = selected && incidentId ? `${selected.id}:${incidentId}` : null
+ const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey)
+ const copied = copiedHostId === selectedId
const copyDiagnostics = useCallback(async () => {
if (!selected) {
return
}
+ const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
+ const snapshot = await readConnectionDiagnosticsSnapshot(
+ clientContext,
+ connectionLogStore,
+ selected.id
+ )
const report = buildConnectionDiagnosticsReport({
hostName: selected.name,
endpoint: selected.endpoint,
- state,
- reconnectAttempts,
- lastConnectedAt,
+ state: snapshot.state,
+ reconnectAttempts: snapshot.reconnectAttempts,
+ lastConnectedAt: snapshot.lastConnectedAt,
platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
appVersion: Constants.expoConfig?.version ?? 'unknown',
- entries
+ desktopAppVersion,
+ entries: snapshot.entries,
+ activePath: snapshot.activePath,
+ pendingPath: snapshot.pendingPath
})
await Clipboard.setStringAsync(report)
- setCopied(true)
- setTimeout(() => setCopied(false), 2000)
- }, [selected, state, reconnectAttempts, lastConnectedAt, entries])
+ setCopiedHostId(selected.id)
+ setTimeout(() => setCopiedHostId((hostId) => (hostId === selected.id ? null : hostId)), 2000)
+ }, [selected, liveDesktopAppVersion, clientContext])
+
+ const sendDiagnostics = useCallback(async () => {
+ if (!selected || !submissionKey || submissionState === 'sending') {
+ return
+ }
+ const startedKey = submissionKey
+ setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, 'sending'))
+ const appVersion = Constants.expoConfig?.version ?? 'unknown'
+ const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim()
+ const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
+ const snapshot = await readConnectionDiagnosticsSnapshot(
+ clientContext,
+ connectionLogStore,
+ selected.id
+ )
+ const currentIncidentId = getReportableConnectionIncidentId({
+ endpoint: selected.endpoint,
+ state: snapshot.state,
+ activePath: snapshot.activePath,
+ pendingPath: snapshot.pendingPath,
+ entries: snapshot.entries
+ })
+ if (`${selected.id}:${currentIncidentId ?? ''}` !== startedKey) {
+ setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, null))
+ return
+ }
+ const report = buildConnectionDiagnosticsReport({
+ hostName: selected.name,
+ endpoint: selected.endpoint,
+ state: snapshot.state,
+ reconnectAttempts: snapshot.reconnectAttempts,
+ lastConnectedAt: snapshot.lastConnectedAt,
+ platform,
+ appVersion,
+ desktopAppVersion,
+ entries: snapshot.entries,
+ activePath: snapshot.activePath,
+ pendingPath: snapshot.pendingPath
+ })
+ const result = await submitConnectionDiagnostics({ report, appVersion, platform })
+ setSubmissionStates((states) =>
+ updateDiagnosticsSubmissionState(states, startedKey, result.ok ? 'sent' : 'failed')
+ )
+ }, [selected, submissionKey, submissionState, liveDesktopAppVersion, clientContext])
return (
@@ -86,7 +188,7 @@ export default function ConnectionLogScreen() {
router.back()}>
- Connection log
+ Network diagnostics
{hosts.length > 1 && (
@@ -95,7 +197,9 @@ export default function ConnectionLogScreen() {
setSelectedId(host.id)}
+ onPress={() =>
+ setManualSelection({ hostId: host.id, requestedHostId: params.hostId, routeKey })
+ }
>
)}
- {copied ? 'Copied' : 'Copy diagnostics'}
+ {copied ? 'Copied' : 'Copy report'}
+ {diagnosis && (
+
+ What this suggests
+ {diagnosis.likelyCause}
+ {diagnosis.nextStep}
+ {diagnosis.reportability === 'orca-relay' && (
+ <>
+
+ Sends a size-limited redacted report including host name, endpoint, versions,
+ connection state, and events—never terminal contents or credentials.
+
+ void sendDiagnostics()}
+ disabled={submissionState === 'sending'}
+ >
+ {submissionState === 'sent' ? (
+
+ ) : (
+
+ )}
+
+ {submissionState === 'sending'
+ ? 'Sending…'
+ : submissionState === 'sent'
+ ? 'Diagnostics sent'
+ : submissionState === 'failed'
+ ? 'Retry sending'
+ : 'Send diagnostics to Orca'}
+
+
+ >
+ )}
+
+ )}
{entries.length > 0 ? (
-
+
) : (
- No connection events yet this session. Events appear as the app dials this host.
+ No connection events yet. Events appear as the app dials this host.
)}
>
@@ -199,6 +338,53 @@ const styles = StyleSheet.create({
fontSize: typography.metaSize,
color: colors.textSecondary
},
+ diagnosisCard: {
+ backgroundColor: colors.bgPanel,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.borderSubtle,
+ borderRadius: 10,
+ padding: spacing.md,
+ marginBottom: spacing.md
+ },
+ diagnosisHeading: {
+ fontSize: typography.metaSize,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ marginBottom: spacing.xs
+ },
+ diagnosisText: {
+ fontSize: typography.metaSize,
+ color: colors.textPrimary,
+ lineHeight: 18
+ },
+ diagnosisNext: {
+ fontSize: typography.metaSize,
+ color: colors.textSecondary,
+ lineHeight: 18,
+ marginTop: spacing.xs
+ },
+ privacyHint: {
+ marginTop: spacing.sm,
+ fontSize: 11,
+ lineHeight: 15,
+ color: colors.textMuted
+ },
+ sendButton: {
+ marginTop: spacing.md,
+ alignSelf: 'flex-start',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.md,
+ borderRadius: 8,
+ backgroundColor: colors.bgRaised
+ },
+ sendButtonText: {
+ fontSize: typography.metaSize,
+ fontWeight: '600',
+ color: colors.textPrimary
+ },
copyButton: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx
index 3400fd3694b..18ebe3efe3d 100644
--- a/mobile/app/h/[hostId]/index.tsx
+++ b/mobile/app/h/[hostId]/index.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
-import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
+import { View, Text, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
import {
@@ -63,14 +63,16 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
+import { HostDiagnosticsLink } from '../../../src/components/HostDiagnosticsLink'
import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner'
import { visibleHostRouteNotice } from '../../../src/host-route-notice'
import { MobileSearchField } from '../../../src/components/MobileSearchField'
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
import { setCachedRepos } from '../../../src/cache/repo-cache'
-import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
+import { colors, spacing } from '../../../src/theme/mobile-theme'
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
+import { hostScreenStyles as styles } from '../../../src/host-screen/host-screen-styles'
import { leaveHostRoute } from '../../../src/host-route-exit'
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
import {
@@ -1090,6 +1092,17 @@ export function HostScreen({
/>
)}
+ {connState !== 'connected' &&
+ !relayRecovery.pairingRejected &&
+ reconnectAttempts >= 3 &&
+ hostId ? (
+
+ router.push({ pathname: '/connection-log', params: { hostId: String(hostId) } })
+ }
+ />
+ ) : null}
+
{/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */}
{routeNotice && (
}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: colors.bgBase
- },
- topChrome: {
- backgroundColor: colors.bgPanel,
- borderBottomWidth: 1,
- borderBottomColor: colors.borderSubtle
- },
- statusBar: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- minHeight: 34,
- paddingTop: spacing.xs,
- paddingHorizontal: spacing.lg
- },
- backButton: {
- width: 32,
- height: 32,
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: spacing.xs
- },
- sidebarCollapseButton: {
- width: 24,
- height: 24,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radii.button,
- marginLeft: spacing.xs
- },
- hostIdentity: {
- flex: 1,
- flexDirection: 'row',
- alignItems: 'center',
- minWidth: 0,
- marginRight: spacing.md
- },
- hostNameText: {
- flex: 1,
- fontSize: 15,
- fontWeight: '600',
- color: colors.textPrimary
- },
- reconnectButton: {
- paddingVertical: 4,
- paddingHorizontal: spacing.sm,
- borderRadius: radii.button,
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- reconnectButtonText: {
- color: colors.textPrimary,
- fontSize: typography.metaSize,
- fontWeight: '600'
- },
- toolbar: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: spacing.xs + 2,
- paddingHorizontal: spacing.md,
- gap: spacing.sm,
- borderBottomWidth: 1,
- borderBottomColor: colors.borderSubtle
- },
- embeddedToolbar: {
- paddingVertical: spacing.xs + 2,
- paddingHorizontal: spacing.sm,
- gap: spacing.xs,
- borderBottomWidth: 1,
- borderBottomColor: colors.borderSubtle
- },
- embeddedToolbarRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm
- },
- embeddedFilterChip: {
- flex: 1,
- minWidth: 0,
- height: 30,
- justifyContent: 'center',
- paddingHorizontal: spacing.xs,
- paddingVertical: 0
- },
- embeddedModeButton: {
- flex: 1,
- minWidth: 0,
- height: 30,
- justifyContent: 'center',
- paddingHorizontal: spacing.xs,
- paddingVertical: 0
- },
- filterChip: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 4,
- paddingHorizontal: spacing.sm + 2,
- paddingVertical: spacing.xs,
- borderRadius: 12,
- borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- filterChipActive: {
- borderColor: colors.textSecondary,
- backgroundColor: colors.bgRaised
- },
- filterChipText: {
- fontSize: 12,
- color: colors.textSecondary
- },
- filterChipTextActive: {
- color: colors.textPrimary
- },
- modeButton: {
- flexDirection: 'row',
- alignItems: 'center',
- flexShrink: 1,
- minWidth: 0,
- gap: 4,
- paddingHorizontal: spacing.sm,
- paddingVertical: spacing.xs
- },
- sortLabel: {
- flexShrink: 1,
- minWidth: 0,
- fontSize: 12,
- color: colors.textSecondary
- },
- toolbarSpacer: {
- flex: 1
- },
- floatingWorkspaceHeaderButton: {
- width: 32,
- height: 32,
- alignItems: 'center',
- justifyContent: 'center',
- marginLeft: spacing.xs
- },
- embeddedToolbarIconButton: {
- flex: 1,
- height: 28,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radii.button
- },
- toolbarIconDisabled: {
- opacity: 0.6
- },
- searchToggle: {
- padding: spacing.xs
- },
- searchBar: {
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: colors.borderSubtle,
- backgroundColor: colors.bgPanel
- },
- centered: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center'
- },
- emptyText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize
- },
- errorText: {
- color: colors.statusRed,
- fontSize: typography.bodySize
- },
- list: {
- paddingBottom: spacing.lg
- },
- sectionHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: spacing.lg,
- paddingTop: spacing.md,
- paddingBottom: spacing.xs
- },
- sectionIcon: {
- marginRight: spacing.xs
- },
- sectionRepoIcon: {
- marginRight: spacing.xs
- },
- sectionTitle: {
- fontSize: 11,
- fontWeight: '600',
- color: colors.textMuted,
- textTransform: 'uppercase',
- letterSpacing: 0.5
- },
- sectionCount: {
- fontSize: 11,
- color: colors.textMuted,
- marginLeft: spacing.xs
- },
- separator: {
- height: 1,
- backgroundColor: colors.borderSubtle,
- marginLeft: spacing.lg + 24,
- marginRight: spacing.lg
- },
- filterModalHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingHorizontal: spacing.xs,
- marginBottom: spacing.md
- },
- filterModalTitle: {
- fontSize: 15,
- fontWeight: '600',
- color: colors.textPrimary
- },
- clearFiltersText: {
- fontSize: 13,
- color: colors.textSecondary
- },
- filterSectionLabel: {
- fontSize: 11,
- fontWeight: '600',
- color: colors.textMuted,
- textTransform: 'uppercase',
- letterSpacing: 0.5,
- marginBottom: spacing.xs,
- paddingHorizontal: spacing.xs
- },
- filterGroup: {
- backgroundColor: colors.bgPanel,
- borderRadius: 12,
- overflow: 'hidden',
- marginBottom: spacing.md
- },
- filterRow: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: spacing.md,
- paddingHorizontal: spacing.md + 2,
- gap: spacing.sm
- },
- filterRowText: {
- flex: 1,
- fontSize: typography.bodySize,
- color: colors.textPrimary
- },
- filterSeparator: {
- height: StyleSheet.hairlineWidth,
- backgroundColor: colors.borderSubtle,
- marginHorizontal: spacing.md
- },
- filterRepoDot: {
- width: 8,
- height: 8,
- borderRadius: 4
- },
- confirmContent: {
- paddingBottom: spacing.lg
- },
- confirmTitle: {
- fontSize: 16,
- fontWeight: '700',
- color: colors.textPrimary
- },
- confirmMessage: {
- fontSize: typography.bodySize,
- color: colors.textSecondary,
- marginTop: spacing.xs,
- lineHeight: 20
- },
- confirmButtons: {
- flexDirection: 'row',
- gap: spacing.sm
- },
- confirmBtn: {
- flex: 1,
- paddingVertical: spacing.sm + 2,
- borderRadius: 10,
- alignItems: 'center'
- },
- confirmBtnCancel: {
- backgroundColor: colors.bgPanel
- },
- confirmBtnDestructive: {
- backgroundColor: colors.statusRed
- },
- confirmBtnPressed: {
- opacity: 0.7
- },
- confirmBtnCancelText: {
- fontSize: typography.bodySize,
- fontWeight: '600',
- color: colors.textSecondary
- },
- confirmBtnDestructiveText: {
- fontSize: typography.bodySize,
- fontWeight: '600',
- color: '#fff'
- }
-})
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 14a94513507..0d36fa96020 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -117,6 +117,10 @@ import {
} from '../../../../src/terminal/terminal-accessory-layout'
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
+import {
+ createTerminalAccessoryRepeatController,
+ createTerminalAccessoryRepeatSender
+} from '../../../../src/terminal/terminal-accessory-repeat'
import {
clearTerminalLiveInputFocusTimer,
isTerminalLiveInputWithinByteLimit,
@@ -2993,16 +2997,22 @@ export default function SessionScreen() {
}
}
- async function handleAccessoryKey(input: ReturnType) {
- if (!client || !activeHandle || !canSend) {
- return
+ async function handleAccessoryKey(
+ input: ReturnType,
+ targetHandle = activeHandleRef.current,
+ isDeliveryTargetCurrent: () => boolean = () => targetHandle === activeHandleRef.current
+ ) {
+ if (!client || !targetHandle || !isDeliveryTargetCurrent() || !canSend) {
+ return false
}
- const targetHandle = activeHandle
const accessoryCommit = await handleLiveInputAccessoryBytes(input)
- if (accessoryCommit.kind !== 'allow-raw') {
- return
+ if (!isDeliveryTargetCurrent()) {
+ return false
}
- await sendTerminalLiveAccessoryRawBytes({
+ if (accessoryCommit.kind !== 'allow-raw') {
+ return accessoryCommit.kind === 'handled'
+ }
+ return sendTerminalLiveAccessoryRawBytes({
client: clientRef.current,
targetHandle,
activeHandle: activeHandleRef.current,
@@ -3399,32 +3409,37 @@ export default function SessionScreen() {
}
}
- // Why: hold-to-repeat matches iOS cadence (400ms then 45ms); non-repeatable keys fire once (holding is destructive).
- const repeatTimeoutRef = useRef | null>(null)
- const repeatIntervalRef = useRef | null>(null)
- // Why: ref keeps repeat firing the current callback; else a mid-hold tab switch/reconnect routes bytes to a stale terminal.
+ const accessoryRepeatRef = useRef(
+ createTerminalAccessoryRepeatController>()
+ )
+ // Why: the current callback observes reconnect state while the sender pins the press to its original terminal.
const handleAccessoryKeyRef = useRef(handleAccessoryKey)
handleAccessoryKeyRef.current = handleAccessoryKey
const stopAccessoryRepeat = useCallback(() => {
- if (repeatTimeoutRef.current) {
- clearTimeout(repeatTimeoutRef.current)
- repeatTimeoutRef.current = null
- }
- if (repeatIntervalRef.current) {
- clearInterval(repeatIntervalRef.current)
- repeatIntervalRef.current = null
- }
+ accessoryRepeatRef.current.stop()
}, [])
const startAccessoryRepeat = useCallback(
(input: ReturnType) => {
- stopAccessoryRepeat()
- repeatTimeoutRef.current = setTimeout(() => {
- repeatIntervalRef.current = setInterval(() => {
- void handleAccessoryKeyRef.current(input)
- }, 45)
- }, 400)
+ // Why: a held repeat must not resume through a replacement client or connection.
+ const targetClient = clientRef.current
+ const targetConnectedAt = targetClient?.getLastConnectedAt() ?? null
+ const isDeliveryTargetCurrent = (targetHandle: string) =>
+ activeHandleRef.current === targetHandle &&
+ targetClient !== null &&
+ clientRef.current === targetClient &&
+ connStateRef.current === 'connected' &&
+ targetClient.getLastConnectedAt() === targetConnectedAt
+ accessoryRepeatRef.current.start(
+ input,
+ createTerminalAccessoryRepeatSender(
+ activeHandleRef.current,
+ isDeliveryTargetCurrent,
+ (nextInput, targetHandle, isTargetCurrent) =>
+ handleAccessoryKeyRef.current(nextInput, targetHandle, isTargetCurrent)
+ )
+ )
},
- [stopAccessoryRepeat]
+ []
)
const setMobileSessionRootRef = useCallback(
(node: View | null): void => {
@@ -3440,15 +3455,14 @@ export default function SessionScreen() {
clearPendingLiveInputCommit()
sessionTabActionSheetRequestSeqRef.current += 1
clearSessionTabActionSheetKeyboardListener()
- stopAccessoryRepeat()
+ accessoryRepeatRef.current.cancel()
},
[
clearPendingLiveInputCommit,
clearDelayedActionTimers,
clearSessionTabActionSheetKeyboardListener,
clearTerminalCache,
- clearToastHideTimer,
- stopAccessoryRepeat
+ clearToastHideTimer
]
)
@@ -4848,7 +4862,6 @@ export default function SessionScreen() {
return
}
const input = createTerminalLiveAccessoryInput(key)
- void handleAccessoryKey(input)
startAccessoryRepeat(input)
}}
onPressOut={() => {
diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx
index c78d7a907df..61e99f19f2a 100644
--- a/mobile/app/pair-scan.tsx
+++ b/mobile/app/pair-scan.tsx
@@ -2,7 +2,6 @@ import { useState, useRef, useCallback } from 'react'
import {
View,
Text,
- StyleSheet,
Pressable,
ActivityIndicator,
Linking,
@@ -19,13 +18,14 @@ import {
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
import { useRefreshHostClient } from '../src/transport/client-context'
-import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
+import { colors, spacing } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
import {
loadMobileOnboardingSteps,
mobileOnboardingDestination
} from '../src/onboarding/mobile-onboarding-plan'
+import { pairScanStyles as styles } from '../src/pair-scan-styles'
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
// route surfaces as a real error with the log visible instead of a
@@ -356,186 +356,3 @@ export default function PairScanScreen() {
)
}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: colors.bgBase,
- padding: spacing.lg
- },
- backButton: {
- width: 36,
- height: 36,
- borderRadius: 18,
- alignItems: 'center',
- justifyContent: 'center',
- marginBottom: spacing.sm
- },
- steps: {
- gap: spacing.sm,
- marginBottom: spacing.lg,
- marginLeft: 7
- },
- step: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm
- },
- stepBadge: {
- width: 22,
- height: 22,
- borderRadius: 11,
- backgroundColor: colors.bgRaised,
- alignItems: 'center',
- justifyContent: 'center'
- },
- stepNumber: {
- fontSize: 12,
- fontWeight: '700',
- color: colors.textSecondary
- },
- stepText: {
- fontSize: typography.bodySize,
- color: colors.textSecondary
- },
- cameraWrap: {
- flex: 1,
- borderRadius: radii.camera,
- overflow: 'hidden'
- },
- // Why: holds the layout slot while the camera is unmounted during
- // paste, so the bottom action button doesn't snap up to fill the
- // empty space.
- cameraPlaceholder: {
- flex: 1,
- backgroundColor: colors.bgPanel,
- borderRadius: radii.camera
- },
- camera: {
- ...StyleSheet.absoluteFillObject
- },
- reticle: {
- ...StyleSheet.absoluteFillObject,
- alignItems: 'center',
- justifyContent: 'center'
- },
- reticleFrame: {
- position: 'relative'
- },
- corner: {
- position: 'absolute',
- width: 28,
- height: 28,
- borderColor: 'rgba(255,255,255,0.7)'
- },
- cornerTL: {
- top: 0,
- left: 0,
- borderTopWidth: 2.5,
- borderLeftWidth: 2.5,
- borderTopLeftRadius: 6
- },
- cornerTR: {
- top: 0,
- right: 0,
- borderTopWidth: 2.5,
- borderRightWidth: 2.5,
- borderTopRightRadius: 6
- },
- cornerBL: {
- bottom: 0,
- left: 0,
- borderBottomWidth: 2.5,
- borderLeftWidth: 2.5,
- borderBottomLeftRadius: 6
- },
- cornerBR: {
- bottom: 0,
- right: 0,
- borderBottomWidth: 2.5,
- borderRightWidth: 2.5,
- borderBottomRightRadius: 6
- },
- centered: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center'
- },
- title: {
- fontSize: typography.titleSize,
- fontWeight: '600',
- color: colors.textPrimary,
- marginBottom: spacing.sm
- },
- subtitle: {
- maxWidth: 310,
- fontSize: typography.bodySize,
- color: colors.textSecondary,
- textAlign: 'center',
- marginBottom: spacing.xl,
- lineHeight: 20
- },
- connectingText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- marginTop: spacing.lg
- },
- logSlot: {
- width: '100%',
- marginTop: spacing.lg,
- paddingHorizontal: spacing.sm
- },
- errorText: {
- color: colors.statusRed,
- fontSize: typography.bodySize,
- textAlign: 'center',
- marginBottom: spacing.xl,
- lineHeight: 20
- },
- primaryButton: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: spacing.xs,
- backgroundColor: colors.textPrimary,
- paddingHorizontal: spacing.xl,
- paddingVertical: spacing.sm + 2,
- borderRadius: radii.button
- },
- primaryButtonText: {
- color: colors.bgBase,
- fontSize: typography.bodySize,
- fontWeight: '600'
- },
- pasteButton: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: spacing.xs,
- marginTop: spacing.md,
- paddingVertical: spacing.sm,
- borderRadius: radii.button
- },
- pasteButtonPressed: {
- opacity: 0.6
- },
- pasteButtonText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- fontWeight: '500'
- },
- errorActions: {
- alignItems: 'center',
- gap: spacing.sm
- },
- secondaryButton: {
- paddingHorizontal: spacing.xl,
- paddingVertical: spacing.sm,
- borderRadius: radii.button
- },
- secondaryButtonText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- fontWeight: '500'
- }
-})
diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx
index 5ae53e0084c..c4313e88fcc 100644
--- a/mobile/app/troubleshoot.tsx
+++ b/mobile/app/troubleshoot.tsx
@@ -222,7 +222,7 @@ export default function TroubleshootScreen() {
onPress={() => router.push('/connection-log')}
>
- View connection log
+ View network diagnostics
{checks.length > 0 && (
diff --git a/mobile/patches/react-native@0.83.9.patch b/mobile/patches/react-native@0.83.9.patch
index 102b052746b..9bda1b252fa 100644
--- a/mobile/patches/react-native@0.83.9.patch
+++ b/mobile/patches/react-native@0.83.9.patch
@@ -46,3 +46,15 @@ index a3f2e01c130c1a53aee0b39e72927d21f2a6973d..c9d34f8b42644800b5c85e5be45b6ebf
AttributedString::Range selectionRange;
// ScrollView-like metrics
Size contentSize;
+diff --git a/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm
+index 79ee7ffe43..956f189a1c 100644
+--- a/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm
++++ b/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm
+@@ -136,6 +136,7 @@ using namespace facebook::react;
+ - (void)prepareForRecycle
+ {
+ [super prepareForRecycle];
+ _textView.state = nullptr;
++ _textView.layoutMetrics = EmptyLayoutMetrics;
+ _accessibilityProvider = nil;
+ }
diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml
index 56e64e1d442..01092f2acc9 100644
--- a/mobile/pnpm-lock.yaml
+++ b/mobile/pnpm-lock.yaml
@@ -9,7 +9,7 @@ overrides:
patchedDependencies:
react-native@0.83.9:
- hash: a6502e7d63769bf1fd639cbbaab1cc0b8aebef31d22048e8f2186fb399d0c16e
+ hash: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d
path: patches/react-native@0.83.9.patch
importers:
diff --git a/mobile/scripts/repro-terminal-color-output.ts b/mobile/scripts/repro-terminal-color-output.ts
new file mode 100644
index 00000000000..50e4e153b54
--- /dev/null
+++ b/mobile/scripts/repro-terminal-color-output.ts
@@ -0,0 +1,60 @@
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+const ESC = String.fromCharCode(27)
+
+export type TerminalColorSnapshot = {
+ label: string
+ handle: string
+ cols: number | null
+ rows: number | null
+ serialized: string
+ lines: string
+}
+
+function countMatches(value: string, pattern: RegExp): number {
+ return value.match(pattern)?.length ?? 0
+}
+
+export function summarizeTerminalColorSnapshot(
+ snapshot: TerminalColorSnapshot
+): Record {
+ const data = snapshot.serialized
+ return {
+ label: snapshot.label,
+ handle: snapshot.handle,
+ cols: snapshot.cols,
+ rows: snapshot.rows,
+ serializedBytes: Buffer.byteLength(data),
+ sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
+ sgrColor: countMatches(
+ data,
+ new RegExp(
+ `${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
+ 'g'
+ )
+ ),
+ sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
+ altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
+ containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
+ containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
+ }
+}
+
+export function saveTerminalColorSnapshots(snapshots: TerminalColorSnapshot[]): string {
+ const dir = join(process.cwd(), 'terminal-color-repro')
+ mkdirSync(dir, { recursive: true })
+ for (const snapshot of snapshots) {
+ const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
+ writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
+ writeFileSync(
+ join(dir, `${base}.escaped.txt`),
+ JSON.stringify(snapshot.serialized || snapshot.lines)
+ )
+ }
+ writeFileSync(
+ join(dir, 'summary.json'),
+ JSON.stringify(snapshots.map(summarizeTerminalColorSnapshot), null, 2)
+ )
+ return dir
+}
diff --git a/mobile/scripts/repro-terminal-colors.ts b/mobile/scripts/repro-terminal-colors.ts
index 3c21df64176..e6c302652a0 100644
--- a/mobile/scripts/repro-terminal-colors.ts
+++ b/mobile/scripts/repro-terminal-colors.ts
@@ -10,10 +10,13 @@
* ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \
* pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB]
*/
-import { mkdirSync, writeFileSync } from 'node:fs'
-import { join } from 'node:path'
import nacl from 'tweetnacl'
import WebSocket from 'ws'
+import {
+ saveTerminalColorSnapshots,
+ summarizeTerminalColorSnapshot,
+ type TerminalColorSnapshot
+} from './repro-terminal-color-output'
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
const token = process.argv[2]
@@ -21,7 +24,6 @@ const serverPublicKeyB64 = process.argv[3]
const worktreeSelector = process.argv[4]
const explicitHandleA = process.argv[5]
const explicitHandleB = process.argv[6]
-const ESC = String.fromCharCode(27)
type RpcResponse = {
id: string
@@ -37,15 +39,6 @@ type PendingRequest = {
reject: (error: Error) => void
}
-type Snapshot = {
- label: string
- handle: string
- cols: number | null
- rows: number | null
- serialized: string
- lines: string
-}
-
if (!token || !serverPublicKeyB64 || !worktreeSelector) {
console.error(
'Usage: pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB]'
@@ -158,9 +151,13 @@ async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise {
+async function captureSnapshot(
+ ws: WebSocket,
+ label: string,
+ handle: string
+): Promise {
const id = nextId()
- const snapshot = await new Promise((resolve, reject) => {
+ const snapshot = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pending.delete(id)
streamListeners.delete(id)
@@ -207,48 +204,6 @@ async function captureSnapshot(ws: WebSocket, label: string, handle: string): Pr
return snapshot
}
-function countMatches(value: string, pattern: RegExp): number {
- return value.match(pattern)?.length ?? 0
-}
-
-function summarize(snapshot: Snapshot): Record {
- const data = snapshot.serialized
- return {
- label: snapshot.label,
- handle: snapshot.handle,
- cols: snapshot.cols,
- rows: snapshot.rows,
- serializedBytes: Buffer.byteLength(data),
- sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
- sgrColor: countMatches(
- data,
- new RegExp(
- `${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
- 'g'
- )
- ),
- sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
- altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
- containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
- containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
- }
-}
-
-function saveSnapshots(snapshots: Snapshot[]): string {
- const dir = join(process.cwd(), 'terminal-color-repro')
- mkdirSync(dir, { recursive: true })
- for (const snapshot of snapshots) {
- const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
- writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
- writeFileSync(
- join(dir, `${base}.escaped.txt`),
- JSON.stringify(snapshot.serialized || snapshot.lines)
- )
- }
- writeFileSync(join(dir, 'summary.json'), JSON.stringify(snapshots.map(summarize), null, 2))
- return dir
-}
-
async function run(ws: WebSocket): Promise {
ws.send(
JSON.stringify({
@@ -303,11 +258,14 @@ async function run(ws: WebSocket): Promise {
const firstB = await captureSnapshot(ws, 'b-during-switch', handleB)
const secondA = await captureSnapshot(ws, 'a-after-switch', handleA)
const snapshots = [firstA, firstB, secondA]
- const dir = saveSnapshots(snapshots)
+ const dir = saveTerminalColorSnapshots(snapshots)
- console.table(snapshots.map(summarize))
+ console.table(snapshots.map(summarizeTerminalColorSnapshot))
console.log(`saved: ${dir}`)
- if (summarize(firstA).sgrColor !== summarize(secondA).sgrColor) {
+ if (
+ summarizeTerminalColorSnapshot(firstA).sgrColor !==
+ summarizeTerminalColorSnapshot(secondA).sgrColor
+ ) {
console.log(
'color SGR count changed between A snapshots: serialization/state changed on desktop'
)
diff --git a/mobile/scripts/repro-worktree-startup-output.ts b/mobile/scripts/repro-worktree-startup-output.ts
new file mode 100644
index 00000000000..83e127a3fa6
--- /dev/null
+++ b/mobile/scripts/repro-worktree-startup-output.ts
@@ -0,0 +1,75 @@
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+const ESC = String.fromCharCode(27)
+
+export type StartupTerminalCapture = {
+ handle: string
+ title: string | null
+ scrollback: Record | null
+ chunks: string[]
+}
+
+function stripAnsi(value: string): string {
+ return (
+ value
+ // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
+ // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
+ .replace(/\r/g, '\n')
+ )
+}
+
+function normalizePreview(value: string): string {
+ return stripAnsi(value)
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .slice(0, 8)
+ .join('\n')
+}
+
+export function summarizeStartupCapture(capture: StartupTerminalCapture): Record {
+ const serialized =
+ typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
+ const live = capture.chunks.join('')
+ const serializedPreview = normalizePreview(serialized)
+ const livePreview = normalizePreview(live)
+ return {
+ handle: capture.handle,
+ title: capture.title,
+ cols: capture.scrollback?.cols ?? null,
+ rows: capture.scrollback?.rows ?? null,
+ serializedBytes: Buffer.byteLength(serialized),
+ liveBytes: Buffer.byteLength(live),
+ liveChunks: capture.chunks.length,
+ serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
+ liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
+ livePreviewContainedInSerialized:
+ livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
+ serializedPreview,
+ livePreview
+ }
+}
+
+export function saveStartupCaptures(
+ captures: StartupTerminalCapture[],
+ worktreeName: string
+): string {
+ const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
+ mkdirSync(dir, { recursive: true })
+ for (const capture of captures) {
+ const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
+ const serialized =
+ typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
+ writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
+ writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
+ writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
+ }
+ writeFileSync(
+ join(dir, 'summary.json'),
+ JSON.stringify(captures.map(summarizeStartupCapture), null, 2)
+ )
+ return dir
+}
diff --git a/mobile/scripts/repro-worktree-startup-stream.ts b/mobile/scripts/repro-worktree-startup-stream.ts
index e4db6182f00..f3bbdc0d5d0 100644
--- a/mobile/scripts/repro-worktree-startup-stream.ts
+++ b/mobile/scripts/repro-worktree-startup-stream.ts
@@ -4,10 +4,15 @@
* Usage:
* pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand]
*/
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import nacl from 'tweetnacl'
import WebSocket from 'ws'
+import {
+ saveStartupCaptures,
+ summarizeStartupCapture,
+ type StartupTerminalCapture
+} from './repro-worktree-startup-output'
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
const USER_DATA =
@@ -15,7 +20,6 @@ const USER_DATA =
const repoSelector = process.argv[2]
const worktreeName = process.argv[3]
const startupCommand = process.argv[4] || 'claude'
-const ESC = String.fromCharCode(27)
type RpcResponse = {
id: string
@@ -36,13 +40,6 @@ type TerminalInfo = {
title: string | null
}
-type Capture = {
- handle: string
- title: string | null
- scrollback: Record | null
- chunks: string[]
-}
-
if (!repoSelector || !worktreeName) {
console.error(
'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand]'
@@ -141,26 +138,6 @@ function formatError(response: RpcResponse): string {
return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000)
}
-function stripAnsi(value: string): string {
- return (
- value
- // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
- .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
- // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
- .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
- .replace(/\r/g, '\n')
- )
-}
-
-function normalizePreview(value: string): string {
- return stripAnsi(value)
- .split('\n')
- .map((line) => line.trim())
- .filter(Boolean)
- .slice(0, 8)
- .join('\n')
-}
-
async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise {
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
@@ -185,7 +162,7 @@ async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise {
+async function subscribe(ws: WebSocket, capture: StartupTerminalCapture): Promise {
const id = nextId()
streamListeners.set(id, (result) => {
if (result.type === 'scrollback') {
@@ -204,44 +181,6 @@ async function subscribe(ws: WebSocket, capture: Capture): Promise {
})
}
-function summarize(capture: Capture): Record {
- const serialized =
- typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
- const live = capture.chunks.join('')
- const serializedPreview = normalizePreview(serialized)
- const livePreview = normalizePreview(live)
- return {
- handle: capture.handle,
- title: capture.title,
- cols: capture.scrollback?.cols ?? null,
- rows: capture.scrollback?.rows ?? null,
- serializedBytes: Buffer.byteLength(serialized),
- liveBytes: Buffer.byteLength(live),
- liveChunks: capture.chunks.length,
- serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
- liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
- livePreviewContainedInSerialized:
- livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
- serializedPreview,
- livePreview
- }
-}
-
-function save(captures: Capture[], worktreeName: string): string {
- const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
- mkdirSync(dir, { recursive: true })
- for (const capture of captures) {
- const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
- const serialized =
- typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
- writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
- writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
- writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
- }
- writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2))
- return dir
-}
-
async function run(ws: WebSocket): Promise {
ws.send(
JSON.stringify({
@@ -312,8 +251,8 @@ async function run(ws: WebSocket): Promise {
for (const capture of captures) {
await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null)
}
- const dir = save(captures, worktreeName)
- console.table(captures.map(summarize))
+ const dir = saveStartupCaptures(captures, worktreeName)
+ console.table(captures.map(summarizeStartupCapture))
console.log(`saved: ${dir}`)
}
diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx
index 6397f69c549..f823f309630 100644
--- a/mobile/src/browser/MobileBrowserPane.tsx
+++ b/mobile/src/browser/MobileBrowserPane.tsx
@@ -1,7 +1,4 @@
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */
-// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
-// can't resolve Node's builtin in a React Native bundle.
-import { Buffer } from 'buffer'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
@@ -10,7 +7,6 @@ import {
PanResponder,
PixelRatio,
Pressable,
- StyleSheet,
Text,
TextInput,
View,
@@ -24,7 +20,7 @@ import type {
BrowserScreencastFrame,
BrowserScreencastFrameMetadata
} from '../transport/browser-screencast-protocol'
-import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import { colors } from '../theme/mobile-theme'
import {
MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS,
buildMobileBrowserScreencastRequest,
@@ -47,11 +43,32 @@ import {
computeBrowserTouchClickRadiusCss,
mapScreenToBrowserPoint,
readLocalTouchPoint,
- type BrowserFrameGeometry,
type BrowserPoint,
type BrowserTouchLayout,
type BrowserZoomState
} from './browser-touch-geometry'
+import {
+ MAX_ZOOM,
+ MIN_ZOOM,
+ assertRpcOk,
+ browserErrorMessage,
+ browserFrameMetadataEqual,
+ buttonColor,
+ cacheBrowserFrame,
+ clearCachedBrowserFramesForWorktree,
+ createBrowserFrameDataUri,
+ createPinchGesture,
+ getCachedBrowserFrame,
+ makeBrowserFrameCacheKey,
+ peekCachedBrowserFrame,
+ shouldSurfaceBrowserError,
+ updateBrowserImageSource,
+ updateBrowserLayerVisibility,
+ updatePinchZoom,
+ type FrameLayer,
+ type PinchGesture
+} from './mobile-browser-frame-state'
+import { mobileBrowserPaneStyles as styles } from './mobile-browser-pane-styles'
import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url'
import { MobileBrowserAddressField } from './MobileBrowserAddressField'
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
@@ -79,15 +96,6 @@ type MobileBrowserPaneProps = {
onToast: (message: string, durationMs?: number) => void
}
-type FrameLayer = 0 | 1
-
-type PinchGesture = {
- distance: number
- scale: number
- anchorX: number
- anchorY: number
-}
-
type PanGesture = {
x: number
y: number
@@ -105,17 +113,7 @@ const SCROLL_START_SLOP = 22
const LONG_PRESS_MS = 550
const WHEEL_INTERVAL_MS = 70
const TOUCH_CLICK_RADIUS_DIP = 14
-const MIN_ZOOM = 1
-const MAX_ZOOM = 3.5
const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 }
-const BROWSER_FRAME_CACHE_LIMIT = 4
-
-type BrowserFrameCacheEntry = {
- uri: string
- metadata: BrowserScreencastFrameMetadata
-}
-
-const browserFrameCache = new Map()
type BrowserPageParams = {
worktree: string
@@ -1268,350 +1266,3 @@ export function MobileBrowserPane({
)
}
-
-function buttonColor(enabled: boolean): string {
- return enabled ? colors.textSecondary : colors.textMuted
-}
-
-function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
- return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
-}
-
-function makeBrowserFrameCacheKey(
- worktreeId: string,
- browserPageId: string | null,
- viewMode: MobileBrowserViewMode
-): string | null {
- return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
-}
-
-function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
- const prefix = `${worktreeId}:`
- for (const key of browserFrameCache.keys()) {
- if (key.startsWith(prefix)) {
- browserFrameCache.delete(key)
- }
- }
-}
-
-function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
- if (!cacheKey) {
- return null
- }
- const cached = browserFrameCache.get(cacheKey)
- if (!cached) {
- return null
- }
- browserFrameCache.delete(cacheKey)
- browserFrameCache.set(cacheKey, cached)
- return cached
-}
-
-function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
- return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
-}
-
-function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
- if (!cacheKey) {
- return
- }
- browserFrameCache.delete(cacheKey)
- browserFrameCache.set(cacheKey, entry)
- while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
- const oldestKey = browserFrameCache.keys().next().value
- if (typeof oldestKey !== 'string') {
- break
- }
- browserFrameCache.delete(oldestKey)
- }
-}
-
-function updateBrowserLayerVisibility(
- layers: [View | null, View | null],
- visible: FrameLayer
-): void {
- for (const [index, layer] of layers.entries()) {
- layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
- }
-}
-
-function updateBrowserImageSource(image: Image | null, uri: string): void {
- // Why: browser frames are large strings; mutating only the native Image
- // source avoids re-rendering the whole tab view for every streamed frame.
- const source = [{ uri }]
- image?.setNativeProps({ source, src: source })
-}
-
-function assertRpcOk(
- response: RpcSuccess | RpcFailure,
- fallbackMessage: string
-): asserts response is RpcSuccess {
- if (!response.ok) {
- throw new Error(response.error.message || fallbackMessage)
- }
-}
-
-function browserFrameMetadataEqual(
- a: BrowserScreencastFrameMetadata | null,
- b: BrowserScreencastFrameMetadata
-): boolean {
- return (
- a?.deviceWidth === b.deviceWidth &&
- a?.deviceHeight === b.deviceHeight &&
- a?.pageScaleFactor === b.pageScaleFactor
- )
-}
-
-function browserErrorMessage(error: unknown, fallback: string): string {
- return error instanceof Error && error.message ? error.message : fallback
-}
-
-function shouldSurfaceBrowserError(message: string): boolean {
- const normalized = message.toLowerCase()
- // Why: selector_not_found can be emitted by in-flight page automation while
- // the browser is still usable; replacing the frame with it feels like a crash.
- return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
-}
-
-function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
- const touches = event.nativeEvent.touches
- if (!touches || touches.length < 2) {
- return null
- }
- const a = readLocalTouchPoint(touches[0])
- const b = readLocalTouchPoint(touches[1])
- return a && b ? { a, b } : null
-}
-
-function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
- return Math.hypot(a.x - b.x, a.y - b.y)
-}
-
-function createPinchGesture(
- event: GestureResponderEvent,
- geometry: BrowserFrameGeometry | null,
- zoom: BrowserZoomState
-): PinchGesture | null {
- if (!geometry) {
- return null
- }
- const pair = touchPair(event)
- if (!pair) {
- return null
- }
- const distance = pointDistance(pair.a, pair.b)
- if (distance < 8) {
- return null
- }
- const centerX = (pair.a.x + pair.b.x) / 2
- const centerY = (pair.a.y + pair.b.y) / 2
- const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
- const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
- return {
- distance,
- scale: zoom.scale,
- anchorX: (centerX - frameCenterX) / zoom.scale,
- anchorY: (centerY - frameCenterY) / zoom.scale
- }
-}
-
-function updatePinchZoom(
- event: GestureResponderEvent,
- geometry: BrowserFrameGeometry | null,
- pinch: PinchGesture
-): BrowserZoomState | null {
- if (!geometry) {
- return null
- }
- const pair = touchPair(event)
- if (!pair) {
- return null
- }
- const nextScale = Math.min(
- MAX_ZOOM,
- Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
- )
- const centerX = (pair.a.x + pair.b.x) / 2
- const centerY = (pair.a.y + pair.b.y) / 2
- const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
- const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
- return clampBrowserZoomState(
- {
- scale: nextScale,
- offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
- offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
- },
- geometry,
- MIN_ZOOM,
- MAX_ZOOM
- )
-}
-
-const styles = StyleSheet.create({
- root: {
- flex: 1,
- minHeight: 0,
- backgroundColor: colors.bgBase
- },
- toolbar: {
- minHeight: 32,
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.xs,
- paddingHorizontal: spacing.sm,
- paddingVertical: 2,
- borderBottomWidth: 1,
- borderBottomColor: colors.borderSubtle,
- backgroundColor: colors.bgPanel
- },
- viewport: {
- flex: 1,
- minHeight: 0,
- overflow: 'hidden',
- backgroundColor: colors.bgBase
- },
- browserImageHost: {
- ...StyleSheet.absoluteFillObject,
- alignItems: 'center',
- justifyContent: 'center',
- overflow: 'hidden'
- },
- browserImageFill: {
- width: '100%',
- height: '100%'
- },
- browserImageLayer: {
- ...StyleSheet.absoluteFillObject,
- alignItems: 'center',
- justifyContent: 'center'
- },
- browserImageLayerHidden: {
- opacity: 0
- },
- browserZoomOffset: {
- alignItems: 'center',
- justifyContent: 'center'
- },
- browserFrameBox: {
- alignItems: 'center',
- justifyContent: 'center',
- overflow: 'hidden'
- },
- browserImage: {
- backgroundColor: colors.bgBase
- },
- overlay: {
- ...StyleSheet.absoluteFillObject,
- alignItems: 'center',
- justifyContent: 'center',
- padding: spacing.xl,
- gap: spacing.sm,
- backgroundColor: 'rgba(13, 15, 24, 0.2)'
- },
- errorText: {
- color: colors.textPrimary,
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- borderRadius: radii.button,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- fontSize: 13,
- textAlign: 'center',
- overflow: 'hidden'
- },
- dialogOverlay: {
- ...StyleSheet.absoluteFillObject,
- zIndex: 30,
- alignItems: 'center',
- justifyContent: 'center',
- padding: spacing.xl,
- backgroundColor: 'rgba(13, 15, 24, 0.5)'
- },
- dialogCard: {
- width: '100%',
- maxWidth: 360,
- borderRadius: radii.card,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- backgroundColor: colors.bgPanel,
- padding: spacing.lg
- },
- dialogTitle: {
- color: colors.textPrimary,
- fontSize: 16,
- fontWeight: '600'
- },
- dialogMessage: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- lineHeight: 20,
- marginTop: spacing.sm
- },
- dialogActions: {
- flexDirection: 'row',
- justifyContent: 'flex-end',
- gap: spacing.sm,
- marginTop: spacing.lg
- },
- dialogButton: {
- minHeight: 34,
- borderRadius: radii.button,
- backgroundColor: colors.bgRaised,
- paddingHorizontal: spacing.md,
- alignItems: 'center',
- justifyContent: 'center'
- },
- dialogButtonPrimary: {
- backgroundColor: colors.textPrimary
- },
- dialogButtonPressed: {
- opacity: 0.75
- },
- dialogButtonText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- fontWeight: '600'
- },
- dialogButtonPrimaryText: {
- color: colors.bgBase
- },
- keyboardDock: {
- zIndex: 20,
- borderTopWidth: 1,
- borderTopColor: colors.borderSubtle,
- backgroundColor: colors.bgPanel
- },
- inputRow: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: spacing.md,
- paddingTop: spacing.xs,
- paddingBottom: spacing.xs + 2
- },
- keyboardInput: {
- flex: 1,
- height: 34,
- backgroundColor: colors.bgRaised,
- color: colors.textPrimary,
- borderRadius: radii.input,
- paddingHorizontal: spacing.md,
- fontSize: 14,
- fontFamily: typography.monoFamily,
- marginRight: spacing.sm
- },
- sendButton: {
- width: 34,
- height: 34,
- borderRadius: 17,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: colors.bgRaised
- },
- disabled: {
- opacity: 0.35
- },
- disabledText: {
- color: colors.textMuted
- }
-})
diff --git a/mobile/src/browser/mobile-browser-frame-state.ts b/mobile/src/browser/mobile-browser-frame-state.ts
new file mode 100644
index 00000000000..9609c2ffda4
--- /dev/null
+++ b/mobile/src/browser/mobile-browser-frame-state.ts
@@ -0,0 +1,210 @@
+import { Buffer } from 'buffer'
+import type { GestureResponderEvent, Image, View } from 'react-native'
+import type { RpcFailure, RpcSuccess } from '../transport/types'
+import type {
+ BrowserScreencastFrame,
+ BrowserScreencastFrameMetadata
+} from '../transport/browser-screencast-protocol'
+import { colors } from '../theme/mobile-theme'
+import {
+ clampBrowserZoomState,
+ readLocalTouchPoint,
+ type BrowserFrameGeometry,
+ type BrowserPoint,
+ type BrowserZoomState
+} from './browser-touch-geometry'
+import type { MobileBrowserViewMode } from './browser-screencast-request'
+
+export type FrameLayer = 0 | 1
+export type PinchGesture = {
+ distance: number
+ scale: number
+ anchorX: number
+ anchorY: number
+}
+export type BrowserFrameCacheEntry = {
+ uri: string
+ metadata: BrowserScreencastFrameMetadata
+}
+export const MIN_ZOOM = 1
+export const MAX_ZOOM = 3.5
+const BROWSER_FRAME_CACHE_LIMIT = 4
+const browserFrameCache = new Map()
+
+export function buttonColor(enabled: boolean): string {
+ return enabled ? colors.textSecondary : colors.textMuted
+}
+
+export function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
+ return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
+}
+
+export function makeBrowserFrameCacheKey(
+ worktreeId: string,
+ browserPageId: string | null,
+ viewMode: MobileBrowserViewMode
+): string | null {
+ return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
+}
+
+export function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
+ const prefix = `${worktreeId}:`
+ for (const key of browserFrameCache.keys()) {
+ if (key.startsWith(prefix)) {
+ browserFrameCache.delete(key)
+ }
+ }
+}
+
+export function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
+ if (!cacheKey) {
+ return null
+ }
+ const cached = browserFrameCache.get(cacheKey)
+ if (!cached) {
+ return null
+ }
+ browserFrameCache.delete(cacheKey)
+ browserFrameCache.set(cacheKey, cached)
+ return cached
+}
+
+export function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
+ return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
+}
+
+export function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
+ if (!cacheKey) {
+ return
+ }
+ browserFrameCache.delete(cacheKey)
+ browserFrameCache.set(cacheKey, entry)
+ while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
+ const oldestKey = browserFrameCache.keys().next().value
+ if (typeof oldestKey !== 'string') {
+ break
+ }
+ browserFrameCache.delete(oldestKey)
+ }
+}
+
+export function updateBrowserLayerVisibility(
+ layers: [View | null, View | null],
+ visible: FrameLayer
+): void {
+ for (const [index, layer] of layers.entries()) {
+ layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
+ }
+}
+
+export function updateBrowserImageSource(image: Image | null, uri: string): void {
+ // Why: browser frames are large strings; mutating only the native Image
+ // source avoids re-rendering the whole tab view for every streamed frame.
+ const source = [{ uri }]
+ image?.setNativeProps({ source, src: source })
+}
+
+export function assertRpcOk(
+ response: RpcSuccess | RpcFailure,
+ fallbackMessage: string
+): asserts response is RpcSuccess {
+ if (!response.ok) {
+ throw new Error(response.error.message || fallbackMessage)
+ }
+}
+
+export function browserFrameMetadataEqual(
+ a: BrowserScreencastFrameMetadata | null,
+ b: BrowserScreencastFrameMetadata
+): boolean {
+ return (
+ a?.deviceWidth === b.deviceWidth &&
+ a?.deviceHeight === b.deviceHeight &&
+ a?.pageScaleFactor === b.pageScaleFactor
+ )
+}
+
+export function browserErrorMessage(error: unknown, fallback: string): string {
+ return error instanceof Error && error.message ? error.message : fallback
+}
+
+export function shouldSurfaceBrowserError(message: string): boolean {
+ const normalized = message.toLowerCase()
+ // Why: selector_not_found can be emitted by in-flight page automation while
+ // the browser is still usable; replacing the frame with it feels like a crash.
+ return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
+}
+
+function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
+ const touches = event.nativeEvent.touches
+ if (!touches || touches.length < 2) {
+ return null
+ }
+ const a = readLocalTouchPoint(touches[0])
+ const b = readLocalTouchPoint(touches[1])
+ return a && b ? { a, b } : null
+}
+
+function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
+ return Math.hypot(a.x - b.x, a.y - b.y)
+}
+
+export function createPinchGesture(
+ event: GestureResponderEvent,
+ geometry: BrowserFrameGeometry | null,
+ zoom: BrowserZoomState
+): PinchGesture | null {
+ if (!geometry) {
+ return null
+ }
+ const pair = touchPair(event)
+ if (!pair) {
+ return null
+ }
+ const distance = pointDistance(pair.a, pair.b)
+ if (distance < 8) {
+ return null
+ }
+ const centerX = (pair.a.x + pair.b.x) / 2
+ const centerY = (pair.a.y + pair.b.y) / 2
+ const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
+ const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
+ return {
+ distance,
+ scale: zoom.scale,
+ anchorX: (centerX - frameCenterX) / zoom.scale,
+ anchorY: (centerY - frameCenterY) / zoom.scale
+ }
+}
+
+export function updatePinchZoom(
+ event: GestureResponderEvent,
+ geometry: BrowserFrameGeometry | null,
+ pinch: PinchGesture
+): BrowserZoomState | null {
+ if (!geometry) {
+ return null
+ }
+ const pair = touchPair(event)
+ if (!pair) {
+ return null
+ }
+ const nextScale = Math.min(
+ MAX_ZOOM,
+ Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
+ )
+ const centerX = (pair.a.x + pair.b.x) / 2
+ const centerY = (pair.a.y + pair.b.y) / 2
+ const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
+ const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
+ return clampBrowserZoomState(
+ {
+ scale: nextScale,
+ offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
+ offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
+ },
+ geometry,
+ MIN_ZOOM,
+ MAX_ZOOM
+ )
+}
diff --git a/mobile/src/browser/mobile-browser-pane-styles.ts b/mobile/src/browser/mobile-browser-pane-styles.ts
new file mode 100644
index 00000000000..b41428516ba
--- /dev/null
+++ b/mobile/src/browser/mobile-browser-pane-styles.ts
@@ -0,0 +1,171 @@
+import { StyleSheet } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+export const mobileBrowserPaneStyles = StyleSheet.create({
+ root: {
+ flex: 1,
+ minHeight: 0,
+ backgroundColor: colors.bgBase
+ },
+ toolbar: {
+ minHeight: 32,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: 2,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderSubtle,
+ backgroundColor: colors.bgPanel
+ },
+ viewport: {
+ flex: 1,
+ minHeight: 0,
+ overflow: 'hidden',
+ backgroundColor: colors.bgBase
+ },
+ browserImageHost: {
+ ...StyleSheet.absoluteFillObject,
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden'
+ },
+ browserImageFill: {
+ width: '100%',
+ height: '100%'
+ },
+ browserImageLayer: {
+ ...StyleSheet.absoluteFillObject,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ browserImageLayerHidden: {
+ opacity: 0
+ },
+ browserZoomOffset: {
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ browserFrameBox: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden'
+ },
+ browserImage: {
+ backgroundColor: colors.bgBase
+ },
+ overlay: {
+ ...StyleSheet.absoluteFillObject,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: spacing.xl,
+ gap: spacing.sm,
+ backgroundColor: 'rgba(13, 15, 24, 0.2)'
+ },
+ errorText: {
+ color: colors.textPrimary,
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ borderRadius: radii.button,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ fontSize: 13,
+ textAlign: 'center',
+ overflow: 'hidden'
+ },
+ dialogOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ zIndex: 30,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: spacing.xl,
+ backgroundColor: 'rgba(13, 15, 24, 0.5)'
+ },
+ dialogCard: {
+ width: '100%',
+ maxWidth: 360,
+ borderRadius: radii.card,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ backgroundColor: colors.bgPanel,
+ padding: spacing.lg
+ },
+ dialogTitle: {
+ color: colors.textPrimary,
+ fontSize: 16,
+ fontWeight: '600'
+ },
+ dialogMessage: {
+ color: colors.textSecondary,
+ fontSize: typography.bodySize,
+ lineHeight: 20,
+ marginTop: spacing.sm
+ },
+ dialogActions: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ gap: spacing.sm,
+ marginTop: spacing.lg
+ },
+ dialogButton: {
+ minHeight: 34,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised,
+ paddingHorizontal: spacing.md,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ dialogButtonPrimary: {
+ backgroundColor: colors.textPrimary
+ },
+ dialogButtonPressed: {
+ opacity: 0.75
+ },
+ dialogButtonText: {
+ color: colors.textSecondary,
+ fontSize: typography.bodySize,
+ fontWeight: '600'
+ },
+ dialogButtonPrimaryText: {
+ color: colors.bgBase
+ },
+ keyboardDock: {
+ zIndex: 20,
+ borderTopWidth: 1,
+ borderTopColor: colors.borderSubtle,
+ backgroundColor: colors.bgPanel
+ },
+ inputRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: spacing.md,
+ paddingTop: spacing.xs,
+ paddingBottom: spacing.xs + 2
+ },
+ keyboardInput: {
+ flex: 1,
+ height: 34,
+ backgroundColor: colors.bgRaised,
+ color: colors.textPrimary,
+ borderRadius: radii.input,
+ paddingHorizontal: spacing.md,
+ fontSize: 14,
+ fontFamily: typography.monoFamily,
+ marginRight: spacing.sm
+ },
+ sendButton: {
+ width: 34,
+ height: 34,
+ borderRadius: 17,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: colors.bgRaised
+ },
+ disabled: {
+ opacity: 0.35
+ },
+ disabledText: {
+ color: colors.textMuted
+ }
+})
diff --git a/mobile/src/components/ConnectionLog.test.tsx b/mobile/src/components/ConnectionLog.test.tsx
new file mode 100644
index 00000000000..c310833f157
--- /dev/null
+++ b/mobile/src/components/ConnectionLog.test.tsx
@@ -0,0 +1,81 @@
+import { createElement } from 'react'
+import { act, create } from 'react-test-renderer'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { ConnectionLogEntry } from '../transport/types'
+import { ConnectionLog } from './ConnectionLog'
+
+vi.mock('react-native', () => ({
+ ScrollView: 'ScrollView',
+ StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 },
+ Text: 'Text',
+ View: 'View'
+}))
+
+const entries: ConnectionLogEntry[] = [
+ { id: 'event-1', ts: 1, level: 'info', message: 'Opening WebSocket' }
+]
+
+const duplicateEntries: ConnectionLogEntry[] = [
+ { id: 'relay-1', ts: 1, level: 'info', message: 'Relay recovery started' },
+ { id: 'relay-1', ts: 2, level: 'warn', message: 'Relay retry scheduled' }
+]
+
+describe('ConnectionLog', () => {
+ type RenderedNode = { props: { style?: unknown } }
+ type Renderer = {
+ root: {
+ findAllByType: (type: unknown) => RenderedNode[]
+ findByType: (type: unknown) => RenderedNode
+ }
+ unmount: () => void
+ }
+
+ let renderer: Renderer | null = null
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ })
+
+ function renderLog(fillAvailableHeight = false): Renderer {
+ act(() => {
+ renderer = create(
+ createElement(ConnectionLog, { entries, fillAvailableHeight })
+ ) as unknown as Renderer
+ })
+ return renderer as Renderer
+ }
+
+ it('keeps the compact height in pairing flows', () => {
+ const instance = renderLog()
+ const containerStyles = instance.root.findAllByType('View')[0]!.props.style
+
+ expect(containerStyles).toContainEqual({ maxHeight: 240 })
+ })
+
+ it('fills the available diagnostics viewport', () => {
+ const instance = renderLog(true)
+ const containerStyles = instance.root.findAllByType('View')[0]!.props.style
+ const scroll = instance.root.findByType('ScrollView')
+
+ expect(containerStyles).toContainEqual({ flex: 1 })
+ expect(scroll.props.style).toEqual({ flex: 1 })
+ })
+
+ it('does not emit duplicate-key warnings for repeated persisted event IDs', () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ act(() => {
+ renderer = create(
+ createElement(ConnectionLog, { entries: duplicateEntries })
+ ) as unknown as Renderer
+ })
+
+ expect(
+ consoleError.mock.calls.some(([message]) =>
+ String(message).includes('Encountered two children with the same key')
+ )
+ ).toBe(false)
+
+ consoleError.mockRestore()
+ })
+})
diff --git a/mobile/src/components/ConnectionLog.tsx b/mobile/src/components/ConnectionLog.tsx
index ad49e46d870..08f467d943f 100644
--- a/mobile/src/components/ConnectionLog.tsx
+++ b/mobile/src/components/ConnectionLog.tsx
@@ -8,6 +8,7 @@ type Props = {
// Tag printed before the first entry so it's clear what's being logged
// (e.g. 'Pairing' vs 'Reconnect').
title?: string
+ fillAvailableHeight?: boolean
}
const LEVEL_COLOR: Record = {
@@ -37,42 +38,53 @@ function formatTime(ts: number, baseTs: number): string {
return `+${Math.round(elapsed)}s`
}
-export function ConnectionLog({ entries, title }: Props) {
+export function ConnectionLog({ entries, title, fillAvailableHeight = false }: Props) {
const scrollRef = useRef(null)
if (entries.length === 0) {
return null
}
const baseTs = entries[0]!.ts
+ const keyOccurrences = new Map()
return (
-
+
{title && {title}}
scrollRef.current?.scrollToEnd({ animated: true })}
>
- {entries.map((entry) => (
-
- {formatTime(entry.ts, baseTs)}
-
- {LEVEL_GLYPH[entry.level]}
-
-
-
- {entry.message}
+ {entries.map((entry) => {
+ const occurrence = keyOccurrences.get(entry.id) ?? 0
+ keyOccurrences.set(entry.id, occurrence + 1)
+ const renderKey = occurrence === 0 ? entry.id : `${entry.id}:${occurrence}`
+ return (
+
+ {formatTime(entry.ts, baseTs)}
+
+ {LEVEL_GLYPH[entry.level]}
- {entry.detail && (
-
- {entry.detail}
+
+
+ {entry.message}
- )}
+ {entry.detail && (
+
+ {entry.detail}
+
+ )}
+
-
- ))}
+ )
+ })}
)
@@ -81,7 +93,6 @@ export function ConnectionLog({ entries, title }: Props) {
const styles = StyleSheet.create({
container: {
width: '100%',
- maxHeight: 240,
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
@@ -89,6 +100,12 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md
},
+ boundedContainer: {
+ maxHeight: 240
+ },
+ fillContainer: {
+ flex: 1
+ },
title: {
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
@@ -97,9 +114,12 @@ const styles = StyleSheet.create({
letterSpacing: 1,
marginBottom: spacing.xs
},
- scroll: {
+ boundedScroll: {
maxHeight: 200
},
+ fillScroll: {
+ flex: 1
+ },
scrollContent: {
gap: 6
},
diff --git a/mobile/src/components/CustomKeyModal.styles.ts b/mobile/src/components/CustomKeyModal.styles.ts
new file mode 100644
index 00000000000..991e557441f
--- /dev/null
+++ b/mobile/src/components/CustomKeyModal.styles.ts
@@ -0,0 +1,276 @@
+import { StyleSheet } from 'react-native'
+import { colors, spacing, radii, typography } from '../theme/mobile-theme'
+
+export const customKeyModalStyles = StyleSheet.create({
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingBottom: spacing.sm
+ },
+ backButton: {
+ width: 30,
+ height: 30,
+ borderRadius: 15,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ backButtonPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ backSpacer: {
+ width: 30
+ },
+ title: {
+ flex: 1,
+ fontSize: 15,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ textAlign: 'center'
+ },
+ group: {
+ backgroundColor: colors.bgPanel,
+ borderRadius: 12,
+ overflow: 'hidden'
+ },
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.borderSubtle,
+ marginHorizontal: spacing.md
+ },
+ row: {
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.md + 2
+ },
+ rowPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ rowLabel: {
+ fontSize: typography.bodySize,
+ fontWeight: '500',
+ color: colors.textPrimary,
+ marginBottom: 1
+ },
+ rowHint: {
+ fontSize: 12,
+ color: colors.textMuted
+ },
+ shortcutForm: {
+ paddingTop: spacing.sm
+ },
+ preview: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: spacing.sm,
+ paddingVertical: spacing.lg + spacing.xs,
+ flexWrap: 'wrap'
+ },
+ previewKeycapRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm
+ },
+ previewPlus: {
+ color: colors.textMuted,
+ fontSize: 16
+ },
+ keycap: {
+ minWidth: 48,
+ height: 48,
+ paddingHorizontal: spacing.md,
+ borderRadius: 10,
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ keycapModifier: {
+ minWidth: 0
+ },
+ keycapWarn: {
+ borderColor: colors.statusAmber
+ },
+ keycapText: {
+ color: colors.textPrimary,
+ fontFamily: typography.monoFamily,
+ fontSize: 17,
+ fontWeight: '600'
+ },
+ keycapTextWarn: {
+ color: colors.statusAmber
+ },
+ keycapModifierText: {
+ color: colors.textSecondary,
+ fontFamily: typography.monoFamily,
+ fontSize: 14,
+ fontWeight: '600'
+ },
+ section: {
+ marginTop: spacing.md
+ },
+ sectionLabel: {
+ fontSize: 11,
+ color: colors.textMuted,
+ textTransform: 'uppercase',
+ letterSpacing: 0.8,
+ marginBottom: spacing.sm,
+ paddingLeft: 2
+ },
+ mods: {
+ flexDirection: 'row',
+ gap: spacing.sm
+ },
+ chip: {
+ flex: 1,
+ height: 40,
+ borderRadius: 8,
+ backgroundColor: colors.bgPanel,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 4
+ },
+ chipSelected: {
+ backgroundColor: colors.textPrimary
+ },
+ chipPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ chipText: {
+ color: colors.textSecondary,
+ fontSize: 14,
+ fontWeight: '500'
+ },
+ chipTextSelected: {
+ color: colors.bgBase
+ },
+ chipGlyph: {
+ color: colors.textMuted,
+ fontSize: 13,
+ fontFamily: typography.monoFamily
+ },
+ chipGlyphSelected: {
+ color: 'rgba(10,10,10,0.5)'
+ },
+ keyInput: {
+ width: '100%',
+ height: 56,
+ borderRadius: 10,
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ color: colors.textPrimary,
+ fontFamily: typography.monoFamily,
+ fontSize: 22,
+ fontWeight: '600',
+ textAlign: 'center'
+ },
+ moreLink: {
+ paddingVertical: spacing.sm,
+ alignItems: 'center'
+ },
+ moreLinkPressed: {
+ opacity: 0.6
+ },
+ moreLinkText: {
+ color: colors.textSecondary,
+ fontSize: 13,
+ textDecorationLine: 'underline'
+ },
+ specialKeysForm: {
+ paddingTop: spacing.xs,
+ paddingBottom: spacing.md,
+ gap: spacing.md
+ },
+ specialGroup: {
+ gap: spacing.xs
+ },
+ specialGroupTitle: {
+ fontSize: 11,
+ color: colors.textMuted,
+ textTransform: 'uppercase',
+ letterSpacing: 0.8,
+ paddingLeft: 2,
+ marginBottom: spacing.xs
+ },
+ keyGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ marginHorizontal: -spacing.xs / 2
+ },
+ keyCellWrap: {
+ paddingHorizontal: spacing.xs / 2,
+ paddingVertical: spacing.xs / 2
+ },
+ keyCell: {
+ height: 40,
+ borderRadius: 8,
+ backgroundColor: colors.bgPanel,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ keyCellPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ keyCellSelected: {
+ backgroundColor: colors.textPrimary
+ },
+ keyCellText: {
+ fontSize: 13,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ fontFamily: typography.monoFamily
+ },
+ keyCellTextSelected: {
+ color: colors.bgBase
+ },
+ macroForm: {
+ padding: spacing.md,
+ gap: spacing.sm
+ },
+ fieldLabel: {
+ fontSize: 13,
+ fontWeight: '500',
+ color: colors.textSecondary
+ },
+ fieldInput: {
+ backgroundColor: colors.bgBase,
+ color: colors.textPrimary,
+ borderRadius: radii.input,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ fontSize: 14,
+ fontFamily: typography.monoFamily,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle
+ },
+ switchRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingVertical: spacing.xs
+ },
+ switchLabel: {
+ fontSize: typography.bodySize,
+ color: colors.textPrimary
+ },
+ saveButton: {
+ marginTop: spacing.md,
+ backgroundColor: colors.textPrimary,
+ paddingVertical: spacing.md,
+ borderRadius: 10,
+ alignItems: 'center'
+ },
+ saveButtonDisabled: {
+ backgroundColor: colors.bgRaised
+ },
+ saveButtonText: {
+ color: colors.bgBase,
+ fontSize: 15,
+ fontWeight: '600'
+ },
+ saveButtonTextDisabled: {
+ color: colors.textMuted
+ }
+})
diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx
index 2777e310dc8..0cc24f4c030 100644
--- a/mobile/src/components/CustomKeyModal.tsx
+++ b/mobile/src/components/CustomKeyModal.tsx
@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState } from 'react'
-import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
+import { View, Text, Pressable, TextInput, Switch } from 'react-native'
import { ChevronLeft } from 'lucide-react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
-import { colors, spacing, radii, typography } from '../theme/mobile-theme'
+import { colors } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import {
buildTerminalShortcutKey,
@@ -11,6 +11,7 @@ import {
type TerminalShortcutModifier,
type TerminalShortcutSpecialKey
} from '../terminal/terminal-accessory-keys'
+import { customKeyModalStyles as styles } from './CustomKeyModal.styles'
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
@@ -409,277 +410,3 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
)
}
-
-const styles = StyleSheet.create({
- header: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingBottom: spacing.sm
- },
- backButton: {
- width: 30,
- height: 30,
- borderRadius: 15,
- alignItems: 'center',
- justifyContent: 'center'
- },
- backButtonPressed: {
- backgroundColor: colors.bgRaised
- },
- backSpacer: {
- width: 30
- },
- title: {
- flex: 1,
- fontSize: 15,
- fontWeight: '600',
- color: colors.textPrimary,
- textAlign: 'center'
- },
- group: {
- backgroundColor: colors.bgPanel,
- borderRadius: 12,
- overflow: 'hidden'
- },
- separator: {
- height: StyleSheet.hairlineWidth,
- backgroundColor: colors.borderSubtle,
- marginHorizontal: spacing.md
- },
- row: {
- paddingVertical: spacing.md,
- paddingHorizontal: spacing.md + 2
- },
- rowPressed: {
- backgroundColor: colors.bgRaised
- },
- rowLabel: {
- fontSize: typography.bodySize,
- fontWeight: '500',
- color: colors.textPrimary,
- marginBottom: 1
- },
- rowHint: {
- fontSize: 12,
- color: colors.textMuted
- },
- shortcutForm: {
- paddingTop: spacing.sm
- },
- preview: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: spacing.sm,
- paddingVertical: spacing.lg + spacing.xs,
- flexWrap: 'wrap'
- },
- previewKeycapRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm
- },
- previewPlus: {
- color: colors.textMuted,
- fontSize: 16
- },
- keycap: {
- minWidth: 48,
- height: 48,
- paddingHorizontal: spacing.md,
- borderRadius: 10,
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- alignItems: 'center',
- justifyContent: 'center'
- },
- keycapModifier: {
- minWidth: 0
- },
- keycapWarn: {
- borderColor: colors.statusAmber
- },
- keycapText: {
- color: colors.textPrimary,
- fontFamily: typography.monoFamily,
- fontSize: 17,
- fontWeight: '600'
- },
- keycapTextWarn: {
- color: colors.statusAmber
- },
- keycapModifierText: {
- color: colors.textSecondary,
- fontFamily: typography.monoFamily,
- fontSize: 14,
- fontWeight: '600'
- },
- section: {
- marginTop: spacing.md
- },
- sectionLabel: {
- fontSize: 11,
- color: colors.textMuted,
- textTransform: 'uppercase',
- letterSpacing: 0.8,
- marginBottom: spacing.sm,
- paddingLeft: 2
- },
- mods: {
- flexDirection: 'row',
- gap: spacing.sm
- },
- chip: {
- flex: 1,
- height: 40,
- borderRadius: 8,
- backgroundColor: colors.bgPanel,
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: 4
- },
- chipSelected: {
- backgroundColor: colors.textPrimary
- },
- chipPressed: {
- backgroundColor: colors.bgRaised
- },
- chipText: {
- color: colors.textSecondary,
- fontSize: 14,
- fontWeight: '500'
- },
- chipTextSelected: {
- color: colors.bgBase
- },
- chipGlyph: {
- color: colors.textMuted,
- fontSize: 13,
- fontFamily: typography.monoFamily
- },
- chipGlyphSelected: {
- color: 'rgba(10,10,10,0.5)'
- },
- keyInput: {
- width: '100%',
- height: 56,
- borderRadius: 10,
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- color: colors.textPrimary,
- fontFamily: typography.monoFamily,
- fontSize: 22,
- fontWeight: '600',
- textAlign: 'center'
- },
- moreLink: {
- paddingVertical: spacing.sm,
- alignItems: 'center'
- },
- moreLinkPressed: {
- opacity: 0.6
- },
- moreLinkText: {
- color: colors.textSecondary,
- fontSize: 13,
- textDecorationLine: 'underline'
- },
- specialKeysForm: {
- paddingTop: spacing.xs,
- paddingBottom: spacing.md,
- gap: spacing.md
- },
- specialGroup: {
- gap: spacing.xs
- },
- specialGroupTitle: {
- fontSize: 11,
- color: colors.textMuted,
- textTransform: 'uppercase',
- letterSpacing: 0.8,
- paddingLeft: 2,
- marginBottom: spacing.xs
- },
- keyGrid: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- marginHorizontal: -spacing.xs / 2
- },
- keyCellWrap: {
- paddingHorizontal: spacing.xs / 2,
- paddingVertical: spacing.xs / 2
- },
- keyCell: {
- height: 40,
- borderRadius: 8,
- backgroundColor: colors.bgPanel,
- alignItems: 'center',
- justifyContent: 'center'
- },
- keyCellPressed: {
- backgroundColor: colors.bgRaised
- },
- keyCellSelected: {
- backgroundColor: colors.textPrimary
- },
- keyCellText: {
- fontSize: 13,
- fontWeight: '600',
- color: colors.textPrimary,
- fontFamily: typography.monoFamily
- },
- keyCellTextSelected: {
- color: colors.bgBase
- },
- macroForm: {
- padding: spacing.md,
- gap: spacing.sm
- },
- fieldLabel: {
- fontSize: 13,
- fontWeight: '500',
- color: colors.textSecondary
- },
- fieldInput: {
- backgroundColor: colors.bgBase,
- color: colors.textPrimary,
- borderRadius: radii.input,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- fontSize: 14,
- fontFamily: typography.monoFamily,
- borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- switchRow: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingVertical: spacing.xs
- },
- switchLabel: {
- fontSize: typography.bodySize,
- color: colors.textPrimary
- },
- saveButton: {
- marginTop: spacing.md,
- backgroundColor: colors.textPrimary,
- paddingVertical: spacing.md,
- borderRadius: 10,
- alignItems: 'center'
- },
- saveButtonDisabled: {
- backgroundColor: colors.bgRaised
- },
- saveButtonText: {
- color: colors.bgBase,
- fontSize: 15,
- fontWeight: '600'
- },
- saveButtonTextDisabled: {
- color: colors.textMuted
- }
-})
diff --git a/mobile/src/components/HostDiagnosticsLink.tsx b/mobile/src/components/HostDiagnosticsLink.tsx
new file mode 100644
index 00000000000..eaab1f96ca4
--- /dev/null
+++ b/mobile/src/components/HostDiagnosticsLink.tsx
@@ -0,0 +1,38 @@
+import { ChevronRight } from 'lucide-react-native'
+import { Pressable, StyleSheet, Text } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+export function HostDiagnosticsLink({ onPress }: { onPress: () => void }): React.JSX.Element {
+ return (
+
+ View network diagnostics
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ link: {
+ marginHorizontal: spacing.md,
+ marginBottom: spacing.sm,
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.md,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between'
+ },
+ text: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontWeight: '600'
+ }
+})
diff --git a/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts b/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts
new file mode 100644
index 00000000000..c4348ad15a2
--- /dev/null
+++ b/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts
@@ -0,0 +1,279 @@
+export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY = [
+ ';',
+ ' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;',
+ ' --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;',
+ ' }',
+ ' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }',
+ ' html, body {',
+ ' width: 100%;',
+ ' min-height: 100%;',
+ ' margin: 0;',
+ ' background: var(--editor-surface);',
+ ' color: var(--foreground);',
+ ' font-family: var(--font-sans);',
+ ' overscroll-behavior: contain;',
+ ' }',
+ ' body { overflow: auto; }',
+ ' #editor {',
+ ' min-height: 100vh;',
+ ' padding: 18px 16px 112px;',
+ ' outline: none;',
+ ' font-size: 14px;',
+ ' line-height: 1.7;',
+ ' word-wrap: break-word;',
+ ' overflow-wrap: anywhere;',
+ ' caret-color: var(--foreground);',
+ ' }',
+ ' #editor[contenteditable="false"] {',
+ ' opacity: 0.78;',
+ ' }',
+ ' #editor:empty::before,',
+ ' #editor p.is-empty:first-child::before {',
+ ' content: attr(data-placeholder);',
+ ' color: var(--muted-foreground);',
+ ' pointer-events: none;',
+ ' }',
+ ' #editor > :first-child { margin-top: 0; }',
+ ' h1, h2, h3, h4, h5, h6 {',
+ ' margin: 1.5em 0 0.5em;',
+ ' font-weight: 600;',
+ ' line-height: 1.3;',
+ ' letter-spacing: 0;',
+ ' }',
+ ' h1 { font-size: 1.85em; font-weight: 700; }',
+ ' h2 { font-size: 1.4em; }',
+ ' h3 { font-size: 1.15em; }',
+ ' p, ul, ol, blockquote { margin: 0.75em 0; }',
+ ' ul, ol { padding-left: 1.5em; }',
+ ' ul { list-style: disc; }',
+ ' ol { list-style: decimal; }',
+ ' li { margin: 0.15em 0; }',
+ ' li > p { margin: 0; }',
+ ' ul[data-type="taskList"] {',
+ ' padding-left: 0;',
+ ' list-style: none;',
+ ' }',
+ ' ul[data-type="taskList"] > li {',
+ ' display: flex;',
+ ' align-items: flex-start;',
+ ' gap: 6px;',
+ ' }',
+ ' ul[data-type="taskList"] > li > label {',
+ ' flex-shrink: 0;',
+ ' display: flex;',
+ ' align-items: center;',
+ ' height: 1.55em;',
+ ' user-select: none;',
+ ' }',
+ ' ul[data-type="taskList"] input[type="checkbox"] {',
+ ' appearance: none;',
+ ' width: 16px;',
+ ' height: 16px;',
+ ' margin: 0;',
+ ' border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);',
+ ' border-radius: 4px;',
+ ' background: transparent;',
+ ' position: relative;',
+ ' }',
+ ' ul[data-type="taskList"] input[type="checkbox"]:checked {',
+ ' background: var(--primary);',
+ ' border-color: var(--primary);',
+ ' }',
+ ' ul[data-type="taskList"] input[type="checkbox"]:checked::after {',
+ ' content: "";',
+ ' position: absolute;',
+ ' left: 4px;',
+ ' top: 1px;',
+ ' width: 5px;',
+ ' height: 9px;',
+ ' border: solid var(--primary-foreground);',
+ ' border-width: 0 2px 2px 0;',
+ ' transform: rotate(45deg);',
+ ' }',
+ ' ul[data-type="taskList"] input[type="checkbox"]:disabled {',
+ ' opacity: 0.65;',
+ ' }',
+ ' ul[data-type="taskList"] > li > div {',
+ ' flex: 1;',
+ ' min-width: 0;',
+ ' }',
+ ' ul[data-type="taskList"] > li[data-checked="true"] > div {',
+ ' text-decoration: line-through;',
+ ' color: var(--muted-foreground);',
+ ' }',
+ ' blockquote {',
+ ' padding: 0.5em 1em;',
+ ' border-left: 3px solid var(--border);',
+ ' border-radius: 0 6px 6px 0;',
+ ' color: var(--muted-foreground);',
+ ' background: color-mix(in srgb, var(--foreground) 2%, transparent);',
+ ' }',
+ ' table {',
+ ' width: 100%;',
+ ' margin: 1em 0;',
+ ' border-collapse: collapse;',
+ ' font-size: 0.95em;',
+ ' }',
+ ' th, td {',
+ ' padding: 8px 14px;',
+ ' border: 1px solid var(--border);',
+ ' text-align: left;',
+ ' vertical-align: top;',
+ ' }',
+ ' th {',
+ ' font-weight: 600;',
+ ' background: color-mix(in srgb, var(--foreground) 4%, transparent);',
+ ' }',
+ ' tr:nth-child(odd) td {',
+ ' background: color-mix(in srgb, var(--foreground) 1.5%, transparent);',
+ ' }',
+ ' code {',
+ ' padding: 0.2em 0.4em;',
+ ' border-radius: 5px;',
+ ' background: color-mix(in srgb, var(--foreground) 8%, transparent);',
+ ' font-size: 0.88em;',
+ ' font-family: var(--font-mono);',
+ ' }',
+ ' pre {',
+ ' margin: 0.75em 0;',
+ ' padding: 14px 18px;',
+ ' border-radius: 8px;',
+ ' border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);',
+ ' overflow-x: auto;',
+ ' line-height: 1.55;',
+ ' background: color-mix(in srgb, var(--foreground) 6%, transparent);',
+ ' font-family: var(--font-mono);',
+ ' white-space: pre-wrap;',
+ ' }',
+ ' pre::before {',
+ ' content: attr(data-language);',
+ ' display: block;',
+ ' min-height: 13px;',
+ ' margin-bottom: 4px;',
+ ' color: var(--muted-foreground);',
+ ' font-size: 11px;',
+ ' text-transform: uppercase;',
+ ' }',
+ ' pre code {',
+ ' padding: 0;',
+ ' border-radius: 0;',
+ ' background: transparent;',
+ ' font-size: 0.92em;',
+ ' white-space: pre-wrap;',
+ ' }',
+ ' hr {',
+ ' margin: 1.5em 0;',
+ ' border: none;',
+ ' border-top: 1px solid var(--border);',
+ ' }',
+ ' a {',
+ ' color: var(--accent-link);',
+ ' text-decoration: underline;',
+ ' text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);',
+ ' text-underline-offset: 2px;',
+ ' }',
+ ' img {',
+ ' display: block;',
+ ' max-width: 100%;',
+ ' margin: 0.75em 0;',
+ ' border-radius: 8px;',
+ ' }',
+ ' ',
+ '',
+ '',
+ ' ',
+ ' ',
+ '',
+ '