diff --git a/apps/kimi-code/dist-web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 b/apps/kimi-code/dist-web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b3048fc115681ee6c1bc86b0aa158cfbbf59daa3 Binary files /dev/null and b/apps/kimi-code/dist-web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 differ diff --git a/apps/kimi-code/dist-web/assets/arc-Bh_RIraT.js b/apps/kimi-code/dist-web/assets/arc-Bh_RIraT.js new file mode 100644 index 0000000000000000000000000000000000000000..630dccc20bb17724bfb2b175f133b7f49e7666fd --- /dev/null +++ b/apps/kimi-code/dist-web/assets/arc-Bh_RIraT.js @@ -0,0 +1 @@ +import{Y as ln,$ as an,a0 as y,a1 as tn,a2 as F,a3 as q,a4 as _,a5 as un,a6 as rn,a7 as J,a8 as o,a9 as j,aa as sn,ab as on,ac as fn}from"./mermaidParser.worker-BFSlSHEW.js";function cn(l){return l.innerRadius}function yn(l){return l.outerRadius}function gn(l){return l.startAngle}function dn(l){return l.endAngle}function mn(l){return l&&l.padAngle}function pn(l,h,I,D,v,A,z,a){var O=I-l,i=D-h,n=z-v,d=a-A,u=d*O-n*i;if(!(u*ur*r+C*C&&(Y=w,$=p),{cx:Y,cy:$,x01:-n,y01:-d,x11:Y*(v/T-1),y11:$*(v/T-1)}}function hn(){var l=cn,h=yn,I=j(0),D=null,v=gn,A=dn,z=mn,a=null,O=ln(i);function i(){var n,d,u=+l.apply(this,arguments),s=+h.apply(this,arguments),f=v.apply(this,arguments)-an,c=A.apply(this,arguments)-an,B=un(c-f),t=c>f;if(a||(a=n=O()),sy))a.moveTo(0,0);else if(B>tn-y)a.moveTo(s*F(f),s*q(f)),a.arc(0,0,s,f,c,!t),u>y&&(a.moveTo(u*F(c),u*q(c)),a.arc(0,0,u,c,f,t));else{var m=f,g=c,R=f,T=c,P=B,S=B,Y=z.apply(this,arguments)/2,$=Y>y&&(D?+D.apply(this,arguments):J(u*u+s*s)),w=_(un(s-u)/2,+I.apply(this,arguments)),p=w,x=w,e,r;if($>y){var C=sn($/u*q(Y)),K=sn($/s*q(Y));(P-=C*2)>y?(C*=t?1:-1,R+=C,T-=C):(P=0,R=T=(f+c)/2),(S-=K*2)>y?(K*=t?1:-1,m+=K,g-=K):(S=0,m=g=(f+c)/2)}var G=s*F(m),H=s*q(m),L=u*F(T),M=u*q(T);if(w>y){var N=s*F(g),Q=s*q(g),V=u*F(R),W=u*q(R),E;if(By?x>y?(e=U(V,W,G,H,s,x,t),r=U(N,Q,L,M,s,x,t),a.moveTo(e.cx+e.x01,e.cy+e.y01),xy)||!(P>y)?a.lineTo(L,M):p>y?(e=U(L,M,N,Q,u,-p,t),r=U(G,H,V,W,u,-p,t),a.lineTo(e.cx+e.x01,e.cy+e.y01),p{const e=r.append("rect");if(e.attr("x",t.x),e.attr("y",t.y),e.attr("fill",t.fill),e.attr("stroke",t.stroke),e.attr("width",t.width),e.attr("height",t.height),t.name&&e.attr("name",t.name),t.rx&&e.attr("rx",t.rx),t.ry&&e.attr("ry",t.ry),t.attrs!==void 0)for(const s in t.attrs)e.attr(s,t.attrs[s]);return t.class&&e.attr("class",t.class),e},"drawRect"),p=i((r,t)=>{const e={x:t.startx,y:t.starty,width:t.stopx-t.startx,height:t.stopy-t.starty,fill:t.fill,stroke:t.stroke,class:"rect"};x(r,e).lower()},"drawBackgroundRect"),y=i((r,t)=>{const e=t.text.replace(d," "),s=r.append("text");s.attr("x",t.x),s.attr("y",t.y),s.attr("class","legend"),s.style("text-anchor",t.anchor),t.class&&s.attr("class",t.class);const a=s.append("tspan");return a.attr("x",t.x+t.textMargin*2),a.text(e),s},"drawText"),m=i((r,t,e,s)=>{const a=r.append("image");a.attr("x",t),a.attr("y",e);const n=o.sanitizeUrl(s);a.attr("xlink:href",n)},"drawImage"),g=i((r,t,e,s)=>{const a=r.append("use");a.attr("x",t),a.attr("y",e);const n=o.sanitizeUrl(s);a.attr("xlink:href",`#${n}`)},"drawEmbeddedImage"),h=i(()=>({x:0,y:0,width:100,height:100,fill:"#EDF2AE",stroke:"#666",anchor:"start",rx:0,ry:0}),"getNoteRect"),f=i(()=>({x:0,y:0,width:100,height:100,"text-anchor":"start",style:"#666",textMargin:0,rx:0,ry:0,tspan:!0}),"getTextObj"),w=i(()=>{let r=l(".mermaidTooltip");return r.empty()&&(r=l("body").append("div").attr("class","mermaidTooltip").style("opacity",0).style("position","absolute").style("text-align","center").style("max-width","200px").style("padding","2px").style("font-size","12px").style("background","#ffffde").style("border","1px solid #333").style("border-radius","2px").style("pointer-events","none").style("z-index","100")),r},"createTooltip");export{p as a,f as b,g as c,x as d,m as e,w as f,h as g,y as h}; diff --git a/apps/kimi-code/dist-web/assets/clojure-P80f7IUj.js b/apps/kimi-code/dist-web/assets/clojure-P80f7IUj.js new file mode 100644 index 0000000000000000000000000000000000000000..f661a7064e52acb26b21782f7d9d70fd92fa0a8c --- /dev/null +++ b/apps/kimi-code/dist-web/assets/clojure-P80f7IUj.js @@ -0,0 +1 @@ +const e=Object.freeze(JSON.parse(`{"displayName":"Clojure","name":"clojure","patterns":[{"include":"#comment"},{"include":"#shebang-comment"},{"include":"#quoted-sexp"},{"include":"#sexp"},{"include":"#keyfn"},{"include":"#string"},{"include":"#vector"},{"include":"#set"},{"include":"#map"},{"include":"#regexp"},{"include":"#var"},{"include":"#constants"},{"include":"#dynamic-variables"},{"include":"#metadata"},{"include":"#namespace-symbol"},{"include":"#symbol"}],"repository":{"comment":{"begin":"(?Math.max(t,Math.min(g,s)),"clamp"),B=w((s="TB")=>{switch(s){case"BT":return"bottom";case"LR":return"right";case"RL":return"left";case"TB":default:return"top"}},"getDefaultSelfLoopSide"),V=w(s=>s==="flowchart"||s==="flowchart-v2"||s==="stateDiagram","shouldMergeSelfLoopSegments"),Z=w((s,t,g,y,c)=>{const o=[],r=new Set;if(g.forEach(({start:i,end:n})=>{i!==y&&r.add(i),n!==y&&r.add(n)}),r.forEach(i=>{const n=s.node(i);typeof n?.x=="number"&&typeof n?.y=="number"&&o.push(n)}),o.length===0&&g.forEach(({edge:i})=>{(i.points??[]).forEach(n=>{typeof n?.x=="number"&&typeof n?.y=="number"&&o.push(n)})}),o.length===0)return B(c);const f=o.reduce((i,n)=>({x:i.x+n.x/o.length,y:i.y+n.y/o.length}),{x:0,y:0}),h=f.x-t.x,a=f.y-t.y;return Math.abs(h)>Math.abs(a)?h>0?"right":"left":Math.abs(a)>0?a>0?"bottom":"top":B(c)},"getSelfLoopSide"),ee=w((s,t="top",g=0,y=0)=>{const c=s.x,o=s.y-g,r=s.width/2,f=s.height/2,h=Math.max(36,Math.min(100,s.width*.8)),a=C(Math.max(y,s.width*.35),36,h),i=C(Math.min(s.width,s.height)*.45,24,48);switch(t){case"bottom":{const n=o+f;return[{x:c-a/2,y:n},{x:c-a/2,y:n+i},{x:c+a/2,y:n+i},{x:c+a/2,y:n}]}case"right":{const n=c+r;return[{x:n,y:o-a/2},{x:n+i,y:o-a/2},{x:n+i,y:o+a/2},{x:n,y:o+a/2}]}case"left":{const n=c-r;return[{x:n,y:o-a/2},{x:n-i,y:o-a/2},{x:n-i,y:o+a/2},{x:n,y:o+a/2}]}case"top":default:{const n=o-f;return[{x:c-a/2,y:n},{x:c-a/2,y:n-i},{x:c+a/2,y:n-i},{x:c+a/2,y:n}]}}},"getSelfLoopPoints"),te=w((s,t,g="top",y=0,c={})=>{const r=s.x,f=s.y-y,h=c.width??0,a=c.height??0;switch(g){case"bottom":return{x:r,y:Math.max(...t.map(i=>i.y))+a/2+4};case"right":return{x:Math.max(...t.map(i=>i.x))+h/2+4,y:f};case"left":return{x:Math.min(...t.map(i=>i.x))-h/2-4,y:f};case"top":default:return{x:r,y:Math.min(...t.map(i=>i.y))-a/2-4}}},"getSelfLoopLabelPosition"),ne=w((s,t=0,{mergeSelfLoops:g=!0}={})=>{const y=new Map,c=[],o=s.graph()?.rankdir;return s.edges().forEach(r=>{const f=s.edge(r);if(g&&f.selfLoop){const h=f.selfLoop.id;y.has(h)||y.set(h,[]),y.get(h).push({edge:f,start:r.v,end:r.w})}else c.push({edge:f,start:r.v,end:r.w})}),y.forEach(r=>{if(r.length!==3){r.forEach(L=>c.push(L));return}r.sort((L,d)=>L.edge.selfLoop.order-d.edge.selfLoop.order);const[f,h,a]=r,i=f.edge.originalEdge??h.edge.originalEdge??a.edge.originalEdge??h.edge,n=s.node(i.start);if(!n){r.forEach(L=>c.push(L));return}const p={width:h.edge.width,height:h.edge.height},m=Z(s,n,r,i.start,o),X=ee(n,m,t,p.width??0),S=te(n,X,m,t,p),b={...h.edge,...i,id:i.id,points:X,start:i.start,end:i.end,x:S.x,y:S.y,width:p.width,height:p.height,labelStyle:h.edge.labelStyle,fromCluster:f.edge.fromCluster??h.edge.fromCluster??a.edge.fromCluster,toCluster:f.edge.toCluster??h.edge.toCluster??a.edge.toCluster};delete b.selfLoop,delete b.originalEdge,c.push({edge:b,start:b.start,end:b.end})}),c},"getEdgesToRender"),T=w(async(s,t,g,y,c,o)=>{l.warn("Graph in recursive render:XAX",I(t),c);const r=t.graph().rankdir;l.trace("Dir in recursive render - dir:",r);const f=s.insert("g").attr("class","root");t.nodes()?l.info("Recursive render XXX",t.nodes()):l.info("No nodes found for",t),t.edges().length>0&&l.info("Recursive edges",t.edge(t.edges()[0]));const h=f.insert("g").attr("class","clusters"),a=f.insert("g").attr("class","edgePaths"),i=f.insert("g").attr("class","edgeLabels"),n=f.insert("g").attr("class","nodes"),p=V(g);await Promise.all(t.nodes().map(async function(d){const e=t.node(d);if(c!==void 0){const u=JSON.parse(JSON.stringify(c.clusterData));l.trace(`Setting data for parent cluster XXX + Node.id = `,d,` + data=`,u.height,` +Parent cluster`,c.height),t.setNode(c.id,u),t.parent(d)||(l.trace("Setting parent",d,c.id),t.setParent(d,c.id,u))}if(l.info("(Insert) Node XXX"+d+": "+JSON.stringify(t.node(d))),e?.clusterNode){l.info("Cluster identified XBX",d,e.width,t.node(d));const{ranksep:u,nodesep:x}=t.graph();e.graph.setGraph({...e.graph.graph(),ranksep:u+25,nodesep:x});const N=await T(n,e.graph,g,y,t.node(d),o),M=N.elem;W(e,M),e.diff=N.diff||0,l.info("New compound node after recursive render XAX",d,"width",e.width,"height",e.height),$(M,e)}else t.children(d).length>0?(l.trace("Cluster - the non recursive path XBX",d,e.id,e,e.width,"Graph:",t),l.trace(P(e.id,t)),E.set(e.id,{id:P(e.id,t),node:e})):(l.trace("Node - the non recursive path XAX",d,n,t.node(d),r),await j(n,t.node(d),{config:o,dir:r}))})),await w(async()=>{const d=t.edges().map(async function(e){const u=t.edge(e.v,e.w,e.name);if(l.info("Edge "+e.v+" -> "+e.w+": "+JSON.stringify(e)),l.info("Edge "+e.v+" -> "+e.w+": ",e," ",JSON.stringify(t.edge(e))),l.info("Fix",E,"ids:",e.v,e.w,"Translating: ",E.get(e.v),E.get(e.w)),p&&u.selfLoop){if(u.selfLoop.order!==1)return;const x=u.id;u.id=u.selfLoop.id,await G(i,u),u.id=x;return}await G(i,u)});await Promise.all(d)},"processEdges")(),l.info("Graph before layout:",JSON.stringify(I(t))),l.info("############################################# XXX"),l.info("### Layout ### XXX"),l.info("############################################# XXX"),U(t),l.info("Graph after layout:",JSON.stringify(I(t)));let X=0,{subGraphTitleTotalMargin:S}=q(o);await Promise.all(A(t).map(async function(d){const e=t.node(d);if(l.info("Position XBX => "+d+": ("+e.x,","+e.y,") width: ",e.width," height: ",e.height),e?.clusterNode)e.y+=S,l.info("A tainted cluster node XBX1",d,e.id,e.width,e.height,e.x,e.y,t.parent(d)),E.get(e.id).node=e,R(e);else if(t.children(d).length>0){l.info("A pure cluster node XBX1",d,e.id,e.x,e.y,e.width,e.height,t.parent(d)),e.height+=S,t.node(e.parentId);const u=e?.padding/2||0,x=e?.labelBBox?.height||0,N=x-u||0;l.debug("OffsetY",N,"labelHeight",x,"halfPadding",u),await F(h,e),E.get(e.id).node=e}else{const u=t.node(e.parentId);e.y+=S/2,l.info("A regular node XBX1 - using the padding",e.id,"parent",e.parentId,e.width,e.height,e.x,e.y,"offsetY",e.offsetY,"parent",u,u?.offsetY,e),R(e)}}));const b=S/2;return ne(t,b,{mergeSelfLoops:p}).forEach(function({edge:d,start:e,end:u}){l.info("Edge "+e+" -> "+u+": "+JSON.stringify(d),d),d.points.forEach(k=>k.y+=b);const x=t.node(e),N=t.node(u),M=z(a,d,E,g,x,N,y);K(d,M)}),t.nodes().forEach(function(d){const e=t.node(d);l.info(d,e.type,e.diff),e.isGroup&&(X=e.diff)}),l.warn("Returning from recursive render XAX",f,X),{elem:f,diff:X}},"recursiveRender"),ae=w(async(s,t)=>{const g=new Q({multigraph:!0,compound:!0}).setGraph({rankdir:s.direction,nodesep:s.config?.nodeSpacing||s.config?.flowchart?.nodeSpacing||s.nodeSpacing,ranksep:s.config?.rankSpacing||s.config?.flowchart?.rankSpacing||s.rankSpacing,marginx:8,marginy:8}).setDefaultEdgeLabel(function(){return{}}),y=t.select("g");v(y,s.markers,s.type,s.diagramId),D(),H(),Y(),O(),s.nodes.forEach(o=>{g.setNode(o.id,{...o}),o.parentId&&g.setParent(o.id,o.parentId)}),l.debug("Edges:",s.edges),s.edges.forEach(o=>{if(o.start===o.end){const r=o.start,f=r+"---"+r+"---1",h=r+"---"+r+"---2",a=g.node(r);g.setNode(f,{domId:f,id:f,parentId:a.parentId,labelStyle:"",label:"",padding:0,shape:"labelRect",style:"",width:10,height:10}),g.setParent(f,a.parentId),g.setNode(h,{domId:h,id:h,parentId:a.parentId,labelStyle:"",padding:0,shape:"labelRect",label:"",style:"",width:10,height:10}),g.setParent(h,a.parentId);const i=structuredClone(o),n=structuredClone(o),p=structuredClone(o),m=structuredClone(o);n.originalEdge=i,n.selfLoop={id:i.id,order:0},p.originalEdge=i,p.selfLoop={id:i.id,order:1},m.originalEdge=i,m.selfLoop={id:i.id,order:2},n.label="",n.arrowTypeEnd="none",n.endLabelLeft="",n.endLabelRight="",n.startLabelLeft="",n.id=r+"-cyclic-special-1",p.startLabelRight="",p.startLabelLeft="",p.endLabelLeft="",p.endLabelRight="",p.arrowTypeStart="none",p.arrowTypeEnd="none",p.id=r+"-cyclic-special-mid",m.label="",m.startLabelRight="",m.startLabelLeft="",m.arrowTypeStart="none",a.isGroup&&(n.fromCluster=r,m.toCluster=r),m.id=r+"-cyclic-special-2",m.arrowTypeStart="none",g.setEdge(r,f,n,r+"-cyclic-special-0"),g.setEdge(f,h,p,r+"-cyclic-special-1"),g.setEdge(h,r,m,r+"-cyclic-special-2")}else g.setEdge(o.start,o.end,{...o},o.id)}),l.warn("Graph at first:",JSON.stringify(I(g))),J(g),l.warn("Graph after XAX:",JSON.stringify(I(g)));const c=_();await T(y,g,s.type,s.diagramId,void 0,c)},"render");export{ne as getEdgesToRender,ae as render}; diff --git a/apps/kimi-code/dist-web/assets/mindmap-definition-LN4V7U3C-BYqiMfi6.js b/apps/kimi-code/dist-web/assets/mindmap-definition-LN4V7U3C-BYqiMfi6.js new file mode 100644 index 0000000000000000000000000000000000000000..d5e426b9b792e9c739933852203f77363e2898ff --- /dev/null +++ b/apps/kimi-code/dist-web/assets/mindmap-definition-LN4V7U3C-BYqiMfi6.js @@ -0,0 +1,96 @@ +import{g as oe}from"./chunk-XXDRQBXY-4Gs_c1bc.js";import{s as ae}from"./chunk-VR4S4FIN-DAoPzKmc.js";import{_ as l,l as C,u as ce,v as le,y as he,B as G,c as B,i as F,b6 as de,aa as ge,ab as ue,ac as pe}from"./mermaid.core-CCxZmhet.js";import"./index-Baap3_X9.js";import"./_commonjsHelpers-CqkleIqs.js";const E=[];for(let e=0;e<256;++e)E.push((e+256).toString(16).slice(1));function fe(e,n=0){return(E[e[n+0]]+E[e[n+1]]+E[e[n+2]]+E[e[n+3]]+"-"+E[e[n+4]]+E[e[n+5]]+"-"+E[e[n+6]]+E[e[n+7]]+"-"+E[e[n+8]]+E[e[n+9]]+"-"+E[e[n+10]]+E[e[n+11]]+E[e[n+12]]+E[e[n+13]]+E[e[n+14]]+E[e[n+15]]).toLowerCase()}const me=new Uint8Array(16);function ye(){return crypto.getRandomValues(me)}function Ee(e,n,g){return crypto.randomUUID?crypto.randomUUID():_e(e)}function _e(e,n,g){e=e||{};const a=e.random??e.rng?.()??ye();if(a.length<16)throw new Error("Random bytes length must be >= 16");return a[6]=a[6]&15|64,a[8]=a[8]&63|128,fe(a)}var Y=(function(){var e=l(function(D,s,i,o){for(i=i||{},o=D.length;o--;i[D[o]]=s);return i},"o"),n=[1,4],g=[1,13],a=[1,12],t=[1,15],h=[1,16],f=[1,20],m=[1,19],_=[6,7,8],T=[1,26],I=[1,24],w=[1,25],d=[6,7,11],R=[1,6,13,15,16,19,22],q=[1,33],J=[1,34],A=[1,6,7,11,13,15,16,19,22],j={trace:l(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mindMap:4,spaceLines:5,SPACELINE:6,NL:7,MINDMAP:8,document:9,stop:10,EOF:11,statement:12,SPACELIST:13,node:14,ICON:15,CLASS:16,nodeWithId:17,nodeWithoutId:18,NODE_DSTART:19,NODE_DESCR:20,NODE_DEND:21,NODE_ID:22,$accept:0,$end:1},terminals_:{2:"error",6:"SPACELINE",7:"NL",8:"MINDMAP",11:"EOF",13:"SPACELIST",15:"ICON",16:"CLASS",19:"NODE_DSTART",20:"NODE_DESCR",21:"NODE_DEND",22:"NODE_ID"},productions_:[0,[3,1],[3,2],[5,1],[5,2],[5,2],[4,2],[4,3],[10,1],[10,1],[10,1],[10,2],[10,2],[9,3],[9,2],[12,2],[12,2],[12,2],[12,1],[12,1],[12,1],[12,1],[12,1],[14,1],[14,1],[18,3],[17,1],[17,4]],performAction:l(function(s,i,o,c,p,r,$){var u=r.length-1;switch(p){case 6:case 7:return c;case 8:c.getLogger().trace("Stop NL ");break;case 9:c.getLogger().trace("Stop EOF ");break;case 11:c.getLogger().trace("Stop NL2 ");break;case 12:c.getLogger().trace("Stop EOF2 ");break;case 15:c.getLogger().info("Node: ",r[u].id),c.addNode(r[u-1].length,r[u].id,r[u].descr,r[u].type);break;case 16:c.getLogger().trace("Icon: ",r[u]),c.decorateNode({icon:r[u]});break;case 17:case 21:c.decorateNode({class:r[u]});break;case 18:c.getLogger().trace("SPACELIST");break;case 19:c.getLogger().trace("Node: ",r[u].id),c.addNode(0,r[u].id,r[u].descr,r[u].type);break;case 20:c.decorateNode({icon:r[u]});break;case 25:c.getLogger().trace("node found ..",r[u-2]),this.$={id:r[u-1],descr:r[u-1],type:c.getType(r[u-2],r[u])};break;case 26:this.$={id:r[u],descr:r[u],type:c.nodeType.DEFAULT};break;case 27:c.getLogger().trace("node found ..",r[u-3]),this.$={id:r[u-3],descr:r[u-1],type:c.getType(r[u-2],r[u])};break}},"anonymous"),table:[{3:1,4:2,5:3,6:[1,5],8:n},{1:[3]},{1:[2,1]},{4:6,6:[1,7],7:[1,8],8:n},{6:g,7:[1,10],9:9,12:11,13:a,14:14,15:t,16:h,17:17,18:18,19:f,22:m},e(_,[2,3]),{1:[2,2]},e(_,[2,4]),e(_,[2,5]),{1:[2,6],6:g,12:21,13:a,14:14,15:t,16:h,17:17,18:18,19:f,22:m},{6:g,9:22,12:11,13:a,14:14,15:t,16:h,17:17,18:18,19:f,22:m},{6:T,7:I,10:23,11:w},e(d,[2,22],{17:17,18:18,14:27,15:[1,28],16:[1,29],19:f,22:m}),e(d,[2,18]),e(d,[2,19]),e(d,[2,20]),e(d,[2,21]),e(d,[2,23]),e(d,[2,24]),e(d,[2,26],{19:[1,30]}),{20:[1,31]},{6:T,7:I,10:32,11:w},{1:[2,7],6:g,12:21,13:a,14:14,15:t,16:h,17:17,18:18,19:f,22:m},e(R,[2,14],{7:q,11:J}),e(A,[2,8]),e(A,[2,9]),e(A,[2,10]),e(d,[2,15]),e(d,[2,16]),e(d,[2,17]),{20:[1,35]},{21:[1,36]},e(R,[2,13],{7:q,11:J}),e(A,[2,11]),e(A,[2,12]),{21:[1,37]},e(d,[2,25]),e(d,[2,27])],defaultActions:{2:[2,1],6:[2,2]},parseError:l(function(s,i){if(i.recoverable)this.trace(s);else{var o=new Error(s);throw o.hash=i,o}},"parseError"),parse:l(function(s){var i=this,o=[0],c=[],p=[null],r=[],$=this.table,u="",M=0,K=0,ne=2,Q=1,ie=r.slice.call(arguments,1),y=Object.create(this.lexer),L={yy:{}};for(var H in this.yy)Object.prototype.hasOwnProperty.call(this.yy,H)&&(L.yy[H]=this.yy[H]);y.setInput(s,L.yy),L.yy.lexer=y,L.yy.parser=this,typeof y.yylloc>"u"&&(y.yylloc={});var W=y.yylloc;r.push(W);var se=y.options&&y.options.ranges;typeof L.yy.parseError=="function"?this.parseError=L.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function re(k){o.length=o.length-2*k,p.length=p.length-k,r.length=r.length-k}l(re,"popStack");function Z(){var k;return k=c.pop()||y.lex()||Q,typeof k!="number"&&(k instanceof Array&&(c=k,k=c.pop()),k=i.symbols_[k]||k),k}l(Z,"lex");for(var b,v,S,z,O={},U,x,ee,V;;){if(v=o[o.length-1],this.defaultActions[v]?S=this.defaultActions[v]:((b===null||typeof b>"u")&&(b=Z()),S=$[v]&&$[v][b]),typeof S>"u"||!S.length||!S[0]){var X="";V=[];for(U in $[v])this.terminals_[U]&&U>ne&&V.push("'"+this.terminals_[U]+"'");y.showPosition?X="Parse error on line "+(M+1)+`: +`+y.showPosition()+` +Expecting `+V.join(", ")+", got '"+(this.terminals_[b]||b)+"'":X="Parse error on line "+(M+1)+": Unexpected "+(b==Q?"end of input":"'"+(this.terminals_[b]||b)+"'"),this.parseError(X,{text:y.match,token:this.terminals_[b]||b,line:y.yylineno,loc:W,expected:V})}if(S[0]instanceof Array&&S.length>1)throw new Error("Parse Error: multiple actions possible at state: "+v+", token: "+b);switch(S[0]){case 1:o.push(b),p.push(y.yytext),r.push(y.yylloc),o.push(S[1]),b=null,K=y.yyleng,u=y.yytext,M=y.yylineno,W=y.yylloc;break;case 2:if(x=this.productions_[S[1]][1],O.$=p[p.length-x],O._$={first_line:r[r.length-(x||1)].first_line,last_line:r[r.length-1].last_line,first_column:r[r.length-(x||1)].first_column,last_column:r[r.length-1].last_column},se&&(O._$.range=[r[r.length-(x||1)].range[0],r[r.length-1].range[1]]),z=this.performAction.apply(O,[u,K,M,L.yy,S[1],p,r].concat(ie)),typeof z<"u")return z;x&&(o=o.slice(0,-1*x*2),p=p.slice(0,-1*x),r=r.slice(0,-1*x)),o.push(this.productions_[S[1]][0]),p.push(O.$),r.push(O._$),ee=$[o[o.length-2]][o[o.length-1]],o.push(ee);break;case 3:return!0}}return!0},"parse")},te=(function(){var D={EOF:1,parseError:l(function(i,o){if(this.yy.parser)this.yy.parser.parseError(i,o);else throw new Error(i)},"parseError"),setInput:l(function(s,i){return this.yy=i||this.yy||{},this._input=s,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:l(function(){var s=this._input[0];this.yytext+=s,this.yyleng++,this.offset++,this.match+=s,this.matched+=s;var i=s.match(/(?:\r\n?|\n).*/g);return i?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),s},"input"),unput:l(function(s){var i=s.length,o=s.split(/(?:\r\n?|\n)/g);this._input=s+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-i),this.offset-=i;var c=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),o.length-1&&(this.yylineno-=o.length-1);var p=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:o?(o.length===c.length?this.yylloc.first_column:0)+c[c.length-o.length].length-o[0].length:this.yylloc.first_column-i},this.options.ranges&&(this.yylloc.range=[p[0],p[0]+this.yyleng-i]),this.yyleng=this.yytext.length,this},"unput"),more:l(function(){return this._more=!0,this},"more"),reject:l(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:l(function(s){this.unput(this.match.slice(s))},"less"),pastInput:l(function(){var s=this.matched.substr(0,this.matched.length-this.match.length);return(s.length>20?"...":"")+s.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:l(function(){var s=this.match;return s.length<20&&(s+=this._input.substr(0,20-s.length)),(s.substr(0,20)+(s.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:l(function(){var s=this.pastInput(),i=new Array(s.length+1).join("-");return s+this.upcomingInput()+` +`+i+"^"},"showPosition"),test_match:l(function(s,i){var o,c,p;if(this.options.backtrack_lexer&&(p={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(p.yylloc.range=this.yylloc.range.slice(0))),c=s[0].match(/(?:\r\n?|\n).*/g),c&&(this.yylineno+=c.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:c?c[c.length-1].length-c[c.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+s[0].length},this.yytext+=s[0],this.match+=s[0],this.matches=s,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(s[0].length),this.matched+=s[0],o=this.performAction.call(this,this.yy,this,i,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),o)return o;if(this._backtrack){for(var r in p)this[r]=p[r];return!1}return!1},"test_match"),next:l(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var s,i,o,c;this._more||(this.yytext="",this.match="");for(var p=this._currentRules(),r=0;ri[0].length)){if(i=o,c=r,this.options.backtrack_lexer){if(s=this.test_match(o,p[r]),s!==!1)return s;if(this._backtrack){i=!1;continue}else return!1}else if(!this.options.flex)break}return i?(s=this.test_match(i,p[c]),s!==!1?s:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:l(function(){var i=this.next();return i||this.lex()},"lex"),begin:l(function(i){this.conditionStack.push(i)},"begin"),popState:l(function(){var i=this.conditionStack.length-1;return i>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:l(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:l(function(i){return i=this.conditionStack.length-1-Math.abs(i||0),i>=0?this.conditionStack[i]:"INITIAL"},"topState"),pushState:l(function(i){this.begin(i)},"pushState"),stateStackSize:l(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:l(function(i,o,c,p){switch(c){case 0:return i.getLogger().trace("Found comment",o.yytext),6;case 1:return 8;case 2:this.begin("CLASS");break;case 3:return this.popState(),16;case 4:this.popState();break;case 5:i.getLogger().trace("Begin icon"),this.begin("ICON");break;case 6:return i.getLogger().trace("SPACELINE"),6;case 7:return 7;case 8:return 15;case 9:i.getLogger().trace("end icon"),this.popState();break;case 10:return i.getLogger().trace("Exploding node"),this.begin("NODE"),19;case 11:return i.getLogger().trace("Cloud"),this.begin("NODE"),19;case 12:return i.getLogger().trace("Explosion Bang"),this.begin("NODE"),19;case 13:return i.getLogger().trace("Cloud Bang"),this.begin("NODE"),19;case 14:return this.begin("NODE"),19;case 15:return this.begin("NODE"),19;case 16:return this.begin("NODE"),19;case 17:return this.begin("NODE"),19;case 18:return 13;case 19:return 22;case 20:return 11;case 21:this.begin("NSTR2");break;case 22:return"NODE_DESCR";case 23:this.popState();break;case 24:i.getLogger().trace("Starting NSTR"),this.begin("NSTR");break;case 25:return i.getLogger().trace("description:",o.yytext),"NODE_DESCR";case 26:this.popState();break;case 27:return this.popState(),i.getLogger().trace("node end ))"),"NODE_DEND";case 28:return this.popState(),i.getLogger().trace("node end )"),"NODE_DEND";case 29:return this.popState(),i.getLogger().trace("node end ...",o.yytext),"NODE_DEND";case 30:return this.popState(),i.getLogger().trace("node end (("),"NODE_DEND";case 31:return this.popState(),i.getLogger().trace("node end (-"),"NODE_DEND";case 32:return this.popState(),i.getLogger().trace("node end (-"),"NODE_DEND";case 33:return this.popState(),i.getLogger().trace("node end (("),"NODE_DEND";case 34:return this.popState(),i.getLogger().trace("node end (("),"NODE_DEND";case 35:return i.getLogger().trace("Long description:",o.yytext),20;case 36:return i.getLogger().trace("Long description:",o.yytext),20}},"anonymous"),rules:[/^(?:\s*%%.*)/i,/^(?:mindmap\b)/i,/^(?::::)/i,/^(?:.+)/i,/^(?:\n)/i,/^(?:::icon\()/i,/^(?:[\s]+[\n])/i,/^(?:[\n]+)/i,/^(?:[^\)]+)/i,/^(?:\))/i,/^(?:-\))/i,/^(?:\(-)/i,/^(?:\)\))/i,/^(?:\))/i,/^(?:\(\()/i,/^(?:\{\{)/i,/^(?:\()/i,/^(?:\[)/i,/^(?:[\s]+)/i,/^(?:[^\(\[\n\)\{\}]+)/i,/^(?:$)/i,/^(?:["][`])/i,/^(?:[^`"]+)/i,/^(?:[`]["])/i,/^(?:["])/i,/^(?:[^"]+)/i,/^(?:["])/i,/^(?:[\)]\))/i,/^(?:[\)])/i,/^(?:[\]])/i,/^(?:\}\})/i,/^(?:\(-)/i,/^(?:-\))/i,/^(?:\(\()/i,/^(?:\()/i,/^(?:[^\)\]\(\}]+)/i,/^(?:.+(?!\(\())/i],conditions:{CLASS:{rules:[3,4],inclusive:!1},ICON:{rules:[8,9],inclusive:!1},NSTR2:{rules:[22,23],inclusive:!1},NSTR:{rules:[25,26],inclusive:!1},NODE:{rules:[21,24,27,28,29,30,31,32,33,34,35,36],inclusive:!1},INITIAL:{rules:[0,1,2,5,6,7,10,11,12,13,14,15,16,17,18,19,20],inclusive:!0}}};return D})();j.lexer=te;function P(){this.yy={}}return l(P,"Parser"),P.prototype=j,j.Parser=P,new P})();Y.parser=Y;var be=Y,ke=12,N={DEFAULT:0,NO_BORDER:0,ROUNDED_RECT:1,RECT:2,CIRCLE:3,CLOUD:4,BANG:5,HEXAGON:6},Se=class{constructor(){this.nodes=[],this.count=0,this.elements={},this.getLogger=this.getLogger.bind(this),this.nodeType=N,this.clear(),this.getType=this.getType.bind(this),this.getElementById=this.getElementById.bind(this),this.getParent=this.getParent.bind(this),this.getMindmap=this.getMindmap.bind(this),this.addNode=this.addNode.bind(this),this.decorateNode=this.decorateNode.bind(this)}static{l(this,"MindmapDB")}clear(){this.nodes=[],this.count=0,this.elements={},this.baseLevel=void 0}getParent(e){for(let n=this.nodes.length-1;n>=0;n--)if(this.nodes[n].level0?this.nodes[0]:null}addNode(e,n,g,a){C.info("addNode",e,n,g,a);let t=!1;this.nodes.length===0?(this.baseLevel=e,e=0,t=!0):this.baseLevel!==void 0&&(e=e-this.baseLevel,t=!1);const h=B();let f=h.mindmap?.padding??G.mindmap.padding;switch(a){case this.nodeType.ROUNDED_RECT:case this.nodeType.RECT:case this.nodeType.HEXAGON:f*=2;break}const m={id:this.count++,nodeId:F(n,h),level:e,descr:F(g,h),type:a,children:[],width:h.mindmap?.maxNodeWidth??G.mindmap.maxNodeWidth,padding:f,isRoot:t},_=this.getParent(e);if(_)_.children.push(m),this.nodes.push(m);else if(t)this.nodes.push(m);else throw new Error(`There can be only one root. No parent could be found for ("${m.descr}")`)}getType(e,n){switch(C.debug("In get type",e,n),e){case"[":return this.nodeType.RECT;case"(":return n===")"?this.nodeType.ROUNDED_RECT:this.nodeType.CLOUD;case"((":return this.nodeType.CIRCLE;case")":return this.nodeType.CLOUD;case"))":return this.nodeType.BANG;case"{{":return this.nodeType.HEXAGON;default:return this.nodeType.DEFAULT}}setElementForId(e,n){this.elements[e]=n}getElementById(e){return this.elements[e]}decorateNode(e){if(!e)return;const n=B(),g=this.nodes[this.nodes.length-1];e.icon&&(g.icon=F(e.icon,n)),e.class&&(g.class=F(e.class,n))}type2Str(e){switch(e){case this.nodeType.DEFAULT:return"no-border";case this.nodeType.RECT:return"rect";case this.nodeType.ROUNDED_RECT:return"rounded-rect";case this.nodeType.CIRCLE:return"circle";case this.nodeType.CLOUD:return"cloud";case this.nodeType.BANG:return"bang";case this.nodeType.HEXAGON:return"hexgon";default:return"no-border"}}assignSections(e,n){if(e.level===0?e.section=void 0:e.section=n,e.children)for(const[g,a]of e.children.entries()){const t=e.level===0?g%(ke-1):n;this.assignSections(a,t)}}flattenNodes(e,n){const g=B(),a=["mindmap-node"];e.isRoot===!0?a.push("section-root","section--1"):e.section!==void 0&&a.push(`section-${e.section}`),e.class&&a.push(e.class);const t=a.join(" "),h=l(m=>{const T=(g.theme?.toLowerCase()??"").includes("redux");switch(m){case N.CIRCLE:return"mindmapCircle";case N.RECT:return"rect";case N.ROUNDED_RECT:return"rounded";case N.CLOUD:return"cloud";case N.BANG:return"bang";case N.HEXAGON:return"hexagon";case N.DEFAULT:return T?"rounded":"defaultMindmapNode";case N.NO_BORDER:default:return"rect"}},"getShapeFromType"),f={id:e.id.toString(),domId:"node_"+e.id.toString(),label:e.descr,labelType:"markdown",isGroup:!1,shape:h(e.type),width:e.width,height:e.height??0,padding:e.padding,cssClasses:t,cssStyles:[],look:g.look,icon:e.icon,x:e.x,y:e.y,level:e.level,nodeId:e.nodeId,type:e.type,section:e.section};if(n.push(f),e.children)for(const m of e.children)this.flattenNodes(m,n)}generateEdges(e,n){if(!e.children)return;const g=B();for(const a of e.children){let t="edge";a.section!==void 0&&(t+=` section-edge-${a.section}`);const h=e.level+1;t+=` edge-depth-${h}`;const f={id:`edge_${e.id}_${a.id}`,start:e.id.toString(),end:a.id.toString(),type:"normal",curve:"basis",thickness:"normal",look:g.look,classes:t,depth:e.level,section:a.section};n.push(f),this.generateEdges(a,n)}}getData(){const e=this.getMindmap(),n=B(),a=de().layout!==void 0,t=n;if(a||(t.layout="cose-bilkent"),!e)return{nodes:[],edges:[],config:t};C.debug("getData: mindmapRoot",e,n),this.assignSections(e);const h=[],f=[];this.flattenNodes(e,h),this.generateEdges(e,f),C.debug(`getData: processed ${h.length} nodes and ${f.length} edges`);const m=new Map;for(const _ of h)m.set(_.id,{shape:_.shape,width:_.width,height:_.height,padding:_.padding});return{nodes:h,edges:f,config:t,rootNode:e,markers:["point"],direction:"TB",nodeSpacing:50,rankSpacing:50,shapes:Object.fromEntries(m),type:"mindmap",diagramId:"mindmap-"+Ee()}}getLogger(){return C}},xe=l(async(e,n,g,a)=>{C.debug(`Rendering mindmap diagram +`+e);const t=a.db,h=t.getData(),f=oe(n,h.config.securityLevel);if(h.type=a.type,h.layoutAlgorithm=ce(h.config.layout,{fallback:"cose-bilkent"}),h.diagramId=n,!t.getMindmap())return;h.nodes.forEach(d=>{d.shape==="rounded"?(d.radius=15,d.taper=15,d.stroke="none",d.width=0,d.padding=15):d.shape==="circle"?d.padding=10:d.shape==="rect"?(d.width=0,d.padding=10):d.shape==="hexagon"&&(d.width=0,d.height=0)}),await le(h,f);const{themeVariables:_}=he(),{useGradient:T,gradientStart:I,gradientStop:w}=_;if(T&&I&&w){const d=f.attr("id"),R=f.append("defs").append("linearGradient").attr("id",`${d}-gradient`).attr("gradientUnits","objectBoundingBox").attr("x1","0%").attr("y1","0%").attr("x2","100%").attr("y2","0%");R.append("stop").attr("offset","0%").attr("stop-color",I).attr("stop-opacity",1),R.append("stop").attr("offset","100%").attr("stop-color",w).attr("stop-opacity",1)}ae(f,h.config.mindmap?.padding??G.mindmap.padding,"mindmapDiagram",h.config.mindmap?.useMaxWidth??G.mindmap.useMaxWidth)},"draw"),Ne={draw:xe},De=l(e=>{const{theme:n,look:g}=e;let a="";for(let t=0;t{let a="";for(let t=0;t{const{theme:n}=e,g=e.svgId,a=e.dropShadow?e.dropShadow.replace("url(#drop-shadow)",`url(${g}-drop-shadow)`):"none";return` + .edge { + stroke-width: 3; + } + ${De(e)} + .section-root rect, .section-root path, .section-root circle, .section-root polygon { + fill: ${e.git0}; + } + .section-root text { + fill: ${e.gitBranchLabel0}; + } + .section-root span { + color: ${n?.includes("redux")?e.nodeBorder:e.gitBranchLabel0}; + } + .icon-container { + height:100%; + display: flex; + justify-content: center; + align-items: center; + } + .edge { + fill: none; + } + .mindmap-node-label { + dy: 1em; + alignment-baseline: middle; + text-anchor: middle; + dominant-baseline: middle; + text-align: center; + } + [data-look="neo"].mindmap-node { + filter: ${a}; + } + [data-look="neo"].mindmap-node.section-root rect, [data-look="neo"].mindmap-node.section-root path, [data-look="neo"].mindmap-node.section-root circle, [data-look="neo"].mindmap-node.section-root polygon { + fill: ${n?.includes("redux")?e.mainBkg:e.git0}; + } + [data-look="neo"].mindmap-node.section-root .text-inner-tspan { + fill: ${n?.includes("redux")?e.nodeBorder:e["cScaleLabel"+(n==="neutral"?1:0)]}; + } + ${e.useGradient&&g&&e.mainBkg?Le(e.THEME_COLOR_LIMIT,g,e.mainBkg):""} +`},"getStyles"),Te=ve,Ae={get db(){return new Se},renderer:Ne,parser:be,styles:Te};export{Ae as diagram}; diff --git a/apps/kimi-code/dist-web/assets/pkl-u5AG7uiY.js b/apps/kimi-code/dist-web/assets/pkl-u5AG7uiY.js new file mode 100644 index 0000000000000000000000000000000000000000..30894bbeaeff44426a74fa9e95f5ac7456a59d4c --- /dev/null +++ b/apps/kimi-code/dist-web/assets/pkl-u5AG7uiY.js @@ -0,0 +1 @@ +const e=Object.freeze(JSON.parse('{"displayName":"Pkl","fileTypes":["pkl","pcf"],"foldingStartMarker":"\\\\{","foldingStopMarker":"}","name":"pkl","patterns":[{"captures":{"1":{"name":"variable.language.pkl"},"2":{"name":"variable.other.module.pkl"}},"match":"\\\\b(module)\\\\s+([$_\\\\p{L}][$0-9_\\\\p{L}]*(?:\\\\.[$_\\\\p{L}][$0-9_\\\\p{L}]*)*)"},{"captures":{"1":{"name":"keyword.class.pkl"},"2":{"name":"entity.name.type.pkl"},"3":{"name":"punctuation.pkl"},"4":{"name":"entity.name.type.pkl"}},"match":"(typealias)\\\\s+([$_\\\\p{L}][$0-9_\\\\p{L}]*)\\\\s*(=)\\\\s*([$_\\\\p{L}][$0-9_\\\\p{L}]*\\\\s*(?:<[^>]*>)?\\\\s*(?:\\\\([^)]*\\\\))?\\\\s*\\\\??\\\\s*(\\\\|\\\\s*[$_\\\\p{L}][$0-9_\\\\p{L}]*\\\\s*(?:<[^>]*>)?\\\\s*(?:\\\\([^)]*\\\\))?\\\\s*\\\\??)*)"},{"captures":{"1":{"name":"keyword.class.pkl"}},"match":"\\\\b(class)\\\\s+[$_\\\\p{L}][$0-9_\\\\p{L}]*","name":"entity.name.type.pkl"},{"captures":{"1":{"name":"keyword.control.pkl"},"2":{"name":"variable.other.property.pkl"},"3":{"name":"variable.other.property.pkl"},"4":{"name":"storage.modifier.pkl"}},"match":"\\\\b(for)\\\\s*\\\\(([$_\\\\p{L}][$0-9_\\\\p{L}]*)(?:\\\\s*,\\\\s*([$_\\\\p{L}][$0-9_\\\\p{L}]*))*\\\\s+(in)"},{"captures":{"1":{"name":"keyword.control.pkl"},"2":{"name":"entity.name.type.pkl"}},"match":"\\\\b(new)\\\\s+([$_\\\\p{L}][$0-9_\\\\p{L}]*\\\\s*(?:<[^>]*>)?\\\\s*(?:\\\\([^)]*\\\\))?\\\\s*\\\\??\\\\s*(\\\\|\\\\s*[$_\\\\p{L}][$0-9_\\\\p{L}]*\\\\s*(?:<[^>]*>)?\\\\s*(?:\\\\([^)]*\\\\))?\\\\s*\\\\??)*)"},{"captures":{"1":{"name":"keyword.pkl"},"2":{"name":"variable.other.property.pkl"}},"match":"\\\\b(function)\\\\s+([$_\\\\p{L}][$0-9_\\\\p{L}]*)"},{"captures":{"1":{"name":"keyword.pkl"},"2":{"name":"entity.name.type.pkl"}},"match":"\\\\b(as)\\\\s+([$_\\\\p{L}][$0-9_\\\\p{L}]*\\\\s*(?:<[^>]*>)?\\\\s*(?:\\\\([^)]*\\\\))?\\\\s*\\\\??\\\\s*(\\\\|\\\\s*[$_\\\\p{L}][$0-9_\\\\p{L}]*\\\\s*(?:<[^>]*>)?\\\\s*(?:\\\\([^)]*\\\\))?\\\\s*\\\\??)*)"},{"match":"\\\\b(true|false|null)\\\\b","name":"constant.character.language.pkl"},{"match":"//.*","name":"comment.line.pkl"},{"begin":"/\\\\*","end":"\\\\*/","name":"comment.block.pkl"},{"begin":"((?:\\\\b|\\\\s*)[$_\\\\p{L}][$0-9_\\\\p{L}]*|`[^`]+`)\\\\s*(:)\\\\s*([$_\\\\p{L}][$0-9_\\\\p{L}]*\\\\s*(?:<[^>]*>)?\\\\s*(?:\\\\([^)]*\\\\))?\\\\s*\\\\??\\\\s*(\\\\|\\\\s*[$_\\\\p{L}][$0-9_\\\\p{L}]*\\\\s*(?:<[^>]*>)?\\\\s*(?:\\\\([^)]*\\\\))?\\\\s*\\\\??)*)","captures":{"1":{"name":"variable.other.property.pkl"},"2":{"name":"punctuation.pkl"},"3":{"name":"entity.name.type.pkl"}},"end":"\\\\s*=|[),]|^[\\\\t ]*$"},{"captures":{"1":{"name":"variable.other.property.pkl"},"2":{"name":"punctuation.pkl"}},"match":"(\\\\b[$_\\\\p{L}][$0-9_\\\\p{L}]*|`[^`]+`)\\\\s*(=)(?!=)"},{"captures":{"1":{"name":"punctuation.pkl"},"2":{"name":"entity.name.type.pkl"}},"match":"(:)\\\\s*([$_\\\\p{L}][$0-9_\\\\p{L}]*\\\\s*(?:<[^>]*>)?\\\\s*(?:\\\\([^)]*\\\\))?\\\\s*\\\\??\\\\s*(\\\\|\\\\s*[$_\\\\p{L}][$0-9_\\\\p{L}]*\\\\s*(?:<[^>]*>)?\\\\s*(?:\\\\([^)]*\\\\))?\\\\s*\\\\??)*)"},{"captures":{"1":{"name":"variable.other.property.pkl"}},"match":"^\\\\s*([$_\\\\p{L}][$0-9_\\\\p{L}]*)\\\\s*\\\\{"},{"match":"\\\\b(hidden|local|abstract|external|open|in|out|amends|extends|fixed|const)\\\\b","name":"storage.modifier.pkl"},{"match":"\\\\b(amends|as|extends|function|is|let|read\\\\???|import|throw|trace)\\\\b","name":"keyword.pkl"},{"match":"\\\\b(if|else|when|for|import|new)\\\\b","name":"keyword.control.pkl"},{"match":"\\\\b0x(?:[A-Fa-f\\\\d][A-F_a-f\\\\d]*[A-Fa-f\\\\d]|[A-F_a-f\\\\d])\\\\b","name":"constant.numeric.hex.pkl"},{"match":"\\\\b0b(?:[01][01_]*[01]|[01])\\\\b","name":"constant.numeric.binary.pkl"},{"match":"\\\\b0o(?:[0-7][0-7_]*[0-7]|[0-7])\\\\b","name":"constant.numeric.octal.pkl"},{"match":"\\\\b\\\\d(?:[0-9_]*\\\\d|)\\\\b","name":"constant.numeric.decimal.pkl"},{"match":"\\\\b(?:(?:\\\\d(?:[0-9_]*\\\\d|))?\\\\.\\\\d(?:[0-9_]*\\\\d|)(?:[Ee][-+]?\\\\d(?:[0-9_]*\\\\d|))?|\\\\d(?:[0-9_]*\\\\d|)[Ee][-+]?\\\\d(?:[0-9_]*\\\\d|))\\\\b","name":"constant.numeric.pkl"},{"match":"[-*+/]|~/|%|\\\\*\\\\*|>=??|<=??|==|!=?|&&|\\\\|\\\\||\\\\|>|\\\\?\\\\?|!!|=|->|\\\\|","name":"keyword.operator.pkl"},{"match":"\\\\b(this|module|outer|super)\\\\b","name":"variable.language.pkl"},{"match":"\\\\b(unknown|never)\\\\b","name":"support.type.pkl"},{"match":"[]()\\\\[{}]","name":"meta.brace.pkl"},{"match":"\\\\b(class|typealias)\\\\b","name":"keyword.class.pkl"},{"match":"\\\\.\\\\?|[.:;]","name":"punctuation.pkl"},{"match":"@[$_\\\\p{L}][$0-9_\\\\p{L}]*","name":"entity.name.type.pkl"},{"begin":"(\\"\\"\\")","captures":{"1":{"name":"punctuation.delimiter.pkl"}},"end":"(\\"\\"\\")","name":"string.quoted.triple.0.pkl","patterns":[{"captures":{"1":{"name":"invalid.illegal.unrecognized-string-escape.pkl"}},"match":"\\\\\\\\(?:[\\"\\\\\\\\nrt]|u\\\\{[A-Fa-f\\\\d]+}|\\\\(.+?\\\\))|(\\\\\\\\.)","name":"constant.character.escape.0.pkl"}]},{"begin":"(\\")","beginCaptures":{"1":{"name":"punctuation.delimiter.pkl"}},"end":"(\\")|(.?)$","endCaptures":{"1":{"name":"punctuation.delimimter.pkl"},"2":{"name":"invalid.illegal.newline.pkl"}},"name":"string.quoted.double.0.pkl","patterns":[{"captures":{"1":{"name":"invalid.illegal.unrecognized-string-escape.pkl"}},"match":"\\\\\\\\(?:[\\"\\\\\\\\nrt]|u\\\\{[A-Fa-f\\\\d]+}|\\\\(.+?\\\\))|(\\\\\\\\.)","name":"constant.character.escape.0.pkl"}]},{"begin":"(#\\"\\"\\")","captures":{"1":{"name":"punctuation.delimiter.pkl"}},"end":"(\\"\\"\\"#)","name":"string.quoted.triple.1.pkl","patterns":[{"captures":{"1":{"name":"invalid.illegal.unrecognized-string-escape.pkl"}},"match":"\\\\\\\\#(?:[\\"\\\\\\\\nrt]|u\\\\{[A-Fa-f\\\\d]+}|\\\\(.+?\\\\))|(\\\\\\\\#.)","name":"constant.character.escape.1.pkl"}]},{"begin":"(#\\")","beginCaptures":{"1":{"name":"punctuation.delimiter.pkl"}},"end":"(\\"#)|(.?)$","endCaptures":{"1":{"name":"punctuation.delimimter.pkl"},"2":{"name":"invalid.illegal.newline.pkl"}},"name":"string.quoted.double.1.pkl","patterns":[{"captures":{"1":{"name":"invalid.illegal.unrecognized-string-escape.pkl"}},"match":"\\\\\\\\#(?:[\\"\\\\\\\\nrt]|u\\\\{[A-Fa-f\\\\d]+}|\\\\(.+?\\\\))|(\\\\\\\\#.)","name":"constant.character.escape.1.pkl"}]},{"begin":"(##\\"\\"\\")","captures":{"1":{"name":"punctuation.delimiter.pkl"}},"end":"(\\"\\"\\"##)","name":"string.quoted.triple.2.pkl","patterns":[{"captures":{"1":{"name":"invalid.illegal.unrecognized-string-escape.pkl"}},"match":"\\\\\\\\##(?:[\\"\\\\\\\\nrt]|u\\\\{[A-Fa-f\\\\d]+}|\\\\(.+?\\\\))|(\\\\\\\\##.)","name":"constant.character.escape.2.pkl"}]},{"begin":"(##\\")","beginCaptures":{"1":{"name":"punctuation.delimiter.pkl"}},"end":"(\\"##)|(.?)$","endCaptures":{"1":{"name":"punctuation.delimimter.pkl"},"2":{"name":"invalid.illegal.newline.pkl"}},"name":"string.quoted.double.2.pkl","patterns":[{"captures":{"1":{"name":"invalid.illegal.unrecognized-string-escape.pkl"}},"match":"\\\\\\\\##(?:[\\"\\\\\\\\nrt]|u\\\\{[A-Fa-f\\\\d]+}|\\\\(.+?\\\\))|(\\\\\\\\##.)","name":"constant.character.escape.2.pkl"}]},{"begin":"(###\\"\\"\\")","captures":{"1":{"name":"punctuation.delimiter.pkl"}},"end":"(\\"\\"\\"###)","name":"string.quoted.triple.3.pkl","patterns":[{"captures":{"1":{"name":"invalid.illegal.unrecognized-string-escape.pkl"}},"match":"\\\\\\\\###(?:[\\"\\\\\\\\nrt]|u\\\\{[A-Fa-f\\\\d]+}|\\\\(.+?\\\\))|(\\\\\\\\###.)","name":"constant.character.escape.3.pkl"}]},{"begin":"(###\\")","beginCaptures":{"1":{"name":"punctuation.delimiter.pkl"}},"end":"(\\"###)|(.?)$","endCaptures":{"1":{"name":"punctuation.delimimter.pkl"},"2":{"name":"invalid.illegal.newline.pkl"}},"name":"string.quoted.double.3.pkl","patterns":[{"captures":{"1":{"name":"invalid.illegal.unrecognized-string-escape.pkl"}},"match":"\\\\\\\\###(?:[\\"\\\\\\\\nrt]|u\\\\{[A-Fa-f\\\\d]+}|\\\\(.+?\\\\))|(\\\\\\\\###.)","name":"constant.character.escape.3.pkl"}]},{"begin":"(####\\"\\"\\")","captures":{"1":{"name":"punctuation.delimiter.pkl"}},"end":"(\\"\\"\\"####)","name":"string.quoted.triple.4.pkl","patterns":[{"captures":{"1":{"name":"invalid.illegal.unrecognized-string-escape.pkl"}},"match":"\\\\\\\\####(?:[\\"\\\\\\\\nrt]|u\\\\{[A-Fa-f\\\\d]+}|\\\\(.+?\\\\))|(\\\\\\\\####.)","name":"constant.character.escape.4.pkl"}]},{"begin":"(####\\")","beginCaptures":{"1":{"name":"punctuation.delimiter.pkl"}},"end":"(\\"####)|(.?)$","endCaptures":{"1":{"name":"punctuation.delimimter.pkl"},"2":{"name":"invalid.illegal.newline.pkl"}},"name":"string.quoted.double.4.pkl","patterns":[{"captures":{"1":{"name":"invalid.illegal.unrecognized-string-escape.pkl"}},"match":"\\\\\\\\####(?:[\\"\\\\\\\\nrt]|u\\\\{[A-Fa-f\\\\d]+}|\\\\(.+?\\\\))|(\\\\\\\\####.)","name":"constant.character.escape.4.pkl"}]},{"begin":"(#####\\"\\"\\")","captures":{"1":{"name":"punctuation.delimiter.pkl"}},"end":"(\\"\\"\\"#####)","name":"string.quoted.triple.5.pkl","patterns":[{"captures":{"1":{"name":"invalid.illegal.unrecognized-string-escape.pkl"}},"match":"\\\\\\\\#####(?:[\\"\\\\\\\\nrt]|u\\\\{[A-Fa-f\\\\d]+}|\\\\(.+?\\\\))|(\\\\\\\\#####.)","name":"constant.character.escape.5.pkl"}]},{"begin":"(#####\\")","beginCaptures":{"1":{"name":"punctuation.delimiter.pkl"}},"end":"(\\"#####)|(.?)$","endCaptures":{"1":{"name":"punctuation.delimimter.pkl"},"2":{"name":"invalid.illegal.newline.pkl"}},"name":"string.quoted.double.5.pkl","patterns":[{"captures":{"1":{"name":"invalid.illegal.unrecognized-string-escape.pkl"}},"match":"\\\\\\\\#####(?:[\\"\\\\\\\\nrt]|u\\\\{[A-Fa-f\\\\d]+}|\\\\(.+?\\\\))|(\\\\\\\\#####.)","name":"constant.character.escape.5.pkl"}]},{"begin":"(######\\"\\"\\")","captures":{"1":{"name":"punctuation.delimiter.pkl"}},"end":"(\\"\\"\\"######)","name":"string.quoted.triple.6.pkl","patterns":[{"captures":{"1":{"name":"invalid.illegal.unrecognized-string-escape.pkl"}},"match":"\\\\\\\\######(?:[\\"\\\\\\\\nrt]|u\\\\{[A-Fa-f\\\\d]+}|\\\\(.+?\\\\))|(\\\\\\\\######.)","name":"constant.character.escape.6.pkl"}]},{"begin":"(######\\")","beginCaptures":{"1":{"name":"punctuation.delimiter.pkl"}},"end":"(\\"######)|(.?)$","endCaptures":{"1":{"name":"punctuation.delimimter.pkl"},"2":{"name":"invalid.illegal.newline.pkl"}},"name":"string.quoted.double.6.pkl","patterns":[{"captures":{"1":{"name":"invalid.illegal.unrecognized-string-escape.pkl"}},"match":"\\\\\\\\######(?:[\\"\\\\\\\\nrt]|u\\\\{[A-Fa-f\\\\d]+}|\\\\(.+?\\\\))|(\\\\\\\\######.)","name":"constant.character.escape.6.pkl"}]}],"scopeName":"source.pkl"}')),a=[e];export{a as default}; diff --git a/apps/kimi-code/dist-web/assets/timeline-definition-FHXFAJF6-CHrctaTh.js b/apps/kimi-code/dist-web/assets/timeline-definition-FHXFAJF6-CHrctaTh.js new file mode 100644 index 0000000000000000000000000000000000000000..dd806db863062c497767f41e34a387dda3fba35b --- /dev/null +++ b/apps/kimi-code/dist-web/assets/timeline-definition-FHXFAJF6-CHrctaTh.js @@ -0,0 +1,120 @@ +import{_ as o,y as pt,T as Rt,U as Ct,V as Wt,c as gt,l as E,D as Pt,K as Bt,W as ft,d as X,t as Vt,X as Ft,p as zt}from"./mermaidParser.worker-BFSlSHEW.js";import{d as ot}from"./arc-Bh_RIraT.js";var tt=(function(){var e=o(function(k,s,d,l){for(d=d||{},l=k.length;l--;d[k[l]]=s);return d},"o"),t=[6,11,13,14,15,17,19,20,23,24],n=[1,12],i=[1,13],r=[1,14],h=[1,15],c=[1,16],a=[1,19],f=[1,20],g={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,timeline_header:4,document:5,EOF:6,timeline:7,timeline_lr:8,timeline_td:9,line:10,SPACE:11,statement:12,NEWLINE:13,title:14,acc_title:15,acc_title_value:16,acc_descr:17,acc_descr_value:18,acc_descr_multiline_value:19,section:20,period_statement:21,event_statement:22,period:23,event:24,$accept:0,$end:1},terminals_:{2:"error",6:"EOF",7:"timeline",8:"timeline_lr",9:"timeline_td",11:"SPACE",13:"NEWLINE",14:"title",15:"acc_title",16:"acc_title_value",17:"acc_descr",18:"acc_descr_value",19:"acc_descr_multiline_value",20:"section",23:"period",24:"event"},productions_:[0,[3,3],[4,1],[4,1],[4,1],[5,0],[5,2],[10,2],[10,1],[10,1],[10,1],[12,1],[12,2],[12,2],[12,1],[12,1],[12,1],[12,1],[21,1],[22,1]],performAction:o(function(s,d,l,p,x,u,S){var v=u.length-1;switch(x){case 1:return u[v-1];case 3:p.setDirection("LR");break;case 4:p.setDirection("TD");break;case 5:this.$=[];break;case 6:u[v-1].push(u[v]),this.$=u[v-1];break;case 7:case 8:this.$=u[v];break;case 9:case 10:this.$=[];break;case 11:p.getCommonDb().setDiagramTitle(u[v].substr(6)),this.$=u[v].substr(6);break;case 12:this.$=u[v].trim(),p.getCommonDb().setAccTitle(this.$);break;case 13:case 14:this.$=u[v].trim(),p.getCommonDb().setAccDescription(this.$);break;case 15:p.addSection(u[v].substr(8)),this.$=u[v].substr(8);break;case 18:p.addTask(u[v],0,""),this.$=u[v];break;case 19:p.addEvent(u[v].substr(2)),this.$=u[v];break}},"anonymous"),table:[{3:1,4:2,7:[1,3],8:[1,4],9:[1,5]},{1:[3]},e(t,[2,5],{5:6}),e(t,[2,2]),e(t,[2,3]),e(t,[2,4]),{6:[1,7],10:8,11:[1,9],12:10,13:[1,11],14:n,15:i,17:r,19:h,20:c,21:17,22:18,23:a,24:f},e(t,[2,10],{1:[2,1]}),e(t,[2,6]),{12:21,14:n,15:i,17:r,19:h,20:c,21:17,22:18,23:a,24:f},e(t,[2,8]),e(t,[2,9]),e(t,[2,11]),{16:[1,22]},{18:[1,23]},e(t,[2,14]),e(t,[2,15]),e(t,[2,16]),e(t,[2,17]),e(t,[2,18]),e(t,[2,19]),e(t,[2,7]),e(t,[2,12]),e(t,[2,13])],defaultActions:{},parseError:o(function(s,d){if(d.recoverable)this.trace(s);else{var l=new Error(s);throw l.hash=d,l}},"parseError"),parse:o(function(s){var d=this,l=[0],p=[],x=[null],u=[],S=this.table,v="",I=0,R=0,W=2,O=1,L=u.slice.call(arguments,1),w=Object.create(this.lexer),H={yy:{}};for(var V in this.yy)Object.prototype.hasOwnProperty.call(this.yy,V)&&(H.yy[V]=this.yy[V]);w.setInput(s,H.yy),H.yy.lexer=w,H.yy.parser=this,typeof w.yylloc>"u"&&(w.yylloc={});var F=w.yylloc;u.push(F);var U=w.options&&w.options.ranges;typeof H.yy.parseError=="function"?this.parseError=H.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function N(A){l.length=l.length-2*A,x.length=x.length-A,u.length=u.length-A}o(N,"popStack");function b(){var A;return A=p.pop()||w.lex()||O,typeof A!="number"&&(A instanceof Array&&(p=A,A=p.pop()),A=d.symbols_[A]||A),A}o(b,"lex");for(var _,$,T,P,C={},G,B,Z,j;;){if($=l[l.length-1],this.defaultActions[$]?T=this.defaultActions[$]:((_===null||typeof _>"u")&&(_=b()),T=S[$]&&S[$][_]),typeof T>"u"||!T.length||!T[0]){var Y="";j=[];for(G in S[$])this.terminals_[G]&&G>W&&j.push("'"+this.terminals_[G]+"'");w.showPosition?Y="Parse error on line "+(I+1)+`: +`+w.showPosition()+` +Expecting `+j.join(", ")+", got '"+(this.terminals_[_]||_)+"'":Y="Parse error on line "+(I+1)+": Unexpected "+(_==O?"end of input":"'"+(this.terminals_[_]||_)+"'"),this.parseError(Y,{text:w.match,token:this.terminals_[_]||_,line:w.yylineno,loc:F,expected:j})}if(T[0]instanceof Array&&T.length>1)throw new Error("Parse Error: multiple actions possible at state: "+$+", token: "+_);switch(T[0]){case 1:l.push(_),x.push(w.yytext),u.push(w.yylloc),l.push(T[1]),_=null,R=w.yyleng,v=w.yytext,I=w.yylineno,F=w.yylloc;break;case 2:if(B=this.productions_[T[1]][1],C.$=x[x.length-B],C._$={first_line:u[u.length-(B||1)].first_line,last_line:u[u.length-1].last_line,first_column:u[u.length-(B||1)].first_column,last_column:u[u.length-1].last_column},U&&(C._$.range=[u[u.length-(B||1)].range[0],u[u.length-1].range[1]]),P=this.performAction.apply(C,[v,R,I,H.yy,T[1],x,u].concat(L)),typeof P<"u")return P;B&&(l=l.slice(0,-1*B*2),x=x.slice(0,-1*B),u=u.slice(0,-1*B)),l.push(this.productions_[T[1]][0]),x.push(C.$),u.push(C._$),Z=S[l[l.length-2]][l[l.length-1]],l.push(Z);break;case 3:return!0}}return!0},"parse")},m=(function(){var k={EOF:1,parseError:o(function(d,l){if(this.yy.parser)this.yy.parser.parseError(d,l);else throw new Error(d)},"parseError"),setInput:o(function(s,d){return this.yy=d||this.yy||{},this._input=s,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var s=this._input[0];this.yytext+=s,this.yyleng++,this.offset++,this.match+=s,this.matched+=s;var d=s.match(/(?:\r\n?|\n).*/g);return d?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),s},"input"),unput:o(function(s){var d=s.length,l=s.split(/(?:\r\n?|\n)/g);this._input=s+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-d),this.offset-=d;var p=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),l.length-1&&(this.yylineno-=l.length-1);var x=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:l?(l.length===p.length?this.yylloc.first_column:0)+p[p.length-l.length].length-l[0].length:this.yylloc.first_column-d},this.options.ranges&&(this.yylloc.range=[x[0],x[0]+this.yyleng-d]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(s){this.unput(this.match.slice(s))},"less"),pastInput:o(function(){var s=this.matched.substr(0,this.matched.length-this.match.length);return(s.length>20?"...":"")+s.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var s=this.match;return s.length<20&&(s+=this._input.substr(0,20-s.length)),(s.substr(0,20)+(s.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var s=this.pastInput(),d=new Array(s.length+1).join("-");return s+this.upcomingInput()+` +`+d+"^"},"showPosition"),test_match:o(function(s,d){var l,p,x;if(this.options.backtrack_lexer&&(x={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(x.yylloc.range=this.yylloc.range.slice(0))),p=s[0].match(/(?:\r\n?|\n).*/g),p&&(this.yylineno+=p.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:p?p[p.length-1].length-p[p.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+s[0].length},this.yytext+=s[0],this.match+=s[0],this.matches=s,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(s[0].length),this.matched+=s[0],l=this.performAction.call(this,this.yy,this,d,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),l)return l;if(this._backtrack){for(var u in x)this[u]=x[u];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var s,d,l,p;this._more||(this.yytext="",this.match="");for(var x=this._currentRules(),u=0;ud[0].length)){if(d=l,p=u,this.options.backtrack_lexer){if(s=this.test_match(l,x[u]),s!==!1)return s;if(this._backtrack){d=!1;continue}else return!1}else if(!this.options.flex)break}return d?(s=this.test_match(d,x[p]),s!==!1?s:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var d=this.next();return d||this.lex()},"lex"),begin:o(function(d){this.conditionStack.push(d)},"begin"),popState:o(function(){var d=this.conditionStack.length-1;return d>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(d){return d=this.conditionStack.length-1-Math.abs(d||0),d>=0?this.conditionStack[d]:"INITIAL"},"topState"),pushState:o(function(d){this.begin(d)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(d,l,p,x){switch(p){case 0:break;case 1:break;case 2:return 13;case 3:break;case 4:break;case 5:return 8;case 6:return 9;case 7:return 7;case 8:return 14;case 9:return this.begin("acc_title"),15;case 10:return this.popState(),"acc_title_value";case 11:return this.begin("acc_descr"),17;case 12:return this.popState(),"acc_descr_value";case 13:this.begin("acc_descr_multiline");break;case 14:this.popState();break;case 15:return"acc_descr_multiline_value";case 16:return 20;case 17:return 24;case 18:return 23;case 19:return 6;case 20:return"INVALID"}},"anonymous"),rules:[/^(?:%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:#[^\n]*)/i,/^(?:timeline[ \t]+LR\b)/i,/^(?:timeline[ \t]+TD\b)/i,/^(?:timeline\b)/i,/^(?:title\s[^\n]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:section\s[^:\n]+)/i,/^(?::\s(?:[^:\n]|:(?!\s))+)/i,/^(?:[^#:\n]+)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[14,15],inclusive:!1},acc_descr:{rules:[12],inclusive:!1},acc_title:{rules:[10],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,8,9,11,13,16,17,18,19,20],inclusive:!0}}};return k})();g.lexer=m;function y(){this.yy={}}return o(y,"Parser"),y.prototype=g,g.Parser=y,new y})();tt.parser=tt;var Ot=tt,yt={};Vt(yt,{addEvent:()=>Tt,addSection:()=>_t,addTask:()=>Et,addTaskOrg:()=>$t,clear:()=>kt,default:()=>Gt,getCommonDb:()=>xt,getDirection:()=>bt,getSections:()=>wt,getTasks:()=>St,setDirection:()=>vt});var D="",mt=0,nt="LR",rt=[],q=[],K=[],xt=o(()=>Ft,"getCommonDb"),kt=o(function(){rt.length=0,q.length=0,D="",K.length=0,nt="LR",zt()},"clear"),vt=o(function(e){nt=e},"setDirection"),bt=o(function(){return nt},"getDirection"),_t=o(function(e){D=e,rt.push(e)},"addSection"),wt=o(function(){return rt},"getSections"),St=o(function(){let e=ct();const t=100;let n=0;for(;!e&&nn.id===mt-1).events.push(e)},"addEvent"),$t=o(function(e){const t={section:D,type:D,description:e,task:e,classes:[]};q.push(t)},"addTaskOrg"),ct=o(function(){const e=o(function(n){return K[n].processed},"compileTask");let t=!0;for(const[n,i]of K.entries())e(n),t=t&&i.processed;return t},"compileTasks"),Gt={clear:kt,getCommonDb:xt,getDirection:bt,setDirection:vt,addSection:_t,getSections:wt,getTasks:St,addTask:Et,addTaskOrg:$t,addEvent:Tt},Nt=0,J=o(function(e,t){const n=e.append("rect");return n.attr("x",t.x),n.attr("y",t.y),n.attr("fill",t.fill),n.attr("stroke",t.stroke),n.attr("width",t.width),n.attr("height",t.height),n.attr("rx",t.rx),n.attr("ry",t.ry),t.class!==void 0&&n.attr("class",t.class),n},"drawRect"),Dt=o(function(e,t){const i=e.append("circle").attr("cx",t.cx).attr("cy",t.cy).attr("class","face").attr("r",15).attr("stroke-width",2).attr("overflow","visible"),r=e.append("g");r.append("circle").attr("cx",t.cx-15/3).attr("cy",t.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666"),r.append("circle").attr("cx",t.cx+15/3).attr("cy",t.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666");function h(f){const g=ot().startAngle(Math.PI/2).endAngle(3*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);f.append("path").attr("class","mouth").attr("d",g).attr("transform","translate("+t.cx+","+(t.cy+2)+")")}o(h,"smile");function c(f){const g=ot().startAngle(3*Math.PI/2).endAngle(5*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);f.append("path").attr("class","mouth").attr("d",g).attr("transform","translate("+t.cx+","+(t.cy+7)+")")}o(c,"sad");function a(f){f.append("line").attr("class","mouth").attr("stroke",2).attr("x1",t.cx-5).attr("y1",t.cy+7).attr("x2",t.cx+5).attr("y2",t.cy+7).attr("class","mouth").attr("stroke-width","1px").attr("stroke","#666")}return o(a,"ambivalent"),t.score>3?h(r):t.score<3?c(r):a(r),i},"drawFace"),Kt=o(function(e,t){const n=e.append("circle");return n.attr("cx",t.cx),n.attr("cy",t.cy),n.attr("class","actor-"+t.pos),n.attr("fill",t.fill),n.attr("stroke",t.stroke),n.attr("r",t.r),n.class!==void 0&&n.attr("class",n.class),t.title!==void 0&&n.append("title").text(t.title),n},"drawCircle"),It=o(function(e,t){const n=t.text.replace(//gi," "),i=e.append("text");i.attr("x",t.x),i.attr("y",t.y),i.attr("class","legend"),i.style("text-anchor",t.anchor),t.class!==void 0&&i.attr("class",t.class);const r=i.append("tspan");return r.attr("x",t.x+t.textMargin*2),r.text(n),i},"drawText"),Ut=o(function(e,t){function n(r,h,c,a,f){return r+","+h+" "+(r+c)+","+h+" "+(r+c)+","+(h+a-f)+" "+(r+c-f*1.2)+","+(h+a)+" "+r+","+(h+a)}o(n,"genPoints");const i=e.append("polygon");i.attr("points",n(t.x,t.y,50,20,7)),i.attr("class","labelBox"),t.y=t.y+t.labelMargin,t.x=t.x+.5*t.labelMargin,It(e,t)},"drawLabel"),Xt=o(function(e,t,n){const i=e.append("g"),r=st();r.x=t.x,r.y=t.y,r.fill=t.fill,r.width=n.width,r.height=n.height,r.class="journey-section section-type-"+t.num,r.rx=3,r.ry=3,J(i,r),Ht(n)(t.text,i,r.x,r.y,r.width,r.height,{class:"journey-section section-type-"+t.num},n,t.colour)},"drawSection"),et=-1,Zt=o(function(e,t,n,i){const r=t.x+n.width/2,h=e.append("g");et++,h.append("line").attr("id",i+"-task"+et).attr("x1",r).attr("y1",t.y).attr("x2",r).attr("y2",450).attr("class","task-line").attr("stroke-width","1px").attr("stroke-dasharray","4 2").attr("stroke","#666"),Dt(h,{cx:r,cy:300+(5-t.score)*30,score:t.score});const a=st();a.x=t.x,a.y=t.y,a.fill=t.fill,a.width=n.width,a.height=n.height,a.class="task task-type-"+t.num,a.rx=3,a.ry=3,J(h,a),Ht(n)(t.task,h,a.x,a.y,a.width,a.height,{class:"task"},n,t.colour)},"drawTask"),jt=o(function(e,t){J(e,{x:t.startx,y:t.starty,width:t.stopx-t.startx,height:t.stopy-t.starty,fill:t.fill,class:"rect"}).lower()},"drawBackgroundRect"),qt=o(function(){return{x:0,y:0,fill:void 0,"text-anchor":"start",width:100,height:100,textMargin:0,rx:0,ry:0}},"getTextObj"),st=o(function(){return{x:0,y:0,width:100,anchor:"start",height:100,rx:0,ry:0}},"getNoteRect"),Ht=(function(){function e(r,h,c,a,f,g,m,y){const k=h.append("text").attr("x",c+f/2).attr("y",a+g/2+5).style("font-color",y).style("text-anchor","middle").text(r);i(k,m)}o(e,"byText");function t(r,h,c,a,f,g,m,y,k){const{taskFontSize:s,taskFontFamily:d}=y,l=r.split(//gi);for(let p=0;p)/).reverse(),r,h=[],c=1.1,a=n.attr("y"),f=parseFloat(n.attr("dy")),g=n.text(null).append("tspan").attr("x",0).attr("y",a).attr("dy",f+"em");for(let m=0;mt||r==="
")&&(h.pop(),g.text(h.join(" ").trim()),r==="
"?h=[""]:h=[r],g=n.append("tspan").attr("x",0).attr("y",a).attr("dy",c+"em").text(r))})}o(it,"wrap");var Qt=o(function(e,t,n,i,r,h=!1){const{theme:c,look:a}=i,f=c?.includes("redux"),g=i?.themeVariables?.THEME_COLOR_LIMIT??12,m=n%g-1,y=e.append("g");t.section=m,y.attr("class",(t.class?t.class+" ":"")+"timeline-node "+("section-"+m));const k=y.append("g"),s=y.append("g"),l=s.append("text").text(t.descr).attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","middle").attr("text-anchor","middle").call(it,t.width).node().getBBox(),p=i.fontSize?.replace?i.fontSize.replace("px",""):i.fontSize;if(t.height=l.height+p*1.1*.5+t.padding,t.height=Math.max(t.height,t.maxHeight),t.width=t.width+2*t.padding,s.attr("transform","translate("+t.width/2+", "+t.padding/2+")"),f&&s.attr("transform",`translate(${t.width/2}, ${h?t.padding/2+3:t.padding})`),te(k,t,m,r,i),a==="neo"&&(y.attr("data-look","neo"),f)){const x=c.includes("dark"),u=e.node()?.ownerSVGElement??e.node(),S=X(u),v=S.attr("id")??"",I=v?`${v}-drop-shadow`:"drop-shadow";if(S.select(`#${I}`).empty()){const R=S.select("defs");(R.empty()?S.append("defs"):R).append("filter").attr("id",I).attr("height","130%").attr("width","130%").append("feDropShadow").attr("dx","4").attr("dy","4").attr("stdDeviation",0).attr("flood-opacity",x?"0.2":"0.06").attr("flood-color",x?"#FFFFFF":"#000000")}}return t},"drawNode"),Yt=o(function(e,t,n){const i=e.append("g"),h=i.append("text").text(t.descr).attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","middle").attr("text-anchor","middle").call(it,t.width).node().getBBox(),c=n.fontSize?.replace?n.fontSize.replace("px",""):n.fontSize;return i.remove(),h.height+c*1.1*.5+t.padding},"getVirtualNodeHeight"),te=o(function(e,t,n,i,r){const{theme:h}=r,c=h?.includes("redux")?0:5,a=5,f=c>0?`M0 ${t.height-a} v${-t.height+2*a} q0,-${c},${c},-${c} h${t.width-2*a} q${c},0,${c},${c} v${t.height-a} H0 Z`:`M0 ${t.height-a} v${-(t.height-a)} h${t.width} v${t.height} H0 Z`;e.append("path").attr("id",i+"-node-"+Nt++).attr("class","node-bkg node-"+t.type).attr("d",f),h?.includes("redux")||e.append("line").attr("class","node-line-"+n).attr("x1",0).attr("y1",t.height).attr("x2",t.width).attr("y2",t.height)},"defaultBkg"),M={drawRect:J,drawCircle:Kt,drawSection:Xt,drawText:It,drawLabel:Ut,drawTask:Zt,drawBackgroundRect:jt,getTextObj:qt,getNoteRect:st,initGraphics:Jt,drawNode:Qt,getVirtualNodeHeight:Yt},ee=o(function(e,t,n,i){const r=gt(),{look:h,theme:c,themeVariables:a}=r,{useGradient:f,gradientStart:g,gradientStop:m}=a,y=r.timeline?.leftMargin??50;E.debug("timeline",i.db);const k=r.securityLevel;let s;k==="sandbox"&&(s=X("#i"+t));const l=(k==="sandbox"?X(s.nodes()[0].contentDocument.body):X("body")).select("#"+t);l.append("g");const p=i.db.getTasks(),x=i.db.getCommonDb().getDiagramTitle();E.debug("task",p),M.initGraphics(l,t);const u=i.db.getSections();E.debug("sections",u);let S=0,v=0,I=0,R=0,W=50+y,O=50;R=50;let L=0,w=!0;u.forEach(function(N){const b={number:L,descr:N,section:L,width:150,padding:20,maxHeight:S},_=M.getVirtualNodeHeight(l,b,r);E.debug("sectionHeight before draw",_),S=Math.max(S,_+20)});let H=0,V=0;E.debug("tasks.length",p.length);for(const[N,b]of p.entries()){const _={number:N,descr:b,section:b.section,width:150,padding:20,maxHeight:v},$=M.getVirtualNodeHeight(l,_,r);E.debug("taskHeight before draw",$),v=Math.max(v,$+20),H=Math.max(H,b.events.length);let T=0;for(const P of b.events){const C={descr:P,section:b.section,number:b.section,width:150,padding:20,maxHeight:50};T+=M.getVirtualNodeHeight(l,C,r)}b.events.length>0&&(T+=(b.events.length-1)*10),V=Math.max(V,T)}E.debug("maxSectionHeight before draw",S),E.debug("maxTaskHeight before draw",v),u&&u.length>0?u.forEach(N=>{const b=p.filter(P=>P.section===N),_={number:L,descr:N,section:L,width:200*Math.max(b.length,1)-50,padding:20,maxHeight:S};E.debug("sectionNode",_);const $=l.append("g"),T=M.drawNode($,_,L,r,t);E.debug("sectionNode output",T),$.attr("transform",`translate(${W}, ${R})`),O+=S+50,b.length>0&<(l,b,L,W,O,v,r,H,V,S,!1,t),W+=200*Math.max(b.length,1),O=R,L++}):(w=!1,lt(l,p,L,W,O,v,r,H,V,S,!0,t));const F=l.node().getBBox();if(E.debug("bounds",F),x&&l.append("text").text(x).attr("x",h==="neo"?F.x*2+y:F.width/2-y).attr("font-size","4ex").attr("font-weight","bold").attr("y",20),I=w?S+v+150:v+100,l.append("g").attr("class","lineWrapper").append("line").attr("x1",y).attr("y1",I).attr("x2",F.width+3*y).attr("y2",I).attr("stroke-width",4).attr("stroke","black").attr("marker-end",`url(#${t}-arrowhead)`),h==="neo"&&f&&c!=="neutral"){const N=l.select("defs"),_=(N.empty()?l.append("defs"):N).append("linearGradient").attr("id",l.attr("id")+"-gradient").attr("gradientUnits","objectBoundingBox").attr("x1","0%").attr("y1","0%").attr("x2","100%").attr("y2","0%");_.append("stop").attr("offset","0%").attr("stop-color",g).attr("stop-opacity",1),_.append("stop").attr("offset","100%").attr("stop-color",m).attr("stop-opacity",1)}ft(void 0,l,r.timeline?.padding??50,r.timeline?.useMaxWidth??!1)},"draw"),lt=o(function(e,t,n,i,r,h,c,a,f,g,m,y){for(const k of t){const s={descr:k.task,section:n,number:n,width:150,padding:20,maxHeight:h};E.debug("taskNode",s);const d=e.append("g").attr("class","taskWrapper"),p=M.drawNode(d,s,n,c,y).height;if(E.debug("taskHeight after draw",p),d.attr("transform",`translate(${i}, ${r})`),h=Math.max(h,p),k.events){const x=e.append("g").attr("class","lineWrapper");let u=h;r+=100,u=u+ne(e,k.events,n,i,r,c,y),r-=100,x.append("line").attr("x1",i+190/2).attr("y1",r+h).attr("x2",i+190/2).attr("y2",r+h+100+f+100).attr("stroke-width",2).attr("stroke","black").attr("marker-end",`url(#${y}-arrowhead)`).attr("stroke-dasharray","5,5")}i=i+200,m&&!c.timeline?.disableMulticolor&&n++}r=r-10},"drawTasks"),ne=o(function(e,t,n,i,r,h,c){let a=0;const f=r;r=r+100;for(const g of t){const m={descr:g,section:n,number:n,width:150,padding:20,maxHeight:50};E.debug("eventNode",m);const y=e.append("g").attr("class","eventWrapper"),s=M.drawNode(y,m,n,h,c,!0).height;a=a+s,y.attr("transform",`translate(${i}, ${r})`),r=r+10+s}return r=f,a},"drawEvents"),re={setConf:o(()=>{},"setConf"),draw:ee},Q=200,z=5,se=Q+z*2,at=Q+100,ie=at+z*2,Lt=10,ae=0,ht=20,Mt=20,dt=30,At=50,oe=o(function(e,t,n,i){const r=gt(),h=r.timeline?.leftMargin??50;E.debug("timeline",i.db);const c=Pt(t);c.append("g");const a=i.db.getTasks(),f=i.db.getCommonDb().getDiagramTitle();E.debug("task",a),M.initGraphics(c);const g=i.db.getSections();E.debug("sections",g);let m=0,y=0;const k=50+h;let s=50;const d=s,l=k,p=se+Mt,x=ie+At,u=l+p;let S=0;const v=g&&g.length>0,I=v?u:k+p,R=Math.max(50,p+x-z*2);g.forEach(function(N){const b={number:S,descr:N,section:S,width:R,padding:z,maxHeight:m},_=M.getVirtualNodeHeight(c,b,r);E.debug("sectionHeight before draw",_),m=Math.max(m,_)});let W=0;E.debug("tasks.length",a.length);for(const[N,b]of a.entries()){const _={number:N,descr:b,section:b.section,width:Q,padding:z,maxHeight:y},$=M.getVirtualNodeHeight(c,_,r);E.debug("taskHeight before draw",$),y=Math.max(y,$);let T=0;for(const P of b.events){const C={descr:P,section:b.section,number:b.section,width:at,padding:z,maxHeight:50};T+=M.getVirtualNodeHeight(c,C,r)}b.events.length>0&&(T+=(b.events.length-1)*Lt),W=Math.max(W,T)+ae}E.debug("maxSectionHeight before draw",m),E.debug("maxTaskHeight before draw",y);const L=Math.max(y,W)+dt;v?g.forEach(N=>{const b=a.filter(Z=>Z.section===N),_={number:S,descr:N,section:S,width:R,padding:z,maxHeight:m};E.debug("sectionNode",_);const $=c.append("g"),T=M.drawNode($,_,S,r);E.debug("sectionNode output",T);const P=I-p;$.attr("transform",`translate(${P}, ${s})`);const C=s+T.height+ht;b.length>0&&ut(c,b,S,I,C,y,r,L,!1);const G=b.length,B=T.height+ht+L*Math.max(G,1)-(G>0?dt*2:0);s+=B,S++}):ut(c,a,S,I,s,y,r,L,!0);let w=c.node()?.getBBox();if(!w)throw new Error("bbox not found");if(E.debug("bounds",w),f){if(c.append("text").text(f).attr("x",w.width/2-h).attr("font-size","4ex").attr("font-weight","bold").attr("y",20),w=c.node()?.getBBox(),!w)throw new Error("bbox not found");E.debug("bounds after title",w)}const[H]=Bt(r.fontSize),V=(H??16)*2,F=(H??16)*.5+20,U=c.append("g").attr("class","lineWrapper");U.append("line").attr("x1",I).attr("y1",d-V).attr("x2",I).attr("y2",w.y+w.height+F).attr("stroke-width",4).attr("stroke","black").attr("marker-end","url(#arrowhead)"),U.lower(),ft(void 0,c,r.timeline?.padding??50,r.timeline?.useMaxWidth??!1)},"draw"),ut=o(function(e,t,n,i,r,h,c,a,f){for(const g of t){const m={descr:g.task,section:n,number:n,width:Q,padding:z,maxHeight:h};E.debug("taskNode",m);const y=e.append("g").attr("class","taskWrapper"),k=M.drawNode(y,m,n,c),s=k.height;E.debug("taskHeight after draw",s);const d=i-Mt-k.width;if(y.attr("transform",`translate(${d}, ${r})`),h=Math.max(h,s),g.events&&g.events.length>0){const l=r,p=i+At;ce(e,g.events,n,i,p,l,c)}r=r+a,f&&!c.timeline?.disableMulticolor&&n++}},"drawTasks"),ce=o(function(e,t,n,i,r,h,c){let a=h;for(const f of t){const g={descr:f,section:n,number:n,width:at,padding:z,maxHeight:0};E.debug("eventNode",g);const m=e.append("g").attr("class","eventWrapper"),k=M.drawNode(m,g,n,c).height;m.attr("transform",`translate(${r}, ${a})`);const s=e.append("g").attr("class","lineWrapper"),d=a+k/2;s.append("line").attr("x1",i).attr("y1",d).attr("x2",r).attr("y2",d).attr("stroke-width",2).attr("stroke","black").attr("marker-end","url(#arrowhead)").attr("stroke-dasharray","5,5"),a=a+k+Lt}return a-h},"drawEvents"),le={setConf:o(()=>{},"setConf"),draw:oe},he=o(e=>{const{theme:t}=pt(),n=t?.includes("dark"),i=t?.includes("color"),r=e.svgId?.replace(/^#/,"")??"",h=r?`url(#${r}-drop-shadow)`:e.dropShadow??"none";let c="";for(let a=0;a{let t="";for(let n=0;n{const{theme:t}=pt(),n=t?.includes("redux"),i=t==="neutral",r=e.svgId?.replace(/^#/,"")??"";let h="";if(e.useGradient&&r&&e.THEME_COLOR_LIMIT&&!i)for(let c=0;c{},"setConf"),draw:o((e,t,n,i)=>(i?.db?.getDirection?.()??"LR")==="TD"?le.draw(e,t,n,i):re.draw(e,t,n,i),"draw")},me={db:yt,renderer:ge,parser:Ot,styles:pe};export{me as diagram}; diff --git a/apps/kimi-code/src/cli/sub/fork.ts b/apps/kimi-code/src/cli/sub/fork.ts new file mode 100644 index 0000000000000000000000000000000000000000..17802cc2a8a2edb1a4ac03ba36df92e389de9a47 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/fork.ts @@ -0,0 +1,197 @@ +import { createInterface } from 'node:readline/promises'; + +import { + setTelemetryContext, + shutdownTelemetry, + track, + withTelemetryContext, +} from '@moonshot-ai/kimi-telemetry'; +import { + createKimiHarness, + type KimiHarness, + type SessionSummary, + type TelemetryClient, +} from '@moonshot-ai/kimi-code-sdk'; +import type { Command } from 'commander'; + +import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app'; +import { createCliTelemetryBootstrap, initializeCliTelemetry } from '#/cli/telemetry'; +import { createKimiCodeHostIdentity } from '#/cli/version'; + +interface WritableLike { + write(chunk: string): boolean; +} + +export interface ForkedSessionResult { + readonly id: string; + readonly title?: string | undefined; +} + +export interface ForkDeps { + readonly listSessions: (workDir: string) => Promise; + readonly forkSession: (sessionId: string) => Promise; + readonly confirmPreviousSession: (summary: SessionSummary) => Promise; + readonly cwd: () => string; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly exit: (code: number) => never; +} + +export interface ForkOptions { + readonly yes: boolean; + readonly cwd?: string | undefined; +} + +export async function handleFork( + deps: ForkDeps, + sessionId: string | undefined, + opts: ForkOptions, +): Promise { + let resolvedId = normalizeOptionalSessionId(sessionId); + if (resolvedId === undefined) { + const sessions = await deps.listSessions(opts.cwd ?? deps.cwd()); + const latest = sessions[0]; + if (latest === undefined) { + deps.stderr.write('No previous session found to fork.\n'); + deps.exit(1); + } + if (!opts.yes) { + const confirmed = await deps.confirmPreviousSession(latest); + if (!confirmed) { + deps.stdout.write('Fork cancelled.\n'); + return; + } + } + resolvedId = latest.id; + } + + const startedAt = Date.now(); + try { + const forked = await deps.forkSession(resolvedId); + const elapsedMs = Date.now() - startedAt; + const title = forked.title === undefined ? '' : ` ("${forked.title}")`; + deps.stdout.write(`Forked to ${forked.id}${title} in ${elapsedMs}ms\n`); + } catch (error) { + deps.stderr.write(`${errorMessage(error)}\n`); + deps.exit(1); + } +} + +export function registerForkCommand(parent: Command, deps?: Partial): void { + parent + .command('fork') + .description('Fork a session into a new session.') + .option( + '--cwd ', + 'Working directory used to find the most recent session to fork. Defaults to the current directory.', + ) + .option('-y, --yes', 'Skip previous-session confirmation.') + .argument('[sessionId]', 'Session id to fork. Defaults to the most recent session.') + .action(async (sessionId: string | undefined, options: { cwd?: string; yes?: boolean }) => { + const resolved = createDefaultForkDeps(deps); + try { + await handleFork(resolved, sessionId, { + yes: options.yes === true, + cwd: options.cwd, + }); + } finally { + await resolved.close(); + } + }); +} + +function createDefaultForkDeps(overrides: Partial = {}): ForkDeps & { + readonly close: () => Promise; +} { + let harness: KimiHarness | undefined; + let telemetryBootstrap: ReturnType | undefined; + let telemetryInitialized = false; + let telemetryShutdown = false; + const identity = createKimiCodeHostIdentity(); + const telemetryClient: TelemetryClient = { + track, + withContext: withTelemetryContext, + setContext: setTelemetryContext, + }; + const getTelemetryBootstrap = (): ReturnType => { + telemetryBootstrap ??= createCliTelemetryBootstrap(); + return telemetryBootstrap; + }; + const getHarness = (): KimiHarness => { + const currentTelemetryBootstrap = getTelemetryBootstrap(); + harness ??= createKimiHarness({ + homeDir: currentTelemetryBootstrap.homeDir, + identity, + telemetry: telemetryClient, + }); + return harness; + }; + const initializeDefaultTelemetry = async (): Promise => { + if (telemetryInitialized) return; + const currentTelemetryBootstrap = getTelemetryBootstrap(); + const currentHarness = getHarness(); + await currentHarness.ensureConfigFile(); + const config = await currentHarness.getConfig(); + initializeCliTelemetry({ + harness: currentHarness, + bootstrap: currentTelemetryBootstrap, + config, + version: identity.version, + uiMode: CLI_UI_MODE, + }); + telemetryInitialized = true; + }; + const shutdownDefaultTelemetry = async (): Promise => { + if (!telemetryInitialized || telemetryShutdown) return; + telemetryShutdown = true; + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + }; + return { + listSessions: + overrides.listSessions ?? + ((workDir: string) => + getHarness().listSessions({ + workDir, + })), + forkSession: + overrides.forkSession ?? + (async (sessionId: string) => { + await initializeDefaultTelemetry(); + try { + const forked = await getHarness().forkSession({ id: sessionId }); + return { id: forked.id, title: forked.summary?.title }; + } finally { + await shutdownDefaultTelemetry(); + } + }), + confirmPreviousSession: overrides.confirmPreviousSession ?? confirmPreviousSession, + cwd: overrides.cwd ?? (() => process.cwd()), + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + exit: overrides.exit ?? ((code: number) => process.exit(code)), + close: async () => { + await harness?.close(); + }, + }; +} + +function normalizeOptionalSessionId(sessionId: string | undefined): string | undefined { + const trimmed = sessionId?.trim(); + return trimmed === undefined || trimmed === '' ? undefined : trimmed; +} + +async function confirmPreviousSession(summary: SessionSummary): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + try { + const title = summary.title === undefined ? summary.id : `${summary.title} (${summary.id})`; + const answer = await rl.question(`Fork previous session "${title}"? [Y/n] `); + const trimmed = answer.trim().toLowerCase(); + return trimmed === '' || trimmed === 'y' || trimmed === 'yes'; + } finally { + rl.close(); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/kimi-code/src/cli/sub/login-flow.ts b/apps/kimi-code/src/cli/sub/login-flow.ts new file mode 100644 index 0000000000000000000000000000000000000000..d10864a428d583e343348198ec119e1c468c900a --- /dev/null +++ b/apps/kimi-code/src/cli/sub/login-flow.ts @@ -0,0 +1,83 @@ +/** + * Shared device-code login flow used by both `kimi login` (top-level + * subcommand) and `kimi acp --login` (the first-class ACP terminal-auth + * entry point). Exiting the process is part of the contract — callers + * MUST treat the returned promise as `Promise`. + */ + +import { createKimiHarness } from '@moonshot-ai/kimi-code-sdk'; +import { OAuthAccessDeniedError, type KimiRegion } from '@moonshot-ai/kimi-code-oauth'; + +import { createKimiCodeHostIdentity } from '#/cli/version'; +import { openUrl } from '#/utils/open-url'; +import { persistedKimiOAuthRef, regionForBareLogin } from '#/utils/region'; + +/** Parse a `--region` CLI flag; exits with an actionable message on bad input. */ +export function parseRegionFlag(value: string): KimiRegion { + if (value !== 'mainland-cn' && value !== 'global') { + process.stderr.write(`Invalid --region "${value}" (expected "mainland-cn" or "global").\n`); + process.exit(1); + } + return value; +} + +export async function runLoginFlow(options: { region?: KimiRegion } = {}): Promise { + // No flag: a fresh install follows the resolved region (env/marker/ + // default); an existing login keeps its own environment (see + // regionForBareLogin — the default slot re-pins mainland-cn, a scoped slot + // keeps its configured hosts). + const region = options.region ?? regionForBareLogin(persistedKimiOAuthRef()); + const identity = createKimiCodeHostIdentity(); + const harness = createKimiHarness({ + identity, + uiMode: 'cli', + }); + const controller = new AbortController(); + process.once('SIGINT', () => { + controller.abort(); + }); + try { + const result = await harness.auth.login(undefined, { + signal: controller.signal, + region, + onDeviceCode: (data) => { + const url = data.verificationUriComplete || data.verificationUri; + // Print the manual fallback before attempting to open the user's + // browser so headless/browser-opener failures never hide the URL + // and code needed to complete login. + process.stderr.write( + [ + '', + `Opening browser for Kimi device login: ${url}`, + `If the browser did not open, paste the URL above and enter code: ${data.userCode}`, + data.expiresIn !== null && data.expiresIn !== undefined + ? `Code expires in ${data.expiresIn}s.` + : undefined, + 'Waiting for authorization to complete…', + '', + ] + .filter((line): line is string => line !== undefined) + .join('\n'), + ); + try { + openUrl(url); + } catch { + // Best effort only: the manual fallback has already been printed. + } + }, + }); + process.stderr.write(`Logged in to ${result.providerName}.\n`); + process.exit(0); + } catch (error) { + if (controller.signal.aborted) { + process.stderr.write('Login cancelled.\n'); + } else if (error instanceof OAuthAccessDeniedError) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Login cancelled: ${message}\n`); + } else { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Login failed: ${message}\n`); + } + process.exit(1); + } +} diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d901198bc37f5235e5c1558f8129e21a8f55ddb --- /dev/null +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -0,0 +1,602 @@ +/** + * `kimi provider` sub-command — non-interactive provider management. + * + * Mirrors the TUI `/provider` flow (apps/kimi-code/src/tui/commands/provider.ts) + * for the custom-registry path so users can import an api.json document, drop + * a provider, or inspect what is configured without launching the TUI. + * + * `add` writes the same `source = { kind: 'apiJson', url, apiKey }` blob the + * TUI does; the next launch's `refreshAllProviderModels` + * (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by URL, retries + * available API-key candidates, and re-fetches the model list, so periodic + * refresh is automatic. + */ + +import { + applyCustomRegistryProvider, + CustomRegistryApiError, + fetchCustomRegistry, + type CustomRegistrySource, + type ManagedKimiConfigShape, +} from '@moonshot-ai/kimi-code-oauth'; +import { + applyCatalogProvider, + catalogProviderModels, + CatalogFetchError, + createKimiHarness, + DEFAULT_CATALOG_URL, + resolveCatalogImport, + type Catalog, + type CatalogProviderEntry, + type KimiConfig, + type KimiHarness, +} from '@moonshot-ai/kimi-code-sdk'; +import type { Command } from 'commander'; + +import { createKimiCodeHostIdentity, createKimiCodeUserAgent } from '#/cli/version'; +import { fetchCatalogOrBuiltIn } from '#/utils/catalog-fetch'; + +interface WritableLike { + write(chunk: string): boolean; +} + +export interface ProviderDeps { + readonly getHarness: () => KimiHarness; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly env: NodeJS.ProcessEnv; + readonly exit: (code: number) => never; +} + +interface AddOptions { + readonly apiKey?: string; +} + +interface ListOptions { + readonly json: boolean; +} + +interface CatalogListOptions { + readonly json: boolean; + readonly filter?: string; + readonly url?: string; +} + +interface CatalogAddOptions { + readonly apiKey?: string; + readonly defaultModel?: string; + readonly url?: string; + readonly baseUrl?: string; +} + +export async function handleProviderAdd( + deps: ProviderDeps, + url: string, + opts: AddOptions, +): Promise { + const apiKey = resolveApiKey(opts.apiKey, deps.env); + if (apiKey === undefined) { + deps.stderr.write( + 'Missing API key. Pass --api-key or set KIMI_REGISTRY_API_KEY.\n', + ); + deps.exit(1); + } + + const trimmedUrl = url.trim(); + if (trimmedUrl.length === 0) { + deps.stderr.write('Registry URL is required.\n'); + deps.exit(1); + } + + const source: CustomRegistrySource = { + kind: 'apiJson', + url: trimmedUrl, + apiKey, + }; + + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + + let entries: Awaited>; + try { + entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() }); + } catch (error) { + const suffix = error instanceof CustomRegistryApiError ? ` (HTTP ${String(error.status)})` : ''; + deps.stderr.write(`Failed to fetch registry${suffix}: ${errorMessage(error)}\n`); + deps.exit(1); + } + + const entryList = Object.values(entries); + if (entryList.length === 0) { + deps.stderr.write(`Registry at ${trimmedUrl} contained no usable providers.\n`); + deps.exit(1); + } + + // `harness.removeProvider` reloads the config from disk on each call (see + // `core-impl.ts removeKimiProvider`), so calling it inside the apply loop + // would discard providers we already applied in memory but have not yet + // persisted. Drop every stale id up front in a single batch instead, then + // apply against the resulting fresh config. + let config = await harness.getConfig(); + const staleIds = entryList + .filter((entry) => config.providers[entry.id] !== undefined) + .map((entry) => entry.id); + for (const id of staleIds) { + config = await harness.removeProvider(id); + } + + const addedProviderIds: string[] = []; + let modelCount = 0; + for (const entry of entryList) { + applyCustomRegistryProvider(asManaged(config), entry, source); + addedProviderIds.push(entry.id); + modelCount += Object.keys(entry.models).length; + } + + await harness.setConfig({ + providers: config.providers, + models: config.models, + }); + + deps.stdout.write( + `Imported ${String(addedProviderIds.length)} provider${addedProviderIds.length === 1 ? '' : 's'} ` + + `(${String(modelCount)} model${modelCount === 1 ? '' : 's'}) from ${trimmedUrl}:\n`, + ); + for (const id of addedProviderIds) { + deps.stdout.write(` - ${id}\n`); + } +} + +export async function handleProviderRemove( + deps: ProviderDeps, + providerId: string, +): Promise { + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + const config = await harness.getConfig(); + if (config.providers[providerId] === undefined) { + deps.stderr.write(`Provider "${providerId}" not found.\n`); + deps.exit(1); + } + await harness.removeProvider(providerId); + deps.stdout.write(`Removed provider "${providerId}".\n`); +} + +export async function handleProviderList( + deps: ProviderDeps, + opts: ListOptions, +): Promise { + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + const config = await harness.getConfig(); + + if (opts.json) { + deps.stdout.write( + `${JSON.stringify({ providers: config.providers, models: config.models ?? {} }, null, 2)}\n`, + ); + return; + } + + const modelsByProvider = new Map(); + for (const [alias, model] of Object.entries(config.models ?? {})) { + const list = modelsByProvider.get(model.provider) ?? []; + list.push(alias); + modelsByProvider.set(model.provider, list); + } + + const providerIds = Object.keys(config.providers).toSorted(); + if (providerIds.length === 0) { + deps.stdout.write('No providers configured.\n'); + return; + } + + for (const id of providerIds) { + const provider = config.providers[id]!; + const aliases = modelsByProvider.get(id) ?? []; + const sourceLabel = providerSourceLabel(provider); + deps.stdout.write( + `${id} type=${provider.type} models=${String(aliases.length)} source=${sourceLabel}\n`, + ); + } + if (config.defaultModel !== undefined) { + deps.stdout.write(`\nDefault model: ${config.defaultModel}\n`); + } +} + +/** + * Fetches the models.dev-style public catalog and lists providers, or — when + * `providerId` is given — drills into one provider and lists its models. This + * mirrors the discovery half of the TUI "Known third-party provider" flow. + */ +export async function handleCatalogList( + deps: ProviderDeps, + providerId: string | undefined, + opts: CatalogListOptions, +): Promise { + const url = opts.url ?? DEFAULT_CATALOG_URL; + const catalog = await loadCatalogOrExit(deps, url); + + if (providerId !== undefined) { + const entry = catalog[providerId]; + if (entry === undefined) { + deps.stderr.write(`Provider "${providerId}" not found in catalog at ${url}.\n`); + deps.exit(1); + } + const models = catalogProviderModels(entry); + if (opts.json) { + deps.stdout.write( + `${JSON.stringify({ providerId, name: entry.name ?? providerId, models }, null, 2)}\n`, + ); + return; + } + if (models.length === 0) { + deps.stdout.write(`Provider "${providerId}" lists no usable models in this catalog.\n`); + return; + } + deps.stdout.write(`${entry.name ?? providerId} (${providerId})\n`); + for (const model of models) { + const cap: string[] = []; + if (model.capability.tool_use) cap.push('tool_use'); + if (model.capability.thinking) cap.push('thinking'); + if (model.capability.image_in) cap.push('image_in'); + const ctx = + typeof model.capability.max_context_tokens === 'number' + ? String(model.capability.max_context_tokens) + : '?'; + const capLabel = cap.length > 0 ? ` [${cap.join(',')}]` : ''; + deps.stdout.write(` ${model.id} ctx=${ctx}${capLabel}\n`); + } + return; + } + + const filter = opts.filter?.toLowerCase(); + const entries = Object.entries(catalog) + .filter(([id, entry]) => { + if (filter === undefined) return true; + const haystack = `${id} ${entry.name ?? ''}`.toLowerCase(); + return haystack.includes(filter); + }) + .toSorted(([a], [b]) => a.localeCompare(b)); + + if (opts.json) { + const out: Record = {}; + for (const [id, entry] of entries) out[id] = entry; + deps.stdout.write(`${JSON.stringify(out, null, 2)}\n`); + return; + } + + if (entries.length === 0) { + if (filter !== undefined) { + deps.stdout.write(`No providers in catalog match "${filter}".\n`); + } else { + deps.stdout.write('Catalog is empty.\n'); + } + return; + } + + for (const [id, entry] of entries) { + const modelCount = entry.models === undefined ? 0 : Object.keys(entry.models).length; + const resolution = resolveCatalogImport(entry); + const wireLabel = + resolution.kind === 'invalid' + ? '?' + : resolution.guessed + ? `${resolution.wire} (guessed)` + : resolution.wire; + deps.stdout.write( + `${id} wire=${wireLabel} models=${String(modelCount)} ${entry.name ?? ''}\n`, + ); + } +} + +/** + * Imports a known provider from the models.dev catalog by id. Unlike + * `provider add` (which expects a custom api.json), this command relies on + * the catalog's normalized metadata to fill in context limits and capabilities. + */ +export async function handleCatalogAdd( + deps: ProviderDeps, + providerId: string, + opts: CatalogAddOptions, +): Promise { + const apiKey = resolveApiKey(opts.apiKey, deps.env); + if (apiKey === undefined) { + deps.stderr.write( + 'Missing API key. Pass --api-key or set KIMI_REGISTRY_API_KEY.\n', + ); + deps.exit(1); + } + + const url = opts.url ?? DEFAULT_CATALOG_URL; + const catalog = await loadCatalogOrExit(deps, url); + + const entry = catalog[providerId]; + if (entry === undefined) { + deps.stderr.write(`Provider "${providerId}" not found in catalog at ${url}.\n`); + deps.exit(1); + } + + const resolution = resolveCatalogImport(entry, opts.baseUrl); + if (resolution.kind === 'invalid') { + switch (resolution.reason) { + case 'unknown-explicit-type': + deps.stderr.write( + `Provider "${providerId}" declares protocol "${entry.type}" in the catalog, which this client version does not support.\n`, + ); + break; + case 'proprietary-sdk': + deps.stderr.write( + `Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock or Cohere); it cannot be imported from the catalog.\n`, + ); + break; + case 'empty-base-url': + deps.stderr.write('--base-url cannot be empty.\n'); + break; + case 'placeholder-base-url': + deps.stderr.write( + `Base URL "${opts.baseUrl}" contains an env placeholder. Pass --base-url with the resolved value.\n`, + ); + break; + } + deps.exit(1); + } + if (resolution.kind === 'needs-base-url') { + deps.stderr.write( + `The catalog does not declare an endpoint for "${providerId}". Pass --base-url (e.g. the vendor's OpenAI-compatible base URL).\n`, + ); + deps.exit(1); + } + const { wire, baseUrl } = resolution; + + const models = catalogProviderModels(entry); + if (models.length === 0) { + deps.stderr.write(`Provider "${providerId}" lists no usable models in this catalog.\n`); + deps.exit(1); + } + + if (opts.defaultModel !== undefined && !models.some((m) => m.id === opts.defaultModel)) { + deps.stderr.write( + `Model "${opts.defaultModel}" is not in provider "${providerId}". Run "kimi provider catalog list ${providerId}" to see available ids.\n`, + ); + deps.exit(1); + } + + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + + let config = await harness.getConfig(); + + // Capture defaults BEFORE `removeProvider`, because that call clears + // `defaultModel` when it points at one of this provider's aliases (see + // `core-impl.ts removeKimiProvider`). Without this, re-importing an + // already-configured provider would lose the user's previously-set default + // even when `--default-model` is not supplied. + const previousDefaultModel = config.defaultModel; + const previousThinking = config.thinking; + + if (config.providers[providerId] !== undefined) { + config = await harness.removeProvider(providerId); + } + + // `applyCatalogProvider` always overwrites both `defaultModel` and + // `[thinking]`. The values we pass here are temporary; we restore + // a consistent state in the post-apply block below. + applyCatalogProvider(config, { + providerId, + wire, + ...(baseUrl === undefined ? {} : { baseUrl }), + apiKey, + models, + selectedModelId: opts.defaultModel ?? '', + thinking: false, + }); + + // Resolve the final `defaultModel`: + // - If the caller asked for one, `applyCatalogProvider` already set it. + // - Else, restore the previous default ONLY when its alias still resolves + // after the catalog refresh; the catalog may have dropped the old + // model, in which case restoring would point default_model at a + // non-existent alias and break the next session. + if (opts.defaultModel === undefined) { + const stillResolves = + previousDefaultModel !== undefined && + config.models?.[previousDefaultModel] !== undefined; + config.defaultModel = stillResolves ? previousDefaultModel : undefined; + } + + // Always restore `[thinking]` from what was there before — including + // `undefined`. Persisting `enabled: false` when the user never set it would + // make `resolveThinkingEffort` (agent-core-v2/src/kosong/model/thinking.ts) treat + // it as an explicit "off" request and silently disable thinking, even for + // thinking-capable models. + config.thinking = previousThinking; + + await harness.setConfig({ + providers: config.providers, + models: config.models, + defaultModel: config.defaultModel, + thinking: config.thinking, + }); + + const displayName = entry.name ?? providerId; + deps.stdout.write( + `Imported ${displayName} (${providerId}) with ${String(models.length)} model${models.length === 1 ? '' : 's'} from ${url}.\n`, + ); + if (resolution.guessed) { + deps.stdout.write( + `Note: the catalog does not declare a protocol for "${providerId}"; guessed "openai". Edit "type" in config.toml if requests fail.\n`, + ); + } + if (opts.defaultModel !== undefined) { + deps.stdout.write(`Default model set to ${providerId}/${opts.defaultModel}.\n`); + } +} + +async function loadCatalogOrExit(deps: ProviderDeps, url: string): Promise { + try { + const loaded = await fetchCatalogOrBuiltIn(url, { userAgent: createKimiCodeUserAgent() }); + if (loaded.fromBuiltIn) { + deps.stderr.write( + `Warning: failed to reach ${url}; using the built-in models.dev catalog snapshot.\n`, + ); + } + return loaded.catalog; + } catch (error) { + const suffix = error instanceof CatalogFetchError ? ` (HTTP ${String(error.status)})` : ''; + deps.stderr.write(`Failed to fetch catalog from ${url}${suffix}: ${errorMessage(error)}\n`); + deps.exit(1); + } +} + +export function registerProviderCommand(parent: Command, deps?: Partial): void { + const provider = parent + .command('provider') + .description('Manage LLM providers non-interactively.'); + + // Last-resort boundary: handlers report expected failures themselves, but + // anything that escapes (e.g. a config write rejected because config.toml + // is invalid) must end as a one-line error + exit 1, not an unhandled + // rejection dumping a stack trace. + const runAction = async ( + resolved: ResolvedProviderDeps, + run: () => Promise, + ): Promise => { + try { + await run(); + } catch (error) { + resolved.stderr.write(`${errorMessage(error)}\n`); + resolved.exit(1); + } finally { + await resolved.close(); + } + }; + + provider + .command('add ') + .description('Import every provider listed in a custom registry (api.json).') + .option('--api-key ', 'Registry API key. Falls back to KIMI_REGISTRY_API_KEY.') + .action(async (url: string, options: { apiKey?: string }) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => handleProviderAdd(resolved, url, { apiKey: options.apiKey })); + }); + + provider + .command('remove ') + .description('Remove a provider and every model alias that referenced it.') + .action(async (providerId: string) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => handleProviderRemove(resolved, providerId)); + }); + + provider + .command('list') + .description('Show configured providers and their model counts.') + .option('--json', 'Emit the raw providers/models config as JSON.', false) + .action(async (options: { json?: boolean }) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => handleProviderList(resolved, { json: options.json === true })); + }); + + const catalog = provider + .command('catalog') + .description('Discover and import providers from the public models.dev catalog.'); + + catalog + .command('list [providerId]') + .description('List providers in the catalog, or models when a providerId is given.') + .option('--filter ', 'Case-insensitive id/name substring filter.') + .option('--url ', `Override catalog URL. Defaults to ${DEFAULT_CATALOG_URL}.`) + .option('--json', 'Emit the matching catalog slice as JSON.', false) + .action( + async ( + providerId: string | undefined, + options: { filter?: string; url?: string; json?: boolean }, + ) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => + handleCatalogList(resolved, providerId, { + json: options.json === true, + ...(options.filter === undefined ? {} : { filter: options.filter }), + ...(options.url === undefined ? {} : { url: options.url }), + }), + ); + }, + ); + + catalog + .command('add ') + .description('Import a known provider from the catalog by id.') + .option('--api-key ', 'API key for the provider. Falls back to KIMI_REGISTRY_API_KEY.') + .option('--default-model ', 'Mark the imported model as default_model after import.') + .option( + '--base-url ', + 'Override the catalog endpoint. Required when the catalog declares none (or an env placeholder).', + ) + .option('--url ', `Override catalog URL. Defaults to ${DEFAULT_CATALOG_URL}.`) + .action( + async ( + providerId: string, + options: { apiKey?: string; defaultModel?: string; url?: string; baseUrl?: string }, + ) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => + handleCatalogAdd(resolved, providerId, { + ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), + ...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }), + ...(options.url === undefined ? {} : { url: options.url }), + ...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }), + }), + ); + }, + ); +} + +type ResolvedProviderDeps = ProviderDeps & { readonly close: () => Promise }; + +function resolveDeps(overrides: Partial = {}): ResolvedProviderDeps { + let harness: KimiHarness | undefined; + const identity = createKimiCodeHostIdentity(); + return { + getHarness: + overrides.getHarness ?? + (() => { + harness ??= createKimiHarness({ identity }); + return harness; + }), + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + env: overrides.env ?? process.env, + exit: overrides.exit ?? ((code: number) => process.exit(code)), + // The v2 harness boots an engine whose watchers hold the event loop open; + // close it so a one-shot command can exit. No-op for injected harnesses. + close: async () => { + await harness?.close(); + }, + }; +} + +function resolveApiKey(flag: string | undefined, env: NodeJS.ProcessEnv): string | undefined { + if (typeof flag === 'string' && flag.length > 0) return flag; + const fromEnv = env['KIMI_REGISTRY_API_KEY']; + if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv; + return undefined; +} + +function asManaged(config: KimiConfig): ManagedKimiConfigShape { + return config as unknown as ManagedKimiConfigShape; +} + +function providerSourceLabel(provider: KimiConfig['providers'][string]): string { + const source = provider.source; + if (source !== undefined) { + if (source['kind'] === 'apiJson' && typeof source['url'] === 'string') { + return `apiJson(${source['url']})`; + } + } + if (provider.oauth !== undefined) return 'oauth'; + return 'inline'; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/kimi-code/src/cli/sub/session.ts b/apps/kimi-code/src/cli/sub/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..e25f572488dc680c9eb01affb48d617b8ad871f4 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/session.ts @@ -0,0 +1,154 @@ +/** + * `kimi session` sub-command group. + * + * CLI glue only: listing semantics (workspace scoping, recency order, + * archived filtering) are owned by the SDK/engine; this file parses options + * and formats rows. + */ + +import { setTelemetryContext, track, withTelemetryContext } from '@moonshot-ai/kimi-telemetry'; +import { + createKimiHarness, + type KimiHarness, + type ListSessionsOptions, + type SessionSummary, + type TelemetryClient, +} from '@moonshot-ai/kimi-code-sdk'; +import type { Command } from 'commander'; + +import { createCliTelemetryBootstrap } from '#/cli/telemetry'; +import { createKimiCodeHostIdentity } from '#/cli/version'; + +interface WritableLike { + write(chunk: string): boolean; +} + +export interface SessionListDeps { + readonly listSessions: (options: ListSessionsOptions) => Promise; + readonly cwd: () => string; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly exit: (code: number) => never; +} + +export interface SessionListOptions { + readonly all: boolean; + readonly archived: boolean; + readonly cwd?: string; + readonly limit?: number; + readonly json: boolean; +} + +export async function handleSessionList( + deps: SessionListDeps, + opts: SessionListOptions, +): Promise { + const workDir = opts.all ? undefined : (opts.cwd ?? deps.cwd()); + const sessions = await deps.listSessions({ + workDir, + includeArchived: opts.archived ? true : undefined, + }); + const limited = opts.limit === undefined ? sessions : sessions.slice(0, opts.limit); + if (opts.json) { + deps.stdout.write(`${JSON.stringify(limited, null, 2)}\n`); + return; + } + if (limited.length === 0) { + deps.stdout.write('No sessions found.\n'); + return; + } + for (const summary of limited) { + deps.stdout.write(`${formatRow(summary, opts.all)}\n`); + } +} + +export function registerSessionCommand(parent: Command, deps?: Partial): void { + const session = parent.command('session').description('Manage sessions non-interactively.'); + + session + .command('list') + .description('List sessions, most recently updated first.') + .option('--cwd ', 'List sessions of this working directory. Defaults to the current directory.') + .option('--all', 'List sessions across every workspace.', false) + .option('--archived', 'Include archived sessions.', false) + .option('--limit ', 'Print at most n sessions.', parseLimitOption) + .option('--json', 'Emit the session summaries as JSON.', false) + .action(async (options: { cwd?: string; all?: boolean; archived?: boolean; limit?: number; json?: boolean }) => { + const resolved = createDefaultSessionListDeps(deps); + try { + await handleSessionList(resolved, { + all: options.all === true, + archived: options.archived === true, + cwd: options.cwd, + limit: options.limit, + json: options.json === true, + }); + } catch (error) { + resolved.stderr.write(`${errorMessage(error)}\n`); + resolved.exit(1); + } finally { + await resolved.close(); + } + }); +} + +function createDefaultSessionListDeps( + overrides: Partial = {}, +): SessionListDeps & { readonly close: () => Promise } { + let harness: KimiHarness | undefined; + const identity = createKimiCodeHostIdentity(); + const telemetryClient: TelemetryClient = { + track, + withContext: withTelemetryContext, + setContext: setTelemetryContext, + }; + const getHarness = (): KimiHarness => { + harness ??= createKimiHarness({ + homeDir: createCliTelemetryBootstrap().homeDir, + identity, + telemetry: telemetryClient, + }); + return harness; + }; + return { + listSessions: + overrides.listSessions ?? + ((options: ListSessionsOptions) => getHarness().listSessions(options)), + cwd: overrides.cwd ?? (() => process.cwd()), + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + exit: overrides.exit ?? ((code: number) => process.exit(code)), + close: async () => { + await harness?.close(); + }, + }; +} + +function formatRow(summary: SessionSummary, showWorkDir: boolean): string { + const archived = summary.archived === true ? ' [archived]' : ''; + const title = sanitizeField(summary.title ?? summary.lastPrompt ?? ''); + const base = `${formatTimestamp(summary.updatedAt)} ${summary.id} ${title}${archived}`; + return showWorkDir ? `${base} ${sanitizeField(summary.workDir)}` : base; +} + +function sanitizeField(value: string): string { + return value.replaceAll(/[\x00-\x1f\x7f]+/g, ' ').trim(); +} + +function formatTimestamp(epochMs: number): string { + const date = new Date(epochMs); + const pad = (value: number): string => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function parseLimitOption(value: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`--limit must be a positive integer, got "${value}"`); + } + return parsed; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/kimi-code/src/cli/sub/upgrade.ts b/apps/kimi-code/src/cli/sub/upgrade.ts new file mode 100644 index 0000000000000000000000000000000000000000..0cac2f1d9ebdaf5ab37a9a66184e30c50e4ae14d --- /dev/null +++ b/apps/kimi-code/src/cli/sub/upgrade.ts @@ -0,0 +1,232 @@ +import { log, type Logger } from '@moonshot-ai/kimi-code-sdk'; +import { track as trackTelemetry, type TelemetryProperties } from '@moonshot-ai/kimi-telemetry'; + +import { INTERACTIVE_UPDATE_CHECK_TIMEOUT_MS } from '#/constant/app'; + +import { refreshUpdateCache } from '#/cli/update/refresh'; +import { selectUpdateTarget } from '#/cli/update/select'; +import { detectInstallSource } from '#/cli/update/source'; +import { + canAutoInstall, + installCommandFor, + installUpdate as installUpdateForeground, + renderInstallSuccessMessage, + renderManualUpdateMessage, +} from '#/cli/update/preflight'; +import { + promptForInstallChoice, + type InstallPromptChoiceValue, + type InstallPromptOptions, +} from '#/cli/update/prompt'; +import { + NPM_PACKAGE_NAME, + type InstallSource, + type UpdateCache, +} from '#/cli/update/types'; + +interface WritableLike { + write(chunk: string): boolean; +} + +type UpgradeTrack = (event: string, properties?: TelemetryProperties) => void; +type UpgradeLogger = Pick; + +export interface UpgradeDeps { + readonly refreshUpdateCache: () => Promise; + readonly detectInstallSource: () => Promise; + readonly installUpdate: ( + source: InstallSource, + version: string, + platform: NodeJS.Platform, + ) => Promise; + readonly promptForInstallChoice: ( + options: InstallPromptOptions, + ) => Promise; + readonly platform: NodeJS.Platform; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly isInteractive: boolean; + readonly yes: boolean; + readonly track: UpgradeTrack; + readonly logger: UpgradeLogger; +} + +export async function handleUpgrade( + currentVersion: string, + overrides: Partial = {}, +): Promise { + const deps = createDefaultUpgradeDeps(overrides); + + let cache: UpdateCache; + try { + cache = await deps.refreshUpdateCache(); + } catch (error) { + const reason = formatErrorMessage(error); + trackUpgradeEvent(deps.track, 'upgrade_command_failed', { + current_version: currentVersion, + stage: 'refresh', + reason, + }); + logUpgradeWarn(deps.logger, 'manual upgrade check failed', { + currentVersion, + error, + }); + deps.stderr.write(`error: failed to check for updates: ${reason}\n`); + return 1; + } + + const target = selectUpdateTarget(currentVersion, cache.latest); + if (target === null) { + trackUpgradeEvent(deps.track, 'upgrade_command_no_update', { + current_version: currentVersion, + }); + logUpgradeInfo(deps.logger, 'manual upgrade no update', { + currentVersion, + }); + deps.stdout.write(`Kimi Code is already up to date (${formatDisplayVersion(currentVersion)}).\n`); + return 0; + } + + const source = await deps.detectInstallSource().catch(() => 'unsupported' as const); + const installCommand = installCommandFor(source, target.version, deps.platform); + if (!canAutoInstall(source, deps.platform) || (!deps.yes && !deps.isInteractive)) { + trackUpgradeEvent(deps.track, 'upgrade_command_manual_command', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpgradeInfo(deps.logger, 'manual upgrade command shown', { + currentVersion, + targetVersion: target.version, + source, + }); + deps.stdout.write(renderManualUpdateMessage(currentVersion, target, source, installCommand)); + return 0; + } + + if (!deps.yes) { + trackUpgradeEvent(deps.track, 'upgrade_command_prompted', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpgradeInfo(deps.logger, 'manual upgrade prompted', { + currentVersion, + targetVersion: target.version, + source, + }); + const choice = await deps.promptForInstallChoice({ + currentVersion, + target, + installCommand, + installSource: source, + }); + if (choice === 'skip') { + trackUpgradeEvent(deps.track, 'upgrade_command_skipped', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpgradeInfo(deps.logger, 'manual upgrade skipped', { + currentVersion, + targetVersion: target.version, + source, + }); + return 0; + } + } + + try { + trackUpgradeEvent(deps.track, 'upgrade_command_install_selected', { + current_version: currentVersion, + target_version: target.version, + source, + }); + await deps.installUpdate(source, target.version, deps.platform); + trackUpgradeEvent(deps.track, 'upgrade_command_succeeded', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpgradeInfo(deps.logger, 'manual upgrade install succeeded', { + currentVersion, + targetVersion: target.version, + source, + }); + deps.stdout.write(renderInstallSuccessMessage(target)); + return 0; + } catch (error) { + trackUpgradeEvent(deps.track, 'upgrade_command_failed', { + current_version: currentVersion, + target_version: target.version, + source, + stage: 'install', + reason: formatErrorMessage(error), + }); + logUpgradeWarn(deps.logger, 'manual upgrade install failed', { + currentVersion, + targetVersion: target.version, + source, + error, + }); + deps.stderr.write( + `warning: failed to install ${NPM_PACKAGE_NAME}@${target.version}: ` + + `${formatErrorMessage(error)}\n`, + ); + return 1; + } +} + +function createDefaultUpgradeDeps(overrides: Partial): UpgradeDeps { + return { + refreshUpdateCache: + overrides.refreshUpdateCache ?? + (() => refreshUpdateCache({ timeoutMs: INTERACTIVE_UPDATE_CHECK_TIMEOUT_MS })), + detectInstallSource: overrides.detectInstallSource ?? (() => detectInstallSource()), + installUpdate: overrides.installUpdate ?? installUpdateForeground, + promptForInstallChoice: overrides.promptForInstallChoice ?? promptForInstallChoice, + platform: overrides.platform ?? process.platform, + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + isInteractive: overrides.isInteractive ?? (process.stdin.isTTY && process.stdout.isTTY), + yes: overrides.yes ?? false, + track: overrides.track ?? trackTelemetry, + logger: overrides.logger ?? log, + }; +} + +function formatDisplayVersion(version: string): string { + return version.startsWith('v') ? version : `v${version}`; +} + +function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function trackUpgradeEvent( + track: UpgradeTrack, + event: string, + properties: TelemetryProperties, +): void { + try { + track(event, properties); + } catch { + // Telemetry must never affect upgrade flow. + } +} + +function logUpgradeInfo(logger: UpgradeLogger, message: string, payload: Record): void { + try { + logger.info(message, payload); + } catch { + // Diagnostic logging must never affect upgrade flow. + } +} + +function logUpgradeWarn(logger: UpgradeLogger, message: string, payload: Record): void { + try { + logger.warn(message, payload); + } catch { + // Diagnostic logging must never affect upgrade flow. + } +} diff --git a/apps/kimi-code/src/cli/sub/web/access-urls.ts b/apps/kimi-code/src/cli/sub/web/access-urls.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f147b970d9c042c7da61c9dcd5b5e1a9aa2ccf3 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/access-urls.ts @@ -0,0 +1,97 @@ +/** + * Build the clickable/copyable access URLs for the running server. + * + * Shared by the `kimi web` ready banner and `kimi web rotate-token` so both + * show the same Local/Network links. When a token is known it rides in the + * `#token=` fragment (never sent to the server, so never logged), letting a + * user open the link on another device and be authenticated automatically. + */ + +import { formatHostForUrl, listNetworkAddresses, type NetworkAddress } from './networks'; + +/** + * Build a directly-openable server URL. When the token is known it is appended + * as `#token=`; otherwise the bare origin (with a trailing slash) is + * returned. + */ +export function buildOpenableUrl(bareOrigin: string, token: string | undefined): string { + const base = bareOrigin.endsWith('/') ? bareOrigin.slice(0, -1) : bareOrigin; + return token === undefined ? `${base}/` : `${base}/#token=${token}`; +} + +/** + * Split a full URL into the part before `#token=` and the `#token=…` fragment + * itself, so callers can render the fragment in a de-emphasized color. Returns + * `[fullUrl, '']` when there is no token fragment. + */ +export function splitTokenFragment(fullUrl: string): [string, string] { + const marker = '#token='; + const idx = fullUrl.indexOf(marker); + return idx < 0 ? [fullUrl, ''] : [fullUrl.slice(0, idx), fullUrl.slice(idx)]; +} + +export interface AccessUrlLine { + /** Fixed-width label including trailing padding, e.g. `"Local: "`. */ + label: string; + /** Full URL, carrying `#token=` when a token is known. */ + url: string; +} + +function isWildcard(host: string): boolean { + return host === '' || host === '0.0.0.0' || host === '::'; +} + +/** + * Rewrite a bound origin for browser auto-open. A wildcard bind host + * (`0.0.0.0` / `::` / empty) is not navigable, so open localhost on the same + * port instead — the same address the ready banner's `Local:` line shows. + */ +export function browserOpenOrigin(origin: string): string { + const separator = origin.lastIndexOf(':'); + const host = origin.slice(origin.indexOf('://') + 3, separator); + if (!isWildcard(host)) return origin; + return `http://localhost${origin.slice(separator)}`; +} + +/** True when `host` is a loopback address (this host only). */ +export function isLoopbackHost(host: string): boolean { + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; +} + +function hostOrigin(host: string, port: number): string { + const family = host.includes(':') ? 'IPv6' : 'IPv4'; + return `http://${formatHostForUrl(host, family)}:${port}`; +} + +/** + * Compute the access-URL lines for a bind host/port. + * + * - wildcard (`0.0.0.0` / `::` / empty): a `Local:` line (localhost) plus one + * `Network:` line per non-loopback interface. + * - loopback: a single `Local:` line. + * - specific host: a single `URL:` line. + */ +export function accessUrlLines( + host: string, + port: number, + token: string | undefined, + networkAddresses?: NetworkAddress[], +): AccessUrlLine[] { + if (isWildcard(host)) { + const lines: AccessUrlLine[] = [ + { label: 'Local: ', url: buildOpenableUrl(`http://localhost:${port}`, token) }, + ]; + const addrs = networkAddresses ?? listNetworkAddresses(); + for (const addr of addrs) { + lines.push({ + label: 'Network: ', + url: buildOpenableUrl(`http://${formatHostForUrl(addr.address, addr.family)}:${port}`, token), + }); + } + return lines; + } + if (isLoopbackHost(host)) { + return [{ label: 'Local: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; + } + return [{ label: 'URL: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; +} diff --git a/apps/kimi-code/src/cli/sub/web/deprecated-server.ts b/apps/kimi-code/src/cli/sub/web/deprecated-server.ts new file mode 100644 index 0000000000000000000000000000000000000000..23c8e63b6f7726c3e59bbd66c9a2f9eb20cf7b31 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/deprecated-server.ts @@ -0,0 +1,38 @@ +/** + * Deprecated `kimi server` shim. + * + * The `kimi server` command tree was replaced by `kimi web` (a foreground + * server opened in the browser). Any `kimi server …` invocation — bare or + * with any legacy subcommand/flags — lands here, prints the deprecation + * notice, and exits 1. The shim itself is scheduled for removal in the next + * major version of Kimi Code. + * + * One subcommand stays functional: `kimi server kill`, the cleanup path for + * background servers started by pre-0.28.0 builds (recorded in the legacy + * single-instance lock, which the instance registry never sees). + */ + +import type { Command } from 'commander'; + +import { registerLegacyKillCommand } from './legacy-kill'; + +export const DEPRECATED_SERVER_NOTICE = + '`kimi server` has been deprecated and no longer works.\n' + + 'Use `kimi web` instead — it runs the local server in the foreground and opens the web UI (`--no-open` to skip).\n' + + 'To stop a server started by a version before 0.28.0, use `kimi server kill`.\n' + + 'This notice will be removed in the next major version of Kimi Code.\n'; + +export function registerDeprecatedServerCommand(program: Command): void { + const server = program + .command('server') + .description('Deprecated — use `kimi web` instead.') + // Swallow every legacy subcommand/flag (`run`, `kill`, `--port`, …) so + // they all land in the same notice instead of a commander parse error. + .allowUnknownOption(true) + .allowExcessArguments(true) + .action(() => { + process.stderr.write(DEPRECATED_SERVER_NOTICE); + process.exit(1); + }); + registerLegacyKillCommand(server); +} diff --git a/apps/kimi-code/src/cli/sub/web/index.ts b/apps/kimi-code/src/cli/sub/web/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..30240210b239ebf2573090a82b697e2c4ff823a3 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/index.ts @@ -0,0 +1,34 @@ +/** + * `kimi web` — run the local Kimi server (REST + WebSocket + web UI) in the + * foreground and open the web UI in the default browser. + * + * The command itself is the runner (`kimi web` = start the server + open the + * browser; `--no-open` to skip). The server stays attached to the terminal + * and stops with Ctrl+C, so there is no kill/ps subcommand; the only + * management subcommand is `web rotate-token` (rotate the home-wide bearer + * token). Servers left behind by pre-0.28.0 builds are cleaned up with + * `kimi server kill`. + */ + +import type { Command } from 'commander'; + +import { registerDeprecatedServerCommand } from './deprecated-server'; +import { registerRotateTokenCommand } from './rotate-token'; +import { buildWebCommand } from './run'; + +export function registerWebCommand(program: Command): void { + const web = buildWebCommand( + program + .command('web') + .description('Run the local Kimi server and open the web UI.'), + ); + registerRotateTokenCommand(web); + registerDeprecatedServerCommand(program); + buildWebCommand( + program + .command('rc') + .alias('remote') + .description('Run the local Kimi server and open the web UI through Remote Control.'), + { forceRemoteControl: true }, + ); +} diff --git a/apps/kimi-code/src/cli/sub/web/legacy-kill.ts b/apps/kimi-code/src/cli/sub/web/legacy-kill.ts new file mode 100644 index 0000000000000000000000000000000000000000..97cc1675a99f029770e046f0b523215ce96ac67b --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/legacy-kill.ts @@ -0,0 +1,263 @@ +/** + * `kimi server kill` — deprecated; only stops a server started by an old + * (pre-`kimi web`, i.e. before 0.28.0) build. + * + * Servers started by current builds run in the foreground attached to a + * terminal (Ctrl+C stops them), so they need no kill command. Builds before + * the `kimi web` command tree could leave a background daemon behind; those + * recorded themselves in the legacy single-instance lock at + * `/server/lock`, which the instance registry never sees. + * This command is the cleanup path for exactly those servers. + * + * The kill combines two independent mechanisms so the server dies even if one + * path fails: + * + * 1. API path — `POST /api/v1/shutdown` for a graceful, in-process shutdown + * (best-effort; old builds may not have the route, or may not + * answer at all). + * 2. PID path — signal the pid recorded in the lock (SIGTERM → wait → + * SIGKILL). SIGKILL is the hard guarantee: it cannot be + * caught or ignored. + * + * The lock file is removed once the recorded pid is confirmed dead (or was + * dead already), so the cleanup is complete after one run. + */ + +import { readFile, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { Command } from 'commander'; + +import { getDataDir } from '#/utils/paths'; + +import { authHeaders, serverOrigin, tryResolveServerToken } from './shared'; + +/** How long to wait for the graceful API shutdown request. */ +const API_TIMEOUT_MS = 2000; +/** Grace period after SIGTERM before escalating to SIGKILL. */ +const TERM_GRACE_MS = 3000; +/** Grace period after SIGKILL before giving up. */ +const KILL_GRACE_MS = 2000; +/** Poll cadence while waiting for the pid to exit. */ +const POLL_INTERVAL_MS = 100; + +/** + * The first release whose servers run in the foreground (`kimi web`) and + * register under `server/instances/`. Servers from older builds are the only + * ones this command can — and should — kill. + */ +export const LEGACY_SERVER_MAX_VERSION = '0.28.0'; + +/** Deprecation notice printed on every `kimi server kill` run. */ +export const DEPRECATED_KILL_NOTICE = + '`kimi server kill` is deprecated: it only stops servers started by a version before 0.28.0. Servers started by `kimi web` run in the foreground — stop them with Ctrl+C.\n'; + +/** + * The fields of the legacy `/server/lock` this command needs. The full + * on-disk shape also carried `started_at` / `host_version` / `entry`, which + * are irrelevant to killing the process. + */ +export interface LegacyServerLock { + pid: number; + host?: string; + port?: number; +} + +export interface LegacyKillDeps { + /** Read and parse the legacy lock; undefined when missing or unparseable. */ + readLock(): Promise; + /** Delete the lock file. Best-effort semantics live with the caller. */ + removeLock(): Promise; + requestShutdown(origin: string, token: string | undefined): Promise; + /** Best-effort read of the persistent bearer token; undefined on miss. */ + resolveToken(): string | undefined; + signalPid(pid: number, signal: NodeJS.Signals): boolean; + pidAlive(pid: number): boolean; + sleep(ms: number): Promise; + stdout: Pick; + stderr: Pick; + now(): number; +} + +export function registerLegacyKillCommand(server: Command): void { + server + .command('kill') + .description( + 'Deprecated — stop a server started by a version before 0.28.0 (recorded in the legacy server lock). Servers started by `kimi web` run in the foreground — stop them with Ctrl+C.', + ) + // Swallow legacy argument shapes (`kimi server kill `, flags): + // the legacy lock records a single server, so they carry no meaning here. + .allowUnknownOption(true) + .allowExcessArguments(true) + .action(async () => { + try { + await handleLegacyKillCommand(DEFAULT_LEGACY_KILL_DEPS); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +export async function handleLegacyKillCommand(deps: LegacyKillDeps): Promise { + deps.stderr.write(DEPRECATED_KILL_NOTICE); + + const lock = await deps.readLock(); + if (lock === undefined) { + deps.stdout.write('No running legacy Kimi server.\n'); + return; + } + + if (!deps.pidAlive(lock.pid)) { + // Stale lock from a server that died without releasing it; sweep it so the + // cleanup is done in one run. + await deps.removeLock().catch(() => {}); + deps.stdout.write('No running legacy Kimi server.\n'); + return; + } + + const outcome = await killLegacyServer(lock, deps); + await deps.removeLock().catch(() => {}); + deps.stdout.write(`Legacy Kimi server (pid ${String(lock.pid)}) ${outcome}.\n`); +} + +/** + * Kill the locked server via the API path (best-effort graceful shutdown) + * followed by the PID path (SIGTERM → wait → SIGKILL). Resolves with how the + * process went down; throws when the pid survives SIGKILL. + */ +async function killLegacyServer( + lock: LegacyServerLock, + deps: LegacyKillDeps, +): Promise<'stopped' | 'killed'> { + const { pid } = lock; + + // 1. API path — best-effort graceful shutdown. Ignore every outcome: an old + // build may not have the route, may be wedged, or may drop the connection + // as it exits. The bearer token is best-effort too: if it can't be read + // the API call 401s and the PID path below still guarantees the kill. + if (lock.port !== undefined) { + const origin = serverOrigin(lock.host ?? '127.0.0.1', lock.port); + await deps.requestShutdown(origin, deps.resolveToken()).catch(() => {}); + } + + // 2. PID path — SIGTERM, wait, then SIGKILL. + deps.signalPid(pid, 'SIGTERM'); + + if (await waitForExit(pid, TERM_GRACE_MS, deps)) { + return 'stopped'; + } + + deps.signalPid(pid, 'SIGKILL'); + + if (await waitForExit(pid, KILL_GRACE_MS, deps)) { + return 'killed'; + } + + throw new Error( + `Failed to stop legacy Kimi server (pid ${String(pid)}); insufficient permissions?`, + ); +} + +async function waitForExit( + pid: number, + timeoutMs: number, + deps: Pick, +): Promise { + const deadline = deps.now() + timeoutMs; + do { + if (!deps.pidAlive(pid)) return true; + await deps.sleep(POLL_INTERVAL_MS); + } while (deps.now() < deadline); + return !deps.pidAlive(pid); +} + +/** `process.kill(pid, 0)` probe — true if the pid exists, false on ESRCH. */ +export function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH') return false; + // EPERM = process exists but we can't signal it. Treat as alive. + return true; + } +} + +/** Send `signal` to `pid`. Returns false if the signal could not be sent. */ +export function signalPid(pid: number, signal: NodeJS.Signals): boolean { + try { + process.kill(pid, signal); + return true; + } catch { + return false; + } +} + +/** POST the shutdown endpoint; resolves once the request completes or times out. */ +export async function requestShutdownViaApi( + origin: string, + token: string | undefined, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, API_TIMEOUT_MS); + try { + await fetch(`${origin}/api/v1/shutdown`, { + method: 'POST', + headers: token !== undefined ? authHeaders(token) : undefined, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + +/** Path of the legacy single-instance lock under the CLI's data dir. */ +export function legacyLockPath(homeDir: string): string { + return join(homeDir, 'server', 'lock'); +} + +/** Read + decode the legacy lock; undefined on missing/unparseable input. */ +export async function readLegacyLock(lockPath: string): Promise { + let raw: string; + try { + raw = await readFile(lockPath, 'utf8'); + } catch { + return undefined; + } + try { + const parsed = JSON.parse(raw) as Partial<{ pid: unknown; host: unknown; port: unknown }>; + // Only accept a positive safe-integer pid: on POSIX, 0 and negative pids + // have process-GROUP semantics, so signaling a corrupt lock's pid could + // hit this CLI's own group or an unrelated one. + if (typeof parsed.pid !== 'number' || !Number.isSafeInteger(parsed.pid) || parsed.pid <= 0) { + return undefined; + } + return { + pid: parsed.pid, + host: typeof parsed.host === 'string' ? parsed.host : undefined, + port: typeof parsed.port === 'number' ? parsed.port : undefined, + }; + } catch { + return undefined; + } +} + +const DEFAULT_LEGACY_KILL_DEPS: LegacyKillDeps = { + readLock: () => readLegacyLock(legacyLockPath(getDataDir())), + removeLock: () => unlink(legacyLockPath(getDataDir())), + requestShutdown: requestShutdownViaApi, + resolveToken: () => tryResolveServerToken(getDataDir()), + signalPid, + pidAlive, + sleep: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }), + stdout: process.stdout, + stderr: process.stderr, + now: () => Date.now(), +}; diff --git a/apps/kimi-code/src/cli/sub/web/networks.ts b/apps/kimi-code/src/cli/sub/web/networks.ts new file mode 100644 index 0000000000000000000000000000000000000000..39ca7d084fb779db8848350377748abc0122b128 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/networks.ts @@ -0,0 +1,84 @@ +/** + * Enumerate this machine's non-loopback network interface addresses, used to + * print `Network: http://:/` hints (à la Vite) when the server + * binds a wildcard host (`0.0.0.0` / `::`). + */ + +import { networkInterfaces } from 'node:os'; + +export interface NetworkAddress { + /** Raw IP address (IPv4 or IPv6); IPv6 is NOT bracket-wrapped here. */ + address: string; + family: 'IPv4' | 'IPv6'; +} + +/** + * List non-internal interface addresses, IPv4 first then IPv6, preserving + * interface order within each family. + * + * Like Vite, this lists the machine's own interface addresses — LAN + * (192.168/10/172.16) plus any directly-assigned public address. It does not + * (and cannot, without an external service) discover a NAT-translated WAN IP, + * and we deliberately avoid any network call for a startup hint. + */ +export function listNetworkAddresses(): NetworkAddress[] { + const raw: NetworkAddress[] = []; + for (const entries of Object.values(networkInterfaces())) { + for (const info of entries ?? []) { + if (info.internal) { + continue; + } + if (info.family === 'IPv4') { + raw.push({ address: info.address, family: 'IPv4' }); + } else if (info.family === 'IPv6') { + raw.push({ address: info.address, family: 'IPv6' }); + } + } + } + return filterDisplayAddresses(raw); +} + +/** + * Drop addresses that are not useful as a connect target and de-duplicate. + * + * IPv6 link-local (`fe80::/10`) is filtered out: it is only reachable with a + * zone id (e.g. `fe80::1%en0`), which our bare URL cannot carry, so showing it + * is pure noise — and it is the bulk of what `os.networkInterfaces()` reports + * on a typical machine. Duplicates (the same address reported on more than one + * interface) are collapsed. The result is IPv4 first, then IPv6, preserving + * order within each family. + */ +export function filterDisplayAddresses( + addrs: readonly NetworkAddress[], +): NetworkAddress[] { + const seen = new Set(); + const kept: NetworkAddress[] = []; + for (const addr of addrs) { + if (addr.family === 'IPv6' && isLinkLocalV6(addr.address)) { + continue; + } + if (seen.has(addr.address)) { + continue; + } + seen.add(addr.address); + kept.push(addr); + } + return [ + ...kept.filter((a) => a.family === 'IPv4'), + ...kept.filter((a) => a.family === 'IPv6'), + ]; +} + +/** True for IPv6 link-local addresses (`fe80::/10`, i.e. `fe80::`–`febf::`). */ +function isLinkLocalV6(address: string): boolean { + const first = Number.parseInt(address.split(':')[0] ?? '', 16); + return first >= 0xfe80 && first <= 0xfebf; +} + +/** + * Format an address for use as a URL host: bracket-wrap IPv6 per RFC 3986, + * return IPv4 as-is. + */ +export function formatHostForUrl(address: string, family: NetworkAddress['family']): string { + return family === 'IPv6' ? `[${address}]` : address; +} diff --git a/apps/kimi-code/src/cli/sub/web/remote-control.ts b/apps/kimi-code/src/cli/sub/web/remote-control.ts new file mode 100644 index 0000000000000000000000000000000000000000..d719767768b50a3f82fbbab76a03419f68439a32 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/remote-control.ts @@ -0,0 +1,92 @@ +import chalk from 'chalk'; + +import { getVersion } from '../../version'; +import { darkColors } from '../../../tui/theme/colors'; +import { supportsHyperlinks, toTerminalHyperlink } from '../../../utils/terminal-hyperlink'; +import type { RemoteControlStatus } from '@moonshot-ai/remote-control'; +import { buildOpenableUrl, splitTokenFragment } from './access-urls'; + +export { + acquireRemoteControlLock, + buildRemoteControlUrl, + filterForwardRequestHeaders, + formatRemoteControlAlreadyRunning, + inspectRemoteControlLock, + parseRawHttpRequest, + remoteControlLockPath, + RemoteControlAlreadyRunningError, + REMOTE_CONTROL_RELAY_ORIGIN, + REMOTE_CONTROL_RELAY_URL_ENV, + resolveRemoteControlRelayOrigin, + rewriteRemoteControlResponse, + startRemoteControl, +} from '@moonshot-ai/remote-control'; +export type { + ParsedRawHttpRequest, + RemoteControlHandle, + RemoteControlLock, + RemoteControlLockInfo, + RemoteControlOptions, + RemoteControlStatus, +} from '@moonshot-ai/remote-control'; + +export interface RemoteControlOutputOptions { + readonly url: string; + readonly localOrigin: string; + readonly localServerToken: string; + readonly deviceName: string; + readonly qrCode: string; + readonly pngPath: string; +} + +export function formatRemoteControlOutput(options: RemoteControlOutputOptions): string { + const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text); + const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); + const accent = (text: string): string => chalk.hex(darkColors.accent)(text); + const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); + const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text); + const status = (text: string): string => chalk.hex(darkColors.success)(text); + const link = (url: string): string => + supportsHyperlinks() ? toTerminalHyperlink(accent(url), url) : accent(url); + const docs = toTerminalHyperlink('docs', 'https://kimi.com/code/docs/remote-control'); + const feedback = toTerminalHyperlink('feedback', 'https://kimi.com/code/feedback'); + const [localBase, localFrag] = splitTokenFragment( + buildOpenableUrl(options.localOrigin, options.localServerToken), + ); + return [ + '', + ` ${title('Kimi Remote Control ready')} ${muted(getVersion())}`, + ` ${muted('Use Kimi Code on this machine from your phone or another computer.')}`, + '', + ` ${label('1.')} Scan the QR code, or open ${link(options.url)}`, + ` ${label('2.')} Log in with your Kimi account`, + ` ${label('3.')} Start chatting — sessions run on this machine`, + '', + ` ${status('✓')} ${muted(`Connected to ${new URL(options.url).host}, waiting for remote devices…`)}`, + ` ${label('This device: ')}${muted(options.deviceName)}`, + ` ${status('⚠')} ${muted('This link grants control of this machine. Do not share it.')}`, + '', + options.qrCode.trimEnd().replaceAll(/^/gm, ' '), + ` ${label('QR code PNG: ')}${options.pngPath} ${muted('(open this if the QR above does not scan)')}`, + ` ${label('Local UI: ')}${accent(localBase)}${dim(localFrag)} ${muted('(LAN: --host)')}`, + '', + ` ${docs} ${muted('·')} ${feedback}`, + ` ${label('Logs: ')}${muted('off (--log-level info)')} ${muted('·')} ${label('Stop: ')}${muted('Ctrl+C')}`, + '', + ].join('\n'); +} + +export function formatRemoteControlStatus(status: RemoteControlStatus): string { + const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); + const value = (text: string): string => chalk.hex(darkColors.success)(text); + switch (status) { + case 'relay_connected': + return ` ${value('✓')} ${label('Connected to relay, waiting for remote devices…')}\n`; + case 'relay_disconnected': + return ` ${value('!')} ${label('Relay disconnected; reconnecting…')}\n`; + case 'device_connected': + return ` ${value('✓')} ${label('Remote device connected (1 active session)')}\n`; + case 'device_disconnected': + return ` ${value('→')} ${label('Remote device disconnected')}\n`; + } +} diff --git a/apps/kimi-code/src/cli/sub/web/rotate-token.ts b/apps/kimi-code/src/cli/sub/web/rotate-token.ts new file mode 100644 index 0000000000000000000000000000000000000000..eeaa63016bff2f1c0009ea00e83aaa16b22b2503 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/rotate-token.ts @@ -0,0 +1,56 @@ +/** + * `kimi web rotate-token` — generate a new persistent server token. + * + * Rewrites `/server.token` (0600, atomic). The previous token + * stops working immediately: a running server re-reads the file on its next + * auth check, so rotation takes effect without a restart. + */ + +import { getLiveServerInstance, rotateServerToken } from '@moonshot-ai/kap-server'; +import chalk from 'chalk'; +import type { Command } from 'commander'; + +import { darkColors } from '#/tui/theme/colors'; +import { getDataDir } from '#/utils/paths'; + +import { accessUrlLines, splitTokenFragment } from './access-urls'; + +export function registerRotateTokenCommand(server: Command): void { + server + .command('rotate-token') + .description( + 'Generate a new persistent server token; the previous token stops working immediately.', + ) + .action(async () => { + try { + const token = await rotateServerToken(getDataDir()); + process.stdout.write( + 'The previous token is now invalid. A running server picks up the new token automatically.\n', + ); + + // Token in the middle: indented and set off by blank lines (no color + // highlight), so it is easy to spot without dominating the output. + process.stdout.write(`\n ${chalk.bold('New server token:')} ${token}\n\n`); + + // Re-print the access links with the new token so the user can + // reconnect immediately. When a server is running its bind host/port + // come from the instance registry; otherwise there is nothing to + // connect to yet. + const instance = await getLiveServerInstance(); + if (instance !== undefined) { + for (const { label, url: href } of accessUrlLines(instance.host, instance.port, token)) { + // De-emphasize the `#token=…` fragment so the host/port stands out. + const [base, frag] = splitTokenFragment(href); + const rendered = + frag === '' + ? chalk.hex(darkColors.accent)(base) + : chalk.hex(darkColors.accent)(base) + chalk.hex(darkColors.textDim)(frag); + process.stdout.write(` ${chalk.dim(label)}${rendered}\n`); + } + } + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} diff --git a/apps/kimi-code/src/cli/sub/web/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts new file mode 100644 index 0000000000000000000000000000000000000000..b10a666d9cd72e4adf92a9707f1c9135bc3aa670 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -0,0 +1,528 @@ +/** + * `kimi web` — run the local server in the foreground and open the web UI. + * + * The server always runs in the current process, attached to the terminal, + * and shuts down cleanly on SIGINT/SIGTERM. `--no-open` skips the browser. + * Multiple instances can share the home directory: each registers itself in + * the instance registry and takes the next free port (see kap-server's + * `startServer`). + */ + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { createServerLogger, startServer, type ServerLogger } from '@moonshot-ai/kap-server'; +import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; +import chalk from 'chalk'; +import { type Command, Option } from 'commander'; + +import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_USER_AGENT_SUFFIX } from '#/constant/app'; +import { getNativeWebAssetsDir } from '#/native/web-assets'; +import { darkColors } from '#/tui/theme/colors'; +import { openUrl as defaultOpenUrl } from '#/utils/open-url'; +import { getDataDir } from '#/utils/paths'; +import { generateRemoteControlQr } from '#/utils/remote-control-qr'; + +import { initializeServerTelemetry } from '../../telemetry'; +import { + createKimiCodeHostIdentity, + getHostPackageRoot, + getVersion, +} from '../../version'; +import { + accessUrlLines, + browserOpenOrigin, + buildOpenableUrl, + isLoopbackHost, + splitTokenFragment, +} from './access-urls'; +import { type NetworkAddress } from './networks'; +import { + formatRemoteControlOutput, + formatRemoteControlStatus, + startRemoteControl, + type RemoteControlHandle, + type RemoteControlOptions, + type RemoteControlStatus, +} from './remote-control'; +import { + DEFAULT_FOREGROUND_LOG_LEVEL, + DEFAULT_LAN_HOST, + DEFAULT_SERVER_HOST, + DEFAULT_SERVER_PORT, + parseServerOptions, + tryResolveServerToken, + VALID_LOG_LEVELS, + type ParsedServerOptions, + type ServerCliOptions, +} from './shared'; + +const WEB_ASSETS_DIR = 'dist-web'; + +/** + * Minimal surface `runServerInProcess` needs from the server. kap-server's + * `RunningServer` is adapted to it (it returns `{ host, port, close }` + * instead of `{ address, logger, close }`). + */ +interface RoutedServer { + readonly address: string; + readonly logger: ServerLogger; + close(): Promise; +} + +export interface WebCliOptions extends ServerCliOptions { + open?: boolean; + remoteControl?: boolean; +} + +export interface StartForegroundHooks { + /** Fires once the server is listening, before the foreground runner blocks. */ + onReady?: (origin: string) => void | Promise; + onShutdown?: (reason: string) => void | Promise; +} + +export interface WebCommandDeps { + /** Foreground runner; defaults to the real in-process runner when omitted. */ + startServerForeground?: ( + options: ParsedServerOptions, + hooks?: StartForegroundHooks, + ) => Promise; + startRemoteControl?: (options: RemoteControlOptions) => Promise; + openUrl(url: string): void; + /** + * Best-effort read of the server's persistent bearer token. When it returns + * a token, the ready banner prints it and the opened Web UI URL carries it in + * the `#token=` fragment (M5.5). Optional so callers/tests that don't supply + * it simply print/open the plain origin. + */ + resolveToken?: () => string | undefined; + /** + * Non-loopback interface addresses to display for a wildcard bind. Defaults + * to the machine's own interfaces (`listNetworkAddresses()`); inject a fixed + * list in tests for deterministic output. + */ + networkAddresses?: NetworkAddress[]; + stdout: Pick; + stderr: Pick; +} + +/** + * Build the Web UI URL, carrying the bearer token in the URL fragment. + * + * The token rides in `#token=` — a client-side fragment that is never + * sent to the server (so it never appears in server access logs) and is not + * logged by proxies. The Web UI reads it from `location.hash` after load. + */ +export function buildWebUrl(origin: string, token: string): string { + return buildOpenableUrl(origin, token); +} + +/** Build the `web` command, mounting the runner action on `cmd` itself. */ +export function buildWebCommand( + cmd: Command, + opts: { forceRemoteControl?: boolean } = {}, +): Command { + const forceRemoteControl = opts.forceRemoteControl === true; + const withServerOptions = cmd + .option( + '--port ', + `Bind port (default ${DEFAULT_SERVER_PORT})`, + String(DEFAULT_SERVER_PORT), + ) + .option( + '--host [host]', + `Bind host. Omit to bind ${DEFAULT_SERVER_HOST} (this machine only); pass --host to bind ${DEFAULT_LAN_HOST} (all interfaces), or --host for a specific host. The bearer token is printed at startup.`, + ) + .option( + '--allowed-host ', + 'Extra Host header value to allow through the DNS-rebinding check. Repeat or comma-separate; a leading dot matches a domain suffix (e.g. .example.com).', + ) + .option( + '--insecure-no-tls', + 'Allow a non-loopback bind without a TLS-terminating reverse proxy. Defaults to true; only relevant for non-loopback binds.', + true, + ) + .option( + '--allow-remote-shutdown', + 'On a non-loopback bind, keep POST /api/v1/shutdown enabled (default: route is disabled → 404).', + false, + ) + .option( + '--dangerous-bypass-auth', + 'Disable bearer-token auth on every REST and WebSocket route, and advertise it via /api/v1/meta so the web UI connects without a token. Only use on a trusted network or behind your own authenticating proxy.', + false, + ) + .option( + '--log-level ', + `Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`, + ) + .option( + '--debug-endpoints', + 'Mount /api/v1/debug/* routes for test introspection. OFF by default; production callers leave this unset.', + false, + ) + .option( + '--web-title ', + 'Set a custom browser tab title for this web UI instance (default: "<workspace dir> | Kimi Code").', + ); + if (!forceRemoteControl) { + withServerOptions.addOption( + new Option( + '--rc, --remote-control', + 'Expose the web UI through Kimi Remote Control.', + ).default(false), + ); + } + return withServerOptions + .option('--no-open', 'Do not open the web UI in the default browser.', true) + .action(async (opts: WebCliOptions) => { + try { + await handleWebCommand( + forceRemoteControl ? { ...opts, remoteControl: true } : opts, + ); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +export async function handleWebCommand( + opts: WebCliOptions, + deps: WebCommandDeps = DEFAULT_WEB_COMMAND_DEPS, +): Promise<void> { + const parsed = parseServerOptions(opts); + if (opts.remoteControl === true && parsed.dangerousBypassAuth) { + throw new Error('--remote-control cannot be combined with --dangerous-bypass-auth.'); + } + if (opts.remoteControl === true && !isLoopbackHost(parsed.host)) { + throw new Error('--remote-control requires a loopback host.'); + } + const run = deps.startServerForeground ?? startServerForeground; + let remoteControl: RemoteControlHandle | undefined; + await run(parsed, { + onReady: async (origin) => { + // Resolve the persistent token only once the server is up: a fresh + // server writes `server.token` on first boot, so reading it beforehand + // would miss first-time starts and the browser would hit the auth gate. + // It is printed in the ready banner and rides in the opened Web UI + // URL's `#token=` fragment (M5.5); falls back to the plain origin / no + // token line when unavailable. When auth is bypassed, the token is + // meaningless and is intentionally NOT shown or carried in the URL. + const token = parsed.dangerousBypassAuth ? undefined : deps.resolveToken?.(); + if (opts.remoteControl === true) { + if (token === undefined) throw new Error('Unable to read the local server token.'); + const dataDir = getDataDir(); + let outputReady = false; + const pendingStatuses: string[] = []; + const onStatus = (status: RemoteControlStatus): void => { + const line = formatRemoteControlStatus(status); + if (outputReady) deps.stdout.write(line); + else pendingStatuses.push(line); + }; + remoteControl = await (deps.startRemoteControl ?? startRemoteControl)({ + homeDir: dataDir, + localOrigin: origin, + localServerToken: token, + clientVersion: `kimi-code/${getVersion()}`, + stderr: deps.stderr, + onStatus, + }); + const qrCode = await generateRemoteControlQr(remoteControl.url, dataDir); + deps.stdout.write( + formatRemoteControlOutput({ + url: remoteControl.url, + localOrigin: origin, + localServerToken: token, + deviceName: remoteControl.deviceName, + qrCode: qrCode.terminal, + pngPath: qrCode.pngPath, + }), + ); + outputReady = true; + for (const line of pendingStatuses) deps.stdout.write(line); + if (opts.open === true) deps.openUrl(remoteControl.url); + return; + } + deps.stdout.write( + parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL + ? formatReadyBanner(origin, parsed.host, { + token, + networkAddresses: deps.networkAddresses, + dangerousBypassAuth: parsed.dangerousBypassAuth, + }) + : formatReadyLine(origin, token, parsed.dangerousBypassAuth), + ); + if (opts.open === true) { + const openOrigin = browserOpenOrigin(origin); + deps.openUrl(token !== undefined ? buildWebUrl(openOrigin, token) : openOrigin); + } + }, + onShutdown: async () => { + await remoteControl?.close(); + }, + }); +} + +function formatReadyLine( + origin: string, + token: string | undefined, + dangerousBypassAuth = false, +): string { + const notice = dangerousBypassAuth + ? `${formatDangerNoticeLines().join('\n')}\n` + : ''; + return `${notice}Kimi server: ${buildOpenableUrl(origin, token)}\n`; +} + +/** + * Red, impossible-to-miss notice emitted when `--dangerous-bypass-auth` + * disables the bearer-token gate. Shared by the full ready banner and the + * compact one-line output so the warning always shows regardless of log level. + */ +function formatDangerNoticeLines(): string[] { + const danger = (text: string): string => chalk.hex(darkColors.error)(text); + const dangerBold = (text: string): string => chalk.bold.hex(darkColors.error)(text); + return [ + ` ${dangerBold('⚠ DANGER: authentication is DISABLED (--dangerous-bypass-auth).')}`, + ` ${danger('Anyone who can reach this port gets full access. Only continue if you understand the risk.')}`, + ` ${danger('If you are unsure, stop this process now with ')}${dangerBold('Ctrl+C')}${danger('.')}`, + ]; +} + +/** + * `kimi web` — runs the local server in-process, attached to the current + * terminal. Resolves only via `process.exit` (SIGINT/SIGTERM). + */ +export async function startServerForeground( + options: ParsedServerOptions, + hooks: StartForegroundHooks = {}, +): Promise<never> { + return runServerInProcess(options, hooks); +} + +/** + * Start the server in the current process and block until shutdown. + * `onReady` fires once the server is listening. + */ +async function runServerInProcess( + options: ParsedServerOptions, + hooks: StartForegroundHooks, +): Promise<never> { + const version = getVersion(); + // Registers the telemetry provider for `track` / `shutdownTelemetry`; the + // client itself is not passed into kap-server. + initializeServerTelemetry({ version }); + + let running: RoutedServer | undefined; + let stopping = false; + + async function shutdown(reason: string): Promise<void> { + if (stopping) return; + stopping = true; + running?.logger.info({ reason }, 'server shutting down'); + try { + await hooks.onShutdown?.(reason); + } catch (error) { + running?.logger.error( + { err: error instanceof Error ? error : new Error(String(error)) }, + 'foreground shutdown hook error', + ); + } + try { + await running?.close(); + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + } catch (error) { + running?.logger.error( + { err: error instanceof Error ? error : new Error(String(error)) }, + 'server shutdown error', + ); + } + process.exit(0); + } + + // kap-server (the DI × Scope engine server) is the only server flavor. Its + // `startServer` returns `{ host, port, close }` rather than `{ address, + // logger, close }`, so adapt it to the `RoutedServer` surface the rest of + // this runner consumes. + const logger = createServerLogger({ level: options.logLevel }); + const webAssetsDir = serverWebAssetsDir(); + if (webAssetsDir === undefined) { + logger.info( + 'dev mode: web assets not built; starting the API server without the web UI', + ); + } + const v2 = await startServer({ + host: options.host, + port: options.port, + // Report the CLI's product version as `server_version` (/meta, web UI) + // rather than kap-server's private package version. + serverVersion: version, + // The CLI's host identity: feeds the engine's bootstrap client identity + // and the derived outbound headers (User-Agent + X-Msh-*), so web-UI + // OAuth flows and model / WebSearch requests carry the CLI identity. The + // `web` User-Agent suffix distinguishes web-UI traffic from direct CLI + // runs upstream (same product token, same platform). + hostIdentity: { + ...createKimiCodeHostIdentity(version), + userAgentSuffix: WEB_USER_AGENT_SUFFIX, + }, + logLevel: options.logLevel, + logger, + debugEndpoints: options.debugEndpoints, + insecureNoTls: options.insecureNoTls, + allowRemoteShutdown: options.allowRemoteShutdown, + allowedHosts: options.allowedHosts, + disableAuth: options.dangerousBypassAuth, + webTitle: options.webTitle, + // Attach the engine's cloud telemetry appender (still gated by the config + // `telemetry` toggle). Complements the v1 client registered above, which + // only covers host-level events. + telemetry: true, + webAssetsDir, + }); + logger.info('serving the REST/WS API and the bundled web UI'); + running = { + address: `http://${v2.host}:${v2.port}`, + logger, + close: () => v2.close(), + }; + + track('server_started', { daemon: false }); + + process.once('SIGINT', () => { + void shutdown('SIGINT'); + }); + process.once('SIGTERM', () => { + void shutdown('SIGTERM'); + }); + + running.logger.info({ address: running.address }, 'server ready'); + + try { + await hooks.onReady?.(running.address); + } catch (error) { + try { + await hooks.onShutdown?.('startup_failed'); + } finally { + await running.close(); + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + } + throw error; + } + + return new Promise<never>(() => { + // Keeps the event loop alive; the process ends via shutdown()/process.exit. + }); +} + +/** + * Resolve the web assets directory passed to kap-server. In dev mode + * (`KIMI_CODE_DEV_SERVER=1`, set by the repo's `dev:server` / `dev:kap-server*` + * scripts) a missing `dist-web` build is tolerated: the server starts API-only + * and the web UI is expected to come from a Vite dev server (the web UI source lives in the code-app repo). + * Outside dev mode the directory is always returned and kap-server keeps + * failing fast when the assets are missing. + */ +export function serverWebAssetsDir( + env: NodeJS.ProcessEnv = process.env, + nativeWebAssetsDir: string | null = getNativeWebAssetsDir(), +): string | undefined { + const dir = resolveServerWebAssetsDir(nativeWebAssetsDir); + if (env['KIMI_CODE_DEV_SERVER'] === '1' && !existsSync(join(dir, 'index.html'))) { + return undefined; + } + return dir; +} + +export function resolveServerWebAssetsDir( + nativeWebAssetsDir: string | null = getNativeWebAssetsDir(), +): string { + return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR); +} + +interface FormatReadyBannerOptions { + /** Persistent bearer token to print; omitted when unresolvable. */ + token?: string; + /** Non-loopback interface addresses to list for a wildcard bind. */ + networkAddresses?: NetworkAddress[]; + /** When true, render a red danger notice (auth is disabled). */ + dangerousBypassAuth?: boolean; +} + +export function formatReadyBanner( + origin: string, + host: string, + opts: FormatReadyBannerOptions = {}, +): string { + const primary = (text: string): string => chalk.hex(darkColors.primary)(text); + const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text); + const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); + const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text); + const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); + const url = (text: string): string => chalk.hex(darkColors.accent)(text); + // Render the `#token=…` fragment in a de-emphasized gray so the host/port + // stands out while the full URL stays selectable for copying. + const urlWithDimToken = (href: string): string => { + const [base, frag] = splitTokenFragment(href); + return frag === '' ? url(base) : url(base) + dim(frag); + }; + + const port = Number(origin.slice(origin.lastIndexOf(':') + 1)); + // Borderless header: the Kimi sprite (the little mascot with eyes) sits next + // to the title, keeping the brand without the enclosing box. + const logo = ['▐█▛█▛█▌', '▐█████▌'] as const; + const lines: string[] = [ + '', + ` ${primary(logo[0])} ${title('Kimi server ready')} ${dim(getVersion())}`, + ` ${primary(logo[1])} ${dim('Local web UI is available from this machine.')}`, + '', + ]; + + if (opts.dangerousBypassAuth === true) { + // Red, impossible-to-miss notice: the bearer-token gate is off, so anyone + // who can reach this port gets full session / filesystem / shell access. + lines.push(...formatDangerNoticeLines(), ''); + } + + // Access links. + for (const { label: text, url: href } of accessUrlLines( + host, + port, + opts.token, + opts.networkAddresses, + )) { + lines.push(` ${label(text)}${urlWithDimToken(href)}`); + } + // On a loopback bind there is no network URL; show how to enable one. + if (isLoopbackHost(host)) { + lines.push(` ${label('Network: ')}${muted('off')}${dim(' use --host to enable')}`); + } + if (opts.token !== undefined) { + // Set the token off with surrounding whitespace rather than color, so it is + // easy to spot without being highlighted. + lines.push(''); + lines.push(` ${label('Token: ')}${opts.token}`); + lines.push(''); + } + + // Auxiliary controls last. + lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`); + // The server always runs in the foreground attached to this terminal. + lines.push(` ${label('Stop: ')}${muted('Ctrl+C')}`); + lines.push(''); + return lines.join('\n'); +} + +const DEFAULT_WEB_COMMAND_DEPS: WebCommandDeps = { + startServerForeground, + openUrl: defaultOpenUrl, + resolveToken: () => { + // Read the persistent `<homeDir>/server.token` written on first boot + // (M5.1). Best-effort: a missing/older server yields undefined and the + // caller opens the plain origin. + return tryResolveServerToken(getDataDir()); + }, + stdout: process.stdout, + stderr: process.stderr, +}; diff --git a/apps/kimi-code/src/cli/sub/web/shared.ts b/apps/kimi-code/src/cli/sub/web/shared.ts new file mode 100644 index 0000000000000000000000000000000000000000..91fede6c07bbdacea36243865beeaea5cdd8a5e0 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/shared.ts @@ -0,0 +1,164 @@ +/** + * Shared helpers for `kimi web` and its subcommands. + * + * Owns the default host/port, option parsers, and health/readiness probes. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { ServerLogLevel } from '@moonshot-ai/kap-server'; + +export const LOCAL_SERVER_HOST = '127.0.0.1'; +export const DEFAULT_LAN_HOST = '0.0.0.0'; +export const DEFAULT_SERVER_HOST = LOCAL_SERVER_HOST; +export const DEFAULT_SERVER_PORT = 58627; +export const DEFAULT_SERVER_ORIGIN = serverOrigin(DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT); + +/** Filename (under KIMI_CODE_HOME) of the persistent server bearer token. */ +export const SERVER_TOKEN_FILE = 'server.token'; + +export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info'; +export const DEFAULT_FOREGROUND_LOG_LEVEL: ServerLogLevel = 'silent'; + +export const VALID_LOG_LEVELS: readonly ServerLogLevel[] = [ + 'fatal', + 'error', + 'warn', + 'info', + 'debug', + 'trace', + 'silent', +]; + +export interface ParsedServerOptions { + host: string; + port: number; + logLevel: ServerLogLevel; + debugEndpoints: boolean; + /** Allow a non-loopback bind without a TLS-terminating reverse proxy. */ + insecureNoTls: boolean; + /** Allow `POST /api/v1/shutdown` on a non-loopback bind. */ + allowRemoteShutdown: boolean; + /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ + dangerousBypassAuth: boolean; + /** Extra `Host` header values to allow through the DNS-rebinding check. */ + allowedHosts: readonly string[]; + /** Custom browser tab title for this web UI instance (`--web-title`). */ + webTitle?: string; +} + +export interface ServerCliOptions { + host?: string | boolean; + port?: string; + logLevel?: string; + debugEndpoints?: boolean; + /** Allow a non-loopback bind without TLS (`--insecure-no-tls`). */ + insecureNoTls?: boolean; + /** Allow remote shutdown on a non-loopback bind (`--allow-remote-shutdown`). */ + allowRemoteShutdown?: boolean; + /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ + dangerousBypassAuth?: boolean; + /** Extra `Host` header values to allow (`--allowed-host`). */ + allowedHost?: string[]; + /** Custom browser tab title for this web UI instance (`--web-title`). */ + webTitle?: string; +} + +export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions { + return { + host: parseHost(opts.host), + port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), + logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL), + debugEndpoints: opts.debugEndpoints === true, + insecureNoTls: opts.insecureNoTls !== false, + allowRemoteShutdown: opts.allowRemoteShutdown === true, + dangerousBypassAuth: opts.dangerousBypassAuth === true, + allowedHosts: parseAllowedHostArgs(opts.allowedHost), + webTitle: opts.webTitle, + }; +} + +export function parseAllowedHostArgs(raw: readonly string[] | undefined): string[] { + if (raw === undefined) return []; + return raw + .flatMap((entry) => entry.split(',')) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function parseHost(raw: string | boolean | undefined): string { + if (raw === undefined || raw === false) return DEFAULT_SERVER_HOST; + if (raw === true || raw === '') return DEFAULT_LAN_HOST; + return raw; +} + +export function parsePort(raw: string | undefined, label: string, fallback: number): number { + if (raw === undefined) return fallback; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n < 0 || n > 65535) { + throw new Error(`error: invalid ${label} value: ${raw}`); + } + return n; +} + +export function parseLogLevel(raw: string | undefined): ServerLogLevel { + if (raw === undefined) return DEFAULT_LOG_LEVEL; + if ((VALID_LOG_LEVELS as readonly string[]).includes(raw)) { + return raw as ServerLogLevel; + } + throw new Error( + `error: invalid --log-level value: ${raw} (allowed: ${VALID_LOG_LEVELS.join(', ')})`, + ); +} + +export function serverOrigin(host: string, port: number): string { + return `http://${host}:${port}`; +} + +/** Strip `/api/v1` and trailing slashes so user-supplied origins are uniform. */ +export function normalizeServerOrigin(value: string): string { + const url = new URL(value); + url.pathname = url.pathname.replace(/\/api\/v1\/?$/, '').replace(/\/$/, ''); + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); +} + +/** + * Read the persistent bearer token for the server. + * + * The server writes `<homeDir>/server.token` (0600) on first boot and reuses + * it across restarts (ROADMAP M5.1); CLI commands that hit a gated REST route + * read it back here and send it as `Authorization: Bearer <token>`. `homeDir` + * is the CLI's own KIMI_CODE_HOME resolution (`getDataDir()`). + * + * Throws a clear error when the file is missing/unreadable — the usual cause + * is a server that has never been started (no token file yet), or an older + * build that predates token auth. + */ +export function resolveServerToken(homeDir: string): string { + const tokenPath = join(homeDir, SERVER_TOKEN_FILE); + try { + return readFileSync(tokenPath, 'utf8').trim(); + } catch (error) { + throw new Error( + `unable to read server token at ${tokenPath}; has the server been started at least once?`, + { cause: error }, + ); + } +} + +/** Best-effort token read: returns `undefined` instead of throwing. */ +export function tryResolveServerToken(homeDir: string): string | undefined { + try { + return resolveServerToken(homeDir); + } catch { + return undefined; + } +} + +/** An `Authorization: Bearer <token>` header bag for `fetch`. */ +export function authHeaders(token: string): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} diff --git a/apps/kimi-code/src/tui/banner/banner-config.ts b/apps/kimi-code/src/tui/banner/banner-config.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b0a2e74b94fdd1ef4f145c73bca078c21d47983 --- /dev/null +++ b/apps/kimi-code/src/tui/banner/banner-config.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +import { fetchClientConfig, type ClientConfigFetchOptions } from '#/utils/client-configs'; + +/** The tips/banner payload is one named config on the client-configs endpoint. */ +const CONFIG_NAME = 'client_banner'; + +/** The payload keeps the legacy tips.json shape, which banner-provider parses + defensively; the schema only guarantees an object. */ +const bannerConfigSchema = z.looseObject({}); + +export type BannerConfig = z.infer<typeof bannerConfigSchema>; +export type BannerConfigFetchOptions = ClientConfigFetchOptions; + +/** + * Fetches the banner config straight from the endpoint — banners are + * time-sensitive announcements, so no caching layer is used. Any failure + * resolves to `undefined` — callers treat that as "no banner". + */ +export async function getBannerConfig( + options: BannerConfigFetchOptions = {}, +): Promise<BannerConfig | undefined> { + return fetchClientConfig(CONFIG_NAME, bannerConfigSchema, options); +} diff --git a/apps/kimi-code/src/tui/banner/banner-provider.ts b/apps/kimi-code/src/tui/banner/banner-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..57536346b3587a04e07e0a83e8e978a7a2335420 --- /dev/null +++ b/apps/kimi-code/src/tui/banner/banner-provider.ts @@ -0,0 +1,356 @@ +import { createHash } from 'node:crypto'; + +import { eq, gte, lt, valid } from 'semver'; + +import type { BannerDisplay, BannerState } from '#/tui/types'; + +import { getBannerConfig } from './banner-config'; +import type { BannerDisplayState } from './state'; + +interface BannerVersionFields { + banner_min_version?: string | null; + banner_max_version?: string | null; + banner_version?: string | null; +} + +interface TipsBannerFallbackItem extends BannerVersionFields { + banner_id?: string | null; + enabled?: boolean; + banner_title?: string | null; + banner_maintext?: string; + banner_subtext?: string | null; + banner_start_time?: string | null; + banner_end_time?: string | null; + banner_display?: unknown; + banner_display_ttl_hours?: unknown; + banner_platform?: string | null; +} + +interface TipsBannerJson extends BannerVersionFields { + banner_id?: string | null; + banner_enabled?: boolean; + banner_title?: string | null; + banner_maintext?: string; + banner_subtext?: string | null; + banner_start_time?: string | null; + banner_end_time?: string | null; + banner_display?: unknown; + banner_display_ttl_hours?: unknown; + banner_fallback_enabled?: boolean; + banner_fallback_list?: unknown[]; + banner_platform?: string | null; +} + +interface BannerHashInput { + tag: string | null; + mainText: string; + subText: string | null; + startTime: string | null; + endTime: string | null; + display: BannerDisplay; + ttlHours?: number; +} + +interface BannerCandidateInput { + id: unknown; + tag: unknown; + mainText: string; + subText: unknown; + display: BannerDisplay; + ttlHours?: number; + startTime?: unknown; + endTime?: unknown; +} + +export interface SelectDisplayableBannerArgs { + json: unknown; + clientVersion: string; + now: Date; + random: () => number; + state: BannerDisplayState; +} + +interface BannerProviderLoadOptions { + state?: BannerDisplayState; + now?: Date; + random?: () => number; +} + +const HOUR_MS = 60 * 60 * 1000; +export const DEFAULT_COOLDOWN_TTL_HOURS = 24; + +function normalizeTag(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeUtcDate(value: string): string { + if (value.endsWith('Z')) return value; + if (/[+-]\d{2}:\d{2}$/.test(value)) return value; + return `${value}Z`; +} + +function parseDate(value: unknown): Date | null { + if (typeof value !== 'string' || value.length === 0) return null; + const normalized = normalizeUtcDate(value); + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; +} + +function isWithinWindow(start: Date | null, end: Date | null, now: Date): boolean { + if (start !== null && now < start) return false; + if (end !== null && now > end) return false; + return true; +} + +type VersionConstraintCompare = (current: string, target: string) => boolean; + +function meetsVersionConstraint( + constraint: unknown, + clientVersion: string, + compare: VersionConstraintCompare, +): boolean { + if (constraint === undefined || constraint === null) return true; + if (typeof constraint !== 'string' || constraint.length === 0) return true; + const target = valid(constraint); + const current = valid(clientVersion); + if (target === null || current === null) return false; + return compare(current, target); +} + +function meetsVersion(banner: BannerVersionFields, clientVersion: string): boolean { + return ( + meetsVersionConstraint(banner.banner_min_version, clientVersion, gte) && + meetsVersionConstraint(banner.banner_max_version, clientVersion, lt) && + meetsVersionConstraint(banner.banner_version, clientVersion, eq) + ); +} + +/** The CLI shows banners targeting every platform (missing / empty / 'all') + or the CLI itself; any other platform value ('desktop', 'web', …) hides + the banner here. */ +function meetsPlatform(value: unknown): boolean { + if (typeof value !== 'string') return true; + const platform = value.trim().toLowerCase(); + return platform === '' || platform === 'all' || platform === 'cli'; +} + +function parseBannerDisplay(value: unknown): BannerDisplay { + if (value === 'once') return 'once'; + if (value === 'cooldown') return 'cooldown'; + return 'always'; +} + +function parseBannerDisplayTtlHours(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : DEFAULT_COOLDOWN_TTL_HOURS; +} + +function normalizeBannerId(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function hashBannerIdentity(input: BannerHashInput): string { + const raw = JSON.stringify([ + input.tag ?? '', + input.mainText, + input.subText ?? '', + input.startTime ?? '', + input.endTime ?? '', + input.display, + input.ttlHours ?? '', + ]); + return createHash('sha256').update(raw).digest('hex').slice(0, 32); +} + +function getBannerKey(rawBannerId: unknown, input: BannerHashInput): string { + return normalizeBannerId(rawBannerId) ?? hashBannerIdentity(input); +} + +function toBannerState(input: BannerCandidateInput): BannerState { + const tag = normalizeTag(input.tag); + const subText = normalizeText(input.subText); + const display = input.display; + const ttlHours = display === 'cooldown' ? parseBannerDisplayTtlHours(input.ttlHours) : undefined; + const startTime = normalizeText(input.startTime); + const endTime = normalizeText(input.endTime); + const key = getBannerKey(input.id, { + tag, + mainText: input.mainText, + subText, + startTime, + endTime, + display, + ttlHours, + }); + + return { + key, + tag, + mainText: input.mainText, + subText, + display, + ttlHours, + }; +} + +function pickActiveBanner( + json: TipsBannerJson, + clientVersion: string, + now: Date, +): BannerState | null { + if (json.banner_enabled !== true) return null; + if (!meetsVersion(json, clientVersion)) return null; + if (!meetsPlatform(json.banner_platform)) return null; + const start = parseDate(json.banner_start_time); + const end = parseDate(json.banner_end_time); + if (!isWithinWindow(start, end, now)) return null; + const mainText = normalizeText(json.banner_maintext); + if (mainText === null) return null; + const display = parseBannerDisplay(json.banner_display); + return toBannerState({ + id: json.banner_id, + tag: json.banner_title, + mainText, + subText: json.banner_subtext, + display, + ttlHours: display === 'cooldown' ? parseBannerDisplayTtlHours(json.banner_display_ttl_hours) : undefined, + startTime: json.banner_start_time, + endTime: json.banner_end_time, + }); +} + +function pickFallbackCandidates( + json: TipsBannerJson, + clientVersion: string, + now: Date, +): BannerState[] { + if (json.banner_fallback_enabled !== true) return []; + const list = Array.isArray(json.banner_fallback_list) ? json.banner_fallback_list : []; + const candidates: BannerState[] = []; + for (const raw of list) { + if (typeof raw !== 'object' || raw === null) continue; + const item = raw as TipsBannerFallbackItem; + if (item.enabled !== true) continue; + if (!meetsVersion(item, clientVersion)) continue; + if (!meetsPlatform(item.banner_platform)) continue; + const start = parseDate(item.banner_start_time); + const end = parseDate(item.banner_end_time); + if (!isWithinWindow(start, end, now)) continue; + const mainText = normalizeText(item.banner_maintext); + if (mainText === null) continue; + const display = parseBannerDisplay(item.banner_display); + candidates.push( + toBannerState({ + id: item.banner_id, + tag: item.banner_title, + mainText, + subText: item.banner_subtext, + display, + ttlHours: display === 'cooldown' ? parseBannerDisplayTtlHours(item.banner_display_ttl_hours) : undefined, + startTime: item.banner_start_time, + endTime: item.banner_end_time, + }), + ); + } + return candidates; +} + +function pickRandomCandidate(candidates: BannerState[], random: () => number): BannerState | null { + if (candidates.length === 0) return null; + const index = Math.floor(random() * candidates.length); + return candidates[index]!; +} + +function pickFallbackBanner( + json: TipsBannerJson, + clientVersion: string, + now: Date, + random: () => number, +): BannerState | null { + return pickRandomCandidate(pickFallbackCandidates(json, clientVersion, now), random); +} + +function parseShownAt(value: string | undefined): Date | null { + if (value === undefined) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function getCooldownTtlHours(banner: BannerState): number { + return typeof banner.ttlHours === 'number' && Number.isFinite(banner.ttlHours) && banner.ttlHours > 0 + ? banner.ttlHours + : DEFAULT_COOLDOWN_TTL_HOURS; +} + +export function shouldDisplayBanner( + banner: BannerState, + state: BannerDisplayState, + now: Date, +): boolean { + if (banner.display === 'always') return true; + const lastShownAt = parseShownAt(state.shown[banner.key]?.lastShownAt); + if (lastShownAt === null) return true; + if (banner.display === 'once') return false; + return now.getTime() - lastShownAt.getTime() >= getCooldownTtlHours(banner) * HOUR_MS; +} + +export function selectBannerState( + json: unknown, + clientVersion: string, + now: Date, + random: () => number, +): BannerState | null { + const typed = typeof json === 'object' && json !== null ? (json as TipsBannerJson) : {}; + return ( + pickActiveBanner(typed, clientVersion, now) ?? + pickFallbackBanner(typed, clientVersion, now, random) + ); +} + +export function selectDisplayableBanner({ + json, + clientVersion, + now, + random, + state, +}: SelectDisplayableBannerArgs): BannerState | null { + const typed = typeof json === 'object' && json !== null ? (json as TipsBannerJson) : {}; + const active = pickActiveBanner(typed, clientVersion, now); + if (active !== null && shouldDisplayBanner(active, state, now)) return active; + const candidates = pickFallbackCandidates(typed, clientVersion, now).filter((candidate) => + shouldDisplayBanner(candidate, state, now), + ); + return pickRandomCandidate(candidates, random); +} + +export class BannerProvider { + constructor(private readonly clientVersion: string) {} + + async load(options: BannerProviderLoadOptions = {}): Promise<BannerState | null> { + // getBannerConfig never throws; undefined means "config unavailable". + const json = await getBannerConfig(); + if (json === undefined) return null; + const now = options.now ?? new Date(); + const random = options.random ?? Math.random; + return options.state === undefined + ? selectBannerState(json, this.clientVersion, now, random) + : selectDisplayableBanner({ + json, + clientVersion: this.clientVersion, + now, + random, + state: options.state, + }); + } +} diff --git a/apps/kimi-code/src/tui/banner/state.ts b/apps/kimi-code/src/tui/banner/state.ts new file mode 100644 index 0000000000000000000000000000000000000000..77877d82a60c9a131a6009420fa058a973d20951 --- /dev/null +++ b/apps/kimi-code/src/tui/banner/state.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +import { getBannerStateFile } from '#/utils/paths'; +import { readJsonFile, writeJsonFile } from '#/utils/persistence'; + +export type BannerDisplayRecord = { + lastShownAt: string; +}; + +export type BannerDisplayState = { + version: 1; + shown: Record<string, BannerDisplayRecord>; +}; + +const BannerDisplayRecordSchema = z + .object({ + lastShownAt: z.string().min(1), + }) + .strict(); + +const BannerDisplayStateSchema = z.preprocess( + (value) => { + if (typeof value !== 'object' || value === null) return value; + const shown = (value as { shown?: unknown }).shown; + if (typeof shown !== 'object' || shown === null) { + return { ...(value as Record<string, unknown>), shown: {} }; + } + + const normalizedShown: Record<string, BannerDisplayRecord> = {}; + for (const [key, record] of Object.entries(shown)) { + if (key.length === 0 || typeof record !== 'object' || record === null) continue; + const lastShownAt = (record as { lastShownAt?: unknown }).lastShownAt; + if (typeof lastShownAt !== 'string' || Number.isNaN(Date.parse(lastShownAt))) continue; + normalizedShown[key] = { lastShownAt }; + } + + return { ...(value as Record<string, unknown>), shown: normalizedShown }; + }, + z + .object({ + version: z.literal(1), + shown: z.record(z.string().min(1), BannerDisplayRecordSchema), + }) + .strict(), +); + +export function emptyBannerDisplayState(): BannerDisplayState { + return { + version: 1, + shown: {}, + }; +} + +export async function readBannerDisplayState( + filePath: string = getBannerStateFile(), +): Promise<BannerDisplayState> { + try { + return await readJsonFile(filePath, BannerDisplayStateSchema, emptyBannerDisplayState()); + } catch { + return emptyBannerDisplayState(); + } +} + +export async function writeBannerDisplayState( + value: BannerDisplayState, + filePath: string = getBannerStateFile(), +): Promise<void> { + await writeJsonFile(filePath, BannerDisplayStateSchema, value); +} diff --git a/apps/kimi-code/src/tui/commands/add-dir.ts b/apps/kimi-code/src/tui/commands/add-dir.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d76e20f35e5632b60736623a8b32e1022366606 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/add-dir.ts @@ -0,0 +1,107 @@ +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; +import type { SlashCommandHost } from './dispatch'; +import { slashBusyMessage, slashCommandBusyReason } from './resolve'; + +type AddDirChoice = 'session' | 'remember' | 'cancel'; + +export async function handleAddDirCommand(host: SlashCommandHost, args: string): Promise<void> { + const input = args.trim(); + let session = host.session; + + if (input.length === 0 || input.toLowerCase() === 'list') { + // With no session yet (v2 session-less startup) the pending startup + // directories live in appState and will be passed to the lazy-created + // session; reflect them instead of reporting an empty list. + const additionalDirs = session?.summary?.additionalDirs ?? host.state.appState.additionalDirs; + if (additionalDirs.length === 0) { + host.showStatus('No additional directories configured.'); + return; + } + host.showStatus(formatAdditionalDirsStatus(additionalDirs)); + return; + } + + if (session === undefined) { + // The path-adding form needs a live session; lazy-create it on first use + // (the read-only `list`/bare forms above tolerate a missing session). + session = await host.ensureSession(); + if (session === undefined) return; + // A first prompt may have started a turn during the await; /add-dir is + // idle-only, so re-check the busy gate resolved before it. + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage('add-dir', busyReason)); + return; + } + } + + host.mountEditorReplacement( + new ChoicePickerComponent({ + title: `Add directory to workspace: ${input}`, + hint: '↑↓ navigate · Enter confirm · Esc cancel', + options: [ + { + value: 'session', + label: 'Yes, for this session', + }, + { + value: 'remember', + label: 'Yes, and remember this directory', + }, + { + value: 'cancel', + label: 'No', + }, + ], + onSelect: (value) => { + void handleAddDirChoice(host, session.id, input, value as AddDirChoice); + }, + onCancel: () => { + host.restoreEditor(); + host.showStatus(`Did not add ${input} as a working directory.`); + }, + }), + ); +} + +function formatAdditionalDirsStatus(additionalDirs: readonly string[]): string { + return ['Additional directories:', ...additionalDirs.map((dir) => ` ${dir}`)].join('\n'); +} + +async function handleAddDirChoice( + host: SlashCommandHost, + sessionId: string, + path: string, + choice: AddDirChoice, +): Promise<void> { + host.restoreEditor(); + + if (choice === 'cancel') { + host.showStatus(`Did not add ${path} as a working directory.`); + return; + } + + const session = host.session; + if (session === undefined || session.id !== sessionId) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + try { + const result = await session.addAdditionalDir(path, { persist: choice === 'remember' }); + host.setAppState({ additionalDirs: result.additionalDirs }); + host.refreshSlashCommandAutocomplete(); + host.showStatus( + choice === 'remember' + ? `Added workspace directory:\n ${path}\n Saved to:\n ${result.configPath}` + : `Added workspace directory:\n ${path}\n For this session only`, + 'success', + ); + } catch (error) { + host.showError(error instanceof Error ? error.message : String(error)); + } +} diff --git a/apps/kimi-code/src/tui/commands/auth.ts b/apps/kimi-code/src/tui/commands/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2a4bfbc7aa3e5e2905899c221a34728deab6ac8 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/auth.ts @@ -0,0 +1,262 @@ +import { + applyOpenPlatformConfig, + fetchOpenPlatformModels, + filterModelsByPrefix, + getOpenPlatformById, + OAuthAccessDeniedError, + OpenPlatformApiError, + type KimiRegion, + type ManagedKimiCodeModelInfo, + type ManagedKimiConfigShape, + type OpenPlatformDefinition, +} from '@moonshot-ai/kimi-code-oauth'; +import { log } from '@moonshot-ai/kimi-code-sdk'; + +import type { ChoiceOption } from '../components/dialogs/choice-picker'; +import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '../constant/kimi-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import { + KIMI_CODE_GLOBAL_PLATFORM_VALUE, + refreshKimiRegion, +} from '#/utils/region'; +import type { LoginProgressSpinnerHandle } from '../types'; +import { + promptApiKey, + promptLogoutProviderSelection, + promptModelSelectionForOpenPlatform, + promptPlatformSelection, +} from './prompts'; +import type { SlashCommandHost } from './dispatch'; + +// --------------------------------------------------------------------------- +// Auth: login / logout +// --------------------------------------------------------------------------- + +export async function handleLoginCommand(host: SlashCommandHost): Promise<void> { + const platformId = await promptPlatformSelection(host); + if (platformId === undefined) return; + + if (platformId === 'kimi-code' || platformId === KIMI_CODE_GLOBAL_PLATFORM_VALUE) { + const region: KimiRegion = platformId === KIMI_CODE_GLOBAL_PLATFORM_VALUE ? 'global' : 'mainland-cn'; + await handleKimiCodeOAuthLogin(host, region); + return; + } + + const platform = getOpenPlatformById(platformId); + if (platform === undefined) return; + await handleOpenPlatformLogin(host, platform); +} + +async function handleKimiCodeOAuthLogin( + host: SlashCommandHost, + region: KimiRegion, +): Promise<void> { + const status = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); + const alreadyLoggedIn = status.providers.some( + (provider) => provider.providerName === DEFAULT_OAUTH_PROVIDER_NAME && provider.hasToken, + ); + + let spinner: LoginProgressSpinnerHandle | undefined; + const controller = new AbortController(); + const cancelLogin = (): void => { + controller.abort(); + }; + host.cancelInFlight = cancelLogin; + try { + // The facade maps region → profile hosts (env overrides keep priority); + // 'mainland-cn' is passed explicitly too so switching back overrides a + // persisted global login. + await host.harness.auth.login(DEFAULT_OAUTH_PROVIDER_NAME, { + signal: controller.signal, + region, + onDeviceCode: (data) => { + spinner = host.showLoginAuthorizationPrompt(data); + }, + }); + refreshKimiRegion(); + spinner?.stop({ ok: true, label: 'Logged in.' }); + spinner = undefined; + try { + await host.authFlow.refreshConfigAfterLogin(); + } catch (refreshError) { + const message = formatErrorMessage(refreshError); + host.showError(`Authentication successful, but failed to refresh config: ${message}`); + return; + } + host.track('login', { + provider: DEFAULT_OAUTH_PROVIDER_NAME, + method: 'oauth', + already_logged_in: alreadyLoggedIn, + }); + if (alreadyLoggedIn) { + host.showStatus('Already logged in. Model configuration refreshed.', 'success'); + } + } catch (error) { + const cancelled = controller.signal.aborted; + const denied = error instanceof OAuthAccessDeniedError; + spinner?.stop({ + ok: false, + label: cancelled || denied ? 'Login cancelled.' : 'Login failed.', + }); + spinner = undefined; + if (cancelled) return; + const message = formatErrorMessage(error); + if (denied) { + host.showError(`Login cancelled: ${message}`); + return; + } + log.warn('login failed', { + providerName: DEFAULT_OAUTH_PROVIDER_NAME, + alreadyLoggedIn, + sessionId: host.session?.id, + error, + }); + host.showError(`Login failed: ${message}`); + } finally { + if (host.cancelInFlight === cancelLogin) { + host.cancelInFlight = undefined; + } + } +} + +async function handleOpenPlatformLogin( + host: SlashCommandHost, + platform: OpenPlatformDefinition, +): Promise<void> { + const consoleHost = platform.consoleUrl?.replace(/^https?:\/\//, '') ?? ''; + const platformName = consoleHost.length > 0 ? `Kimi Platform (${consoleHost})` : 'Kimi Platform'; + const subtitleLines = [ + `${'base_url'.padEnd(12)}${platform.baseUrl}`, + `${'saved to'.padEnd(12)}~/.kimi-code/config.toml`, + ]; + const apiKey = await promptApiKey(host, platformName, subtitleLines); + if (apiKey === undefined) return; + + const controller = new AbortController(); + const cancelLogin = (): void => { + controller.abort(); + }; + host.cancelInFlight = cancelLogin; + + let models: ManagedKimiCodeModelInfo[]; + try { + models = await fetchOpenPlatformModels(platform, apiKey, fetch, controller.signal); + models = filterModelsByPrefix(models, platform); + } catch (error) { + if (controller.signal.aborted) return; + const msg = formatErrorMessage(error); + host.showError(`Failed to verify API key: ${msg}`); + if ( + error instanceof OpenPlatformApiError && + error.status === 401 + ) { + host.showStatus( + 'Hint: If your API key was obtained from Kimi Code, please select "Kimi Code" instead.', + ); + } + return; + } finally { + if (host.cancelInFlight === cancelLogin) { + host.cancelInFlight = undefined; + } + } + + if (models.length === 0) { + host.showError('No models available for this platform.'); + return; + } + + const selection = await promptModelSelectionForOpenPlatform(host, models, platform); + if (selection === undefined) return; + + const existingConfig = await host.harness.getConfig(); + if (existingConfig.providers[platform.id] !== undefined) { + await host.harness.removeProvider(platform.id); + } + + const config = await host.harness.getConfig(); + applyOpenPlatformConfig(config as ManagedKimiConfigShape, { + platform, + models, + selectedModel: selection.model, + thinking: selection.thinking !== 'off', + effort: + selection.thinking !== 'off' && selection.thinking !== 'on' + ? selection.thinking + : undefined, + apiKey, + }); + + await host.harness.setConfig({ + providers: config.providers, + models: config.models, + defaultModel: config.defaultModel, + thinking: config.thinking, + }); + + await host.authFlow.refreshConfigAfterLogin(); + host.track('login', { provider: platform.id, method: 'api_key' }); + host.showStatus(`Setup complete: ${platform.name} · ${selection.model.id}`); +} + +export async function handleLogoutCommand(host: SlashCommandHost): Promise<void> { + const oauthStatus = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); + const hasOAuthToken = oauthStatus.providers.some( + (p) => p.providerName === DEFAULT_OAUTH_PROVIDER_NAME && p.hasToken, + ); + const config = await host.harness.getConfig(); + const hasManagedRemnant = + hasOAuthToken || config.providers[DEFAULT_OAUTH_PROVIDER_NAME] !== undefined; + const apiKeyProviderIds = Object.keys(config.providers ?? {}) + .filter((id) => id !== DEFAULT_OAUTH_PROVIDER_NAME) + .toSorted(); + + const options: ChoiceOption[] = []; + if (hasManagedRemnant) { + options.push({ + value: DEFAULT_OAUTH_PROVIDER_NAME, + label: PRODUCT_NAME, + description: 'OAuth login', + }); + } + for (const id of apiKeyProviderIds) { + const baseUrl = config.providers[id]?.baseUrl; + options.push({ + value: id, + label: id, + description: typeof baseUrl === 'string' && baseUrl.length > 0 ? baseUrl : undefined, + }); + } + + if (options.length === 0) { + host.showStatus('Nothing to logout.'); + return; + } + + const currentModel = host.state.appState.model.trim(); + const currentProvider = host.state.appState.availableModels[currentModel]?.provider; + + const target = await promptLogoutProviderSelection(host, options, currentProvider); + if (target === undefined) return; + + if (target === DEFAULT_OAUTH_PROVIDER_NAME) { + await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME); + } else { + await host.harness.removeProvider(target); + } + + if (target === currentProvider) { + await host.authFlow.refreshConfigAfterLogout(); + } else { + const updated = await host.harness.getConfig({ reload: true }); + host.setAppState({ + availableModels: updated.models ?? {}, + availableProviders: updated.providers ?? {}, + }); + } + refreshKimiRegion(); + + host.track('logout', { provider: target }); + const label = target === DEFAULT_OAUTH_PROVIDER_NAME ? PRODUCT_NAME : target; + host.showStatus(`Logged out from ${label}.`); +} diff --git a/apps/kimi-code/src/tui/commands/btw.ts b/apps/kimi-code/src/tui/commands/btw.ts new file mode 100644 index 0000000000000000000000000000000000000000..a36c3e018cdecb528b9ffa8d19af287f2defea5a --- /dev/null +++ b/apps/kimi-code/src/tui/commands/btw.ts @@ -0,0 +1,28 @@ +import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import { extractInlineSkillActivations } from '../utils/inline-skill-tokens'; +import type { SlashCommandHost } from './dispatch'; + +export async function handleBtwCommand(host: SlashCommandHost, args: string): Promise<void> { + const prompt = args.trim(); + const session = host.session; + if (host.state.appState.model.trim().length === 0 || session === undefined) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + host.btwPanelController.closeOrCancel(); + + try { + const agentId = await session.startBtw(); + const activations = extractInlineSkillActivations(prompt, host.skillCommandMap, { + includeLeading: true, + }); + host.btwPanelController.open( + agentId, + prompt, + activations.length > 0 ? activations : undefined, + ); + } catch (error) { + host.showError(`Failed to start /btw: ${formatErrorMessage(error)}`); + } +} diff --git a/apps/kimi-code/src/tui/commands/complete-args.ts b/apps/kimi-code/src/tui/commands/complete-args.ts new file mode 100644 index 0000000000000000000000000000000000000000..e377c9bb03f024084e552c1c7ff1ae3c698153a2 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/complete-args.ts @@ -0,0 +1,41 @@ +import type { AutocompleteItem } from '@moonshot-ai/pi-tui'; + +/** + * A completable token (subcommand or flag) for a slash command's argument + * position. Generic across commands — any `KimiSlashCommand` can build a + * `getArgumentCompletions` from a list of these via {@link completeLeadingArg}. + */ +export interface ArgCompletionSpec { + /** The token inserted on completion, e.g. `pause` or `resume`. */ + readonly value: string; + /** Short description shown in the autocomplete menu. */ + readonly description: string; +} + +/** + * Generic leading-token completer for slash-command arguments. + * + * pi-tui passes `argumentPrefix` = everything typed after `/<command> `. We only + * complete the *first* token: once the user has typed a space after it (moved on + * to an objective, a flag value, etc.) we return `null` so completion never + * clobbers free text. Matching is case-insensitive prefix match on `value`. + */ +export function completeLeadingArg( + specs: readonly ArgCompletionSpec[], + argumentPrefix: string, +): AutocompleteItem[] | null { + if (argumentPrefix.includes(' ')) return null; + const lower = argumentPrefix.toLowerCase(); + const items = specs + .filter((spec) => spec.value.toLowerCase().startsWith(lower)) + .map((spec) => ({ value: spec.value, label: spec.value, description: spec.description })); + // Nothing left to complete: the user has finished typing a token that is the + // sole remaining match (e.g. `status`). Keeping the menu open here would make + // Enter confirm the no-op completion instead of submitting the command, so we + // suppress it. (A space after the token already returns null above.) + const [only] = items; + if (items.length === 1 && only !== undefined && only.value.toLowerCase() === lower) { + return null; + } + return items.length > 0 ? items : null; +} diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..c35be4dbe8b99d673a009e6dc65cf84a03760dec --- /dev/null +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -0,0 +1,912 @@ +import { + effectiveModelAlias, + PRIMARY_SUBAGENT_MODEL_CHOICE, + SECONDARY_DERIVED_MODEL_ALIAS, + type ExperimentalFeatureState, + type ModelAlias, + type PermissionMode, + type Session, + type ThinkingEffort, +} from '@moonshot-ai/kimi-code-sdk'; + +import { EditorSelectorComponent } from '../components/dialogs/editor-selector'; +import { EffortSelectorComponent } from '../components/dialogs/effort-selector'; +import { + ExperimentsSelectorComponent, + type ExperimentalFeatureDraftChange, +} from '../components/dialogs/experiments-selector'; +import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selector'; +import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; +import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; +import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; +import { SurveyPreferenceSelectorComponent } from '../components/dialogs/survey-preference-selector'; +import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; +import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector'; +import { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig } from '../config'; +import type { ThemeName } from '#/tui/theme'; +import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme'; +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import { PERMISSION_MODE_DESCRIPTIONS, PERMISSION_MODE_DISPLAY_NAMES } from '../utils/permission-mode'; +import { thinkingEffortToConfig } from '../utils/thinking-config'; +import { showUsage } from './info'; +import { setExperimentalFeatures } from './experimental-flags'; +import type { SlashCommandHost } from './dispatch'; + +// --------------------------------------------------------------------------- +// Plan / Config commands +// --------------------------------------------------------------------------- + +const MODEL_PICKER_REFRESH_TIMEOUT_MS = 2_000; + +const MODEL_SWITCH_CACHE_WARNING = + 'Note: Switching models invalidates the existing prompt cache. Use /new to avoid extra token costs.'; +const EFFORT_SWITCH_CACHE_WARNING = + 'Note: Switching effort invalidates the existing prompt cache. Use /new to avoid extra token costs.'; + +/** True once the conversation has at least one user message: a switch from + * then on resends the accumulated context, losing the cache. Shell-command + * echoes are also 'user' transcript entries but carry an empty `bullet`, so + * they're excluded. */ +function hasConversationHistory(host: SlashCommandHost): boolean { + return host.state.transcriptEntries.some( + (entry) => entry.kind === 'user' && entry.bullet !== '', + ); +} + +export function currentTuiConfig(host: Pick<SlashCommandHost, 'state'>): TuiConfig { + return { + theme: host.state.appState.theme, + editorCommand: host.state.appState.editorCommand, + disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + renderLatex: host.state.appState.renderLatex ?? DEFAULT_TUI_CONFIG.renderLatex ?? true, + cacheExpiryHint: host.state.appState.cacheExpiryHint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, + disableFeedbackSurvey: + host.state.appState.disableFeedbackSurvey ?? DEFAULT_TUI_CONFIG.disableFeedbackSurvey, + notifications: host.state.appState.notifications, + upgrade: host.state.appState.upgrade, + statusLine: host.state.appState.statusLine ?? DEFAULT_TUI_CONFIG.statusLine, + }; +} + +export function effectiveModelForHost(host: SlashCommandHost, model: ModelAlias): ModelAlias { + const providerType = host.state.appState.availableProviders[model.provider]?.type; + // Flat models (no named provider, e.g. inline base_url served by a v2 + // backend) have no provider entry to look up; their own protocol declaration + // plays the provider-identity role, mirroring the resolver. + return effectiveModelAlias(model, providerType ?? model.protocol); +} + +export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise<void> { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const subcmd = args.trim().toLowerCase(); + if (subcmd === 'clear') { + await session.clearPlan(); + host.showNotice('Plan cleared'); + return; + } + + let enabled: boolean; + if (subcmd.length === 0) enabled = !host.state.appState.planMode; + else if (subcmd === 'on') enabled = true; + else if (subcmd === 'off') enabled = false; + else { + host.showError(`Unknown plan subcommand: ${subcmd}`); + return; + } + + // The session may already be in the requested mode (e.g. it was created + // with config.defaultPlanMode applied), and re-entering plan mode throws. + if (host.state.appState.planMode === enabled) { + host.showNotice(`Plan mode is already ${enabled ? 'on' : 'off'}`); + return; + } + + await applyPlanMode(host, session, enabled); +} + +async function applyPlanMode(host: SlashCommandHost, session: Session, enabled: boolean): Promise<void> { + try { + await session.setPlanMode(enabled); + host.setAppState({ planMode: enabled }); + if (enabled) { + const plan = await session.getPlan().catch(() => null); + host.showNotice( + 'Plan mode: ON', + plan?.path !== undefined ? `Plan will be created here: ${plan.path}` : undefined, + ); + return; + } + host.showNotice('Plan mode: OFF'); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to set plan mode: ${msg}`); + } +} + +export async function handleCompactCommand(host: SlashCommandHost, args: string): Promise<void> { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + const customInstruction = args.trim() || undefined; + await session.compact({ instruction: customInstruction }); +} + +export async function handleEditorCommand(host: SlashCommandHost, args: string): Promise<void> { + const command = args.trim(); + if (command.length === 0) { + showEditorPicker(host); + return; + } + await applyEditorChoice(host, command); +} + +export async function handleThemeCommand(host: SlashCommandHost, args: string): Promise<void> { + const theme = args.trim(); + if (theme.length === 0) { + showThemePicker(host); + return; + } + if (!isBuiltInTheme(theme)) { + const custom = await loadCustomThemeMerged(theme); + if (custom === null) { + host.showError(`Unknown theme: ${theme}`); + return; + } + } + await applyThemeChoice(host, theme); +} + +export async function handleModelCommand(host: SlashCommandHost, args: string): Promise<void> { + const alias = args.trim(); + await refreshModelsForPicker(host); + if (alias.length === 0) { + showModelPicker(host); + return; + } + if (host.state.appState.availableModels[alias] === undefined) { + host.showError(`Unknown model alias: ${alias}`); + return; + } + showModelPicker(host, alias); +} + +export async function handleSecondaryModelCommand(host: SlashCommandHost, args: string): Promise<void> { + const alias = args.trim(); + await refreshModelsForPicker(host); + const models = pickerModelsForHost(host); + // The pool reserves `primary` as the symbolic "caller's own model" choice — + // a user alias with that name can never be the subagent default. + delete models[PRIMARY_SUBAGENT_MODEL_CHOICE]; + if (alias === PRIMARY_SUBAGENT_MODEL_CHOICE) { + host.showError( + `"${PRIMARY_SUBAGENT_MODEL_CHOICE}" is reserved by the subagent model pool (it always binds the caller's own model) — rename the [models] alias to use it here.`, + ); + return; + } + if (Object.keys(models).length === 0) { + host.showNotice( + 'No models configured', + 'Run /login to sign in to Kimi, or /provider to add another provider from a model catalog.', + ); + return; + } + if (alias.length > 0 && models[alias] === undefined) { + host.showError(`Unknown model alias: ${alias}`); + return; + } + const secondary = (await host.harness.getConfig()).secondaryModel; + // The v2 engine honors a lone legacy `model` key as the fallback pool + // default — reflect it as the picker's current value. + const current = secondary?.defaultModel ?? secondary?.model ?? ''; + showSecondaryModelPicker(host, models, current, alias.length > 0 ? alias : undefined); +} + +export async function handleEffortCommand(host: SlashCommandHost, args: string): Promise<void> { + const alias = host.state.appState.model; + const model = host.state.appState.availableModels[alias]; + if (model === undefined) { + host.showError('No model selected. Run /model to select one first.'); + return; + } + const effective = effectiveModelForHost(host, model); + const segments = segmentsFor(effective); + const arg = args.trim().toLowerCase(); + if (arg.length === 0) { + showEffortPicker(host, effective, segments); + return; + } + if (!segments.includes(arg)) { + const providerType = host.state.appState.availableProviders[effective.provider]?.type; + const protocol = effective.protocol ?? providerType; + if (protocol !== 'anthropic') { + host.showError( + `Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`, + ); + return; + } + const knownEfforts = effective.supportEfforts?.join(', ') ?? 'none declared'; + host.showStatus( + `Thinking effort "${arg}" is not listed for ${alias} (known: ${knownEfforts}). Sending "${arg}" unchanged; the configured provider will validate it.`, + 'warning', + ); + } + await performModelSwitch(host, alias, arg, true); +} + +function showEffortPicker( + host: SlashCommandHost, + model: ModelAlias, + segments: readonly string[], +): void { + const liveEffort = host.state.appState.thinkingEffort; + const currentValue = segments.includes(liveEffort) ? liveEffort : (segments[0] ?? 'off'); + const alias = host.state.appState.model; + host.mountEditorReplacement( + new EffortSelectorComponent({ + efforts: segments, + currentValue, + warning: hasConversationHistory(host) ? EFFORT_SWITCH_CACHE_WARNING : undefined, + onSelect: (effort) => { + host.restoreEditor(); + void performModelSwitch(host, alias, effort, true); + }, + onSessionOnlySelect: (effort) => { + host.restoreEditor(); + void performModelSwitch(host, alias, effort, false); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +// --------------------------------------------------------------------------- +// Pickers & config apply +// --------------------------------------------------------------------------- + +function showEditorPicker(host: SlashCommandHost): void { + const currentValue = host.state.appState.editorCommand ?? ''; + host.mountEditorReplacement( + new EditorSelectorComponent({ + currentValue, + onSelect: (value) => { + host.restoreEditor(); + void applyEditorChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +async function refreshModelsForPicker(host: SlashCommandHost): Promise<void> { + try { + const result = await withTimeout( + host.authFlow.refreshOAuthProviderModels(), + MODEL_PICKER_REFRESH_TIMEOUT_MS, + ); + if (result === undefined) return; + for (const f of result.failed) { + host.showStatus(`Skipped refreshing ${f.provider}: ${f.reason}`, 'warning'); + } + } catch (error) { + host.showStatus(`Skipped refreshing models: ${formatErrorMessage(error)}`, 'warning'); + } +} + +async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined> { + let timeout: ReturnType<typeof setTimeout> | undefined; + try { + return await Promise.race([ + promise, + new Promise<undefined>((resolve) => { + timeout = setTimeout(() => { + resolve(undefined); + }, timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + +async function applyEditorChoice(host: SlashCommandHost, value: string): Promise<void> { + const previous = host.state.appState.editorCommand ?? ''; + if (value === previous && value.length > 0) { + host.showStatus(`Editor unchanged: ${value.length > 0 ? value : 'auto-detect'}`); + return; + } + + const editorCommand = value.length > 0 ? value : null; + try { + await saveTuiConfig({ + ...currentTuiConfig(host), + editorCommand, + }); + } catch (error) { + host.showStatus( + `Failed to save editor: ${formatErrorMessage(error)}`, + 'error', + ); + return; + } + + host.setAppState({ editorCommand }); + host.showStatus( + value.length > 0 + ? `Editor set to "${value}".` + : 'Editor set to auto-detect ($VISUAL / $EDITOR).', + ); +} + +/** + * The models a picker may offer: the user's configured aliases with + * host-effective provider resolution applied, minus the synthesized + * `__secondary__` derived entry — a runtime artifact of the v1 engine's + * `[secondary_model]` recipe that must never be selectable as a model. + */ +function pickerModelsForHost(host: SlashCommandHost): Record<string, ModelAlias> { + return Object.fromEntries( + Object.entries(host.state.appState.availableModels) + .filter(([alias]) => alias !== SECONDARY_DERIVED_MODEL_ALIAS) + .map(([alias, model]) => [alias, effectiveModelForHost(host, model)]), + ); +} + +export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void { + const models = pickerModelsForHost(host); + const entries = Object.entries(models); + if (entries.length === 0) { + host.showNotice( + 'No models configured', + 'Run /login to sign in to Kimi, or /provider to add another provider from a model catalog.', + ); + return; + } + host.mountEditorReplacement( + new TabbedModelSelectorComponent({ + models, + currentValue: host.state.appState.model, + selectedValue, + currentThinkingEffort: host.state.appState.thinkingEffort, + warning: hasConversationHistory(host) ? MODEL_SWITCH_CACHE_WARNING : undefined, + onSelect: ({ alias, thinking }) => { + host.restoreEditor(); + void performModelSwitch(host, alias, thinking, true); + }, + onSessionOnlySelect: ({ alias, thinking }) => { + host.restoreEditor(); + void performModelSwitch(host, alias, thinking, false); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +async function performModelSwitch( + host: SlashCommandHost, + alias: string, + effort: ThinkingEffort, + persist: boolean, +): Promise<void> { + let session = host.session; + if (session === undefined) { + // A first prompt may still be inside lazy creation: wait it out so the + // switch lands on the new session instead of being overwritten by its + // assembly. + await host.waitForLazyCreation(); + session = host.session; + } + if (host.state.appState.streamingPhase !== 'idle') { + host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); + return; + } + + const prevModel = host.state.appState.model; + const prevEffort = host.state.appState.thinkingEffort; + const modelChanged = alias !== prevModel; + const effortChanged = effort !== prevEffort; + const runtimeChanged = modelChanged || effortChanged; + let effectiveAlias = alias; + let effectiveEffort = effort; + + try { + if (session === undefined && runtimeChanged) { + await host.authFlow.activateModelAfterLogin(alias, effort); + } else if (session !== undefined) { + if (alias !== prevModel) { + await session.setModel(alias); + } + if (effort !== prevEffort) { + await session.setThinking(effort); + } + const status = await session.getStatus(); + effectiveAlias = status.model ?? alias; + effectiveEffort = status.thinkingEffort; + } + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to switch model: ${msg}`); + return; + } + + if (session === undefined) { + effectiveAlias = host.state.appState.model; + effectiveEffort = host.state.appState.thinkingEffort; + } + const effectiveModelChanged = effectiveAlias !== prevModel; + const effectiveEffortChanged = effectiveEffort !== prevEffort; + const displayName = modelDisplayName( + effectiveAlias, + host.state.appState.availableModels[effectiveAlias], + ); + host.setAppState({ model: effectiveAlias, thinkingEffort: effectiveEffort }); + if (session === undefined && runtimeChanged) { + if (effectiveModelChanged) { + host.track('model_switch', { model: effectiveAlias }); + } + if (effectiveEffortChanged) { + host.track('thinking_toggle', { + enabled: effectiveEffort !== 'off', + effort: effectiveEffort, + from: prevEffort, + }); + } + } + + let persisted = false; + if (persist) { + try { + persisted = await persistModelSelection( + host, + effectiveAlias, + effectiveEffort, + effectiveEffortChanged, + ); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`); + return; + } + } + + let status: string; + if (effectiveModelChanged) { + status = persist + ? `Switched to ${displayName} with thinking ${effectiveEffort}.` + : `Switched to ${displayName} with thinking ${effectiveEffort} for this session only.`; + } else if (effectiveEffortChanged) { + status = persist + ? `Thinking set to ${effectiveEffort}.` + : `Thinking set to ${effectiveEffort} for this session only.`; + } else if (persist && persisted) { + status = `Saved ${displayName} with thinking ${effectiveEffort} as default.`; + } else { + status = `Already using ${displayName} with thinking ${effectiveEffort}.`; + } + host.showStatus(status, 'success'); +} + +async function persistModelSelection( + host: SlashCommandHost, + alias: string, + effort: ThinkingEffort, + effortChanged: boolean, +): Promise<boolean> { + const config = await host.harness.getConfig({ reload: true }); + const model = host.state.appState.availableModels[alias]; + const full = thinkingEffortToConfig( + effort, + model === undefined ? undefined : effectiveModelForHost(host, model), + ); + // Re-confirming the effort shown when the picker opened is not an explicit + // choice — persist the model but leave the stored effort preference alone. + const patch = effortChanged ? full : { enabled: full.enabled }; + if ( + config.defaultModel === alias && + config.thinking?.enabled === patch.enabled && + (!effortChanged || config.thinking?.effort === patch.effort) + ) { + return false; + } + await host.harness.setConfig({ + defaultModel: alias, + thinking: patch, + }); + return true; +} + +// --------------------------------------------------------------------------- +// Secondary model (`/secondary-model`) — persists `[secondary_model] default_model` +// --------------------------------------------------------------------------- + +function showSecondaryModelPicker( + host: SlashCommandHost, + models: Record<string, ModelAlias>, + currentValue: string, + selectedValue?: string, +): void { + host.mountEditorReplacement( + new TabbedModelSelectorComponent({ + models, + currentValue, + selectedValue, + currentThinkingEffort: 'off', + // Subagent pool bindings carry no explicit thinking level, so the picker + // hides the Thinking footer instead of offering a no-op choice. + thinkingControl: false, + title: ' Select a secondary model (subagents)', + onSelect: ({ alias }) => { + host.restoreEditor(); + void performSecondaryModelSave(host, alias); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +/** + * Persists `[secondary_model] default_model`. When a + * `[secondary_model.models]` pool exists and does not list the alias yet, the + * alias is added with an empty description — the engine requires the default + * to be a pool key. Without a pool the default alone forms an implicit + * single-entry pool, so nothing else is written. No live-apply step: the + * engine resolves the pool per spawn, so the next subagent dispatch picks the + * new value up on its own. + */ +async function performSecondaryModelSave(host: SlashCommandHost, alias: string): Promise<void> { + const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); + try { + const config = await host.harness.getConfig({ reload: true }); + const existing = config.secondaryModel?.models; + const patch: { defaultModel: string; models?: Record<string, string> } = { + defaultModel: alias, + }; + if (existing !== undefined) { + patch.models = { ...existing, [alias]: existing[alias] ?? '' }; + } + await host.harness.setConfig({ secondaryModel: patch }); + } catch (error) { + host.showError(`Failed to save secondary model: ${formatErrorMessage(error)}`); + return; + } + host.showStatus( + `Secondary model set to ${displayName}. Newly spawned subagents will use it by default.`, + 'success', + ); +} + +function showThemePicker(host: SlashCommandHost): void { + host.mountEditorReplacement( + new ThemeSelectorComponent({ + currentValue: host.state.appState.theme, + onSelect: (value) => { + host.restoreEditor(); + void applyThemeChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promise<void> { + if (theme === host.state.appState.theme) { + if (theme === 'auto') host.refreshTerminalThemeTracking(); + host.showStatus(`Theme unchanged: "${theme}".`); + return; + } + + // Validate custom themes up front so a missing / malformed file reports an + // error instead of silently persisting a name that resolves to the dark + // fallback. + if (!isBuiltInTheme(theme)) { + const palette = await loadCustomThemeMerged(theme); + if (palette === null) { + host.showStatus(`Theme "${theme}" could not be loaded.`, 'error'); + return; + } + } + + try { + await saveTuiConfig({ + ...currentTuiConfig(host), + theme, + }); + } catch (error) { + host.showStatus( + `Failed to save theme: ${formatErrorMessage(error)}`, + 'error', + ); + return; + } + + const resolved = theme === 'auto' + ? (currentTheme.palette === lightColors ? 'light' : 'dark') + : undefined; + await host.applyTheme(theme, resolved); + host.refreshTerminalThemeTracking(); + host.track('theme_switch', { theme }); + const detail = theme === 'auto' ? ` (tracking terminal; current: ${resolved})` : ''; + host.showStatus(`Theme set to "${theme}"${detail}.`); +} + +export function showPermissionPicker(host: SlashCommandHost, initialMode?: PermissionMode): void { + host.mountEditorReplacement( + new PermissionSelectorComponent({ + currentValue: host.state.appState.permissionMode, + initialValue: initialMode, + onSelect: (value) => { + host.restoreEditor(); + void applyPermissionChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +export function showUpdatePreferencePicker(host: SlashCommandHost): void { + host.mountEditorReplacement( + new UpdatePreferenceSelectorComponent({ + currentValue: host.state.appState.upgrade.autoInstall, + onSelect: (value) => { + host.restoreEditor(); + void applyUpdatePreferenceChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +export async function showExperimentsPanel(host: SlashCommandHost): Promise<void> { + let features: readonly ExperimentalFeatureState[]; + try { + features = await host.harness.getExperimentalFeatures(); + } catch (error) { + host.showError(`Failed to load experimental features: ${formatErrorMessage(error)}`); + return; + } + mountExperimentsPanel(host, features); +} + +export async function applyExperimentalFeatureChanges( + host: SlashCommandHost, + changes: readonly ExperimentalFeatureDraftChange[], +): Promise<void> { + if (changes.length === 0) { + host.showStatus( + 'No experimental feature changes to apply.', + 'textMuted', + ); + return; + } + + const experimental: Record<string, boolean> = {}; + for (const change of changes) { + experimental[change.id] = change.enabled; + } + + try { + await host.harness.setConfig({ experimental }); + const features = await host.harness.getExperimentalFeatures(); + setExperimentalFeatures(features); + host.refreshSlashCommandAutocomplete(); + host.restoreEditor(); + if (host.session !== undefined && changes.some((change) => change.id !== 'notify_user')) { + const reloadedSession = await host.harness.reloadSession({ id: host.session.id }); + await host.reloadCurrentSessionView( + reloadedSession, + 'Experimental features updated. Session reloaded.', + ); + } else { + host.showStatus('Experimental features updated.', 'success'); + } + if ( + host.session !== undefined && + changes.some((change) => change.id === 'notify_user' && change.enabled) + ) { + host.showNotice( + 'Start a new session to use Updates if this session was created with the feature disabled.', + ); + } + if (changes.some((change) => change.id === 'tower')) { + // TowerFeature assembles its tool/profile contributions once at App + // scope construction, so a live flag flip cannot install or retract + // them; only the mode machinery (enter/injection/guards) reacts live. + host.showNotice('Tower mode takes effect after restarting Kimi Code.'); + } + host.track('experimental_features_apply', { + changed: changes.length, + flags: features + .filter((feature) => feature.enabled) + .map((feature) => feature.id) + .toSorted() + .join(','), + }); + } catch (error) { + host.showError(`Failed to update experimental features: ${formatErrorMessage(error)}`); + } +} + +function mountExperimentsPanel( + host: SlashCommandHost, + features: readonly ExperimentalFeatureState[], +): void { + host.mountEditorReplacement( + new ExperimentsSelectorComponent({ + features, + onApply: (changes) => { + void applyExperimentalFeatureChanges(host, changes); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +type UpdatePreferenceHost = { + readonly state: { + readonly appState: Pick< + SlashCommandHost['state']['appState'], + 'theme' | 'editorCommand' | 'notifications' | 'upgrade' + >; + }; + setAppState(patch: Pick<SlashCommandHost['state']['appState'], 'upgrade'>): void; + showStatus(msg: string, color?: string): void; + track: SlashCommandHost['track']; +}; + +export async function applyUpdatePreferenceChoice( + host: UpdatePreferenceHost, + autoInstall: boolean, +): Promise<void> { + if (autoInstall === host.state.appState.upgrade.autoInstall) { + host.showStatus(`Automatic updates already ${autoInstall ? 'enabled' : 'disabled'}.`); + return; + } + + const upgrade = { autoInstall }; + try { + await saveTuiConfig({ + ...currentTuiConfig(host as unknown as SlashCommandHost), + upgrade, + }); + } catch (error) { + host.showStatus( + `Failed to save automatic update setting: ${formatErrorMessage(error)}`, + 'error', + ); + return; + } + + host.setAppState({ upgrade }); + host.track('upgrade_preference_changed', { auto_install: autoInstall }); + host.showStatus(`Automatic updates ${autoInstall ? 'enabled' : 'disabled'}.`); +} + +async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMode): Promise<void> { + if (mode === host.state.appState.permissionMode) { + host.showStatus(`Permission mode unchanged: ${PERMISSION_MODE_DISPLAY_NAMES[mode]}.`); + return; + } + + try { + if (host.session !== undefined) { + await host.session.setPermission(mode); + } + // v2 session-less: the chosen mode is recorded in appState and passed to + // the lazy-created session. + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to set permission mode: ${msg}`); + return; + } + + host.setAppState({ permissionMode: mode }); + host.showNotice(`Permission mode: ${PERMISSION_MODE_DISPLAY_NAMES[mode]}`); + if (mode !== 'manual') { + host.showStatus(PERMISSION_MODE_DESCRIPTIONS[mode], 'warning'); + } +} + +export function showSurveyPreferencePicker(host: SlashCommandHost): void { + host.mountEditorReplacement( + new SurveyPreferenceSelectorComponent({ + currentValue: host.state.appState.disableFeedbackSurvey !== true, + onSelect: (value) => { + host.restoreEditor(); + void applySurveyPreferenceChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +type SurveyPreferenceHost = { + readonly state: { + readonly appState: Pick< + SlashCommandHost['state']['appState'], + 'theme' | 'editorCommand' | 'notifications' | 'upgrade' | 'disableFeedbackSurvey' + >; + }; + setAppState( + patch: Pick<SlashCommandHost['state']['appState'], 'disableFeedbackSurvey'>, + ): void; + showStatus(msg: string, color?: string): void; +}; + +export async function applySurveyPreferenceChoice( + host: SurveyPreferenceHost, + enabled: boolean, +): Promise<void> { + const disableFeedbackSurvey = !enabled; + if (disableFeedbackSurvey === (host.state.appState.disableFeedbackSurvey === true)) { + host.showStatus(`Feedback survey already ${enabled ? 'enabled' : 'disabled'}.`); + return; + } + + try { + await saveTuiConfig({ + ...currentTuiConfig(host as unknown as SlashCommandHost), + disableFeedbackSurvey, + }); + } catch (error) { + host.showStatus( + `Failed to save session rating setting: ${formatErrorMessage(error)}`, + 'error', + ); + return; + } + + host.setAppState({ disableFeedbackSurvey }); + host.showStatus(`Feedback survey ${enabled ? 'enabled' : 'disabled'}.`); +} + +export function showSettingsSelector(host: SlashCommandHost): void { + host.mountEditorReplacement( + new SettingsSelectorComponent({ + onSelect: (value) => { + handleSettingsSelection(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelection): void { + host.restoreEditor(); + switch (value) { + case 'model': showModelPicker(host); return; + case 'permission': showPermissionPicker(host); return; + case 'theme': showThemePicker(host); return; + case 'editor': showEditorPicker(host); return; + case 'survey': showSurveyPreferencePicker(host); return; + case 'experiments': void showExperimentsPanel(host); return; + case 'upgrade': showUpdatePreferencePicker(host); return; + case 'usage': void showUsage(host); return; + } +} diff --git a/apps/kimi-code/src/tui/commands/copy.ts b/apps/kimi-code/src/tui/commands/copy.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb77b25155694cbedbb5275d61f28a7f3db4cfa9 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/copy.ts @@ -0,0 +1,41 @@ +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; +import type { TranscriptEntry } from '../types'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +/** + * Visible text of the last assistant transcript entry, newest first; empty + * string when none. Sourced from the rendered transcript rather than the + * model context so it survives compaction and session resume: after + * `/compact` the context keeps user messages plus a user-role summary only, + * while the last reply is still on screen. Only entries tagged `modelText` + * count — hook-result and goal-completion cards share kind 'assistant' but + * are not replies. + */ +export function findLastAssistantText(entries: readonly TranscriptEntry[]): string { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry === undefined || entry.kind !== 'assistant' || entry.modelText !== true) continue; + if (entry.content.trim().length > 0) return entry.content; + } + return ''; +} + +export async function handleCopyCommand(host: SlashCommandHost): Promise<void> { + const text = findLastAssistantText(host.state.transcriptEntries); + if (text.length === 0) { + host.showStatus('No assistant message to copy.', 'warning'); + return; + } + + try { + const method = await copyTextToClipboard(text); + host.showStatus( + method === 'native' + ? `Copied to clipboard (${String(text.length)} characters).` + : `Copied via terminal escape sequence (unverified, ${String(text.length)} characters).`, + ); + } catch (error) { + host.showError(`Failed to copy to clipboard: ${formatErrorMessage(error)}`); + } +} diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff10df66a512f68e6caf76d933f94176873a39f7 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -0,0 +1,607 @@ +import type { Component, Focusable } from '@moonshot-ai/pi-tui'; +import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; +import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; + +import type { ColorToken, ThemeName } from '#/tui/theme'; + +import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui'; +import type { AuthFlowController } from '../controllers/auth-flow'; +import type { BtwPanelController } from '../controllers/btw-panel'; +import type { StreamingUIController } from '../controllers/streaming-ui'; +import type { TasksBrowserController } from '../controllers/tasks-browser'; +import { tryHandleDanceCommand } from '../easter-eggs/dance'; +import type { ResolvedTheme } from '../theme/colors'; +import type { TUIState } from '../tui-state'; +import type { + AppState, + InlineSkillActivation, + LoginProgressSpinnerHandle, + QueuedMessage, + TranscriptEntry, +} from '../types'; +import { formatErrorMessage } from '../utils/event-payload'; +import { + extractInlineSkillActivations, + findInlineSkillTokens, +} from '../utils/inline-skill-tokens'; +import { handleLoginCommand, handleLogoutCommand } from './auth'; +import { handleBtwCommand } from './btw'; +import { handleCopyCommand } from './copy'; +import { + handleCompactCommand, + handleEditorCommand, + handleEffortCommand, + handleModelCommand, + handlePlanCommand, + handleSecondaryModelCommand, + handleThemeCommand, + showExperimentsPanel, + showModelPicker, + showPermissionPicker, + showSettingsSelector, +} from './config'; +import { handleGoalCommand } from './goal'; +import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; +import { handleAddDirCommand } from './add-dir'; +import { parseSlashInput } from './parse'; +import { handlePluginsCommand } from './plugins'; +import { handleProviderCommand } from './provider'; +import { + findBuiltInSlashCommand, + resolveSlashCommandAvailability, + type BuiltinSlashCommandName, +} from './registry'; +import { handleReloadCommand, handleReloadTuiCommand } from './reload'; +import type { SkillListSession } from './skills'; +import { + canRestoreSubmittedInput, + resolveSlashCommandInput, + slashBusyMessage, + slashCommandBusyReason, +} from './resolve'; +import { + handleExportDebugZipCommand, + handleExportMdCommand, + handleForkCommand, + handleInitCommand, + handleTitleCommand, +} from './session'; +import { handleSwarmCommand } from './swarm'; +import { handleTowerCommand } from './tower'; +import { handleUndoCommand } from './undo'; +import { handleRemoteControlCommand, handleWebCommand } from './web'; + +// --------------------------------------------------------------------------- +// Re-exports — keep existing consumers working +// --------------------------------------------------------------------------- + +export { handleLoginCommand, handleLogoutCommand } from './auth'; +export { handleBtwCommand } from './btw'; +export { handleCopyCommand } from './copy'; +export { handleAddDirCommand } from './add-dir'; +export { + handleCompactCommand, + handleEditorCommand, + handleEffortCommand, + handleModelCommand, + handlePlanCommand, + handleSecondaryModelCommand, + handleThemeCommand, + showModelPicker, + showExperimentsPanel, + showPermissionPicker, + showSettingsSelector, +} from './config'; +export { handleSwarmCommand } from './swarm'; +export { handleTowerCommand } from './tower'; +export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; +export { handlePluginsCommand } from './plugins'; +export { handleReloadCommand, handleReloadTuiCommand } from './reload'; +export { handleGoalCommand } from './goal'; +export { + handleExportDebugZipCommand, + handleExportMdCommand, + handleForkCommand, + handleInitCommand, + handleTitleCommand, +} from './session'; +export { handleUndoCommand } from './undo'; +export { handleRemoteControlCommand, handleWebCommand } from './web'; + +// --------------------------------------------------------------------------- +// Host interface +// --------------------------------------------------------------------------- + +export interface SlashCommandHost { + state: TUIState; + session: Session | undefined; + readonly harness: KimiHarness; + cancelInFlight: (() => void) | undefined; + deferUserMessages: boolean; + + setAppState(patch: Partial<AppState>): void; + resetLivePane(): void; + showError(msg: string): void; + showStatus(msg: string, color?: ColorToken): void; + showNotice(title: string, detail?: string): void; + appendTranscriptEntry(entry: TranscriptEntry): void; + track(event: string, props?: Record<string, unknown>): void; + mountEditorReplacement(panel: Component & Focusable): void; + restoreEditor(): void; + restoreInputText(text: string): void; + refreshSlashCommandAutocomplete(): void; + /** + * Rebuild the plugin slash-command list. With no session (v2 session-less + * startup) this reads the app-global plugin commands instead, so `/plugins` + * mutations apply before the first session exists. + */ + refreshPluginCommands(session?: Session): Promise<void>; + /** + * Rebuild the skill slash-command list. With no session (v2 session-less + * startup) this reads the workspace skills instead. + */ + refreshSkillCommands(session?: SkillListSession): Promise<void>; + /** + * Seed appState with the config defaults the v2 engine would apply at + * createSession time (model, permission, plan mode, thinking effort, + * context cap). No-op semantics on a live session path: only /reload calls + * it while still session-less. + */ + hydrateLazyConfigDefaults(): Promise<void>; + + // Session + requireSession(): Session; + /** + * Lazy-create the session on first use (v2 engine). Returns the existing + * session, or undefined (with the error already surfaced) when creation + * fails. + */ + ensureSession(): Promise<Session | undefined>; + /** Await the in-flight lazy session creation, if any (v2); no-op otherwise. */ + waitForLazyCreation(): Promise<void>; + switchToSession(session: Session, message: string): Promise<void>; + reloadCurrentSessionView(session: Session, message: string): Promise<void>; + beginSessionRequest(): void; + failSessionRequest(message: string): void; + sendQueuedMessage(session: Session, item: QueuedMessage): void; + requestQueuedGoalPromotion?(): void; + /** Reset the client-side cache-break baseline after the context was cut + * (/undo): the next step's cache-read drop is expected, not a break. */ + noteContextCut?(): void; + + // UI + showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle; + showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle; + showProgressSpinner(label: string): LoginProgressSpinnerHandle; + + // Theme + applyTheme(theme: ThemeName, resolved?: ResolvedTheme): Promise<void>; + refreshTerminalThemeTracking(): void; + + // Dispatch + stop(exitCode?: number): Promise<void>; + setExitOpenUrl(url: string): void; + /** + * Register a task that takes over the process after the TUI has shut down + * (instead of exiting): the runner awaits it and only exits when it returns. + * Used by `/web` to keep a freshly started server attached to this terminal + * until Ctrl+C. + */ + setExitForegroundTask(task: (exitCode: number) => Promise<void>): void; + showHelpPanel(): void; + createNewSession(): Promise<void>; + showSessionPicker(): Promise<void>; + sendNormalUserInput(text: string): void; + /** + * Submit a prompt that explicitly activates one or more skills inline + * (v2 engine only): all activations ride the same submission as the prompt + * and launch as a single turn. + */ + sendInlineSkillUserInput(text: string, activations: readonly InlineSkillActivation[]): Promise<void>; + sendSkillActivation(session: Session, skillName: string, skillArgs: string): void; + activatePluginCommand( + session: Session, + pluginId: string, + commandName: string, + args: string, + ): void; + readonly skillCommandMap: Map<string, string>; + readonly pluginCommandMap: Map<string, string>; + + // Controller refs + readonly streamingUI: StreamingUIController; + readonly btwPanelController: BtwPanelController; + readonly tasksBrowserController: TasksBrowserController; + readonly authFlow: AuthFlowController; +} + +// --------------------------------------------------------------------------- +// Dispatch — entry point from handleUserInput +// --------------------------------------------------------------------------- + +export function dispatchInput(host: SlashCommandHost, text: string): void { + if (parseSlashInput(text) !== null) { + // A leading skill command combined with further inline skill tokens + // (`/skill:a args /skill:b`) is one grouped submission on the v2 engine. + if (dispatchInlineSkillCombo(host, text)) { + return; + } + void executeSlashCommand(host, text); + return; + } + // Inline skill tokens anywhere in a plain prompt activate the skills. + const activations = extractInlineSkillActivations(text, host.skillCommandMap); + if (activations.length > 0) { + void host.sendInlineSkillUserInput(text, activations); + return; + } + host.sendNormalUserInput(text); +} + +/** + * Handle a leading-slash input that may be a bundled submission. Returns true + * when the input was claimed, false when it should fall through to the + * regular single-skill slash path. + * + * Bundle rule: two or more known skill tokens with the first one leading the + * input make the whole input one bundled prompt in which every token + * activates with NO args — the mention is the whole interface, and args stay + * a standalone-activation concept (`/skill:a some args` with no other tokens + * keeps its single-skill path). Tokenization is whitespace-generic, so + * space- and newline-separated bundles behave identically. A recognized + * builtin or plugin command always keeps its own path, no matter how many + * skill tokens its arguments mention. + */ +function dispatchInlineSkillCombo(host: SlashCommandHost, text: string): boolean { + // The intent is parsed without the busy flags on purpose: submissions + // through sendInlineSkillUserInput queue while busy — only genuine + // single-skill commands reject. + const intent = resolveSlashCommandInput({ + input: text, + skillCommandMap: host.skillCommandMap, + pluginCommandMap: host.pluginCommandMap, + isStreaming: false, + isCompacting: false, + }); + if (intent.kind !== 'skill' && intent.kind !== 'message') return false; + + const tokens = findInlineSkillTokens(text, { + isKnownSkill: (commandName) => + host.skillCommandMap.has(commandName) || host.skillCommandMap.has(`skill:${commandName}`), + includeLeading: true, + }); + // The 'message' kind joins the bundle rule because parseSlashInput only + // splits on a literal space: a newline after a leading skill resolves to + // 'message' instead of 'skill', and must not silently drop the leading + // activation. + if (tokens.length >= 2 && tokens[0]!.start === 0) { + const activations = extractInlineSkillActivations(text, host.skillCommandMap, { + includeLeading: true, + }); + void host.sendInlineSkillUserInput(text, activations); + return true; + } + + // An unrecognized leading slash token makes the whole input a plain + // message; scan it for inline skills like any other plain prompt. + if (intent.kind !== 'message') return false; + const activations = extractInlineSkillActivations(text, host.skillCommandMap); + if (activations.length === 0) return false; + void host.sendInlineSkillUserInput(text, activations); + return true; +} + +async function executeSlashCommand(host: SlashCommandHost, input: string): Promise<void> { + const parsedCommand = parseSlashInput(input); + const intent = resolveSlashCommandInput({ + input, + skillCommandMap: host.skillCommandMap, + pluginCommandMap: host.pluginCommandMap, + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + + switch (intent.kind) { + case 'not-command': + return; + case 'blocked': + host.track('input_command_invalid', { reason: 'blocked', command: intent.commandName }); + host.showError(slashBusyMessage(intent.commandName, intent.reason)); + // The editor buffer was already cleared on submit; give the rejected + // command line back so hand-typed input is not lost. + host.restoreInputText(input); + return; + case 'invalid': + host.track('input_command_invalid', { + reason: intent.reason, + command: intent.commandName, + }); + host.showError(`Invalid slash command: /${intent.commandName}`); + return; + case 'skill': { + if (host.state.appState.model.trim().length === 0) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + let session = host.session; + if (session === undefined) { + session = await ensureSessionForCommand(host); + if (session === undefined) return; + // A first prompt may have started a turn while the session was being + // created; skill commands are always busy-gated, so re-check the gate + // resolved before the await. + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage(intent.commandName, busyReason)); + return; + } + } + host.track('input_command', { + command: intent.commandName, + skill_name: intent.skillName, + }); + host.sendSkillActivation(session, intent.skillName, intent.args); + return; + } + case 'plugin-command': { + if (host.state.appState.model.trim().length === 0) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + let session = host.session; + if (session === undefined) { + session = await ensureSessionForCommand(host); + if (session === undefined) return; + // Same busy re-check as the skill path: plugin commands are always + // busy-gated too. + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage(intent.commandName, busyReason)); + return; + } + } + host.track('input_command', { command: `${intent.pluginId}:${intent.commandName}` }); + host.activatePluginCommand(session, intent.pluginId, intent.commandName, intent.args); + return; + } + case 'message': + // Unknown slash command: let /dance claim it before it falls through to + // the model as a normal message. This runs *after* builtin and skill + // resolution, so a real command or a same-named skill always wins. + if (parsedCommand !== null && tryHandleDanceCommand(host, parsedCommand)) { + return; + } + host.sendNormalUserInput(intent.input); + return; + case 'builtin': + host.track('input_command', { command: intent.name }); + if (intent.name === 'new' && parsedCommand?.name === 'clear') { + host.track('clear'); + } + try { + await handleBuiltInSlashCommand(host, intent.name, intent.args, input); + } catch (error) { + host.showError(formatErrorMessage(error)); + } + return; + } +} + +/** + * Lazy-create the session for a slash command that needs one (v2 engine). A + * missing session means the TUI started session-less, so commands create it + * on first use. Returns undefined (error already shown) when creation fails. + */ +async function ensureSessionForCommand(host: SlashCommandHost): Promise<Session | undefined> { + return host.ensureSession(); +} + +/** Builtin commands that need an active session; lazy-created on the v2 engine. */ +const SESSION_REQUIRING_COMMANDS: ReadonlySet<BuiltinSlashCommandName> = new Set([ + 'btw', + 'compact', + 'export-debug-zip', + 'export-md', + 'fork', + 'goal', + 'init', + 'plan', + 'swarm', + 'undo', + 'web', +]); + +async function handleBuiltInSlashCommand( + host: SlashCommandHost, + name: BuiltinSlashCommandName, + args: string, + input: string, +): Promise<void> { + if (host.session === undefined && SESSION_REQUIRING_COMMANDS.has(name)) { + const session = await ensureSessionForCommand(host); + if (session === undefined) { + // Creation failed after submit cleared the buffer; give the input + // back unless the user moved on — a newer draft or an opened panel. + if (canRestoreSubmittedInput(host)) host.restoreInputText(input); + return; + } + // A first prompt may have started a turn while the session was being + // created; re-check the availability gate that was resolved before the + // await (idle-only commands are blocked while a turn is active). + const command = findBuiltInSlashCommand(name); + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if ( + busyReason !== undefined && + command !== undefined && + resolveSlashCommandAvailability(command, args) === 'idle-only' + ) { + host.showError(slashBusyMessage(name, busyReason)); + // Same as the dispatch blocked branch: give the cleared input back, + // guarded the same way — session creation awaited above. + if (canRestoreSubmittedInput(host)) host.restoreInputText(input); + return; + } + } + switch (name) { + case 'exit': + void host.stop(); + return; + case 'help': + host.showHelpPanel(); + return; + case 'version': + host.showStatus(`Kimi Code v${host.state.appState.version}`); + return; + case 'new': { + // A first-use lazy creation may still be in flight: wait it out so /new + // never races a second createSession against the pending prompt. + await host.waitForLazyCreation(); + // The waited-out prompt may have started a turn meanwhile; /new is + // idle-only, so re-run the busy gate resolved before the await. + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage(name, busyReason)); + return; + } + await host.createNewSession(); + host.state.ui.requestRender(); + return; + } + case 'sessions': + void host.showSessionPicker(); + return; + case 'tasks': + void host.tasksBrowserController.show(); + return; + case 'mcp': + void showMcpServers(host); + return; + case 'plugins': + // `handlePluginsCommand` throws when no session is active (its own + // requireSession), so catch here instead of letting the `void` call + // reject unhandled. + try { + await handlePluginsCommand(host, args); + } catch (error) { + host.showError(formatErrorMessage(error)); + } + return; + case 'add-dir': + await handleAddDirCommand(host, args); + return; + case 'experiments': + await showExperimentsPanel(host); + return; + case 'reload': + await handleReloadCommand(host); + return; + case 'reload-tui': + await handleReloadTuiCommand(host); + return; + case 'editor': + await handleEditorCommand(host, args); + return; + case 'theme': + await handleThemeCommand(host, args); + return; + case 'model': + await handleModelCommand(host, args); + return; + case 'secondary-model': + await handleSecondaryModelCommand(host, args); + return; + case 'effort': + await handleEffortCommand(host, args); + return; + case 'provider': + await handleProviderCommand(host); + return; + case 'permission': + showPermissionPicker(host); + return; + case 'settings': + showSettingsSelector(host); + return; + case 'usage': + void showUsage(host); + return; + case 'status': + void showStatusReport(host); + return; + case 'feedback': + await handleFeedbackCommand(host); + return; + case 'btw': + await handleBtwCommand(host, args); + return; + case 'title': + await handleTitleCommand(host, args); + return; + case 'yolo': + showPermissionPicker(host, 'yolo'); + return; + case 'auto': + showPermissionPicker(host, 'auto'); + return; + case 'plan': + await handlePlanCommand(host, args); + return; + case 'swarm': + await handleSwarmCommand(host, args); + return; + case 'tower': + await handleTowerCommand(host, args); + return; + case 'compact': + await handleCompactCommand(host, args); + return; + case 'goal': + await handleGoalCommand(host, args); + return; + case 'init': + await handleInitCommand(host); + return; + case 'fork': + await handleForkCommand(host, args); + return; + case 'export-md': + await handleExportMdCommand(host, args); + return; + case 'export-debug-zip': + await handleExportDebugZipCommand(host); + return; + case 'copy': + await handleCopyCommand(host); + return; + case 'login': + await handleLoginCommand(host); + return; + case 'logout': + await handleLogoutCommand(host); + return; + case 'undo': + await handleUndoCommand(host, args); + return; + case 'web': + await handleWebCommand(host); + return; + case 'remote-control': + await handleRemoteControlCommand(host); + return; + default: + host.showError(`Unknown slash command: /${String(name)}`); + return; + } +} diff --git a/apps/kimi-code/src/tui/commands/experimental-flags.ts b/apps/kimi-code/src/tui/commands/experimental-flags.ts new file mode 100644 index 0000000000000000000000000000000000000000..0800e7b3637c599c826b8a803ba4018bad8dfe8b --- /dev/null +++ b/apps/kimi-code/src/tui/commands/experimental-flags.ts @@ -0,0 +1,19 @@ +import type { ExperimentalFeatureState, ExperimentalFlagMap } from '@moonshot-ai/kimi-code-sdk'; + +import { experimentalFeatureMap } from '#/utils/experimental-features'; + +// Resolved experimental features, fetched once from the core over RPC at startup and then read +// synchronously by the command palette and dispatch. App-local cache, not a source of truth. +let snapshot: ExperimentalFlagMap = {}; + +/** Replace the cached flag snapshot. Call after fetching via `harness.getExperimentalFeatures()`. */ +export function setExperimentalFeatures( + features: readonly Pick<ExperimentalFeatureState, 'id' | 'enabled'>[], +): void { + snapshot = experimentalFeatureMap(features); +} + +/** An `undefined` flag means "not gated" → always enabled, so callers can pass an optional flag id. */ +export function isExperimentalFlagEnabled(flag: string | undefined): boolean { + return flag === undefined || snapshot[flag] === true; +} diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts new file mode 100644 index 0000000000000000000000000000000000000000..d33d7b64b52febc78779fb2bcd3d4f630f21b62e --- /dev/null +++ b/apps/kimi-code/src/tui/commands/goal.ts @@ -0,0 +1,576 @@ +import { ErrorCodes, isKimiError, type PermissionMode } from '@moonshot-ai/kimi-code-sdk'; + +import { + GoalStartPermissionPromptComponent, + type GoalStartPermissionChoice, +} from '../components/dialogs/goal-start-permission-prompt'; +import { + GoalQueueEditDialogComponent, + GoalQueueManagerComponent, + type GoalQueueEditResult, + type GoalQueueManagerAction, +} from '../components/dialogs/goal-queue-manager'; +import { + GoalSetMessageComponent, + GoalStatusMessageComponent, + UpcomingGoalAddedMessageComponent, +} from '../components/messages/goal-panel'; +import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui'; +import { + appendGoalQueueItem, + moveGoalQueueItem, + readGoalQueue, + removeGoalQueueItem, + updateGoalQueueItem, + type GoalQueueSnapshot, +} from '../goal-queue-store'; +import { formatErrorMessage } from '../utils/event-payload'; +import { PERMISSION_MODE_DESCRIPTIONS, PERMISSION_MODE_DISPLAY_NAMES } from '../utils/permission-mode'; +import { canRestoreSubmittedInput } from './resolve'; +import type { SlashCommandHost } from './dispatch'; + +const MAX_GOAL_OBJECTIVE_LENGTH = 4000; +const RESUME_GOAL_INPUT = 'Resume the active goal.'; +const START_NEXT_GOAL_NOW_MESSAGE = 'No active goal. Starting this goal now.'; + +type GoalCommandHost = Pick< + SlashCommandHost, + | 'state' + | 'session' + | 'requireSession' + | 'setAppState' + | 'showError' + | 'showNotice' + | 'showStatus' + | 'track' + | 'mountEditorReplacement' + | 'restoreEditor' + | 'restoreInputText' + | 'sendNormalUserInput' +>; + +export interface GoalStartOptions { + readonly beforeSend?: () => boolean | Promise<boolean>; + readonly sendInput?: (objective: string) => void; +} + +export type ParsedGoalCommand = + | { readonly kind: 'status' } + | { readonly kind: 'pause' } + | { readonly kind: 'resume' } + | { readonly kind: 'cancel' } + | { + readonly kind: 'create'; + readonly objective: string; + readonly replace: boolean; + } + | { readonly kind: 'next-add'; readonly objective: string } + | { readonly kind: 'next-manage' } + | { + readonly kind: 'error'; + readonly message: string; + readonly severity?: 'error' | 'hint'; + /** Restore the typed `/goal ...` line into the editor so the input is not lost. */ + readonly restoreInput?: boolean; + }; + +const CONTROL_SUBCOMMANDS = new Set(['pause', 'resume', 'cancel']); + +/** + * Parses the deterministic `/goal` command grammar. Reserved subcommands + * (`pause`/`resume`/`cancel`/`status`/`replace`) are only honored as the first + * token; use `/goal -- <objective>` to start a goal whose text begins with one + * of those words. (`cancel` is the single discard action — it removes the + * current goal.) Stop conditions are expressed in the objective in natural + * language (e.g. "…or stop after 20 turns"); the model honors them when it + * self-audits each turn and reports `complete`/`blocked` via UpdateGoal. + */ +export function parseGoalCommand(rawArgs: string): ParsedGoalCommand { + const args = rawArgs.trim(); + if (args.length === 0 || args === 'status') return { kind: 'status' }; + + const tokens = args.split(/\s+/); + const first = tokens[0]; + if (first === 'next') { + return parseNextGoalCommand(tokens); + } + if (first !== undefined && CONTROL_SUBCOMMANDS.has(first) && tokens.length === 1) { + return { kind: first as 'pause' | 'resume' | 'cancel' }; + } + + let index = 0; + let replace = false; + if (tokens[index] === 'replace') { + replace = true; + index += 1; + } + // `--` ends subcommand parsing so an objective can begin with a reserved word + // (e.g. `/goal -- pause the rollout`). + if (tokens[index] === '--') { + index += 1; + } + + const objective = tokens.slice(index).join(' ').trim(); + if (objective.length === 0) { + // A usage hint, not a failure — shown in the same calm style as the other + // "nothing to act on" messages (no goal to pause/resume/cancel). + return { + kind: 'error', + severity: 'hint', + message: 'Provide a goal objective, e.g. `/goal Ship feature X`.', + }; + } + if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { + return { + kind: 'error', + restoreInput: true, + message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Put long content in a file and reference the file path.`, + }; + } + return { kind: 'create', objective, replace }; +} + +export async function handleGoalCommand(host: SlashCommandHost, args: string): Promise<void> { + const parsed = parseGoalCommand(args); + switch (parsed.kind) { + case 'error': + if (parsed.severity === 'hint') host.showStatus(parsed.message); + else host.showError(parsed.message); + // Give rejected input back so a long hand-typed objective is not + // lost — unless the user already moved on (a newer draft or an + // opened panel), which is possible after the async lazy-session + // creation on the v2 engine. + if (parsed.restoreInput === true && canRestoreSubmittedInput(host)) + host.restoreInputText(`/goal ${args}`); + return; + case 'status': + await showGoalStatus(host); + return; + case 'pause': + await pauseGoal(host); + return; + case 'resume': + await resumeGoal(host); + return; + case 'cancel': + await cancelGoal(host); + return; + case 'next-add': + await queueNextGoal(host, parsed); + return; + case 'next-manage': + await showGoalQueueManager(host); + return; + case 'create': + await createGoal(host, parsed, args); + return; + } +} + +function parseNextGoalCommand(tokens: readonly string[]): ParsedGoalCommand { + if (tokens.length === 2 && tokens[1] === 'manage') return { kind: 'next-manage' }; + let index = 1; + if (tokens[index] === '--') index += 1; + const objective = tokens.slice(index).join(' ').trim(); + if (objective.length === 0) { + return { + kind: 'error', + severity: 'hint', + message: + 'Provide an upcoming goal objective, e.g. `/goal next Ship feature X`, or use `/goal next manage`.', + }; + } + if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { + return { + kind: 'error', + restoreInput: true, + message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Put long content in a file and reference the file path.`, + }; + } + return { kind: 'next-add', objective }; +} + +/** + * Live pre-send check for the main editor: when the typed text is a `/goal` + * create/next command whose objective already exceeds the length limit, + * returns a warning to show while typing — before anything is submitted or + * sent to the server. Returns undefined for non-goal input and for control + * forms (`status`/`pause`/`resume`/`cancel`/`next manage`). + */ +export function goalObjectiveLengthWarning(text: string): string | undefined { + // Submitted text is trimmed before dispatch, so match leading whitespace. + const trimmed = text.trimStart(); + if (!trimmed.startsWith('/goal')) return undefined; + const args = trimmed.slice('/goal'.length); + // parseSlashInput splits the command name at a literal space only, so a + // newline/tab boundary (`/goal⏎…`, `/goalfoo`) is not the goal command. + if (args.length > 0 && args.charAt(0) !== ' ') return undefined; + const objective = extractGoalObjective(args); + if (objective === undefined || objective.length <= MAX_GOAL_OBJECTIVE_LENGTH) return undefined; + return `Goal objective is too long (${objective.length}/${MAX_GOAL_OBJECTIVE_LENGTH} characters); put long content in a file and reference the file path.`; +} + +/** + * Mirrors the parse grammar above: strips `next` / `replace` / `--` and + * returns the objective text, or undefined when the args form a control + * command that carries no objective. + */ +function extractGoalObjective(rawArgs: string): string | undefined { + const args = rawArgs.trim(); + if (args.length === 0 || args === 'status') return undefined; + const tokens = args.split(/\s+/); + const first = tokens[0]; + let index = 0; + if (first === 'next') { + if (tokens.length === 2 && tokens[1] === 'manage') return undefined; + index = 1; + } else { + if (first !== undefined && CONTROL_SUBCOMMANDS.has(first) && tokens.length === 1) { + return undefined; + } + if (tokens[index] === 'replace') index += 1; + } + if (tokens[index] === '--') index += 1; + return tokens.slice(index).join(' ').trim(); +} + +async function queueNextGoal( + host: SlashCommandHost, + parsed: Extract<ParsedGoalCommand, { kind: 'next-add' }>, +): Promise<void> { + const session = host.requireSession(); + let hasCurrentGoal: boolean; + try { + const { goal } = await session.getGoal(); + hasCurrentGoal = goal !== null; + } catch (error) { + host.showError(`Failed to inspect current goal: ${formatErrorMessage(error)}`); + return; + } + + if (!hasCurrentGoal && !isBusy(host)) { + host.showStatus(START_NEXT_GOAL_NOW_MESSAGE); + await createGoal( + host, + { kind: 'create', objective: parsed.objective, replace: false }, + `next ${parsed.objective}`, + ); + return; + } + + try { + await appendGoalQueueItem(session, { objective: parsed.objective }); + } catch (error) { + host.showError(formatErrorMessage(error)); + return; + } + host.track('goal_queue_append'); + if (!hasCurrentGoal) host.requestQueuedGoalPromotion?.(); + host.state.transcriptContainer.addChild( + new UpcomingGoalAddedMessageComponent(), + ); + host.state.ui.requestRender(); +} + +async function showGoalQueueManager( + host: SlashCommandHost, + selectedGoalId?: string, +): Promise<void> { + let snapshot: GoalQueueSnapshot; + try { + snapshot = await readGoalQueue(host.requireSession()); + } catch (error) { + host.showError(`Failed to load upcoming goals: ${formatErrorMessage(error)}`); + return; + } + + host.track('goal_queue_manage'); + host.mountEditorReplacement( + new GoalQueueManagerComponent({ + goals: snapshot.goals, + selectedGoalId, + onAction: async (action) => { + try { + return await handleGoalQueueManagerAction(host, action); + } catch (error) { + host.showError(`Failed to update upcoming goals: ${formatErrorMessage(error)}`); + return undefined; + } + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +async function handleGoalQueueManagerAction( + host: SlashCommandHost, + action: GoalQueueManagerAction, +): Promise<GoalQueueSnapshot | void> { + const session = host.requireSession(); + switch (action.kind) { + case 'move': { + const snapshot = await moveGoalQueueItem(session, { + goalId: action.goalId, + direction: action.direction, + }); + host.track('goal_queue_move', { direction: action.direction }); + return snapshot; + } + case 'delete': { + const snapshot = await removeGoalQueueItem(session, { goalId: action.goalId }); + host.track('goal_queue_remove'); + return snapshot; + } + case 'edit': + await showGoalQueueEditDialog(host, action.goalId); + return; + } +} + +async function showGoalQueueEditDialog( + host: SlashCommandHost, + goalId: string, +): Promise<void> { + let snapshot: GoalQueueSnapshot; + try { + snapshot = await readGoalQueue(host.requireSession()); + } catch (error) { + host.showError(`Failed to load upcoming goals: ${formatErrorMessage(error)}`); + return; + } + + const goal = snapshot.goals.find((item) => item.id === goalId); + if (goal === undefined) { + host.showStatus('Queued goal no longer exists.'); + await showGoalQueueManager(host); + return; + } + + host.mountEditorReplacement( + new GoalQueueEditDialogComponent({ + goal, + onDone: (result) => { + void handleGoalQueueEditResult(host, result).catch((error: unknown) => { + host.showError(`Failed to update upcoming goal: ${formatErrorMessage(error)}`); + }); + }, + }), + ); +} + +async function handleGoalQueueEditResult( + host: SlashCommandHost, + result: GoalQueueEditResult, +): Promise<void> { + if (result.kind === 'cancel') { + await showGoalQueueManager(host, result.goalId); + return; + } + + await updateGoalQueueItem(host.requireSession(), { + goalId: result.goalId, + objective: result.objective, + }); + host.track('goal_queue_update'); + await showGoalQueueManager(host, result.goalId); +} + +export async function createGoal( + host: GoalCommandHost, + parsed: Extract<ParsedGoalCommand, { kind: 'create' }>, + rawArgs?: string, + options: GoalStartOptions = {}, +): Promise<boolean> { + // A goal must be able to start a model turn; refuse to create one otherwise. + if (host.state.appState.model.trim().length === 0 || host.session === undefined) { + host.showError(LLM_NOT_SET_MESSAGE); + return false; + } + + if ( + host.state.appState.permissionMode === 'manual' || + host.state.appState.permissionMode === 'yolo' + ) { + showGoalStartPermissionPrompt(host, parsed, rawArgs ?? parsed.objective, options); + return false; + } + + return startGoal(host, parsed, options); +} + +function showGoalStartPermissionPrompt( + host: GoalCommandHost, + parsed: Extract<ParsedGoalCommand, { kind: 'create' }>, + rawArgs: string, + options: GoalStartOptions, +): void { + const commandText = `/goal ${rawArgs.trim()}`; + const cancelStart = (): void => { + host.restoreInputText(commandText); + host.showStatus('Goal not started.'); + }; + host.mountEditorReplacement( + new GoalStartPermissionPromptComponent({ + mode: host.state.appState.permissionMode === 'yolo' ? 'yolo' : 'manual', + onSelect: (choice) => { + if (choice === 'cancel') { + cancelStart(); + return; + } + host.restoreEditor(); + void startGoalWithPermission(host, parsed, choice, options); + }, + onCancel: cancelStart, + }), + ); +} + +async function startGoalWithPermission( + host: GoalCommandHost, + parsed: Extract<ParsedGoalCommand, { kind: 'create' }>, + choice: GoalStartPermissionChoice, + options: GoalStartOptions, +): Promise<void> { + const previousMode = host.state.appState.permissionMode; + const switched = + choice !== previousMode && (choice === 'auto' || choice === 'yolo'); + if (switched) { + if (!(await setPermissionForGoal(host, choice))) return; + } + const started = await startGoal(host, parsed, options); + // The permission switch only exists to run this goal. If creation fails + // (e.g. a goal already exists and `replace` was not given), restore the + // previous mode so the session is not left more permissive than before. + if (!started && switched) { + await setPermissionForGoal(host, previousMode); + return; + } + // Announce the switch only once the goal actually starts: shown earlier, a + // failed creation would leave a stale permissive-mode notice in the + // transcript even though the rollback above restored the previous mode. + if (switched) { + host.showNotice(`Permission mode: ${PERMISSION_MODE_DISPLAY_NAMES[choice]}`); + host.showStatus(PERMISSION_MODE_DESCRIPTIONS[choice], 'warning'); + } +} + +async function setPermissionForGoal(host: GoalCommandHost, mode: PermissionMode): Promise<boolean> { + try { + await host.requireSession().setPermission(mode); + } catch (error) { + host.showError(`Failed to set permission mode: ${formatErrorMessage(error)}`); + return false; + } + host.setAppState({ permissionMode: mode }); + return true; +} + +async function startGoal( + host: GoalCommandHost, + parsed: Extract<ParsedGoalCommand, { kind: 'create' }>, + options: GoalStartOptions, +): Promise<boolean> { + try { + await host.requireSession().createGoal({ + objective: parsed.objective, + replace: parsed.replace, + }); + } catch (error) { + if (isKimiError(error) && error.code === ErrorCodes.GOAL_ALREADY_EXISTS) { + host.showError( + 'A goal is already active. Use `/goal replace <objective>` to replace it, or `/goal status` to inspect it.', + ); + return false; + } + host.showError(formatErrorMessage(error)); + return false; + } + if (options.beforeSend !== undefined && !(await options.beforeSend())) { + return false; + } + host.state.transcriptContainer.addChild(new GoalSetMessageComponent()); + host.state.ui.requestRender(); + if (options.sendInput !== undefined) { + options.sendInput(parsed.objective); + } else { + host.sendNormalUserInput(parsed.objective); + } + return true; +} + +async function pauseGoal(host: SlashCommandHost): Promise<void> { + const session = host.requireSession(); + try { + await session.pauseGoal(); + if (isStreaming(host)) await session.cancel(); + } catch (error) { + if (isKimiError(error) && error.code === ErrorCodes.GOAL_NOT_FOUND) { + host.showStatus('No goal to pause.'); + return; + } + host.showError(formatErrorMessage(error)); + return; + } + host.track('goal_pause'); + host.showStatus('Goal paused. Use `/goal resume` to continue.'); +} + +async function resumeGoal(host: SlashCommandHost): Promise<void> { + if (host.state.appState.model.trim().length === 0 || host.session === undefined) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + + try { + await host.requireSession().resumeGoal(); + } catch (error) { + if (isKimiError(error) && error.code === ErrorCodes.GOAL_NOT_FOUND) { + host.showStatus('No goal to resume.'); + return; + } + host.showError(formatErrorMessage(error)); + return; + } + host.track('goal_resume'); + host.sendNormalUserInput(RESUME_GOAL_INPUT); +} + +async function cancelGoal(host: SlashCommandHost): Promise<void> { + const session = host.requireSession(); + try { + await session.cancelGoal(); + if (isStreaming(host)) await session.cancel(); + } catch (error) { + if (isKimiError(error) && error.code === ErrorCodes.GOAL_NOT_FOUND) { + host.showStatus('No goal to cancel.'); + return; + } + host.showError(formatErrorMessage(error)); + return; + } + host.track('goal_cancel'); + host.showNotice('Goal cancelled.'); +} + +async function showGoalStatus(host: SlashCommandHost): Promise<void> { + const { goal } = await host.requireSession().getGoal(); + host.track('goal_status', { status: goal?.status ?? 'none' }); + if (goal === null) { + host.showStatus('No goal set. Start one with `/goal <objective>`.'); + return; + } + host.state.transcriptContainer.addChild( + new GoalStatusMessageComponent(goal), + ); + host.state.ui.requestRender(); +} + +function isStreaming(host: SlashCommandHost): boolean { + return host.state.appState.streamingPhase !== 'idle'; +} + +function isBusy(host: SlashCommandHost): boolean { + return isStreaming(host) || host.state.appState.isCompacting; +} diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cdafaac1e6676784d9bfafc50cb000a50f3432c1 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -0,0 +1,43 @@ +export * from './experimental-flags'; +export * from './parse'; +export * from './registry'; +export * from './resolve'; +export * from './skills'; +export * from './plugin-commands'; +export * from './types'; + +export { dispatchInput, type SlashCommandHost } from './dispatch'; +export { handleLoginCommand, handleLogoutCommand } from './auth'; +export { handleBtwCommand } from './btw'; +export { handleCopyCommand } from './copy'; +export { + handleCompactCommand, + handleEditorCommand, + handleModelCommand, + handlePlanCommand, + handleThemeCommand, + showExperimentsPanel, + showModelPicker, + showPermissionPicker, + showSettingsSelector, +} from './config'; +export { handleSwarmCommand } from './swarm'; +export { handleTowerCommand } from './tower'; +export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; +export { handlePluginsCommand } from './plugins'; +export { handleReloadCommand, handleReloadTuiCommand } from './reload'; +export { handleGoalCommand, parseGoalCommand, goalObjectiveLengthWarning } from './goal'; +export { goalArgumentCompletions } from './registry'; +export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session'; +export { handleUndoCommand } from './undo'; +export { handleRemoteControlCommand, handleWebCommand } from './web'; +export { + promptApiKey, + promptCatalogProviderSelection, + promptFeedbackInput, + promptLogoutProviderSelection, + promptModelSelectionForCatalog, + promptModelSelectionForOpenPlatform, + promptPlatformSelection, + runModelSelector, +} from './prompts'; diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8bdc0635963e30a50a08764f7224fafe256ff1c --- /dev/null +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -0,0 +1,255 @@ +import { release as osRelease, type as osType } from 'node:os'; + +import type { McpServerInfo, SessionStatus, SessionUsage } from '@moonshot-ai/kimi-code-sdk'; + +import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel'; +import { buildStatusReportLines } from '../components/messages/status-panel'; +import { buildUsageReportLines, UsagePanelComponent, type ManagedUsageReport } from '../components/messages/usage-panel'; +import { isExperimentalFlagEnabled } from './experimental-flags'; +import { quotaUsageRows } from '#/utils/usage/usage-format'; +import { + FEEDBACK_ISSUE_URL, + FEEDBACK_STATUS_CANCELLED, + FEEDBACK_STATUS_FALLBACK, + FEEDBACK_STATUS_NETWORK_ERROR, + FEEDBACK_STATUS_NOT_SIGNED_IN, + FEEDBACK_STATUS_SUBMITTING, + FEEDBACK_STATUS_SUCCESS, + FEEDBACK_STATUS_UPLOAD_FAILED, + FEEDBACK_TELEMETRY_EVENT, + feedbackIdLine, + feedbackSessionLine, + kimiCodeSignupUrl, + withFeedbackVersionPrefix, +} from '../constant/feedback'; +import { DEFAULT_OAUTH_PROVIDER_NAME, isManagedUsageProvider } from '../constant/kimi-tui'; +import { submitFeedbackWithAttachments } from '../../feedback/feedback-attachments'; +import { formatErrorMessage } from '../utils/event-payload'; +import { openUrl } from '#/utils/open-url'; +import { promptFeedbackAttachment, promptFeedbackInput } from './prompts'; +import type { SlashCommandHost } from './dispatch'; + +// --------------------------------------------------------------------------- +// Feedback +// --------------------------------------------------------------------------- + +export async function handleFeedbackCommand(host: SlashCommandHost): Promise<void> { + const fallback = (reason: string): void => { + host.showStatus(reason); + host.showStatus(FEEDBACK_ISSUE_URL); + openUrl(FEEDBACK_ISSUE_URL); + }; + + // Gate on the OAuth token rather than the active model's provider: a + // signed-in user running an API-key model can still submit feedback + // through the authenticated channel. + let signedIn = false; + try { + const status = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); + signedIn = status.providers.some( + (provider) => provider.providerName === DEFAULT_OAUTH_PROVIDER_NAME && provider.hasToken, + ); + } catch { + // The sign-in state is unreadable — keep the feedback entry usable by + // falling back to GitHub Issues instead of failing the command. + fallback(FEEDBACK_STATUS_FALLBACK); + return; + } + if (!signedIn) { + host.showStatus(FEEDBACK_STATUS_NOT_SIGNED_IN); + host.showStatus(kimiCodeSignupUrl()); + host.showStatus(FEEDBACK_ISSUE_URL); + return; + } + + // Stage 1: collect the free-form feedback text. + const input = await promptFeedbackInput(host); + if (input === undefined) { + host.showStatus(FEEDBACK_STATUS_CANCELLED); + return; + } + + // Stage 2: ask whether to attach diagnostics (logs / codebase). + const level = await promptFeedbackAttachment(host); + if (level === undefined) { + host.showStatus(FEEDBACK_STATUS_CANCELLED); + return; + } + + const version = withFeedbackVersionPrefix(host.state.appState.version); + const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING); + // Guarantee the spinner's underlying setInterval is always cleared, even when + // submitFeedback throws — otherwise the interval (and its per-frame + // requestRender) leaks for the rest of the session. + let stopped = false; + const stopSpinner = (opts: { ok: boolean; label: string }): void => { + if (stopped) return; + stopped = true; + spinner.stop(opts); + }; + try { + const res = await host.harness.auth.submitFeedback({ + content: input.value, + sessionId: host.state.appState.sessionId, + version, + os: `${osType()} ${osRelease()}`, + model: host.state.appState.model.length > 0 ? host.state.appState.model : null, + }); + + if (res.kind !== 'ok') { + stopSpinner({ ok: false, label: res.message }); + fallback(FEEDBACK_STATUS_FALLBACK); + return; + } + + // Stage 3: prepare and upload each requested attachment independently. + // Attachment failures are non-fatal partial failures — the text feedback + // already exists server-side — so a throw here degrades to the + // partial-failure status, never to the GitHub fallback in the outer catch. + let attachmentFailed = false; + try { + attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level); + } catch { + attachmentFailed = true; + } + + stopSpinner({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); + host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); + host.showStatus(feedbackIdLine(res.feedbackId)); + host.track(FEEDBACK_TELEMETRY_EVENT); + if (attachmentFailed) { + host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED); + } + } catch (error) { + stopSpinner({ ok: false, label: FEEDBACK_STATUS_NETWORK_ERROR }); + fallback(FEEDBACK_STATUS_FALLBACK); + throw error; + } +} + +// --------------------------------------------------------------------------- +// Info commands +// --------------------------------------------------------------------------- + +interface SessionUsageResult { + readonly usage?: SessionUsage; + readonly error?: string; +} + +interface RuntimeStatusResult { + readonly status?: SessionStatus; + readonly error?: string; +} + +interface ManagedUsageResult { + readonly usage?: ManagedUsageReport; + readonly error?: string; +} + +export async function showUsage(host: SlashCommandHost): Promise<void> { + const sessionUsage = await loadSessionUsageReport(host); + const managedUsage = await loadManagedUsageReport(host); + const reportArgs = { + sessionUsage: sessionUsage.usage, + sessionUsageError: sessionUsage.error, + contextUsage: host.state.appState.contextUsage, + contextTokens: host.state.appState.contextTokens, + maxContextTokens: host.state.appState.maxContextTokens, + managedUsage: managedUsage?.usage, + managedUsageError: managedUsage?.error, + }; + const panel = new UsagePanelComponent(() => buildUsageReportLines(reportArgs), 'primary'); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); +} + +export async function showStatusReport(host: SlashCommandHost): Promise<void> { + const [runtimeStatus, managedUsage] = await Promise.all([ + loadRuntimeStatusReport(host), + loadManagedUsageReport(host), + ]); + const appState = host.state.appState; + const reportArgs = { + version: appState.version, + model: appState.model, + workDir: appState.workDir, + sessionId: appState.sessionId, + sessionTitle: appState.sessionTitle, + thinkingEffort: appState.thinkingEffort, + permissionMode: appState.permissionMode, + planMode: appState.planMode, + towerMode: appState.towerMode, + towerAvailable: isExperimentalFlagEnabled('tower'), + contextUsage: appState.contextUsage, + contextTokens: appState.contextTokens, + maxContextTokens: appState.maxContextTokens, + availableModels: appState.availableModels, + status: runtimeStatus.status, + statusError: runtimeStatus.error, + managedUsage: managedUsage?.usage, + managedUsageError: managedUsage?.error, + }; + const panel = new UsagePanelComponent(() => buildStatusReportLines(reportArgs), 'primary', ' Status '); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); +} + +export async function showMcpServers(host: SlashCommandHost): Promise<void> { + let servers: readonly McpServerInfo[]; + try { + if (host.session !== undefined) { + servers = await host.session.listMcpServers(); + } else { + // v2 session-less: the MCP connection set is workspace-scoped, so it is + // inspectable before the first session exists. + servers = await host.harness.listWorkspaceMcpServers(host.state.appState.workDir); + } + } catch (error) { + host.showError(`Failed to load MCP servers: ${formatErrorMessage(error)}`); + return; + } + + const title = servers.length > 0 ? ` MCP (${servers.length}) ` : ' MCP '; + const panel = new UsagePanelComponent( + () => buildMcpStatusReportLines({ servers }), + 'primary', + title, + ); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); +} + +async function loadSessionUsageReport(host: SlashCommandHost): Promise<SessionUsageResult> { + if (host.session === undefined) return {}; + try { + return { usage: await host.session.getUsage() }; + } catch (error) { + return { error: formatErrorMessage(error) }; + } +} + +async function loadRuntimeStatusReport(host: SlashCommandHost): Promise<RuntimeStatusResult> { + if (host.session === undefined) return {}; + try { + return { status: await host.session.getStatus() }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} + +async function loadManagedUsageReport(host: SlashCommandHost): Promise<ManagedUsageResult | undefined> { + const alias = host.state.appState.model; + const providerKey = host.state.appState.availableModels[alias]?.provider; + if (!isManagedUsageProvider(providerKey)) return undefined; + + let res; + try { + res = await host.harness.auth.getManagedUsage(providerKey); + } catch (error) { + return { error: formatErrorMessage(error) }; + } + if (res.kind === 'error') { + return { error: res.message }; + } + return { usage: { rows: quotaUsageRows(res.quota), extraUsage: res.quota.extraUsage } }; +} diff --git a/apps/kimi-code/src/tui/commands/parse.ts b/apps/kimi-code/src/tui/commands/parse.ts new file mode 100644 index 0000000000000000000000000000000000000000..1fcc01548f7625848056f3fe858ec233392eeb5c --- /dev/null +++ b/apps/kimi-code/src/tui/commands/parse.ts @@ -0,0 +1,14 @@ +import type { ParsedSlashInput } from './types'; + +export function parseSlashInput(input: string): ParsedSlashInput | null { + if (!input.startsWith('/')) return null; + const trimmed = input.slice(1).trim(); + if (trimmed.length === 0) return null; + const spaceIdx = trimmed.indexOf(' '); + const name = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); + const args = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim(); + // Reject file paths (e.g. `/usr/local/bin`), but allow namespaced plugin + // commands whose name itself contains `/` (e.g. `plugin:frontend/component`). + if (name.includes('/') && !name.includes(':')) return null; + return { name, args }; +} diff --git a/apps/kimi-code/src/tui/commands/plugin-commands.ts b/apps/kimi-code/src/tui/commands/plugin-commands.ts new file mode 100644 index 0000000000000000000000000000000000000000..a26e6fdb553fba5e9a4e8119a1d68b0b054b55f7 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/plugin-commands.ts @@ -0,0 +1,27 @@ +import type { PluginCommandDef } from '@moonshot-ai/kimi-code-sdk'; + +import type { KimiSlashCommand } from './types'; + +export interface PluginSlashCommands { + readonly commands: readonly KimiSlashCommand[]; + /** Maps a namespaced command name (`plugin:command`) to its markdown body. */ + readonly commandMap: ReadonlyMap<string, string>; +} + +export function pluginCommandName(pluginId: string, name: string): string { + return `${pluginId}:${name}`; +} + +export function buildPluginSlashCommands(defs: readonly PluginCommandDef[]): PluginSlashCommands { + const commandMap = new Map<string, string>(); + const commands = defs.map((def) => { + const commandName = pluginCommandName(def.pluginId, def.name); + commandMap.set(commandName, def.body); + return { + name: commandName, + aliases: [], + description: def.description, + } satisfies KimiSlashCommand; + }); + return { commands, commandMap }; +} diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts new file mode 100644 index 0000000000000000000000000000000000000000..a11a6e4643e1021f093b08b9e055dcf2111446c3 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -0,0 +1,901 @@ +import { homedir as osHomedir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; + +import { + log, + type CapabilityStatus, + type PluginInfo, + type PluginSummary, + type Session, +} from '@moonshot-ai/kimi-code-sdk'; +import { Markdown, Spacer } from '@moonshot-ai/pi-tui'; + +import { + PluginInstallTrustConfirmComponent, + PluginMcpSelectorComponent, + PluginRemoveConfirmComponent, + PluginsPanelComponent, + type PluginInstallTrustConfirmResult, + type PluginMcpSelection, + type PluginRemoveConfirmResult, + type PluginsPanelSelection, + type PluginsPanelTabId, +} from '../components/dialogs/plugins-selector'; +import { + buildPluginsInfoLines, + buildPluginsListLines, +} from '../components/messages/plugins-status-panel'; +import { UsagePanelComponent } from '../components/messages/usage-panel'; +import { createMarkdownTheme } from '../theme/pi-tui-theme'; +import { formatErrorMessage } from '../utils/event-payload'; +import { createMarkdownOptions } from '../utils/markdown-options'; +import { + formatPluginSourceLabel, + isOfficialPluginInstall, + isOfficialPluginSource, +} from '../utils/plugin-source-label'; +import { + KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, + QUOTA_CONSUMING_PLUGIN_IDS, +} from '#/constant/app'; +import { + loadPluginMarketplace, + withBuiltInEntries, + withMarketplaceLatestVersions, + type PluginMarketplace, + type PluginMarketplaceEntry, +} from '#/utils/plugin-marketplace'; +import { openUrl } from '#/utils/open-url'; +import type { SlashCommandHost } from './dispatch'; + +interface ShowPluginsPickerOptions { + readonly selectedId?: string; + readonly pluginHint?: { + readonly id: string; + readonly text: string; + }; + readonly initialTab?: PluginsPanelTabId; + readonly marketplaceSource?: string; +} + +interface PluginMcpServerHint { + readonly server: string; + readonly text: string; +} + +interface ShowPluginMcpPickerOptions { + readonly selectedServer?: string; + readonly serverHint?: PluginMcpServerHint; +} + +/** The plugin-management surface `/plugins` operates on. */ +type PluginApi = Pick< + Session, + | 'listPlugins' + | 'installPlugin' + | 'setPluginEnabled' + | 'setPluginMcpServerEnabled' + | 'removePlugin' + | 'reloadPlugins' + | 'getPluginInfo' +>; + +/** + * Resolve the plugin-management API. Plugin state is app-global, so a + * session-less startup still gets a working `/plugins` through the harness's + * global facade; once a session exists the session's own API is used. + */ +async function resolvePluginApi(host: SlashCommandHost): Promise<PluginApi> { + if (host.session !== undefined) return host.session; + return { + listPlugins: () => host.harness.listPlugins(), + installPlugin: (source) => host.harness.installPlugin(source), + setPluginEnabled: (id, enabled) => host.harness.setPluginEnabled(id, enabled), + setPluginMcpServerEnabled: (id, server, enabled) => + host.harness.setPluginMcpServerEnabled(id, server, enabled), + removePlugin: (id) => host.harness.removePlugin(id), + reloadPlugins: () => host.harness.reloadPlugins(), + getPluginInfo: (id) => host.harness.getPluginInfo(id), + }; +} + +export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: string): Promise<void> { + const args = rawArgs.trim().split(/\s+/).filter((part) => part.length > 0); + const sub = args[0]; + const rest = args.slice(1); + const session = await resolvePluginApi(host); + + try { + if (sub === undefined) { + await showPluginsPicker(host); + return; + } + if (sub === 'list') { + await renderPluginsList(host); + return; + } + if (sub === 'install') { + const source = rest.join(' ').trim(); + if (source.length === 0) { + host.showError('Usage: /plugins install <local-path-or-zip-url>'); + return; + } + if (!(await confirmInstallTrust(host, source, isOfficialPluginSource(source)))) { + host.showStatus('Install cancelled.'); + return; + } + const spinner = host.showProgressSpinner(`Installing plugin from ${truncateForStatus(source)}…`); + try { + await installPluginFromSource(host, source); + spinner.stop({ ok: true, label: `Install finished — see details below.` }); + } catch (error) { + spinner.stop({ ok: false, label: `Install failed: ${formatErrorMessage(error)}` }); + throw error; + } + return; + } + if (sub === 'marketplace') { + const marketplaceSource = rest.join(' ').trim() || undefined; + await showPluginsPicker(host, { + // Custom marketplaces often omit `tier`, so their entries land on the + // Curated tab (entry.tier !== 'official'). Open there when a custom + // source is supplied; otherwise the default catalog's official entries + // make Official the right landing tab. + initialTab: marketplaceSource === undefined ? 'official' : 'third-party', + marketplaceSource, + }); + return; + } + if (sub === 'info') { + const id = rest[0]; + if (id === undefined) { + await showPluginsPicker(host); + return; + } + await renderPluginInfo(host, id); + return; + } + if (sub === 'mcp') { + const action = rest[0]; + const id = rest[1]; + const server = rest[2]; + if ((action !== 'enable' && action !== 'disable') || id === undefined || server === undefined) { + host.showError('Usage: /plugins mcp enable|disable <id> <server>'); + return; + } + await session.setPluginMcpServerEnabled(id, server, action === 'enable'); + host.showStatus( + `${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /reload or /new to apply.`, + ); + return; + } + if (sub === 'enable' || sub === 'disable') { + const id = rest[0]; + if (id === undefined) { + await showPluginsPicker(host); + return; + } + await applyPluginEnabled(host, id, sub === 'enable'); + return; + } + if (sub === 'remove') { + const id = rest[0]; + if (id === undefined) { + host.showError('Usage: /plugins remove <id>'); + return; + } + if (!(await confirmRemovePlugin(host, id))) { + host.showStatus(`Remove cancelled: ${id}.`); + return; + } + await removePlugin(host, id); + return; + } + if (sub === 'reload') { + await reloadPlugins(host); + return; + } + const plugins = await session.listPlugins(); + if (plugins.some((plugin) => plugin.id === sub)) { + await renderPluginInfo(host, sub); + return; + } + host.showError(`Unknown /plugins action: ${sub}. Run /plugins to choose interactively.`); + } catch (error) { + host.showError(`/plugins ${sub ?? ''} failed: ${formatErrorMessage(error)}`); + } +} + +/** + * Resolve the capability API. Like plugin state, capability state is + * app-global on the v2 engine, so a session-less startup still gets + * readiness and installs through the harness's global facade; with a live + * session the session's own API is used. + */ +type CapabilityApi = Pick<Session, 'listCapabilities' | 'getCapability' | 'installCapability'>; + +async function resolveCapabilityApi(host: SlashCommandHost): Promise<CapabilityApi> { + if (host.session !== undefined) return host.session; + return host.harness; +} + +function logCapabilityStatus(capability: CapabilityStatus, installed?: boolean): void { + const payload = { + capabilityId: capability.id, + pluginId: capability.pluginId, + installed, + supported: capability.supported, + state: capability.state, + version: capability.version, + install: capability.install, + steps: capability.steps, + }; + const hasStepIssues = capability.steps.some((step) => step.state !== 'ok'); + if ( + capability.install.error !== undefined || + (installed !== false && hasStepIssues) + ) { + log.warn('capability needs attention', payload); + } else { + log.info('capability status', payload); + } +} + +async function showPluginsPicker( + host: SlashCommandHost, + options?: ShowPluginsPickerOptions, +): Promise<void> { + let plugins: readonly PluginSummary[]; + try { + plugins = await (await resolvePluginApi(host)).listPlugins(); + } catch (error) { + host.showError(`Failed to load plugins: ${formatErrorMessage(error)}`); + return; + } + + let capabilities: readonly CapabilityStatus[] = []; + try { + capabilities = await (await resolveCapabilityApi(host)).listCapabilities(); + } catch (error) { + log.warn('capability status unavailable', { error }); + } + + const installedIds = new Set(plugins.map((plugin) => plugin.id)); + for (const capability of capabilities) { + logCapabilityStatus(capability, installedIds.has(capability.pluginId ?? capability.id)); + } + + const panel = new PluginsPanelComponent({ + installed: plugins, + installedIds, + capabilities, + catalogIsDefault: isDefaultMarketplaceCatalog(options?.marketplaceSource), + initialTab: options?.initialTab, + selectedId: options?.selectedId, + pluginHint: options?.pluginHint, + onSelect: (selection) => { + // Each branch of the handler either mounts the next view or restores the + // editor itself, so do not pre-restore here — that would flash the editor + // for in-place actions like toggling a plugin. + void handlePluginsPanelSelection(host, panel, selection).catch((error: unknown) => { + host.showError(`/plugins failed: ${formatErrorMessage(error)}`); + }); + }, + onCancel: () => { + host.restoreEditor(); + }, + // Every tab except Custom needs the catalog: Official/Curated list it, + // and Installed uses it to show update badges. The Installed/Custom tabs + // keep working even when the marketplace is unreachable (badges simply stay + // hidden until data arrives). + onRequestMarketplace: () => { + void loadMarketplaceCatalog(host, panel, options?.marketplaceSource, capabilities); + }, + }); + host.mountEditorReplacement(panel); + // Kick off the catalog fetch for any tab that needs it: Installed uses it for + // update badges, Official/Curated list it. Custom never reads the catalog, + // so skip the fetch there. Done here (after `panel` is initialized) rather + // than inside the component constructor, because the callback above closes + // over `panel`. + if (options?.initialTab !== 'custom') { + panel.setMarketplaceLoading(); + void loadMarketplaceCatalog(host, panel, options?.marketplaceSource, capabilities); + } +} + +/** + * Adapt a capability from the engine's registry into a catalog row. The + * engine is the single source of truth for what the built-in capabilities + * are — the CLI only renders them. The `capability:<id>` source marker + * routes installs through the capability flow (never a plain plugin + * install), so the row needs no real URL. + */ +function capabilityMarketplaceEntry(capability: CapabilityStatus): PluginMarketplaceEntry { + return { + id: capability.id, + displayName: capability.displayName, + description: capability.description, + tier: 'official', + source: `capability:${capability.id}`, + builtIn: true, + }; +} + +/** + * Injection is part of the DEFAULT catalog experience only: any explicit + * replacement (the slash-command source or a user-set env override) opts out + * wholesale. The dev marketplace server started by scripts/dev.mjs serves + * this repo's own catalog and marks itself, so it still counts as default. + */ +function isDefaultMarketplaceCatalog( + source: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (source !== undefined) return false; + if (env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] === undefined) return true; + return env['KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER'] === '1'; +} + +async function loadMarketplaceCatalog( + host: SlashCommandHost, + panel: PluginsPanelComponent, + source: string | undefined, + capabilities: readonly CapabilityStatus[], +): Promise<void> { + const builtInEntries = isDefaultMarketplaceCatalog(source) + ? capabilities.map(capabilityMarketplaceEntry) + : undefined; + let marketplace: PluginMarketplace; + let catalog: PluginMarketplace; + try { + // Phase 1: render the catalog as soon as it arrives. Version lookups + // (GitHub releases/latest round trips) must not gate the first paint. + // Keep the raw parsed catalog for phase 2: injecting built-ins first + // would mask the matching catalog entries' GitHub sources behind + // `capability:<id>` rows, making their versions unresolvable. + catalog = await loadPluginMarketplace({ + workDir: host.state.appState.workDir, + source, + skipLatestVersions: true, + }); + marketplace = + builtInEntries !== undefined ? withBuiltInEntries(catalog, builtInEntries) : catalog; + panel.setMarketplace(marketplace.plugins, marketplace.source); + host.state.ui.requestRender(); + } catch (error) { + // Any phase-1 failure (unreachable OR malformed catalog) surfaces as an + // error: the panel keeps built-in capability rows installable in the + // Official tab while the error is shown, and a broken catalog must not + // be masked as a successfully loaded, built-ins-only marketplace. + panel.setMarketplaceError(formatErrorMessage(error)); + host.state.ui.requestRender(); + return; + } + try { + // Phase 2: resolve latest versions in the background (against the raw + // catalog), re-apply the built-in injection so resolved versions flow + // onto capability rows, then refresh so update badges appear. Failures + // degrade to badge-less rows and never clobber the rendered list. + const enrichedCatalog = await withMarketplaceLatestVersions(catalog); + const enriched = + builtInEntries !== undefined + ? withBuiltInEntries(enrichedCatalog, builtInEntries) + : enrichedCatalog; + panel.setMarketplace(enriched.plugins, enriched.source); + } catch (error) { + log.warn('marketplace version lookup failed', { error }); + } + host.state.ui.requestRender(); +} + +async function showPluginMcpPicker( + host: SlashCommandHost, + id: string, + options?: ShowPluginMcpPickerOptions, +): Promise<void> { + let info: PluginInfo; + try { + info = await (await resolvePluginApi(host)).getPluginInfo(id); + } catch (error) { + host.showError(`Failed to load plugin MCP servers: ${formatErrorMessage(error)}`); + return; + } + + host.mountEditorReplacement( + new PluginMcpSelectorComponent({ + info, + selectedServer: options?.selectedServer, + serverHint: options?.serverHint, + onSelect: (selection) => { + // Every MCP action re-mounts a picker, so let the handler do the + // mounting — pre-restoring the editor here would flash on toggle. + void handlePluginMcpSelection(host, selection).catch((error: unknown) => { + host.showError(`/plugins mcp failed: ${formatErrorMessage(error)}`); + }); + }, + onCancel: () => { + host.restoreEditor(); + void showPluginsPicker(host, { selectedId: id }); + }, + }), + ); +} + +async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise<boolean> { + let displayName = id; + try { + displayName = (await (await resolvePluginApi(host)).getPluginInfo(id)).displayName; + } catch { + // Keep the confirmation available even when plugin details cannot be loaded. + } + + return new Promise((resolveConfirmed) => { + host.mountEditorReplacement( + new PluginRemoveConfirmComponent({ + id, + displayName, + onDone: (result: PluginRemoveConfirmResult) => { + host.restoreEditor(); + resolveConfirmed(result.kind === 'confirm'); + }, + }), + ); + }); +} + +async function confirmInstallTrust( + host: SlashCommandHost, + label: string, + official: boolean, +): Promise<boolean> { + // Kimi-built official plugins are trusted implicitly; anything else requires + // the user to explicitly opt in via the trust prompt. + if (official) return true; + return new Promise((resolveConfirmed) => { + host.mountEditorReplacement( + new PluginInstallTrustConfirmComponent({ + label, + onDone: (result: PluginInstallTrustConfirmResult) => { + host.restoreEditor(); + resolveConfirmed(result.kind === 'confirm'); + }, + }), + ); + }); +} + +const CAPABILITY_POLL_INTERVAL_MS = 700; +const CAPABILITY_POLL_ATTEMPTS = 260; // ~3 minutes of runtime setup budget + +/** Client-injected v2 entries install their runtime and plugin together. + * Trust keys on the parser-proof `builtIn` flag — the `capability:<id>` + * source string stays purely diagnostic. */ +function isCapabilityEntry(entry: PluginMarketplaceEntry): boolean { + return entry.builtIn === true; +} + +/** + * Closed-set plugin id check for the post-remove note. What must not happen + * is answering membership by running `listCapabilities()`, which fires every + * entry's detector (seconds of probes) just to print one hint line. + */ +function isCapabilityPluginId(id: string): boolean { + return id === 'kimi-cu' || id === 'kimi-cu-win' || id === 'kimi-webbridge'; +} + +/** Poll a background capability install until it settles (or we run out of budget). */ +async function pollCapabilityInstall( + host: SlashCommandHost, + id: string, +): Promise<CapabilityStatus | undefined> { + const api = await resolveCapabilityApi(host); + let previousProgress = ''; + for (let attempt = 0; attempt < CAPABILITY_POLL_ATTEMPTS; attempt += 1) { + await new Promise((resolve) => { + setTimeout(resolve, CAPABILITY_POLL_INTERVAL_MS); + }); + const status = await api.getCapability(id); + if (!status.install.running) return status; + const progress = `${status.install.step ?? ''}:${status.install.percent ?? ''}`; + if (progress !== previousProgress) { + previousProgress = progress; + log.info('capability install progress', { + capabilityId: id, + step: status.install.step, + percent: status.install.percent, + }); + } + } + return undefined; +} + +export const __pluginsCommandInternals = { + isCapabilityEntry, + installCapabilityFromPanel, + isDefaultMarketplaceCatalog, + pollCapabilityInstall, + removePlugin, +}; + +async function installCapabilityFromPanel( + host: SlashCommandHost, + panel: PluginsPanelComponent, + entry: PluginMarketplaceEntry, +): Promise<void> { + const label = entry.displayName; + // Capability entries are official by construction; the trust prompt is + // reserved for unreviewed third-party plugins. + panel.setInstalling(truncateForStatus(label)); + host.state.ui.requestRender(); + const api = await resolveCapabilityApi(host); + log.info('capability install requested', { capabilityId: entry.id }); + try { + // An install already running (started from another panel or client) is + // followed, not restarted — the service rejects duplicate starts even + // though the original is healthy. + const alreadyRunning = await api + .getCapability(entry.id) + .then((status) => status.install.running, () => false); + if (!alreadyRunning) { + await api.installCapability(entry.id); + } else { + log.info('following running capability install', { capabilityId: entry.id }); + } + } catch (error) { + log.warn('capability install failed to start', { capabilityId: entry.id, error }); + panel.clearInstalling(); + host.state.ui.requestRender(); + host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`); + host.restoreEditor(); + return; + } + let result: CapabilityStatus | undefined; + try { + result = await pollCapabilityInstall(host, entry.id); + } catch (error) { + log.warn('capability install polling failed', { capabilityId: entry.id, error }); + result = undefined; + } + panel.clearInstalling(); + // Close the panel so the result lines land in the transcript, matching the + // plain plugin install flow. + host.restoreEditor(); + if (result === undefined) { + host.showStatus(`${label} installation is still running in the background.`); + return; + } + logCapabilityStatus(result); + if (result.install.error !== undefined) { + host.showError(`${label} installation failed: ${result.install.error}`); + host.showStatus('Fix the reported error, then install again from /plugins.', 'warning'); + return; + } + if (result.state !== 'ready') { + const permissionsRequired = + entry.id === 'kimi-cu' && + result.steps.some((step) => step.id === 'permissions' && step.state !== 'ok'); + if (permissionsRequired) { + host.showStatus( + 'Grant Accessibility and Screen Recording in System Settings → Privacy & Security.', + 'warning', + ); + } else { + host.showError( + `${label} installation did not complete. Check the logs and install again from /plugins.`, + ); + } + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); + return; + } + if (entry.id === 'kimi-webbridge') { + host.showNotice(`${label} is installed.`); + host.state.transcriptContainer.addChild(new Spacer(1)); + host.state.transcriptContainer.addChild( + new Markdown(WEBBRIDGE_POST_INSTALL_MARKDOWN, 2, 0, createMarkdownTheme(), undefined, createMarkdownOptions()), + ); + host.state.ui.requestRender(); + return; + } + host.showStatus(`${label} is installed.`); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); +} + +async function installFromPanel( + host: SlashCommandHost, + panel: PluginsPanelComponent, + source: string, + label: string, + official: boolean, +): Promise<void> { + if (!(await confirmInstallTrust(host, label, official))) { + host.showStatus(`Install cancelled: ${label}.`); + host.restoreEditor(); + return; + } + // Official installs keep the panel mounted and show the inline installing + // state; third-party installs pass through a trust prompt that replaces the + // panel, so fall back to a transcript status for those. + if (official) { + panel.setInstalling(truncateForStatus(label)); + } else { + host.showStatus(`Installing or updating ${label} from marketplace…`); + } + host.state.ui.requestRender(); + try { + await installPluginFromSource(host, source); + } catch (error) { + if (official) { + panel.clearInstalling(); + host.state.ui.requestRender(); + } else { + // The trust prompt replaced the panel; re-mount it so the user can retry + // instead of being dropped back at the editor. + host.mountEditorReplacement(panel); + } + host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`); + return; + } + // Close the panel after installing so the result status and the + // "/reload or /new" tip are visible in the transcript. + host.restoreEditor(); +} + +async function applyPluginEnabled( + host: SlashCommandHost, + id: string, + enabled: boolean, + showStatus = true, +): Promise<string> { + const session = await resolvePluginApi(host); + await session.setPluginEnabled(id, enabled); + let info: PluginInfo | undefined; + try { + info = await session.getPluginInfo(id); + } catch { + info = undefined; + } + const mcpHint = + enabled && info !== undefined && info.mcpServerCount > info.enabledMcpServerCount + ? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} <server>.` + : ''; + if (showStatus) { + host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /reload or /new to apply.${mcpHint}`); + } + const inlineMcpHint = mcpHint.length > 0 ? ' · MCP servers disabled' : ''; + return `${pluginInlineChangeHint()}${inlineMcpHint}`; +} + +async function handlePluginsPanelSelection( + host: SlashCommandHost, + panel: PluginsPanelComponent, + selection: PluginsPanelSelection, +): Promise<void> { + switch (selection.kind) { + case 'toggle': { + const hint = await applyPluginEnabled(host, selection.id, selection.enabled, false); + await showPluginsPicker(host, { + initialTab: 'installed', + selectedId: selection.id, + pluginHint: { id: selection.id, text: hint }, + }); + return; + } + case 'remove': + if (!(await confirmRemovePlugin(host, selection.id))) { + host.showStatus(`Remove cancelled: ${selection.id}.`); + await showPluginsPicker(host, { initialTab: 'installed', selectedId: selection.id }); + return; + } + await removePlugin(host, selection.id); + await showPluginsPicker(host, { initialTab: 'installed' }); + return; + case 'mcp': + await showPluginMcpPicker(host, selection.id); + return; + case 'details': + host.restoreEditor(); + await renderPluginInfo(host, selection.id); + return; + case 'reload': + await reloadPlugins(host); + await showPluginsPicker(host, { initialTab: 'installed' }); + return; + case 'install': + if (isCapabilityEntry(selection.entry)) { + await installCapabilityFromPanel(host, panel, selection.entry); + return; + } + await installFromPanel( + host, + panel, + selection.entry.source, + selection.entry.displayName, + isOfficialPluginSource(selection.entry.source), + ); + return; + case 'install-source': + await installFromPanel( + host, + panel, + selection.source, + selection.source, + isOfficialPluginSource(selection.source), + ); + return; + case 'open-url': + host.restoreEditor(); + openUrl(selection.url); + host.showStatus(`Opening the ${selection.label} page in your browser…`, 'success'); + host.showStatus(`If it did not open, visit ${selection.url}`); + return; + } +} + +async function handlePluginMcpSelection( + host: SlashCommandHost, + selection: PluginMcpSelection, +): Promise<void> { + switch (selection.kind) { + case 'toggle': + await ( + await resolvePluginApi(host) + ).setPluginMcpServerEnabled(selection.pluginId, selection.server, selection.enabled); + await showPluginMcpPicker(host, selection.pluginId, { + selectedServer: selection.server, + serverHint: { + server: selection.server, + text: pluginInlineChangeHint(), + }, + }); + return; + case 'back': + await showPluginsPicker(host, { selectedId: selection.pluginId }); + return; + } +} + +async function removePlugin(host: SlashCommandHost, id: string): Promise<void> { + await (await resolvePluginApi(host)).removePlugin(id); + host.showStatus(`Removed ${id}.`); + if (isCapabilityPluginId(id)) { + host.showStatus( + 'Note: the runtime binaries were left untouched, but Kimi Code plugin wiring is disabled for new sessions. Restart Kimi Code before reinstalling from the Official tab.', + ); + return; + } + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); +} + +async function renderPluginsList( + host: SlashCommandHost, + plugins?: readonly PluginSummary[], +): Promise<void> { + const currentPlugins = plugins ?? (await (await resolvePluginApi(host)).listPlugins()); + const title = ` Plugins (${currentPlugins.length}) `; + const panel = new UsagePanelComponent( + () => buildPluginsListLines({ plugins: currentPlugins }), + 'primary', + title, + ); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); +} + +async function renderPluginInfo(host: SlashCommandHost, id: string): Promise<void> { + const info = await (await resolvePluginApi(host)).getPluginInfo(id); + const panel = new UsagePanelComponent( + () => buildPluginsInfoLines({ info }), + 'primary', + ` ${info.id} `, + ); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); +} + +async function installPluginFromSource( + host: SlashCommandHost, + source: string, +): Promise<void> { + const session = await resolvePluginApi(host); + const beforeList = await session.listPlugins(); + const summary = await session.installPlugin( + resolvePluginInstallSource(source, host.state.appState.workDir), + ); + showPluginInstallResult(host, beforeList, summary); +} + +const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.'; + +const WEBBRIDGE_POST_INSTALL_MARKDOWN = [ + '*Two steps left to use Kimi Browser Extension:*', + '1. Install the browser extension', + '', + ' - [Chrome Web Store](https://chromewebstore.google.com/detail/kimi-webbridge/fldmhceldgbpfpkbgopacenieobmligc)', + ' - [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/kimi-webbridge/bnlffdbcfnanfbknnlaflhlhkocccckg)', + ' - [Manual installation guide](https://www.kimi.com/code/docs/kimi-code-cli/customization/plugins.html#install-the-browser-extension)', + '', + '2. Run `/reload` or `/new` to apply it.', +].join('\n'); + +const PLUGIN_QUOTA_NOTE = 'Note: This plugin consumes your quota.'; + +function showPluginInstallResult( + host: SlashCommandHost, + beforeList: readonly PluginSummary[], + summary: PluginSummary, +): void { + const previous = beforeList.find((entry) => entry.id === summary.id); + const serverWord = summary.mcpServerCount === 1 ? 'server' : 'servers'; + const mcpHint = + summary.mcpServerCount > 0 + ? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.` + : ''; + const action = describeInstallAction(previous, summary); + host.showStatus(`${action} (${summary.id}).${mcpHint}`); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); + // Gate on provenance, not just the id: a local/GitHub fork whose manifest + // reuses a billed plugin's id is not the official quota-consuming build. + if (QUOTA_CONSUMING_PLUGIN_IDS.includes(summary.id) && isOfficialPluginInstall(summary)) { + host.showStatus(PLUGIN_QUOTA_NOTE, 'warning'); + } +} + +function describeInstallAction( + previous: PluginSummary | undefined, + next: PluginSummary, +): string { + const sourceLabel = formatPluginSourceLabel(next); + const versionFromTo = (prev?: string, cur?: string): string => { + if (prev === undefined || prev === cur) return cur === undefined ? '' : ` ${cur}`; + return ` ${prev} → ${cur ?? '-'}`; + }; + if (previous === undefined) { + return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} ${sourcePhrase(sourceLabel)}`; + } + if (sourceIdentity(previous) !== sourceIdentity(next)) { + const prevSourceLabel = formatPluginSourceLabel(previous); + return `Migrated ${next.displayName}: ${prevSourceLabel} → ${sourceLabel}${versionFromTo(previous.version, next.version)}`; + } + return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} ${sourcePhrase(sourceLabel)}`; +} + +// formatPluginSourceLabel already prefixes zip-url hosts with "via", so adding +// "from" would read as "from via <host>". Only prepend "from" otherwise. +function sourcePhrase(sourceLabel: string): string { + return sourceLabel.startsWith('via ') ? sourceLabel : `from ${sourceLabel}`; +} + +function sourceIdentity(plugin: PluginSummary): string { + if (plugin.source === 'github' && plugin.github !== undefined) { + return `github:${plugin.github.owner}/${plugin.github.repo}`; + } + return plugin.source; +} + +function truncateForStatus(input: string): string { + const max = 80; + return input.length > max ? `${input.slice(0, max - 1)}…` : input; +} + +async function reloadPlugins(host: SlashCommandHost): Promise<void> { + const summary = await (await resolvePluginApi(host)).reloadPlugins(); + const line = `Reload: +${summary.added.length} -${summary.removed.length}` + + (summary.errors.length > 0 ? ` (${summary.errors.length} errors)` : ''); + host.showStatus(line); + // Rebuild the TUI's plugin slash-command list from the reloaded service so + // newly added/enabled commands resolve in this session-less UI right away. + await host.refreshPluginCommands(host.session); +} + +function resolvePluginInstallSource(source: string, workDir: string): string { + const trimmed = source.trim(); + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed; + if (trimmed === '~') return osHomedir(); + if (trimmed.startsWith('~/')) return join(osHomedir(), trimmed.slice(2)); + return isAbsolute(trimmed) ? trimmed : resolve(workDir, trimmed); +} + +function pluginInlineChangeHint(): string { + return 'run /reload or /new to apply'; +} diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts new file mode 100644 index 0000000000000000000000000000000000000000..cbfc33072f58303091f416224d848085f6a8054d --- /dev/null +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -0,0 +1,251 @@ +import { + catalogModelToAlias, + resolveCatalogImport, + type Catalog, + type CatalogModel, + type ModelAlias, + type ThinkingEffort, +} from '@moonshot-ai/kimi-code-sdk'; +import { capabilitiesForModel } from '@moonshot-ai/kimi-code-oauth'; +import type { + ManagedKimiCodeModelInfo, + OpenPlatformDefinition, +} from '@moonshot-ai/kimi-code-oauth'; + +import { ApiKeyInputDialogComponent, type ApiKeyInputResult } from '../components/dialogs/api-key-input-dialog'; +import { ChoicePickerComponent, type ChoiceOption } from '../components/dialogs/choice-picker'; +import { FeedbackInputDialogComponent, type FeedbackInputDialogResult } from '../components/dialogs/feedback-input-dialog'; +import { ModelSelectorComponent } from '../components/dialogs/model-selector'; +import { PlatformSelectorComponent } from '../components/dialogs/platform-selector'; +import type { SlashCommandHost } from './dispatch'; + +export function promptPlatformSelection(host: SlashCommandHost): Promise<string | undefined> { + return new Promise((resolve) => { + const selector = new PlatformSelectorComponent({ + onSelect: (platformId) => { + host.restoreEditor(); + resolve(platformId); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(selector); + }); +} + +export function promptLogoutProviderSelection( + host: SlashCommandHost, + options: readonly ChoiceOption[], + currentValue: string | undefined, +): Promise<string | undefined> { + return new Promise((resolve) => { + const picker = new ChoicePickerComponent({ + title: 'Select a provider to log out', + options, + currentValue, + onSelect: (value) => { + host.restoreEditor(); + resolve(value); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(picker); + }); +} + +export interface FeedbackPromptResult { + readonly value: string; +} + +export function promptFeedbackInput(host: SlashCommandHost): Promise<FeedbackPromptResult | undefined> { + return new Promise((resolve) => { + const dialog = new FeedbackInputDialogComponent((result: FeedbackInputDialogResult) => { + host.restoreEditor(); + resolve(result.kind === 'ok' ? { value: result.value } : undefined); + }); + host.mountEditorReplacement(dialog); + }); +} + +export type FeedbackAttachmentLevel = 'none' | 'logs' | 'logs+codebase'; + +const FEEDBACK_ATTACHMENT_OPTIONS: readonly ChoiceOption[] = [ + { value: 'none', label: 'No attachment', description: 'Text feedback only' }, + { + value: 'logs', + label: 'Logs only', + description: 'Upload wire events and diagnostic logs from this session', + }, + { + value: 'logs+codebase', + label: 'Logs + codebase', + description: + 'Include your codebase for deeper diagnosis. Sensitive files are automatically excluded — e.g. .env, config files, secret keys. We use attachments only for diagnosis and never share them.', + descriptionTone: 'warning', + }, +]; + +export function promptFeedbackAttachment( + host: SlashCommandHost, +): Promise<FeedbackAttachmentLevel | undefined> { + return new Promise((resolve) => { + const picker = new ChoicePickerComponent({ + title: 'Share diagnostic info to help us investigate?', + options: FEEDBACK_ATTACHMENT_OPTIONS, + onSelect: (value) => { + host.restoreEditor(); + resolve(value as FeedbackAttachmentLevel); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(picker); + }); +} + +export function promptApiKey( + host: SlashCommandHost, + platformName: string, + subtitleLines: readonly string[] = ['Your key will be saved to ~/.kimi-code/config.toml'], +): Promise<string | undefined> { + return new Promise((resolve) => { + const dialog = new ApiKeyInputDialogComponent( + platformName, + subtitleLines, + (result: ApiKeyInputResult) => { + host.restoreEditor(); + resolve(result.kind === 'ok' ? result.value : undefined); + }, + ); + host.mountEditorReplacement(dialog); + }); +} + +/** + * Asks for the provider endpoint the catalog did not declare (or declared + * only as an env placeholder) — required for catalog imports whose protocol + * was guessed, where the built-in default endpoint would point at the wrong + * host. Esc cancels the import. + */ +export function promptBaseUrl(host: SlashCommandHost, platformName: string): Promise<string | undefined> { + return new Promise((resolve) => { + const dialog = new ApiKeyInputDialogComponent( + platformName, + ['The catalog declares no endpoint for this provider — enter its base URL.'], + (result: ApiKeyInputResult) => { + host.restoreEditor(); + resolve(result.kind === 'ok' ? result.value : undefined); + }, + { + title: `Enter base URL for ${platformName}`, + mask: false, + emptyHint: 'Base URL cannot be empty.', + }, + ); + host.mountEditorReplacement(dialog); + }); +} + +export function promptCatalogProviderSelection(host: SlashCommandHost, catalog: Catalog): Promise<string | undefined> { + return new Promise((resolve) => { + const options: ChoiceOption[] = Object.entries(catalog) + .filter(([, entry]) => resolveCatalogImport(entry).kind !== 'invalid') + .map(([id, entry]) => ({ + value: id, + label: entry.name ?? id, + description: + typeof entry.api === 'string' && entry.api.length > 0 ? entry.api : undefined, + })) + .toSorted((a, b) => a.label.localeCompare(b.label)); + + if (options.length === 0) { + host.showError('Catalog has no providers with supported wire types.'); + resolve(undefined); + return; + } + + const picker = new ChoicePickerComponent({ + title: 'Select a provider', + options, + searchable: true, + onSelect: (value) => { + host.restoreEditor(); + resolve(value); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(picker); + }); +} + +export async function promptModelSelectionForOpenPlatform( + host: SlashCommandHost, + models: ManagedKimiCodeModelInfo[], + platform: OpenPlatformDefinition, +): Promise<{ model: ManagedKimiCodeModelInfo; thinking: ThinkingEffort } | undefined> { + const modelDict: Record<string, ModelAlias> = {}; + for (const m of models) { + modelDict[`${platform.id}/${m.id}`] = { + provider: platform.id, + model: m.id, + maxContextSize: m.contextLength, + capabilities: capabilitiesForModel(m), + displayName: m.displayName, + }; + } + const selection = await runModelSelector(host, modelDict); + if (selection === undefined) return undefined; + const model = models.find((m) => `${platform.id}/${m.id}` === selection.alias); + return model ? { model, thinking: selection.thinking } : undefined; +} + +export async function promptModelSelectionForCatalog( + host: SlashCommandHost, + providerId: string, + models: CatalogModel[], +): Promise<{ model: CatalogModel; thinking: ThinkingEffort } | undefined> { + const modelDict: Record<string, ModelAlias> = {}; + for (const m of models) { + modelDict[`${providerId}/${m.id}`] = catalogModelToAlias(providerId, m); + } + const selection = await runModelSelector(host, modelDict); + if (selection === undefined) return undefined; + const model = models.find((m) => `${providerId}/${m.id}` === selection.alias); + return model ? { model, thinking: selection.thinking } : undefined; +} + +export function runModelSelector( + host: SlashCommandHost, + modelDict: Record<string, ModelAlias>, +): Promise<{ alias: string; thinking: ThinkingEffort } | undefined> { + return new Promise((resolve) => { + const firstAlias = Object.keys(modelDict)[0] ?? ''; + const caps = modelDict[firstAlias]?.capabilities ?? []; + const initialThinking = caps.includes('always_thinking') || caps.includes('thinking'); + const selector = new ModelSelectorComponent({ + models: modelDict, + currentValue: firstAlias, + currentThinkingEffort: initialThinking ? 'on' : 'off', + searchable: true, + onSelect: ({ alias, thinking }) => { + host.restoreEditor(); + resolve({ alias, thinking }); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(selector); + }); +} diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..492072a914577aa304cde8f1a34f12ed2a2d2065 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -0,0 +1,432 @@ +import { + applyCustomRegistryEntries, + fetchCustomRegistry, + type CustomRegistrySource, + type ManagedKimiConfigShape, +} from '@moonshot-ai/kimi-code-oauth'; +import { + applyCatalogProvider, + catalogProviderModels, + CatalogFetchError, + DEFAULT_CATALOG_URL, + resolveCatalogImport, + SECONDARY_DERIVED_MODEL_ALIAS, + type Catalog, + type ThinkingEffort, +} from '@moonshot-ai/kimi-code-sdk'; + +import { createKimiCodeUserAgent } from '#/cli/version'; +import { fetchCatalogOrBuiltIn } from '#/utils/catalog-fetch'; +import { refreshKimiRegion } from '#/utils/region'; +import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; +import { + CustomRegistryImportDialogComponent, + type CustomRegistryImportResult, +} from '../components/dialogs/custom-registry-import'; +import { + ProviderManagerComponent, + type ProviderManagerOptions, +} from '../components/dialogs/provider-manager'; +import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; +import { DEFAULT_OAUTH_PROVIDER_NAME } from '../constant/kimi-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import { thinkingEffortToConfig } from '../utils/thinking-config'; +import { effectiveModelForHost } from './config'; +import { + promptApiKey, + promptBaseUrl, + promptCatalogProviderSelection, +} from './prompts'; +import type { SlashCommandHost } from './dispatch'; + +// --------------------------------------------------------------------------- +// /provider command +// --------------------------------------------------------------------------- + +export async function handleProviderCommand(host: SlashCommandHost): Promise<void> { + const options = buildProviderManagerOptions(host); + const component = new ProviderManagerComponent(options); + host.mountEditorReplacement(component); +} + +function buildProviderManagerOptions(host: SlashCommandHost): ProviderManagerOptions { + const activeProviderId = + host.state.appState.availableModels[host.state.appState.model]?.provider; + return { + providers: host.state.appState.availableProviders, + activeProviderId, + onAdd: () => { + void handleProviderAdd(host).catch((error: unknown) => { + host.showError(`Add provider failed: ${formatErrorMessage(error)}`); + }); + }, + onDeleteSource: (providerIds) => { + void handleProviderManagerDeleteSource(host, providerIds).catch((error: unknown) => { + host.showError(`Remove provider failed: ${formatErrorMessage(error)}`); + }); + }, + onClose: () => { + host.restoreEditor(); + }, + }; +} + +async function handleProviderManagerDeleteSource( + host: SlashCommandHost, + providerIds: readonly string[], +): Promise<void> { + for (const providerId of providerIds) { + try { + await handleProviderDelete(host, providerId); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to delete provider ${providerId}: ${msg}`); + } + } + reopenProviderManager(host); +} + +async function handleProviderDelete(host: SlashCommandHost, providerId: string): Promise<void> { + if (providerId === DEFAULT_OAUTH_PROVIDER_NAME) { + await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME); + // Drop the process-wide region cache with the credential: derived + // endpoints (updates, marketplace, site links, telemetry) must fall back + // to the marker/default profile, not the logged-out region. + refreshKimiRegion(); + await host.authFlow.refreshConfigAfterLogout(); + return; + } + + const activeProvider = + host.state.appState.availableModels[host.state.appState.model]?.provider; + const config = await host.harness.removeProvider(providerId); + if (activeProvider === providerId) { + await host.authFlow.refreshConfigAfterLogout(); + } else { + host.setAppState({ + availableProviders: config.providers ?? {}, + availableModels: config.models ?? {}, + }); + } +} + +async function handleProviderAdd(host: SlashCommandHost): Promise<void> { + const source = await promptProviderAddSource(host); + if (source === undefined) { + reopenProviderManager(host); + return; + } + + if (source === 'known') { + await handleCatalogProviderAdd(host); + return; + } + const handled = await handleCustomRegistryAddViaDialog(host); + if (!handled) { + reopenProviderManager(host); + } +} + +function reopenProviderManager(host: SlashCommandHost): void { + const options = buildProviderManagerOptions(host); + const component = new ProviderManagerComponent(options); + host.mountEditorReplacement(component); +} + +function promptProviderAddSource( + host: SlashCommandHost, +): Promise<'known' | 'custom' | undefined> { + return new Promise((resolve) => { + const picker = new ChoicePickerComponent({ + title: 'Add provider', + options: [ + { value: 'known', label: 'Known third-party provider' }, + { value: 'custom', label: 'Custom registry (api.json)' }, + ], + onSelect: (value) => { + host.restoreEditor(); + resolve(value === 'known' || value === 'custom' ? value : undefined); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(picker); + }); +} + +async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> { + const controller = new AbortController(); + const cancel = (): void => { + controller.abort(); + }; + host.cancelInFlight = cancel; + + const spinner = host.showLoginProgressSpinner(`Fetching catalog from ${DEFAULT_CATALOG_URL}`); + let catalog: Catalog | undefined; + try { + const loaded = await fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { + signal: controller.signal, + userAgent: createKimiCodeUserAgent(), + }); + catalog = loaded.catalog; + spinner.stop({ + ok: true, + label: loaded.fromBuiltIn + ? 'Catalog loaded from built-in snapshot (models.dev unreachable).' + : 'Catalog loaded.', + }); + } catch (error) { + if (controller.signal.aborted) { + spinner.stop({ ok: false, label: 'Aborted.' }); + } else { + const hint = error instanceof CatalogFetchError ? ` (HTTP ${error.status})` : ''; + spinner.stop({ ok: false, label: 'Failed to load catalog.' }); + host.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`); + } + } finally { + if (host.cancelInFlight === cancel) host.cancelInFlight = undefined; + } + + if (catalog === undefined) return; + + const providerId = await promptCatalogProviderSelection(host, catalog); + if (providerId === undefined) return; + const entry = catalog[providerId]; + if (entry === undefined) return; + + const models = catalogProviderModels(entry); + if (models.length === 0) { + host.showError(`Provider "${providerId}" has no usable models in this catalog.`); + return; + } + + let resolution = resolveCatalogImport(entry); + if (resolution.kind === 'needs-base-url') { + const entered = await promptBaseUrl(host, entry.name ?? providerId); + if (entered === undefined) return; + resolution = resolveCatalogImport(entry, entered); + } + if (resolution.kind !== 'ok') { + if (resolution.kind === 'invalid') { + if (resolution.reason === 'unknown-explicit-type') { + host.showError( + `Provider "${providerId}" declares protocol "${entry.type}" in the catalog, which this client version does not support.`, + ); + } else if (resolution.reason === 'proprietary-sdk') { + host.showError( + `Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock or Cohere); it cannot be imported from the catalog.`, + ); + } else { + host.showError( + `Base URL contains an env placeholder or is empty. Enter the resolved URL instead.`, + ); + } + } + return; + } + const { wire, baseUrl } = resolution; + + const apiKey = await promptApiKey(host, entry.name ?? providerId); + if (apiKey === undefined) return; + + // Persist the provider and all its models immediately after the api key is + // entered. The model selector that follows is just a convenience to pick the + // default model; ESC leaves the provider in place without a default selection. + const existingConfig = await host.harness.getConfig(); + if (existingConfig.providers[providerId] !== undefined) { + await host.harness.removeProvider(providerId); + } + + const config = await host.harness.getConfig(); + applyCatalogProvider(config, { + providerId, + wire, + baseUrl, + apiKey, + models, + selectedModelId: '', // no default yet; user picks in the model selector + thinking: false, // will be resolved by the model selector + }); + + await host.harness.setConfig({ + providers: config.providers, + models: config.models, + }); + + await host.authFlow.refreshConfigAfterLogin(); + host.track('connect', { provider: providerId, method: 'catalog' }); + host.showStatus(`Provider added: ${entry.name ?? providerId}`); + if (resolution.guessed) { + host.showStatus( + `Protocol guessed as "openai" for ${providerId} — edit "type" in config.toml if requests fail.`, + ); + } + + // Build a merged model dictionary that includes existing models plus the + // newly-persisted provider's models, so the tabbed selector shows every + // provider's tab (the new provider's tab starts active via initialTabId). + // The v1 runtime may carry the synthesized `__secondary__` derived entry — + // never selectable in a picker. + const stateModels = await host.harness.getConfig().then((c) => c.models ?? {}); + const mergedModels = { ...stateModels }; + delete mergedModels[SECONDARY_DERIVED_MODEL_ALIAS]; + + const selector = new TabbedModelSelectorComponent({ + models: mergedModels, + currentValue: host.state.appState.model, + selectedValue: Object.keys(mergedModels).find((a) => a.startsWith(`${providerId}/`)), + currentThinkingEffort: host.state.appState.thinkingEffort, + initialTabId: providerId, + onSelect: ({ alias, thinking }) => { + host.restoreEditor(); + void setDefaultModel(host, alias, thinking).catch((error: unknown) => { + host.showError(`Set default model failed: ${formatErrorMessage(error)}`); + }); + }, + onCancel: () => { + host.restoreEditor(); + }, + }); + host.mountEditorReplacement(selector); +} + +export async function setDefaultModel( + host: SlashCommandHost, + alias: string, + effort: ThinkingEffort, +): Promise<void> { + // Resolve efforts the same way the /model path does (effectiveModelForHost + // applies overrides and the protocol-profile inference): catalog entries for + // e.g. Anthropic models declare no support_efforts on the alias, and without + // the inference an above-default pick would slip through as a persisted effort. + const model = host.state.appState.availableModels[alias]; + const thinking = thinkingEffortToConfig( + effort, + model === undefined ? undefined : effectiveModelForHost(host, model), + ); + if (host.session === undefined) { + // A first prompt may still be inside lazy creation: wait it out so the + // pick lands on the new session instead of racing its assembly (same + // coordination as the /model path). + await host.waitForLazyCreation(); + } + await host.harness.setConfig({ + defaultModel: alias, + thinking, + }); + // Whether activation made the engine emit model_switch (it reached a live + // session AND changed the bound alias — both engines track only an actual + // change). Recorded at activation time rather than snapshotted at entry: a + // lazy session can come live while the config writes above are pending; a + // session created BY activation (v1) or a same-alias rebind does not count + // — both bind the model without an engine event. + let engineTrackedSwitch = await host.authFlow.refreshConfigAfterLogin(); + // refreshConfigAfterLogin reactivates from the persisted config, so a pick + // the gate keeps session-only never reaches the runtime — apply it after + // the refresh, or the persisted value would clobber it. + if (thinking.effort === undefined && effort !== 'off' && effort !== 'on') { + engineTrackedSwitch = + (await host.authFlow.activateModelAfterLogin(alias, effort)) || engineTrackedSwitch; + } + // When the engine never emitted (no live session, or the alias was already + // bound), the TUI stays the sole producer for the pick. + if (!engineTrackedSwitch) { + host.track('model_switch', { model: alias }); + } + host.showStatus(`Default model set to ${alias} with thinking ${effort}.`); +} + +async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise<boolean> { + const value = await promptCustomRegistryImport(host); + if (value === undefined) return false; + + const source: CustomRegistrySource = { + kind: 'apiJson', + url: value.url, + apiKey: value.apiKey, + }; + + let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>; + try { + entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() }); + } catch (error) { + host.showError(`Failed to import registry: ${formatErrorMessage(error)}`); + return false; + } + + const addedProviderIds = Object.values(entries).map((entry) => entry.id); + try { + const config = await host.harness.getConfig(); + applyCustomRegistryEntries( + config as unknown as ManagedKimiConfigShape, + entries, + source, + ); + await host.harness.setConfig({ + providers: config.providers, + models: config.models, + }); + await host.authFlow.refreshConfigAfterLogin(); + } catch (error) { + host.showError(`Failed to apply registry: ${formatErrorMessage(error)}`); + return false; + } + + const count = addedProviderIds.length; + if (count === 0) { + host.showStatus('Registry contained no providers.'); + return false; + } + host.showStatus( + count === 1 + ? 'Imported 1 provider from registry.' + : `Imported ${String(count)} providers from registry.`, + 'success', + ); + + // Offer the model selector so the user can pick a default, just like the + // catalog (known-provider) flow. Copy without the v1-synthesized + // `__secondary__` derived entry — never selectable in a picker. + const stateModels = { ...(await host.harness.getConfig().then((c) => c.models ?? {})) }; + delete stateModels[SECONDARY_DERIVED_MODEL_ALIAS]; + const firstNewAlias = Object.keys(stateModels).find((a) => + addedProviderIds.some((pid) => a.startsWith(`${pid}/`)), + ); + const firstNewProvider = firstNewAlias + ? stateModels[firstNewAlias]?.provider + : addedProviderIds[0]; + const selector = new TabbedModelSelectorComponent({ + models: stateModels, + currentValue: host.state.appState.model, + selectedValue: firstNewAlias, + currentThinkingEffort: host.state.appState.thinkingEffort, + initialTabId: firstNewProvider, + onSelect: ({ alias, thinking }) => { + host.restoreEditor(); + void setDefaultModel(host, alias, thinking).catch((error: unknown) => { + host.showError(`Set default model failed: ${formatErrorMessage(error)}`); + }); + }, + onCancel: () => { + host.restoreEditor(); + }, + }); + host.mountEditorReplacement(selector); + return true; +} + +function promptCustomRegistryImport( + host: SlashCommandHost, +): Promise<{ readonly url: string; readonly apiKey: string } | undefined> { + return new Promise((resolve) => { + const dialog = new CustomRegistryImportDialogComponent( + (result: CustomRegistryImportResult) => { + host.restoreEditor(); + resolve(result.kind === 'ok' ? result.value : undefined); + }, + ); + host.mountEditorReplacement(dialog); + }); +} diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts new file mode 100644 index 0000000000000000000000000000000000000000..343dae738e8f7ad4de8b8432b048a944e546e36f --- /dev/null +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -0,0 +1,476 @@ +import { readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, dirname, join, relative, resolve } from 'pathe'; + +import type { AutocompleteItem } from '@moonshot-ai/pi-tui'; + +import { completeLeadingArg, type ArgCompletionSpec } from './complete-args'; +import type { KimiSlashCommand, SlashCommandAvailability } from './types'; + +/** Subcommands offered when autocompleting `/goal <…>`. */ +const GOAL_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'status', description: 'Show the current goal' }, + { value: 'pause', description: 'Pause the active goal' }, + { value: 'resume', description: 'Resume a paused goal' }, + { value: 'cancel', description: 'Cancel and remove the current goal' }, + { value: 'replace', description: 'Replace the current goal with a new objective' }, + { value: 'next', description: 'Queue an upcoming goal' }, +]; + +const GOAL_NEXT_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'manage', description: 'Manage upcoming goals' }, +]; + +const SWARM_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'on', description: 'Turn swarm mode on' }, + { value: 'off', description: 'Turn swarm mode off' }, +]; + +const TOWER_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'status', description: 'Report tower status' }, + { value: 'teardown', description: 'Tear down the tower' }, + { value: 'on', description: 'Turn tower mode on' }, + { value: 'off', description: 'Turn tower mode off' }, +]; + +const ADD_DIR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'list', description: 'Show configured additional workspace directories' }, +]; + +/** Argument autocompletion for the `/goal` command (subcommands). */ +export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i); + if (nextMatch !== null) { + return ( + completeLeadingArg(GOAL_NEXT_ARG_COMPLETIONS, nextMatch[1] ?? '')?.map((item) => ({ + ...item, + value: `next ${item.value}`, + })) ?? null + ); + } + return completeLeadingArg(GOAL_ARG_COMPLETIONS, argumentPrefix); +} + +/** Argument autocompletion for the `/swarm` command (subcommands). */ +export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix); +} + +/** Argument autocompletion for the `/tower` command (subcommands). */ +export function towerArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + return completeLeadingArg(TOWER_ARG_COMPLETIONS, argumentPrefix); +} + +/** Argument autocompletion for the `/add-dir` command. */ +export function addDirArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + if (isPathLikeAddDirArgument(argumentPrefix)) { + return completeAddDirPath(argumentPrefix); + } + return completeLeadingArg(ADD_DIR_ARG_COMPLETIONS, argumentPrefix); +} + +function isPathLikeAddDirArgument(argumentPrefix: string): boolean { + return argumentPrefix === '.' || argumentPrefix === '..' || argumentPrefix.startsWith('./') || argumentPrefix.startsWith('../') || argumentPrefix.startsWith('/') || argumentPrefix.startsWith('~'); +} + +function completeAddDirPath(argumentPrefix: string): AutocompleteItem[] | null { + const normalizedPrefix = argumentPrefix === '~' ? '~/' : argumentPrefix; + const expandedPrefix = expandHomePrefix(normalizedPrefix); + const parentInput = getDirectoryCompletionParentInput(normalizedPrefix, expandedPrefix); + const partialName = normalizedPrefix.endsWith('/') ? '' : basename(expandedPrefix); + const parentDir = resolveDirectoryCompletionParent(parentInput); + let entries; + try { + entries = readdirSync(parentDir, { withFileTypes: true }); + } catch { + return null; + } + + const items: AutocompleteItem[] = []; + for (const entry of entries) { + if (entry.name === '.' || entry.name === '..' || entry.name.startsWith('.')) continue; + if (partialName.length > 0 && !entry.name.toLowerCase().startsWith(partialName.toLowerCase())) continue; + const absolutePath = join(parentDir, entry.name); + if (!isDirectoryPath(absolutePath, entry.isDirectory(), entry.isSymbolicLink())) continue; + const value = formatDirectoryCompletionValue(normalizedPrefix, parentInput, entry.name); + items.push({ + value, + label: `${entry.name}/`, + description: absolutePath, + }); + } + + return items.length > 0 ? items : null; +} + +function expandHomePrefix(argumentPrefix: string): string { + if (argumentPrefix === '~') return homedir(); + if (argumentPrefix.startsWith('~/')) return join(homedir(), argumentPrefix.slice(2)); + return argumentPrefix; +} + +function getDirectoryCompletionParentInput(argumentPrefix: string, expandedPrefix: string): string { + if (argumentPrefix === '/') return '/'; + if (argumentPrefix === '~/') return homedir(); + if (argumentPrefix.endsWith('/')) return expandedPrefix.slice(0, -1); + return dirname(expandedPrefix); +} + +function resolveDirectoryCompletionParent(parentInput: string): string { + if (parentInput === '~') return homedir(); + if (parentInput.startsWith('~/')) return join(homedir(), parentInput.slice(2)); + return resolve(parentInput); +} + +function isDirectoryPath(path: string, isDirectory: boolean, isSymlink: boolean): boolean { + if (isDirectory) return true; + if (!isSymlink) return false; + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function formatDirectoryCompletionValue(argumentPrefix: string, parentInput: string, entryName: string): string { + if (argumentPrefix.startsWith('~/')) { + const home = homedir(); + const homeRelative = relative(home, parentInput); + return `~${homeRelative.length > 0 ? `/${homeRelative}` : ''}/${entryName}/`; + } + if (argumentPrefix.startsWith('/')) { + return `${join(parentInput, entryName)}/`; + } + return `${join(parentInput, entryName)}/`; +} + +export const BUILTIN_SLASH_COMMANDS = [ + { + name: 'yolo', + aliases: ['yes'], + description: 'Ask When Needed mode: routine edits and commands run automatically; risky actions, questions, and plans still ask.', + priority: 101, + availability: 'always', + }, + { + name: 'auto', + aliases: [], + description: 'Never Ask mode: never interrupts you; everything runs and is decided automatically.', + priority: 99, + availability: 'always', + }, + { + name: 'permission', + aliases: [], + description: 'Select permission mode', + priority: 100, + availability: 'always', + }, + { + name: 'settings', + aliases: ['config'], + description: 'Open TUI settings', + priority: 100, + availability: 'always', + }, + { + name: 'plan', + aliases: [], + description: 'Toggle plan mode', + priority: 100, + availability: (args) => (args.trim().toLowerCase() === 'clear' ? 'idle-only' : 'always'), + }, + { + name: 'swarm', + aliases: [], + description: 'Toggle swarm mode or run one task in swarm mode', + priority: 100, + argumentHint: '[on|off] | <task>', + completeArgs: swarmArgumentCompletions, + availability: 'idle-only', + }, + { + name: 'tower', + aliases: [], + description: 'Report tower status, toggle tower mode, or turn it on with a base branch', + priority: 100, + argumentHint: '[status|teardown|on|off] | <base-branch>', + completeArgs: towerArgumentCompletions, + // Every form stays available while busy: base selections apply to the next + // TowerInit of the running coordinator turn, so /tower commands never wait + // for the previous one to finish. + availability: 'always', + experimentalFlag: 'tower', + }, + { + name: 'model', + aliases: [], + description: 'Switch LLM model', + priority: 100, + availability: 'always', + }, + { + name: 'secondary-model', + aliases: ['subagent-model'], + description: 'Configure the secondary model for subagents', + priority: 90, + availability: 'always', + }, + { + name: 'effort', + aliases: ['thinking'], + description: 'Switch thinking effort', + priority: 95, + availability: 'always', + }, + { + name: 'provider', + aliases: ['providers'], + description: 'Manage AI providers (add / delete / refresh)', + priority: 95, + availability: 'always', + }, + { + name: 'btw', + aliases: [], + description: 'Ask a forked side agent a question', + priority: 90, + availability: 'always', + }, + { + name: 'help', + aliases: ['h', '?'], + description: 'Show available commands and shortcuts', + priority: 80, + availability: 'always', + }, + { + name: 'new', + aliases: ['clear'], + description: 'Start a fresh session in the current workspace', + priority: 80, + }, + { + name: 'sessions', + aliases: ['resume'], + description: 'Browse and resume sessions', + priority: 80, + }, + { + name: 'tasks', + aliases: ['task'], + description: 'Browse background tasks', + priority: 80, + availability: 'always', + }, + { + name: 'mcp', + aliases: [], + description: 'Show MCP server status', + priority: 60, + availability: 'always', + }, + { + name: 'plugins', + aliases: [], + description: 'Manage plugins', + priority: 60, + availability: 'always', + }, + { + name: 'add-dir', + aliases: [], + description: 'Add or list an additional workspace directory', + priority: 60, + availability: 'idle-only', + argumentHint: '[list] | <path>', + completeArgs: addDirArgumentCompletions, + }, + { + name: 'experiments', + aliases: ['experimental'], + description: 'Manage experimental features', + priority: 60, + availability: 'idle-only', + }, + { + name: 'reload', + aliases: [], + description: 'Reload session and apply config.toml settings plus tui.toml UI preferences', + priority: 60, + availability: 'idle-only', + }, + { + name: 'reload-tui', + aliases: [], + description: 'Reload only tui.toml UI preferences', + priority: 60, + availability: 'always', + }, + { + name: 'compact', + aliases: [], + description: 'Compact the conversation context', + priority: 80, + argumentHint: '<instruction>', + }, + { + name: 'goal', + aliases: [], + description: 'Start or manage an autonomous goal', + priority: 80, + argumentHint: '[status|pause|resume|cancel|replace|next] | <objective>', + completeArgs: goalArgumentCompletions, + // status / pause / cancel are always available; creation, replacement, and + // resume start (or restart) a turn and so are idle-only. + availability: (args) => { + const trimmed = args.trim(); + if (trimmed === 'next' || trimmed.startsWith('next ')) return 'always'; + return trimmed === '' || trimmed === 'status' || trimmed === 'pause' || trimmed === 'cancel' + ? 'always' + : 'idle-only'; + }, + }, + { + name: 'init', + aliases: [], + description: 'Analyze the codebase and generate AGENTS.md', + }, + { + name: 'fork', + aliases: [], + description: 'Fork the current session into a copy without switching to it', + priority: 80, + }, + { + name: 'title', + aliases: ['rename'], + description: 'Set or show session title', + priority: 60, + argumentHint: '<title>', + availability: 'always', + }, + { + name: 'usage', + aliases: [], + description: 'Show session tokens + context window + plan quotas', + priority: 60, + availability: 'always', + }, + { + name: 'status', + aliases: [], + description: 'Show current session and runtime status', + priority: 60, + availability: 'always', + }, + { + name: 'feedback', + aliases: ['bug'], + description: 'Send feedback to make Kimi Code better', + priority: 60, + availability: 'always', + }, + { + name: 'undo', + aliases: [], + description: 'Withdraw the last prompt from the transcript', + priority: 80, + availability: 'idle-only', + }, + { + name: 'editor', + aliases: [], + description: 'Set the external editor for Ctrl-G', + priority: 60, + availability: 'always', + }, + { + name: 'theme', + aliases: [], + description: 'Set the terminal UI theme', + priority: 60, + availability: 'always', + }, + { + name: 'logout', + aliases: ['disconnect'], + description: 'Log out of a configured provider', + priority: 40, + }, + { + name: 'login', + aliases: [], + description: 'Select a platform and authenticate', + priority: 40, + }, + { + name: 'export-md', + aliases: ['export'], + description: 'Export current session as a Markdown file', + priority: 40, + }, + { + name: 'export-debug-zip', + aliases: [], + description: 'Export current session as a debug ZIP archive', + priority: 40, + }, + { + name: 'copy', + aliases: [], + description: 'Copy the last assistant message to the clipboard', + priority: 40, + }, + { + name: 'web', + aliases: [], + description: 'Open the current session in the Web UI by starting a new server', + priority: 40, + availability: 'always', + }, + { + name: 'remote-control', + aliases: ['rc'], + description: 'Open the current session through Kimi Remote Control', + priority: 40, + availability: 'always', + }, + { + name: 'exit', + aliases: ['quit', 'q'], + description: 'Exit the application', + priority: 20, + }, + { + name: 'version', + aliases: [], + description: 'Show version information', + priority: 20, + availability: 'always', + }, +] as const satisfies readonly KimiSlashCommand[]; + +export type BuiltinSlashCommand = (typeof BUILTIN_SLASH_COMMANDS)[number]; +export type BuiltinSlashCommandName = BuiltinSlashCommand['name']; + +export function findBuiltInSlashCommand(commandName: string): BuiltinSlashCommand | undefined { + const commands = BUILTIN_SLASH_COMMANDS as readonly KimiSlashCommand<BuiltinSlashCommandName>[]; + return commands.find( + (command) => command.name === commandName || command.aliases.includes(commandName), + ) as BuiltinSlashCommand | undefined; +} + +export function resolveSlashCommandAvailability( + command: KimiSlashCommand, + args: string, +): SlashCommandAvailability { + const availability = command.availability ?? 'idle-only'; + return typeof availability === 'function' ? availability(args) : availability; +} + +export function sortSlashCommands(commands: readonly KimiSlashCommand[]): KimiSlashCommand[] { + return [...commands].toSorted( + (a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.name.localeCompare(b.name), + ); +} diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts new file mode 100644 index 0000000000000000000000000000000000000000..2fa34312902da73cc645d52b3f76550fd85341d5 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -0,0 +1,86 @@ +import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; + +import { currentTheme, lightColors } from '#/tui/theme'; +import { loadTuiConfig, type TuiConfig } from '../config'; +import { setMarkdownRenderLatex } from '../utils/markdown-options'; +import type { SlashCommandHost } from './dispatch'; +import { setExperimentalFeatures } from './experimental-flags'; + +export async function handleReloadTuiCommand(host: SlashCommandHost): Promise<void> { + const tuiConfig = await loadTuiConfig(undefined, (message) => + host.showStatus(message, 'warning'), + ); + await applyReloadedTuiConfig(host, tuiConfig); + host.showStatus('TUI config reloaded.', 'success'); +} + +export async function handleReloadCommand(host: SlashCommandHost): Promise<void> { + const tuiConfig = await loadTuiConfig(undefined, (message) => + host.showStatus(message, 'warning'), + ); + const session = host.session; + + if (session !== undefined) { + const reloadedSession = await host.harness.reloadSession({ + id: session.id, + forcePluginSessionStartReminder: true, + }); + await host.reloadCurrentSessionView(reloadedSession, 'Session reloaded.'); + } + + const config = await host.harness.getConfig({ reload: true }); + setExperimentalFeatures(await host.harness.getExperimentalFeatures()); + if (session === undefined) { + // Session-less v2: rebuild the workspace-level dynamic commands too, so + // skill/plugin changes apply before the first session exists. + await host.refreshSkillCommands(); + await host.refreshPluginCommands(); + } + host.refreshSlashCommandAutocomplete(); + applyRuntimeConfig(host, config); + await applyReloadedTuiConfig(host, tuiConfig); + + if (session === undefined) { + // Still session-less on the v2 engine: refresh the lazy defaults too, so + // defaults edited externally (config.toml, a newly added default model) + // reach the first lazy-created session instead of staying stale. + await host.hydrateLazyConfigDefaults(); + host.showStatus( + 'Runtime and TUI config reloaded; no active session.', + 'success', + ); + } +} + +export async function applyReloadedTuiConfig( + host: SlashCommandHost, + config: TuiConfig, +): Promise<void> { + // Set the LaTeX toggle before applyTheme: theme application invalidates the + // transcript components, which rebuild their Markdown children and copy the + // options at construction — so the new value must be live by then. + setMarkdownRenderLatex(config.renderLatex ?? true); + const resolved = config.theme === 'auto' + ? (currentTheme.palette === lightColors ? 'light' : 'dark') + : undefined; + await host.applyTheme(config.theme, resolved); + host.refreshTerminalThemeTracking(); + host.setAppState({ + editorCommand: config.editorCommand, + disablePasteBurst: config.disablePasteBurst, + renderLatex: config.renderLatex, + cacheExpiryHint: config.cacheExpiryHint, + disableFeedbackSurvey: config.disableFeedbackSurvey, + notifications: config.notifications, + upgrade: config.upgrade, + statusLine: config.statusLine, + }); + host.state.editor.setDisablePasteBurst(config.disablePasteBurst); +} + +function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void { + host.setAppState({ + availableModels: config.models ?? {}, + availableProviders: config.providers ?? {}, + }); +} diff --git a/apps/kimi-code/src/tui/commands/resolve.ts b/apps/kimi-code/src/tui/commands/resolve.ts new file mode 100644 index 0000000000000000000000000000000000000000..7bcc942ad17b207a89b0310d73fd6aaf15e5c92b --- /dev/null +++ b/apps/kimi-code/src/tui/commands/resolve.ts @@ -0,0 +1,157 @@ +import { + findBuiltInSlashCommand, + resolveSlashCommandAvailability, + type BuiltinSlashCommand, + type BuiltinSlashCommandName, +} from './registry'; +import { isExperimentalFlagEnabled } from './experimental-flags'; +import { parseSlashInput } from './parse'; +import type { TUIState } from '../tui-state'; +import type { + KimiSlashCommand, + SlashCommandBusyReason, + SlashCommandInvalidReason, +} from './types'; + +export type SlashCommandIntent = + | { readonly kind: 'not-command' } + | { + readonly kind: 'builtin'; + readonly command: BuiltinSlashCommand; + readonly name: BuiltinSlashCommandName; + readonly args: string; + } + | { + readonly kind: 'skill'; + readonly commandName: string; + readonly skillName: string; + readonly args: string; + } + | { + readonly kind: 'plugin-command'; + readonly commandName: string; + readonly pluginId: string; + readonly args: string; + } + | { readonly kind: 'message'; readonly input: string } + | { + readonly kind: 'blocked'; + readonly commandName: string; + readonly reason: SlashCommandBusyReason; + } + | { + readonly kind: 'invalid'; + readonly commandName: string; + readonly reason: SlashCommandInvalidReason; + }; + +export interface ResolveSlashCommandInput { + readonly input: string; + readonly skillCommandMap: ReadonlyMap<string, string>; + readonly pluginCommandMap: ReadonlyMap<string, string>; + readonly isStreaming: boolean; + readonly isCompacting: boolean; +} + +export function resolveSlashCommandInput(options: ResolveSlashCommandInput): SlashCommandIntent { + const parsed = parseSlashInput(options.input); + if (parsed === null) return { kind: 'not-command' }; + + const command = findBuiltInSlashCommand(parsed.name); + // `command` is a literal union where only some members carry `experimentalFlag`; widen to read it. + if ( + command !== undefined && + isExperimentalFlagEnabled((command as KimiSlashCommand).experimentalFlag) + ) { + const busyReason = slashCommandBusyReason(options); + if ( + busyReason !== undefined && + resolveSlashCommandAvailability(command, parsed.args) === 'idle-only' + ) { + return { + kind: 'blocked', + commandName: parsed.name, + reason: busyReason, + }; + } + return { + kind: 'builtin', + command, + name: command.name, + args: parsed.args, + }; + } + + const skillName = resolveSkillCommand(options.skillCommandMap, parsed.name); + if (skillName !== undefined) { + // Skill activations are never blocked by a busy session: the TUI queues + // them behind the running turn exactly like normal messages (see + // sendSkillActivation), and Ctrl-S steers them as real activations, so + // skill commands can be issued any time. + return { + kind: 'skill', + commandName: parsed.name, + skillName, + args: parsed.args.trim(), + }; + } + + if (options.pluginCommandMap.has(parsed.name)) { + const busyReason = slashCommandBusyReason(options); + if (busyReason !== undefined) { + return { + kind: 'blocked', + commandName: parsed.name, + reason: busyReason, + }; + } + const separator = parsed.name.indexOf(':'); + const pluginId = separator === -1 ? parsed.name : parsed.name.slice(0, separator); + const commandName = separator === -1 ? '' : parsed.name.slice(separator + 1); + return { + kind: 'plugin-command', + commandName, + pluginId, + args: parsed.args.trim(), + }; + } + + return { + kind: 'message', + input: options.input, + }; +} + +export function resolveSkillCommand( + skillCommandMap: ReadonlyMap<string, string>, + commandName: string, +): string | undefined { + return skillCommandMap.get(commandName) ?? skillCommandMap.get(`skill:${commandName}`); +} + +export function slashCommandBusyReason( + options: Pick<ResolveSlashCommandInput, 'isStreaming' | 'isCompacting'>, +): SlashCommandBusyReason | undefined { + if (options.isStreaming) return 'streaming'; + if (options.isCompacting) return 'compacting'; + return undefined; +} + +export function slashBusyMessage( + commandName: string, + reason: SlashCommandBusyReason, +): string { + if (reason === 'streaming') { + return `Cannot /${commandName} while streaming — press Esc or Ctrl-C first.`; + } + return `Cannot /${commandName} while compacting — wait for compaction to finish first.`; +} + +/** + * Whether a delayed input restore is still safe: the editor must be empty + * (no newer draft) and still mounted (no editor-replacement panel opened + * meanwhile). Restores that run synchronously with submit do not need this. + */ +export function canRestoreSubmittedInput(host: { state: TUIState }): boolean { + return host.state.editor.getText().length === 0 && !host.state.editorReplacementMounted; +} diff --git a/apps/kimi-code/src/tui/commands/session.ts b/apps/kimi-code/src/tui/commands/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f773209b7e8c0d95ac12c0d3a79ebe6e56cbf73 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/session.ts @@ -0,0 +1,219 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type { Session } from '@moonshot-ai/kimi-code-sdk'; + +import { detectInstallSource } from '#/cli/update/source'; +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; +import { detectShellEnvironment } from '#/utils/process/shell-env'; +import { quoteShellArg } from '#/utils/shell-quote'; +import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; +import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { isAbortError } from '../utils/errors'; +import { formatErrorMessage } from '../utils/event-payload'; +import { buildExportMarkdown } from '../utils/export-markdown'; +import type { SlashCommandHost } from './dispatch'; + +// --------------------------------------------------------------------------- +// Session commands +// --------------------------------------------------------------------------- + +export async function handleTitleCommand(host: SlashCommandHost, args: string): Promise<void> { + const title = args.trim(); + if (title.length === 0) { + const current = host.state.appState.sessionTitle; + host.showStatus( + current !== null && current.length > 0 + ? `Session title: ${current}` + : `Session title: (not set) — id: ${host.state.appState.sessionId}`, + ); + return; + } + + let session = host.session; + if (session === undefined) { + // Setting a title needs a live session; lazy-create it on first use (the + // bare read-only form above works session-less). + session = await host.ensureSession(); + if (session === undefined) return; + } + + const newTitle = title.slice(0, 200); + try { + await host.harness.renameSession({ id: session.id, title: newTitle }); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to set title: ${msg}`); + return; + } + host.showStatus(`Session title set to: ${newTitle}`); +} + +export async function handleForkCommand(host: SlashCommandHost, args: string): Promise<void> { + void args; + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const sourceTitle = forkSourceTitle(host, session); + try { + const forked = await host.harness.forkSession({ + id: session.id, + title: `Fork: ${sourceTitle}`, + }); + const forkId = forked.id; + try { + await forked.close(); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Session forked (${forkId}), but failed to release its runtime: ${msg}`); + return; + } + // Stay in the source session: switching to the fork would close the + // source, killing its in-flight turn and background tasks. The fork is + // an independent copy the user can switch to explicitly via /sessions, + // or enter from a new CLI process with the printed resume command. + const command = forkResumeCommand(host.state.appState.workDir, forkId); + let clipboardNote: string; + try { + const method = await copyTextToClipboard(command); + // OSC 52 delivery is fire-and-forget: terminals without OSC 52 support + // silently drop the sequence, so only native delivery may claim success + // (same wording convention as /copy). + clipboardNote = + method === 'native' + ? 'Command copied to clipboard' + : 'Command copied via terminal escape sequence (unverified)'; + } catch { + clipboardNote = 'Failed to copy command to clipboard'; + } + host.showStatus( + `Session forked (${forkId}). Still in the original session; switch to the fork via /sessions.\n` + + ` To enter the fork in a new process, run: ${command}\n` + + ` ${clipboardNote}`, + ); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to fork session: ${msg}`); + } +} + +function forkResumeCommand(workDir: string, forkId: string): string { + const dir = quoteShellArg(workDir); + // cmd.exe's `cd` only updates the given drive's remembered directory — a + // terminal on a different drive stays put, and the resume then runs in the + // wrong working directory. `pushd` switches drive + directory in both + // cmd.exe and PowerShell (`cd /d` would break PowerShell). + const changeDir = process.platform === 'win32' ? `pushd ${dir}` : `cd ${dir}`; + return `${changeDir} && kimi --resume ${quoteShellArg(forkId)}`; +} + +function forkSourceTitle(host: SlashCommandHost, session: Session): string { + const currentTitle = host.state.appState.sessionTitle?.trim(); + if (currentTitle !== undefined && currentTitle.length > 0) return currentTitle; + + const summaryTitle = + typeof session.summary?.title === 'string' ? session.summary.title.trim() : ''; + return summaryTitle.length > 0 ? summaryTitle : session.id; +} + +export async function handleExportMdCommand(host: SlashCommandHost, args: string): Promise<void> { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + host.showStatus('Exporting session as Markdown…'); + try { + const context = await session.getContext(); + if (context.history.length === 0) { + host.showError('No messages to export.'); + return; + } + + const now = new Date(); + const shortId = session.id.slice(0, 8); + const timestamp = now.toISOString().replaceAll(/[-:]/g, '').replace(/T/, '-').slice(0, 15); + const defaultName = `kimi-export-${shortId}-${timestamp}.md`; + + const trimmedArgs = args.trim(); + const outputPath = trimmedArgs.length > 0 + ? resolve(trimmedArgs) + : resolve(host.state.appState.workDir, defaultName); + + const md = buildExportMarkdown({ + sessionId: session.id, + workDir: host.state.appState.workDir, + history: context.history, + tokenCount: context.tokenCount, + now, + }); + + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, md, 'utf-8'); + + const linked = toTerminalHyperlink(outputPath, pathToFileURL(outputPath).href); + host.showNotice(`Exported ${String(context.history.length)} messages`, linked); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to export session: ${msg}`); + } +} + +export async function handleExportDebugZipCommand(host: SlashCommandHost): Promise<void> { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + host.showStatus('Exporting session…'); + try { + const installSource = await detectInstallSource(); + const shellEnv = detectShellEnvironment(); + const result = await host.harness.exportSession({ + id: session.id, + version: host.state.appState.version, + installSource, + shellEnv, + includeGlobalLog: true, + }); + const linked = toTerminalHyperlink(result.zipPath, pathToFileURL(result.zipPath).href); + host.showNotice('Export complete', linked); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to export session: ${msg}`); + } +} + +export async function handleInitCommand(host: SlashCommandHost): Promise<void> { + const session = host.session; + if (host.state.appState.model.trim().length === 0 || session === undefined) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + + host.deferUserMessages = true; + host.beginSessionRequest(); + try { + await session.init(); + host.track('init_complete'); + host.streamingUI.finalizeTurn((item) => { + host.sendQueuedMessage(session, item); + }); + } catch (error) { + if (isAbortError(error)) { + host.setAppState({ streamingPhase: 'idle' }); + host.resetLivePane(); + return; + } + const msg = error instanceof Error ? error.message : String(error); + host.failSessionRequest(`Init failed: ${msg}`); + } finally { + host.deferUserMessages = false; + } +} diff --git a/apps/kimi-code/src/tui/commands/skills.ts b/apps/kimi-code/src/tui/commands/skills.ts new file mode 100644 index 0000000000000000000000000000000000000000..2997a8b151c44443d87ec8b474a4cb1d3611915f --- /dev/null +++ b/apps/kimi-code/src/tui/commands/skills.ts @@ -0,0 +1,48 @@ +import type { Session, SkillSummary } from '@moonshot-ai/kimi-code-sdk'; + +import type { KimiSlashCommand } from './types'; + +export type SkillListSession = Pick<Session, 'listSkills'>; + +export interface SkillSlashCommands { + readonly commands: readonly KimiSlashCommand[]; + readonly commandMap: ReadonlyMap<string, string>; +} + +export function isUserActivatableSkill(skill: SkillSummary): boolean { + return ( + skill.type === undefined || + skill.type === 'prompt' || + skill.type === 'inline' || + skill.type === 'flow' + ); +} + +function compareSkillSlashCommands(a: SkillSummary, b: SkillSummary): number { + return ( + getSkillSlashCommandGroup(a.source) - getSkillSlashCommandGroup(b.source) || + a.name.localeCompare(b.name) + ); +} + +function getSkillSlashCommandGroup(source: SkillSummary['source']): number { + return source === 'builtin' ? 0 : 1; +} + +export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillSlashCommands { + const commandMap = new Map<string, string>(); + const sortedSkills = [...skills].toSorted(compareSkillSlashCommands); + const commands = sortedSkills.filter(isUserActivatableSkill).map((skill) => { + const commandName = + skill.source === 'builtin' || skill.isSubSkill === true + ? skill.name + : `skill:${skill.name}`; + commandMap.set(commandName, skill.name); + return { + name: commandName, + aliases: [], + description: skill.description ?? '', + }; + }); + return { commands, commandMap }; +} diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts new file mode 100644 index 0000000000000000000000000000000000000000..daf5cecb1b2fca56d548c42c1354535405c18424 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -0,0 +1,159 @@ +import type { PermissionMode } from '@moonshot-ai/kimi-code-sdk'; + +import { + SwarmStartPermissionPromptComponent, + type SwarmStartPermissionChoice, +} from '../components/dialogs/swarm-start-permission-prompt'; +import { + SwarmModeMarkerComponent, + type SwarmModeMarkerState, +} from '../components/messages/swarm-markers'; +import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import { PERMISSION_MODE_DESCRIPTIONS, PERMISSION_MODE_DISPLAY_NAMES } from '../utils/permission-mode'; +import type { SlashCommandHost } from './dispatch'; + +export async function handleSwarmCommand(host: SlashCommandHost, args: string): Promise<void> { + if (host.session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const prompt = args.trim(); + const mode = swarmModeSubcommand(prompt); + if (mode !== undefined) { + await applySwarmMode(host, mode, `/swarm ${prompt}`); + return; + } + + if (prompt.length === 0) { + await applySwarmMode(host, !host.state.appState.swarmMode, '/swarm'); + return; + } + + if (host.state.appState.model.trim().length === 0) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + + if (host.state.appState.permissionMode === 'manual') { + showSwarmStartPermissionPrompt(host, `/swarm ${prompt}`, 'Swarm task not started.', (choice) => + startSwarmWithPermission(host, prompt, choice), + ); + return; + } + + await startSwarmTask(host, prompt); +} + +function showSwarmStartPermissionPrompt( + host: SlashCommandHost, + commandText: string, + cancelStatus: string, + onSelect: (choice: SwarmStartPermissionChoice) => Promise<void>, +): void { + const cancelStart = (): void => { + host.restoreInputText(commandText); + host.showStatus(cancelStatus); + }; + host.mountEditorReplacement( + new SwarmStartPermissionPromptComponent({ + onSelect: (choice) => { + host.restoreEditor(); + void onSelect(choice); + }, + onCancel: cancelStart, + }), + ); +} + +async function startSwarmWithPermission( + host: SlashCommandHost, + prompt: string, + choice: SwarmStartPermissionChoice, +): Promise<void> { + if (choice === 'auto' || choice === 'yolo') { + if (!(await setPermissionForSwarm(host, choice))) return; + } + await startSwarmTask(host, prompt); +} + +async function setPermissionForSwarm(host: SlashCommandHost, mode: PermissionMode): Promise<boolean> { + try { + await host.requireSession().setPermission(mode); + } catch (error) { + host.showError(`Failed to set permission mode: ${formatErrorMessage(error)}`); + return false; + } + host.setAppState({ permissionMode: mode }); + host.showNotice(`Permission mode: ${PERMISSION_MODE_DISPLAY_NAMES[mode]}`); + host.showStatus(PERMISSION_MODE_DESCRIPTIONS[mode], 'warning'); + return true; +} + +async function startSwarmTask(host: SlashCommandHost, prompt: string): Promise<void> { + if (!host.state.appState.swarmMode && !(await setSwarmMode(host, true, 'task'))) { + return; + } + renderSwarmModeMarker(host, 'active'); + host.sendNormalUserInput(prompt); +} + +async function applySwarmMode( + host: SlashCommandHost, + enabled: boolean, + commandText: string, +): Promise<void> { + if (enabled && host.state.appState.swarmMode) { + host.showStatus('Swarm mode is already on.'); + return; + } + if (!enabled && !host.state.appState.swarmMode) { + host.showStatus('Swarm mode is already off.'); + return; + } + if (enabled && host.state.appState.permissionMode === 'manual') { + showSwarmStartPermissionPrompt(host, commandText, 'Swarm mode not enabled.', async (choice) => { + if ((choice === 'auto' || choice === 'yolo') && !(await setPermissionForSwarm(host, choice))) { + return; + } + if (!(await setSwarmMode(host, true, 'manual'))) return; + renderSwarmModeMarker(host, 'active'); + }); + return; + } + if (!(await setSwarmMode(host, enabled, 'manual'))) return; + renderSwarmModeMarker(host, enabled ? 'active' : 'inactive'); +} + +async function setSwarmMode( + host: SlashCommandHost, + enabled: boolean, + trigger: 'manual' | 'task', +): Promise<boolean> { + try { + await host.requireSession().setSwarmMode(enabled, trigger); + } catch (error) { + host.showError( + `Failed to ${enabled ? 'enable' : 'disable'} swarm mode: ${formatErrorMessage(error)}`, + ); + return false; + } + host.setAppState({ swarmMode: enabled }); + host.state.swarmModeEntry = enabled ? trigger : undefined; + return true; +} + +function swarmModeSubcommand(input: string): boolean | undefined { + const command = input.toLowerCase(); + if (command === 'on') return true; + if (command === 'off') return false; + return undefined; +} + +function renderSwarmModeMarker(host: SlashCommandHost, state: SwarmModeMarkerState): void { + host.state.transcriptContainer.addChild( + new SwarmModeMarkerComponent(state), + ); + host.state.ui.requestRender(); +} diff --git a/apps/kimi-code/src/tui/commands/tower.ts b/apps/kimi-code/src/tui/commands/tower.ts new file mode 100644 index 0000000000000000000000000000000000000000..890a1c3fa4f6d93515353afca7b74b2f1ca233de --- /dev/null +++ b/apps/kimi-code/src/tui/commands/tower.ts @@ -0,0 +1,90 @@ +import type { Session } from '@moonshot-ai/kimi-code-sdk'; + +import { TOWER_STATUS_PROMPT, TOWER_TEARDOWN_PROMPT } from '../constant/kimi-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +export async function handleTowerCommand(host: SlashCommandHost, args: string): Promise<void> { + const input = args.trim(); + const sub = input.toLowerCase(); + + if (sub === 'on') { + await applyTowerMode(host, true); + return; + } + if (sub === 'off') { + await applyTowerMode(host, false); + return; + } + if (sub === '' || sub === 'status') { + host.sendNormalUserInput(TOWER_STATUS_PROMPT); + return; + } + if (sub === 'teardown') { + host.sendNormalUserInput(TOWER_TEARDOWN_PROMPT); + return; + } + + await startTowerWithBase(host, input); +} + +async function startTowerWithBase(host: SlashCommandHost, base: string): Promise<void> { + // `/tower <base>` is manual activation too: it turns tower mode on and pins + // the branch missions merge back into (the engine validates that it is a + // local branch). Only the agent can never enter the mode by itself. + const wasActive = host.state.appState.towerMode; + if (!(await setTowerMode(host, true, base))) return; + host.showNotice(wasActive ? `Tower base: ${base}` : `Tower mode: ON (base: ${base})`); +} + +async function applyTowerMode(host: SlashCommandHost, enabled: boolean): Promise<void> { + const wasActive = host.state.appState.towerMode; + // The setter is idempotent engine-side, so always reassert — a stale cache + // must not leave the authoritative mode unchanged. + if (!(await setTowerMode(host, enabled))) return; + if (wasActive === enabled) { + host.showStatus(`Tower mode is already ${enabled ? 'on' : 'off'}.`); + return; + } + host.showNotice(enabled ? 'Tower mode: ON' : 'Tower mode: OFF'); +} + +async function setTowerMode( + host: SlashCommandHost, + enabled: boolean, + base?: string, +): Promise<boolean> { + const session = await requireSessionEnsured(host); + if (session === undefined) return false; + try { + await session.setTowerMode(enabled, base); + // The engine may silently refuse entry (flag off, feature not assembled + // until a restart, another session owning the workspace tower) — confirm + // the mode actually took before reporting success. + const status = await session.getStatus(); + const effective = status.towerMode ?? false; + if (effective !== enabled) { + host.setAppState({ towerMode: effective }); + host.showError( + enabled + ? 'Tower mode could not be enabled — another session owns this workspace tower, or the experiment is off / was just turned on and needs a restart.' + : 'Tower mode could not be disabled.', + ); + return false; + } + } catch (error) { + host.showError( + `Failed to ${enabled ? 'enable' : 'disable'} tower mode: ${formatErrorMessage(error)}`, + ); + return false; + } + host.setAppState({ towerMode: enabled }); + return true; +} + +async function requireSessionEnsured(host: SlashCommandHost): Promise<Session | undefined> { + if (host.session !== undefined) return host.session; + // v2 session-less: lazy-create the session, then toggle — the same path + // the first prompt takes. + return host.ensureSession(); +} diff --git a/apps/kimi-code/src/tui/commands/types.ts b/apps/kimi-code/src/tui/commands/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..9380bbee3880fc1ab81cb3c743afc6e38916b571 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/types.ts @@ -0,0 +1,30 @@ +import type { AutocompleteItem, SlashCommand } from '@moonshot-ai/pi-tui'; + +export type SlashCommandAvailability = 'always' | 'idle-only'; + +export interface KimiSlashCommand<Name extends string = string> extends SlashCommand { + readonly name: Name; + readonly aliases: readonly string[]; + readonly description: string; + readonly priority?: number; + readonly availability?: SlashCommandAvailability | ((args: string) => SlashCommandAvailability); + /** When set, the command is hidden from the palette and blocked unless this flag is enabled. + * A plain string: the gating flag may live in either engine's registry (v1 core or v2 domain). */ + readonly experimentalFlag?: string; + /** + * Generic argument autocompletion. `argumentPrefix` is the text typed after + * `/<command> `; return suggestions or `null`. Declared as a plain function + * property (not a method) so passing it around is `this`-free. Adapted to + * pi-tui's `getArgumentCompletions` in the autocomplete setup. + */ + readonly completeArgs?: (argumentPrefix: string) => AutocompleteItem[] | null; +} + +export interface ParsedSlashInput { + readonly name: string; + readonly args: string; +} + +export type SlashCommandBusyReason = 'streaming' | 'compacting'; + +export type SlashCommandInvalidReason = 'unknown'; diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3aef7f8d976c1725ac2bd805bc1ef220edf4994 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -0,0 +1,558 @@ +import type { Component } from '@moonshot-ai/pi-tui'; +import type { ContextMessage } from '@moonshot-ai/kimi-code-sdk'; +import { isKimiError } from '@moonshot-ai/kimi-code-sdk'; + +import { WelcomeComponent } from '../components/chrome/welcome'; +import { CompactionComponent } from '../components/dialogs/compaction'; +import { + UndoSelectorComponent, + type UndoChoice, +} from '../components/dialogs/undo-selector'; +import { AgentGroupComponent } from '../components/messages/agent-group'; +import { AgentSwarmProgressComponent } from '../components/messages/agent-swarm-progress'; +import { AssistantMessageComponent } from '../components/messages/assistant-message'; +import { BackgroundAgentStatusComponent } from '../components/messages/background-agent-status'; +import { CronMessageComponent } from '../components/messages/cron-message'; +import { ReadGroupComponent } from '../components/messages/read-group'; +import { SkillActivationComponent } from '../components/messages/skill-activation'; +import { PluginCommandComponent } from '../components/messages/plugin-command'; +import { ThinkingComponent } from '../components/messages/thinking'; +import { ToolCallComponent } from '../components/messages/tool-call'; +import { UserMessageComponent } from '../components/messages/user-message'; +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import type { TranscriptEntry } from '../types'; +import { formatErrorMessage } from '../utils/event-payload'; +import { getTranscriptComponentEntry } from '../utils/transcript-component-metadata'; +import { nextTranscriptId } from '../utils/transcript-id'; +import type { SlashCommandHost } from './dispatch'; + +// --------------------------------------------------------------------------- +// Undo command +// --------------------------------------------------------------------------- + +interface UndoAvailability { + readonly maxCount: number; + readonly stoppedAtCompaction: boolean; +} + +type UndoSessionContext = Awaited< + ReturnType<NonNullable<SlashCommandHost['session']>['getContext']> +>; + +const UNDO_LIMIT_STATUS_TURN_ID = 'undo-limit-status'; + +export async function handleUndoCommand( + host: SlashCommandHost, + args: string = '', +): Promise<void> { + if (host.state.appState.streamingPhase !== 'idle') { + host.showError('Cannot undo while streaming — press Esc or Ctrl-C first.'); + return; + } + + const trimmed = args.trim(); + if (trimmed.length === 0) { + await showUndoSelector(host); + return; + } + + const count = parseUndoCount(trimmed); + if (count === undefined) { + host.showError('Usage: /undo [count], where count is a positive integer.'); + return; + } + + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const availability = await resolveUndoAvailability(host); + if (count > availability.maxCount) { + showUndoLimitStatus(host, formatUndoLimitMessage(count, availability)); + return; + } + + await undoByCount(host, count); +} + +async function undoByCount(host: SlashCommandHost, count: number): Promise<boolean> { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return false; + } + + const entries = host.state.transcriptEntries; + const lastUserIndex = findUndoAnchorEntryIndex(entries, count); + if (lastUserIndex === undefined) { + showUndoLimitStatus(host, 'Nothing to undo.'); + return false; + } + // When the anchor is a bundled prompt, its skill activation cards sit + // before it (contiguous, marked at submission/replay time) and are removed + // together with it. + + try { + await session.undoHistory(count); + } catch (error) { + const limit = undoLimitFromError(error); + if (limit !== undefined) { + showUndoLimitStatus(host, formatUndoLimitMessage(limit.requestedCount, limit)); + return false; + } + const message = formatErrorMessage(error); + host.showError(`Failed to undo: ${message}`); + return false; + } + host.noteContextCut?.(); + await refreshTodoPanel(host); + + const children = host.state.transcriptContainer.children; + const lastUserComponentIndex = findUndoAnchorComponentIndex(children, count); + if (lastUserComponentIndex !== undefined) { + // A hook result may interleave between the bundle's cards and its prompt + // and survives undo in the engine, so it is skipped (kept) while the + // cards around it are removed. Only the contiguous marked run belongs to + // this submission: a standalone `/skill` card is unmarked and never + // swept. Structural removal only: the container's ref-checked render + // cache detects the child-list change; no tree-wide invalidate needed. + const groupChildIndices = new Set<number>(); + for (let i = lastUserComponentIndex - 1; i >= 0; i--) { + const entry = getTranscriptComponentEntry(children[i]!); + if (entry?.bundledWithPrompt === true) { + groupChildIndices.add(i); + continue; + } + if (entry?.hookResult === true) continue; + break; + } + removeUndoContextComponents(children, lastUserComponentIndex, groupChildIndices); + } + + const groupEntryIndices = new Set<number>(); + for (let i = lastUserIndex - 1; i >= 0; i--) { + const prev = entries[i]; + if (prev?.bundledWithPrompt === true) { + groupEntryIndices.add(i); + continue; + } + if (prev?.hookResult === true) continue; + break; + } + const preservedEntries = entries.filter( + (entry, index) => + !( + (index >= lastUserIndex || groupEntryIndices.has(index)) && + isUndoContextEntry(entry) + ), + ); + entries.splice(0, entries.length, ...preservedEntries); + + if (entries.length === 0) { + renderWelcome(host); + } + + host.state.ui.requestRender(); + return true; +} + +async function refreshTodoPanel(host: SlashCommandHost): Promise<void> { + const session = host.session; + if (session === undefined) return; + try { + const todos = await session.getTodos(); + if (todos.length > 0 && todos.every((todo) => todo.status === 'done')) { + host.streamingUI.setTodoList([]); + return; + } + host.streamingUI.setTodoList(todos); + } catch { + return; + } +} + +async function showUndoSelector(host: SlashCommandHost): Promise<void> { + if (host.session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const availability = await resolveUndoAvailability(host); + const choices = createUndoChoices( + host.state.transcriptEntries, + host.state.transcriptContainer.children, + availability.maxCount, + ); + if (choices.length === 0) { + showUndoLimitStatus(host, formatNothingToUndoMessage(availability)); + return; + } + + host.mountEditorReplacement( + new UndoSelectorComponent({ + choices, + onSelect: (choice) => { + void undoByCount(host, choice.count).then((undone) => { + if (undone) { + host.restoreInputText(choice.input); + return; + } + host.restoreEditor(); + }); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +function parseUndoCount(args: string): number | undefined { + const value = args.trim(); + if (value.length === 0) return 1; + if (!/^[1-9]\d*$/.test(value)) return undefined; + const count = Number(value); + return Number.isSafeInteger(count) ? count : undefined; +} + +async function resolveUndoAvailability( + host: SlashCommandHost, +): Promise<UndoAvailability> { + const local = undoAvailabilityFromTranscript( + host.state.transcriptEntries, + host.state.transcriptContainer.children, + ); + const context = await getSessionContext(host.session); + if (context === undefined) return local; + + const activeContext = undoAvailabilityFromContext(context.history); + return { + maxCount: Math.min(local.maxCount, activeContext.maxCount), + stoppedAtCompaction: + local.stoppedAtCompaction || activeContext.stoppedAtCompaction, + }; +} + +async function getSessionContext( + session: SlashCommandHost['session'], +): Promise<UndoSessionContext | undefined> { + const getContext = ( + session as { getContext?: () => Promise<UndoSessionContext> } | undefined + )?.getContext; + if (session === undefined || getContext === undefined) return undefined; + try { + return await getContext.call(session); + } catch { + return undefined; + } +} + +function undoAvailabilityFromTranscript( + entries: readonly TranscriptEntry[], + children: readonly Component[], +): UndoAvailability { + const { anchors, stoppedAtCompaction } = activeUndoAnchorEntries(entries, children); + return { + maxCount: anchors.length, + stoppedAtCompaction, + }; +} + +function undoAvailabilityFromContext( + history: readonly ContextMessage[], +): UndoAvailability { + let maxCount = 0; + let stoppedAtCompaction = false; + + for (let i = history.length - 1; i >= 0; i--) { + const message = history[i]; + if (message === undefined) continue; + if (message.origin?.kind === 'injection') continue; + if (message.origin?.kind === 'compaction_summary') { + stoppedAtCompaction = true; + break; + } + if (isContextUndoAnchor(message)) maxCount++; + } + + return { maxCount, stoppedAtCompaction }; +} + +function isContextUndoAnchor(message: ContextMessage): boolean { + if (message.role !== 'user') return false; + const origin = message.origin; + if (origin === undefined || origin.kind === 'user') return true; + if (origin.kind === 'skill_activation') { + return origin.trigger === 'user-slash'; + } + if (origin.kind === 'plugin_command') { + return origin.trigger === 'user-slash'; + } + return false; +} + +function createUndoChoices( + entries: readonly TranscriptEntry[], + children: readonly Component[], + maxCount: number, +): readonly UndoChoice[] { + if (maxCount <= 0) return []; + const anchors = activeUndoAnchorEntries(entries, children).anchors.slice(-maxCount); + return anchors.map((entry, index) => ({ + id: entry.id, + count: anchors.length - index, + input: formatUndoChoiceInput(entry), + label: formatUndoChoiceLabel(entry), + })); +} + +function activeUndoAnchorEntries( + entries: readonly TranscriptEntry[], + children: readonly Component[], +): { readonly anchors: readonly TranscriptEntry[]; readonly stoppedAtCompaction: boolean } { + const lastCompactionChildIndex = children.findLastIndex( + (child) => child instanceof CompactionComponent, + ); + if (lastCompactionChildIndex >= 0) { + return { + anchors: children + .slice(lastCompactionChildIndex + 1) + .map((child) => getTranscriptComponentEntry(child)) + .filter((entry): entry is TranscriptEntry => entry !== undefined) + .filter(isUndoAnchorEntry), + stoppedAtCompaction: true, + }; + } + + const lastCompactionEntryIndex = entries.findLastIndex( + (entry) => entry.compactionData !== undefined, + ); + const activeEntries = + lastCompactionEntryIndex >= 0 ? entries.slice(lastCompactionEntryIndex + 1) : entries; + return { + anchors: activeEntries.filter(isUndoAnchorEntry), + stoppedAtCompaction: lastCompactionEntryIndex >= 0, + }; +} + +function formatUndoChoiceLabel( + entry: TranscriptEntry, +): string { + if (entry.kind === 'skill_activation') { + const name = singleLine( + entry.skillName ?? entry.content.replace(/^Activated skill:\s*/, ''), + ); + const args = singleLine(entry.skillArgs ?? ''); + if (name.length === 0) return 'Skill: unknown'; + return args.length > 0 ? `/${name} ${args}` : `/${name}`; + } + if (entry.kind === 'plugin_command' && entry.pluginCommandData !== undefined) { + return formatPluginCommandSlash(entry.pluginCommandData) ?? 'User message'; + } + + const content = singleLine(entry.content); + const imageCount = entry.imageAttachmentIds?.length ?? 0; + if (content.length > 0) return content; + if (imageCount > 0) { + return `User message (${String(imageCount)} ${imageCount === 1 ? 'image' : 'images'})`; + } + return 'User message'; +} + +function formatUndoChoiceInput(entry: TranscriptEntry): string { + if (entry.kind === 'skill_activation') { + const name = singleLine( + entry.skillName ?? entry.content.replace(/^Activated skill:\s*/, ''), + ); + const args = singleLine(entry.skillArgs ?? ''); + if (name.length === 0) return ''; + return args.length > 0 ? `/${name} ${args}` : `/${name}`; + } + if (entry.kind === 'plugin_command' && entry.pluginCommandData !== undefined) { + return formatPluginCommandSlash(entry.pluginCommandData) ?? entry.content; + } + return entry.content; +} + +function formatPluginCommandSlash(data: NonNullable<TranscriptEntry['pluginCommandData']>): string | undefined { + const name = `${data.pluginId}:${data.commandName}`; + const args = singleLine(data.args ?? ''); + if (name.length === 0) return undefined; + return args.length > 0 ? `/${name} ${args}` : `/${name}`; +} + +function singleLine(text: string): string { + return text.replaceAll(/\s+/g, ' ').trim(); +} + +function formatUndoLimitMessage( + requestedCount: number, + availability: UndoAvailability, +): string { + const reason = availability.stoppedAtCompaction ? ' after the last compaction' : ''; + const requested = formatPromptCount(requestedCount); + const max = formatPromptCount(availability.maxCount); + return `Cannot undo ${requested}; only ${max} can be undone in the active context${reason}.`; +} + +function formatNothingToUndoMessage(availability: UndoAvailability): string { + if (availability.stoppedAtCompaction) { + return 'Nothing to undo after the last compaction.'; + } + return 'Nothing to undo.'; +} + +function formatPromptCount(count: number): string { + return `${String(count)} ${count === 1 ? 'prompt' : 'prompts'}`; +} + +function showUndoLimitStatus(host: SlashCommandHost, message: string): void { + host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'status', + turnId: UNDO_LIMIT_STATUS_TURN_ID, + renderMode: 'plain', + content: message, + }); +} + +function undoLimitFromError( + error: unknown, +): (UndoAvailability & { readonly requestedCount: number }) | undefined { + if (!isKimiError(error)) return undefined; + const details = error.details; + if (details?.['reason'] !== 'undo_limit') return undefined; + const requestedCount = details['requestedCount']; + const maxCount = details['undoableCount']; + const stoppedAtCompaction = details['stoppedAtCompaction']; + if ( + typeof requestedCount !== 'number' || + typeof maxCount !== 'number' || + typeof stoppedAtCompaction !== 'boolean' + ) { + return undefined; + } + return { requestedCount, maxCount, stoppedAtCompaction }; +} + +function isUndoAnchorEntry(entry: TranscriptEntry): boolean { + return ( + entry.kind === 'user' || + (entry.kind === 'skill_activation' && + entry.skillTrigger === 'user-slash' && + entry.bundledWithPrompt !== true) || + entry.kind === 'plugin_command' + ); +} + +function findUndoAnchorEntryIndex( + entries: readonly TranscriptEntry[], + count: number, +): number | undefined { + let found = 0; + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry !== undefined && isUndoAnchorEntry(entry)) { + found++; + if (found === count) return i; + } + } + return undefined; +} + +function isUndoContextEntry(entry: TranscriptEntry): boolean { + switch (entry.kind) { + case 'user': + case 'assistant': + case 'tool_call': + case 'thinking': + case 'skill_activation': + case 'plugin_command': + case 'cron': + return true; + case 'status': + case 'goal': + return entry.turnId !== undefined; + case 'welcome': + return false; + } +} + +function findUndoAnchorComponentIndex( + children: readonly Component[], + count: number, +): number | undefined { + let found = 0; + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i]; + if (child !== undefined && isUndoAnchorComponent(child)) { + found++; + if (found === count) return i; + } + } + return undefined; +} + +function removeUndoContextComponents( + children: Component[], + startIndex: number, + additionalIndices: ReadonlySet<number>, +): void { + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i]; + if ( + child !== undefined && + (i >= startIndex || additionalIndices.has(i)) && + isUndoContextComponent(child) + ) { + children.splice(i, 1); + } + } +} + +function isUndoAnchorComponent(child: Component): boolean { + const entry = getTranscriptComponentEntry(child); + return ( + child instanceof UserMessageComponent || + (child instanceof SkillActivationComponent && + child.trigger === 'user-slash' && + entry?.bundledWithPrompt !== true) || + child instanceof PluginCommandComponent + ); +} + +function isUndoContextComponent(child: Component): boolean { + const entry = getTranscriptComponentEntry(child); + if (entry !== undefined) { + return isUndoContextEntry(entry); + } + + return ( + child instanceof UserMessageComponent || + child instanceof AssistantMessageComponent || + child instanceof ThinkingComponent || + child instanceof ToolCallComponent || + child instanceof AgentGroupComponent || + child instanceof AgentSwarmProgressComponent || + child instanceof ReadGroupComponent || + child instanceof SkillActivationComponent || + child instanceof PluginCommandComponent || + child instanceof BackgroundAgentStatusComponent || + child instanceof CronMessageComponent + ); +} + +function renderWelcome(host: SlashCommandHost): void { + if ( + host.state.transcriptContainer.children.some( + (child) => child instanceof WelcomeComponent, + ) + ) { + return; + } + host.state.transcriptContainer.addChild( + new WelcomeComponent(host.state.appState), + ); +} diff --git a/apps/kimi-code/src/tui/commands/web.ts b/apps/kimi-code/src/tui/commands/web.ts new file mode 100644 index 0000000000000000000000000000000000000000..95c310d71ec33cca30da788687b6aa9ab0967a20 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/web.ts @@ -0,0 +1,154 @@ +import chalk from 'chalk'; + +import { splitTokenFragment } from '#/cli/sub/web/access-urls'; +import { getVersion } from '#/cli/version'; +import { + buildRemoteControlUrl, + formatRemoteControlAlreadyRunning, + formatRemoteControlOutput, + formatRemoteControlStatus, + inspectRemoteControlLock, + startRemoteControl, + type RemoteControlStatus, +} from '#/cli/sub/web/remote-control'; +import { formatReadyBanner, startServerForeground } from '#/cli/sub/web/run'; +import { parseServerOptions, tryResolveServerToken } from '#/cli/sub/web/shared'; +import { openUrl } from '#/utils/open-url'; +import { getDataDir } from '#/utils/paths'; +import { generateRemoteControlQr } from '#/utils/remote-control-qr'; + +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { darkColors } from '../theme/colors'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +/** + * `/web` — hand the current session off to the browser. + * + * Always starts a new server: the TUI shuts down and this process becomes the + * server, running in the foreground attached to this terminal and taking the + * next free port alongside any running ones. The session deep link opens from + * the ready hook once the server is actually listening. + */ +export async function handleWebCommand(host: SlashCommandHost): Promise<void> { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + startNewServerAfterExit(host, session.id); + await host.stop(); +} + +export async function handleRemoteControlCommand(host: SlashCommandHost): Promise<void> { + await host.waitForLazyCreation(); + const session = host.session; + + const holder = await inspectRemoteControlLock(getDataDir()); + if (holder !== undefined) { + host.showError(formatRemoteControlAlreadyRunning(holder)); + return; + } + + host.setExitForegroundTask(async () => { + const options = parseServerOptions({}); + let remoteControl: Awaited<ReturnType<typeof startRemoteControl>> | undefined; + try { + await startServerForeground(options, { + onReady: async (origin) => { + const dataDir = getDataDir(); + const token = tryResolveServerToken(dataDir); + if (token === undefined) throw new Error('Unable to read the local server token.'); + let outputReady = false; + const pendingStatuses: string[] = []; + const onStatus = (status: RemoteControlStatus): void => { + const line = formatRemoteControlStatus(status); + if (outputReady) process.stdout.write(line); + else pendingStatuses.push(line); + }; + remoteControl = await startRemoteControl({ + homeDir: dataDir, + localOrigin: origin, + localServerToken: token, + clientVersion: `kimi-code/${getVersion()}`, + onStatus, + }); + const url = buildRemoteControlUrl(remoteControl.deviceId, session?.id); + const qrCode = await generateRemoteControlQr(url, dataDir); + process.stdout.write( + formatRemoteControlOutput({ + url, + localOrigin: origin, + localServerToken: token, + deviceName: remoteControl.deviceName, + qrCode: qrCode.terminal, + pngPath: qrCode.pngPath, + }), + ); + outputReady = true; + for (const line of pendingStatuses) process.stdout.write(line); + openUrl(url); + }, + onShutdown: async () => { + await remoteControl?.close(); + }, + }); + } catch (error) { + process.stderr.write(`Failed to start Remote Control: ${formatErrorMessage(error)}\n`); + process.exit(1); + } + }); + await host.stop(); +} + +/** + * Register the exit takeover that turns this process into the new server once + * the TUI has shut down (where `process.exit` would normally happen): the + * server stays attached to this terminal until Ctrl+C, and the session deep + * link opens from the ready hook once the server is actually listening. The + * terminal shows the same ready banner as `kimi web` plus the deep link. + */ +function startNewServerAfterExit(host: SlashCommandHost, sessionId: string): void { + host.setExitForegroundTask(async () => { + const options = parseServerOptions({}); + try { + await startServerForeground(options, { + onReady: (origin) => { + // Resolve the token here (after the server is listening): a fresh + // server writes `server.token` on first boot, so reading it earlier + // would miss first-time starts and the browser would hit the auth + // gate. + const token = tryResolveServerToken(getDataDir()); + const url = webSessionUrl(origin, sessionId, token); + process.stdout.write(formatReadyBanner(origin, options.host, { token })); + process.stdout.write(`\n ${sessionLine(url)}\n`); + openUrl(url); + }, + }); + } catch (error) { + process.stderr.write(`Failed to start server: ${formatErrorMessage(error)}\n`); + process.exit(1); + } + }); +} + +/** Styled `Session:` line for the foreground handoff; the token fragment is + * dimmed like in the ready banner so the host/path stands out. */ +function sessionLine(url: string, labelText = 'Session: '): string { + const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); + const accent = (text: string): string => chalk.hex(darkColors.accent)(text); + const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); + const [base, frag] = splitTokenFragment(url); + return `${label(labelText)}${accent(base)}${frag === '' ? '' : dim(frag)}`; +} + +/** + * Build the deep-link URL the web UI recognises for a session. When a token is + * known it rides in the `#token=` fragment (never sent to the server, so never + * logged), so the browser authenticates on load just like `kimi web`. + */ +export function webSessionUrl(origin: string, sessionId: string, token?: string): string { + const base = `${origin.replace(/\/+$/, '')}/sessions/${encodeURIComponent(sessionId)}`; + return token === undefined ? base : `${base}#token=${token}`; +} diff --git a/apps/kimi-code/src/tui/components/chrome/banner.ts b/apps/kimi-code/src/tui/components/chrome/banner.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ecf4af2c2837a1f2fb0e6c9bee7febe99faa614 --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/banner.ts @@ -0,0 +1,95 @@ +import type { Component } from '@moonshot-ai/pi-tui'; +import { visibleWidth, wrapTextWithAnsi } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import type { BannerState } from '#/tui/types'; + +const PREFIX_STAR = '✦'; +const PADDING = ' '; +/** + * Minimum column count the main text gets next to an inline tag. A long tag + * (e.g. a full sentence from the remote banner config) can fit on the line + * yet leave only a sliver for the main text, which then wraps into a narrow, + * hard-broken column. When that would happen the tag moves onto its own line + * and the main text uses (nearly) the full width instead. + */ +const MIN_INLINE_MAIN_TEXT_WIDTH = 16; + +export class BannerComponent implements Component { + constructor(private readonly state: BannerState) {} + + invalidate(): void {} + + render(width: number): string[] { + const main = (s: string): string => currentTheme.boldFg('textStrong', s); + const dim = (s: string): string => currentTheme.fg('textDim', s); + + // Render nothing but the trailing blank if the terminal cannot hold a + // single visible column. + if (width < 1) { + return ['']; + } + + const tagText = this.state.tag ?? ''; + // Do not add a colon/tag suffix here; the caller-provided tag includes its + // own punctuation/separator. + const tagLabel = tagText.length > 0 ? `${PREFIX_STAR} ${tagText}` : ''; + const tagStyled = tagLabel.length > 0 ? currentTheme.boldFg('primary', tagLabel) : ''; + const tagDisplay = tagStyled.length > 0 ? tagStyled + PADDING : ''; + const tagWidth = visibleWidth(tagDisplay); + const showTag = tagWidth > 0 && tagWidth < width; + // Hanging indent aligning with the tag text (right after "✦ "). + const hangingWidth = visibleWidth(PREFIX_STAR + PADDING); + // If the inline tag would squeeze the main text into too narrow a column, + // render the tag on its own line and give the main text the full width. + const tagOnOwnLine = showTag && width - tagWidth < MIN_INLINE_MAIN_TEXT_WIDTH; + const inlineTag = showTag && !tagOnOwnLine; + // Body lines (continuations of the main text) indent to match the first + // line's main-text column, which starts right after the tag display. When + // the tag is on its own line, the main text aligns with the tag text. + const bodyIndent = inlineTag ? ' '.repeat(tagWidth) : tagOnOwnLine ? ' '.repeat(hangingWidth) : ''; + // Descriptive subtext lines (the second line in the design) start at the + // column after the leading star + space, aligning with the tag text itself. + const descIndent = showTag ? ' '.repeat(hangingWidth) : ''; + const bodyContentWidth = + width - (inlineTag ? tagWidth : tagOnOwnLine ? hangingWidth : 0); + const descContentWidth = width - (showTag ? hangingWidth : 0); + + if (bodyContentWidth <= 0) { + return ['']; + } + + const mainSegments = this.state.mainText.split('\n'); + const subSegments = this.state.subText ? this.state.subText.split('\n') : []; + + const result: string[] = []; + if (tagOnOwnLine) { + result.push(tagStyled); + } + for (let i = 0; i < mainSegments.length; i++) { + const wrapped = wrapTextWithAnsi(mainSegments[i]!, bodyContentWidth); + for (let j = 0; j < wrapped.length; j++) { + const boldLine = main(wrapped[j]!); + if (i === 0 && j === 0 && inlineTag) { + result.push(tagDisplay + boldLine); + } else { + result.push(bodyIndent + boldLine); + } + } + } + + for (const sub of subSegments) { + const available = descContentWidth <= 0 ? bodyContentWidth : descContentWidth; + const wrapped = wrapTextWithAnsi(sub, available); + for (const line of wrapped) { + result.push(descIndent + dim(line)); + } + } + + // Add a blank line below the banner so the following transcript content + // (e.g. the input prompt / status messages) is visually separated. + result.push(''); + + return result; + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/device-code-box.ts b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts new file mode 100644 index 0000000000000000000000000000000000000000..26ec211a0e44d84a78541712ee15ee07bee99e43 --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts @@ -0,0 +1,79 @@ +/** + * OAuth device-code panel rendered inside the transcript. + * + * Borrows the rounded-border layout from `WelcomeComponent` so the login + * prompt matches the rest of the chrome. All colors flow through the + * active palette so theme switches take effect on the next render. + */ + +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +export interface DeviceCodeBoxParams { + readonly title: string; + readonly url: string; + readonly code: string; + readonly hint?: string; +} + +export class DeviceCodeBoxComponent implements Component { + private readonly params: DeviceCodeBoxParams; + + constructor(params: DeviceCodeBoxParams) { + this.params = params; + } + + invalidate(): void {} + + render(width: number): string[] { + const { title, url, code, hint } = this.params; + const border = (s: string): string => currentTheme.fg('primary', s); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); + const pad = ' '; + + const titleLine = truncateToWidth(currentTheme.boldFg('textStrong', title), innerWidth, '…'); + const promptLine = truncateToWidth( + currentTheme.fg('textDim', 'Visit the URL below in your browser to authorize:'), + innerWidth, + '…', + ); + const urlLine = truncateToWidth(currentTheme.fg('primary', url), innerWidth, '…'); + + const codeLabel = currentTheme.boldFg('textDim', 'Verification code: '); + const codeValue = currentTheme.boldFg('accent', code); + const codeLine = truncateToWidth(`${codeLabel}${codeValue}`, innerWidth, '…'); + + const contentLines: string[] = [titleLine, '', promptLine, urlLine, '', codeLine]; + if (hint !== undefined && hint.length > 0) { + contentLines.push(''); + contentLines.push(truncateToWidth(currentTheme.fg('textDim', hint), innerWidth, '…')); + } + + if (safeWidth < 4) { + return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))]; + } + + const lines: string[] = [ + '', + border('╭' + '─'.repeat(safeWidth - 2) + '╮'), + border('│') + ' '.repeat(safeWidth - 2) + border('│'), + ]; + + for (const content of contentLines) { + const truncated = content; + const vis = visibleWidth(truncated); + const rightPad = Math.max(0, innerWidth - vis); + lines.push(border('│') + pad + truncated + ' '.repeat(rightPad) + border('│')); + } + + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); + + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2dd2ea2bbf650349ba1001d8db7edda34ae4c9a --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -0,0 +1,568 @@ +/** + * Footer/status bar — multi-line status display at the bottom of the TUI. + * + * Layout: + * Line 1: [Ask When Needed] [plan] <model> <cwd> <git-badge> <shortcut hints> + * Line 2: context: N% (tokens/max) + */ + +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; +import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk'; + +import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips'; +import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance'; +import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; +import type { AppState } from '#/tui/types'; +import { PERMISSION_MODE_DISPLAY_NAMES } from '#/tui/utils/permission-mode'; +import { + StatusLineCommandRunner, + type StatusLinePayload, +} from '#/tui/utils/status-line-command'; +import { + createGitStatusCache, + formatGitBadgeBase, + formatPullRequestBadge, + type GitStatus, + type GitStatusCache, +} from '#/utils/git/git-status'; +import { + formatTokenCount, + usagePercent, + usagePercentFromRatio, +} from '#/utils/usage/usage-format'; + +/** What the footer's fixed ctrl+o hint offers: expand collapsed tool output, or collapse it again. */ +export type ToolOutputExpandHint = 'expand' | 'collapse'; + +const DEFAULT_STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git'] as const; + +const MAX_CWD_SEGMENTS = 3; +const GOAL_TIMER_INTERVAL_MS = 1_000; + +// Toolbar tips — rotates every 10s. Most tips are short and pair up (two +// joined by " | ") when space allows; tips flagged `solo` are long or +// important enough to take the whole slot on their own. A `priority` weight +// makes a tip recur more often in the rotation (default 1). Width is always +// the final arbiter (a pair that doesn't fit falls back to its first tip). +const TIP_ROTATE_INTERVAL_MS = 10_000; +const TIP_SEPARATOR = ' | '; + +/** + * Expand tips into a rotation sequence using smooth weighted round-robin + * (the nginx SWRR algorithm). Higher-`priority` tips appear more often while + * staying evenly spread, so a tip generally does not land next to its own + * duplicate. Deterministic and computed once at module load. Exported for + * unit testing. + */ +export function buildWeightedTips(tips: readonly ToolbarTip[]): readonly ToolbarTip[] { + const items = tips.map((t) => ({ + tip: t, + weight: Math.max(1, Math.trunc(t.priority ?? 1)), + current: 0, + })); + const total = items.reduce((sum, it) => sum + it.weight, 0); + const seq: ToolbarTip[] = []; + for (let n = 0; n < total; n++) { + let best = items[0]!; + for (const it of items) { + it.current += it.weight; + if (it.current > best.current) best = it; + } + best.current -= total; + seq.push(best.tip); + } + return seq; +} + +const ROTATION: readonly ToolbarTip[] = buildWeightedTips(ALL_TIPS); + +function currentTipIndex(): number { + return Math.floor(Date.now() / TIP_ROTATE_INTERVAL_MS); +} + +/** + * Pick the tip(s) for a rotation index over the weighted ROTATION sequence. + * `primary` is always shown when it fits; `pair` (primary + next tip joined + * by the separator) is offered for wide terminals. Pairing is skipped when + * the current/next tip is `solo` or when the neighbour is a duplicate of the + * current tip (which can happen at the wrap boundary), keeping long/important + * tips on their own and avoiding "X | X". + */ +function tipsForIndex(index: number): { primary: string; pair: string | null } { + const n = ROTATION.length; + if (n === 0) return { primary: '', pair: null }; + const offset = ((index % n) + n) % n; + const current = ROTATION[offset]!; + if (n === 1 || current.solo) return { primary: current.text, pair: null }; + const next = ROTATION[(offset + 1) % n]!; + if (next.solo || next.text === current.text) return { primary: current.text, pair: null }; + return { primary: current.text, pair: current.text + TIP_SEPARATOR + next.text }; +} + +/** + * Footer goal badge, e.g. `[goal ● active · 4m · 7 turns]`. Only shown for a + * live (active/paused) goal; terminal/no goal -> no badge. Turn count is a raw + * count unless an explicit turn budget is set, in which case it shows used/limit. + */ +function formatGoalBadge( + goal: AppState['goal'], + colors: ColorPalette, + wallClockMs?: number, +): string | null { + if (goal === null || goal === undefined) return null; + // Show the badge for every persisted, resumable status. `complete` clears the + // goal, so it never reaches here; only the unset case returns null. + if (goal.status !== 'active' && goal.status !== 'paused' && goal.status !== 'blocked') { + return null; + } + const dotColor = + goal.status === 'active' + ? colors.primary + : goal.status === 'blocked' + ? colors.warning + : colors.textMuted; + const turns = + goal.budget.turnBudget !== null + ? `${goal.turnsUsed}/${goal.budget.turnBudget} turns` + : `${goal.turnsUsed} ${goal.turnsUsed === 1 ? 'turn' : 'turns'}`; + const label = `${goal.status} · ${formatBadgeElapsed(wallClockMs ?? goal.wallClockMs)} · ${turns}`; + return ( + chalk.hex(colors.textMuted)('[goal ') + + chalk.hex(dotColor)('●') + + chalk.hex(colors.textMuted)(` ${label}]`) + ); +} + +function formatBadgeElapsed(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h${minutes % 60}m`; +} + +function modelDisplayName(state: AppState): string { + const model = state.availableModels[state.model]; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.displayName ?? effective?.model ?? state.model; +} + +function shortenCwd(path: string): string { + if (!path) return path; + const home = process.env['HOME'] ?? ''; + let work = path; + if (home && path === home) { + return '~'; + } + if (home && path.startsWith(home + '/')) { + work = '~' + path.slice(home.length); + } + + const segments = work.split('/').filter((s) => s.length > 0); + if (segments.length <= MAX_CWD_SEGMENTS) return work; + const tail = segments.slice(-MAX_CWD_SEGMENTS).join('/'); + return `…/${tail}`; +} + +/** + * Footer context readout. Percent comes from the exact token counts when + * both are known (the ratio can lag a step behind); otherwise it falls + * back to the precomputed ratio. Counts use the shared 1024-based + * formatter. + */ +function formatContextStatus(usage: number, tokens?: number, maxTokens?: number): string { + if (maxTokens !== undefined && maxTokens > 0 && tokens !== undefined) { + const pct = String(usagePercent(tokens, maxTokens)); + return `context: ${pct}% (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`; + } + return `context: ${String(usagePercentFromRatio(usage))}%`; +} + +export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string { + const base = chalk.hex(colors.textDim)(formatGitBadgeBase(status)); + if (status.pullRequest === null) return base; + + const pullRequest = chalk.hex(colors.primary)( + formatPullRequestBadge(status.pullRequest, { linkPullRequest: true }), + ); + return `${base} ${pullRequest}`; +} + +export class FooterComponent implements Component { + private state: AppState; + private readonly onRefresh: () => void; + private gitCache: GitStatusCache; + private gitCacheWorkDir: string; + private transientHint: string | null = null; + private warningHint: string | null = null; + private expandHintProvider: (() => ToolOutputExpandHint | null) | null = null; + private goalSnapshotKey: string | null = null; + private goalObservedAtMs = Date.now(); + private goalTimer: ReturnType<typeof setInterval> | null = null; + private statusLineRunner: StatusLineCommandRunner | null = null; + /** + * Non-terminal background-task counts split by kind so the footer can + * render two distinct badges. `bashTasks` covers `bash-*` BPM tasks + * spawned via `Shell run_in_background=true`; `agentTasks` covers + * `agent-*` BPM tasks (background subagents). Either zero hides its + * respective badge. + */ + private backgroundBashTaskCount = 0; + private backgroundAgentCount = 0; + + constructor(state: AppState, onRefresh: () => void = () => {}) { + this.state = state; + this.onRefresh = onRefresh; + this.gitCacheWorkDir = state.workDir; + this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh }); + this.syncGoalClock(state.goal); + this.syncGoalTimer(state.goal); + this.syncStatusLineRunner(state); + } + + setState(state: AppState): void { + if (state.workDir !== this.gitCacheWorkDir) { + this.gitCacheWorkDir = state.workDir; + this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh }); + } + this.syncGoalClock(state.goal); + this.syncGoalTimer(state.goal); + this.syncStatusLineRunner(state); + this.state = state; + } + + private syncStatusLineRunner(state: AppState): void { + const command = state.statusLine?.command ?? null; + if (command === null) { + this.statusLineRunner?.dispose(); + this.statusLineRunner = null; + return; + } + if (this.statusLineRunner?.command !== command) { + // A reload can swap one command for another; the old runner would + // otherwise keep executing the previous script until restart. + this.statusLineRunner?.dispose(); + this.statusLineRunner = new StatusLineCommandRunner(command, this.onRefresh); + } + } + + /** + * Short-lived hint that replaces the rotating toolbar tips on line 1. + * Used by the exit-confirmation double-tap flow to show "Press Ctrl+C + * again to exit" without requiring a toast/overlay subsystem. + * Pass `null` to clear. + */ + setTransientHint(hint: string | null): void { + this.transientHint = hint; + } + + getTransientHint(): string | null { + return this.transientHint; + } + + /** + * Longer-lived warning for line 2 (e.g. the over-long `/goal` objective + * warning). Unlike the transient hint it has no owner/timeout: the caller + * sets and clears it directly. A transient hint takes precedence while + * present; the warning returns as soon as the transient hint clears. + * Pass `null` to clear. + */ + setWarningHint(hint: string | null): void { + this.warningHint = hint; + } + + /** + * Source of the fixed `ctrl+o expand` / `ctrl+o collapse` hint on line 1: + * `expand` while the transcript holds collapsed tool output ctrl+o can + * reveal, `collapse` once it is shown, `null` when there is nothing to + * toggle. Read on every render so it tracks the transcript exactly. + */ + setExpandHintProvider(provider: () => ToolOutputExpandHint | null): void { + this.expandHintProvider = provider; + } + + /** + * Sync both background-task badges with live counts. Each non-zero + * count produces its own bracketed badge on line 1; zeros hide them + * independently. + */ + setBackgroundCounts(counts: { bashTasks: number; agentTasks: number }): void { + this.backgroundBashTaskCount = Math.max(0, counts.bashTasks); + this.backgroundAgentCount = Math.max(0, counts.agentTasks); + } + + invalidate(): void {} + + render(width: number): string[] { + const colors = currentTheme.palette; + const state = this.state; + + // ── Line 1: slots composed per status_line.items, or a user command ── + let line1: string; + let customLine: string | null = null; + if (this.statusLineRunner !== null) { + this.statusLineRunner.maybeRefresh(this.statusLinePayload()); + customLine = this.statusLineRunner.current(); + } + + if (customLine !== null) { + // status_line.command: the first stdout line takes over line 1. + line1 = chalk.hex(colors.text)(customLine); + } else { + const slots = this.buildSlots(colors); + const configured = this.state.statusLine?.items ?? null; + const order: readonly string[] = configured ?? DEFAULT_STATUS_LINE_ITEMS; + const composeLeft = (withTips: boolean): string => { + const left: string[] = []; + for (const slot of order) { + if (!withTips && slot === 'tips') continue; + const pieces = slots[slot as keyof typeof slots]; + if (pieces !== undefined) left.push(...pieces); + } + return left.join(' '); + }; + let leftLine = composeLeft(true); + let leftWidth = visibleWidth(leftLine); + + // The right side holds the fixed ctrl+o hint (while the transcript has + // tool output to expand or collapse) and the rotating tips, unless the + // tips were given an inline slot in items or dropped from items. The + // hint never rotates and wins over a tip that no longer fits — an + // inline tip included: it gives way when the hint would not fit beside it. + const tipsInline = order.includes('tips'); + const shortcut = this.expandShortcut(); + if (tipsInline && shortcut !== null && leftWidth + 2 + visibleWidth(shortcut) > width) { + leftLine = composeLeft(false); + leftWidth = visibleWidth(leftLine); + } + const showTips = !tipsInline && (configured === null || configured.includes('tips')); + const tipCandidates: string[] = []; + if (showTips) { + const { primary, pair } = tipsForIndex(currentTipIndex()); + if (pair) tipCandidates.push(pair); + if (primary) tipCandidates.push(primary); + } + const remaining = Math.max(0, width - leftWidth - 2); + const rightText = this.buildRightText(tipCandidates, remaining, colors); + + if (rightText.length > 0) { + const pad = width - leftWidth - visibleWidth(rightText); + line1 = leftLine + ' '.repeat(Math.max(0, pad)) + rightText; + } else if (leftWidth <= width) { + line1 = leftLine; + } else { + line1 = truncateToWidth(leftLine, width, '…'); + } + } + + // ── Line 2: hint (bottom-left) + context (right) ── + const contextText = formatContextStatus( + state.contextUsage, + state.contextTokens, + state.maxContextTokens, + ); + const contextWidth = visibleWidth(contextText); + let line2: string; + const hint = this.transientHint ?? this.warningHint; + if (hint) { + const maxHintWidth = Math.max(0, width - contextWidth - 1); + const shownHint = + visibleWidth(hint) <= maxHintWidth ? hint : truncateToWidth(hint, maxHintWidth, '…'); + const hintWidth = visibleWidth(shownHint); + const pad = Math.max(0, width - hintWidth - contextWidth); + line2 = + chalk.hex(colors.warning).bold(shownHint) + + ' '.repeat(pad) + + chalk.hex(colors.text)(contextText); + } else { + // A status_line.command owns line 1 outright, so the ctrl+o hint moves + // down here; the transient and warning hints above take precedence. + const shortcut = customLine !== null ? this.expandShortcut() : null; + const left = + shortcut !== null && visibleWidth(shortcut) + 1 + contextWidth <= width + ? chalk.hex(colors.textDim)(shortcut) + : ''; + const leftPad = Math.max(0, width - visibleWidth(left) - contextWidth); + line2 = left + ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); + } + + return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; + } + + /** The fixed ctrl+o hint plus the first rotating tip that still fits beside it. */ + /** `ctrl+o expand` / `ctrl+o collapse`, or null when there is nothing to toggle. */ + private expandShortcut(): string | null { + const hint = this.expandHintProvider?.() ?? null; + return hint === null ? null : `ctrl+o ${hint}`; + } + + private buildRightText(tips: readonly string[], remaining: number, colors: ColorPalette): string { + const shortcut = this.expandShortcut(); + if (shortcut === null) { + const tip = tips.find((candidate) => visibleWidth(candidate) <= remaining); + return tip === undefined ? '' : chalk.hex(colors.textMuted)(tip); + } + for (const tip of tips) { + if (visibleWidth(`${shortcut}${TIP_SEPARATOR}${tip}`) <= remaining) { + return ( + chalk.hex(colors.textDim)(shortcut) + chalk.hex(colors.textMuted)(`${TIP_SEPARATOR}${tip}`) + ); + } + } + return visibleWidth(shortcut) <= remaining ? chalk.hex(colors.textDim)(shortcut) : ''; + } + + /** + * Rendered pieces per status-line slot. Empty-content slots (e.g. no goal, + * outside a git repo) yield an empty list so composition just skips them. + */ + private buildSlots(colors: ColorPalette): Record<string, string[]> { + const state = this.state; + const slots: Record<string, string[]> = { + mode: [], + goal: [], + model: [], + tasks: [], + cwd: [], + git: [], + tips: [], + }; + + { + const { primary, pair } = tipsForIndex(currentTipIndex()); + const tip = pair ?? primary; + if (tip) slots['tips'] = [chalk.hex(colors.textMuted)(tip)]; + } + + const modes: string[] = []; + if (state.permissionMode === 'auto') modes.push(chalk.hex(colors.warning).bold(PERMISSION_MODE_DISPLAY_NAMES.auto)); + if (state.permissionMode === 'yolo') modes.push(chalk.hex(colors.warning).bold(PERMISSION_MODE_DISPLAY_NAMES.yolo)); + if (state.planMode) modes.push(chalk.hex(colors.primary).bold('plan')); + if (state.swarmMode) modes.push(chalk.hex(colors.accent).bold('swarm')); + if (state.towerMode) modes.push(chalk.hex(colors.accent).bold('tower')); + if (modes.length > 0) slots['mode'] = [modes.join(' ')]; + + const goalBadge = formatGoalBadge(state.goal, colors, this.goalWallClockMs(state.goal)); + if (goalBadge !== null) slots['goal'] = [goalBadge]; + + const model = modelDisplayName(state); + if (model) { + const effort = state.thinkingEffort; + const rawCurrentModel = state.availableModels[state.model]; + const currentModel = + rawCurrentModel === undefined ? undefined : effectiveModelAlias(rawCurrentModel); + // Only effort-capable models (those declaring support_efforts) show the + // concrete effort; legacy boolean models keep the plain "thinking" suffix. + const hasEfforts = (currentModel?.supportEfforts?.length ?? 0) > 0; + const thinkingLabel = + effort !== 'off' + ? hasEfforts && effort !== 'on' + ? ` thinking: ${effort}` + : ' thinking' + : ''; + const modelLabel = `${model}${thinkingLabel}`; + let renderedModelLabel = chalk.hex(colors.text)(modelLabel); + if (isRainbowDancing()) { + renderedModelLabel = renderDanceFooterModel(modelLabel); + } + slots['model'] = [renderedModelLabel]; + } + + // Background-task badges. `bash-*` tasks (shell processes) and `agent-*` + // tasks (background subagents) stay separate so the user can tell them + // apart at a glance. + const taskBadges: string[] = []; + if (this.backgroundBashTaskCount > 0) { + const noun = this.backgroundBashTaskCount === 1 ? 'task' : 'tasks'; + taskBadges.push( + chalk.hex(colors.primary)(`[${String(this.backgroundBashTaskCount)} ${noun} running]`), + ); + } + if (this.backgroundAgentCount > 0) { + const noun = this.backgroundAgentCount === 1 ? 'agent' : 'agents'; + taskBadges.push( + chalk.hex(colors.primary)(`[${String(this.backgroundAgentCount)} ${noun} running]`), + ); + } + slots['tasks'] = taskBadges; + + const cwd = shortenCwd(state.workDir); + if (cwd) slots['cwd'] = [chalk.hex(colors.textDim)(cwd)]; + + const git = this.gitCache.getStatus(); + if (git !== null) slots['git'] = [formatFooterGitBadge(git, colors)]; + + return slots; + } + + private statusLinePayload(): StatusLinePayload { + const state = this.state; + return { + model: modelDisplayName(state), + cwd: state.workDir, + gitBranch: this.gitCache.getStatus()?.branch ?? null, + permissionMode: state.permissionMode, + planMode: state.planMode, + contextUsage: state.contextUsage, + contextTokens: state.contextTokens, + maxContextTokens: state.maxContextTokens, + sessionId: state.sessionId, + version: state.version, + }; + } + + private syncGoalClock(goal: AppState['goal']): void { + const key = goalSnapshotKey(goal); + if (key === this.goalSnapshotKey) return; + this.goalSnapshotKey = key; + this.goalObservedAtMs = Date.now(); + } + + private syncGoalTimer(goal: AppState['goal']): void { + if (goal?.status === 'active') { + if (this.goalTimer !== null) return; + this.goalTimer = setInterval(() => { + this.onRefresh(); + }, GOAL_TIMER_INTERVAL_MS); + this.goalTimer.unref?.(); + return; + } + + if (this.goalTimer !== null) { + clearInterval(this.goalTimer); + this.goalTimer = null; + } + } + + dispose(): void { + if (this.goalTimer !== null) { + clearInterval(this.goalTimer); + this.goalTimer = null; + } + } + + private goalWallClockMs(goal: AppState['goal']): number | undefined { + if (goal === null || goal === undefined) return undefined; + if (goal.status !== 'active') return goal.wallClockMs; + return goal.wallClockMs + Math.max(0, Date.now() - this.goalObservedAtMs); + } +} + +function goalSnapshotKey(goal: AppState['goal']): string | null { + if (goal === null || goal === undefined) return null; + return [ + goal.goalId, + goal.status, + goal.terminalReason ?? '', + String(goal.turnsUsed), + String(goal.tokensUsed), + String(goal.wallClockMs), + String(goal.budget.tokenBudget), + String(goal.budget.turnBudget), + String(goal.budget.wallClockBudgetMs), + ].join('\u0000'); +} diff --git a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts new file mode 100644 index 0000000000000000000000000000000000000000..eec93f33167ef165a14c2aa0d7544352f031742c --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts @@ -0,0 +1,103 @@ +/** + * Container that reserves left/right gutter columns around its children, + * so the chrome (statusline, transcript, panels) lines up with the input + * box's inner content area instead of butting up against the terminal edge. + * + * Children are rendered at `width - left - right` and each emitted line is + * prefixed with `left` plain spaces. Right padding is logical only — we + * never emit trailing spaces, since terminals already paint background to + * the edge and adding them would just churn the diff renderer. + * + * The render cache below validates per child (component identity + the + * identity of its rendered line array), so structural child-list changes — + * append, splice-removal, in-place replacement — are picked up correctly + * without a tree-wide `invalidate()`. Reserve `invalidate()` for global + * style changes that genuinely dirty every child (e.g. theme switches). + */ + +import { Container } from '@moonshot-ai/pi-tui'; +import type { Component, TuiMouseDispatchResult, TuiMouseEvent } from '@moonshot-ai/pi-tui'; + +import { prefixPreservingOsc133Zone } from '#/tui/utils/osc133'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; + +interface TranscriptRenderCache { + width: number; + childRefs: Component[]; + childRenderRefs: string[][]; + prefixed: string[][]; + out: string[]; +} + +export class GutterContainer extends Container { + private renderCache: TranscriptRenderCache | undefined; + constructor( + private readonly leftPad: number, + private readonly rightPad: number, + ) { + super(); + } + + override invalidate(): void { + this.renderCache = undefined; + super.invalidate(); + } + + override render(width: number): string[] { + const inner = Math.max(1, width - this.leftPad - this.rightPad); + const lead = ' '.repeat(this.leftPad); + + const cache = this.renderCache; + const cacheValid = + isRenderCacheEnabled() && + cache !== undefined && + cache.width === width && + cache.childRefs.length === this.children.length; + + const childRefs: Component[] = []; + const childRenderRefs: string[][] = []; + const prefixed: string[][] = []; + let allReused = cacheValid; + + let i = 0; + for (const child of this.children) { + const lines = child.render(inner); + childRefs.push(child); + childRenderRefs.push(lines); + const reused = cacheValid && cache.childRefs[i] === child && cache.childRenderRefs[i] === lines; + if (reused) { + prefixed.push(cache.prefixed[i]!); + } else { + allReused = false; + // OSC 133 zone markers must stay at byte 0 for the fullscreen + // renderer's prompt navigation, so the gutter goes after them. + prefixed.push(lines.map((line) => prefixPreservingOsc133Zone(line, lead))); + } + i++; + } + + let out: string[]; + if (allReused) { + out = cache!.out; + } else { + out = []; + for (const lines of prefixed) { + for (const line of lines) out.push(line); + } + } + + if (isRenderCacheEnabled()) { + this.renderCache = { width, childRefs, childRenderRefs, prefixed, out }; + } + + return out; + } + + // Mouse events arrive in this container's frame, which includes the + // gutters; children render at the shrunk inner width after the left pad, + // so translate before delegating or clicks land a gutter-width off. + override handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined { + const inner = Math.max(1, event.width - this.leftPad - this.rightPad); + return super.handleMouse({ ...event, x: event.x - this.leftPad, width: inner }); + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9d21452e7694f763982a4315ed2c260d2d2a6ed --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts @@ -0,0 +1,107 @@ +import { Text, visibleWidth } from '@moonshot-ai/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; + +import { + BRAILLE_SPINNER_FRAMES, + BRAILLE_SPINNER_INTERVAL_MS, + MOON_SPINNER_FRAMES, + MOON_SPINNER_INTERVAL_MS, +} from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; + +export type SpinnerStyle = 'moon' | 'braille'; + +export class MoonLoader extends Text { + private currentFrame = 0; + private intervalId: ReturnType<typeof setInterval> | null = null; + private ui: TUI; + private frames: string[]; + private interval: number; + private colorFn?: (s: string) => string; + private label: string; + private displayText = ''; + // Inline text used when the spinner is embedded into another line (e.g. the + // agent-swarm progress status line). It intentionally excludes the tip: the + // tip is only rendered when the loader sits on its own row in the activity + // pane, otherwise it would get squeezed against whatever follows the inline + // spinner (like the swarm progress bar). + private inlineText = ''; + private tip: string = ''; + private availableWidth = 0; + + constructor( + ui: TUI, + style: SpinnerStyle = 'moon', + colorFn?: (s: string) => string, + label: string = '', + ) { + super('', 1, 0); + this.ui = ui; + this.frames = style === 'moon' ? [...MOON_SPINNER_FRAMES] : [...BRAILLE_SPINNER_FRAMES]; + this.interval = style === 'moon' ? MOON_SPINNER_INTERVAL_MS : BRAILLE_SPINNER_INTERVAL_MS; + this.colorFn = colorFn; + this.label = label; + this.start(); + } + + start(): void { + this.updateDisplay(); + this.intervalId = setInterval(() => { + this.currentFrame = (this.currentFrame + 1) % this.frames.length; + this.updateDisplay(); + }, this.interval); + } + + stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + dispose(): void { + this.stop(); + } + + setLabel(label: string): void { + this.label = label; + this.updateDisplay(); + } + + setColorFn(colorFn: (s: string) => string): void { + this.colorFn = colorFn; + this.updateDisplay(); + } + + setTip(tip: string): void { + this.tip = tip; + this.updateDisplay(); + } + + setAvailableWidth(width: number): void { + if (this.availableWidth === width) return; + this.availableWidth = width; + this.updateDisplay(); + } + + renderInline(): string { + return this.inlineText; + } + + private updateDisplay(): void { + const frame = this.frames[this.currentFrame]!; + const coloredFrame = this.colorFn ? this.colorFn(frame) : frame; + const baseText = this.label ? `${coloredFrame} ${this.label}` : coloredFrame; + this.inlineText = baseText; + let text = baseText; + if (this.tip) { + const withTip = baseText + currentTheme.fg('textDim', this.tip); + if (this.availableWidth === 0 || visibleWidth(withTip) <= this.availableWidth) { + text = withTip; + } + } + this.displayText = text; + this.setText(this.displayText); + this.ui.requestRender(); + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/notify-panel.ts b/apps/kimi-code/src/tui/components/chrome/notify-panel.ts new file mode 100644 index 0000000000000000000000000000000000000000..dbcc7b99c0d3d54f4f2b66b16f3ab70aebbe0e7c --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/notify-panel.ts @@ -0,0 +1,336 @@ +/** + * NotifyPanel — mid-turn updates, shown as a bordered box right above the + * input area (below the Todo panel), visually matching the editor. + * + * Updates are grouped into CHANNELS, one per agent: the main agent plus one + * channel per subagent that posts a `NotifyUser` update. The top border is a + * tab strip of channel labels; `Ctrl+N` focuses the box, then `←`/`→` switch + * channels and `↑`/`↓` page through the current channel's updates. While the + * user is not focused, the view follows the latest activity across channels; + * while focused it stays put and other channels collect an unread dot. + * Entries render at their natural height — the box adapts instead of + * truncating. When the turn ends the box folds to a one-line stub until the + * user focuses it; the host clears the box when the next turn starts. + */ + +import type { Component } from '@moonshot-ai/pi-tui'; +import { Markdown, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import { MAIN_AGENT_ID } from '#/tui/constant/kimi-tui'; +import { currentTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { createMarkdownOptions } from '#/tui/utils/markdown-options'; + +import { wrapWithSideBorders } from '../editor/custom-editor'; + +export interface NotifyEntry { + readonly id: string; + readonly agentId: string; + readonly agentName?: string; + readonly time: number; + readonly text: string; +} + +export interface NotifyChannelView { + readonly label: string; + readonly entries: readonly { readonly id: string; readonly text: string }[]; + readonly unread: number; +} + +interface NotifyChannel { + readonly key: string; + readonly label: string; + readonly entries: NotifyEntry[]; + /** Page (entry index) the user last read in this channel. */ + page: number; + /** Ids of entries that arrived while the user was reading another channel. */ + readonly unreadIds: Set<string>; +} + +function padToVisibleWidth(text: string, target: number): string { + const truncated = truncateToWidth(text, target); + const gap = target - visibleWidth(truncated); + return gap > 0 ? truncated + ' '.repeat(gap) : truncated; +} + +export class NotifyPanelComponent implements Component { + private readonly channels: NotifyChannel[] = []; + /** Key of the channel on display; channels themselves are append-only. */ + private activeKey = MAIN_AGENT_ID; + private focused = false; + private ended = false; + /** Turn ended and no one is reading: the box folds to a one-line stub. */ + private collapsed = false; + + /** + * Add or update an entry in a channel. A repeated `id` updates the entry in + * place; a new id appends. While unfocused the view jumps to the entry's + * channel and its tail — the latest activity always wins; while focused the + * user's page stays put and background channels collect unread instead. + */ + upsert(entry: NotifyEntry): void { + const ch = this.channelFor(entry); + const existing = ch.entries.find((item) => item.id === entry.id); + const isNew = existing === undefined; + if (existing !== undefined) ch.entries[ch.entries.indexOf(existing)] = entry; + else ch.entries.push(entry); + if (!this.focused) { + this.activeKey = ch.key; + ch.page = ch.entries.length - 1; + ch.unreadIds.clear(); + } else if (isNew && ch.key !== this.activeKey) { + ch.unreadIds.add(entry.id); + } + this.ended = false; + this.collapsed = false; + } + + remove(id: string): boolean { + const ch = this.channels.find((channel) => channel.entries.some((entry) => entry.id === id)); + if (ch === undefined) return false; + const index = ch.entries.findIndex((entry) => entry.id === id); + ch.entries.splice(index, 1); + if (ch.entries.length === 0) { + this.channels.splice(this.channels.indexOf(ch), 1); + if (ch.key === this.activeKey) this.activeKey = this.channels.at(-1)?.key ?? MAIN_AGENT_ID; + } else { + ch.page = Math.min(ch.page, ch.entries.length - 1); + ch.unreadIds.delete(id); + } + if (this.channels.length === 0) this.focused = false; + return true; + } + + clear(): void { + this.channels.length = 0; + this.activeKey = MAIN_AGENT_ID; + this.focused = false; + this.ended = false; + this.collapsed = false; + } + + isEmpty(): boolean { + return this.channels.length === 0; + } + + getChannels(): readonly NotifyChannelView[] { + return this.channels.map((ch) => ({ + label: ch.label, + entries: ch.entries.map((entry) => ({ id: entry.id, text: entry.text })), + unread: ch.unreadIds.size, + })); + } + + /** Every entry across channels, in display order (main first, then arrival). */ + getEntries(): readonly NotifyEntry[] { + return this.channels.flatMap((ch) => ch.entries); + } + + /** + * The turn that produced these updates has ended: dim the title and fold + * the box down to a one-line stub so the final reply owns the screen. A + * focused user keeps reading — the fold lands when they blur. + */ + setEnded(ended: boolean): void { + this.ended = ended; + if (ended && !this.focused) this.collapsed = true; + if (!ended) this.collapsed = false; + } + + /** Grab keyboard paging for the box; returns false when there is nothing to read. */ + focus(): boolean { + if (this.channels.length === 0) return false; + this.focused = true; + this.collapsed = false; + this.activeChannel().unreadIds.clear(); + return true; + } + + blur(): boolean { + if (!this.focused) return false; + this.focused = false; + if (this.ended) this.collapsed = true; + return true; + } + + isFocused(): boolean { + return this.focused; + } + + /** Older channel (`←`), landing on its latest update; false on the leftmost tab. */ + prevChannel(): boolean { + const index = this.activeIndex(); + if (index <= 0) return false; + this.activeKey = this.channels[index - 1]!.key; + const ch = this.activeChannel(); + ch.page = ch.entries.length - 1; + ch.unreadIds.clear(); + return true; + } + + /** Newer channel (`→`), landing on its latest update; false on the rightmost tab. */ + nextChannel(): boolean { + const index = this.activeIndex(); + if (index >= this.channels.length - 1) return false; + this.activeKey = this.channels[index + 1]!.key; + const ch = this.activeChannel(); + ch.page = ch.entries.length - 1; + ch.unreadIds.clear(); + return true; + } + + /** Older update in the current channel (`↑`); false on the first page. */ + prevPage(): boolean { + if (this.channels.length === 0) return false; + const ch = this.activeChannel(); + if (ch.page <= 0) return false; + ch.page -= 1; + return true; + } + + /** Newer update in the current channel (`↓`); false on the latest page. */ + nextPage(): boolean { + if (this.channels.length === 0) return false; + const ch = this.activeChannel(); + if (ch.page >= ch.entries.length - 1) return false; + ch.page += 1; + return true; + } + + invalidate(): void {} + + render(width: number): string[] { + if (this.channels.length === 0) return []; + const c = currentTheme.palette; + const paint = chalk.hex(this.focused ? c.primary : c.border); + + if (this.collapsed) { + return ['', this.stubLine(width)].map((line) => truncateToWidth(line, width)); + } + + const innerWidth = Math.max(1, width - 6); + const ch = this.activeChannel(); + const entry = ch.entries[Math.min(ch.page, ch.entries.length - 1)]!; + const bodyRows = new Markdown( + entry.text.trim(), + 0, + 0, + createMarkdownTheme(), + undefined, + createMarkdownOptions(), + ).render(innerWidth); + + const padRow = (row: string): string => ` ${padToVisibleWidth(row, width - 6)} `; + const emptyRow = ' '.repeat(width); + const lines: string[] = ['─'.repeat(width), emptyRow]; + for (const row of bodyRows) { + lines.push(padRow(row)); + } + lines.push(emptyRow, '─'.repeat(width)); + + const boxed = wrapWithSideBorders(lines, paint, { label: this.title(width) }); + return ['', ...boxed].map((line) => truncateToWidth(line, width)); + } + + private channelFor(entry: NotifyEntry): NotifyChannel { + const key = entry.agentId; + const found = this.channels.find((ch) => ch.key === key); + if (found !== undefined) return found; + const created: NotifyChannel = { + key, + label: this.labelFor(entry.agentName ?? key), + entries: [], + page: 0, + unreadIds: new Set(), + }; + if (key === MAIN_AGENT_ID) this.channels.unshift(created); + else this.channels.push(created); + return created; + } + + /** Dedup identical labels as `explore`, `explore(2)`, `explore(3)`, … */ + private labelFor(base: string): string { + if (!this.channels.some((ch) => ch.label === base)) return base; + let n = 2; + while (this.channels.some((ch) => ch.label === `${base}(${String(n)})`)) n += 1; + return `${base}(${String(n)})`; + } + + private activeIndex(): number { + const index = this.channels.findIndex((ch) => ch.key === this.activeKey); + return Math.max(0, index); + } + + private activeChannel(): NotifyChannel { + return this.channels[this.activeIndex()]!; + } + + /** + * The collapsed one-liner: an expand hint marker, the channel tabs, the + * total update count, and a preview of the current entry's first line — + * everything the user needs to decide whether to open the box. + */ + private stubLine(width: number): string { + const c = currentTheme.palette; + const dim = chalk.hex(c.textDim); + const marker = chalk.hex(c.primary)('▸'); + const tabs = this.channels + .map((channel) => this.renderTab(channel, channel.key === this.activeKey)) + .join(dim(' · ')); + const total = this.channels.reduce((sum, ch) => sum + ch.entries.length, 0); + const noun = total === 1 ? 'update' : 'updates'; + const head = ` ${marker} ${tabs} ${dim(`· ${String(total)} ${noun}`)}`; + const tail = ` ${dim('· ctrl+n')}`; + const preview = this.stubPreviewText(); + if (preview !== undefined) { + const budget = width - visibleWidth(head) - visibleWidth(tail) - 3; + if (budget >= 12) return `${head} ${dim('·')} ${dim(truncateToWidth(preview, budget))}${tail}`; + } + if (visibleWidth(head) + visibleWidth(tail) <= width) return `${head}${tail}`; + return ` ${marker} ${dim(`${String(total)} ${noun} · ctrl+n`)}`; + } + + /** First non-empty line of the current entry, stripped of list/bold markers. */ + private stubPreviewText(): string | undefined { + const ch = this.activeChannel(); + const entry = ch.entries[Math.min(ch.page, ch.entries.length - 1)]; + if (entry === undefined) return undefined; + const firstLine = entry.text + .trim() + .split('\n') + .find((line) => line.trim().length > 0) + ?.trim() + .replace(/^[-*#>\s]+/, '') + .replaceAll('**', ''); + return firstLine === undefined || firstLine.length === 0 ? undefined : firstLine; + } + + /** + * The styled top-border label: a tab strip of channel labels (active tab + * highlighted, background channels with unread dotted), then the page + * indicator and key hints, slimmed down progressively as width shrinks. + */ + private title(width: number): string | undefined { + const c = currentTheme.palette; + const ch = this.activeChannel(); + const page = `${String(ch.page + 1)}/${String(ch.entries.length)}`; + const state = this.ended ? ' · turn ended' : ''; + const hint = this.focused ? ' · ← → agent · ↑ ↓ update · esc close' : ' · ctrl+n page'; + const paintTitle = this.ended ? chalk.hex(c.textDim).bold : chalk.hex(c.primary).bold; + const tabs = this.channels + .map((channel) => this.renderTab(channel, channel.key === this.activeKey)) + .join(chalk.hex(c.textDim)(' · ')); + const full = ` ${tabs} · Updates ${page}${state}${hint} `; + if (visibleWidth(full) <= width - 4) return paintTitle(full); + const compact = ` ${ch.label} · Updates ${page}${state} `; + return visibleWidth(compact) <= width - 4 ? paintTitle(compact) : undefined; + } + + private renderTab(channel: NotifyChannel, active: boolean): string { + const c = currentTheme.palette; + if (active) return chalk.hex(c.primary).bold(channel.label); + const label = channel.unreadIds.size > 0 ? `${channel.label}●` : channel.label; + return channel.unreadIds.size > 0 ? chalk.hex(c.primary)(label) : chalk.hex(c.textDim)(label); + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts new file mode 100644 index 0000000000000000000000000000000000000000..b101b6d6c7b172b647cbdb97b208323357f3eae9 --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts @@ -0,0 +1,223 @@ +/** + * TodoPanel — live-updating TODO list shown before the input area. + * + * Mounted as a dedicated `Container` slot between the activity pane + * (spinners / thinking stream) and the queue / editor block. The host + * calls {@link setTodos} whenever the LLM invokes the `TodoList` + * tool; state survives across turns so the list stays visible until + * explicitly cleared (`todos: []`), a new session starts, or `/clear` + * is issued. + */ + +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; + +export type TodoStatus = 'pending' | 'in_progress' | 'done'; + +export interface TodoItem { + readonly title: string; + readonly status: TodoStatus; +} + +const MAX_VISIBLE = 5; + +export interface VisibleTodos { + readonly rows: readonly TodoItem[]; + readonly hidden: number; + readonly hiddenCounts: Record<TodoStatus, number>; +} + +/** + * Pick which todos to render when the list exceeds {@link MAX_VISIBLE}. + * + * The selector is order-agnostic — the TodoList tool keeps whatever + * order the model produced and does not group items by status, so an + * interleaved sequence like `pending, done, pending, done, ...` is + * possible and must still yield MAX_VISIBLE rows when enough exist. + * + * Strategy: + * 1. Include every `in_progress` item (capped at MAX_VISIBLE). + * 2. Fill remaining slots with "what's next" — the earliest `pending` + * items in their original positions — while reserving one slot for + * "what just finished" — the latest `done` item — when both kinds + * exist. If one side has too few candidates, the other expands. + * + * Items are returned in their original order. + */ +export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos { + if (todos.length <= MAX_VISIBLE) { + return { + rows: [...todos], + hidden: 0, + hiddenCounts: { done: 0, in_progress: 0, pending: 0 }, + }; + } + + const inProgress: number[] = []; + const pending: number[] = []; + const done: number[] = []; + for (const [i, todo] of todos.entries()) { + if (todo.status === 'in_progress') inProgress.push(i); + else if (todo.status === 'pending') pending.push(i); + else done.push(i); + } + + const picked = new Set<number>(); + for (const i of inProgress.slice(0, MAX_VISIBLE)) picked.add(i); + + if (picked.size < MAX_VISIBLE) { + // Most recent done first; earliest pending first. + const doneCandidates = done.toReversed(); + const pendingCandidates = pending; + + const remaining = MAX_VISIBLE - picked.size; + let doneCount: number; + let pendingCount: number; + if (doneCandidates.length === 0) { + doneCount = 0; + pendingCount = Math.min(remaining, pendingCandidates.length); + } else if (pendingCandidates.length === 0) { + pendingCount = 0; + doneCount = Math.min(remaining, doneCandidates.length); + } else { + doneCount = 1; + pendingCount = Math.min(remaining - 1, pendingCandidates.length); + if (pendingCount < remaining - 1) { + doneCount = Math.min(doneCandidates.length, remaining - pendingCount); + } + } + + for (let i = 0; i < doneCount; i++) picked.add(doneCandidates[i] as number); + for (let i = 0; i < pendingCount; i++) picked.add(pendingCandidates[i] as number); + } + + const sortedIdx = [...picked].toSorted((a, b) => a - b); + + const hiddenCounts: Record<TodoStatus, number> = { done: 0, in_progress: 0, pending: 0 }; + for (const [i, todo] of todos.entries()) { + if (!picked.has(i)) { + hiddenCounts[todo.status] += 1; + } + } + + return { + rows: sortedIdx.map((i) => todos[i] as TodoItem), + hidden: todos.length - sortedIdx.length, + hiddenCounts, + }; +} + +export class TodoPanelComponent implements Component { + private todos: readonly TodoItem[] = []; + private expanded = false; + + setTodos(todos: readonly TodoItem[]): void { + this.todos = todos.map((t) => ({ title: t.title, status: t.status })); + } + + getTodos(): readonly TodoItem[] { + return this.todos; + } + + clear(): void { + this.todos = []; + this.expanded = false; + } + + isEmpty(): boolean { + return this.todos.length === 0; + } + + /** True when the list exceeds the collapsed cap, i.e. there is something to expand. */ + hasOverflow(): boolean { + return this.todos.length > MAX_VISIBLE; + } + + setExpanded(expanded: boolean): void { + this.expanded = expanded; + } + + toggleExpanded(): void { + this.expanded = !this.expanded; + } + + invalidate(): void {} + + render(width: number): string[] { + if (this.todos.length === 0) return []; + const c = currentTheme.palette; + const lines: string[] = [ + chalk.hex(c.border)('─'.repeat(width)), + chalk.hex(c.primary).bold(' Todo'), + ]; + + if (this.expanded) { + for (const todo of this.todos) { + lines.push(renderRow(todo, c)); + } + if (this.todos.length > MAX_VISIBLE) { + lines.push( + chalk.hex(c.textDim)(` all ${String(this.todos.length)} items · ctrl+t to collapse`), + ); + } + } else { + const { rows, hidden, hiddenCounts } = selectVisibleTodos(this.todos); + for (const todo of rows) { + lines.push(renderRow(todo, c)); + } + if (hidden > 0) { + const distribution = formatHiddenCounts(hiddenCounts); + const suffix = distribution.length > 0 ? ` (${distribution})` : ''; + lines.push( + chalk.hex(c.textDim)(` … +${hidden} more${suffix} · ctrl+t to expand`), + ); + } + } + + return lines.map((line) => truncateToWidth(line, width)); + } +} + +function renderRow(todo: TodoItem, colors: ColorPalette): string { + const marker = statusMarker(todo.status, colors); + const titleStyled = styleTitle(todo.title, todo.status, colors); + return ` ${marker} ${titleStyled}`; +} + +function statusMarker(status: TodoStatus, colors: ColorPalette): string { + switch (status) { + case 'in_progress': + return chalk.hex(colors.primary).bold('●'); + case 'done': + return chalk.hex(colors.success)('✓'); + case 'pending': + return chalk.hex(colors.textDim)('○'); + } +} + +function styleTitle(title: string, status: TodoStatus, colors: ColorPalette): string { + switch (status) { + case 'in_progress': + return chalk.hex(colors.text).bold(title); + case 'done': + return chalk.hex(colors.textDim).strikethrough(title); + case 'pending': + return chalk.hex(colors.text)(title); + } +} + +const STATUS_LABELS: readonly { status: TodoStatus; label: string }[] = [ + { status: 'done', label: 'done' }, + { status: 'in_progress', label: 'in progress' }, + { status: 'pending', label: 'pending' }, +]; + +export function formatHiddenCounts(counts: Record<TodoStatus, number>): string { + return STATUS_LABELS.filter(({ status }) => counts[status] > 0) + .map(({ status, label }) => `${counts[status]} ${label}`) + .join(' · '); +} diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts new file mode 100644 index 0000000000000000000000000000000000000000..10cdecbdb292a6b8a2e42895e1d2eb881c5607dc --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -0,0 +1,111 @@ +/** + * Welcome panel shown at the top of the TUI. + * Renders a round-bordered box with the logo, session, model, and version. + */ + +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk'; + +import { isRainbowDancing, renderDanceWelcomeHeader } from '#/tui/easter-eggs/dance'; +import type { AppState } from '#/tui/types'; +import { currentTheme } from '#/tui/theme'; + +export class WelcomeComponent implements Component { + private state: AppState; + + constructor(state: AppState) { + this.state = state; + } + + invalidate(): void {} + + render(width: number): string[] { + const safeWidth = Math.max(0, width); + const primary = (s: string): string => chalk.hex(currentTheme.palette.primary)(s); + const isLoggedOut = !this.state.model; + const activeModel = this.state.availableModels[this.state.model]; + const effectiveActiveModel = activeModel === undefined ? undefined : effectiveModelAlias(activeModel); + + if (safeWidth < 24) { + const title = chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!'); + const prompt = isLoggedOut + ? chalk.hex(currentTheme.palette.warning)('Run /login or /provider to get started.') + : chalk.hex(currentTheme.palette.textDim)('Send /help for help information.'); + const model = isLoggedOut + ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') + : (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model); + return ['', title, prompt, `Model: ${model}`].map((line) => + truncateToWidth(line, safeWidth, '…'), + ); + } + + const innerWidth = Math.max(1, safeWidth - 4); + const pad = ' '; + + // Logo + side-by-side text. + const logo = ['▐█▛█▛█▌', '▐█████▌'] as const; + const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); + const gap = ' '; + const textWidth = Math.max(4, innerWidth - logoWidth - gap.length); + + const rightRow0 = truncateToWidth( + chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!'), + textWidth, + '…', + ); + const dim = chalk.hex(currentTheme.palette.textDim); + const labelStyle = chalk.bold.hex(currentTheme.palette.textDim); + const rightRow1 = truncateToWidth( + dim(isLoggedOut ? 'Run /login or /provider to get started.' : 'Send /help for help information.'), + textWidth, + '…', + ); + + let renderedHeaderLines = [ + primary(logo[0].padEnd(logoWidth)) + gap + rightRow0, + primary(logo[1].padEnd(logoWidth)) + gap + rightRow1, + ]; + if (isRainbowDancing()) { + renderedHeaderLines = renderDanceWelcomeHeader(logo, textWidth, rightRow1); + } + + const modelValue = isLoggedOut + ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') + : (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model); + + const infoLines = [ + labelStyle('Directory: ') + this.state.workDir, + labelStyle('Session: ') + this.state.sessionId, + labelStyle('Model: ') + modelValue, + labelStyle('Version: ') + this.state.version, + ]; + + if (this.state.mcpServersSummary) { + infoLines.push(labelStyle('MCP: ') + this.state.mcpServersSummary); + } + + const contentLines: string[] = [...renderedHeaderLines, '', ...infoLines]; + + const lines: string[] = [ + '', + primary('╭' + '─'.repeat(safeWidth - 2) + '╮'), + primary('│') + ' '.repeat(safeWidth - 2) + primary('│'), + ]; + + for (const content of contentLines) { + const truncated = truncateToWidth(content, innerWidth, '…'); + const vis = visibleWidth(truncated); + const rightPad = Math.max(0, innerWidth - vis); + lines.push(primary('│') + pad + truncated + ' '.repeat(rightPad) + primary('│')); + } + + lines.push(primary('│') + ' '.repeat(safeWidth - 2) + primary('│')); + lines.push(primary('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); + + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/working-tips.ts b/apps/kimi-code/src/tui/components/chrome/working-tips.ts new file mode 100644 index 0000000000000000000000000000000000000000..23d0027389fe38f61ad432b57223cce72fca8c27 --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/working-tips.ts @@ -0,0 +1,31 @@ +import { WORKING_TIPS, type ToolbarTip } from '#/tui/constant/tips'; + +import { buildWeightedTips } from './footer'; + +export { WORKING_TIPS }; + +const TIP_ROTATE_INTERVAL_MS = 10_000; + +const WORKING_TIP_ROTATION = buildWeightedTips(WORKING_TIPS); + +export function currentWorkingTip(now = Date.now()): ToolbarTip | undefined { + if (WORKING_TIP_ROTATION.length === 0) return undefined; + const index = Math.floor(now / TIP_ROTATE_INTERVAL_MS) % WORKING_TIP_ROTATION.length; + return WORKING_TIP_ROTATION[index]; +} + +/** + * Pick a random tip from the weighted working-tip rotation. + * If `excludeText` is provided and there are other tips available, avoid + * returning the same text twice in a row. + */ +export function pickRandomWorkingTip(excludeText?: string): ToolbarTip | undefined { + if (WORKING_TIP_ROTATION.length === 0) return undefined; + const candidates = + excludeText === undefined || WORKING_TIP_ROTATION.length === 1 + ? WORKING_TIP_ROTATION + : WORKING_TIP_ROTATION.filter((t) => t.text !== excludeText); + const pool = candidates.length > 0 ? candidates : WORKING_TIP_ROTATION; + const index = Math.floor(Math.random() * pool.length); + return pool[index]; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/agent-activity-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/agent-activity-viewer.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a58d9ffd5b80cdfcf94560c4c8e031e33e36672 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/agent-activity-viewer.ts @@ -0,0 +1,434 @@ +/** + * AgentActivityViewer — full-screen detail view for a background agent task. + * + * Same full-screen skeleton as `TaskOutputViewer` (header / scrolling body / + * footer, tail-follow), but the body is assembled from the in-memory + * `SubagentActivityRecord` instead of the task's captured output: recent + * steps with their assistant text (Markdown, same as the main transcript) + * and tool calls rendered through the main-flow result renderers + * (`pickResultRenderer` / `pickChip` / `extractKeyArgument`). `ToolCallComponent` + * itself is not reused — it is a live, event-driven component, while this + * view renders a snapshot. + * + * Ctrl+O toggles a global expand of every tool result (same semantics as the + * main transcript's `toolOutputExpanded`), capped by what the store retained. + */ + +import { + Container, + Key, + matchesKey, + type Focusable, + type Terminal, + truncateToWidth, + visibleWidth, +} from '@moonshot-ai/pi-tui'; +import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; + +import { MESSAGE_INDENT } from '#/tui/constant/rendering'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import type { + SubagentActivityRecord, + SubToolCallActivity, +} from '#/tui/controllers/subagent-activity-store'; +import { currentTheme } from '#/tui/theme'; +import type { ToolCallBlockData } from '#/tui/types'; +import { printableChar } from '#/tui/utils/printable-key'; +import { AssistantMessageComponent } from '../messages/assistant-message'; +import { extractKeyArgument } from '../messages/tool-call'; +import { pickChip } from '../messages/tool-renderers/chip'; +import { pickResultRenderer } from '../messages/tool-renderers/registry'; +import { STATUS_LABEL, statusColor } from './task-output-viewer'; + +const ELLIPSIS = '…'; + +export interface AgentActivityViewerProps { + readonly taskId: string; + readonly info: BackgroundTaskInfo | undefined; + readonly record: SubagentActivityRecord | undefined; + readonly onClose: () => void; +} + +function padToWidth(line: string, width: number): string { + const w = visibleWidth(line); + if (w === width) return line; + if (w > width) return truncateToWidth(line, width, ELLIPSIS); + return line + ' '.repeat(width - w); +} + +function fitExactly(line: string, width: number): string { + let s = line; + if (visibleWidth(s) > width) s = truncateToWidth(s, width, ELLIPSIS); + return padToWidth(s, width); +} + +export class AgentActivityViewer extends Container implements Focusable { + focused = false; + + private props: AgentActivityViewerProps; + private readonly terminal: Terminal; + private expanded = false; + /** Index of the topmost visible body line. */ + private scrollTop = 0; + /** Stick to the bottom on updates until the user scrolls away. */ + private followTail = true; + private lines: string[] = []; + private lastCacheKey = ''; + + constructor(props: AgentActivityViewerProps, terminal: Terminal) { + super(); + this.props = props; + this.terminal = terminal; + } + + setProps(next: AgentActivityViewerProps): void { + this.props = next; + this.invalidate(); + } + + override invalidate(): void { + // Theme switches arrive as a tree-wide invalidate; the styled body lines + // are cached, so drop the cache here to pick up the new palette. + this.lastCacheKey = ''; + super.invalidate(); + } + + // ── input ────────────────────────────────────────────────────────── + + handleInput(data: string): void { + const visible = this.viewableRows(); + const k = printableChar(data); + + if (matchesKey(data, Key.escape) || k === 'q' || k === 'Q') { + this.props.onClose(); + return; + } + if (matchesKey(data, Key.ctrl('o'))) { + this.expanded = !this.expanded; + this.lastCacheKey = ''; + this.invalidate(); + return; + } + if (matchesKey(data, Key.up) || k === 'k') { + this.scrollBy(-1); + return; + } + if (matchesKey(data, Key.down) || k === 'j') { + this.scrollBy(1); + return; + } + if ( + matchesKey(data, Key.pageUp) || + matchesKey(data, Key.ctrl('u')) || + k === ' ' || + data === '\u0002' /* C-b */ + ) { + this.scrollBy(-Math.max(1, visible - 1)); + return; + } + if ( + matchesKey(data, Key.pageDown) || + matchesKey(data, Key.ctrl('d')) || + data === '\u0006' /* C-f */ + ) { + this.scrollBy(Math.max(1, visible - 1)); + return; + } + if (matchesKey(data, Key.home) || k === 'g') { + this.scrollTo(0); + return; + } + if (matchesKey(data, Key.end) || k === 'G') { + this.scrollTo(this.maxScroll()); + return; + } + } + + private scrollBy(delta: number): void { + this.scrollTo(this.scrollTop + delta); + } + + private scrollTo(target: number): void { + this.scrollTop = Math.max(0, Math.min(target, this.maxScroll())); + this.followTail = this.scrollTop >= this.maxScroll(); + this.invalidate(); + } + + private maxScroll(): number { + return Math.max(0, this.lines.length - this.viewableRows()); + } + + /** Content rows inside the body frame: total rows minus header(1) + + * footer(1) + top border(1) + bottom border(1). */ + private viewableRows(): number { + return Math.max(1, this.terminal.rows - 4); + } + + // ── body assembly ────────────────────────────────────────────────── + + private cacheKey(innerWidth: number): string { + const record = this.props.record; + return [ + String(innerWidth), + this.expanded ? 'x' : 'c', + record?.agentId ?? '', + String(record?.version ?? -1), + ].join('|'); + } + + private buildLines(innerWidth: number): string[] { + const record = this.props.record; + if (record === undefined) { + return [currentTheme.dim(`${MESSAGE_INDENT}[no activity recorded]`)]; + } + + const out: string[] = []; + for (const step of record.steps) { + out.push(currentTheme.dim(`── step ${String(step.step)} ──`)); + if (step.retrying !== undefined) { + out.push(currentTheme.fg('warning', `${MESSAGE_INDENT}↻ ${step.retrying}`)); + } + if (step.textTail.trim().length > 0) { + const message = new AssistantMessageComponent(); + message.updateContent(step.textTail); + out.push(...message.render(innerWidth)); + } + for (const call of step.toolCalls) { + out.push(this.buildToolCallHeader(call)); + out.push(...this.renderToolCallBody(call, innerWidth)); + } + out.push(''); + } + + if (record.error !== undefined && record.error.length > 0) { + out.push(currentTheme.fg('error', 'Failed')); + const message = new AssistantMessageComponent(); + message.updateContent(record.error); + out.push(...message.render(innerWidth)); + } else if (record.resultSummary !== undefined && record.resultSummary.length > 0) { + out.push(currentTheme.boldFg('primary', 'Result')); + const message = new AssistantMessageComponent(); + message.updateContent(record.resultSummary); + out.push(...message.render(innerWidth)); + } + + if (out.length === 0) { + out.push(currentTheme.dim(`${MESSAGE_INDENT}Waiting for activity…`)); + } + return out; + } + + /** Same shape as the main flow's generic header (`tool-call.ts` + * `buildHeader`): bullet + verb + name + key argument + chip. Custom + * per-tool label wording (e.g. "Ran a command") is intentionally not + * mirrored — the per-tool *body* renderers carry the specialization. */ + private buildToolCallHeader(call: SubToolCallActivity): string { + let bullet: string; + if (call.status === 'error') { + bullet = currentTheme.fg('error', '✗ '); + } else if (call.status === 'done') { + bullet = currentTheme.fg('success', STATUS_BULLET); + } else { + bullet = currentTheme.fg('text', STATUS_BULLET); + } + const verb = call.status === 'running' ? 'Using' : 'Used'; + const name = currentTheme.boldFg('primary', call.name); + const keyArg = extractKeyArgument(call.name, call.args); + const argStr = keyArg === null || keyArg.length === 0 ? '' : currentTheme.dim(` (${keyArg})`); + + let chipStr = ''; + if (call.result !== undefined) { + const provider = pickChip(call.name); + const text = provider?.(this.toToolCallBlockData(call), call.result) ?? ''; + if (text.length > 0) { + chipStr = + call.result.is_error === true + ? currentTheme.fg('error', ` · ${text}`) + : currentTheme.dim(` · ${text}`); + } + } + return `${bullet}${verb} ${name}${argStr}${chipStr}`; + } + + private renderToolCallBody(call: SubToolCallActivity, innerWidth: number): string[] { + if (call.result === undefined) { + return call.liveOutputTail === undefined || call.liveOutputTail.length === 0 + ? [] + : [currentTheme.dim(`${MESSAGE_INDENT}│ ${call.liveOutputTail}`)]; + } + // The store caps retained output, which cannot survive as a parseable + // media envelope (base64) — show a marker instead of dumping the blob. + if (call.name === 'ReadMediaFile' && call.result.is_error !== true) { + return [currentTheme.dim(`${MESSAGE_INDENT}[media output omitted]`)]; + } + const components = pickResultRenderer(call.name)( + this.toToolCallBlockData(call), + call.result, + { expanded: this.expanded }, + ); + const out: string[] = []; + for (const component of components) { + out.push(...component.render(innerWidth)); + } + return out; + } + + private toToolCallBlockData(call: SubToolCallActivity): ToolCallBlockData { + return { id: call.id, name: call.name, args: call.args }; + } + + // ── render ───────────────────────────────────────────────────────── + + override render(width: number): string[] { + const rows = Math.max(3, this.terminal.rows); + const bodyHeight = rows - 2; + const innerWidth = Math.max(1, width - 4); + + const key = this.cacheKey(innerWidth); + if (key !== this.lastCacheKey) { + this.lines = this.buildLines(innerWidth); + this.lastCacheKey = key; + } + if (this.followTail) this.scrollTop = this.maxScroll(); + + const header = this.renderHeader(width); + const body = this.renderBody(width, bodyHeight); + const footer = this.renderFooter(width, bodyHeight); + + const out: string[] = [header]; + for (const line of body) out.push(line); + out.push(footer); + return out; + } + + private renderHeader(width: number): string { + const title = currentTheme.boldFg('primary', ' Agent activity '); + const record = this.props.record; + const info = this.props.info; + const segments: string[] = []; + + if (record !== undefined) { + const label = + record.description !== undefined && record.description.length > 0 + ? `${record.agentName} › ${record.description}` + : record.agentName; + segments.push(currentTheme.boldFg('text', label)); + } else { + segments.push(currentTheme.boldFg('text', this.props.taskId)); + } + if (info !== undefined) { + segments.push(currentTheme.fg(statusColor(info.status), STATUS_LABEL[info.status])); + } + if (record !== undefined && record.steps.length > 0) { + const from = record.steps[0]!.step; + const to = record.steps.at(-1)!.step; + let range = `step ${String(from)}–${String(to)} / ${String(record.totalSteps)}`; + if (record.totalSteps > record.steps.length) range += ' · earlier steps discarded'; + segments.push(currentTheme.fg('textMuted', range)); + } + + const composed = title + segments.join(' '); + return fitExactly(composed, width); + } + + private renderBody(width: number, bodyHeight: number): string[] { + const innerWidth = Math.max(1, width - 4); + + const max = this.maxScroll(); + if (this.scrollTop > max) this.scrollTop = max; + if (this.scrollTop < 0) this.scrollTop = 0; + + const viewRows = Math.max(1, bodyHeight - 2); + const top = currentTheme.fg('primary', '┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); + + const out: string[] = [top]; + for (let i = 0; i < viewRows; i++) { + const lineIndex = this.scrollTop + i; + const raw = this.lines[lineIndex] ?? ''; + const inner = fitExactly(raw, innerWidth); + out.push(currentTheme.fg('primary', '│ ') + inner + currentTheme.fg('primary', ' │')); + } + out.push(bottom); + return out; + } + + private renderFooter(width: number, bodyHeight: number): string { + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); + + const total = this.lines.length; + const viewRows = Math.max(1, bodyHeight - 2); + const maxScroll = Math.max(0, total - viewRows); + const percent = + maxScroll === 0 ? 100 : Math.round((this.scrollTop / maxScroll) * 100); + const lineFrom = total === 0 ? 0 : this.scrollTop + 1; + const lineTo = Math.min(total, this.scrollTop + viewRows); + + const position = currentTheme.fg( + 'textMuted', + ` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `, + ); + const keys = + `${key('↑↓')} ${dim('line')} ` + + `${key('PgUp/PgDn')} ${dim('page')} ` + + `${key('g/G')} ${dim('top/bot')} ` + + `${key('Ctrl+O')} ${dim(this.expanded ? 'collapse' : 'expand')} ` + + `${key('Q/Esc')} ${dim('cancel')}`; + const left = ` ${keys}`; + const leftW = visibleWidth(left); + const rightW = visibleWidth(position); + if (leftW + 2 + rightW <= width) { + return left + ' '.repeat(width - leftW - rightW) + position; + } + return fitExactly(left, width); + } +} + +/** + * Plain-text preview of a record for the tasks browser's Preview frame (the + * frame styles whole lines itself, so this stays ANSI-free). The frame shows + * the tail of the string, so the full retained activity is returned. + */ +export function formatSubagentActivityPreview(record: SubagentActivityRecord): string { + const lines: string[] = []; + for (const step of record.steps) { + lines.push(`── step ${String(step.step)} ──`); + if (step.retrying !== undefined) lines.push(`${MESSAGE_INDENT}↻ ${step.retrying}`); + if (step.textTail.trim().length > 0) lines.push(...step.textTail.trimEnd().split('\n')); + for (const call of step.toolCalls) { + lines.push(formatPreviewToolCall(call)); + if ( + call.result === undefined && + call.liveOutputTail !== undefined && + call.liveOutputTail.length > 0 + ) { + lines.push(`${MESSAGE_INDENT}│ ${call.liveOutputTail}`); + } + } + } + if (record.error !== undefined && record.error.length > 0) { + lines.push('Failed:', ...record.error.trimEnd().split('\n')); + } else if (record.resultSummary !== undefined && record.resultSummary.length > 0) { + lines.push('Result:', ...record.resultSummary.trimEnd().split('\n')); + } + if (lines.length === 0) { + return record.status === 'running' ? 'Waiting for activity…' : ''; + } + return lines.join('\n'); +} + +function formatPreviewToolCall(call: SubToolCallActivity): string { + const mark = call.status === 'done' ? '✓' : call.status === 'error' ? '✗' : '●'; + const verb = call.status === 'running' ? 'Using' : 'Used'; + const keyArg = extractKeyArgument(call.name, call.args); + const argStr = keyArg === null || keyArg.length === 0 ? '' : ` (${keyArg})`; + + let chip = ''; + if (call.result !== undefined) { + const callData: ToolCallBlockData = { id: call.id, name: call.name, args: call.args }; + const text = pickChip(call.name)?.(callData, call.result) ?? ''; + if (text.length > 0) chip = ` · ${text}`; + } + return `${mark} ${verb} ${call.name}${argStr}${chip}`; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts new file mode 100644 index 0000000000000000000000000000000000000000..2165dc48dac287cdf1f9f093c1a55781c34f5feb --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -0,0 +1,165 @@ +import { + Container, + Input, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +export type ApiKeyInputResult = + | { readonly kind: 'ok'; readonly value: string } + | { readonly kind: 'cancel' }; + +const FOOTER = 'Enter to submit · Esc to cancel'; + +function maskInputLine(raw: string): string { + const prefix = '> '; + if (!raw.startsWith(prefix)) return raw; + + // Strip trailing padding spaces so they stay as spaces. + let end = raw.length; + while (end > prefix.length && raw[end - 1] === ' ') { + end--; + } + const padding = raw.slice(end); + const content = raw.slice(prefix.length, end); + + // Protect ANSI escape sequences (reverse-video cursor, IME marker, etc.) + // while masking every other visible character. + const parts = content.split(/(\u001B(?:\[[0-9;]*m|_pi:c\u0007))/); + const maskedContent = parts + .map((part, index) => { + if (index % 2 === 1) return part; // ANSI sequence + return part.replaceAll(/./g, '•'); + }) + .join(''); + + return prefix + maskedContent + padding; +} + +export class ApiKeyInputDialogComponent extends Container implements Focusable { + focused = false; + + private readonly input = new Input(); + private readonly onDone: (result: ApiKeyInputResult) => void; + private readonly title: string; + private readonly subtitleLines: readonly string[]; + private readonly mask: boolean; + private readonly emptyHint: string; + private done = false; + private emptyHinted = false; + + constructor( + platformName: string, + subtitleLines: readonly string[], + onDone: (result: ApiKeyInputResult) => void, + options?: { title?: string; mask?: boolean; emptyHint?: string }, + ) { + super(); + this.onDone = onDone; + this.title = options?.title ?? `Enter API key for ${platformName}`; + this.subtitleLines = subtitleLines; + this.mask = options?.mask ?? true; + this.emptyHint = options?.emptyHint ?? 'API key cannot be empty.'; + this.input.onSubmit = (value) => { + this.submit(value); + }; + } + + handleInput(data: string): void { + if (this.done) return; + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('c')) || + matchesKey(data, Key.ctrl('d')) + ) { + this.cancel(); + return; + } + if (this.emptyHinted) { + this.emptyHinted = false; + } + this.input.handleInput(data); + } + + override invalidate(): void { + super.invalidate(); + this.input.invalidate(); + } + + override render(width: number): string[] { + this.input.focused = this.focused && !this.done; + + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); + const pad = ' '; + + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg('textStrong', this.title); + const subtitleSource = this.emptyHinted ? [this.emptyHint] : this.subtitleLines; + const subtitleLines = subtitleSource.map((line) => + truncateToWidth(currentTheme.fg('textDim', line), innerWidth, '…'), + ); + const footerStyled = currentTheme.fg('textDim', FOOTER); + + const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); + const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); + const rawInputLine = this.input.render(innerWidth)[0] ?? '> '; + const inputLine = + this.mask && this.input.getValue() !== '' ? maskInputLine(rawInputLine) : rawInputLine; + + const contentLines: string[] = [ + titleLine, + '', + ...subtitleLines, + '', + inputLine, + '', + footerLine, + ]; + + if (safeWidth < 4) { + return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))]; + } + + const lines: string[] = [ + '', + border('╭' + '─'.repeat(safeWidth - 2) + '╮'), + border('│') + ' '.repeat(safeWidth - 2) + border('│'), + ]; + + for (const content of contentLines) { + const vis = visibleWidth(content); + const rightPad = Math.max(0, innerWidth - vis); + lines.push(border('│') + pad + content + ' '.repeat(rightPad) + border('│')); + } + + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); + + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); + } + + private submit(value: string): void { + if (this.done) return; + const trimmed = value.trim(); + if (trimmed.length === 0) { + this.emptyHinted = true; + return; + } + this.done = true; + this.onDone({ kind: 'ok', value: trimmed }); + } + + private cancel(): void { + if (this.done) return; + this.done = true; + this.onDone({ kind: 'cancel' }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts new file mode 100644 index 0000000000000000000000000000000000000000..e18f5709b3b8f72a29e3206ac23a0d59e474fdc1 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts @@ -0,0 +1,457 @@ +/** + * ApprovalPanel — pi-tui version of the approval request UI. + * + * Container-based component with keyboard navigation. + */ + +import { + Container, + Input, + matchesKey, + Key, + decodeKittyPrintable, + type Focusable, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from '@moonshot-ai/pi-tui'; +import { currentTheme } from '#/tui/theme'; +import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; +import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; +import type { + ApprovalPanelChoice, + DiffDisplayBlock, + DisplayBlock, + FileContentDisplayBlock, + PendingApproval, +} from '#/tui/reverse-rpc/types'; + +export interface ApprovalPanelResponse { + readonly response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled'; + readonly feedback?: string | undefined; + readonly selected_label?: string | undefined; +} + +function truncateOneLine(text: string, max: number): string { + const firstLine = text.split('\n')[0] ?? ''; + return firstLine.length > max ? firstLine.slice(0, max - 1) + '…' : firstLine; +} + +const DIFF_SUMMARY_MAX_LINES = 10; +const CONTENT_SUMMARY_MAX_LINES = 10; + +interface BlockStyles { + strong: (s: string) => string; + dim: (s: string) => string; + accent: (s: string) => string; + gutter: (s: string) => string; + errorBold: (s: string) => string; +} + +function makeBlockStyles(): BlockStyles { + return { + strong: (s) => currentTheme.fg('textStrong', s), + dim: (s) => currentTheme.fg('textDim', s), + accent: (s) => currentTheme.fg('accent', s), + gutter: (s) => currentTheme.fg('diffGutter', s), + errorBold: (s) => currentTheme.boldFg('error', s), + }; +} + +function appendWrappedLine( + lines: string[], + firstPrefix: string, + continuationPrefix: string, + content: string, + width: number, +): void { + const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix)); + const wrapped = wrapTextWithAnsi(content, Math.max(1, width - prefixWidth)); + if (wrapped.length === 0) { + lines.push(firstPrefix); + return; + } + lines.push(`${firstPrefix}${wrapped[0] ?? ''}`); + for (let i = 1; i < wrapped.length; i++) { + lines.push(`${continuationPrefix}${wrapped[i] ?? ''}`); + } +} + +function renderShellDisplayBlock( + block: Extract<DisplayBlock, { type: 'shell' }>, + s: BlockStyles, + width: number, +): string[] { + const lines: string[] = []; + if (block.cwd !== undefined && block.cwd.length > 0) { + lines.push(s.dim(`cwd: ${block.cwd}`)); + } + if (block.danger !== undefined) { + lines.push(s.errorBold(`Dangerous: ${block.danger}`)); + } + const cmdLines = block.command.length > 0 ? block.command.split('\n') : ['']; + cmdLines.forEach((cmdLine, idx) => { + const prefix = idx === 0 ? `${s.accent('$')} ` : `${s.dim('·')} `; + appendWrappedLine(lines, prefix, ' ', s.strong(cmdLine), width); + }); + if (block.description !== undefined && block.description.length > 0) { + lines.push(` ${s.dim(block.description)}`); + } + return lines; +} + +function renderDisplayBlock( + block: DisplayBlock, + s: BlockStyles, + contentWidth: number, +): string[] { + switch (block.type) { + case 'diff': + return renderDiffLinesClustered(block.old_text, block.new_text, block.path, { + contextLines: 3, + expandKeyHint: 'ctrl+e to preview', + maxLines: DIFF_SUMMARY_MAX_LINES, + }); + case 'file_content': { + const lang = block.language ?? langFromPath(block.path); + const allLines = highlightLines(block.content, lang); + const shown = allLines.slice(0, CONTENT_SUMMARY_MAX_LINES); + const lines = [s.strong(block.path)]; + for (const [i, line] of shown.entries()) { + lines.push(s.gutter(String(i + 1).padStart(4) + ' ') + line); + } + const remaining = allLines.length - shown.length; + if (remaining > 0) { + lines.push( + s.dim( + ` … ${String(remaining)} more line${remaining > 1 ? 's' : ''} hidden (ctrl+e to preview)`, + ), + ); + } + return lines; + } + case 'shell': + return renderShellDisplayBlock(block, s, contentWidth); + case 'file_op': { + const op = s.accent(block.operation.padEnd(5)); + const lines = [`${op} ${s.strong(block.path)}`]; + if (block.detail !== undefined && block.detail.length > 0) { + lines.push(s.dim(block.detail)); + } + return lines; + } + case 'url_fetch': { + const method = s.accent((block.method ?? 'GET').toUpperCase().padEnd(5)); + return [`${method} ${s.strong(block.url)}`]; + } + case 'search': { + const lines = [`${s.accent('search')} ${s.strong(block.query)}`]; + if (block.scope !== undefined && block.scope.length > 0) { + lines.push(s.dim(`scope: ${block.scope}`)); + } + return lines; + } + case 'invocation': { + const lines = [`${s.accent(block.kind.padEnd(5))} ${s.strong(block.name)}`]; + if (block.description !== undefined && block.description.length > 0) { + lines.push(s.dim(truncateOneLine(block.description, 200))); + } + return lines; + } + case 'brief': + return block.text + ? block.text.split('\n').map((line) => (line.length > 0 ? s.strong(line) : '')) + : []; + case 'background_task': + return [ + s.strong(`${block.status} ${block.kind} task ${block.task_id}: ${block.description}`), + ]; + case 'todo': + return block.items.map((item) => s.strong(`- [${item.status}] ${item.title}`)); + default: + return []; + } +} + +function normalizeApprovalText(text: string): string { + return text.replaceAll('\r\n', '\n').trim(); +} + +function isDuplicateBriefBlock(block: DisplayBlock, description: string): boolean { + if (block.type !== 'brief' || block.text.trim().length === 0) return false; + const normalizedDescription = normalizeApprovalText(description); + if (normalizedDescription.length === 0) return false; + const normalizedBlockText = normalizeApprovalText(block.text); + if (normalizedBlockText === normalizedDescription) return true; + const blockLines = normalizedBlockText.split('\n'); + if (blockLines.length <= 1) return false; + return normalizeApprovalText(blockLines.slice(1).join('\n')) === normalizedDescription; +} + +function headerFor(toolName: string): string { + switch (toolName) { + case 'Bash': + return 'Run this command?'; + case 'Write': + return 'Write this file?'; + case 'Edit': + return 'Apply these edits?'; + case 'TaskStop': + return 'Stop this task?'; + case 'ExitPlanMode': + return 'Ready to build with this plan?'; + default: + return `Approve ${toolName}?`; + } +} + +export class ApprovalPanelComponent extends Container implements Focusable { + focused = false; + private selectedIndex = 0; + private feedbackMode = false; + private readonly feedbackInput = new Input(); + private onResponse: (response: ApprovalPanelResponse) => void; + private request: PendingApproval; + private readonly onToggleToolOutput: (() => void) | undefined; + private readonly onOpenPreview: + | ((block: DiffDisplayBlock | FileContentDisplayBlock) => void) + | undefined; + + constructor( + request: PendingApproval, + onResponse: (response: ApprovalPanelResponse) => void, + onToggleToolOutput?: () => void, + onOpenPreview?: (block: DiffDisplayBlock | FileContentDisplayBlock) => void, + ) { + super(); + this.request = request; + this.onResponse = onResponse; + this.onToggleToolOutput = onToggleToolOutput; + this.onOpenPreview = onOpenPreview; + this.feedbackInput.onSubmit = (value) => { + this.submit(this.selectedIndex, value); + }; + this.feedbackInput.onEscape = () => { + this.feedbackMode = false; + this.feedbackInput.setValue(''); + }; + } + + private submit(index: number, feedback: string = ''): void { + const option = this.choiceAt(index); + if (!option) return; + this.onResponse({ + response: option.response, + feedback: feedback || undefined, + selected_label: option.selected_label, + }); + } + + private selectAndSubmit(index: number): void { + const option = this.choiceAt(index); + if (!option) return; + if (option.requires_feedback === true) { + this.selectedIndex = index; + this.feedbackMode = true; + } else { + this.submit(index); + } + } + + handleInput(data: string): void { + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('c')) || + matchesKey(data, Key.ctrl('d')) + ) { + this.onResponse({ response: 'rejected' }); + return; + } + + if (matchesKey(data, Key.ctrl('e'))) { + const previewable = this.findPreviewableBlock(); + if (previewable !== undefined && this.onOpenPreview !== undefined) { + this.onOpenPreview(previewable); + } + return; + } + + if (matchesKey(data, Key.ctrl('o'))) { + this.onToggleToolOutput?.(); + return; + } + + if (this.feedbackMode) { + if (matchesKey(data, Key.up)) { + this.feedbackMode = false; + this.selectedIndex = (this.selectedIndex - 1 + this.choiceCount()) % this.choiceCount(); + return; + } + if (matchesKey(data, Key.down)) { + this.feedbackMode = false; + this.selectedIndex = (this.selectedIndex + 1) % this.choiceCount(); + return; + } + this.feedbackInput.handleInput(data); + return; + } + + if (this.choiceCount() === 0) return; + if (matchesKey(data, Key.up)) { + this.selectedIndex = (this.selectedIndex - 1 + this.choiceCount()) % this.choiceCount(); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = (this.selectedIndex + 1) % this.choiceCount(); + return; + } + if (matchesKey(data, Key.enter)) { + this.selectAndSubmit(this.selectedIndex); + return; + } + + const printable = decodeKittyPrintable(data) ?? data; + const numericIndex = Number(printable) - 1; + if (Number.isInteger(numericIndex) && numericIndex >= 0 && numericIndex < this.choiceCount()) { + this.selectAndSubmit(numericIndex); + } + } + + override render(width: number): string[] { + this.clear(); + this.ensureValidSelection(); + this.feedbackInput.focused = this.focused && this.feedbackMode; + const { data } = this.request; + const blockStyles = makeBlockStyles(); + const borderColor = (text: string) => currentTheme.fg('borderFocus', text); + const borderColorBold = (text: string) => currentTheme.boldFg('borderFocus', text); + const selectColorBold = (text: string) => currentTheme.boldFg('accent', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const strong = (text: string) => currentTheme.fg('textStrong', text); + const horizontalBar = borderColor('─'.repeat(width)); + const indent = (s: string): string => ` ${s}`; + + const title = headerFor(data.tool_name); + const lines: string[] = [ + horizontalBar, + indent(`${borderColorBold('▶')} ${borderColorBold(title)}`), + ]; + + const dedupedBlocks = data.display.filter( + (block) => !isDuplicateBriefBlock(block, data.description), + ); + const visibleBlocks = dedupedBlocks.slice(0, 5); + const hasPreviewable = visibleBlocks.some( + (block) => block.type === 'diff' || block.type === 'file_content', + ); + + if (visibleBlocks.length > 0) { + lines.push(''); + for (const block of visibleBlocks) { + const blockLines = renderDisplayBlock( + block, + blockStyles, + Math.max(1, width - 2), + ); + for (const line of blockLines) { + lines.push(indent(line)); + } + } + } else if (data.description) { + lines.push(''); + for (const descLine of data.description.split('\n')) { + lines.push(indent(dim(descLine))); + } + } + + lines.push(''); + for (let idx = 0; idx < data.choices.length; idx++) { + const option = data.choices[idx]; + if (option === undefined) continue; + const isSelected = idx === this.selectedIndex; + const num = idx + 1; + + const labelWithNum = `${String(num)}. ${option.label}`; + if (this.feedbackMode && option.requires_feedback === true && isSelected) { + lines.push(indent(this.renderInlineFeedbackLine(width - 2, labelWithNum))); + } else if (isSelected) { + lines.push(indent(`${selectColorBold('▶')} ${selectColorBold(labelWithNum)}`)); + } else { + lines.push(indent(strong(` ${labelWithNum}`))); + } + + // Optional helper text under the label, aligned past the pointer/number. + // Choices without a description render exactly as before. + if ( + option.description !== undefined && + option.description.length > 0 && + !(this.feedbackMode && option.requires_feedback === true && isSelected) + ) { + for (const descLine of wrapTextWithAnsi(option.description, Math.max(20, width - 7))) { + lines.push(indent(` ${dim(descLine)}`)); + } + } + } + + lines.push(''); + if (this.feedbackMode) { + lines.push(indent(dim('Type feedback · ↵ submit.'))); + } else { + const expandHint = hasPreviewable ? ' · ctrl+e preview' : ''; + lines.push( + indent( + dim( + `↑/↓ select · ${buildNumericHint(data.choices.length)} choose · ↵ confirm${expandHint}`, + ), + ), + ); + } + lines.push(horizontalBar); + + return lines.map((line) => truncateToWidth(line, width)); + } + + private findPreviewableBlock(): DiffDisplayBlock | FileContentDisplayBlock | undefined { + for (const block of this.request.data.display) { + if (block.type === 'diff' || block.type === 'file_content') return block; + } + return undefined; + } + + private choiceAt(index: number): ApprovalPanelChoice | undefined { + return this.request.data.choices[index]; + } + + private choiceCount(): number { + return this.request.data.choices.length; + } + + private ensureValidSelection(): void { + const count = this.choiceCount(); + if (count === 0) { + this.selectedIndex = 0; + return; + } + if (this.selectedIndex < 0 || this.selectedIndex >= count) { + this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, count - 1)); + } + } + + private renderInlineFeedbackLine(width: number, labelWithNum: string): string { + const prefix = `${currentTheme.boldFg('accent', '▶')} ${currentTheme.boldFg('accent', labelWithNum)} `; + const inputWidth = Math.max(4, width - visibleWidth(prefix) + 2); + const inputLine = this.feedbackInput.render(inputWidth)[0] ?? '> '; + const inlineInput = inputLine.startsWith('> ') ? inputLine.slice(2) : inputLine; + return prefix + inlineInput; + } + + override invalidate(): void { + super.invalidate(); + this.feedbackInput.invalidate(); + } +} + +function buildNumericHint(count: number): string { + if (count <= 0) return '↵'; + return Array.from({ length: Math.min(count, 9) }, (_, idx) => String(idx + 1)).join('/'); +} diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts new file mode 100644 index 0000000000000000000000000000000000000000..044884792e46f177cbc23169abac4bbcc6d56d78 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts @@ -0,0 +1,251 @@ +/** + * ApprovalPreviewViewer — full-screen preview of an Edit diff or Write + * file content for the approval flow. + * + * Mounted by `kimi-tui.ts` via the same nested-takeover pattern as + * `TaskOutputViewer`: the active approval panel is preserved underneath + * and restored on close. The viewer is intentionally a snapshot — its + * lines are rendered once at construction and only sliced on scroll, so + * the per-frame render cost stays in `O(viewport)` even when the + * underlying diff/content is very large. + * + * This avoids the prior failure mode where pressing ctrl+e on an Edit + * with a long hunk inflated the approval panel past one screen, which + * collided with pi-tui's inline differential renderer and the terminal + * emulator's "snap to bottom on stdout" reflex, causing flicker and an + * unscrollable history pane. + */ + +import { + Container, + Key, + matchesKey, + type Terminal, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; +import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; +import type { DiffDisplayBlock, FileContentDisplayBlock } from '#/tui/reverse-rpc/types'; +import { currentTheme } from '#/tui/theme'; +import { printableChar } from '#/tui/utils/printable-key'; + +const ELLIPSIS = '…'; + +export type ApprovalPreviewBlock = DiffDisplayBlock | FileContentDisplayBlock; + +export interface ApprovalPreviewViewerProps { + readonly block: ApprovalPreviewBlock; + readonly onClose: () => void; +} + +function padToWidth(line: string, width: number): string { + const w = visibleWidth(line); + if (w === width) return line; + if (w > width) return truncateToWidth(line, width, ELLIPSIS); + return line + ' '.repeat(width - w); +} + +function fitExactly(line: string, width: number): string { + let s = line; + if (visibleWidth(s) > width) s = truncateToWidth(s, width, ELLIPSIS); + return padToWidth(s, width); +} + +export class ApprovalPreviewViewer extends Container implements Focusable { + focused = false; + + private readonly props: ApprovalPreviewViewerProps; + private readonly terminal: Terminal; + /** Pre-rendered body lines (ANSI-styled, no border / no gutter). */ + private bodyLines: string[]; + /** Title shown in the header (path + diff stats / "Write" label). */ + private headerTitle: string; + /** Index of the topmost visible line. */ + private scrollTop = 0; + + constructor(props: ApprovalPreviewViewerProps, terminal: Terminal) { + super(); + this.props = props; + this.terminal = terminal; + const built = buildBody(props.block); + this.bodyLines = built.lines; + this.headerTitle = built.title; + } + + handleInput(data: string): void { + const visible = this.viewableRows(); + const k = printableChar(data); + + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('e')) || + k === 'q' || + k === 'Q' + ) { + this.props.onClose(); + return; + } + if (matchesKey(data, Key.up) || k === 'k') { + this.scrollBy(-1); + return; + } + if (matchesKey(data, Key.down) || k === 'j') { + this.scrollBy(1); + return; + } + if (matchesKey(data, Key.pageUp) || k === ' ' || data === '\x02') { + this.scrollBy(-Math.max(1, visible - 1)); + return; + } + if (matchesKey(data, Key.pageDown) || data === '\x06') { + this.scrollBy(Math.max(1, visible - 1)); + return; + } + if (matchesKey(data, Key.home) || k === 'g') { + this.scrollTo(0); + return; + } + if (matchesKey(data, Key.end) || k === 'G') { + this.scrollTo(this.maxScroll()); + return; + } + } + + private scrollBy(delta: number): void { + this.scrollTo(this.scrollTop + delta); + } + + override invalidate(): void { + const built = buildBody(this.props.block); + this.bodyLines = built.lines; + this.headerTitle = built.title; + } + + private scrollTo(target: number): void { + this.scrollTop = Math.max(0, Math.min(target, this.maxScroll())); + super.invalidate(); + } + + private maxScroll(): number { + return Math.max(0, this.bodyLines.length - this.viewableRows()); + } + + /** Body rows = terminal rows − header(1) − top border(1) − bottom border(1) − footer(1). */ + private viewableRows(): number { + return Math.max(1, this.terminal.rows - 4); + } + + override render(width: number): string[] { + const rows = Math.max(3, this.terminal.rows); + const bodyHeight = rows - 2; + + const header = this.renderHeader(width); + const body = this.renderBody(width, bodyHeight); + const footer = this.renderFooter(width, bodyHeight); + + return [header, ...body, footer]; + } + + private renderHeader(width: number): string { + const title = currentTheme.boldFg('primary', ' Preview '); + return fitExactly(title + this.headerTitle, width); + } + + private renderBody(width: number, bodyHeight: number): string[] { + const innerWidth = Math.max(1, width - 4); + + const max = this.maxScroll(); + if (this.scrollTop > max) this.scrollTop = max; + if (this.scrollTop < 0) this.scrollTop = 0; + + const viewRows = bodyHeight - 2; + const top = currentTheme.fg('primary', '┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); + + const out: string[] = [top]; + for (let i = 0; i < viewRows; i++) { + const lineIndex = this.scrollTop + i; + const raw = this.bodyLines[lineIndex] ?? ''; + out.push(currentTheme.fg('primary', '│ ') + fitExactly(raw, innerWidth) + currentTheme.fg('primary', ' │')); + } + out.push(bottom); + return out; + } + + private renderFooter(width: number, bodyHeight: number): string { + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); + + const total = this.bodyLines.length; + const viewRows = Math.max(1, bodyHeight - 2); + const maxScroll = Math.max(0, total - viewRows); + const percent = maxScroll === 0 ? 100 : Math.round((this.scrollTop / maxScroll) * 100); + const lineFrom = total === 0 ? 0 : this.scrollTop + 1; + const lineTo = Math.min(total, this.scrollTop + viewRows); + + const position = currentTheme.fg( + 'textMuted', + ` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `, + ); + const keys = + `${key('↑↓')} ${dim('line')} ` + + `${key('PgUp/PgDn')} ${dim('page')} ` + + `${key('g/G')} ${dim('top/bot')} ` + + `${key('Q/Esc/Ctrl+E')} ${dim('cancel')}`; + const left = ` ${keys}`; + const leftW = visibleWidth(left); + const rightW = visibleWidth(position); + if (leftW + 2 + rightW <= width) { + return left + ' '.repeat(width - leftW - rightW) + position; + } + return fitExactly(left, width); + } +} + +interface BuiltBody { + lines: string[]; + title: string; +} + +function buildBody(block: ApprovalPreviewBlock): BuiltBody { + if (block.type === 'diff') { + return buildDiffBody(block); + } + return buildFileContentBody(block); +} + +function buildDiffBody(block: DiffDisplayBlock): BuiltBody { + // renderDiffLinesClustered emits a `+N -M path` header on its first line + // followed by every changed line plus surrounding context. We pull the + // header out into the viewer chrome so the body is purely scrollable diff + // content; this also means we don't double-render the path. + const rendered = renderDiffLinesClustered( + block.old_text, + block.new_text, + block.path, + { + contextLines: 3, + oldStart: block.old_start ?? 1, + newStart: block.new_start ?? 1, + }, + ); + const [header = '', ...rest] = rendered; + return { lines: rest, title: stripLeadingSpace(header) }; +} + +function buildFileContentBody(block: FileContentDisplayBlock): BuiltBody { + const lang = block.language ?? langFromPath(block.path); + const highlighted = highlightLines(block.content, lang); + const lines = highlighted.map( + (line, i) => currentTheme.fg('diffGutter', String(i + 1).padStart(4) + ' ') + line, + ); + const title = currentTheme.fg('textStrong', block.path); + return { lines, title }; +} + +function stripLeadingSpace(s: string): string { + return s.replace(/^ +/, ''); +} diff --git a/apps/kimi-code/src/tui/components/dialogs/cache-hint-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/cache-hint-dialog.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee0cae258efaa074875b5507622453670cb37609 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/cache-hint-dialog.ts @@ -0,0 +1,121 @@ +/** + * CacheHintDialog — shown when a resumed (or long-idle) session's context + * cache has almost certainly expired, so the next turn re-sends the whole + * history uncached. Offers compact / new session / continue / never. + * + * Layout mirrors the list-dialog spec (DESIGN.md): top border, title, hint, + * body line, then options with right-column descriptions. + */ + +import { + Container, + matchesKey, + Key, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { formatIdleDuration } from '#/tui/utils/cache-hint'; +import { SearchableList } from '#/tui/utils/searchable-list'; +import { formatTokenCount } from '#/utils/usage/usage-format'; + +export type CacheHintAction = 'compact' | 'new' | 'continue' | 'never'; + +interface CacheHintOption { + readonly value: CacheHintAction; + readonly label: string; + readonly description?: string; +} + +const OPTIONS: readonly CacheHintOption[] = [ + { + value: 'compact', + label: 'Compact and continue', + description: 'one-time compact cost · cheapest way to keep this topic', + }, + { + value: 'new', + label: 'Start a new session', + description: 'zero context cost · best for a new task', + }, + { + value: 'continue', + label: 'Continue as-is', + description: 'full history kept · highest cost per turn', + }, + { value: 'never', label: "Don't ask me again" }, +]; + +export interface CacheHintDialogOptions { + readonly idleSeconds: number; + readonly totalTokens: number; + readonly onSelect: (action: CacheHintAction) => void; + readonly onCancel: () => void; +} + +export class CacheHintDialogComponent extends Container implements Focusable { + focused = false; + private readonly opts: CacheHintDialogOptions; + private readonly list: SearchableList<CacheHintOption>; + + constructor(opts: CacheHintDialogOptions) { + super(); + this.opts = opts; + this.list = new SearchableList({ + items: OPTIONS, + toSearchText: (o) => o.label, + initialIndex: 0, + searchable: false, + }); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.enter)) { + const chosen = this.list.selected(); + if (chosen !== undefined) this.opts.onSelect(chosen.value); + return; + } + this.list.handleKey(data); + } + + override render(width: number): string[] { + const view = this.list.view(); + const title = `This session has been idle for ${formatIdleDuration(this.opts.idleSeconds)} and is ~${formatTokenCount(this.opts.totalTokens)} tokens.`; + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ` ${title}`), + currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc cancel'), + '', + currentTheme.fg( + 'text', + ' Cache expired — the next message re-sends the entire history at full price.', + ), + ]; + + const maxLabelWidth = Math.max(...OPTIONS.map((o) => visibleWidth(o.label))); + for (let i = view.page.start; i < view.page.end; i++) { + const opt = view.items[i]!; + const isSelected = i === view.selectedIndex; + const pointer = isSelected ? SELECT_POINTER : ' '; + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); + line += isSelected + ? currentTheme.boldFg('primary', opt.label) + : currentTheme.fg('text', opt.label); + if (opt.description !== undefined) { + const gap = maxLabelWidth - visibleWidth(opt.label) + 4; + line += ' '.repeat(gap) + currentTheme.fg('textMuted', opt.description); + } + lines.push(line); + } + + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ad6c0654a1a9f888a12c24f942e67d24baeda5a --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -0,0 +1,224 @@ +/** + * ChoicePicker — modal single-select list for slash commands that ask + * the user to pick from a small set of preset values. + * + * Mirrors SessionPickerComponent's container-replacement pattern: host + * calls `showChoicePicker(...)` which clears the editor container, + * addChild(picker), setFocus(picker); the picker invokes `onSelect` or + * `onCancel`, and the host tears it down. + */ + +import { + Container, + matchesKey, + Key, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; +import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme, type ColorToken } from '#/tui/theme'; +import { printableChar } from '#/tui/utils/printable-key'; +import { SearchableList } from '#/tui/utils/searchable-list'; + +export interface ChoiceOption { + /** Value passed to onSelect (e.g. the actual editor command string). */ + readonly value: string; + /** Display text shown in the list. */ + readonly label: string; + /** Optional semantic tone for labels that need stronger visual treatment. */ + readonly tone?: 'danger'; + /** Optional explanatory text shown below the label. */ + readonly description?: string | undefined; + /** Color token applied to the description while this option is selected, drawing + * attention to important details. Falls back to `textMuted` when unset or not selected. */ + readonly descriptionTone?: ColorToken; +} + +export interface ChoicePickerOptions { + readonly title: string; + readonly hint?: string; + readonly formatHint?: (text: string) => string; + readonly notice?: string; + /** Color tone for the notice line. Defaults to 'success'. */ + readonly noticeTone?: 'success' | 'warning'; + readonly options: readonly ChoiceOption[]; + readonly currentValue?: string; + readonly initialValue?: string; + /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ + readonly searchable?: boolean; + /** Items per page. Lists longer than this paginate. */ + readonly pageSize?: number; + readonly onSelect: (value: string) => void; + /** When provided, Alt+S invokes this with the selected value instead of + * onSelect — used to apply the choice to the current session only. */ + readonly onSessionOnlySelect?: (value: string) => void; + readonly onCancel: () => void; +} + +function wrapDescription(text: string, width: number): string[] { + const maxWidth = Math.max(1, width); + const words = text + .trim() + .split(/\s+/) + .filter((word) => word.length > 0); + const lines: string[] = []; + let current = ''; + + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (visibleWidth(candidate) <= maxWidth) { + current = candidate; + continue; + } + if (current.length > 0) lines.push(current); + current = visibleWidth(word) <= maxWidth ? word : truncateToWidth(word, maxWidth, '…'); + } + + if (current.length > 0) lines.push(current); + return lines; +} + +export class ChoicePickerComponent extends Container implements Focusable { + focused = false; + private readonly opts: ChoicePickerOptions; + private readonly list: SearchableList<ChoiceOption>; + + constructor(opts: ChoicePickerOptions) { + super(); + this.opts = opts; + const initialIdx = opts.options.findIndex( + (o) => o.value === (opts.initialValue ?? opts.currentValue), + ); + this.list = new SearchableList({ + items: opts.options, + toSearchText: (o) => `${o.label} ${o.description ?? ''}`, + pageSize: opts.pageSize, + initialIndex: Math.max(initialIdx, 0), + searchable: opts.searchable === true, + }); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + if (this.list.clearQuery()) return; + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + const chosen = this.list.selected(); + if (chosen !== undefined) this.opts.onSessionOnlySelect(chosen.value); + return; + } + // Left/Right page through the list (this picker has no horizontal control). + if (matchesKey(data, Key.left)) { + this.list.pageUp(); + return; + } + if (matchesKey(data, Key.right)) { + this.list.pageDown(); + return; + } + // Enter always selects. Space selects too — but only when the list is not + // searchable; in a searchable list a space must reach the query instead. + const isSpace = matchesKey(data, Key.space) || printableChar(data) === ' '; + if (matchesKey(data, Key.enter) || (isSpace && this.opts.searchable !== true)) { + const chosen = this.list.selected(); + if (chosen !== undefined) this.opts.onSelect(chosen.value); + return; + } + this.list.handleKey(data); + } + + override render(width: number): string[] { + const searchable = this.opts.searchable === true; + const view = this.list.view(); + const options = view.items; + + // Header mirrors the model dialog (see model-selector.ts): border, title + // with a "(type to search)" suffix until you type, the hint, a blank, then + // the search line. Key vocabulary is lowercase to match every list dialog. + const navParts = ['↑↓ navigate']; + if (view.page.pageCount > 1) navParts.push('←→ page'); + navParts.push('Enter select', 'Esc cancel'); + const hint = this.opts.hint ?? navParts.join(' · '); + + const titleSuffix = + searchable && view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; + const hintLines = hint.split(/\r?\n/); + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ` ${this.opts.title}`) + titleSuffix, + ]; + for (const hintLine of hintLines) { + lines.push( + this.opts.formatHint === undefined + ? currentTheme.fg('textMuted', ` ${hintLine}`) + : this.opts.formatHint(` ${hintLine}`), + ); + } + if (this.opts.notice !== undefined) { + const tone = this.opts.noticeTone ?? 'success'; + const noticeWidth = Math.max(1, width - 1); + for (const noticeLine of this.opts.notice.split(/\r?\n/)) { + for (const wrapped of wrapDescription(noticeLine, noticeWidth)) { + lines.push(currentTheme.fg(tone, ` ${wrapped}`)); + } + } + } + lines.push(''); + if (searchable && view.query.length > 0) { + lines.push(currentTheme.fg('primary', ` Search: `) + currentTheme.fg('text', view.query)); + } + + if (options.length === 0) { + lines.push(currentTheme.fg('textMuted', ' No matches')); + } + for (let i = view.page.start; i < view.page.end; i++) { + const opt = options[i]!; + const isSelected = i === view.selectedIndex; + const isCurrent = opt.value === this.opts.currentValue; + const pointer = isSelected ? SELECT_POINTER : ' '; + const labelStyle = optionLabelStyle(opt, isSelected); + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); + line += labelStyle(opt.label); + if (isCurrent) { + line += ' ' + currentTheme.fg('success', CURRENT_MARK); + } + lines.push(line); + if (opt.description !== undefined && opt.description.length > 0) { + const descriptionWidth = Math.max(1, width - 4); + const descriptionColor = + isSelected && opt.descriptionTone !== undefined ? opt.descriptionTone : 'textMuted'; + for (const descLine of wrapDescription(opt.description, descriptionWidth)) { + lines.push(currentTheme.fg(descriptionColor, ` ${descLine}`)); + } + } + } + + lines.push(''); + if (view.page.pageCount > 1) { + lines.push( + currentTheme.fg('textMuted', + ` Page ${String(view.page.page + 1)}/${String(view.page.pageCount)}`, + ), + ); + } + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } +} + +function optionLabelStyle( + option: ChoiceOption, + selected: boolean, +): (text: string) => string { + if (option.tone === 'danger') { + return selected + ? (text) => currentTheme.boldFg('error', text) + : (text) => currentTheme.fg('error', text); + } + return selected + ? (text) => currentTheme.boldFg('primary', text) + : (text) => currentTheme.fg('text', text); +} diff --git a/apps/kimi-code/src/tui/components/dialogs/compaction.ts b/apps/kimi-code/src/tui/components/dialogs/compaction.ts new file mode 100644 index 0000000000000000000000000000000000000000..52d8bb8ffdacfe750d4fd3f8307b795fbcc057ea --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/compaction.ts @@ -0,0 +1,187 @@ +/** + * Renders a compaction block in the transcript. + * + * Lifecycle: + * - constructed on `compaction.started` → blinking white bullet + + * "Compacting context..." and optional custom instruction + * - `markDone()` on `compaction.completed` → solid green bullet + + * "Compaction complete (X → Y tokens)" + * - `markCanceled()` on `compaction.cancelled` → solid warning bullet + + * "Compaction cancelled" + * + * Bullet animation mirrors `ToolCallComponent` (500ms blink) so the user + * reads the same "work in progress" signal across the UI. + */ + +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; + +const BLINK_INTERVAL = 500; + +export class CompactionComponent extends Container { + private readonly ui: TUI | undefined; + private readonly headerText: Text; + private instructionText: Text | undefined; + private readonly instruction: string | undefined; + private readonly tip: string | undefined; + private blinkOn = true; + private blinkTimer: ReturnType<typeof setInterval> | null = null; + private done = false; + private canceled = false; + private tokensBefore: number | undefined; + private tokensAfter: number | undefined; + private summary: string | undefined; + private summaryText: Text | undefined; + private expanded = false; + + constructor(ui?: TUI, instruction?: string | undefined, tip?: string) { + super(); + this.ui = ui; + this.instruction = instruction; + this.tip = tip; + + // Top margin so the block isn't glued to the previous transcript + // entry (status line, tool result, etc.). + this.addChild(new Spacer(1)); + this.headerText = new Text(this.buildHeader(), 0, 0); + this.addChild(this.headerText); + this.addInstructionChild(); + + this.startBlink(); + } + + private addInstructionChild(): void { + if (this.instruction !== undefined) { + this.instructionText = new Text(currentTheme.dim(` ${this.instruction}`), 0, 0); + this.addChild(this.instructionText); + } + } + + private removeInstructionChild(): void { + if (this.instructionText === undefined) return; + const index = this.children.indexOf(this.instructionText); + if (index !== -1) { + this.children.splice(index, 1); + } + this.instructionText = undefined; + } + + override invalidate(): void { + // Repaint the header with the active palette (it caches ANSI codes). + this.headerText.setText(this.buildHeader()); + // Rebuild instruction and summary text with fresh theme colours, preserving + // header → instruction → summary child order. + const expanded = this.expanded; + this.removeInstructionChild(); + if (expanded) { + this.removeSummaryChild(); + } + this.addInstructionChild(); + if (expanded) { + this.addSummaryChild(); + } + super.invalidate(); + } + + markDone(tokensBefore?: number, tokensAfter?: number, summary?: string): void { + if (this.done || this.canceled) return; + this.done = true; + this.tokensBefore = tokensBefore; + this.tokensAfter = tokensAfter; + this.summary = summary; + this.stopBlink(); + this.headerText.setText(this.buildHeader()); + if (this.expanded) { + this.addSummaryChild(); + } + this.ui?.requestRender(); + } + + markCanceled(): void { + if (this.done || this.canceled) return; + this.canceled = true; + this.stopBlink(); + this.headerText.setText(this.buildHeader()); + this.ui?.requestRender(); + } + + setExpanded(expanded: boolean): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + if (expanded) { + this.addSummaryChild(); + } else { + this.removeSummaryChild(); + } + this.headerText.setText(this.buildHeader()); + this.ui?.requestRender(); + } + + private addSummaryChild(): void { + if (this.summaryText !== undefined || this.summary === undefined || this.summary.length === 0) { + return; + } + const indentedSummary = this.summary + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + this.summaryText = new Text(currentTheme.dim(indentedSummary), 0, 0); + this.addChild(this.summaryText); + } + + private removeSummaryChild(): void { + if (this.summaryText === undefined) return; + const index = this.children.indexOf(this.summaryText); + if (index !== -1) { + this.children.splice(index, 1); + } + this.summaryText = undefined; + } + + dispose(): void { + this.stopBlink(); + } + + private buildHeader(): string { + if (this.done) { + const bullet = currentTheme.fg('success', STATUS_BULLET); + const label = currentTheme.boldFg('success', 'Compaction complete'); + const detail = + this.tokensBefore !== undefined && this.tokensAfter !== undefined + ? currentTheme.dim(` (${String(this.tokensBefore)} → ${String(this.tokensAfter)} tokens)`) + : ''; + const shortcutHint = + this.summary !== undefined && this.summary.length > 0 + ? currentTheme.dim(` (Ctrl-O to ${this.expanded ? 'hide' : 'show'} compaction summary)`) + : ''; + return `${bullet}${label}${detail}${shortcutHint}`; + } + if (this.canceled) { + const bullet = currentTheme.fg('warning', STATUS_BULLET); + const label = currentTheme.boldFg('warning', 'Compaction cancelled'); + return `${bullet}${label}`; + } + const bullet = this.blinkOn ? currentTheme.fg('text', STATUS_BULLET) : ' '; + const label = currentTheme.boldFg('primary', 'Compacting context…'); + const tip = this.tip ? currentTheme.fg('textDim', ` · Tip: ${this.tip}`) : ''; + return `${bullet}${label}${tip}`; + } + + private startBlink(): void { + this.blinkTimer = setInterval(() => { + this.blinkOn = !this.blinkOn; + this.headerText.setText(this.buildHeader()); + this.ui?.requestRender(); + }, BLINK_INTERVAL); + } + + private stopBlink(): void { + if (this.blinkTimer !== null) { + clearInterval(this.blinkTimer); + this.blinkTimer = null; + } + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa4aa910b22ae6bd1eff096a28a398b69877e407 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts @@ -0,0 +1,251 @@ +/** + * CustomRegistryImportDialog — blue rounded box that collects a custom + * registry URL and a Bearer token before importing the registry's + * provider entries. + * + * Geometry mirrors `ApiKeyInputDialogComponent` so the chrome stays + * consistent with the API-key login flow. Two fields, switched with + * Tab / Shift-Tab / Up / Down; Enter advances to the next field (and submits + * on the last field), Esc cancels. Both fields are required. + */ + +import { + Container, + Input, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +export interface CustomRegistryImportValue { + readonly url: string; + readonly apiKey: string; +} + +export type CustomRegistryImportResult = + | { readonly kind: 'ok'; readonly value: CustomRegistryImportValue } + | { readonly kind: 'cancel' }; + +const TITLE = 'Import custom provider registry'; +const SUBTITLE_DEFAULT = 'Paste an api.json URL and its Bearer token.'; +const SUBTITLE_URL_EMPTY = 'Registry URL cannot be empty.'; +const SUBTITLE_TOKEN_EMPTY = 'Bearer token cannot be empty.'; +const FOOTER_NOT_LAST = 'Tab / ↑↓ to switch · Enter for next field · Esc to cancel'; +const FOOTER_LAST = 'Tab / ↑↓ to switch · Enter to submit · Esc to cancel'; + +type FieldId = 'url' | 'token'; + +function maskInputLine(raw: string): string { + const prefix = '> '; + if (!raw.startsWith(prefix)) return raw; + + // Strip trailing padding spaces so they stay as spaces. + let end = raw.length; + while (end > prefix.length && raw[end - 1] === ' ') { + end--; + } + const padding = raw.slice(end); + const content = raw.slice(prefix.length, end); + + // Protect ANSI escape sequences (reverse-video cursor, IME marker, etc.) + // while masking every other visible character. + const parts = content.split(/(\u001B(?:\[[0-9;]*m|_pi:c\u0007))/); + const maskedContent = parts + .map((part, index) => { + if (index % 2 === 1) return part; // ANSI sequence + return part.replaceAll(/[^ ]/g, '•'); + }) + .join(''); + + return prefix + maskedContent + padding; +} + +export class CustomRegistryImportDialogComponent extends Container implements Focusable { + focused = false; + + private readonly urlInput = new Input(); + private readonly tokenInput = new Input(); + private readonly onDone: (result: CustomRegistryImportResult) => void; + private activeField: FieldId = 'url'; + private done = false; + private hint: 'none' | 'url-empty' | 'token-empty' = 'none'; + + constructor( + onDone: (result: CustomRegistryImportResult) => void, + defaultUrl: string = '', + ) { + super(); + this.onDone = onDone; + if (defaultUrl.length > 0) this.urlInput.setValue(defaultUrl); + // Enter on the URL field advances to the token field; Enter on the token + // (last) field submits. + this.urlInput.onSubmit = () => { + this.focusField('token'); + }; + this.tokenInput.onSubmit = () => { + this.handleSubmit(); + }; + } + + handleInput(data: string): void { + if (this.done) return; + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('c')) || + matchesKey(data, Key.ctrl('d')) + ) { + this.cancel(); + return; + } + + if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift('tab'))) { + this.toggleField(); + return; + } + if (matchesKey(data, Key.down)) { + this.focusField('token'); + return; + } + if (matchesKey(data, Key.up)) { + this.focusField('url'); + return; + } + + if (this.hint !== 'none') { + this.hint = 'none'; + } + + if (this.activeField === 'url') { + this.urlInput.handleInput(data); + } else { + this.tokenInput.handleInput(data); + } + } + + override invalidate(): void { + super.invalidate(); + this.urlInput.invalidate(); + this.tokenInput.invalidate(); + } + + override render(width: number): string[] { + const dialogActive = this.focused && !this.done; + this.urlInput.focused = dialogActive && this.activeField === 'url'; + this.tokenInput.focused = dialogActive && this.activeField === 'token'; + + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); + const pad = ' '; + + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg('textStrong', TITLE); + const subtitleText = + this.hint === 'url-empty' + ? SUBTITLE_URL_EMPTY + : this.hint === 'token-empty' + ? SUBTITLE_TOKEN_EMPTY + : SUBTITLE_DEFAULT; + const subtitleStyled = currentTheme.fg('textDim', subtitleText); + const footerStyled = currentTheme.fg( + 'textDim', + this.activeField === 'url' ? FOOTER_NOT_LAST : FOOTER_LAST, + ); + + const urlLabelText = 'Registry URL'; + const tokenLabelText = 'Bearer token'; + const urlLabelStyled = + this.activeField === 'url' + ? currentTheme.boldFg('accent', urlLabelText) + : currentTheme.fg('textDim', urlLabelText); + const tokenLabelStyled = + this.activeField === 'token' + ? currentTheme.boldFg('accent', tokenLabelText) + : currentTheme.fg('textDim', tokenLabelText); + + const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); + const subtitleLine = truncateToWidth(subtitleStyled, innerWidth, '…'); + const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); + const urlLabelLine = truncateToWidth(urlLabelStyled, innerWidth, '…'); + const tokenLabelLine = truncateToWidth(tokenLabelStyled, innerWidth, '…'); + const urlInputLine = this.urlInput.render(innerWidth)[0] ?? '> '; + const rawTokenInputLine = this.tokenInput.render(innerWidth)[0] ?? '> '; + const tokenInputLine = maskInputLine(rawTokenInputLine); + + const contentLines: string[] = [ + titleLine, + '', + subtitleLine, + '', + urlLabelLine, + urlInputLine, + '', + tokenLabelLine, + tokenInputLine, + '', + footerLine, + ]; + + if (safeWidth < 4) { + return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))]; + } + + const lines: string[] = [ + '', + border('╭' + '─'.repeat(safeWidth - 2) + '╮'), + border('│') + ' '.repeat(safeWidth - 2) + border('│'), + ]; + + for (const content of contentLines) { + const vis = visibleWidth(content); + const rightPad = Math.max(0, innerWidth - vis); + lines.push(border('│') + pad + content + ' '.repeat(rightPad) + border('│')); + } + + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); + + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); + } + + private toggleField(): void { + this.focusField(this.activeField === 'url' ? 'token' : 'url'); + } + + private focusField(field: FieldId): void { + this.hint = 'none'; + this.activeField = field; + } + + private handleSubmit(): void { + if (this.done) return; + + const urlValue = this.urlInput.getValue().trim(); + const tokenValue = this.tokenInput.getValue().trim(); + + if (urlValue.length === 0) { + this.hint = 'url-empty'; + this.activeField = 'url'; + return; + } + if (tokenValue.length === 0) { + this.hint = 'token-empty'; + this.activeField = 'token'; + return; + } + + this.done = true; + this.onDone({ kind: 'ok', value: { url: urlValue, apiKey: tokenValue } }); + } + + private cancel(): void { + if (this.done) return; + this.done = true; + this.onDone({ kind: 'cancel' }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts b/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e98a457bf0cfa24c55d801cc5106984737369d2 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts @@ -0,0 +1,27 @@ +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +const EDITOR_OPTIONS: readonly ChoiceOption[] = [ + { value: 'code --wait', label: 'VS Code (code --wait)' }, + { value: 'vim', label: 'Vim' }, + { value: 'nvim', label: 'Neovim' }, + { value: 'nano', label: 'Nano' }, + { value: '', label: 'Auto-detect ($VISUAL / $EDITOR)' }, +]; + +export interface EditorSelectorOptions { + readonly currentValue: string; + readonly onSelect: (value: string) => void; + readonly onCancel: () => void; +} + +export class EditorSelectorComponent extends ChoicePickerComponent { + constructor(opts: EditorSelectorOptions) { + super({ + title: 'Select external editor', + options: [...EDITOR_OPTIONS], + currentValue: opts.currentValue, + onSelect: opts.onSelect, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..bba3243062db41c0c479f900c5954eac9bb6cddd --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts @@ -0,0 +1,104 @@ +import { + Container, + Key, + matchesKey, + truncateToWidth, + wrapTextWithAnsi, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; + +import { currentTheme } from '#/tui/theme'; + +import { effortLabel } from './model-selector'; + +export interface EffortSelectorOptions { + readonly title?: string; + /** Selectable thinking efforts for the current model (e.g. ["off","low","high","max"]). */ + readonly efforts: readonly ThinkingEffort[]; + /** Currently active effort (highlighted). */ + readonly currentValue: ThinkingEffort; + readonly onSelect: (effort: ThinkingEffort) => void; + /** When provided, Alt+S applies the choice to the current session only. */ + readonly onSessionOnlySelect?: (effort: ThinkingEffort) => void; + readonly onCancel: () => void; + /** When set, rendered as warning-colored lines directly below the key-hint + * line; wraps instead of truncating when it exceeds the width (e.g. the + * mid-conversation switch cost notice). */ + readonly warning?: string; +} + +/** + * Horizontal segmented picker for the `/effort` command. + * + * Mirrors the thinking control rendered under `/model` (see + * `renderThinkingControl` in model-selector.ts): a single row of segments, + * the active one wrapped in `[ ]`. ←/→ step the active segment, Enter + * commits, and Alt+S (when provided) applies session-only. + */ +export class EffortSelectorComponent extends Container implements Focusable { + focused = false; + private readonly opts: EffortSelectorOptions; + private activeIndex: number; + + constructor(opts: EffortSelectorOptions) { + super(); + this.opts = opts; + const idx = opts.efforts.indexOf(opts.currentValue); + this.activeIndex = Math.max(idx, 0); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.left)) { + this.activeIndex = Math.max(0, this.activeIndex - 1); + return; + } + if (matchesKey(data, Key.right)) { + this.activeIndex = Math.min(this.opts.efforts.length - 1, this.activeIndex + 1); + return; + } + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + this.opts.onSessionOnlySelect(this.opts.efforts[this.activeIndex]!); + return; + } + if (matchesKey(data, Key.enter)) { + this.opts.onSelect(this.opts.efforts[this.activeIndex]!); + return; + } + } + + override render(width: number): string[] { + const hintParts = ['←→ switch', 'Enter select']; + if (this.opts.onSessionOnlySelect !== undefined) hintParts.push('Alt+S session-only'); + hintParts.push('Esc cancel'); + + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ` ${this.opts.title ?? 'Select thinking effort'}`), + currentTheme.fg('textMuted', ` ${hintParts.join(' · ')}`), + ]; + if (this.opts.warning !== undefined) { + for (const line of wrapTextWithAnsi(this.opts.warning, Math.max(1, width - 1))) { + lines.push(currentTheme.fg('warning', ` ${line}`)); + } + } + lines.push(''); + + const segments = this.opts.efforts.map((effort, index) => { + const label = effortLabel(effort); + return index === this.activeIndex + ? currentTheme.boldFg('primary', `[ ${label} ]`) + : currentTheme.fg('text', ` ${label} `); + }); + lines.push(` ${segments.join(' ')}`); + + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e466f873908623a7d27b2583d67226cc2476277 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts @@ -0,0 +1,233 @@ +import { + Container, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; +import type { ExperimentalFeatureState } from '@moonshot-ai/kimi-code-sdk'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { printableChar } from '#/tui/utils/printable-key'; +import { SearchableList } from '#/tui/utils/searchable-list'; + +const ELLIPSIS = '…'; + +export interface ExperimentalFeatureDraftChange { + readonly id: ExperimentalFeatureState['id']; + readonly enabled: boolean; +} + +export interface ExperimentsSelectorOptions { + readonly features: readonly ExperimentalFeatureState[]; + readonly onApply: (changes: readonly ExperimentalFeatureDraftChange[]) => void; + readonly onCancel: () => void; +} + +export class ExperimentsSelectorComponent extends Container implements Focusable { + focused = false; + + private readonly opts: ExperimentsSelectorOptions; + private readonly list: SearchableList<ExperimentalFeatureState>; + private readonly draft = new Map<ExperimentalFeatureState['id'], boolean>(); + + constructor(opts: ExperimentsSelectorOptions) { + super(); + this.opts = opts; + this.list = new SearchableList({ + items: opts.features, + toSearchText: (feature) => `${feature.title} ${feature.id} ${feature.description}`, + searchable: true, + }); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + if (this.list.clearQuery()) return; + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.enter)) { + const changes = this.draftChanges(); + if (changes.length > 0) this.opts.onApply(changes); + return; + } + const decoded = printableChar(data); + if (matchesKey(data, Key.space) || decoded === ' ') { + const selected = this.list.selected(); + if (selected !== undefined) this.toggleDraft(selected); + return; + } + this.list.handleKey(data); + } + + override render(width: number): string[] { + const view = this.list.view(); + const titleSuffix = + view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; + const hintParts = ['↑↓ navigate']; + if (view.page.pageCount > 1) hintParts.push('PgUp/PgDn page'); + hintParts.push('Space toggle', 'Enter apply', 'Esc cancel'); + if (view.query.length > 0) hintParts.push('Backspace clear'); + + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Experimental features') + titleSuffix, + currentTheme.fg('textMuted', ` ${hintParts.join(' · ')}`), + '', + ]; + + if (view.query.length > 0) { + lines.push(currentTheme.fg('primary', ` Search: `) + currentTheme.fg('text', view.query)); + } + + if (view.items.length === 0) { + lines.push(currentTheme.fg('textMuted', ' No matches')); + } + + for (let i = view.page.start; i < view.page.end; i++) { + const feature = view.items[i]!; + const selected = i === view.selectedIndex; + lines.push(...this.renderFeature(feature, selected, width)); + } + + lines.push(''); + if (view.query.length > 0) { + lines.push( + currentTheme.fg( + 'textMuted', + ` ${String(view.items.length)} / ${String(this.opts.features.length)}`, + ), + ); + } else if (view.page.end < view.items.length) { + lines.push( + currentTheme.fg( + 'textMuted', + ` ▼ ${String(view.items.length - view.page.end)} more`, + ), + ); + } + lines.push(this.renderApplyButton()); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } + + private toggleDraft(feature: ExperimentalFeatureState): void { + if (isLocked(feature)) return; + + const enabled = !this.effectiveEnabled(feature); + if (enabled === feature.enabled) { + this.draft.delete(feature.id); + return; + } + this.draft.set(feature.id, enabled); + } + + private effectiveEnabled(feature: ExperimentalFeatureState): boolean { + return this.draft.get(feature.id) ?? feature.enabled; + } + + private isDraftChanged(feature: ExperimentalFeatureState): boolean { + return this.effectiveEnabled(feature) !== feature.enabled; + } + + private draftChanges(): ExperimentalFeatureDraftChange[] { + const changes: ExperimentalFeatureDraftChange[] = []; + for (const feature of this.opts.features) { + if (this.isDraftChanged(feature)) { + changes.push({ id: feature.id, enabled: this.effectiveEnabled(feature) }); + } + } + return changes; + } + + private renderApplyButton(): string { + const changes = this.draftChanges(); + const count = changes.length; + const label = '[ Apply changes and reload ]'; + const summary = + count === 0 ? 'no changes' : `${String(count)} ${count === 1 ? 'change' : 'changes'}`; + const button = count === 0 + ? currentTheme.fg('textDim', label) + : currentTheme.boldFg('primary', label); + const summaryText = count === 0 + ? currentTheme.fg('textMuted', summary) + : currentTheme.fg('success', summary); + return ` ${button} ${summaryText}`; + } + + private renderFeature( + feature: ExperimentalFeatureState, + selected: boolean, + width: number, + ): string[] { + const pointer = selected ? SELECT_POINTER : ' '; + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); + const label = selected ? currentTheme.boldFg('primary', feature.title) : currentTheme.fg('text', feature.title); + const enabled = this.effectiveEnabled(feature); + const status = enabled ? 'enabled' : 'disabled'; + const statusText = enabled ? currentTheme.fg('success', status) : currentTheme.fg('textDim', status); + const detail = this.isDraftChanged(feature) + ? `${featureDetail(feature)} · modified` + : featureDetail(feature); + const lines = [ + `${prefix}${label} ${statusText}`, + currentTheme.fg('textMuted', ` ${detail}`), + ]; + const descriptionWidth = Math.max(1, width - 4); + for (const line of wrapText(feature.description, descriptionWidth)) { + lines.push(currentTheme.fg('textMuted', ` ${line}`)); + } + return lines; + } +} + +function isLocked(feature: ExperimentalFeatureState): boolean { + return feature.source === 'env' || feature.source === 'master-env'; +} + +function featureDetail(feature: ExperimentalFeatureState): string { + const source = sourceLabel(feature); + if (feature.source === 'env' || feature.source === 'master-env') { + return `id ${feature.id} · ${source}`; + } + return `id ${feature.id} · ${source} · ${feature.env}`; +} + +function sourceLabel(feature: ExperimentalFeatureState): string { + switch (feature.source) { + case 'master-env': + return 'locked by KIMI_CODE_EXPERIMENTAL_FLAG'; + case 'env': + return `locked by ${feature.env}`; + case 'config': + return 'config'; + case 'default': + return 'default'; + } +} + +function wrapText(text: string, width: number): string[] { + const maxWidth = Math.max(1, width); + const words = text + .trim() + .split(/\s+/) + .filter((word) => word.length > 0); + const lines: string[] = []; + let current = ''; + + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (visibleWidth(candidate) <= maxWidth) { + current = candidate; + continue; + } + if (current.length > 0) lines.push(current); + current = visibleWidth(word) <= maxWidth ? word : truncateToWidth(word, maxWidth, ELLIPSIS); + } + + if (current.length > 0) lines.push(current); + return lines; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1f108dd7905db3084973a570c9f047cbf41665d --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts @@ -0,0 +1,139 @@ +/** + * FeedbackInputDialog — blue rounded box that collects a single line of + * user feedback before submitting it to the managed Kimi Code platform. + * + * Geometry mirrors `DeviceCodeBox` so the chrome stays consistent with + * the OAuth login flow. The box embeds a `pi-tui` Input for the actual + * text entry; cursor visibility tracks the dialog's `focused` flag. + * + * This is stage 1 of the feedback flow: it collects the free-form text + * only. Whether to attach diagnostic logs / codebase is decided in a + * follow-up stage (see `promptFeedbackAttachment`). + */ + +import { + Container, + Input, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; +import { currentTheme } from '#/tui/theme'; + +export type FeedbackInputDialogResult = + | { readonly kind: 'ok'; readonly value: string } + | { readonly kind: 'cancel' }; + +const TITLE = 'Send feedback to Kimi Code'; +const SUBTITLE_DEFAULT = "Tell us what's working or what's not."; +const SUBTITLE_EMPTY = 'Feedback cannot be empty.'; +const FOOTER = 'Enter to submit · Esc to cancel'; + +export class FeedbackInputDialogComponent extends Container implements Focusable { + focused = false; + + private readonly input = new Input(); + private readonly onDone: (result: FeedbackInputDialogResult) => void; + private done = false; + private emptyHinted = false; + + constructor(onDone: (result: FeedbackInputDialogResult) => void) { + super(); + this.onDone = onDone; + this.input.onSubmit = (value) => { + this.submit(value); + }; + } + + handleInput(data: string): void { + if (this.done) return; + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('c')) || + matchesKey(data, Key.ctrl('d')) + ) { + this.cancel(); + return; + } + if (this.emptyHinted) { + this.emptyHinted = false; + } + this.input.handleInput(data); + } + + override invalidate(): void { + super.invalidate(); + this.input.invalidate(); + } + + override render(width: number): string[] { + this.input.focused = this.focused && !this.done; + + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); + const pad = ' '; + + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg('textStrong', TITLE); + const subtitleText = this.emptyHinted ? SUBTITLE_EMPTY : SUBTITLE_DEFAULT; + const subtitleStyled = currentTheme.fg('textDim', subtitleText); + const footerStyled = currentTheme.fg('textDim', FOOTER); + + const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); + const subtitleLine = truncateToWidth(subtitleStyled, innerWidth, '…'); + const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); + const inputLine = this.input.render(innerWidth)[0] ?? '> '; + + const contentLines: string[] = [ + titleLine, + '', + subtitleLine, + '', + inputLine, + '', + footerLine, + ]; + + if (safeWidth < 4) { + return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))]; + } + + const lines: string[] = [ + '', + border('╭' + '─'.repeat(safeWidth - 2) + '╮'), + border('│') + ' '.repeat(safeWidth - 2) + border('│'), + ]; + + for (const content of contentLines) { + const vis = visibleWidth(content); + const rightPad = Math.max(0, innerWidth - vis); + lines.push(border('│') + pad + content + ' '.repeat(rightPad) + border('│')); + } + + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); + + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); + } + + private submit(value: string): void { + if (this.done) return; + const trimmed = value.trim(); + if (trimmed.length === 0) { + this.emptyHinted = true; + return; + } + this.done = true; + this.onDone({ kind: 'ok', value: trimmed }); + } + + private cancel(): void { + if (this.done) return; + this.done = true; + this.onDone({ kind: 'cancel' }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a45d624359299f01b53d6b398bd36b9f5efb5e7 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts @@ -0,0 +1,645 @@ +import { + Container, + Key, + matchesKey, + CURSOR_MARKER, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import type { + GoalQueueMoveDirection, + GoalQueueSnapshot, + UpcomingGoal, +} from '#/tui/goal-queue-store'; +import { currentTheme } from '#/tui/theme'; +import { printableChar } from '#/tui/utils/printable-key'; +import { SearchableList } from '#/tui/utils/searchable-list'; + +const MAX_GOAL_OBJECTIVE_LENGTH = 4000; +const MAX_EDIT_INPUT_LINES = 8; +const ELLIPSIS = '…'; +const BRACKET_PASTE_START = '\u001B[200~'; +const BRACKET_PASTE_END = '\u001B[201~'; +const SHIFT_ENTER_LEGACY = '\u001B\r'; +const SHIFT_ENTER_CSI = '\u001B[13;2~'; +const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); +// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to strip pasted terminal control sequences +const ANSI_CSI = /\u001B\[[0-?]*[ -/]*[@-~]/g; + +export type GoalQueueManagerAction = + | { + readonly kind: 'move'; + readonly goalId: string; + readonly direction: GoalQueueMoveDirection; + } + | { readonly kind: 'edit'; readonly goalId: string } + | { readonly kind: 'delete'; readonly goalId: string }; + +export interface GoalQueueManagerOptions { + readonly goals: readonly UpcomingGoal[]; + readonly selectedGoalId?: string; + readonly pageSize?: number; + readonly onAction: ( + action: GoalQueueManagerAction, + ) => GoalQueueSnapshot | void | Promise<GoalQueueSnapshot | void>; + readonly onCancel: () => void; +} + +export type GoalQueueEditResult = + | { readonly kind: 'save'; readonly goalId: string; readonly objective: string } + | { readonly kind: 'cancel'; readonly goalId: string }; + +export interface GoalQueueEditDialogOptions { + readonly goal: UpcomingGoal; + readonly onDone: (result: GoalQueueEditResult) => void; +} + +export class GoalQueueManagerComponent extends Container implements Focusable { + focused = false; + + private readonly opts: GoalQueueManagerOptions; + private goals: readonly UpcomingGoal[]; + private list: SearchableList<UpcomingGoal>; + private movingGoalId: string | undefined; + private busy = false; + + constructor(opts: GoalQueueManagerOptions) { + super(); + this.opts = opts; + this.goals = opts.goals; + this.list = this.createList(opts.selectedGoalId); + } + + handleInput(data: string): void { + if (this.busy) return; + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + + const selected = this.selectedGoal(); + const decoded = printableChar(data); + if (matchesKey(data, Key.space) || decoded === ' ') { + this.movingGoalId = this.movingGoalId === selected?.id ? undefined : selected?.id; + return; + } + + if ((decoded === 'e' || decoded === 'E') && selected !== undefined) { + void this.opts.onAction({ kind: 'edit', goalId: selected.id }); + return; + } + + if ((decoded === 'd' || decoded === 'D') && selected !== undefined) { + void this.applyQueueAction({ kind: 'delete', goalId: selected.id }); + return; + } + + if (this.movingGoalId !== undefined) { + if (matchesKey(data, Key.up)) { + void this.applyQueueAction({ kind: 'move', goalId: this.movingGoalId, direction: 'up' }); + return; + } + if (matchesKey(data, Key.down)) { + void this.applyQueueAction({ kind: 'move', goalId: this.movingGoalId, direction: 'down' }); + return; + } + } + + if (this.list.handleKey(data)) return; + } + + override render(width: number): string[] { + const view = this.list.view(); + const hint = this.movingGoalId === undefined + ? '↑↓ navigate · Space select · E edit · D delete · Esc cancel' + : '↑↓ reorder · Space done · E edit · D delete · Esc cancel'; + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Upcoming goals'), + currentTheme.fg('textMuted', ` ${hint}`), + '', + ]; + + if (this.goals.length === 0) { + lines.push(currentTheme.fg('textMuted', ' No upcoming goals.')); + } else { + for (let i = view.page.start; i < view.page.end; i++) { + const goal = view.items[i]; + if (goal === undefined) continue; + lines.push(this.renderGoal(goal, i, i === view.selectedIndex, width)); + } + + const below = view.items.length - view.page.end; + if (below > 0) { + lines.push(''); + lines.push(currentTheme.fg('textMuted', ` ▼ ${String(below)} more`)); + } + } + + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } + + private renderGoal(goal: UpcomingGoal, index: number, selected: boolean, width: number): string { + const moving = goal.id === this.movingGoalId; + const pointer = selected ? SELECT_POINTER : ' '; + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); + const labelPrefix = `${String(index + 1)}. `; + const stateLabel = moving ? ' selected' : ''; + const labelWidth = visibleWidth(labelPrefix); + const stateWidth = visibleWidth(stateLabel); + const objectiveWidth = Math.max(1, width - 5 - labelWidth - stateWidth); + const objective = truncateToWidth( + formatListObjective(goal.objective), + objectiveWidth, + ELLIPSIS, + ); + const textStyle = selected + ? (text: string) => currentTheme.boldFg('primary', text) + : (text: string) => currentTheme.fg('text', text); + let line = prefix + textStyle(labelPrefix + objective); + if (moving) line += currentTheme.fg('success', stateLabel); + return line; + } + + private selectedGoal(): UpcomingGoal | undefined { + return this.list.selected(); + } + + private async applyQueueAction(action: Exclude<GoalQueueManagerAction, { kind: 'edit' }>) { + this.busy = true; + try { + const result = await this.opts.onAction(action); + if (result !== undefined) { + const selectedGoalId = action.kind === 'delete' ? undefined : action.goalId; + this.goals = result.goals; + if (!this.goals.some((goal) => goal.id === this.movingGoalId)) { + this.movingGoalId = undefined; + } + this.list = this.createList(selectedGoalId ?? this.movingGoalId); + } + } finally { + this.busy = false; + this.invalidate(); + } + } + + private createList(selectedGoalId?: string): SearchableList<UpcomingGoal> { + const initialIndex = this.goals.findIndex((goal) => goal.id === selectedGoalId); + return new SearchableList({ + items: this.goals, + toSearchText: (goal) => goal.objective, + pageSize: this.opts.pageSize, + initialIndex: initialIndex === -1 ? 0 : initialIndex, + searchable: false, + }); + } +} + +export class GoalQueueEditDialogComponent extends Container implements Focusable { + focused = false; + + private readonly input = new MultilineGoalInput(); + private readonly opts: GoalQueueEditDialogOptions; + private done = false; + private error: string | undefined; + + constructor(opts: GoalQueueEditDialogOptions) { + super(); + this.opts = opts; + this.input.setValue(opts.goal.objective); + this.input.onSubmit = (value) => { + this.submit(value); + }; + } + + handleInput(data: string): void { + if (this.done) return; + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('c')) || + matchesKey(data, Key.ctrl('d')) + ) { + this.done = true; + this.opts.onDone({ kind: 'cancel', goalId: this.opts.goal.id }); + return; + } + this.error = undefined; + this.input.handleInput(data); + } + + override invalidate(): void { + super.invalidate(); + this.input.invalidate(); + } + + override render(width: number): string[] { + this.input.focused = this.focused && !this.done; + + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); + const pad = ' '; + const border = (s: string): string => currentTheme.fg('primary', s); + const title = truncateToWidth( + currentTheme.boldFg('textStrong', 'Edit upcoming goal'), + innerWidth, + ELLIPSIS, + ); + const subtitle = truncateToWidth( + currentTheme.fg( + this.error === undefined ? 'textDim' : 'warning', + this.error ?? 'Update the queued objective.', + ), + innerWidth, + ELLIPSIS, + ); + const inputLines = this.input.render(innerWidth); + const footer = truncateToWidth( + currentTheme.fg('textDim', 'Enter submit · Shift-Enter/Ctrl-J newline · Esc cancel'), + innerWidth, + ELLIPSIS, + ); + const contentLines = [title, '', subtitle, '', ...inputLines, '', footer]; + if (safeWidth < 4) { + return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, ELLIPSIS))]; + } + + const lines = [ + '', + border('╭' + '─'.repeat(safeWidth - 2) + '╮'), + border('│') + ' '.repeat(safeWidth - 2) + border('│'), + ]; + + for (const content of contentLines) { + const rightPad = Math.max(0, innerWidth - visibleWidth(content)); + lines.push(border('│') + pad + content + ' '.repeat(rightPad) + border('│')); + } + + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); + + return lines.map((line) => truncateToWidth(line, safeWidth, ELLIPSIS)); + } + + private submit(value: string): void { + const objective = value.trim(); + if (objective.length === 0) { + this.error = 'Goal objective cannot be empty.'; + return; + } + if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { + this.error = `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters; put long content in a file and reference the file path.`; + return; + } + this.opts.onDone({ kind: 'save', goalId: this.opts.goal.id, objective }); + } +} + +class MultilineGoalInput { + focused = false; + onSubmit?: (value: string) => void; + + private value = ''; + private cursor = 0; + private pasteBuffer: string | undefined; + + getValue(): string { + return this.value; + } + + setValue(value: string): void { + this.value = normalizeNewlines(value); + this.cursor = this.value.length; + } + + handleInput(data: string): void { + if (this.handleBracketedPaste(data)) return; + + if (isNewlineInput(data)) { + this.insert('\n'); + return; + } + + if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) { + this.onSubmit?.(this.value); + return; + } + + if (matchesKey(data, Key.backspace)) { + this.deleteBeforeCursor(); + return; + } + + if (matchesKey(data, Key.delete)) { + this.deleteAfterCursor(); + return; + } + + if (matchesKey(data, Key.left)) { + this.cursor = previousGraphemeStart(this.value, this.cursor); + return; + } + + if (matchesKey(data, Key.right)) { + this.cursor = nextGraphemeEnd(this.value, this.cursor); + return; + } + + if (matchesKey(data, Key.up)) { + this.moveVertical(-1); + return; + } + + if (matchesKey(data, Key.down)) { + this.moveVertical(1); + return; + } + + if (matchesKey(data, Key.home) || matchesKey(data, Key.ctrl('a'))) { + this.cursor = this.currentLineStart(); + return; + } + + if (matchesKey(data, Key.end) || matchesKey(data, Key.ctrl('e'))) { + this.cursor = this.currentLineEnd(); + return; + } + + const decoded = printableChar(data); + if (isPrintableText(decoded)) { + this.insert(decoded); + } + } + + invalidate(): void { + // No cached layout. + } + + render(width: number): string[] { + const safeWidth = Math.max(4, width); + const logicalLines = this.value.split('\n'); + const cursor = this.cursorLocation(); + const range = visibleLineRange(logicalLines.length, cursor.line); + const rendered: string[] = []; + + if (range.start > 0) { + rendered.push(padInputLine(` ${ELLIPSIS} ${String(range.start)} previous`, safeWidth)); + } + + for (let lineIndex = range.start; lineIndex < range.end; lineIndex++) { + const line = logicalLines[lineIndex] ?? ''; + const prefix = lineIndex === 0 ? '> ' : ' '; + rendered.push( + lineIndex === cursor.line + ? renderCursorLine(line, cursor.column, prefix, safeWidth, this.focused) + : renderTextLine(line, prefix, safeWidth), + ); + } + + const remaining = logicalLines.length - range.end; + if (remaining > 0) { + rendered.push(padInputLine(` ${ELLIPSIS} ${String(remaining)} more`, safeWidth)); + } + + return rendered; + } + + private insert(text: string): void { + const normalized = normalizeNewlines(text); + this.value = + this.value.slice(0, this.cursor) + normalized + this.value.slice(this.cursor); + this.cursor += normalized.length; + } + + private deleteBeforeCursor(): void { + if (this.cursor === 0) return; + const start = previousGraphemeStart(this.value, this.cursor); + this.value = this.value.slice(0, start) + this.value.slice(this.cursor); + this.cursor = start; + } + + private deleteAfterCursor(): void { + if (this.cursor >= this.value.length) return; + const end = nextGraphemeEnd(this.value, this.cursor); + this.value = this.value.slice(0, this.cursor) + this.value.slice(end); + } + + private moveVertical(delta: -1 | 1): void { + const starts = lineStarts(this.value); + const location = this.cursorLocation(starts); + const targetLine = location.line + delta; + if (targetLine < 0 || targetLine >= starts.length) return; + + const targetStart = starts[targetLine] ?? 0; + const targetEnd = lineEndForStart(this.value, starts, targetLine); + this.cursor = Math.min(targetStart + location.column, targetEnd); + } + + private currentLineStart(): number { + return this.value.lastIndexOf('\n', Math.max(0, this.cursor - 1)) + 1; + } + + private currentLineEnd(): number { + return lineEndAt(this.value, this.cursor); + } + + private cursorLocation(starts = lineStarts(this.value)): { line: number; column: number } { + let line = 0; + for (let i = 0; i < starts.length; i++) { + const start = starts[i] ?? 0; + if (start > this.cursor) break; + line = i; + } + const lineStart = starts[line] ?? 0; + return { line, column: this.cursor - lineStart }; + } + + private handleBracketedPaste(data: string): boolean { + if (this.pasteBuffer !== undefined) { + this.appendPasteChunk(data); + return true; + } + + const start = data.indexOf(BRACKET_PASTE_START); + if (start === -1) return false; + + this.pasteBuffer = ''; + const before = data.slice(0, start); + if (isPrintableText(before)) this.insert(before); + this.appendPasteChunk(data.slice(start + BRACKET_PASTE_START.length)); + return true; + } + + private appendPasteChunk(data: string): void { + if (this.pasteBuffer === undefined) return; + + this.pasteBuffer += data; + const end = this.pasteBuffer.indexOf(BRACKET_PASTE_END); + if (end === -1) return; + + const pasted = this.pasteBuffer.slice(0, end); + const remaining = this.pasteBuffer.slice(end + BRACKET_PASTE_END.length); + this.pasteBuffer = undefined; + this.insert(sanitizePastedText(pasted)); + if (remaining.length > 0) this.handleInput(remaining); + } +} + +function isNewlineInput(data: string): boolean { + return ( + data === '\n' || + data === SHIFT_ENTER_LEGACY || + data === SHIFT_ENTER_CSI || + matchesKey(data, Key.ctrl('j')) + ); +} + +function normalizeNewlines(text: string): string { + return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); +} + +function formatListObjective(objective: string): string { + return objective.replaceAll(/\s+/g, ' ').trim(); +} + +function sanitizePastedText(text: string): string { + const normalized = normalizeNewlines(text).replaceAll(ANSI_CSI, ''); + let out = ''; + for (let i = 0; i < normalized.length;) { + const code = normalized.codePointAt(i); + if (code === undefined) break; + const char = String.fromCodePoint(code); + if (char === '\n' || isPrintableText(char)) { + out += char; + } + i += code > 0xffff ? 2 : 1; + } + return out; +} + +function isPrintableText(text: string): boolean { + if (text.length === 0) return false; + for (let i = 0; i < text.length;) { + const code = text.codePointAt(i); + if (code === undefined) return false; + if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) return false; + i += code > 0xffff ? 2 : 1; + } + return true; +} + +function lineStarts(text: string): number[] { + const starts = [0]; + for (let i = 0; i < text.length; i++) { + if (text[i] === '\n') starts.push(i + 1); + } + return starts; +} + +function lineEndAt(text: string, offset: number): number { + const end = text.indexOf('\n', offset); + return end === -1 ? text.length : end; +} + +function lineEndForStart(text: string, starts: readonly number[], line: number): number { + const nextStart = starts[line + 1]; + return nextStart === undefined ? text.length : nextStart - 1; +} + +function previousGraphemeStart(text: string, offset: number): number { + if (offset <= 0) return 0; + let previous = 0; + for (const segment of SEGMENTER.segment(text.slice(0, offset))) { + previous = segment.index; + } + return previous; +} + +function nextGraphemeEnd(text: string, offset: number): number { + if (offset >= text.length) return text.length; + const segment = SEGMENTER.segment(text.slice(offset))[Symbol.iterator]().next().value; + return segment === undefined ? text.length : offset + segment.segment.length; +} + +function visibleLineRange(totalLines: number, cursorLine: number): { start: number; end: number } { + if (totalLines <= MAX_EDIT_INPUT_LINES) return { start: 0, end: totalLines }; + + const half = Math.floor(MAX_EDIT_INPUT_LINES / 2); + const start = Math.min( + Math.max(0, cursorLine - half), + Math.max(0, totalLines - MAX_EDIT_INPUT_LINES), + ); + return { start, end: start + MAX_EDIT_INPUT_LINES }; +} + +function renderTextLine(line: string, prefix: string, width: number): string { + const prefixWidth = visibleWidth(prefix); + const textWidth = Math.max(1, width - prefixWidth); + const text = truncateToWidth(line, textWidth, ELLIPSIS); + return padInputLine(prefix + text, width); +} + +function renderCursorLine( + line: string, + column: number, + prefix: string, + width: number, + focused: boolean, +): string { + const prefixWidth = visibleWidth(prefix); + const textWidth = Math.max(1, width - prefixWidth); + const cursorEnd = nextGraphemeEnd(line, column); + const before = line.slice(0, column); + const cursorText = line.slice(column, cursorEnd) || ' '; + const after = line.slice(cursorEnd); + const cursorWidth = Math.max(1, visibleWidth(cursorText)); + const beforeWidth = Math.max(0, textWidth - cursorWidth); + const beforeView = takeEndByWidth(before, beforeWidth); + const afterView = takeStartByWidth( + after, + Math.max(0, textWidth - visibleWidth(beforeView) - cursorWidth), + ); + const marker = focused ? CURSOR_MARKER : ''; + return padInputLine( + prefix + beforeView + marker + chalk.inverse(cursorText) + afterView, + width, + ); +} + +function takeStartByWidth(text: string, width: number): string { + let out = ''; + let used = 0; + for (const segment of SEGMENTER.segment(text)) { + const segmentWidth = visibleWidth(segment.segment); + if (used + segmentWidth > width) break; + out += segment.segment; + used += segmentWidth; + } + return out; +} + +function takeEndByWidth(text: string, width: number): string { + let out = ''; + let used = 0; + const segments = [...SEGMENTER.segment(text)]; + for (let i = segments.length - 1; i >= 0; i--) { + const segment = segments[i]; + if (segment === undefined) continue; + const segmentWidth = visibleWidth(segment.segment); + if (used + segmentWidth > width) break; + out = segment.segment + out; + used += segmentWidth; + } + return out; +} + +function padInputLine(line: string, width: number): string { + return line + ' '.repeat(Math.max(0, width - visibleWidth(line))); +} diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts new file mode 100644 index 0000000000000000000000000000000000000000..873dcc2e671f62e7d450373c4d489a03448f63d0 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts @@ -0,0 +1,93 @@ +import { + StartPermissionPromptComponent, + type StartPermissionOption, +} from './start-permission-prompt'; + +export type GoalStartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; + +export interface GoalStartPermissionPromptOptions { + readonly mode: 'manual' | 'yolo'; + readonly onSelect: (choice: GoalStartPermissionChoice) => void; + readonly onCancel: () => void; +} + +export const GOAL_START_MANUAL_OPTIONS: readonly StartPermissionOption[] = [ + { + value: 'auto', + label: 'Switch to Never Ask and start', + description: + 'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + }, + { + value: 'yolo', + label: 'Switch to Ask When Needed and start', + description: + 'Tools and plan changes are approved automatically. Kimi Code may still ask you questions.', + }, + { + value: 'manual', + label: 'Start in Always Ask', + description: + 'Keep approvals on. Kimi Code will ask before risky actions, so the goal may stop and wait for you.', + }, + { + value: 'cancel', + label: 'Do not start', + description: 'Return to the input box with your goal command.', + }, +]; + +export const GOAL_START_YOLO_OPTIONS: readonly StartPermissionOption[] = [ + { + value: 'auto', + label: 'Switch to Never Ask and start', + description: + 'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + }, + { + value: 'yolo', + label: 'Keep Ask When Needed and start', + description: + 'Tools and plan changes stay approved automatically. Kimi Code may still ask you questions.', + }, + { + value: 'cancel', + label: 'Do not start', + description: 'Return to the input box with your goal command.', + }, +]; + +export function goalStartOptions(mode: 'manual' | 'yolo'): readonly StartPermissionOption[] { + return mode === 'yolo' ? GOAL_START_YOLO_OPTIONS : GOAL_START_MANUAL_OPTIONS; +} + +const MANUAL_OPTIONS = GOAL_START_MANUAL_OPTIONS; + +const YOLO_OPTIONS = GOAL_START_YOLO_OPTIONS; + +const MANUAL_NOTICE_LINES = [ + 'Always Ask mode asks you before Kimi Code runs commands, edits files, or takes other risky actions.', + 'Always Ask mode is not suitable for unattended goal work.', + 'You can go back without losing your command.', +] as const; + +const YOLO_NOTICE_LINES = [ + 'Ask When Needed mode approves tools and plan changes automatically.', + 'Ask When Needed mode can still stop for questions.', + 'Switch to Never Ask if you want questions skipped during goal work.', +] as const; + +export class GoalStartPermissionPromptComponent extends StartPermissionPromptComponent { + constructor(opts: GoalStartPermissionPromptOptions) { + super({ + title: + opts.mode === 'yolo' + ? 'Start a goal in Ask When Needed mode?' + : 'Start a goal with approvals on?', + noticeLines: opts.mode === 'yolo' ? YOLO_NOTICE_LINES : MANUAL_NOTICE_LINES, + options: opts.mode === 'yolo' ? YOLO_OPTIONS : MANUAL_OPTIONS, + onSelect: opts.onSelect, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts new file mode 100644 index 0000000000000000000000000000000000000000..10fd5d5fe8b9da88ab37f9a8b911f24754bb8d59 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts @@ -0,0 +1,156 @@ +/** + * HelpPanel — modal `/help` display. Lists keyboard shortcuts, slash + * commands (with aliases + descriptions) in colour-coded sections. + * + * Mirrors the container-replacement pattern used by SessionPicker / + * ApprovalPanel: host mounts the panel into `editorContainer`, picks + * it as the focused component, and tears it down on the `onClose` + * callback (fired on Esc / Enter / q). + */ + +import { + Container, + matchesKey, + Key, + decodeKittyPrintable, + type Focusable, + truncateToWidth, +} from '@moonshot-ai/pi-tui'; +import { currentTheme } from '#/tui/theme'; + +export interface KeyboardShortcut { + readonly keys: string; + readonly description: string; +} + +export interface HelpPanelCommand { + readonly name: string; + readonly aliases: readonly string[]; + readonly description: string; +} + +/** Static list — keep in sync with the global editor bindings. */ +export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [ + { keys: 'Shift-Tab', description: 'Toggle plan mode' }, + { keys: 'Ctrl-G', description: 'Edit in external editor ($VISUAL / $EDITOR)' }, + { keys: 'Ctrl-O', description: 'Toggle tool output / compaction summary expansion' }, + { keys: 'Ctrl-T', description: 'Expand / collapse the todo list (when truncated)' }, + { keys: 'Ctrl-S', description: 'Steer — inject a follow-up during streaming' }, + { keys: 'Shift-Enter / Ctrl-J', description: 'Insert newline' }, + { keys: 'Ctrl-C', description: 'Interrupt stream / clear input' }, + { keys: 'Ctrl-D', description: 'Exit (on empty input)' }, + { keys: 'Esc', description: 'Close dialogs / interrupt streaming' }, + { keys: '↑ / ↓', description: 'Browse input history' }, + { keys: 'Enter', description: 'Submit' }, +]; + +export interface HelpPanelOptions { + readonly commands: readonly HelpPanelCommand[]; + readonly shortcuts?: readonly KeyboardShortcut[]; + readonly onClose: () => void; + /** Terminal height — used to decide whether to show the hint tail. */ + readonly maxVisible?: number; +} + +export class HelpPanelComponent extends Container implements Focusable { + focused = false; + private readonly opts: HelpPanelOptions; + private scrollTop = 0; + + constructor(opts: HelpPanelOptions) { + super(); + this.opts = opts; + } + + handleInput(data: string): void { + const printable = decodeKittyPrintable(data) ?? data; + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.enter) || + printable === 'q' || + printable === 'Q' + ) { + this.opts.onClose(); + return; + } + if (matchesKey(data, Key.up)) { + this.scrollTop = Math.max(0, this.scrollTop - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.scrollTop += 1; // render clamps + return; + } + if (matchesKey(data, Key.pageUp)) { + this.scrollTop = Math.max(0, this.scrollTop - 10); + return; + } + if (matchesKey(data, Key.pageDown)) { + this.scrollTop += 10; + } + } + + override render(width: number): string[] { + const accent = (text: string) => currentTheme.fg('primary', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const muted = (text: string) => currentTheme.fg('textMuted', text); + const kbdColor = (text: string) => currentTheme.fg('warning', text); + const slashColor = (text: string) => currentTheme.fg('primary', text); + + const shortcuts = this.opts.shortcuts ?? DEFAULT_KEYBOARD_SHORTCUTS; + const kbdWidth = Math.max(8, ...shortcuts.map((s) => s.keys.length)); + const sortedCmds = [...this.opts.commands].toSorted(compareSlashCommandsForDisplay); + const cmdLabels = sortedCmds.map((c) => { + const aliases = c.aliases.length > 0 ? ` (${c.aliases.map((a) => '/' + a).join(', ')})` : ''; + return `/${c.name}${aliases}`; + }); + const cmdWidth = Math.max(12, ...cmdLabels.map((l) => l.length)); + const lines: string[] = [ + accent('─'.repeat(width)), + currentTheme.boldFg('primary', ' help ') + muted('· Esc / Enter / q to cancel · ↑↓ scroll'), + '', + // Greeting + ` ${dim('Sure, Kimi is ready to help! Just send a message to get started.')}`, + '', + // Section: keyboard shortcuts + ` ${currentTheme.bold('Keyboard shortcuts')}`, + ...shortcuts.map((s) => ` ${kbdColor(s.keys.padEnd(kbdWidth))} ${dim(s.description)}`), + '', + // Section: slash commands + ` ${currentTheme.bold('Slash commands')}`, + ...sortedCmds.map((cmd, i) => { + const label = cmdLabels[i] ?? `/${cmd.name}`; + return ` ${slashColor(label.padEnd(cmdWidth))} ${dim(cmd.description)}`; + }), + '', + accent('─'.repeat(width)), + ]; + + // Apply scroll windowing — keep the borders visible. + const content = lines.slice(1, lines.length - 1); + const maxVisible = Math.max(5, this.opts.maxVisible ?? 24); + if (content.length > maxVisible) { + this.scrollTop = Math.max(0, Math.min(this.scrollTop, content.length - maxVisible)); + const slice = content.slice(this.scrollTop, this.scrollTop + maxVisible); + const scrollInfo = muted( + ` showing ${String(this.scrollTop + 1)}-${String(this.scrollTop + slice.length)} of ${String(content.length)}`, + ); + return [lines[0] ?? '', ...slice, scrollInfo, lines.at(-1) ?? ''].map((line) => + truncateToWidth(line, width), + ); + } + this.scrollTop = 0; + return lines.map((line) => truncateToWidth(line, width)); + } +} + +function compareSlashCommandsForDisplay(a: HelpPanelCommand, b: HelpPanelCommand): number { + return ( + getSlashCommandDisplayGroup(a.name) - getSlashCommandDisplayGroup(b.name) || + a.name.localeCompare(b.name) + ); +} + +function getSlashCommandDisplayGroup(name: string): number { + return name.startsWith('skill:') ? 1 : 0; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..2532f14a2d83cc1a1481ade2c148136df0755339 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -0,0 +1,402 @@ +import { effectiveModelAlias, type ModelAlias, type ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; +import { + Container, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; +import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { SearchableList } from '#/tui/utils/searchable-list'; + +import type { ChoiceOption } from './choice-picker'; + +type ThinkingAvailability = 'toggle' | 'always-on' | 'unsupported'; + +interface ModelChoice { + readonly alias: string; + readonly model: ModelAlias; + /** Model display name (left column). */ + readonly name: string; + /** Provider display name (right column). */ + readonly provider: string; + /** Combined text the fuzzy filter matches against (name + provider). */ + readonly label: string; +} + +export interface ModelSelection { + readonly alias: string; + /** Chosen thinking effort: 'off', or a concrete effort such as 'low' / + * 'high' / 'max'. Boolean 'on' is normalized to the model's default effort + * before the selection is committed (see commitEffort). */ + readonly thinking: ThinkingEffort; +} + +export function modelDisplayName(alias: string, model: ModelAlias | undefined): string { + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.displayName ?? effective?.model ?? alias; +} + +export function providerDisplayName(provider: string): string { + if (provider === DEFAULT_OAUTH_PROVIDER_NAME) return PRODUCT_NAME; + if (provider.startsWith('managed:')) return provider.slice('managed:'.length); + return provider; +} + +export function createModelChoiceOptions( + models: Record<string, ModelAlias>, +): readonly ChoiceOption[] { + return Object.entries(models).map(([alias, cfg]) => { + const effective = effectiveModelAlias(cfg); + return { + value: alias, + label: `${modelDisplayName(alias, effective)} (${providerDisplayName(effective.provider)})`, + }; + }); +} + +export interface ModelSelectorOptions { + readonly models: Record<string, ModelAlias>; + readonly currentValue: string; + readonly selectedValue?: string; + /** Live thinking effort of the currently active model (e.g. 'off', 'on', + * 'high'). Used to highlight the active segment for the current model. */ + readonly currentThinkingEffort: ThinkingEffort; + /** Overrides the default ' Select a model' title line. */ + readonly title?: string; + /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ + readonly searchable?: boolean; + /** Items per page. Lists longer than this paginate (PgUp/PgDn). */ + readonly pageSize?: number; + /** When true, the hint line mentions the Tab provider switch — set by + * TabbedModelSelectorComponent so the inner list advertises the tab keys. */ + readonly providerSwitchHint?: boolean; + /** When set, rendered as warning-colored lines directly below the key-hint + * line; wraps instead of truncating when it exceeds the width (e.g. the + * mid-conversation switch cost notice). */ + readonly warning?: string; + /** Set to false to hide the Thinking footer and disable ←/→ effort + * switching — for pickers whose selection carries no thinking level. */ + readonly thinkingControl?: boolean; + readonly onSelect: (selection: ModelSelection) => void; + /** When provided, Alt+S invokes this instead of onSelect — used to apply the + * choice to the current session only, without persisting it as the default. */ + readonly onSessionOnlySelect?: (selection: ModelSelection) => void; + readonly onCancel: () => void; +} + +function createModelChoices(models: Record<string, ModelAlias>): readonly ModelChoice[] { + return Object.entries(models).map(([alias, cfg]) => { + const effective = effectiveModelAlias(cfg); + const name = modelDisplayName(alias, effective); + const provider = providerDisplayName(effective.provider); + return { alias, model: effective, name, provider, label: `${name} (${provider})` }; + }); +} + +export function thinkingAvailability(model: ModelAlias): ThinkingAvailability { + const caps = model.capabilities ?? []; + if (caps.includes('always_thinking')) return 'always-on'; + if (caps.includes('thinking') || model.adaptiveThinking === true) return 'toggle'; + return 'unsupported'; +} + +export function effortsOf(model: ModelAlias): readonly string[] { + return model.supportEfforts ?? []; +} + +/** + * Ordered list of selectable thinking efforts for a model. Effort-capable models + * expose their declared efforts (with an 'off' entry when the model is not + * always-on); legacy boolean models expose 'on'/'off'; single-segment lists + * mean the control is effectively locked. + */ +export function segmentsFor(model: ModelAlias): readonly string[] { + const efforts = effortsOf(model); + const availability = thinkingAvailability(model); + if (efforts.length > 0) { + return availability === 'always-on' ? efforts : ['off', ...efforts]; + } + if (availability === 'always-on') return ['on']; + if (availability === 'unsupported') return ['off']; + return ['on', 'off']; +} + +export function effortLabel(effort: string): string { + if (effort.length === 0) return effort; + return effort.charAt(0).toUpperCase() + effort.slice(1); +} + +/** + * Default thinking effort for a model: declared `default_effort`, else the + * middle `support_efforts` entry, else `'on'` for boolean models, `'off'` when + * thinking is unsupported. + */ +export function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { + if (thinkingAvailability(model) === 'unsupported') return 'off'; + const efforts = effortsOf(model); + if (efforts.length > 0) { + return model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!; + } + return 'on'; +} + +/** + * Normalize a draft effort before committing a selection. A boolean `'on'` + * never leaks past the UI boundary — it becomes the model's default effort + * (a concrete effort for effort-capable models, `'on'` only for genuine + * boolean models). + */ +function commitEffort(choice: ModelChoice, draft: ThinkingEffort): ThinkingEffort { + if (draft === 'on') return defaultThinkingEffortFor(choice.model); + return draft; +} + +/** + * Flat, searchable single-list model picker. + * + * One navigation axis: ↑/↓ move the cursor (PgUp/PgDn page), typing fuzzy-filters + * across every provider (provider name included), and ←/→ toggle the thinking + * draft for models that support it. There are no provider tabs — filtering by + * typing a provider name replaces them. See .agents/skills/write-tui/DESIGN.md. + */ +export class ModelSelectorComponent extends Container implements Focusable { + focused = false; + private readonly opts: ModelSelectorOptions; + private readonly list: SearchableList<ModelChoice>; + /** Per-model thinking-effort override set by ←/→; absent → the default. */ + private readonly thinkingOverrides = new Map<string, string>(); + + constructor(opts: ModelSelectorOptions) { + super(); + this.opts = opts; + const choices = createModelChoices(opts.models); + const selectedValue = opts.selectedValue ?? opts.currentValue; + const selectedIdx = choices.findIndex((choice) => choice.alias === selectedValue); + this.list = new SearchableList({ + items: choices, + toSearchText: (choice) => choice.label, + pageSize: opts.pageSize, + initialIndex: Math.max(selectedIdx, 0), + searchable: opts.searchable === true, + }); + } + + /** + * Thinking effort for a model: an explicit ←/→ override when set, otherwise + * the live effort for the active model, otherwise the model's default effort + * (effort-capable) or 'on' (other thinking-capable models). + */ + private draftFor(choice: ModelChoice): string { + const override = this.thinkingOverrides.get(choice.alias); + if (override !== undefined) return override; + if (choice.alias === this.opts.currentValue) return this.opts.currentThinkingEffort; + const efforts = effortsOf(choice.model); + if (efforts.length > 0) { + // A model with support_efforts but no default_effort defaults to the + // middle entry of its supported efforts. + const def = choice.model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]; + if (def !== undefined && efforts.includes(def)) return def; + return efforts[0]!; + } + return thinkingAvailability(choice.model) !== 'unsupported' ? 'on' : 'off'; + } + + /** Draft coerced onto the model's segment list so rendering/selection never + * reference a effort the model cannot actually select. */ + private effectiveEffort(choice: ModelChoice): string { + const draft = this.draftFor(choice); + const segments = segmentsFor(choice.model); + return segments.includes(draft) ? draft : segments[0]!; + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + if (this.list.clearQuery()) return; + this.opts.onCancel(); + return; + } + + // ↑/↓, PgUp/PgDn, and — when searchable — typing + Backspace. + if (this.list.handleKey(data)) { + return; + } + + // Left/Right move the active thinking effort within the model's segments. + if ( + this.opts.thinkingControl !== false && + (matchesKey(data, Key.left) || matchesKey(data, Key.right)) + ) { + const selected = this.selectedChoice(); + if (selected !== undefined) { + const segments = segmentsFor(selected.model); + if (segments.length > 1) { + const current = this.effectiveEffort(selected); + const idx = segments.indexOf(current); + // The two-segment case is the legacy boolean On/Off control: both + // arrows flip it. With more segments (efforts), ←/→ step. + let next: number; + if (segments.length === 2) { + next = idx === 0 ? 1 : 0; + } else { + const delta = matchesKey(data, Key.left) ? -1 : 1; + next = Math.max(0, Math.min(segments.length - 1, idx + delta)); + } + if (next !== idx) { + this.thinkingOverrides.set(selected.alias, segments[next]!); + } + } + } + return; + } + + if (matchesKey(data, Key.enter)) { + const selected = this.selectedChoice(); + if (selected === undefined) return; + this.opts.onSelect({ + alias: selected.alias, + thinking: commitEffort(selected, this.effectiveEffort(selected)), + }); + return; + } + + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + const selected = this.selectedChoice(); + if (selected === undefined) return; + this.opts.onSessionOnlySelect({ + alias: selected.alias, + thinking: commitEffort(selected, this.effectiveEffort(selected)), + }); + } + } + + override render(width: number): string[] { + const searchable = this.opts.searchable === true; + const view = this.list.view(); + const totalCount = Object.keys(this.opts.models).length; + + const titleSuffix = + searchable && view.query.length === 0 + ? currentTheme.fg('textMuted', ' (type to search)') + : ''; + + // "type to search" already lives in the title suffix, so the hint only + // surfaces the backspace shortcut once a query is active. + const hintParts: string[] = []; + if (this.opts.providerSwitchHint) hintParts.push('Tab toggle provider'); + hintParts.push('↑↓ navigate'); + if (searchable && view.query.length > 0) hintParts.push('Backspace clear'); + hintParts.push('Enter select'); + if (this.opts.onSessionOnlySelect !== undefined) hintParts.push('Alt+S session-only'); + hintParts.push('Esc cancel'); + + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', this.opts.title ?? ' Select a model') + titleSuffix, + currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), + ]; + if (this.opts.warning !== undefined) { + for (const line of wrapTextWithAnsi(this.opts.warning, Math.max(1, width - 1))) { + lines.push(currentTheme.fg('warning', ` ${line}`)); + } + } + lines.push(''); + + if (searchable && view.query.length > 0) { + lines.push(currentTheme.fg('primary', ' Search: ') + currentTheme.fg('text', view.query)); + } + + if (view.items.length === 0) { + lines.push(currentTheme.fg('textMuted', ' No matches')); + } else { + // Column width for model names so the provider column lines up. Capped so + // the provider + "← current" marker still fit on normal terminal widths. + const nameCap = Math.max(8, Math.floor(width * 0.5)); + let nameWidth = 0; + for (let i = view.page.start; i < view.page.end; i++) { + const choice = view.items[i]; + if (choice !== undefined) nameWidth = Math.max(nameWidth, visibleWidth(choice.name)); + } + nameWidth = Math.min(nameWidth, nameCap); + + for (let i = view.page.start; i < view.page.end; i++) { + const choice = view.items[i]; + if (choice === undefined) continue; + const isSelected = i === view.selectedIndex; + const isCurrent = choice.alias === this.opts.currentValue; + const pointer = isSelected ? SELECT_POINTER : ' '; + const truncatedName = truncateToWidth(choice.name, nameWidth, '…'); + const namePad = ' '.repeat(Math.max(0, nameWidth - visibleWidth(truncatedName))); + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); + line += (isSelected ? currentTheme.boldFg('primary', truncatedName) : currentTheme.fg('text', truncatedName)) + namePad; + line += ' ' + currentTheme.fg('textMuted', choice.provider); + if (isCurrent) { + line += ' ' + currentTheme.fg('success', CURRENT_MARK); + } + lines.push(line); + } + } + + // Scroll / match indicator. + if (view.query.length > 0) { + lines.push(''); + lines.push( + currentTheme.fg('textMuted', ` ${String(view.items.length)} / ${String(totalCount)}`), + ); + } else { + const below = view.items.length - view.page.end; + if (below > 0) { + lines.push(''); + lines.push(currentTheme.fg('textMuted', ` ▼ ${String(below)} more`)); + } + } + + lines.push(''); + const selected = this.selectedChoice(); + if (selected !== undefined && this.opts.thinkingControl !== false) { + const canSwitch = segmentsFor(selected.model).length > 1; + const thinkingHeader = canSwitch ? ' Thinking (←→ to switch)' : ' Thinking'; + lines.push(currentTheme.fg('textMuted', thinkingHeader)); + lines.push(this.renderThinkingControl(selected)); + lines.push(''); + } + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } + + private selectedChoice(): ModelChoice | undefined { + return this.list.selected(); + } + + private renderThinkingControl(choice: ModelChoice): string { + const segment = (label: string, active: boolean): string => + active + ? currentTheme.boldFg('primary', `[ ${label} ]`) + : currentTheme.fg('text', ` ${label} `); + // The whole segment is muted, suffix included, so the disabled side reads + // as a single greyed-out control rather than a selectable option. + const unavailable = (label: string): string => + currentTheme.fg('textMuted', ` ${label} (Unsupported) `); + + // Non-effort always-on / unsupported models keep the original On/Off layout + // so the control never shifts while moving across legacy models. + const efforts = effortsOf(choice.model); + const availability = thinkingAvailability(choice.model); + if (efforts.length === 0 && availability === 'always-on') { + return ` ${segment('On', true)} ${unavailable('Off')}`; + } + if (efforts.length === 0 && availability === 'unsupported') { + return ` ${unavailable('On')} ${segment('Off', true)}`; + } + + const segments = segmentsFor(choice.model); + const active = this.effectiveEffort(choice); + const rendered = segments.map((effort) => segment(effortLabel(effort), effort === active)); + return ` ${rendered.join(' ')}`; + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ffbec57569b250b9354bddf0b6c852fa49e8c97 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts @@ -0,0 +1,49 @@ +import type { PermissionMode } from '@moonshot-ai/kimi-code-sdk'; + +import { PERMISSION_MODE_DESCRIPTIONS, PERMISSION_MODE_DISPLAY_NAMES } from '#/tui/utils/permission-mode'; + +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +const PERMISSION_OPTIONS: readonly ChoiceOption[] = [ + { + value: 'manual', + label: PERMISSION_MODE_DISPLAY_NAMES.manual, + description: PERMISSION_MODE_DESCRIPTIONS.manual, + }, + { + value: 'yolo', + label: PERMISSION_MODE_DISPLAY_NAMES.yolo, + description: PERMISSION_MODE_DESCRIPTIONS.yolo, + }, + { + value: 'auto', + label: PERMISSION_MODE_DISPLAY_NAMES.auto, + description: PERMISSION_MODE_DESCRIPTIONS.auto, + }, +]; + +function isPermissionModeChoice(value: string): value is PermissionMode { + return value === 'manual' || value === 'auto' || value === 'yolo'; +} + +export interface PermissionSelectorOptions { + readonly currentValue: PermissionMode; + readonly initialValue?: PermissionMode; + readonly onSelect: (mode: PermissionMode) => void; + readonly onCancel: () => void; +} + +export class PermissionSelectorComponent extends ChoicePickerComponent { + constructor(opts: PermissionSelectorOptions) { + super({ + title: 'Select permission mode', + options: [...PERMISSION_OPTIONS], + currentValue: opts.currentValue, + initialValue: opts.initialValue, + onSelect: (value) => { + if (isPermissionModeChoice(value)) opts.onSelect(value); + }, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..89a51d6c666a3336d18d8bdba94182970ca5c501 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts @@ -0,0 +1,38 @@ +import { OPEN_PLATFORMS } from '@moonshot-ai/kimi-code-oauth'; + +import { KIMI_CODE_GLOBAL_PLATFORM_VALUE } from '#/utils/region'; + +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +const KIMI_CODE_MAINLAND_CN_OPTION: ChoiceOption = { + value: 'kimi-code', + label: 'Kimi Code (kimi.com/code)', +}; +const KIMI_CODE_GLOBAL_OPTION: ChoiceOption = { + value: KIMI_CODE_GLOBAL_PLATFORM_VALUE, + label: 'Kimi Code (kimi.ai/code)', +}; + +function platformOptions(): readonly ChoiceOption[] { + return [ + KIMI_CODE_MAINLAND_CN_OPTION, + KIMI_CODE_GLOBAL_OPTION, + ...OPEN_PLATFORMS.map((platform) => ({ value: platform.id, label: platform.name })), + ]; +} + +export interface PlatformSelectorOptions { + readonly onSelect: (platformId: string) => void; + readonly onCancel: () => void; +} + +export class PlatformSelectorComponent extends ChoicePickerComponent { + constructor(opts: PlatformSelectorOptions) { + super({ + title: 'Select a platform', + options: [...platformOptions()], + onSelect: opts.onSelect, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..237ab78ad11f897f4d32b14561942efc01036259 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -0,0 +1,968 @@ +import { + Container, + Input, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; +import type { + CapabilityStatus, + PluginInfo, + PluginMcpServerInfo, + PluginSummary, +} from '@moonshot-ai/kimi-code-sdk'; +import chalk from 'chalk'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; +import { formatPluginSourceLabel, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; +import { printableChar } from '#/tui/utils/printable-key'; +import { renderTabStrip } from '#/tui/utils/tab-strip'; +import { computeUpdateStatus, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; + +import { ChoicePickerComponent } from './choice-picker'; + +const MCP_SERVER_PREFIX = 'mcp:'; + +const REMOVE_CONFIRM_CANCEL = 'cancel'; +const REMOVE_CONFIRM_REMOVE = 'remove'; +const INSTALL_TRUST_EXIT = 'exit'; +const INSTALL_TRUST_TRUST = 'trust'; +const ELLIPSIS = '…'; + +// Hardcoded Web Bridge promotion: a built-in fallback shown only while the +// marketplace catalog is loading, unreachable, or predates the real +// `kimi-webbridge` entry. Selecting it opens the install page in the browser; +// once the catalog carries the real entry, that row wins and installs +// normally. +const WEB_BRIDGE_URL = 'https://www.kimi.com/features/webbridge#local-agent'; +const WEB_BRIDGE_ENTRY: PluginMarketplaceEntry = { + id: 'kimi-webbridge', + displayName: 'Kimi Browser Extension', + source: WEB_BRIDGE_URL, + tier: 'official', + homepage: WEB_BRIDGE_URL, + description: 'Control your real browser from Kimi Code — navigate, click, type, and screenshot', +}; + +// Only the hardcoded pinned row should open the WebBridge install page. Match +// by reference (not id) so a catalog entry on another tab that happens to +// reuse the same id still installs normally instead of being hijacked. +function isPinnedWebBridgeEntry(entry: PluginMarketplaceEntry): boolean { + return entry === WEB_BRIDGE_ENTRY; +} + +interface PluginsOverviewItem { + readonly value: string; + readonly kind: 'plugin' | 'action'; + readonly label: string; + readonly status?: string; + readonly description: string; +} + +export type PluginMcpSelection = + | { readonly kind: 'toggle'; readonly pluginId: string; readonly server: string; readonly enabled: boolean } + | { readonly kind: 'back'; readonly pluginId: string }; + +export interface PluginMcpSelectorOptions { + readonly info: PluginInfo; + readonly selectedServer?: string; + readonly serverHint?: { + readonly server: string; + readonly text: string; + }; + readonly onSelect: (selection: PluginMcpSelection) => void; + readonly onCancel: () => void; +} + +export class PluginMcpSelectorComponent extends Container implements Focusable { + focused = false; + + private readonly opts: PluginMcpSelectorOptions; + private readonly items: readonly PluginsOverviewItem[]; + private selectedIndex = 0; + + constructor(opts: PluginMcpSelectorOptions) { + super(); + this.opts = opts; + this.items = buildMcpItems(opts.info); + const selectedIndex = this.items.findIndex( + (item) => item.value === `${MCP_SERVER_PREFIX}${opts.selectedServer}`, + ); + this.selectedIndex = Math.max(0, selectedIndex); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.space) || printableChar(data) === ' ') { + const chosen = this.items[this.selectedIndex]; + if (chosen === undefined) return; + if (chosen.value === 'back') { + this.opts.onSelect({ kind: 'back', pluginId: this.opts.info.id }); + return; + } + const serverName = mcpItemServerName(chosen); + if (serverName === undefined) return; + const server = this.opts.info.mcpServers.find((item) => item.name === serverName); + if (server === undefined) return; + this.opts.onSelect({ + kind: 'toggle', + pluginId: this.opts.info.id, + server: server.name, + enabled: !server.enabled, + }); + } + } + + override render(width: number): string[] { + const { info } = this.opts; + const colors = currentTheme.palette; + const serverItems = this.items.filter((item) => item.kind === 'plugin'); + const actionItems = this.items.filter((item) => item.kind === 'action'); + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(` MCP servers · ${info.displayName}`), + mutedHintLine(' ↑↓ navigate · Enter/Space enable/disable · Esc cancel', colors), + '', + sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`, colors), + ]; + + if (serverItems.length === 0) { + lines.push(chalk.hex(colors.textMuted)(' No MCP servers declared.')); + } else { + for (let i = 0; i < serverItems.length; i++) { + lines.push(...this.renderItem(serverItems[i]!, i, width)); + } + } + + lines.push(''); + lines.push(sectionLabel('Actions', colors)); + for (let i = 0; i < actionItems.length; i++) { + lines.push(...this.renderItem(actionItems[i]!, serverItems.length + i, width)); + } + + lines.push(''); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } + + private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { + const colors = currentTheme.palette; + const selected = index === this.selectedIndex; + const pointer = selected ? SELECT_POINTER : ' '; + const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + let line = prefix + labelStyle(item.label); + if (item.status !== undefined) { + line += ' ' + statusStyle(item, colors)(item.status); + } + const serverName = mcpItemServerName(item); + if (serverName !== undefined && this.opts.serverHint?.server === serverName) { + line += ' ' + chalk.hex(colors.warning)(this.opts.serverHint.text); + } + const descriptionWidth = Math.max(1, width - 4); + const lines = [line]; + for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { + lines.push(mutedHintLine(` ${descLine}`, colors)); + } + return lines; + } +} + +export type PluginRemoveConfirmResult = + | { readonly kind: 'confirm' } + | { readonly kind: 'cancel' }; + +export interface PluginRemoveConfirmOptions { + readonly id: string; + readonly displayName: string; + readonly onDone: (result: PluginRemoveConfirmResult) => void; +} + +export class PluginRemoveConfirmComponent extends ChoicePickerComponent { + constructor(opts: PluginRemoveConfirmOptions) { + super({ + title: `Remove ${opts.displayName} (${opts.id})?`, + hint: '↑↓ navigate · Enter/Space select · ←/Esc cancel', + formatHint: mutedHintLine, + options: [ + { + value: REMOVE_CONFIRM_CANCEL, + label: 'Cancel', + description: 'Keep this plugin installed.', + }, + { + value: REMOVE_CONFIRM_REMOVE, + label: 'Remove plugin', + tone: 'danger', + description: 'Remove only the install record; plugin files are left in place.', + }, + ], + onSelect: (value) => { + opts.onDone(value === REMOVE_CONFIRM_REMOVE ? { kind: 'confirm' } : { kind: 'cancel' }); + }, + onCancel: () => { + opts.onDone({ kind: 'cancel' }); + }, + }); + } +} + +export type PluginInstallTrustConfirmResult = + | { readonly kind: 'confirm' } + | { readonly kind: 'cancel' }; + +export interface PluginInstallTrustConfirmOptions { + /** Plugin display name or source, shown in the title for identification. */ + readonly label: string; + readonly onDone: (result: PluginInstallTrustConfirmResult) => void; +} + +/** + * Confirmation shown before installing a third-party (unofficial) plugin. + * Defaults to "Exit" so the user must explicitly switch to "Trust and install" + * to proceed with a plugin that Kimi has not reviewed. + */ +export class PluginInstallTrustConfirmComponent extends ChoicePickerComponent { + constructor(opts: PluginInstallTrustConfirmOptions) { + super({ + title: `Install third-party plugin ${opts.label}?`, + hint: '↑↓ navigate · Enter/Space select · ←/Esc cancel', + formatHint: mutedHintLine, + notice: + '⚠️ This is a third-party plugin that Kimi has not reviewed. It can bundle MCP servers, ' + + 'skills, or files that run code and access your workspace. Install it only if you ' + + 'trust the source.', + noticeTone: 'warning', + options: [ + { + value: INSTALL_TRUST_EXIT, + label: 'Exit', + description: 'Cancel the installation.', + }, + { + value: INSTALL_TRUST_TRUST, + label: 'Trust and install', + tone: 'danger', + description: 'Install this third-party plugin anyway.', + }, + ], + onSelect: (value) => { + opts.onDone(value === INSTALL_TRUST_TRUST ? { kind: 'confirm' } : { kind: 'cancel' }); + }, + onCancel: () => { + opts.onDone({ kind: 'cancel' }); + }, + }); + } +} + +function overviewPluginDescription(plugin: PluginSummary): string { + const state = plugin.state === 'ok' ? '' : ` · state ${plugin.state}`; + const skills = `${plugin.skillCount} skill${plugin.skillCount === 1 ? '' : 's'}`; + const mcp = + plugin.mcpServerCount > 0 + ? ` · MCP ${plugin.enabledMcpServerCount}/${plugin.mcpServerCount}` + : ''; + const diagnostics = plugin.hasErrors ? ' · diagnostics available' : ''; + const source = ` · ${formatPluginSourceLabel(plugin)}`; + const trust = ` · ${pluginTrustLabel(plugin)}`; + return `id ${plugin.id} · ${skills}${mcp}${source}${trust}${state}${diagnostics}`; +} + +function pluginStatus(plugin: PluginSummary): string | undefined { + if (plugin.state !== 'ok') return plugin.state; + return plugin.enabled ? 'enabled' : 'disabled'; +} + +function marketplaceStatusStyle(status: string, colors: ColorPalette): (text: string) => string { + // States recede, actions pop: "installed …" is a quiet fact (dim), while + // "install …" (the available action) stays primary and "update …" stays a + // warning — the two used to share near-identical green-ish treatments in + // the same column and read as interchangeable. + if (status.startsWith('update')) return chalk.hex(colors.warning); + if (status.startsWith('installed')) return chalk.hex(colors.textDim); + return chalk.hex(colors.primary); +} + +/** Rounded single-line URL input box (DESIGN §9), shared by the marketplace + * Custom tab and the unified plugins panel. */ +function renderUrlInputBox( + input: Input, + focused: boolean, + width: number, + colors: ColorPalette, +): string[] { + input.focused = focused; + const border = (s: string): string => chalk.hex(colors.primary)(s); + const boxWidth = Math.max(24, width - 2); + const innerWidth = Math.max(10, boxWidth - 4); + const inputLine = input.render(innerWidth)[0] ?? ''; + const rightPad = Math.max(0, innerWidth - visibleWidth(inputLine)); + return [ + ' ' + border('╭' + '─'.repeat(boxWidth - 2) + '╮'), + ' ' + border('│') + ' ' + inputLine + ' '.repeat(rightPad) + border('│'), + ' ' + border('╰' + '─'.repeat(boxWidth - 2) + '╯'), + ]; +} + +// =========================================================================== +// Unified /plugins panel: Installed / Official / Curated / Custom tabs. +// =========================================================================== + +export type PluginsPanelTabId = 'installed' | 'official' | 'third-party' | 'custom'; + +export type PluginsPanelSelection = + | { readonly kind: 'toggle'; readonly id: string; readonly enabled: boolean } + | { readonly kind: 'remove'; readonly id: string } + | { readonly kind: 'mcp'; readonly id: string } + | { readonly kind: 'details'; readonly id: string } + | { readonly kind: 'reload' } + | { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry } + | { readonly kind: 'install-source'; readonly source: string } + | { readonly kind: 'open-url'; readonly url: string; readonly label: string }; + +export interface PluginsPanelOptions { + readonly installed: readonly PluginSummary[]; + readonly installedIds: ReadonlySet<string>; + readonly capabilities?: readonly CapabilityStatus[]; + /** + * False when the marketplace was explicitly replaced (slash-command + * source or env override): built-in rows then stay out of the Official + * tab entirely. Undefined means the default catalog. + */ + readonly catalogIsDefault?: boolean; + readonly initialTab?: PluginsPanelTabId; + readonly selectedId?: string; + readonly pluginHint?: { readonly id: string; readonly text: string }; + readonly onSelect: (selection: PluginsPanelSelection) => void; + readonly onCancel: () => void; + /** Called the first time the Official or Curated tab needs its catalog. + * The host fetches the marketplace and calls setMarketplace / setMarketplaceError. */ + readonly onRequestMarketplace?: () => void; +} + +type MarketState = + | { readonly status: 'idle' } + | { readonly status: 'loading' } + | { readonly status: 'error'; readonly message: string } + | { readonly status: 'loaded'; readonly entries: readonly PluginMarketplaceEntry[]; readonly source: string }; + +const PLUGINS_PANEL_TABS: readonly { id: PluginsPanelTabId; label: string }[] = [ + { id: 'installed', label: 'Installed' }, + { id: 'official', label: 'Official' }, + { id: 'third-party', label: 'Curated' }, + { id: 'custom', label: 'Custom' }, +]; + +export class PluginsPanelComponent extends Container implements Focusable { + focused = false; + + private readonly opts: PluginsPanelOptions; + private readonly customInput = new Input(); + private activeTabIndex: number; + private selectedIndex = 0; + private market: MarketState = { status: 'idle' }; + private installing: string | undefined; + + constructor(opts: PluginsPanelOptions) { + super(); + this.opts = opts; + this.activeTabIndex = Math.max( + 0, + PLUGINS_PANEL_TABS.findIndex((tab) => tab.id === (opts.initialTab ?? 'installed')), + ); + if (opts.selectedId !== undefined && this.activeTab.id === 'installed') { + const idx = opts.installed.findIndex((p) => p.id === opts.selectedId); + if (idx >= 0) this.selectedIndex = idx; + } + this.customInput.onSubmit = (value) => { + const source = value.trim(); + if (source.length > 0) this.opts.onSelect({ kind: 'install-source', source }); + }; + } + + marketplaceStatus(): MarketState['status'] { + return this.market.status; + } + + setMarketplaceLoading(): void { + this.market = { status: 'loading' }; + } + + setMarketplace(entries: readonly PluginMarketplaceEntry[], source: string): void { + this.market = { status: 'loaded', entries, source }; + } + + setMarketplaceError(message: string): void { + this.market = { status: 'error', message }; + } + + setInstalling(label: string): void { + this.installing = label; + this.invalidate(); + } + + clearInstalling(): void { + this.installing = undefined; + this.invalidate(); + } + + private get activeTab(): (typeof PLUGINS_PANEL_TABS)[number] { + return PLUGINS_PANEL_TABS[this.activeTabIndex]!; + } + + private get marketplaceEntries(): readonly PluginMarketplaceEntry[] { + if (this.market.status !== 'loaded') return []; + return this.market.entries.toSorted( + (a, b) => + Number(this.isMarketplaceEntryInstalled(b)) - + Number(this.isMarketplaceEntryInstalled(a)), + ); + } + + private get installedVersions(): ReadonlyMap<string, string | undefined> { + return new Map(this.opts.installed.map((plugin) => [plugin.id, plugin.version])); + } + + private capabilityFor(id: string): CapabilityStatus | undefined { + return this.opts.capabilities?.find((capability) => capability.id === id); + } + + /** Capability state for a MARKETPLACE row: only our own injected rows + * (flagged `builtIn` — a custom catalog cannot forge the flag) may show + * capability status, matching how Enter routes them. */ + private capabilityForEntry(entry: PluginMarketplaceEntry): CapabilityStatus | undefined { + return entry.builtIn === true ? this.capabilityFor(entry.id) : undefined; + } + + private installedPluginId(entry: PluginMarketplaceEntry): string { + return this.capabilityForEntry(entry)?.pluginId ?? entry.id; + } + + private isMarketplaceEntryInstalled(entry: PluginMarketplaceEntry): boolean { + return this.opts.installedIds.has(this.installedPluginId(entry)); + } + + private get officialEntries(): readonly PluginMarketplaceEntry[] { + // While the catalog is loading or unreachable, the locally-known + // capability rows still render and install — built-in runtime setup + // must never be blocked by an unrelated catalog fetch. + if (this.market.status !== 'loaded') { + return this.pendingBuiltInEntries.some((entry) => entry.id === WEB_BRIDGE_ENTRY.id) + ? this.pendingBuiltInEntries + : [...this.pendingBuiltInEntries, WEB_BRIDGE_ENTRY]; + } + // The real catalog entry wins when present (it installs the actual + // plugin); the hardcoded promo row is only a fallback while the catalog + // is loading, unreachable, or predates it — never a duplicate row. + return this.officialCatalogEntries.some((entry) => entry.id === WEB_BRIDGE_ENTRY.id) + ? this.officialCatalogEntries + : [WEB_BRIDGE_ENTRY, ...this.officialCatalogEntries]; + } + + /** Capability rows synthesized from the engine's registry, independent of + * the marketplace state; unsupported platforms hide them entirely. Only + * the default catalog gets built-in rows — an explicitly overridden + * marketplace must be able to fully replace the Official tab. */ + private get pendingBuiltInEntries(): readonly PluginMarketplaceEntry[] { + if (this.opts.catalogIsDefault === false) return []; + return (this.opts.capabilities ?? []) + .filter((capability) => capability.supported) + .map(capabilityMarketplaceEntry); + } + + private get officialCatalogEntries(): readonly PluginMarketplaceEntry[] { + return this.marketplaceEntries.filter((entry) => { + if (entry.tier !== 'official') return false; + return this.capabilityForEntry(entry)?.supported !== false; + }); + } + + private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] { + // Anything not explicitly marked official lands here: `curated` entries plus + // entries that omit `tier` (custom marketplaces often do). Without this, + // untiered entries would be invisible in both marketplace tabs. + return this.marketplaceEntries.filter((entry) => entry.tier !== 'official'); + } + + private requestMarketplaceIfNeeded(): void { + // The Installed tab also needs the catalog to render update badges; only the + // Custom tab (manual URL entry) can skip the fetch entirely. + if (this.market.status === 'idle' && this.activeTab.id !== 'custom') { + this.market = { status: 'loading' }; + this.opts.onRequestMarketplace?.(); + } + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.tab)) { + this.activeTabIndex = (this.activeTabIndex + 1) % PLUGINS_PANEL_TABS.length; + this.selectedIndex = 0; + this.requestMarketplaceIfNeeded(); + return; + } + if (matchesKey(data, Key.shift('tab'))) { + this.activeTabIndex = + (this.activeTabIndex - 1 + PLUGINS_PANEL_TABS.length) % PLUGINS_PANEL_TABS.length; + this.selectedIndex = 0; + this.requestMarketplaceIfNeeded(); + return; + } + switch (this.activeTab.id) { + case 'installed': + this.handleInstalledInput(data); + return; + case 'official': + case 'third-party': + this.handleMarketplaceInput(data); + return; + case 'custom': + this.customInput.handleInput(data); + return; + } + } + + private handleInstalledInput(data: string): void { + const plugins = this.opts.installed; + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(plugins.length - 1, this.selectedIndex + 1); + return; + } + const plugin = plugins[this.selectedIndex]; + const ch = printableChar(data); + // Decode Space for terminals that send printable keys via Kitty/CSI-u + // sequences (e.g. VS Code's integrated terminal); `matchesKey(Key.space)` + // alone misses those and the toggle silently stops working. + if (matchesKey(data, Key.space) || ch === ' ') { + if (plugin !== undefined) { + this.opts.onSelect({ kind: 'toggle', id: plugin.id, enabled: !plugin.enabled }); + } + return; + } + if (ch === 'd' || ch === 'D') { + if (plugin !== undefined) this.opts.onSelect({ kind: 'remove', id: plugin.id }); + return; + } + if (ch === 'm' || ch === 'M') { + if (plugin !== undefined) this.opts.onSelect({ kind: 'mcp', id: plugin.id }); + return; + } + if (ch === 'r' || ch === 'R') { + this.opts.onSelect({ kind: 'reload' }); + return; + } + if (matchesKey(data, Key.enter)) { + if (plugin === undefined) return; + const update = this.installedUpdateStatus(plugin); + if (update !== undefined) { + this.opts.onSelect({ kind: 'install', entry: update.entry }); + } else { + this.opts.onSelect({ kind: 'details', id: plugin.id }); + } + return; + } + if (ch === 'i' || ch === 'I') { + if (plugin !== undefined) this.opts.onSelect({ kind: 'details', id: plugin.id }); + } + } + + private handleMarketplaceInput(data: string): void { + const entries = this.activeTab.id === 'official' ? this.officialEntries : this.thirdPartyEntries; + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + // Clamp to 0 while the catalog is still loading (entries empty); otherwise + // `entries.length - 1` is -1 and a later Enter reads `entries[-1]`. + this.selectedIndex = entries.length === 0 ? 0 : Math.min(entries.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.enter)) { + const entry = entries[this.selectedIndex]; + if (entry === undefined) return; + if (isPinnedWebBridgeEntry(entry)) { + this.opts.onSelect({ kind: 'open-url', url: WEB_BRIDGE_URL, label: entry.displayName }); + return; + } + this.opts.onSelect({ kind: 'install', entry }); + } + } + + override invalidate(): void { + super.invalidate(); + this.customInput.invalidate(); + } + + override render(width: number): string[] { + if (this.installing !== undefined) { + return this.renderInstalling(width); + } + const colors = currentTheme.palette; + const tab = this.activeTab.id; + const hint = + tab === 'installed' + ? this.installedHint() + : tab === 'custom' + ? ' Tab switch · Enter install · Esc cancel' + : ' Tab switch · ↑↓ navigate · Enter open/install · Esc cancel'; + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Plugins'), + mutedHintLine(hint, colors), + '', + renderTabStrip({ + labels: PLUGINS_PANEL_TABS.map((t) => t.label), + activeIndex: this.activeTabIndex, + width, + colors, + }), + '', + ]; + + if (tab === 'installed') this.renderInstalled(lines, width); + else if (tab === 'official') this.renderOfficial(lines, width); + else if (tab === 'third-party') this.renderThirdParty(lines, width); + else this.renderCustom(lines, width); + + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } + + private renderInstalled(lines: string[], width: number): void { + const { installed } = this.opts; + const colors = currentTheme.palette; + if (installed.length === 0) { + lines.push(chalk.hex(colors.textMuted)(' No plugins installed.')); + } else { + for (let i = 0; i < installed.length; i++) { + lines.push(...this.renderInstalledRow(installed[i]!, i, width)); + } + } + lines.push(''); + lines.push(mutedHintLine(` ${installed.length} installed`, colors)); + } + + private installedHint(): string { + const plugin = this.opts.installed[this.selectedIndex]; + const hasUpdate = plugin !== undefined && this.installedUpdateStatus(plugin) !== undefined; + const enter = hasUpdate ? 'Enter update' : 'Enter details'; + return ` Tab switch · Space toggle · D remove · M MCP · ${enter} · I details · R reload · Esc cancel`; + } + + private installedUpdateStatus( + plugin: PluginSummary, + ): { entry: PluginMarketplaceEntry; local: string; latest: string } | undefined { + if (this.market.status !== 'loaded') return undefined; + const entry = this.market.entries.find( + (candidate) => + candidate.id === plugin.id || + (candidate.builtIn === true && + this.capabilityForEntry(candidate)?.pluginId === plugin.id), + ); + if (entry === undefined) return undefined; + const status = computeUpdateStatus(entry.version, plugin.version, true); + return status.kind === 'update' ? { entry, local: status.local, latest: status.latest } : undefined; + } + + private renderInstalledRow(plugin: PluginSummary, index: number, width: number): string[] { + const colors = currentTheme.palette; + const selected = index === this.selectedIndex; + const pointer = selected ? SELECT_POINTER : ' '; + const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const status = pluginStatus(plugin); + const update = this.installedUpdateStatus(plugin); + let line = prefix + labelStyle(plugin.displayName); + if (status !== undefined) { + line += ' ' + statusStyle({ kind: 'plugin', value: '', label: '', description: '', status }, colors)(status); + } + if (update !== undefined) { + const badge = `update ${update.local} → ${update.latest}`; + line += ' ' + marketplaceStatusStyle(badge, colors)(badge); + } + if (this.opts.pluginHint?.id === plugin.id) { + line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text); + } + const descWidth = Math.max(1, width - 4); + const out = [line]; + for (const descLine of wrapOverviewDescription(overviewPluginDescription(plugin), descWidth)) { + out.push(mutedHintLine(` ${descLine}`, colors)); + } + return out; + } + + private renderMarketplaceTab( + lines: string[], + width: number, + entries: readonly PluginMarketplaceEntry[], + indexOffset = 0, + // Counts (installed/available footer) are computed over this list: + // the Official tab renders the pinned promo as a row but excludes it + // from the catalog counts, matching its pre-catalog semantics. + entriesForCount: readonly PluginMarketplaceEntry[] = entries, + ): void { + const colors = currentTheme.palette; + if (this.market.status === 'loading' || this.market.status === 'idle') { + lines.push(chalk.hex(colors.textMuted)(' Loading marketplace…')); + return; + } + if (this.market.status === 'error') { + lines.push(chalk.hex(colors.warning)(` Marketplace unavailable: ${this.market.message}`)); + lines.push(mutedHintLine(' Use the Custom tab to install from a URL.', colors)); + return; + } + if (entries.length === 0) { + lines.push(chalk.hex(colors.textMuted)(' No plugins found.')); + } else { + for (let i = 0; i < entries.length; i++) { + lines.push(...this.renderMarketplaceRow(entries[i]!, i + indexOffset, width)); + } + } + const installedCount = entriesForCount.filter((entry) => + this.isMarketplaceEntryInstalled(entry), + ).length; + lines.push(''); + lines.push( + mutedHintLine( + ` ${installedCount} installed · ${entriesForCount.length - installedCount} available`, + colors, + ), + ); + lines.push(mutedHintLine(` Source: ${this.market.source}`, colors)); + } + + private renderOfficial(lines: string[], width: number): void { + // Loading / error: `officialEntries` carries the locally-known + // capability rows (plus the promo fallback when webbridge is not among + // them), so built-in setup works before the catalog arrives. Once + // loaded, the promo appears only when the catalog lacks the real entry. + if (this.market.status !== 'loaded') { + const entries = this.officialEntries; + for (let i = 0; i < entries.length; i += 1) { + lines.push(...this.renderMarketplaceRow(entries[i]!, i, width)); + } + this.renderMarketplaceTab(lines, width, [], entries.length); + return; + } + this.renderMarketplaceTab(lines, width, this.officialEntries, 0, this.officialCatalogEntries); + } + + private renderThirdParty(lines: string[], width: number): void { + if (this.opts.catalogIsDefault !== false) { + const colors = currentTheme.palette; + lines.push(mutedHintLine(' Third-party plugins from our partners.', colors)); + lines.push(''); + } + this.renderMarketplaceTab(lines, width, this.thirdPartyEntries); + } + + private renderMarketplaceRow(entry: PluginMarketplaceEntry, index: number, width: number): string[] { + const colors = currentTheme.palette; + const selected = index === this.selectedIndex; + const pointer = selected ? SELECT_POINTER : ' '; + const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const capability = this.capabilityForEntry(entry); + const status = isPinnedWebBridgeEntry(entry) + ? 'open in browser' + : capability?.install.running === true + ? 'installing…' + : marketplaceEntryStatus( + entry, + this.installedVersions, + this.installedPluginId(entry), + ); + const line = + prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status); + const descWidth = Math.max(1, width - 4); + const out = [line]; + const description = + this.activeTab.id === 'official' + ? officialMarketplaceEntryDescription(entry) + : marketplaceEntryDescription(entry); + for (const descLine of wrapOverviewDescription(description, descWidth)) { + out.push(mutedHintLine(` ${descLine}`, colors)); + } + return out; + } + + private renderCustom(lines: string[], width: number): void { + const colors = currentTheme.palette; + lines.push(mutedHintLine(' Install from a GitHub URL (or zip URL / local path):', colors)); + lines.push(''); + lines.push(...renderUrlInputBox(this.customInput, this.focused, width, colors)); + } + + private renderInstalling(width: number): string[] { + const colors = currentTheme.palette; + const lines = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Plugins'), + '', + chalk.hex(colors.textMuted)(` Installing ${this.installing}…`), + '', + chalk.hex(colors.primary)('─'.repeat(width)), + ]; + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } +} + +function buildMcpItems(info: PluginInfo): PluginsOverviewItem[] { + const items: PluginsOverviewItem[] = info.mcpServers.map((server) => ({ + value: `${MCP_SERVER_PREFIX}${server.name}`, + kind: 'plugin', + label: server.name, + status: server.enabled ? 'enabled' : 'disabled', + description: mcpServerDescription(server), + })); + items.push({ + value: 'back', + kind: 'action', + label: 'Back to installed plugins', + description: 'Return to the local plugin manager.', + }); + return items; +} + +function mcpServerDescription(server: PluginMcpServerInfo): string { + const action = server.enabled ? 'Enter/Space disable' : 'Enter/Space enable'; + if (server.transport === 'http' || server.transport === 'sse') { + return `${action} · ${server.transport.toUpperCase()} · ${server.url ?? server.runtimeName}`; + } + const args = server.args !== undefined && server.args.length > 0 ? ` ${server.args.join(' ')}` : ''; + const command = `${server.command ?? ''}${args}`.trim(); + const cwd = server.cwd === undefined ? '' : ` · cwd ${server.cwd}`; + return `${action} · stdio · ${command || server.runtimeName}${cwd}`; +} + +function mcpItemServerName(item: PluginsOverviewItem): string | undefined { + if (!item.value.startsWith(MCP_SERVER_PREFIX)) return undefined; + return item.value.slice(MCP_SERVER_PREFIX.length); +} + +function marketplaceEntryDescription(entry: PluginMarketplaceEntry): string { + const tier = marketplaceTierLabel(entry.tier); + const description = entry.description ?? tier; + const version = entry.version !== undefined ? ` · v${entry.version}` : ''; + const keywords = + entry.keywords !== undefined && entry.keywords.length > 0 + ? ` · ${entry.keywords.join(', ')}` + : ''; + const tierSuffix = entry.description !== undefined ? ` · ${tier}` : ''; + return `${description} · id ${entry.id}${version}${tierSuffix}${keywords}`; +} + +function officialMarketplaceEntryDescription(entry: PluginMarketplaceEntry): string { + return entry.description ?? ''; +} + +function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string { + if (tier === 'official') return 'Official plugin'; + if (tier === 'curated') return 'Curated plugin'; + return 'Plugin'; +} + +function capabilityMarketplaceEntry(capability: CapabilityStatus): PluginMarketplaceEntry { + return { + id: capability.id, + displayName: capability.displayName, + source: `capability:${capability.id}`, + tier: 'official', + description: capability.description, + builtIn: true, + }; +} + +function installStatus(entry: PluginMarketplaceEntry): string { + return entry.version === undefined ? 'install' : `install v${entry.version}`; +} + +function marketplaceEntryStatus( + entry: PluginMarketplaceEntry, + installed: ReadonlyMap<string, string | undefined>, + installedPluginId = entry.id, +): string { + const status = computeUpdateStatus( + entry.version, + installed.get(installedPluginId), + installed.has(installedPluginId), + ); + switch (status.kind) { + case 'update': + return `update ${status.local} → ${status.latest}`; + case 'up-to-date': + return status.version === undefined ? 'installed' : `installed · v${status.version}`; + case 'not-installed': + return installStatus(entry); + } +} + +function sectionLabel(label: string, colors: ColorPalette): string { + return chalk.hex(colors.textDim).bold(` ${label}`); +} + +function statusStyle( + item: PluginsOverviewItem, + colors: ColorPalette, +): (text: string) => string { + if (item.kind === 'action') return chalk.hex(colors.textDim); + if (item.status === 'enabled' || item.status === 'installed') return chalk.hex(colors.success); + if (item.status?.startsWith('install')) return chalk.hex(colors.primary); + if (item.status === 'disabled') return chalk.hex(colors.textDim); + if (item.status !== undefined && /^\d/.test(item.status)) return chalk.hex(colors.textDim); + return chalk.hex(colors.warning); +} + +function mutedHintLine(text: string, colors?: ColorPalette): string { + if (colors !== undefined) { + return chalk.hex(colors.textMuted)(text); + } + return currentTheme.fg('textMuted', text); +} + +function wrapOverviewDescription(text: string, width: number): string[] { + const maxWidth = Math.max(1, width); + const words = text + .trim() + .split(/\s+/) + .filter((word) => word.length > 0); + const lines: string[] = []; + let current = ''; + + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (visibleWidth(candidate) <= maxWidth) { + current = candidate; + continue; + } + if (current.length > 0) lines.push(current); + current = visibleWidth(word) <= maxWidth ? word : truncateToWidth(word, maxWidth, ELLIPSIS); + } + + if (current.length > 0) lines.push(current); + return lines; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ae0da03d20d1c2814d94793cdd0cab7be887dff --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts @@ -0,0 +1,447 @@ +/** + * ProviderManagerComponent — pure-view CRUD UI for the `/provider` command. + * + * Single-column layout showing one row per "platform / source": + * - each Open Platform login (1 source = 1 provider) + * - each Custom Registry connection grouping by `{url, apiKey}` + * (1 source = N providers from the same api.json fetch) + * - any other configured provider (1 source = 1 provider) + * - a synthetic final `[ Add New Platform ]` action row + * Kimi Code OAuth (`DEFAULT_OAUTH_PROVIDER_NAME`) is intentionally hidden + * — that account is managed through `/login` / `/logout`, not here. + * + * Keyboard: + * - ↑ / ↓ move highlight + * - ← / → · PgUp/PgDn page + * - Enter on `[ Add New Platform ]` → `onAdd()` + * - D delete with inline `[y/N]` confirmation + * on a source row → `onDeleteSource(providerIds)` + * on `[ Add New Platform ]` → ignored + * - Esc `onClose()` (outside confirm) + * + * The `[y/N]` confirmation is a transient substate handled in-component: + * while armed, only `y` / `Y` / `n` / `N` / `Esc` are honored and the + * prompt replaces the footer hint. + * + * The component is pure-view: every CRUD side effect is dispatched back + * through callbacks. The host (`KimiTui`) is responsible for performing + * the harness / config mutations and then pushing a fresh snapshot via + * `setOptions`. + */ + +import type { ProviderConfig } from '@moonshot-ai/kimi-code-sdk'; +import { + getOpenPlatformById, + isOpenPlatformId, + type CustomRegistrySource, +} from '@moonshot-ai/kimi-code-oauth'; +import { + Container, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; +import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { printableChar } from '#/tui/utils/printable-key'; +import { pageView, type PageView } from '#/tui/utils/paging'; + +interface ConfirmState { + readonly label: string; + readonly providerIds: readonly string[]; +} + +export interface ProviderManagerOptions { + /** All currently configured providers (`config.providers`). */ + readonly providers: Record<string, ProviderConfig>; + /** Provider id of the currently active model. */ + readonly activeProviderId?: string; + readonly onAdd: () => void; + /** Delete all providers under a source (Open Platform / custom-registry + * fetch / standalone). Passed the full provider-id list so the host + * doesn't have to re-derive the source grouping. */ + readonly onDeleteSource: (providerIds: readonly string[]) => void; + readonly onClose: () => void; +} + +/** Real (non-synthetic) source row. */ +interface SourceRow { + readonly kind: 'source'; + readonly id: string; + readonly label: string; + readonly providerIds: readonly string[]; + /** True when one of `providerIds` is the active provider. */ + readonly hasActive: boolean; + /** Optional base URL extracted from the provider config. */ + readonly baseUrl?: string; +} + +/** Synthetic `[ Add New Platform ]` action row pinned to the bottom. */ +interface AddRow { + readonly kind: 'add'; + readonly id: '__add__'; + readonly label: string; +} + +type Row = SourceRow | AddRow; + +const ADD_ROW_LABEL = '[ Add New Platform ]'; +const PAGE_SIZE = 8; +const HEADER_HINT = '↑↓ navigate · D delete · Esc cancel'; + +// Narrows a `ProviderConfig` blob to a `CustomRegistrySource` payload. +// Mirrors `readCustomRegistrySource` in `kimi-tui.ts`. We can't import +// that helper because it lives in the host and would create a cyclic +// dependency on the component's container; duplicating ~15 lines is cheap. +function readCustomRegistrySource(provider: unknown): CustomRegistrySource | undefined { + if (typeof provider !== 'object' || provider === null) return undefined; + const source = (provider as { readonly source?: unknown }).source; + if (typeof source !== 'object' || source === null) return undefined; + const candidate = source as { + readonly kind?: unknown; + readonly url?: unknown; + readonly apiKey?: unknown; + }; + if (candidate.kind !== 'apiJson') return undefined; + if (typeof candidate.url !== 'string' || candidate.url.length === 0) return undefined; + if (typeof candidate.apiKey !== 'string') return undefined; + return { kind: 'apiJson', url: candidate.url, apiKey: candidate.apiKey }; +} + +/** + * Pretty-print a URL for the source-row label. Strips the scheme and + * truncates obvious api.json suffixes so the row stays narrow. Falls + * back to the raw URL if parsing fails. + */ +function sourceUrlLabel(url: string): string { + try { + const parsed = new URL(url); + return parsed.host + parsed.pathname.replace(/\/+$/, ''); + } catch { + return url; + } +} + +/** + * Group providers into source rows + append the synthetic add-row. + * The grouping rules: + * - `DEFAULT_OAUTH_PROVIDER_NAME` → skipped (managed via /logout). + * - Open Platform id (`isOpenPlatformId(id)`) → 1 source per provider, + * label = `OpenPlatformDefinition.name`. + * - `cfg.source.kind === 'apiJson'` → one source per `{url, apiKey}` + * pair, label = hostname + pathname. + * - Anything else → 1 source per provider, label = provider id. + */ +function buildRows(opts: ProviderManagerOptions): readonly Row[] { + const sources: SourceRow[] = []; + + // Map from `${url}${apiKey}` → index into `sources`, so we can + // append further providers into the same group. + const customRegistryIndex = new Map<string, number>(); + + for (const [id, cfg] of Object.entries(opts.providers)) { + if (id === DEFAULT_OAUTH_PROVIDER_NAME) continue; + + const isActive = id === opts.activeProviderId; + + if (isOpenPlatformId(id)) { + const platform = getOpenPlatformById(id); + sources.push({ + kind: 'source', + id: `open:${id}`, + label: platform?.name ?? id, + providerIds: [id], + hasActive: isActive, + }); + continue; + } + + const baseUrl = + typeof cfg === 'object' && cfg !== null && 'baseUrl' in cfg && typeof cfg.baseUrl === 'string' + ? cfg.baseUrl + : undefined; + + const customSource = readCustomRegistrySource(cfg); + if (customSource !== undefined) { + const key = `${customSource.url}${customSource.apiKey}`; + const existingIdx = customRegistryIndex.get(key); + if (existingIdx !== undefined) { + const existing = sources[existingIdx]; + if (existing !== undefined && existing.kind === 'source') { + sources[existingIdx] = { + kind: 'source', + id: existing.id, + label: existing.label, + providerIds: [...existing.providerIds, id], + hasActive: existing.hasActive || isActive, + baseUrl: existing.baseUrl, + }; + } + continue; + } + customRegistryIndex.set(key, sources.length); + sources.push({ + kind: 'source', + id: `custom:${key}`, + label: sourceUrlLabel(customSource.url), + providerIds: [id], + hasActive: isActive, + baseUrl, + }); + continue; + } + + sources.push({ + kind: 'source', + id: `provider:${id}`, + label: id, + providerIds: [id], + hasActive: isActive, + baseUrl, + }); + } + + return [...sources, { kind: 'add', id: '__add__', label: ADD_ROW_LABEL }]; +} + +export class ProviderManagerComponent extends Container implements Focusable { + focused = false; + private opts: ProviderManagerOptions; + private rows: readonly Row[]; + private selectedIndex: number; + private confirm: ConfirmState | undefined; + + constructor(opts: ProviderManagerOptions) { + super(); + this.opts = opts; + this.rows = buildRows(opts); + const activeIdx = opts.activeProviderId + ? this.rows.findIndex( + (row) => row.kind === 'source' && row.providerIds.includes(opts.activeProviderId ?? ''), + ) + : -1; + this.selectedIndex = Math.max(activeIdx, 0); + this.confirm = undefined; + } + + /** + * Replace the props the component renders against. Existing selection + * is preserved when possible (by id or first provider id) so deletions + * don't visually jump. Any in-flight `[y/N]` substate is cleared because + * the underlying target may have changed. + */ + setOptions(next: ProviderManagerOptions): void { + const previousSelected = this.rows[this.selectedIndex]; + const previousSelectedId = previousSelected?.id; + const previousFirstProviderId = + previousSelected?.kind === 'source' ? previousSelected.providerIds[0] : undefined; + + this.opts = next; + this.rows = buildRows(next); + this.confirm = undefined; + + let newIdx = -1; + if (previousSelectedId !== undefined) { + newIdx = this.rows.findIndex((row) => row.id === previousSelectedId); + } + if (newIdx < 0 && previousFirstProviderId !== undefined) { + newIdx = this.rows.findIndex( + (row) => row.kind === 'source' && row.providerIds.includes(previousFirstProviderId), + ); + } + if (newIdx < 0) { + newIdx = Math.min(this.selectedIndex, Math.max(0, this.rows.length - 1)); + } + this.selectedIndex = newIdx; + this.invalidate(); + } + + /** Rows after applying the active fuzzy filter; the add-row is always kept. */ + private page(): PageView { + return pageView(this.rows.length, this.selectedIndex, PAGE_SIZE); + } + + handleInput(data: string): void { + if (this.confirm !== undefined) { + this.handleConfirmInput(data); + return; + } + + if (matchesKey(data, Key.escape)) { + this.opts.onClose(); + return; + } + + const rows = this.rows; + + if (matchesKey(data, Key.up)) { + if (rows.length === 0) return; + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.invalidate(); + return; + } + if (matchesKey(data, Key.down)) { + if (rows.length === 0) return; + this.selectedIndex = Math.min(rows.length - 1, this.selectedIndex + 1); + this.invalidate(); + return; + } + + if (matchesKey(data, Key.left) || matchesKey(data, Key.pageUp)) { + if (rows.length === 0) return; + this.selectedIndex = Math.max(0, this.selectedIndex - PAGE_SIZE); + this.invalidate(); + return; + } + if (matchesKey(data, Key.right) || matchesKey(data, Key.pageDown)) { + if (rows.length === 0) return; + this.selectedIndex = Math.min(rows.length - 1, this.selectedIndex + PAGE_SIZE); + this.invalidate(); + return; + } + + if (matchesKey(data, Key.enter)) { + const selected = rows[this.selectedIndex]; + if (selected?.kind === 'add') { + this.opts.onAdd(); + } + return; + } + + // Delete the highlighted provider with the D key. + const ch = printableChar(data); + if (ch === 'd' || ch === 'D') { + this.armDeleteConfirm(); + } + } + + private armDeleteConfirm(): void { + const selected = this.rows[this.selectedIndex]; + if (selected === undefined || selected.kind === 'add') return; + const ids = selected.providerIds; + const prompt = + ids.length === 1 + ? `Delete platform "${selected.label}"?` + : `Delete platform "${selected.label}" and all ${String(ids.length)} providers?`; + this.confirm = { + label: prompt, + providerIds: ids, + }; + this.invalidate(); + } + + private handleConfirmInput(data: string): void { + const k = printableChar(data); + if (matchesKey(data, Key.escape) || k === 'n' || k === 'N') { + this.confirm = undefined; + this.invalidate(); + return; + } + if (k === 'y' || k === 'Y') { + const confirm = this.confirm; + this.confirm = undefined; + this.invalidate(); + if (confirm === undefined) return; + this.opts.onDeleteSource(confirm.providerIds); + return; + } + // Any other key while in the confirm substate is ignored. + } + + override render(width: number): string[] { + const lines: string[] = []; + + // Header shape mirrors the model dialog (see model-selector.ts): a single + // top border, the title, the keymap hint, then a blank line. No inner + // border under the title. + const border = currentTheme.fg('primary', '─'.repeat(width)); + lines.push(border); + lines.push(currentTheme.boldFg('primary', ' Providers')); + lines.push(currentTheme.fg('textMuted', ' ' + HEADER_HINT)); + lines.push(''); + + const rows = this.rows; + if (rows.length === 0) { + lines.push(currentTheme.fg('textMuted', ' No providers configured.')); + } else { + const view = this.page(); + for (let i = view.start; i < view.end; i++) { + const row = rows[i]; + if (row === undefined) continue; + for (const line of renderRow(row, { isSelected: i === this.selectedIndex, width })) { + lines.push(line); + } + } + } + + lines.push(''); + + if (this.confirm !== undefined) { + lines.push(this.renderConfirmLine(width)); + } else { + const view = this.page(); + if (view.pageCount > 1) { + lines.push( + currentTheme.fg( + 'textMuted', + ` Page ${String(view.page + 1)}/${String(view.pageCount)}`, + ), + ); + } + } + + lines.push(border); + return lines.map((line) => truncateToWidth(line, width)); + } + + private renderConfirmLine(width: number): string { + const confirm = this.confirm; + const prompt = confirm?.label ?? ''; + const styled = currentTheme.boldFg('warning', ` ${prompt} [y/N]`); + return truncateToWidth(styled, width, '…'); + } +} + + +function renderRow( + row: Row, + ctx: { isSelected: boolean; width: number }, +): string[] { + const { isSelected, width } = ctx; + const pointer = isSelected ? SELECT_POINTER : ' '; + const pointerStyle = (text: string) => + isSelected ? currentTheme.fg('primary', text) : currentTheme.fg('textDim', text); + // The synthetic "Add New Platform" row is an action/CTA: keep it in the brand + // color so it never reads as disabled, and bold it when selected (matching + // the other rows' selected treatment). + const labelStyle = (text: string) => + isSelected + ? currentTheme.boldFg('primary', text) + : row.kind === 'add' + ? currentTheme.fg('primary', text) + : currentTheme.fg('text', text); + + // The active provider is flagged with a trailing "← current" (success), + // matching the model selector's current-item marker — see .agents/skills/write-tui/DESIGN.md. + const isActive = row.kind === 'source' && row.hasActive; + const marker = isActive ? ` ${CURRENT_MARK}` : ''; + + // Reserve 2 leading spaces + 2 for the pointer + room for the marker. + const labelWidth = Math.max(0, width - 4 - visibleWidth(marker)); + const labelText = truncateToWidth(row.label, labelWidth, '…'); + let line = ` ${pointerStyle(`${pointer} `)}${labelStyle(labelText)}`; + if (isActive) line += currentTheme.fg('success', marker); + + const lines: string[] = [line]; + + if (row.kind === 'source' && row.baseUrl !== undefined && row.baseUrl.length > 0) { + const urlText = truncateToWidth(row.baseUrl, Math.max(0, width - 6), '…'); + lines.push(currentTheme.fg('textMuted', ` ${urlText}`)); + } + + return lines; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1d5a41bc9efac2fddd3f547e3e4c7abcb7440dc --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts @@ -0,0 +1,794 @@ +/** + * QuestionDialog — pi-tui version of the structured question prompt. + * + * Each question collects an answer locally, and a final Submit tab + * reviews everything before the answers are emitted upstream. + */ + +import { + Container, + Input, + matchesKey, + Key, + decodeKittyPrintable, + type Focusable, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import type { + PendingQuestion, + QuestionPanelResponse, + QuestionSubmissionMethod, +} from '#/tui/reverse-rpc/types'; + +const NUMBER_KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; +const MAX_BODY_LINES = 12; +const DEFAULT_OTHER_LABEL = 'Other'; +const NOT_ANSWERED_LABEL = 'Not answered'; +const REVIEW_TITLE = 'Review your answer before submit'; +const SUBMIT_PROMPT = 'Ready to submit your answers?'; +const UNANSWERED_WARNING = 'Some questions are still unanswered.'; +const SUBMIT_ACTIONS = ['Submit', 'Cancel'] as const; + +interface DisplayOption { + readonly label: string; + readonly description?: string | undefined; + readonly kind: 'preset' | 'other'; +} + +/** + * Push `content` to `lines`, wrapping it to fit `width` with a hanging + * indent. The first physical line starts with `firstPrefix`; continuation + * lines get `continuationPrefix`. Pass `tone` to wrap every emitted line + * in a single ANSI span (cleaner for selection highlights and matches the + * pre-wrap rendering tests expect); leave it undefined when the prefixes + * already carry their own mixed styling. + */ +function appendWrapped( + lines: string[], + firstPrefix: string, + continuationPrefix: string, + content: string, + width: number, + tone?: (s: string) => string, +): void { + const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix)); + const contentWidth = Math.max(1, width - prefixWidth); + const wrapped = wrapTextWithAnsi(content, contentWidth); + const styleLine = tone ?? ((s: string) => s); + if (wrapped.length === 0) { + lines.push(styleLine(firstPrefix)); + return; + } + lines.push(styleLine(`${firstPrefix}${wrapped[0] ?? ''}`)); + for (let i = 1; i < wrapped.length; i++) { + lines.push(styleLine(`${continuationPrefix}${wrapped[i] ?? ''}`)); + } +} + +export class QuestionDialogComponent extends Container implements Focusable { + focused = false; + + private readonly request: PendingQuestion; + private readonly onAnswer: (response: QuestionPanelResponse) => void; + private readonly maxVisibleOptions: number; + private readonly otherInput = new Input(); + + private currentTab = 0; + private submitActionIdx = 0; + private editingOther = false; + private reviewMessage: string | undefined; + private lastAnswerMethod: QuestionSubmissionMethod | undefined; + + /** Per-question cursor position. */ + private readonly cursors: number[]; + /** Per-question single-select choice. */ + private readonly singleSelections: (number | undefined)[]; + /** Per-question multi-select choices. */ + private readonly multiSelections: Set<number>[]; + /** Per-question free-text drafts for the synthetic Other option. */ + private readonly otherDrafts: string[]; + /** Per-question committed Other values. */ + private readonly committedOtherValues: (string | undefined)[]; + /** Per-question derived answers used by tabs + review. */ + private readonly answers: (string | undefined)[]; + + private readonly onToggleToolOutput: (() => void) | undefined; + + constructor( + request: PendingQuestion, + onAnswer: (response: QuestionPanelResponse) => void, + maxVisibleOptions = 6, + onToggleToolOutput?: () => void, + ) { + super(); + this.request = request; + this.onAnswer = onAnswer; + this.maxVisibleOptions = maxVisibleOptions; + this.onToggleToolOutput = onToggleToolOutput; + this.otherInput.onSubmit = (value) => { + this.commitOtherInput(value, 'enter'); + }; + + const total = request.data.questions.length; + this.cursors = Array.from({ length: total }, (): number => 0); + this.singleSelections = Array.from({ length: total }, (): number | undefined => undefined); + this.multiSelections = Array.from({ length: total }, () => new Set<number>()); + this.otherDrafts = Array.from({ length: total }, (): string => ''); + this.committedOtherValues = Array.from({ length: total }, (): string | undefined => undefined); + this.answers = Array.from({ length: total }, (): string | undefined => undefined); + } + + // ── Input ───────────────────────────────────────────────────────── + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.onAnswer({ answers: [] }); + return; + } + + if (matchesKey(data, Key.ctrl('c')) || matchesKey(data, Key.ctrl('d'))) { + this.onAnswer({ answers: [] }); + return; + } + + if (matchesKey(data, Key.ctrl('o'))) { + this.onToggleToolOutput?.(); + return; + } + + if (this.isEditingOther()) { + this.handleOtherInput(data); + return; + } + + if (this.isSubmitTab()) { + this.handleSubmitInput(data); + return; + } + + const questionIdx = this.currentQuestionIndex(); + if (questionIdx === undefined) return; + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return; + + const optionCount = this.displayOptions(questionIdx).length; + if (optionCount === 0) return; + + if (matchesKey(data, Key.up)) { + this.moveQuestionCursor(-1); + return; + } + if (matchesKey(data, Key.down)) { + this.moveQuestionCursor(1); + return; + } + + if (matchesKey(data, Key.left)) { + this.gotoTab(this.currentTab - 1); + return; + } + if (matchesKey(data, Key.right) || matchesKey(data, Key.tab)) { + this.gotoTab(this.currentTab + 1); + return; + } + + if (matchesKey(data, Key.enter)) { + this.activateQuestionOption(this.currentCursor(), 'enter'); + return; + } + + const printable = decodeKittyPrintable(data) ?? data; + const numIdx = NUMBER_KEYS.indexOf(printable); + if (numIdx >= 0 && numIdx < optionCount) { + this.cursors[questionIdx] = numIdx; + this.activateQuestionOption(numIdx, 'number_key'); + return; + } + + if ((printable === ' ' || matchesKey(data, Key.space)) && question.multi_select) { + this.activateQuestionOption(this.currentCursor(), 'space'); + } + } + + private handleOtherInput(data: string): void { + const questionIdx = this.currentQuestionIndex(); + if (questionIdx === undefined) return; + + if (matchesKey(data, Key.tab)) { + this.syncOtherDraft(questionIdx); + this.editingOther = false; + this.gotoTab(this.currentTab + 1); + return; + } + if (matchesKey(data, Key.up)) { + this.syncOtherDraft(questionIdx); + this.editingOther = false; + this.moveQuestionCursor(-1); + return; + } + if (matchesKey(data, Key.down)) { + this.syncOtherDraft(questionIdx); + this.editingOther = false; + this.moveQuestionCursor(1); + return; + } + + this.otherInput.handleInput(data); + this.syncOtherDraft(questionIdx); + this.reviewMessage = undefined; + } + + private handleSubmitInput(data: string): void { + if (matchesKey(data, Key.up)) { + this.submitActionIdx = + (this.submitActionIdx - 1 + SUBMIT_ACTIONS.length) % SUBMIT_ACTIONS.length; + this.reviewMessage = undefined; + return; + } + if (matchesKey(data, Key.down)) { + this.submitActionIdx = (this.submitActionIdx + 1) % SUBMIT_ACTIONS.length; + this.reviewMessage = undefined; + return; + } + + if (matchesKey(data, Key.left)) { + this.gotoTab(this.currentTab - 1); + return; + } + if (matchesKey(data, Key.right) || matchesKey(data, Key.tab)) { + this.gotoTab(this.currentTab + 1); + return; + } + + if (matchesKey(data, Key.enter)) { + this.executeSubmitAction(this.submitActionIdx, 'enter'); + return; + } + + const printable = decodeKittyPrintable(data) ?? data; + if (printable === '1') { + this.submitActionIdx = 0; + this.executeSubmitAction(0, 'number_key'); + return; + } + if (printable === '2') { + this.submitActionIdx = 1; + this.executeSubmitAction(1, 'number_key'); + } + } + + // ── State mutation ──────────────────────────────────────────────── + + private gotoTab(target: number): void { + const total = this.totalTabs(); + if (total <= 0) return; + + const wrapped = ((target % total) + total) % total; + if (wrapped === this.currentTab) return; + + this.currentTab = wrapped; + this.editingOther = false; + this.reviewMessage = undefined; + if (this.isSubmitTab()) this.submitActionIdx = 0; + } + + private moveQuestionCursor(delta: number): void { + const questionIdx = this.currentQuestionIndex(); + if (questionIdx === undefined) return; + + const total = this.displayOptions(questionIdx).length; + if (total <= 0) return; + + this.cursors[questionIdx] = (this.currentCursor() + delta + total) % total; + this.reviewMessage = undefined; + } + + private activateQuestionOption(optionIdx: number, method: QuestionSubmissionMethod): void { + const questionIdx = this.currentQuestionIndex(); + if (questionIdx === undefined) return; + + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return; + + this.cursors[questionIdx] = optionIdx; + this.editingOther = false; + this.reviewMessage = undefined; + + if (this.isOtherOption(questionIdx, optionIdx)) { + if (question.multi_select && this.multiSelections[questionIdx]?.has(optionIdx)) { + this.multiSelections[questionIdx].delete(optionIdx); + this.lastAnswerMethod = method; + this.updateAnswer(questionIdx); + return; + } + this.enterOtherInput(questionIdx); + return; + } + + if (question.multi_select) { + const set = this.multiSelections[questionIdx]; + if (set === undefined) return; + if (set.has(optionIdx)) set.delete(optionIdx); + else set.add(optionIdx); + this.lastAnswerMethod = method; + this.updateAnswer(questionIdx); + return; + } + + this.singleSelections[questionIdx] = optionIdx; + this.committedOtherValues[questionIdx] = undefined; + this.lastAnswerMethod = method; + this.updateAnswer(questionIdx); + this.advanceAfterSingleSelect(questionIdx); + } + + private enterOtherInput(questionIdx: number): void { + this.cursors[questionIdx] = this.otherOptionIndex(questionIdx); + this.editingOther = true; + this.otherInput.setValue(this.otherDraftValue(questionIdx)); + this.reviewMessage = undefined; + } + + private commitOtherInput(rawValue: string | undefined, method: QuestionSubmissionMethod): void { + const questionIdx = this.currentQuestionIndex(); + if (questionIdx === undefined) return; + + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return; + + const value = (rawValue ?? this.otherInput.getValue()).trim(); + if (value.length === 0) return; + + this.otherInput.setValue(value); + this.otherDrafts[questionIdx] = value; + this.committedOtherValues[questionIdx] = value; + + if (question.multi_select) { + this.multiSelections[questionIdx]?.add(this.otherOptionIndex(questionIdx)); + } else { + this.singleSelections[questionIdx] = this.otherOptionIndex(questionIdx); + } + + this.lastAnswerMethod = method; + this.updateAnswer(questionIdx); + this.editingOther = false; + this.reviewMessage = undefined; + + if (!question.multi_select) this.advanceAfterSingleSelect(questionIdx); + } + + private advanceAfterSingleSelect(questionIdx: number): void { + const next = this.findNextUnansweredAfter(questionIdx); + this.currentTab = next ?? this.submitTabIndex(); + this.reviewMessage = undefined; + if (this.isSubmitTab()) this.submitActionIdx = 0; + } + + private findNextUnansweredAfter(fromIdx: number): number | null { + const total = this.request.data.questions.length; + for (let idx = fromIdx + 1; idx < total; idx++) { + if (!this.isAnswered(idx)) return idx; + } + return null; + } + + private updateAnswer(questionIdx: number): void { + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return; + + if (question.multi_select) { + const labels: string[] = []; + const set = this.multiSelections[questionIdx] ?? new Set<number>(); + const otherIdx = this.otherOptionIndex(questionIdx); + for (let i = 0; i < question.options.length; i++) { + if (!set.has(i)) continue; + const label = question.options[i]?.label; + if (label !== undefined && label.length > 0) labels.push(label); + } + const otherText = this.committedOtherValues[questionIdx]; + if (set.has(otherIdx) && otherText !== undefined && otherText.length > 0) { + labels.push(otherText); + } + this.answers[questionIdx] = labels.length > 0 ? labels.join(', ') : undefined; + return; + } + + const selection = this.singleSelections[questionIdx]; + if (selection === undefined) { + this.answers[questionIdx] = undefined; + return; + } + + if (this.isOtherOption(questionIdx, selection)) { + const otherText = this.committedOtherValues[questionIdx]; + this.answers[questionIdx] = + otherText !== undefined && otherText.length > 0 ? otherText : undefined; + return; + } + + const label = question.options[selection]?.label; + this.answers[questionIdx] = label !== undefined && label.length > 0 ? label : undefined; + } + + private executeSubmitAction(actionIdx: number, method: QuestionSubmissionMethod): void { + if (actionIdx === 1) { + this.onAnswer({ answers: [] }); + return; + } + + this.reviewMessage = undefined; + this.emitAnswers(method); + } + + private emitAnswers(method: QuestionSubmissionMethod): void { + const out: string[] = []; + for (let i = 0; i < this.answers.length; i++) { + const answer = this.answers[i]; + if (answer !== undefined && answer.length > 0) out[i] = answer; + } + this.onAnswer({ answers: out, method: this.lastAnswerMethod ?? method }); + } + + // ── Render ──────────────────────────────────────────────────────── + + override render(width: number): string[] { + this.otherInput.focused = this.focused && this.isEditingOther(); + return this.isSubmitTab() ? this.renderSubmitTab(width) : this.renderQuestionTab(width); + } + + private renderQuestionTab(width: number): string[] { + const questionIdx = this.currentQuestionIndex(); + if (questionIdx === undefined) return this.renderSubmitTab(width); + + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return []; + + const accent = (text: string) => currentTheme.fg('primary', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const success = (text: string) => currentTheme.fg('success', text); + + const renderWidth = Math.max(1, width); + const lines: string[] = [accent('─'.repeat(renderWidth)), currentTheme.boldFg('primary', ' question'), '']; + this.pushTabs(lines); + lines.push(''); + + appendWrapped(lines, ' ? ', ' ', question.question, renderWidth, accent); + if (this.isEditingOther()) { + lines.push(dim(' Type your answer, then press Enter to save.')); + } + + if (question.body !== undefined && question.body.trim().length > 0) { + lines.push(''); + const bodyLines = question.body.trim().split('\n'); + const visibleBodyLines = bodyLines.slice(0, MAX_BODY_LINES); + for (const bodyLine of visibleBodyLines) { + appendWrapped(lines, ' ', ' ', bodyLine, renderWidth, dim); + } + if (bodyLines.length > visibleBodyLines.length) { + lines.push(dim(` … ${String(bodyLines.length - visibleBodyLines.length)} more lines`)); + } + } + + lines.push(''); + + const options = this.displayOptions(questionIdx); + const cursor = this.currentCursor(); + const visibleStart = this.computeVisibleStart(cursor, options.length); + const visibleEnd = Math.min(options.length, visibleStart + this.maxVisibleOptions); + const multiSet = this.multiSelections[questionIdx] ?? new Set<number>(); + const singleSelection = this.singleSelections[questionIdx]; + + for (let i = visibleStart; i < visibleEnd; i++) { + const option = options[i]; + if (option === undefined) continue; + const num = i + 1; + const isCursor = i === cursor; + const isOther = option.kind === 'other'; + const isSelected = question.multi_select ? multiSet.has(i) : singleSelection === i; + + if (this.isEditingOther() && isCursor && isOther) { + lines.push(this.renderEditingOtherLine(renderWidth, questionIdx, option, num, isSelected)); + continue; + } + + const label = this.renderOptionLabel(questionIdx, option, isCursor); + + let tone: (s: string) => string; + let prefix: string; + if (question.multi_select) { + const checked = isSelected ? '✓' : ' '; + prefix = ` [${checked}] `; + if (isSelected && isCursor) tone = (s) => currentTheme.boldFg('success', s); + else if (isSelected) tone = success; + else if (isCursor) tone = accent; + else tone = dim; + } else if (isSelected && this.isAnswered(questionIdx)) { + prefix = isCursor ? ` → [${String(num)}] ` : ` [${String(num)}] `; + tone = isCursor ? (s) => currentTheme.boldFg('success', s) : success; + } else if (isCursor) { + prefix = ` → [${String(num)}] `; + tone = accent; + } else { + prefix = ` [${String(num)}] `; + tone = dim; + } + const continuation = ' '.repeat(visibleWidth(prefix)); + appendWrapped(lines, prefix, continuation, label, renderWidth, tone); + + if ( + option.description !== undefined && + option.description.length > 0 && + !(this.isEditingOther() && isCursor && isOther) + ) { + appendWrapped(lines, ' ', ' ', option.description, renderWidth, dim); + } + } + + if (visibleEnd < options.length || visibleStart > 0) { + lines.push( + dim( + ` showing ${String(visibleStart + 1)}-${String(visibleEnd)} of ${String(options.length)}`, + ), + ); + } + + lines.push(''); + lines.push(this.buildQuestionHint(dim, questionIdx)); + lines.push(accent('─'.repeat(renderWidth))); + + return lines.map((line) => truncateToWidth(line, width)); + } + + private renderSubmitTab(width: number): string[] { + const accent = (text: string) => currentTheme.fg('primary', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const text = (t: string) => currentTheme.fg('text', t); + const warning = (text: string) => currentTheme.fg('warning', text); + + const renderWidth = Math.max(1, width); + const lines: string[] = [accent('─'.repeat(renderWidth)), currentTheme.boldFg('primary', ' question'), '']; + this.pushTabs(lines); + lines.push(''); + lines.push(currentTheme.boldFg('text', ` ${REVIEW_TITLE}`)); + const reviewWarning = + this.reviewMessage ?? (this.hasUnansweredQuestions() ? UNANSWERED_WARNING : undefined); + if (reviewWarning !== undefined) { + lines.push(warning(` ${reviewWarning}`)); + } + lines.push(''); + + for (let i = 0; i < this.request.data.questions.length; i++) { + const question = this.request.data.questions[i]; + if (question === undefined) continue; + const answer = this.answers[i]; + appendWrapped( + lines, + ` ${dim('Q')} `, + ' ', + question.question, + renderWidth, + ); + if (answer !== undefined && answer.length > 0) { + appendWrapped( + lines, + ` ${accent('→')} `, + ' ', + text(answer), + renderWidth, + ); + } else { + lines.push(` ${dim('→')} ${dim(NOT_ANSWERED_LABEL)}`); + } + } + + lines.push(''); + lines.push(text(` ${SUBMIT_PROMPT}`)); + lines.push(''); + + for (let i = 0; i < SUBMIT_ACTIONS.length; i++) { + const label = SUBMIT_ACTIONS[i]; + if (label === undefined) continue; + const num = i + 1; + if (i === this.submitActionIdx) { + lines.push(accent(` → [${String(num)}] ${label}`)); + } else { + lines.push(dim(` [${String(num)}] ${label}`)); + } + } + + lines.push(''); + lines.push(this.buildSubmitHint(dim)); + lines.push(accent('─'.repeat(renderWidth))); + + return lines.map((line) => truncateToWidth(line, width)); + } + + private pushTabs(lines: string[]): void { + const dim = (text: string) => currentTheme.fg('textDim', text); + const active = (text: string) => + currentTheme.bg('primary', currentTheme.boldFg('text', text)); + + const tabs: string[] = []; + for (let i = 0; i < this.request.data.questions.length; i++) { + const question = this.request.data.questions[i]; + if (question === undefined) continue; + const label = + question.header !== undefined && question.header.length > 0 + ? question.header + : `Q${String(i + 1)}`; + if (i === this.currentTab) tabs.push(active(` ${label} `)); + else if (this.isAnswered(i)) tabs.push(currentTheme.fg('success', `(✓) ${label}`)); + else tabs.push(dim(`(○) ${label}`)); + } + + const submitLabel = 'Submit'; + if (this.isSubmitTab()) tabs.push(active(` ${submitLabel} `)); + else tabs.push(dim(` ${submitLabel} `)); + + lines.push(` ${tabs.join(' ')}`); + } + + private buildQuestionHint(dim: (s: string) => string, questionIdx: number): string { + if (this.isEditingOther()) { + const parts: string[] = [ + 'type answer', + '↵ save', + ...(this.totalTabs() > 1 ? ['tab switch'] : []), + 'esc cancel', + ]; + return dim(` ${parts.join(' ')}`); + } + + const optionCount = Math.min(this.displayOptions(questionIdx).length, NUMBER_KEYS.length); + const numberHint = optionCount <= 1 ? '1' : `1-${String(optionCount)}`; + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return dim(' esc cancel'); + + const parts: string[] = [ + '↑↓ select', + `${numberHint} / ↵ ${question.multi_select ? 'toggle' : 'choose'}`, + ]; + if (this.totalTabs() > 1) parts.push('←/→/tab switch'); + parts.push('esc cancel'); + return dim(` ${parts.join(' ')}`); + } + + private buildSubmitHint(dim: (s: string) => string): string { + const parts: string[] = ['↑↓ select', '1/2 choose', '↵ confirm']; + if (this.totalTabs() > 1) parts.push('←/→/tab switch'); + parts.push('esc cancel'); + return dim(` ${parts.join(' ')}`); + } + + private computeVisibleStart(cursor: number, total: number): number { + if (total <= this.maxVisibleOptions) return 0; + const half = Math.floor(this.maxVisibleOptions / 2); + const max = Math.max(0, total - this.maxVisibleOptions); + return Math.max(0, Math.min(cursor - half, max)); + } + + // ── Helpers ─────────────────────────────────────────────────────── + + private totalTabs(): number { + return this.request.data.questions.length + 1; + } + + private submitTabIndex(): number { + return this.request.data.questions.length; + } + + private isSubmitTab(): boolean { + return this.currentTab === this.submitTabIndex(); + } + + private isEditingOther(): boolean { + return this.editingOther && !this.isSubmitTab(); + } + + private currentQuestionIndex(): number | undefined { + return this.isSubmitTab() ? undefined : this.currentTab; + } + + private currentCursor(): number { + const questionIdx = this.currentQuestionIndex(); + if (questionIdx === undefined) return 0; + return this.cursors[questionIdx] ?? 0; + } + + private displayOptions(questionIdx: number): DisplayOption[] { + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return []; + + return [ + ...question.options.map((option) => ({ + label: option.label, + description: option.description, + kind: 'preset' as const, + })), + { + label: question.other_label?.length ? question.other_label : DEFAULT_OTHER_LABEL, + description: question.other_description?.length ? question.other_description : undefined, + kind: 'other' as const, + }, + ]; + } + + private otherOptionIndex(questionIdx: number): number { + return this.request.data.questions[questionIdx]?.options.length ?? 0; + } + + private isOtherOption(questionIdx: number, optionIdx: number): boolean { + return optionIdx === this.otherOptionIndex(questionIdx); + } + + private renderOptionLabel(questionIdx: number, option: DisplayOption, isCursor: boolean): string { + if (option.kind !== 'other') return option.label; + + const value = this.otherDraftValue(questionIdx); + if (this.isEditingOther() && isCursor) { + return `${option.label}: ${value ?? ''}█`; + } + if (value !== undefined && value.length > 0) return `${option.label}: ${value}`; + return option.label; + } + + private renderEditingOtherLine( + width: number, + questionIdx: number, + option: DisplayOption, + num: number, + isSelected: boolean, + ): string { + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return option.label; + + let prefix: string; + if (question.multi_select) { + const checked = isSelected ? '✓' : ' '; + const body = ` [${checked}] ${option.label}: `; + prefix = isSelected + ? currentTheme.boldFg('success', body) + : currentTheme.fg('primary', body); + } else { + const body = ` → [${String(num)}] ${option.label}: `; + prefix = + isSelected && this.isAnswered(questionIdx) + ? currentTheme.boldFg('success', body) + : currentTheme.fg('primary', body); + } + + const inputWidth = Math.max(4, width - visibleWidth(prefix) + 2); + const inputLine = this.otherInput.render(inputWidth)[0] ?? '> '; + const inlineInput = inputLine.startsWith('> ') ? inputLine.slice(2) : inputLine; + return prefix + inlineInput; + } + + private otherDraftValue(questionIdx: number): string { + return (this.otherDrafts[questionIdx] ?? this.committedOtherValues[questionIdx]) ?? ''; + } + + private syncOtherDraft(questionIdx: number): void { + this.otherDrafts[questionIdx] = this.otherInput.getValue(); + } + + private isAnswered(questionIdx: number): boolean { + const answer = this.answers[questionIdx]; + return answer !== undefined && answer.length > 0; + } + + private hasUnansweredQuestions(): boolean { + for (let i = 0; i < this.request.data.questions.length; i++) { + if (!this.isAnswered(i)) return true; + } + return false; + } + + override invalidate(): void { + super.invalidate(); + this.otherInput.invalidate(); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts new file mode 100644 index 0000000000000000000000000000000000000000..cddb55377efd87fe18c5b016b8136de6c7e46524 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -0,0 +1,530 @@ +/** + * SessionPicker — pi-tui version of the session selection dialog. + */ + +import { + Container, + matchesKey, + Key, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; +import { formatSessionLabel } from '#/migration/index'; +import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { printableChar } from '#/tui/utils/printable-key'; +import { SearchableList } from '#/tui/utils/searchable-list'; + +export interface SessionRow { + readonly id: string; + readonly title: string | null; + readonly last_prompt?: string | null; + readonly work_dir: string; + readonly updated_at: number; + readonly metadata?: Readonly<Record<string, unknown>> | undefined; +} + +const ELLIPSIS = '…'; + +function formatRelativeTime(ts: number): string { + // SessionSummary timestamps come from filesystem stat `*timeMs`, + // so they use the same millisecond unit as `Date.now()`. + if (!Number.isFinite(ts) || ts <= 0) return ''; + const diffSec = Math.floor(Math.max(0, Date.now() - ts) / 1000); + if (diffSec < 60) return 'just now'; + const minutes = Math.floor(diffSec / 60); + if (minutes < 60) return `${String(minutes)}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${String(hours)}h ago`; + const days = Math.floor(hours / 24); + return `${String(days)}d ago`; +} + +function homeAlias(path: string): string { + const home = process.env['HOME'] ?? ''; + if (home && path.startsWith(home)) return '~' + path.slice(home.length); + return path; +} + +// Truncates from the LEFT (keeps the tail), prefixing an ellipsis when clipped. +// Paths typically carry the relevant info near the end, so we drop the prefix. +function truncatePathLeft(path: string, maxWidth: number): string { + if (maxWidth <= 0) return ''; + if (visibleWidth(path) <= maxWidth) return path; + if (maxWidth === 1) return ELLIPSIS; + // Walk graphemes from the end accumulating width, keep the longest tail + // whose width + ellipsis fits. + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + const segments = [...segmenter.segment(path)].map((s) => s.segment); + let used = 0; + const budget = maxWidth - 1; // reserve 1 column for ellipsis + let i = segments.length - 1; + while (i >= 0) { + const seg = segments[i]; + if (seg === undefined) break; + const w = visibleWidth(seg); + if (used + w > budget) break; + used += w; + i--; + } + return ELLIPSIS + segments.slice(i + 1).join(''); +} + +function singleLine(text: string): string { + return text.replaceAll(/\s+/g, ' ').trim(); +} + +function sessionSearchText(session: SessionRow): string { + return singleLine((session.title ?? session.id).trim() || session.id); +} + +export class SessionPickerComponent extends Container implements Focusable { + private sessions: SessionRow[]; + private currentSessionId: string; + private onSelect: (session: SessionRow) => void | Promise<void>; + private onCancel: () => void; + private onToggleScope?: (selectedSessionId: string) => void; + private maxVisibleSessions: number; + private pageSize: number; + private visibleCount: number; + private scope: 'cwd' | 'all'; + private loading: boolean; + private hasMore: boolean; + private loadingMore: boolean; + private list: SearchableList<SessionRow>; + private deleteState?: { session: SessionRow; phase: 'confirm' | 'deleting' }; + private selectInFlight = false; + + focused = false; + + constructor(opts: { + sessions: SessionRow[]; + loading: boolean; + currentSessionId: string; + scope?: 'cwd' | 'all'; + initialSelectedSessionId?: string; + pageSize?: number; + onSelect: (session: SessionRow) => void | Promise<void>; + onCancel: () => void; + onCtrlC?: () => void; + onCtrlD?: () => void; + onToggleScope?: (selectedSessionId: string) => void; + maxVisibleSessions?: number; + /** More pages exist on the backend (keyset paging). */ + hasMore?: boolean; + /** A follow-up page fetch is in flight. */ + loadingMore?: boolean; + /** Fired when the cursor reaches the end of every row fetched so far. */ + onLoadMore?: () => void; + /** Fired when a search query becomes active while pages remain unfetched. */ + onSearchDrain?: () => void; + /** Fired after the user confirms deletion with `y`; the picker clears its delete state once the request settles. */ + onDeleteRequest?: (session: SessionRow) => Promise<void>; + }) { + super(); + this.sessions = opts.sessions; + this.loading = opts.loading; + this.currentSessionId = opts.currentSessionId; + this.scope = opts.scope ?? 'cwd'; + this.onSelect = opts.onSelect; + this.onCancel = opts.onCancel; + this.onToggleScope = opts.onToggleScope; + this.maxVisibleSessions = opts.maxVisibleSessions ?? 4; + this.pageSize = Math.max(1, opts.pageSize ?? 50); + this.hasMore = opts.hasMore ?? false; + this.loadingMore = opts.loadingMore ?? false; + this.onLoadMore = opts.onLoadMore; + this.onSearchDrain = opts.onSearchDrain; + const initialIndex = this.resolveInitialSelectedIndex(opts.initialSelectedSessionId); + this.list = new SearchableList({ + items: this.sessions, + toSearchText: sessionSearchText, + pageSize: this.pageSize, + initialIndex, + searchable: true, + }); + const initialLoadedPages = Math.ceil((initialIndex + 1) / this.pageSize); + this.visibleCount = Math.min(this.sessions.length, initialLoadedPages * this.pageSize); + this.onCtrlC = opts.onCtrlC; + this.onCtrlD = opts.onCtrlD; + this.onDeleteRequest = opts.onDeleteRequest; + } + + private readonly onCtrlC?: () => void; + private readonly onCtrlD?: () => void; + private readonly onLoadMore?: () => void; + private readonly onSearchDrain?: () => void; + private readonly onDeleteRequest?: (session: SessionRow) => Promise<void>; + + /** Appends a freshly fetched page, keeping the cursor and active query. */ + appendSessions(rows: SessionRow[]): void { + this.sessions = [...this.sessions, ...rows]; + this.list.setItems(this.sessions); + // Rows arriving while a query is active must become visible without + // waiting for the next keypress; only grow, never shrink the window. + this.visibleCount = Math.max( + this.visibleCount, + Math.min(this.list.view().items.length, this.pageSize), + ); + } + + /** Updates the backend-paging facts after an in-flight fetch settles. */ + setPaging(hasMore: boolean, loadingMore: boolean): void { + this.hasMore = hasMore; + this.loadingMore = loadingMore; + } + + private resolveInitialSelectedIndex(initialSelectedSessionId: string | undefined): number { + if (initialSelectedSessionId === undefined) return 0; + const index = this.sessions.findIndex((session) => session.id === initialSelectedSessionId); + return Math.max(index, 0); + } + + private filteredSessions(): readonly SessionRow[] { + return this.list.view().items; + } + + private loadedSessions(sessions: readonly SessionRow[] = this.filteredSessions()): SessionRow[] { + return sessions.slice(0, Math.min(sessions.length, this.visibleCount)); + } + + private syncVisibleCount(previousQuery: string): void { + const view = this.list.view(); + if (view.query !== previousQuery) { + this.visibleCount = Math.min(view.items.length, this.pageSize); + // A fresh query only searches the pages fetched so far; ask the host to + // drain the rest in the background so search covers every session. + if (view.query.length > 0 && previousQuery.length === 0 && this.hasMore) { + this.onSearchDrain?.(); + } + return; + } + + const loadedCount = Math.min(view.items.length, this.visibleCount); + if (view.selectedIndex >= loadedCount - 1 && loadedCount < view.items.length) { + this.visibleCount = Math.min(view.items.length, this.visibleCount + this.pageSize); + } + // The cursor reached the end of everything fetched: pull the next page. + if ( + this.hasMore && + !this.loadingMore && + view.items.length > 0 && + view.selectedIndex >= view.items.length - 1 + ) { + this.onLoadMore?.(); + } + } + + handleInput(data: string): void { + if (this.deleteState !== undefined) { + this.handleDeleteInput(data); + return; + } + // A selection runs resume/switch asynchronously; input during that window + // (e.g. Ctrl+X delete) would race the session swap. + if (this.selectInFlight) return; + if (matchesKey(data, Key.ctrl('c'))) { + this.onCtrlC?.(); + return; + } + if (matchesKey(data, Key.ctrl('d'))) { + this.onCtrlD?.(); + return; + } + if (matchesKey(data, Key.ctrl('a'))) { + this.onToggleScope?.(this.list.selected()?.id ?? this.currentSessionId); + return; + } + if (matchesKey(data, Key.ctrl('x'))) { + const selected = this.list.selected(); + if (selected !== undefined && this.onDeleteRequest !== undefined) { + this.deleteState = { session: selected, phase: 'confirm' }; + this.invalidate(); + } + return; + } + if (matchesKey(data, Key.escape)) { + if (this.list.clearQuery()) { + this.visibleCount = Math.min(this.filteredSessions().length, this.pageSize); + return; + } + this.onCancel(); + return; + } + if (matchesKey(data, Key.enter)) { + const session = this.list.selected(); + if (session) { + const selection = this.onSelect(session); + if (selection !== undefined) { + this.selectInFlight = true; + const clear = (): void => { + this.selectInFlight = false; + }; + void selection.then(clear, clear); + } + } + return; + } + + const previousQuery = this.list.view().query; + if (this.list.handleKey(data)) { + this.syncVisibleCount(previousQuery); + } + } + + private handleDeleteInput(data: string): void { + const state = this.deleteState; + if (state === undefined || state.phase === 'deleting') return; + const k = printableChar(data); + if (matchesKey(data, Key.escape) || k === 'n' || k === 'N') { + this.deleteState = undefined; + this.invalidate(); + return; + } + if (k === 'y' || k === 'Y') { + this.deleteState = { session: state.session, phase: 'deleting' }; + this.invalidate(); + const sessionId = state.session.id; + const clear = (): void => { + if (this.deleteState?.session.id !== sessionId) return; + this.deleteState = undefined; + this.invalidate(); + }; + // then(clear, clear): rejections settle too — the host has already surfaced the failure. + void this.onDeleteRequest?.(state.session).then(clear, clear); + } + } + + private renderDeleteStateLine(width: number): string { + const state = this.deleteState; + if (state === undefined) return ''; + const rawTitle = (state.session.title ?? state.session.id).trim() || state.session.id; + const label = singleLine( + formatSessionLabel({ title: rawTitle, metadata: state.session.metadata }), + ); + const prefix = state.phase === 'confirm' ? 'Delete session "' : 'Deleting session "'; + const suffix = state.phase === 'confirm' ? '"? [y/N]' : '"…'; + const labelBudget = Math.max(0, width - visibleWidth(prefix) - visibleWidth(suffix)); + const shown = truncateToWidth(label, labelBudget, ELLIPSIS); + // The suffix carries the confirm/cancel keys: it survives by truncating + // the head (prefix + label) instead of the composed line. + const head = truncateToWidth( + prefix + shown, + Math.max(0, width - visibleWidth(suffix)), + ELLIPSIS, + ); + const styled = + state.phase === 'confirm' + ? currentTheme.boldFg('warning', head + suffix) + : currentTheme.fg('textMuted', head + suffix); + return truncateToWidth(styled, width, ELLIPSIS); + } + + override render(width: number): string[] { + return this.renderLines(width).map((line) => truncateToWidth(line, width, ELLIPSIS)); + } + + // Builds the raw lines; `render()` applies a final width clamp so no line + // can ever exceed the terminal width. The per-line budgets below keep the + // layout tidy at normal widths, but on a very narrow terminal those budgets + // floor at a minimum and the trailing time/badge are appended in full, so + // the clamp in `render()` is what guarantees the renderer's invariant and + // prevents the "Rendered line exceeds terminal width" crash (issue #240). + private renderLines(width: number): string[] { + const lines: string[] = [currentTheme.fg('primary', '─'.repeat(width))]; + const title = this.scope === 'all' ? 'All sessions' : 'Sessions'; + const scopeHint = + this.onToggleScope === undefined + ? undefined + : this.scope === 'all' + ? 'Ctrl+A current cwd' + : 'Ctrl+A all'; + + if (this.loading) { + lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS))); + lines.push( + currentTheme.fg('textMuted', truncateToWidth('Loading sessions…', width, ELLIPSIS)), + ); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines; + } + + if (this.sessions.length === 0) { + const hintParts = [scopeHint, 'Esc cancel'].filter( + (item): item is string => item !== undefined, + ); + lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS))); + lines.push( + currentTheme.fg('textMuted', truncateToWidth(hintParts.join(' · '), width, ELLIPSIS)), + ); + lines.push(''); + lines.push( + currentTheme.fg('textMuted', truncateToWidth('No sessions found.', width, ELLIPSIS)), + ); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines; + } + + const view = this.list.view(); + const titleSuffix = + view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; + const hintParts = [ + ...(view.query.length > 0 ? ['Backspace clear'] : []), + '↑↓ navigate', + scopeHint, + ...(this.onDeleteRequest !== undefined ? ['Ctrl+X delete'] : []), + 'Enter select', + 'Esc cancel', + ].filter((item): item is string => item !== undefined); + + lines.push(currentTheme.boldFg('primary', title) + titleSuffix); + lines.push(currentTheme.fg('textMuted', hintParts.join(' · '))); + lines.push(''); + + if (view.query.length > 0) { + lines.push(currentTheme.fg('primary', 'Search: ') + currentTheme.fg('text', view.query)); + } + + const loadedSessions = this.loadedSessions(view.items); + if (loadedSessions.length === 0) { + lines.push(currentTheme.fg('textMuted', truncateToWidth('No matches', width, ELLIPSIS))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines; + } + const selectedIndex = view.selectedIndex; + const visibleStart = Math.max( + 0, + Math.min( + selectedIndex - Math.floor(this.maxVisibleSessions / 2), + Math.max(0, loadedSessions.length - this.maxVisibleSessions), + ), + ); + const visibleSessions = loadedSessions.slice( + visibleStart, + visibleStart + this.maxVisibleSessions, + ); + + for (const [vi, session] of visibleSessions.entries()) { + const index = visibleStart + vi; + const isSelected = index === selectedIndex; + const isCurrent = session.id === this.currentSessionId; + const card = this.renderSessionCard(width, session, isSelected, isCurrent); + lines.push(...card); + if (vi < visibleSessions.length - 1) lines.push(''); + } + + const filteredCount = view.items.length; + if ( + loadedSessions.length > visibleSessions.length || + view.query.length > 0 || + this.hasMore || + this.loadingMore + ) { + lines.push(''); + const moreSuffix = this.loadingMore + ? ' · loading more…' + : this.hasMore + ? view.query.length > 0 + ? ' · searching all…' + : ' · scroll for more' + : ''; + const totalSuffix = + view.query.length > 0 + ? `${String(loadedSessions.length)} loaded / ${String(filteredCount)} matches` + : this.hasMore || this.loadingMore + ? `${String(loadedSessions.length)} loaded` + : loadedSessions.length === this.sessions.length + ? `${String(loadedSessions.length)} sessions` + : `${String(loadedSessions.length)} loaded / ${String(this.sessions.length)} sessions`; + const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${totalSuffix}${moreSuffix}`; + lines.push(currentTheme.fg('textMuted', truncateToWidth(footer, width, ELLIPSIS))); + } + + if (this.deleteState !== undefined) { + lines.push(''); + lines.push(this.renderDeleteStateLine(width)); + } + + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines; + } + + private renderSessionCard( + width: number, + session: SessionRow, + isSelected: boolean, + isCurrent: boolean, + ): string[] { + const pointer = isSelected ? SELECT_POINTER : ' '; + const indent = ' '; + const indentWidth = visibleWidth(indent); + const titleColor: 'primary' | 'text' = isSelected ? 'primary' : 'text'; + const titleStyle = (text: string) => + isSelected ? currentTheme.boldFg(titleColor, text) : currentTheme.fg(titleColor, text); + + const time = formatRelativeTime(session.updated_at); + const badge = isCurrent ? CURRENT_MARK : ''; + const rawTitle = (session.title ?? session.id).trim() || session.id; + const titleSource = formatSessionLabel({ title: rawTitle, metadata: session.metadata }); + + // Inline trailing parts after the title: "<title> <time> ← current". + const trailingParts = [time, badge].filter((p) => p.length > 0); + const trailingText = trailingParts.length > 0 ? ' ' + trailingParts.join(' ') : ''; + const trailingWidth = visibleWidth(trailingText); + const headerPrefixWidth = visibleWidth(pointer) + 1; // pointer + space + const titleBudget = Math.max(8, width - headerPrefixWidth - trailingWidth); + const shownTitle = truncateToWidth(singleLine(titleSource), titleBudget, ELLIPSIS); + + let header = currentTheme.fg(isSelected ? 'primary' : 'textDim', pointer + ' '); + header += titleStyle(shownTitle); + if (time.length > 0) header += ' ' + currentTheme.fg('textDim', time); + if (badge.length > 0) header += ' ' + currentTheme.fg('success', badge); + const card: string[] = [header]; + + // Session id is rendered in full at normal widths (the final clamp in + // `render()` truncates it only when the terminal is narrower than the id). + // The directory wraps to its own line if it would push past the edge. + const fullId = session.id; + const idWidth = visibleWidth(fullId); + const metaGap = ' '; + const metaGapWidth = visibleWidth(metaGap); + const idLineWidth = indentWidth + idWidth; + const aliasedDir = homeAlias(session.work_dir); + const dirWidth = visibleWidth(aliasedDir); + + if (idLineWidth + metaGapWidth + dirWidth <= width) { + card.push( + indent + + currentTheme.fg('textMuted', fullId) + + currentTheme.fg('textDim', metaGap) + + currentTheme.fg('textMuted', aliasedDir), + ); + } else { + // Not enough room for both on one line — keep the id intact and put the + // directory on the next line (left-truncated only if it still doesn't fit). + card.push( + indent + + currentTheme.fg( + 'textMuted', + truncateToWidth(fullId, Math.max(idWidth, width - indentWidth), ELLIPSIS), + ), + ); + const dirBudget = Math.max(8, width - indentWidth); + const dir = truncatePathLeft(aliasedDir, dirBudget); + card.push(indent + currentTheme.fg('textMuted', dir)); + } + + const rawPrompt = session.last_prompt?.trim(); + if (rawPrompt && rawPrompt.length > 0) { + const promptMarker = '› '; + const promptMarkerWidth = visibleWidth(promptMarker); + const promptBudget = Math.max(8, width - indentWidth - promptMarkerWidth); + const promptText = truncateToWidth(singleLine(rawPrompt), promptBudget, ELLIPSIS); + const promptLine = indent + currentTheme.fg('textDim', promptMarker + promptText); + card.push(promptLine); + } + + return card; + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..581407d57a8658560f62bf1bdb073ff77eb3c2f4 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts @@ -0,0 +1,85 @@ +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +export type SettingsSelection = + | 'model' + | 'theme' + | 'editor' + | 'permission' + | 'survey' + | 'experiments' + | 'upgrade' + | 'usage'; + +const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ + { + value: 'model', + label: 'Model', + description: 'Switch the active model and thinking mode.', + }, + { + value: 'permission', + label: 'Permission', + description: 'Choose how tool actions are approved.', + }, + { + value: 'theme', + label: 'Theme', + description: 'Change the terminal UI theme.', + }, + { + value: 'editor', + label: 'Editor', + description: 'Set the external editor command.', + }, + { + value: 'survey', + label: 'Feedback survey', + description: 'Turn the occasional session rating prompt on or off.', + }, + { + value: 'experiments', + label: 'Experiments', + description: 'Turn experimental features on or off.', + }, + { + value: 'upgrade', + label: 'Automatic updates', + description: 'Turn automatic CLI updates on or off.', + }, + { + value: 'usage', + label: 'Usage', + description: 'Show session tokens, context window, and plan quotas.', + }, +]; + +function isSettingsSelection(value: string): value is SettingsSelection { + return ( + value === 'model' || + value === 'theme' || + value === 'editor' || + value === 'permission' || + value === 'survey' || + value === 'experiments' || + value === 'upgrade' || + value === 'usage' + ); +} + +export interface SettingsSelectorOptions { + readonly onSelect: (value: SettingsSelection) => void; + readonly onCancel: () => void; +} + +export class SettingsSelectorComponent extends ChoicePickerComponent { + constructor(opts: SettingsSelectorOptions) { + super({ + title: 'Settings', + options: [...SETTINGS_OPTIONS], + onSelect: (value) => { + if (isSettingsSelection(value)) opts.onSelect(value); + }, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b8487d9038f00b7c2288948034d156eaaa45a78 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts @@ -0,0 +1,125 @@ +import { + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Component, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; + +export type StartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; + +export interface StartPermissionOption<TChoice extends StartPermissionChoice = StartPermissionChoice> { + readonly value: TChoice; + readonly label: string; + readonly description: string; +} + +export interface StartPermissionPromptOptions< + TChoice extends StartPermissionChoice = StartPermissionChoice, +> { + readonly title: string; + readonly noticeLines: readonly string[]; + readonly options: readonly StartPermissionOption<TChoice>[]; + readonly onSelect: (choice: TChoice) => void; + readonly onCancel: () => void; +} + +export class StartPermissionPromptComponent<TChoice extends StartPermissionChoice = StartPermissionChoice> + implements Component, Focusable +{ + focused = false; + private selectedIndex = 0; + + constructor(private readonly opts: StartPermissionPromptOptions<TChoice>) {} + + invalidate(): void {} + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(this.opts.options.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { + this.opts.onSelect(this.opts.options[this.selectedIndex]!.value); + } + } + + render(width: number): string[] { + const rule = currentTheme.fg('primary', '─'.repeat(width)); + const lines = [ + rule, + currentTheme.boldFg('primary', ` ${this.opts.title}`), + currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc cancel'), + '', + ]; + + const textWidth = Math.max(20, width - 2); + for (const paragraph of this.opts.noticeLines) { + for (const line of wrapPlain(paragraph, textWidth)) { + lines.push(` ${styleModeNames(line, 'textMuted')}`); + } + lines.push(''); + } + + for (let i = 0; i < this.opts.options.length; i += 1) { + const option = this.opts.options[i]!; + const selected = i === this.selectedIndex; + const pointer = selected ? SELECT_POINTER : ' '; + lines.push( + currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `) + + styleLabel(option.label, selected), + ); + for (const line of wrapPlain(option.description, Math.max(20, width - 4))) { + lines.push(` ${styleModeNames(line, 'textMuted')}`); + } + lines.push(''); + } + + lines.push(rule); + return lines.map((line) => truncateToWidth(line, width)); + } +} + +function styleLabel(label: string, selected: boolean): string { + if (selected) return currentTheme.boldFg('primary', label); + return styleModeNames(label, 'text'); +} + +function styleModeNames(text: string, baseToken: 'text' | 'textMuted'): string { + return text + .split(/(\b(?:Always Ask|Ask When Needed|Never Ask)\b)/g) + .map((part) => { + if (part === 'Always Ask' || part === 'Ask When Needed' || part === 'Never Ask') return currentTheme.boldFg('textStrong', part); + return currentTheme.fg(baseToken, part); + }) + .join(''); +} + +function wrapPlain(text: string, width: number): string[] { + const words = text.split(/\s+/).filter((word) => word.length > 0); + const lines: string[] = []; + let current = ''; + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (visibleWidth(candidate) <= width) { + current = candidate; + continue; + } + if (current.length > 0) lines.push(current); + current = visibleWidth(word) <= width ? word : truncateToWidth(word, width, '…'); + } + if (current.length > 0) lines.push(current); + return lines.length > 0 ? lines : ['']; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/survey-preference-selector.ts b/apps/kimi-code/src/tui/components/dialogs/survey-preference-selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ef788e883176da446fbc8508d1734c7a8e72e63 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/survey-preference-selector.ts @@ -0,0 +1,34 @@ +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +const SURVEY_PREFERENCE_OPTIONS: readonly ChoiceOption[] = [ + { + value: 'on', + label: 'On', + description: 'Show the occasional rating prompt above the editor.', + }, + { + value: 'off', + label: 'Off', + description: 'Never show the rating prompt.', + }, +]; + +export interface SurveyPreferenceSelectorOptions { + readonly currentValue: boolean; + readonly onSelect: (value: boolean) => void; + readonly onCancel: () => void; +} + +export class SurveyPreferenceSelectorComponent extends ChoicePickerComponent { + constructor(opts: SurveyPreferenceSelectorOptions) { + super({ + title: 'Feedback survey', + options: [...SURVEY_PREFERENCE_OPTIONS], + currentValue: opts.currentValue ? 'on' : 'off', + onSelect: (value) => { + opts.onSelect(value === 'on'); + }, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0dd63072fb45a3cd090e196cef2e5ecebd6efbb --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts @@ -0,0 +1,50 @@ +import { + StartPermissionPromptComponent, + type StartPermissionOption, +} from './start-permission-prompt'; + +export type SwarmStartPermissionChoice = 'auto' | 'yolo' | 'manual'; + +export interface SwarmStartPermissionPromptOptions { + readonly onSelect: (choice: SwarmStartPermissionChoice) => void; + readonly onCancel: () => void; +} + +const OPTIONS: readonly StartPermissionOption<SwarmStartPermissionChoice>[] = [ + { + value: 'auto', + label: 'Switch to Never Ask and start', + description: + 'Best for swarm tasks. Tools are approved automatically, and questions are skipped.', + }, + { + value: 'yolo', + label: 'Switch to Ask When Needed and start', + description: + 'Tools and plan changes are approved automatically. Kimi Code may still ask you questions.', + }, + { + value: 'manual', + label: 'Start in Always Ask', + description: + 'Keep approvals on. Kimi Code may stop and wait for you during the swarm task.', + }, +]; + +const NOTICE_LINES = [ + 'Always Ask mode asks you before Kimi Code runs commands, edits files, or takes other risky actions.', + 'Always Ask mode can block swarm work while agents are running.', + 'You can go back without losing your command.', +] as const; + +export class SwarmStartPermissionPromptComponent extends StartPermissionPromptComponent<SwarmStartPermissionChoice> { + constructor(opts: SwarmStartPermissionPromptOptions) { + super({ + title: 'Start a swarm task with approvals on?', + noticeLines: NOTICE_LINES, + options: OPTIONS, + onSelect: opts.onSelect, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..9726ad483242df9e2176cdb0a7ad1b60cac42d42 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -0,0 +1,198 @@ +/** + * TabbedModelSelectorComponent — a thin wrapper around ModelSelectorComponent + * that splits the model list into per-provider tabs. + * + * Tabs are derived from the `models` passed at construction time: + * ['all', ...uniqueProviderIds] (insertion order, deduplicated) + * + * Each tab owns its own inner ModelSelectorComponent built from the filtered + * subset of models. ↑/↓/Enter/Esc/←/→ (thinking) and typing (filter) are + * forwarded to the active inner selector; Tab / Shift-Tab cycle between tabs. + * + * The active tab is highlighted with a filled background (matching the + * AskUserQuestion dialog's tab strip) — see .agents/skills/write-tui/DESIGN.md. + */ + +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { + Container, + Key, + matchesKey, + truncateToWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import { renderTabStrip } from '#/tui/utils/tab-strip'; + +import { + ModelSelectorComponent, + providerDisplayName, + type ModelSelection, + type ModelSelectorOptions, +} from './model-selector'; + +const ALL_TAB_ID = 'all'; +const ALL_TAB_LABEL = 'All'; + +export interface TabbedModelSelectorOptions { + readonly models: Record<string, ModelAlias>; + readonly currentValue: string; + readonly selectedValue?: string; + readonly currentThinkingEffort: string; + /** Forwarded to each inner selector; overrides the default ' Select a model' + * title line. */ + readonly title?: string; + /** When set, the tab for this provider id is initially active instead of the + * tab derived from `currentValue`. */ + readonly initialTabId?: string; + /** When set, warning-colored lines are rendered directly below the key-hint + * line, wrapping as needed (e.g. the mid-conversation switch cost notice). */ + readonly warning?: string; + /** Forwarded to each inner selector; set to false to hide the Thinking + * footer and disable ←/→ effort switching. */ + readonly thinkingControl?: boolean; + readonly onSelect: (selection: ModelSelection) => void; + /** Forwarded to each inner selector; when set, Alt+S applies the choice to + * the current session only without persisting it as the default. */ + readonly onSessionOnlySelect?: (selection: ModelSelection) => void; + readonly onCancel: () => void; +} + +interface ModelTab { + readonly id: string; + readonly label: string; + readonly selector: ModelSelectorComponent; +} + +export class TabbedModelSelectorComponent extends Container implements Focusable { + focused = false; + private readonly opts: TabbedModelSelectorOptions; + private readonly tabs: readonly ModelTab[]; + private activeIndex: number; + + constructor(opts: TabbedModelSelectorOptions) { + super(); + this.opts = opts; + this.tabs = buildTabs(opts); + + // Default to the "All" tab. Only an explicit initialTabId (e.g. the + // provider just added via /provider) opens on a specific provider tab — + // the current model is still highlighted inside whichever tab is active. + const initialTabIdx = opts.initialTabId + ? this.tabs.findIndex((tab) => tab.id === opts.initialTabId) + : -1; + this.activeIndex = Math.max(initialTabIdx, 0); + this.syncFocusToActive(); + } + + handleInput(data: string): void { + if (this.tabs.length > 1) { + if (matchesKey(data, Key.tab)) { + this.activeIndex = (this.activeIndex + 1) % this.tabs.length; + this.syncFocusToActive(); + return; + } + if (matchesKey(data, Key.shift('tab'))) { + this.activeIndex = (this.activeIndex - 1 + this.tabs.length) % this.tabs.length; + this.syncFocusToActive(); + return; + } + } + this.tabs[this.activeIndex]?.selector.handleInput(data); + } + + override render(width: number): string[] { + const active = this.tabs[this.activeIndex]; + if (active === undefined) return []; + const inner = active.selector.render(width); + if (this.tabs.length <= 1) { + return inner.map((line) => truncateToWidth(line, width)); + } + // Layout: divider, title, hint, optional warning, blank, tab strip, blank, + // then the model list. The header ends at its first blank line — keep that + // blank above the strip, and separate the tabs from the list with another + // blank. + const stripLine = renderTabStrip({ + labels: this.tabs.map((tab) => tab.label), + activeIndex: this.activeIndex, + width, + colors: currentTheme.palette, + }); + const headerEnd = inner.findIndex((line) => line === ''); + const splitAt = headerEnd === -1 ? 3 : headerEnd; + const out: string[] = [...inner.slice(0, splitAt + 1), stripLine, '']; + for (let i = splitAt + 1; i < inner.length; i++) out.push(inner[i]!); + return out.map((line) => truncateToWidth(line, width)); + } + + override invalidate(): void { + super.invalidate(); + for (const tab of this.tabs) { + tab.selector.invalidate(); + } + } + + private syncFocusToActive(): void { + for (let i = 0; i < this.tabs.length; i++) { + const tab = this.tabs[i]!; + tab.selector.focused = this.focused && i === this.activeIndex; + } + } +} + +function buildTabs(opts: TabbedModelSelectorOptions): readonly ModelTab[] { + const entries = Object.entries(opts.models); + const providerIds: string[] = []; + const seen = new Set<string>(); + for (const [, model] of entries) { + const provider = model.provider; + if (!seen.has(provider)) { + seen.add(provider); + providerIds.push(provider); + } + } + + const tabs: ModelTab[] = [ + { + id: ALL_TAB_ID, + label: ALL_TAB_LABEL, + selector: makeSelector(opts, opts.models), + }, + ]; + for (const providerId of providerIds) { + const subset: Record<string, ModelAlias> = {}; + for (const [alias, model] of entries) { + if (model.provider === providerId) subset[alias] = model; + } + tabs.push({ + id: providerId, + label: providerDisplayName(providerId), + selector: makeSelector(opts, subset), + }); + } + return tabs; +} + +function makeSelector( + opts: TabbedModelSelectorOptions, + subset: Record<string, ModelAlias>, +): ModelSelectorComponent { + const candidate = opts.selectedValue ?? opts.currentValue; + const selectedValue = subset[candidate] !== undefined ? candidate : undefined; + const inner: ModelSelectorOptions = { + models: subset, + currentValue: opts.currentValue, + ...(selectedValue !== undefined ? { selectedValue } : {}), + currentThinkingEffort: opts.currentThinkingEffort, + title: opts.title, + searchable: true, + providerSwitchHint: true, + warning: opts.warning, + thinkingControl: opts.thinkingControl, + onSelect: opts.onSelect, + onSessionOnlySelect: opts.onSessionOnlySelect, + onCancel: opts.onCancel, + }; + return new ModelSelectorComponent(inner); +} diff --git a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad266fbb620fc5a0845a0c03ccf54bd0a4fb7e2e --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts @@ -0,0 +1,264 @@ +/** + * TaskOutputViewer — full-screen pi-tui rendered output viewer for + * a single background task. Replaces the previous "shell out to less" + * approach so the experience stays inside the TUI: same colors, same + * fonts, same redraw cycle, no alt-screen flip-flop. + * + * Mounted by `kimi-tui.ts` via nested container swap on top of the + * TasksBrowserApp. Snapshot view (no live tail) — content is fetched + * once when the viewer opens. + */ + +import { + Container, + Key, + matchesKey, + type Terminal, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; +import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; + +import { currentTheme } from '#/tui/theme'; +import { printableChar } from '@/tui/utils/printable-key'; +import { sanitizeShellOutput } from '#/tui/utils/shell-output'; + +const ELLIPSIS = '…'; + +export interface TaskOutputViewerProps { + readonly taskId: string; + readonly info: BackgroundTaskInfo | undefined; + readonly output: string; + readonly onClose: () => void; +} + +export const STATUS_LABEL: Record<BackgroundTaskStatus, string> = { + running: 'running', + completed: 'completed', + failed: 'failed', + timed_out: 'timed out', + killed: 'killed', + lost: 'lost', +}; + +export function statusColor(status: BackgroundTaskStatus): 'success' | 'textMuted' | 'error' { + switch (status) { + case 'running': + return 'success'; + case 'completed': + return 'textMuted'; + case 'failed': + case 'timed_out': + case 'killed': + case 'lost': + return 'error'; + } +} + +function padToWidth(line: string, width: number): string { + const w = visibleWidth(line); + if (w === width) return line; + if (w > width) return truncateToWidth(line, width, ELLIPSIS); + return line + ' '.repeat(width - w); +} + +function fitExactly(line: string, width: number): string { + let s = line; + if (visibleWidth(s) > width) s = truncateToWidth(s, width, ELLIPSIS); + return padToWidth(s, width); +} + +export class TaskOutputViewer extends Container implements Focusable { + focused = false; + + private props: TaskOutputViewerProps; + private readonly terminal: Terminal; + /** Output split on '\n'. Replaced on `setProps` when `output` changes. */ + private lines: string[]; + /** Index of the topmost visible line. */ + private scrollTop = 0; + + constructor(props: TaskOutputViewerProps, terminal: Terminal) { + super(); + this.props = props; + this.terminal = terminal; + this.lines = this.splitOutput(props.output); + } + + /** + * Update viewer props. When `output` grows (the watched task wrote + * new content), follow the tail like `less +F` if the user is parked + * at the bottom; otherwise keep the user's current scroll position + * so they can read history without being yanked around. + */ + setProps(next: TaskOutputViewerProps): void { + const previousOutput = this.props.output; + const wasAtBottom = this.scrollTop >= this.maxScroll(); + this.props = next; + if (next.output !== previousOutput) { + this.lines = this.splitOutput(next.output); + if (wasAtBottom) this.scrollTop = this.maxScroll(); + else this.scrollTop = Math.min(this.scrollTop, this.maxScroll()); + } + this.invalidate(); + } + + private splitOutput(output: string): string[] { + return (output.length > 0 ? sanitizeShellOutput(output) : '[no output captured]').split('\n'); + } + + // ── input ────────────────────────────────────────────────────────── + + handleInput(data: string): void { + const visible = this.viewableRows(); + const k = printableChar(data); + + if (matchesKey(data, Key.escape) || k === 'q' || k === 'Q') { + this.props.onClose(); + return; + } + if (matchesKey(data, Key.up) || k === 'k') { + this.scrollBy(-1); + return; + } + if (matchesKey(data, Key.down) || k === 'j') { + this.scrollBy(1); + return; + } + if ( + matchesKey(data, Key.pageUp) || + matchesKey(data, Key.ctrl('u')) || + k === ' ' || + data === '\u0002' /* C-b */ + ) { + this.scrollBy(-Math.max(1, visible - 1)); + return; + } + if ( + matchesKey(data, Key.pageDown) || + matchesKey(data, Key.ctrl('d')) || + data === '\u0006' /* C-f */ + ) { + this.scrollBy(Math.max(1, visible - 1)); + return; + } + if (matchesKey(data, Key.home) || k === 'g') { + this.scrollTo(0); + return; + } + if (matchesKey(data, Key.end) || k === 'G') { + this.scrollTo(this.maxScroll()); + return; + } + } + + private scrollBy(delta: number): void { + this.scrollTo(this.scrollTop + delta); + } + + private scrollTo(target: number): void { + this.scrollTop = Math.max(0, Math.min(target, this.maxScroll())); + this.invalidate(); + } + + private maxScroll(): number { + return Math.max(0, this.lines.length - this.viewableRows()); + } + + /** + * Number of content rows visible inside the body frame: total terminal + * rows minus header(1) + footer(1) + top border(1) + bottom border(1). + */ + private viewableRows(): number { + return Math.max(1, this.terminal.rows - 4); + } + + // ── render ───────────────────────────────────────────────────────── + + override render(width: number): string[] { + const rows = Math.max(3, this.terminal.rows); + const bodyHeight = rows - 2; + + const header = this.renderHeader(width); + const body = this.renderBody(width, bodyHeight); + const footer = this.renderFooter(width, bodyHeight); + + const out: string[] = [header]; + for (const line of body) out.push(line); + out.push(footer); + return out; + } + + private renderHeader(width: number): string { + const title = currentTheme.boldFg('primary', ' Task output '); + const id = currentTheme.boldFg('text', this.props.taskId); + const info = this.props.info; + const segments: string[] = []; + if (info !== undefined) { + segments.push(currentTheme.fg(statusColor(info.status), STATUS_LABEL[info.status])); + if (info.kind === 'process' && info.exitCode !== null) { + segments.push(currentTheme.fg('textMuted', `exit ${String(info.exitCode)}`)); + } + if (info.description && info.description.length > 0) { + segments.push(currentTheme.fg('textMuted', info.description)); + } + } + const composed = title + id + (segments.length > 0 ? ' ' + segments.join(' ') : ''); + return fitExactly(composed, width); + } + + private renderBody(width: number, bodyHeight: number): string[] { + // Reserve 1 col for left/right border each, 1 col for left padding. + const innerWidth = Math.max(1, width - 4); + + // Re-clamp scroll in case the terminal got resized smaller. + const max = this.maxScroll(); + if (this.scrollTop > max) this.scrollTop = max; + if (this.scrollTop < 0) this.scrollTop = 0; + + const viewRows = bodyHeight - 2; // inside top + bottom border + const top = currentTheme.fg('primary', '┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); + + const out: string[] = [top]; + for (let i = 0; i < viewRows; i++) { + const lineIndex = this.scrollTop + i; + const raw = this.lines[lineIndex] ?? ''; + const inner = fitExactly(currentTheme.fg('text', raw), innerWidth); + out.push(currentTheme.fg('primary', '│ ') + inner + currentTheme.fg('primary', ' │')); + } + out.push(bottom); + return out; + } + + private renderFooter(width: number, bodyHeight: number): string { + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); + + const total = this.lines.length; + const viewRows = Math.max(1, bodyHeight - 2); + const maxScroll = Math.max(0, total - viewRows); + const percent = + maxScroll === 0 ? 100 : Math.round((this.scrollTop / maxScroll) * 100); + const lineFrom = this.scrollTop + 1; + const lineTo = Math.min(total, this.scrollTop + viewRows); + + const position = currentTheme.fg( + 'textMuted', + ` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `, + ); + const keys = + `${key('↑↓')} ${dim('line')} ` + + `${key('PgUp/PgDn/Ctrl+U/D')} ${dim('page')} ` + + `${key('g/G')} ${dim('top/bot')} ` + + `${key('Q/Esc')} ${dim('cancel')}`; + const left = ` ${keys}`; + const leftW = visibleWidth(left); + const rightW = visibleWidth(position); + if (leftW + 2 + rightW <= width) { + return left + ' '.repeat(width - leftW - rightW) + position; + } + return fitExactly(left, width); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1943fda1f87589e67356f9349843be08d933560 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -0,0 +1,667 @@ +/** + * TasksBrowserApp — full-screen alt-screen takeover for browsing + * background tasks. Three-pane layout (left task list, right top + * detail, right bottom preview output) framed by a header row and + * footer key hint. + * + * Mounted by `kimi-tui.ts` via container swap rather than `showOverlay` + * — the main TUI's children are saved, cleared, and this component is + * added as the sole child so it covers the entire screen. The + * controller restores the children when the user exits. + * + * Data (tasks list, tail output) flows in via `setProps`; user actions + * fire the `on*` callbacks back to the controller. + */ + +import { + Container, + Key, + matchesKey, + type Terminal, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; +import type { + BackgroundTaskInfo, + BackgroundTaskStatus, + ModelAlias, +} from '@moonshot-ai/kimi-code-sdk'; + +import { SELECT_POINTER } from '@/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { printableChar } from '@/tui/utils/printable-key'; +import { sanitizeShellOutput } from '#/tui/utils/shell-output'; +import { modelDisplayName } from './model-selector'; + +const ELLIPSIS = '…'; + +export type TasksFilter = 'all' | 'active'; + +export interface TasksBrowserProps { + readonly tasks: readonly BackgroundTaskInfo[]; + readonly filter: TasksFilter; + readonly selectedTaskId: string | undefined; + readonly tailOutput: string | undefined; + readonly tailLoading: boolean; + readonly flashMessage: string | undefined; + /** Model catalog from the app config, used to resolve task model aliases + * to display names (same mapping as the other subagent surfaces). */ + readonly availableModels: Record<string, ModelAlias>; + readonly onSelect: (taskId: string) => void; + readonly onToggleFilter: () => void; + readonly onRefresh: () => void; + readonly onCancel: () => void; + /** Fired when the user confirms a stop request via the inline `y` prompt. */ + readonly onStopConfirmed: (taskId: string) => void; + /** Fired when the user presses Enter or O on a selected task. */ + readonly onOpenOutput: (taskId: string) => void; + /** Fired when stop is requested on a task that cannot be stopped. */ + readonly onStopIgnored?: (taskId: string, reason: 'terminal') => void; +} + +const STATUS_LABEL: Record<BackgroundTaskStatus, string> = { + running: 'running', + completed: 'completed', + failed: 'failed', + timed_out: 'timed out', + killed: 'killed', + lost: 'lost', +}; + +/** Auto-cancel the inline stop confirmation after this many ms. */ +const STOP_CONFIRM_TIMEOUT_MS = 5_000; + +/** Minimum dimensions before we just print a "too small" message. */ +const MIN_WIDTH = 48; +const MIN_HEIGHT = 10; + +/** Hard caps so a tiny / huge terminal still gets a sensible left-column width. */ +const LIST_COL_MIN = 28; +const LIST_COL_MAX = 44; +const LIST_COL_RATIO = 0.32; + +function statusColor(status: BackgroundTaskStatus): 'success' | 'textMuted' | 'error' { + switch (status) { + case 'running': + return 'success'; + case 'completed': + return 'textMuted'; + case 'failed': + case 'timed_out': + case 'killed': + case 'lost': + return 'error'; + } +} + +function isTerminal(status: BackgroundTaskStatus): boolean { + return ( + status === 'completed' || + status === 'failed' || + status === 'timed_out' || + status === 'killed' || + status === 'lost' + ); +} + +function formatRelativeTime(ts: number | null | undefined): string { + if (ts === null || ts === undefined || !Number.isFinite(ts) || ts <= 0) return ''; + const diffSec = Math.floor(Math.max(0, Date.now() - ts) / 1000); + if (diffSec < 60) return 'just now'; + const minutes = Math.floor(diffSec / 60); + if (minutes < 60) return `${String(minutes)}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${String(hours)}h ago`; + const days = Math.floor(hours / 24); + return `${String(days)}d ago`; +} + +function singleLine(text: string): string { + return text.replaceAll(/\s+/g, ' ').trim(); +} + +function padToWidth(line: string, width: number): string { + const w = visibleWidth(line); + if (w === width) return line; + if (w > width) return truncateToWidth(line, width, ELLIPSIS); + return line + ' '.repeat(width - w); +} + +/** Fit `line` into exactly `width` columns, even after CJK-edge truncation. */ +function fitExactly(line: string, width: number): string { + let s = line; + if (visibleWidth(s) > width) s = truncateToWidth(s, width, ELLIPSIS); + return padToWidth(s, width); +} + +function visibleTasks( + tasks: readonly BackgroundTaskInfo[], + filter: TasksFilter, +): BackgroundTaskInfo[] { + // The /tasks panel is for background task management. Foreground tasks + // (detached === false) are shown in the main transcript instead, and only + // appear here after being detached via Ctrl+B. `detached !== false` keeps + // reconcile ghosts whose `detached` field may be undefined. + const backgroundOnly = tasks.filter((t) => t.detached !== false); + if (filter === 'all') return [...backgroundOnly]; + return backgroundOnly.filter((t) => !isTerminal(t.status)); +} + +function compareTasks(a: BackgroundTaskInfo, b: BackgroundTaskInfo): number { + const aTerminal = isTerminal(a.status); + const bTerminal = isTerminal(b.status); + if (aTerminal !== bTerminal) return aTerminal ? 1 : -1; + if (!aTerminal) return a.startedAt - b.startedAt; + return (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt); +} + +interface StatusCounts { + running: number; + completed: number; + terminalFailed: number; +} + +function countByStatus(tasks: readonly BackgroundTaskInfo[]): StatusCounts { + const counts: StatusCounts = { running: 0, completed: 0, terminalFailed: 0 }; + for (const t of tasks) { + switch (t.status) { + case 'running': + counts.running += 1; + break; + case 'completed': + counts.completed += 1; + break; + case 'failed': + case 'timed_out': + case 'killed': + case 'lost': + counts.terminalFailed += 1; + break; + } + } + return counts; +} + +export class TasksBrowserApp extends Container implements Focusable { + focused = false; + + private props: TasksBrowserProps; + private readonly terminal: Terminal; + private sortedVisible: BackgroundTaskInfo[]; + private selectedIndex = 0; + private listScroll = 0; + private pendingStopTaskId: string | undefined = undefined; + private pendingStopTimer: NodeJS.Timeout | undefined = undefined; + + constructor(props: TasksBrowserProps, terminal: Terminal) { + super(); + this.props = props; + this.terminal = terminal; + this.sortedVisible = visibleTasks(props.tasks, props.filter).toSorted(compareTasks); + this.syncSelectionFromProps(); + } + + setProps(next: TasksBrowserProps): void { + this.props = next; + this.sortedVisible = visibleTasks(next.tasks, next.filter).toSorted(compareTasks); + this.syncSelectionFromProps(); + if (this.pendingStopTaskId !== undefined) { + const task = next.tasks.find((t) => t.taskId === this.pendingStopTaskId); + if (task === undefined || isTerminal(task.status)) this.clearPendingStop(); + } + this.invalidate(); + } + + private syncSelectionFromProps(): void { + if (this.sortedVisible.length === 0) { + this.selectedIndex = 0; + this.listScroll = 0; + return; + } + if (this.props.selectedTaskId !== undefined) { + const idx = this.sortedVisible.findIndex((t) => t.taskId === this.props.selectedTaskId); + if (idx !== -1) { + this.selectedIndex = idx; + return; + } + } + if (this.selectedIndex >= this.sortedVisible.length) { + this.selectedIndex = this.sortedVisible.length - 1; + } + } + + private clearPendingStop(): void { + this.pendingStopTaskId = undefined; + if (this.pendingStopTimer !== undefined) { + clearTimeout(this.pendingStopTimer); + this.pendingStopTimer = undefined; + } + } + + private emitSelect(): void { + const task = this.sortedVisible[this.selectedIndex]; + if (task) this.props.onSelect(task.taskId); + } + + handleInput(data: string): void { + const k = printableChar(data); + + if (this.pendingStopTaskId !== undefined) { + if (k === 'y' || k === 'Y') { + const taskId = this.pendingStopTaskId; + this.clearPendingStop(); + this.props.onStopConfirmed(taskId); + this.invalidate(); + return; + } + this.clearPendingStop(); + this.invalidate(); + return; + } + + if (matchesKey(data, Key.escape) || k === 'q' || k === 'Q') { + this.props.onCancel(); + return; + } + if (matchesKey(data, Key.up) || k === 'k') { + if (this.sortedVisible.length === 0) return; + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.emitSelect(); + this.invalidate(); + return; + } + if (matchesKey(data, Key.down) || k === 'j') { + if (this.sortedVisible.length === 0) return; + this.selectedIndex = Math.min(this.sortedVisible.length - 1, this.selectedIndex + 1); + this.emitSelect(); + this.invalidate(); + return; + } + if (matchesKey(data, Key.tab) || k === '\t') { + this.props.onToggleFilter(); + return; + } + if (k === 'r' || k === 'R') { + this.props.onRefresh(); + return; + } + if (k === 's' || k === 'S') { + const task = this.sortedVisible[this.selectedIndex]; + if (task === undefined) return; + if (isTerminal(task.status)) { + this.props.onStopIgnored?.(task.taskId, 'terminal'); + return; + } + this.pendingStopTaskId = task.taskId; + this.pendingStopTimer = setTimeout(() => { + this.clearPendingStop(); + this.invalidate(); + }, STOP_CONFIRM_TIMEOUT_MS); + this.invalidate(); + return; + } + if (k === 'o' || k === 'O' || matchesKey(data, Key.enter)) { + const task = this.sortedVisible[this.selectedIndex]; + if (task) this.props.onOpenOutput(task.taskId); + return; + } + } + + /** + * Render the entire screen as `terminal.rows` lines of `width` cols. + * Layout: header(1) + body(rows-2) + footer(1). + */ + override render(width: number): string[] { + const rows = Math.max(1, this.terminal.rows); + if (width < MIN_WIDTH || rows < MIN_HEIGHT) { + return this.renderTooSmall(width, rows); + } + + const header = this.renderHeader(width); + const footer = this.renderFooter(width); + const bodyHeight = rows - 2; + + const listWidth = Math.max( + LIST_COL_MIN, + Math.min(LIST_COL_MAX, Math.floor(width * LIST_COL_RATIO)), + ); + const rightWidth = width - listWidth; + + const listFrame = this.renderListFrame(listWidth, bodyHeight); + const rightFrames = this.renderRightStack(rightWidth, bodyHeight); + + const lines: string[] = [header]; + for (let i = 0; i < bodyHeight; i++) { + lines.push((listFrame[i] ?? ' '.repeat(listWidth)) + (rightFrames[i] ?? ' '.repeat(rightWidth))); + } + lines.push(footer); + return lines; + } + + // ── header / footer ────────────────────────────────────────────────── + + private renderHeader(width: number): string { + const title = currentTheme.boldFg('primary', ' TASK BROWSER '); + const filterText = currentTheme.fg( + 'textMuted', + ` filter=${this.props.filter === 'all' ? 'ALL' : 'ACTIVE'} `, + ); + // Count only the tasks actually listed (background tasks after the + // foreground-task filter), so a foreground-only session doesn't read + // "1 running / 1 total" above an empty list. + const visible = visibleTasks(this.props.tasks, this.props.filter); + const counts = countByStatus(visible); + const countSegments: string[] = []; + if (counts.running > 0) + countSegments.push(currentTheme.fg('success', ` ${String(counts.running)} running `)); + if (counts.completed > 0) + countSegments.push(currentTheme.fg('textDim', ` ${String(counts.completed)} completed `)); + if (counts.terminalFailed > 0) + countSegments.push( + currentTheme.fg('error', ` ${String(counts.terminalFailed)} interrupted `), + ); + const totals = currentTheme.fg('textMuted', ` ${String(visible.length)} total `); + + const composed = title + filterText + countSegments.join('') + totals; + return fitExactly(composed, width); + } + + private renderFooter(width: number): string { + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); + + if (this.pendingStopTaskId !== undefined) { + const warn = (text: string): string => currentTheme.boldFg('warning', text); + const line = + ` ${warn('Stop')} ${currentTheme.fg('text', this.pendingStopTaskId)}? ` + + `${key('Y')} ${dim('confirm')} ${key('N')}${dim('/')}${key('esc')} ${dim('cancel')} `; + return fitExactly(line, width); + } + + const parts = [ + ` ${key('↑↓')} ${dim('select')}`, + `${key('Enter/O')} ${dim('output')}`, + `${key('S')} ${dim('stop')}`, + `${key('R')} ${dim('refresh')}`, + `${key('Tab')} ${dim('filter')}`, + `${key('Q/Esc')} ${dim('cancel')} `, + ]; + const left = parts.join(' '); + const flash = this.props.flashMessage; + if (flash !== undefined && flash.length > 0) { + const flashStyled = currentTheme.fg('warning', ` ${flash} `); + const total = visibleWidth(left) + visibleWidth(flashStyled); + if (total <= width) { + return left + ' '.repeat(width - total) + flashStyled; + } + } + return fitExactly(left, width); + } + + // ── frame primitive ────────────────────────────────────────────────── + + /** + * Render a framed box: `┌─ Title ─┐` top, `│ <content> │` sides, `└─┘` + * bottom. Result is exactly `width × height` cells. `content` is a + * pre-rendered array of inner-width-sized lines; extra rows are padded. + */ + private renderFrame( + title: string, + content: readonly string[], + width: number, + height: number, + ): string[] { + if (height < 2 || width < 4) { + const out: string[] = []; + for (let i = 0; i < height; i++) out.push(' '.repeat(width)); + return out; + } + const innerWidth = width - 2; + const innerHeight = height - 2; + + const titleStyled = currentTheme.boldFg('textStrong', title); + const titleWidth = visibleWidth(titleStyled); + const titleSegment = `─ ${titleStyled} `; + const titleSegmentWidth = visibleWidth(titleSegment); + const remainingDashes = Math.max(0, innerWidth - titleSegmentWidth); + const topMid = + titleWidth > 0 && titleSegmentWidth <= innerWidth + ? currentTheme.fg('primary', '─ ') + + titleStyled + + ' ' + + currentTheme.fg('primary', '─'.repeat(remainingDashes)) + : currentTheme.fg('primary', '─'.repeat(innerWidth)); + const top = currentTheme.fg('primary', '┌') + topMid + currentTheme.fg('primary', '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(innerWidth) + '┘'); + + const lines: string[] = [top]; + for (let i = 0; i < innerHeight; i++) { + const inner = content[i] ?? ''; + lines.push(currentTheme.fg('primary', '│') + fitExactly(inner, innerWidth) + currentTheme.fg('primary', '│')); + } + lines.push(bottom); + return lines; + } + + // ── left: task list frame ──────────────────────────────────────────── + + private renderListFrame(width: number, height: number): string[] { + const title = `Tasks [${this.props.filter}]`; + const innerHeight = Math.max(0, height - 2); + + if (this.sortedVisible.length === 0) { + const empty = + this.props.filter === 'active' + ? 'No active tasks. Tab = show all.' + : 'No background tasks in this session.'; + const lines: string[] = [currentTheme.fg('textMuted', empty)]; + while (lines.length < innerHeight) lines.push(''); + return this.renderFrame(title, lines, width, height); + } + + this.adjustScroll(innerHeight); + + const innerWidth = width - 2; + const allLines: string[] = []; + for (const [index, task] of this.sortedVisible.entries()) { + allLines.push(this.renderListRow(task, index === this.selectedIndex, innerWidth)); + const modelText = this.agentModelText(task); + if (modelText !== undefined) { + allLines.push(this.renderModelRow(modelText, innerWidth)); + } + } + const lines = allLines.slice(this.listScroll, this.listScroll + innerHeight); + while (lines.length < innerHeight) lines.push(''); + + return this.renderFrame(title, lines, width, height); + } + + private renderListRow(task: BackgroundTaskInfo, selected: boolean, innerWidth: number): string { + const pointer = selected ? `${SELECT_POINTER} ` : ' '; + const pointerStyled = currentTheme.fg(selected ? 'primary' : 'textDim', pointer); + + const idColor = selected + ? 'primary' + : task.kind === 'agent' + ? 'success' + : task.kind === 'question' + ? 'warning' + : 'accent'; + const idText = selected + ? currentTheme.boldFg(idColor, task.taskId) + : currentTheme.fg(idColor, task.taskId); + const idPad = ' '.repeat(Math.max(0, 17 - task.taskId.length)); + + const status = STATUS_LABEL[task.status]; + const statusBadge = currentTheme.fg(statusColor(task.status), status); + + const prefix = `${pointerStyled}${idText}${idPad} ${statusBadge}`; + const prefixWidth = visibleWidth(prefix); + const descBudget = Math.max(0, innerWidth - prefixWidth - 1); + if (descBudget < 4) return fitExactly(prefix, innerWidth); + + const description = + singleLine(task.description) || + (task.kind === 'process' ? singleLine(task.command) : '') || + '(no description)'; + const desc = truncateToWidth(description, descBudget, ELLIPSIS); + return fitExactly(`${prefix} ${currentTheme.fg('text', desc)}`, innerWidth); + } + + /** Secondary line under an agent task's row: the model it runs on, resolved + * through the model catalog like the other subagent surfaces. */ + private agentModelText(task: BackgroundTaskInfo): string | undefined { + if (task.kind !== 'agent' || task.model === undefined) return undefined; + const name = modelDisplayName(task.model, this.props.availableModels[task.model]); + return name.length === 0 ? undefined : name; + } + + private renderModelRow(text: string, innerWidth: number): string { + const indent = ' '; + const clipped = truncateToWidth(text, Math.max(0, innerWidth - indent.length), ELLIPSIS); + return indent + currentTheme.fg('textMuted', clipped); + } + + // Agent tasks with a bound model take two lines (row + model line), so + // scrolling is tracked in rendered lines rather than task indices. + private taskLineStarts(): { starts: number[]; total: number } { + const starts: number[] = []; + let total = 0; + for (const task of this.sortedVisible) { + starts.push(total); + total += this.agentModelText(task) === undefined ? 1 : 2; + } + return { starts, total }; + } + + private adjustScroll(visibleRows: number): void { + if (visibleRows <= 0) { + this.listScroll = 0; + return; + } + const { starts, total } = this.taskLineStarts(); + const selectedStart = starts[this.selectedIndex] ?? 0; + const selectedEnd = (starts[this.selectedIndex + 1] ?? total) - 1; + if (selectedStart < this.listScroll) { + this.listScroll = selectedStart; + } else if (selectedEnd >= this.listScroll + visibleRows) { + this.listScroll = selectedEnd - visibleRows + 1; + } + const maxScroll = Math.max(0, total - visibleRows); + if (this.listScroll < 0) this.listScroll = 0; + if (this.listScroll > maxScroll) this.listScroll = maxScroll; + } + + // ── right: detail + preview stack ──────────────────────────────────── + + private renderRightStack(width: number, height: number): string[] { + // Detail wants ~10 rows (or 40% of body, whichever is larger) — agent tasks + // carry Task ID / Status / Description / Agent ID / Agent type / Model / + // Effort / Time. Clamp it so the preview frame keeps its borders plus one + // content row even near the minimum terminal height. + const detailHeight = Math.min( + Math.max(10, Math.min(Math.floor(height * 0.4), height - 5)), + Math.max(3, height - 3), + ); + const previewHeight = height - detailHeight; + return [ + ...this.renderDetailFrame(width, detailHeight), + ...this.renderPreviewFrame(width, previewHeight), + ]; + } + + private renderDetailFrame(width: number, height: number): string[] { + const innerHeight = Math.max(0, height - 2); + const task = this.sortedVisible[this.selectedIndex]; + if (task === undefined) { + const empty = currentTheme.fg('textMuted', 'Select a task from the list.'); + const lines: string[] = [empty]; + while (lines.length < innerHeight) lines.push(''); + return this.renderFrame('Detail', lines, width, height); + } + + const label = (text: string): string => currentTheme.fg('textMuted', text.padEnd(14)); + const value = (text: string): string => currentTheme.fg('text', text); + + const lines: string[] = [ + `${label('Task ID:')}${value(task.taskId)}`, + `${label('Status:')}${currentTheme.fg(statusColor(task.status), STATUS_LABEL[task.status])}`, + `${label('Description:')}${value(singleLine(task.description) || '—')}`, + ]; + if (task.kind === 'process' && task.command && task.command !== task.description) { + lines.push(`${label('Command:')}${value(singleLine(task.command))}`); + } + if (task.kind === 'agent' && task.agentId !== undefined) { + lines.push(`${label('Agent ID:')}${value(task.agentId)}`); + } + if (task.kind === 'agent' && task.subagentType !== undefined) { + lines.push(`${label('Agent type:')}${value(task.subagentType)}`); + } + if (task.kind === 'agent' && task.model !== undefined) { + lines.push(`${label('Model:')}${value(this.agentModelText(task) ?? task.model)}`); + } + if (task.kind === 'agent' && task.thinkingEffort !== undefined) { + lines.push(`${label('Effort:')}${value(task.thinkingEffort)}`); + } + if (task.kind === 'question') { + lines.push(`${label('Questions:')}${currentTheme.fg('textMuted', String(task.questionCount))}`); + if (task.toolCallId !== undefined) { + lines.push(`${label('Tool call:')}${currentTheme.fg('textMuted', task.toolCallId)}`); + } + } + const timing = + task.status === 'running' + ? `running ${formatRelativeTime(task.startedAt)}` + : task.endedAt !== null && task.endedAt !== undefined + ? `finished ${formatRelativeTime(task.endedAt)}` + : ''; + if (timing.length > 0) lines.push(`${label('Time:')}${currentTheme.fg('textMuted', timing)}`); + if (task.kind === 'process' && task.pid > 0) { + lines.push(`${label('Pid:')}${currentTheme.fg('textMuted', String(task.pid))}`); + } + if (task.kind === 'process' && task.exitCode !== null) { + lines.push(`${label('Exit code:')}${currentTheme.fg('textMuted', String(task.exitCode))}`); + } + if (task.stopReason !== undefined && task.stopReason.length > 0) { + lines.push(`${label('Reason:')}${currentTheme.fg('textMuted', task.stopReason)}`); + } + while (lines.length < innerHeight) lines.push(''); + return this.renderFrame('Detail', lines, width, height); + } + + private renderPreviewFrame(width: number, height: number): string[] { + const innerHeight = Math.max(0, height - 2); + const task = this.sortedVisible[this.selectedIndex]; + if (task === undefined) { + const lines: string[] = [currentTheme.fg('textMuted', 'No task selected.')]; + while (lines.length < innerHeight) lines.push(''); + return this.renderFrame('Preview Output', lines, width, height); + } + + let body: string; + if (this.props.tailLoading) body = '[loading…]'; + else if (this.props.tailOutput === undefined || this.props.tailOutput.length === 0) + body = '[no output captured]'; + else body = sanitizeShellOutput(this.props.tailOutput); + + const rawLines = body.split('\n'); + const tailLines = rawLines.slice(-innerHeight); + const styled = tailLines.map((line) => currentTheme.fg('textDim', line)); + while (styled.length < innerHeight) styled.push(''); + return this.renderFrame('Preview Output', styled, width, height); + } + + // ── too-small fallback ────────────────────────────────────────────── + + private renderTooSmall(width: number, rows: number): string[] { + const lines: string[] = []; + const msg = currentTheme.fg( + 'error', + `Terminal too small (need ≥ ${String(MIN_WIDTH)} × ${String(MIN_HEIGHT)})`, + ); + lines.push(fitExactly(msg, width)); + for (let i = 1; i < rows; i++) lines.push(' '.repeat(width)); + return lines; + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts b/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecd3953fd8658a785ce3abbcf2b85a450faeb5d1 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts @@ -0,0 +1,35 @@ +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +import { listCustomThemesSync } from '#/tui/theme/custom-theme-loader'; +import type { ThemeName } from '#/tui/theme/index'; + +const THEME_OPTIONS: readonly ChoiceOption[] = [ + { value: 'auto', label: 'Auto (match terminal)' }, + { value: 'dark', label: 'Dark' }, + { value: 'light', label: 'Light' }, +]; + +export interface ThemeSelectorOptions { + readonly currentValue: ThemeName; + readonly onSelect: (theme: ThemeName) => void; + readonly onCancel: () => void; +} + +export class ThemeSelectorComponent extends ChoicePickerComponent { + constructor(opts: ThemeSelectorOptions) { + const customThemes = listCustomThemesSync(); + const options: ChoiceOption[] = [ + ...THEME_OPTIONS, + ...customThemes.map((name) => ({ value: name, label: `Custom: ${name}` })), + ]; + super({ + title: 'Select theme', + options, + currentValue: opts.currentValue, + onSelect: (value) => { + opts.onSelect(value); + }, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts new file mode 100644 index 0000000000000000000000000000000000000000..a42256a8998039caa8df2166311d9fcd25dab79e --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts @@ -0,0 +1,140 @@ +import { + Key, + matchesKey, + truncateToWidth, + wrapTextWithAnsi, + type Component, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import type { WorkspaceTrustMcpServerInfo } from '@moonshot-ai/kimi-code-sdk'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; + +export type TrustPromptChoice = 'trust' | 'distrust'; + +export interface TrustPromptOptions { + readonly workDir: string; + /** Project-level MCP servers that trusting would enable; may be empty. */ + readonly gatedMcpServers: readonly WorkspaceTrustMcpServerInfo[]; + /** Esc resolves to 'distrust' as well. */ + readonly onSelect: (choice: TrustPromptChoice) => void; +} + +interface TrustPromptOption { + readonly value: TrustPromptChoice; + readonly label: string; + readonly description: string; +} + +const OPTIONS: readonly TrustPromptOption[] = [ + { + value: 'trust', + label: 'Trust this folder', + description: 'Enable project MCP servers. Remembered for this folder.', + }, + { + value: 'distrust', + label: "Don't trust", + description: 'Exit Kimi Code. Asked again next launch.', + }, +]; + +export class TrustPromptComponent implements Component, Focusable { + focused = false; + private selectedIndex = 0; + + constructor(private readonly opts: TrustPromptOptions) {} + + invalidate(): void {} + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onSelect('distrust'); + return; + } + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(OPTIONS.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { + this.opts.onSelect(OPTIONS[this.selectedIndex]!.value); + } + } + + render(width: number): string[] { + const rule = currentTheme.fg('primary', '─'.repeat(width)); + const lines = [ + rule, + currentTheme.boldFg('primary', ' Trust this folder?'), + currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc exit'), + '', + ...wrapTextWithAnsi(this.opts.workDir, Math.max(20, width - 2)).map( + (line) => ` ${currentTheme.fg('textStrong', line)}`, + ), + '', + ]; + + const notice = + 'Project-level MCP servers are disabled until you explicitly choose Trust. Trust starts the listed project MCP targets and remembers this folder.'; + for (const line of wrapTextWithAnsi(notice, Math.max(20, width - 2))) { + lines.push(` ${currentTheme.fg('textMuted', line)}`); + } + if (this.opts.gatedMcpServers.length > 0) { + lines.push(` ${currentTheme.fg('warning', 'Project MCP targets:')}`); + for (const server of this.opts.gatedMcpServers) { + const details = formatMcpTarget(server); + for (const line of wrapTextWithAnsi(details, Math.max(20, width - 4))) { + lines.push(` ${currentTheme.fg('warning', line)}`); + } + } + } + lines.push(''); + + for (let i = 0; i < OPTIONS.length; i += 1) { + const option = OPTIONS[i]!; + const selected = i === this.selectedIndex; + const pointer = selected ? SELECT_POINTER : ' '; + const label = selected + ? currentTheme.boldFg('primary', option.label) + : currentTheme.fg('text', option.label); + lines.push(currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `) + label); + for (const line of wrapTextWithAnsi(option.description, Math.max(20, width - 4))) { + lines.push(` ${currentTheme.fg('textMuted', line)}`); + } + lines.push(''); + } + + lines.push(rule); + return lines.map((line) => truncateToWidth(line, width)); + } +} + +function formatMcpTarget(server: WorkspaceTrustMcpServerInfo): string { + if (server.transport === 'stdio') { + const args = server.args === undefined ? '' : ` args=${JSON.stringify(server.args)}`; + const cwd = server.cwd === undefined ? '' : ` cwd=${server.cwd}`; + return sanitizeForDisplay(`${server.name} (stdio): command=${server.command ?? ''}${args}${cwd}`); + } + return sanitizeForDisplay(`${server.name} (${server.transport}): url=${server.url ?? ''}`); +} + +/** + * Drops C0/C1 control characters (including ESC) from workspace-supplied text: + * the trust prompt renders before the workspace is trusted, so a planted + * `.mcp.json` must not inject terminal control sequences into it. + */ +function sanitizeForDisplay(value: string): string { + let result = ''; + for (const char of value) { + const code = char.codePointAt(0) ?? 0; + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) continue; + result += char; + } + return result; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..37320f92bde603a6df7b287424b0bc7498774480 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts @@ -0,0 +1,120 @@ +import { + Container, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { SearchableList } from '#/tui/utils/searchable-list'; + +const MAX_VISIBLE_CHOICES = 5; +const PREFERRED_SELECTED_OFFSET = 2; + +export interface UndoChoice { + readonly id: string; + readonly count: number; + readonly input: string; + readonly label: string; +} + +export interface UndoSelectorOptions { + readonly choices: readonly UndoChoice[]; + readonly onSelect: (choice: UndoChoice) => void; + readonly onCancel: () => void; +} + +export class UndoSelectorComponent extends Container implements Focusable { + focused = false; + private readonly opts: UndoSelectorOptions; + private readonly list: SearchableList<UndoChoice>; + private submitted = false; + + constructor(opts: UndoSelectorOptions) { + super(); + this.opts = opts; + this.list = new SearchableList({ + items: opts.choices, + toSearchText: (choice) => choice.label, + initialIndex: Math.max(0, opts.choices.length - 1), + }); + } + + handleInput(data: string): void { + if (this.submitted) return; + + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + + if (this.list.handleKey(data)) { + return; + } + + if (matchesKey(data, Key.enter)) { + const selected = this.list.selected(); + if (selected !== undefined) { + this.submitted = true; + this.opts.onSelect(selected); + } + } + } + + override render(width: number): string[] { + const view = this.list.view(); + const hintParts = ['↑↓ navigate', 'Enter select', 'Esc cancel']; + + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Select messages to undo'), + currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), + '', + ]; + + if (view.items.length === 0) { + lines.push(currentTheme.fg('textMuted', ' No messages')); + } else { + const visibleCount = Math.min(MAX_VISIBLE_CHOICES, view.items.length); + const maxStart = view.items.length - visibleCount; + const start = Math.min( + Math.max(0, view.selectedIndex - PREFERRED_SELECTED_OFFSET), + maxStart, + ); + const end = start + visibleCount; + + for (let i = start; i < end; i++) { + const choice = view.items[i]; + if (choice === undefined) continue; + lines.push( + this.renderChoiceLine(choice, i === view.selectedIndex, i > view.selectedIndex, width), + ); + } + } + + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } + + private renderChoiceLine( + choice: UndoChoice, + isSelected: boolean, + inUndoRange: boolean, + width: number, + ): string { + const pointer = isSelected ? SELECT_POINTER : ' '; + const prefix = ` ${pointer} `; + const labelBudget = Math.max(8, width - visibleWidth(prefix)); + const label = truncateToWidth(choice.label, labelBudget, '…'); + const token = isSelected ? 'primary' : inUndoRange ? 'textDim' : 'text'; + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', prefix); + line += isSelected + ? currentTheme.boldFg(token, label) + : currentTheme.fg(token, label); + return line; + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts b/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..35055e084c62fc609faa469a7b53cc1de2587391 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts @@ -0,0 +1,34 @@ +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +const UPDATE_PREFERENCE_OPTIONS: readonly ChoiceOption[] = [ + { + value: 'on', + label: 'On', + description: 'Install new versions in the background.', + }, + { + value: 'off', + label: 'Off', + description: 'Show the install prompt instead.', + }, +]; + +export interface UpdatePreferenceSelectorOptions { + readonly currentValue: boolean; + readonly onSelect: (value: boolean) => void; + readonly onCancel: () => void; +} + +export class UpdatePreferenceSelectorComponent extends ChoicePickerComponent { + constructor(opts: UpdatePreferenceSelectorOptions) { + super({ + title: 'Automatic updates', + options: [...UPDATE_PREFERENCE_OPTIONS], + currentValue: opts.currentValue ? 'on' : 'off', + onSelect: (value) => { + opts.onSelect(value === 'on'); + }, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfe895cc99bf7c46b8d491fd240a66ffed55a15f --- /dev/null +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -0,0 +1,897 @@ +/** + * Custom editor extending pi-tui Editor with app-level keybindings. + */ + +import { + Editor, + isKeyRelease, + matchesKey, + Key, + SelectList, + visibleWidth, + type SelectItem, + type TUI, +} from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; +import { printableChar } from '#/tui/utils/printable-key'; + +import { extractAtPrefix } from './file-mention-provider'; +import { findInlineSkillTokens } from '../../utils/inline-skill-tokens'; +import { WrappingSelectList } from './wrapping-select-list'; + +// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences +const ANSI_SGR = /\u001B\[[0-9;]*m/g; + +const PASTE_MARKER_RE = /\[paste #(\d+)(?: (?:\+\d+ lines|\d+ chars))?\]/g; +const BRACKET_PASTE_START = '\u001B[200~'; +const BRACKET_PASTE_END = '\u001B[201~'; + +// Kitty keyboard protocol CSI-u sequence: ESC [ keycode ; modifier[:eventType] u. +// We intentionally match only the simple two-field form — enough to rewrite +// `ctrl+<LETTER>` with caps_lock into `ctrl+<letter>` without caps_lock. +// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match CSI +const KITTY_CSI_U = /^\u001B\[(\d+);(\d+)((?::\d+)*)u$/; +// Kitty modifier bit layout: shift=1, alt=2, ctrl=4, super=8, hyper=16, +// meta=32, caps_lock=64, num_lock=128. Reported value is `mask + 1`. +const CAPS_LOCK_BIT = 64; +const CTRL_BIT = 4; +const SHIFT_BIT = 1; + +interface AutocompleteInternals { + cancelAutocomplete(): void; + readonly autocompleteAbort?: AbortController; + readonly autocompleteDebounceTimer?: ReturnType<typeof setTimeout>; +} + +interface AutocompleteListFactoryInternals { + createAutocompleteList?: (prefix: string, items: SelectItem[]) => SelectList; +} + +interface AutocompleteTriggerInternals { + tryTriggerAutocomplete: (explicitTab?: boolean) => void; + requestAutocomplete: (options: { force: boolean; explicitTab: boolean }) => void; +} + +// Mirror pi-tui's private SLASH_COMMAND_SELECT_LIST_LAYOUT +// (dist/components/editor.js); keep in sync when bumping pi-tui. +const SLASH_COMMAND_SELECT_LIST_LAYOUT = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +} as const; + +/** + * Workaround for a pi-tui bug that surfaces when Kitty keyboard protocol + * is active AND caps_lock is on. In that state terminals emit, e.g., + * `ESC[68;69u` for ctrl+d (codepoint=68=`D`, modifier=ctrl|caps_lock). + * pi-tui's `matchesKittySequence` masks `caps_lock` out of the *modifier* + * but leaves the *codepoint* capitalised, so `matchesKey(data, "ctrl+d")` + * (which expects codepoint=100=`d`) fails and every ctrl-shortcut is + * silently dropped. + * + * We rewrite the sequence back to its unlocked form before dispatching, + * but only when ctrl is held and shift is not — i.e. exactly the + * `ctrl+<letter>` case. Plain uppercase (caps_lock only, no ctrl) and + * explicit ctrl+shift+<letter> are left alone. + */ +export function normalizeCapsLockedCtrl(data: string): string { + const m = data.match(KITTY_CSI_U); + if (m === null) return data; + const codepoint = Number(m[1]); + const modifierPlus1 = Number(m[2]); + const tail = m[3] ?? ''; + if (!Number.isFinite(codepoint) || !Number.isFinite(modifierPlus1)) return data; + const modifier = modifierPlus1 - 1; + if ((modifier & CAPS_LOCK_BIT) === 0) return data; + if ((modifier & CTRL_BIT) === 0) return data; + if ((modifier & SHIFT_BIT) !== 0) return data; + if (codepoint < 65 || codepoint > 90) return data; + const loweredCodepoint = codepoint + 32; + const strippedModifier = (modifier & ~CAPS_LOCK_BIT) + 1; + return `\u001B[${String(loweredCodepoint)};${String(strippedModifier)}${tail}u`; +} + +/** Convert a visible-char index (ANSI-stripped) back to an index into the raw ANSI-bearing string. */ +function mapVisibleIdxToRaw(line: string, visibleIdx: number): number { + let visibleCount = 0; + let i = 0; + const re = new RegExp(ANSI_SGR.source, 'y'); + while (i < line.length && visibleCount < visibleIdx) { + re.lastIndex = i; + const m = re.exec(line); + if (m !== null && m.index === i) { + i += m[0].length; + } else { + visibleCount++; + i++; + } + } + return i; +} + +function stripSgr(s: string): string { + return s.replace(ANSI_SGR, ''); +} + +interface CustomEditorOptions { + disablePasteBurst?: boolean; +} + +export class CustomEditor extends Editor { + public onEscape?: () => void; + /** + * Fired for every input that is not a lone Escape. Used to disarm a pending + * double-Esc so only two consecutive Escape presses trigger the shortcut. + */ + public onNonEscapeInput?: () => void; + public onPreInput?: (data: string) => boolean; + public onCtrlD?: () => void; + public onCtrlC?: () => void; + public onToggleToolExpand?: () => void; + public onOpenExternalEditor?: () => void; + public onCtrlS?: () => void; + /** Return `true` to consume Ctrl+B; return `false`/`undefined` to fall through to the editor default (cursor-left). */ + public onCtrlB?: () => boolean; + /** Return `true` to consume Ctrl+T (the todo list had overflow to toggle); return `false`/`undefined` to fall through to the editor default. */ + public onToggleTodoExpand?: () => boolean; + /** Return true to consume Ctrl+N (the Updates panel grabbed or released focus); otherwise use the editor bindings. */ + public onPageNotify?: () => boolean; + /** Route `←`/`→`/`↑`/`↓`/`Esc` to the focused Updates panel; return `true` to consume. */ + public onNotifyPanelKey?: (key: 'left' | 'right' | 'up' | 'down' | 'escape') => boolean; + public onUndo?: () => void; + public onTextPaste?: () => void; + /** + * Called when ↑ is pressed in an empty editor. Return `true` to consume + * the key (e.g. recalled a queued message); return `false` to fall + * through so pi-tui's built-in history navigation runs. + */ + public onUpArrowEmpty?: () => boolean; + public onDownArrowEmpty?: () => boolean; + public onShiftTab?: () => void; + /** 'bash' when entering a `!` shell command. The `!` is never part of the + * text buffer — it is a separate mode + prompt symbol (see handleInput). */ + public inputMode: 'prompt' | 'bash' = 'prompt'; + public onInputModeChange?: (mode: 'prompt' | 'bash') => void; + public connectedAbove = false; + public borderHighlighted = false; + /** + * Called when the user triggers "paste image" (Ctrl-V on Unix, + * Alt-V on Windows — Ctrl-V is terminal-reserved there). Return + * `true` to consume the key (image was read and handled); return + * `false` to let the key fall through to the normal paste path. + * The callback may be async; CustomEditor queues subsequent keystrokes until + * it settles before dispatching them. + */ + public onPasteImage?: () => Promise<boolean>; + + private consumingPaste = false; + private consumeBuffer = ''; + /** Serialize paste callbacks so Enter/typing cannot overtake an image paste. */ + private pasteInFlight = false; + private readonly pasteInputQueue: string[] = []; + private argumentHints: ReadonlyMap<string, string> = new Map(); + private skillCommandNames: ReadonlySet<string> = new Set(); + + setArgumentHints(hints: ReadonlyMap<string, string>): void { + this.argumentHints = hints; + } + + setSkillCommandNames(names: ReadonlySet<string>): void { + this.skillCommandNames = names; + } + + constructor(tui: TUI, options: CustomEditorOptions = {}) { + // paddingX: 4 reserves column 0 for the left vertical border (│), + // column 1 as a single space between border and prompt, column 2 for + // the `>` prompt token, and column 3 as the space between prompt and + // content. The right side mirrors with 3 padding columns and the right + // border at the last column. + const theme = createEditorTheme(); + super(tui, theme, { + paddingX: 4, + disablePasteBurst: options.disablePasteBurst, + inlineSlashTrigger: true, + }); + + // pi-tui keeps `createAutocompleteList` private; shadow it with an + // instance property so slash command menus render descriptions wrapped + // to at most two lines. Non-slash completion (paths, @ mentions) keeps + // pi-tui's single-line list. + (this as unknown as AutocompleteListFactoryInternals).createAutocompleteList = ( + prefix, + items, + ) => { + if (prefix.startsWith('/')) { + return new WrappingSelectList( + items, + this.getAutocompleteMaxVisible(), + theme.selectList, + SLASH_COMMAND_SELECT_LIST_LAYOUT, + ); + } + return new SelectList(items, this.getAutocompleteMaxVisible(), theme.selectList); + }; + + // pi-tui auto-triggers autocomplete for `/` (and letters in a slash + // context) with force:false, which routes through the slash-command + // branch. In bash mode `/` is a path separator, not a command prefix, so + // shadow the trigger to request file path completion (force:true) instead. + // Prompt mode keeps the original force:false behaviour. `tryTriggerAutocomplete` + // is private in pi-tui's typings but a plain prototype method at runtime. + const triggerInternals = this as unknown as AutocompleteTriggerInternals; + triggerInternals.tryTriggerAutocomplete = (explicitTab = false) => { + triggerInternals.requestAutocomplete({ force: this.inputMode === 'bash', explicitTab }); + }; + } + + override setDisablePasteBurst(disabled: boolean): void { + super.setDisablePasteBurst(disabled); + } + + public setInputMode(mode: 'prompt' | 'bash'): void { + if (this.inputMode === mode) return; + this.inputMode = mode; + this.onInputModeChange?.(mode); + } + + private expandPasteMarkerAtCursor(): boolean { + const { line, col } = this.getCursor(); + const lines = this.getLines(); + const currentLine = lines[line] ?? ''; + + for (const match of currentLine.matchAll(PASTE_MARKER_RE)) { + const start = match.index; + const end = start + match[0].length; + if (col < start || col > end) continue; + + const pasteId = Number(match[1]); + const pastes = (this as unknown as { pastes: Map<number, string> }).pastes; + const content = pastes.get(pasteId); + if (content === undefined) return false; + + const text = this.getText(); + const offset = lines.slice(0, line).reduce((sum, l) => sum + l.length + 1, 0) + start; + const newText = text.slice(0, offset) + content + text.slice(offset + match[0].length); + // Keep the paste registry intact: the text still holds other live markers + // whose entries a plain setText would drop (upstream resets the registry). + this.setText(newText, { preservePasteRegistry: true }); + return true; + } + return false; + } + + public hasAutocompleteActivity(): boolean { + const autocomplete = this as unknown as AutocompleteInternals; + return ( + this.isShowingAutocomplete() || + autocomplete.autocompleteAbort !== undefined || + autocomplete.autocompleteDebounceTimer !== undefined + ); + } + + private cancelAutocompleteActivity(): void { + // pi-tui exposes `isShowingAutocomplete()` but keeps cancellation private. + // Kimi needs Esc to win over app-level cancel while the slash menu request is active. + (this as unknown as AutocompleteInternals).cancelAutocomplete(); + } + + override render(width: number): string[] { + const lines = super.render(width); + if (lines.length < 3) return lines; + const firstContentIdx = 1; + const isBash = this.inputMode === 'bash'; + const text = this.getText().trimStart(); + if (!isBash) { + // Paint the leading slash command on the first content line only, then + // inline skill tokens on every content line (multi-line prompts can + // reference skills anywhere). + const original = lines[firstContentIdx]; + if (original !== undefined) { + let highlighted = original; + let leadingRange: { start: number; end: number } | null = null; + if (text.startsWith('/')) { + leadingRange = leadingSlashTokenRange(stripSgr(original)); + const leading = highlightFirstSlashToken(original, 'primary'); + if (leading !== undefined) { + highlighted = leading; + } + } + const inline = highlightInlineSkillTokens( + highlighted, + this.skillCommandNames, + leadingRange, + 'primary', + ); + if (inline !== undefined) { + highlighted = inline; + } + if (highlighted !== original) { + lines[firstContentIdx] = highlighted; + } + } + for (let i = firstContentIdx + 1; i < lines.length - 1; i++) { + const original = lines[i]; + if (original === undefined) continue; + const inline = highlightInlineSkillTokens(original, this.skillCommandNames, null, 'primary'); + if (inline !== undefined) { + lines[i] = inline; + } + } + } + const hint = this.computeArgumentHint(); + if (hint !== undefined) { + const line = lines[firstContentIdx]; + if (line !== undefined) { + lines[firstContentIdx] = injectArgumentHint(line, hint, this.getText().length, width); + } + } + const firstContent = lines[firstContentIdx]; + if (firstContent !== undefined) { + const withPrompt = injectPromptSymbol( + firstContent, + isBash ? '!' : '>', + isBash ? (s) => this.borderColor(s) : undefined, + ); + if (withPrompt !== undefined) { + lines[firstContentIdx] = withPrompt; + } + } + // `this.borderColor` is pi-tui's per-render paint function. The host may + // overwrite it (e.g. plan-mode / slash-context highlight via + // `editor.borderColor = chalk.hex(primary)`), so we route corners and + // side bars through the same hook to stay in sync. + return wrapWithSideBorders(lines, (s) => this.borderColor(s), { + connectedAbove: this.connectedAbove && !this.borderHighlighted, + label: isBash ? ` ${currentTheme.boldFg('shellMode', '! shell mode')} ` : undefined, + }); + } + + private computeArgumentHint(): string | undefined { + // Argument hints describe slash commands, which do not exist in bash mode. + if (this.inputMode === 'bash') return undefined; + const text = this.getText(); + const match = /^\/(\S+)( ?)$/.exec(text); + if (match === null) return undefined; + const cmd = match[1]; + const trailingSpace = match[2] ?? ''; + if (cmd === undefined) return undefined; + const hint = this.argumentHints.get(cmd); + if (hint === undefined) return undefined; + const { line, col } = this.getCursor(); + if (line !== 0) return undefined; + const currentLine = this.getLines()[0] ?? ''; + if (col !== currentLine.length) return undefined; + return trailingSpace.length > 0 ? hint : ` ${hint}`; + } + + override handleInput(data: string): void { + const normalized = normalizeCapsLockedCtrl(data); + if (isKeyRelease(normalized)) { + return; + } + + // Clipboard reads are asynchronous. Queue every key received while a + // paste callback is in flight and replay it once the callback settles + // (clipboard read + placeholder insert — compression and the daemon + // upload continue in the background off this path), so Enter cannot + // submit a draft that is still missing the pasted image. + if (this.pasteInFlight) { + this.pasteInputQueue.push(normalized); + return; + } + + // Any input other than a lone Escape breaks a pending double-Esc sequence, + // so the shortcut only fires for two consecutive Escape presses. + if (!matchesKey(normalized, Key.escape)) { + this.onNonEscapeInput?.(); + } + + if (this.onPreInput?.(normalized) === true) { + return; + } + + // When a paste marker was just expanded, discard the trailing bracketed + // paste data that the terminal sends alongside the Ctrl-V keystroke. + if (this.consumingPaste) { + this.consumeBuffer += normalized; + if (this.consumeBuffer.includes(BRACKET_PASTE_END)) { + this.consumingPaste = false; + this.consumeBuffer = ''; + } + return; + } + + // If a bracketed paste arrives while the cursor sits on an existing + // paste marker, expand that marker instead of pasting new content. + if (normalized.includes(BRACKET_PASTE_START) && this.expandPasteMarkerAtCursor()) { + if (!normalized.includes(BRACKET_PASTE_END)) { + this.consumingPaste = true; + } + return; + } + + // Paste image binding — platform-aware: + // Windows terminals reserve Ctrl-V for their own paste handling + // (e.g. Windows Terminal's Ctrl+V shortcut), so we listen for + // Alt-V there. Everywhere else Ctrl-V pastes. When the host + // reports no image available, we fall through to pi-tui's + // normal paste path so text from the clipboard still works. + const pasteKey = process.platform === 'win32' ? 'alt+v' : Key.ctrl('v'); + if (matchesKey(normalized, pasteKey)) { + if (this.expandPasteMarkerAtCursor()) { + return; + } + if (this.onPasteImage !== undefined) { + const handler = this.onPasteImage; + const pasteAsText = (): void => { + this.onTextPaste?.(); + super.handleInput.call(this, normalized); + }; + this.pasteInFlight = true; + void handler() + .then((handled) => { + if (!handled) pasteAsText(); + }) + .catch(() => { + // A rejecting image-paste handler must not leak an unhandled + // rejection (the CLI turns those into a silent exit) — treat it + // the same as "no image available" and fall back to text paste. + pasteAsText(); + }) + .finally(() => { + this.pasteInFlight = false; + this.flushPasteInputQueue(); + }); + return; + } + } + + if (matchesKey(normalized, Key.ctrl('d'))) { + if (this.getText().length === 0) { + this.onCtrlD?.(); + return; + } + } + + if (matchesKey(normalized, Key.ctrl('c'))) { + this.onCtrlC?.(); + return; + } + + if (matchesKey(normalized, Key.ctrl('g'))) { + this.onOpenExternalEditor?.(); + return; + } + + if (matchesKey(normalized, Key.ctrl('o'))) { + this.onToggleToolExpand?.(); + return; + } + + if (matchesKey(normalized, Key.ctrl('s'))) { + this.onCtrlS?.(); + return; + } + + if (matchesKey(normalized, Key.ctrl('b'))) { + // Only consume the key when the handler actually detached something; + // otherwise fall through so readline's backward-char still works at the + // idle prompt. + if (this.onCtrlB?.() === true) return; + } + + if (matchesKey(normalized, Key.ctrl('t'))) { + // Only consume the key when the todo list actually has overflow to + // expand/collapse; otherwise fall through to the editor default. + if (this.onToggleTodoExpand?.() === true) return; + } + + if (matchesKey(normalized, Key.ctrl('n'))) { + // Only consume the key when the Updates panel grabbed or released + // focus; otherwise fall through to the editor default. + if (this.onPageNotify?.() === true) return; + } + + // A focused Updates panel owns ←/→ (channel switching), ↑/↓ (paging the + // channel's updates) and Esc (release focus); when it is not focused the + // handler returns false and every key falls through to the normal editor + // behavior below. Active autocomplete outranks the panel: its menu needs + // the same keys for selection and dismissal. + if ( + !this.hasAutocompleteActivity() && + (matchesKey(normalized, Key.left) || + matchesKey(normalized, Key.right) || + matchesKey(normalized, Key.up) || + matchesKey(normalized, Key.down) || + matchesKey(normalized, Key.escape)) + ) { + const panelKey = matchesKey(normalized, Key.left) + ? ('left' as const) + : matchesKey(normalized, Key.right) + ? ('right' as const) + : matchesKey(normalized, Key.up) + ? ('up' as const) + : matchesKey(normalized, Key.down) + ? ('down' as const) + : ('escape' as const); + if (this.onNotifyPanelKey?.(panelKey) === true) return; + } + + if (matchesKey(normalized, 'shift+tab')) { + this.onShiftTab?.(); + return; + } + + if (matchesKey(normalized, Key.ctrl('-'))) { + this.onUndo?.(); + } + + // Exit bash mode: Backspace/Escape on an empty `!` prompt returns to prompt + // mode. Because the `!` is not in the buffer, "deleting" it is really + // "delete on empty bash input". + if ( + this.inputMode === 'bash' && + this.getText().length === 0 && + (matchesKey(normalized, Key.escape) || matchesKey(normalized, Key.backspace)) + ) { + this.inputMode = 'prompt'; + this.onInputModeChange?.('prompt'); + return; + } + + if (matchesKey(normalized, Key.up)) { + if (this.getText().length === 0 && this.onUpArrowEmpty) { + if (this.onUpArrowEmpty()) return; + // fall through to super so Editor's built-in history navigation runs + } + } + + if (matchesKey(normalized, Key.down)) { + if (this.getText().length === 0 && this.onDownArrowEmpty) { + if (this.onDownArrowEmpty()) return; + } + } + + if (matchesKey(normalized, Key.escape)) { + if (this.hasAutocompleteActivity()) { + this.cancelAutocompleteActivity(); + return; + } + this.onEscape?.(); + return; + } + + // Swallow Tab while the autocomplete dropdown is closed so it does not + // trigger pi-tui's built-in file completion. When the dropdown is open, + // fall through so pi-tui can still accept the selected item with Tab. + if (matchesKey(normalized, Key.tab) && !this.isShowingAutocomplete()) { + return; + } + + // Enter bash mode: typing `!` at the start of an empty prompt. The `!` is + // not inserted into the buffer — it becomes the mode + prompt symbol, so the + // cursor never has to skip over it and submit never has to strip it. + if ( + this.inputMode === 'prompt' && + printableChar(normalized) === '!' && + this.getText().length === 0 + ) { + this.inputMode = 'bash'; + this.onInputModeChange?.('bash'); + return; + } + + const emptyPromptBeforeInput = this.inputMode === 'prompt' && this.getText().length === 0; + super.handleInput(normalized); + + // Enter bash mode when `!...` is pasted into an empty prompt. The typed path + // above handles the single `!` keystroke; this catches bracketed / Ctrl-V + // pastes whose content starts with `!`. Strip the leading `!` so the buffer + // holds only the command, exactly like the typed path. + if (emptyPromptBeforeInput && this.inputMode === 'prompt' && this.getText().startsWith('!')) { + this.inputMode = 'bash'; + this.onInputModeChange?.('bash'); + this.setText(this.getText().slice(1)); + } + + this.reopenAutocompleteAfterInput(); + } + + private flushPasteInputQueue(): void { + if (this.pasteInFlight) return; + const next = this.pasteInputQueue.shift(); + if (next === undefined) return; + this.handleInput(next); + if (!this.pasteInFlight) this.flushPasteInputQueue(); + } + + private reopenAutocompleteAfterInput(): void { + if (this.isShowingAutocomplete()) return; + const { line, col } = this.getCursor(); + const textBeforeCursor = this.getLines()[line]?.slice(0, col) ?? ''; + const editor = this as unknown as { + requestAutocomplete?: (options: { force: boolean; explicitTab: boolean }) => void; + }; + if (editor.requestAutocomplete === undefined) return; + const trigger = (): void => { + // Use force:false so slash-aware logic runs: commands with argument + // completions return their subcommands, commands without them return + // null. force:true would bypass the slash branch and fall through to + // path completion, wrongly popping up the file list. + editor.requestAutocomplete?.({ force: false, explicitTab: false }); + }; + + // Reopen path / argument completion right after a `/` is typed + // (e.g. `/add-dir /` or an `@dir/` mention). + if (textBeforeCursor.endsWith('/')) { + const isAtMention = extractAtPrefix(textBeforeCursor) !== null; + if (isAtMention) { + trigger(); + } else if (this.inputMode === 'bash') { + // In bash mode `/` is a path separator, not a slash command. A bare + // leading `/` is already handled by the tryTriggerAutocomplete shadow + // in the constructor; this branch covers the inline case (e.g. `ls /`, + // `cat /etc/`, `/add-dir/`) that pi-tui never auto-triggers. force:true + // is required so pi-tui's own slash-command handling is bypassed — + // force:false would let it pop up subcommand completions. + if (textBeforeCursor.trimStart() !== '/') { + editor.requestAutocomplete?.({ force: true, explicitTab: false }); + } + } else { + const isSlashArgument = textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' '); + if (isSlashArgument) { + trigger(); + } + } + return; + } + + // After accepting a slash command name via Tab, pi-tui inserts a trailing + // space and closes the menu without triggering argument completion. Reopen + // it so subcommands (e.g. `/goal ` → status/pause/…) show immediately. + // Skipped in bash mode: `/` is a path there, and force:false would let + // pi-tui's own slash-command handling pop up subcommand completions. + if ( + this.inputMode !== 'bash' && + textBeforeCursor.endsWith(' ') && + textBeforeCursor.startsWith('/') && + textBeforeCursor.includes(' ') + ) { + trigger(); + } + } +} + +/** + * Return a copy of `line` with the first `/token` coloured using `hex`. + * For `/goal next manage`, also colour the command-path tokens. + * `line` may already contain SGR escapes (cursor inverse, etc.); we + * locate `/` via visible-index math so ANSI pass-through survives. + * Returns `undefined` if no token is found. + */ +export function highlightFirstSlashToken(line: string, token: 'primary'): string | undefined { + const visible = stripSgr(line); + const range = leadingSlashTokenRange(visible); + if (range === null) return undefined; + const ranges = [range]; + if (visible.slice(range.start, range.end) === '/goal') { + ranges.push(...goalCommandPathRanges(visible, range.end)); + } + return highlightVisibleRanges(line, ranges, token); +} + +function leadingSlashTokenRange(visible: string): { start: number; end: number } | null { + const slashIdx = visible.indexOf('/'); + if (slashIdx < 0) return null; + // Guard: only paint when `/` is the first non-whitespace character + // on the line (avoids colouring a mid-sentence slash). + for (let i = 0; i < slashIdx; i++) { + if (visible[i] !== ' ' && visible[i] !== '\t') return null; + } + // Token ends at the next whitespace (or the visible end). + let endVisible = slashIdx + 1; + while (endVisible < visible.length) { + const ch = visible[endVisible]; + if (ch === ' ' || ch === '\t') break; + endVisible++; + } + const visibleToken = visible.slice(slashIdx, endVisible); + if (visibleToken.slice(1).includes('/')) return null; + return { start: slashIdx, end: endVisible }; +} + +/** + * Highlight inline skill tokens in `line`. A token is painted only when it + * names a known skill; `exclude` (the already-painted leading slash command + * range) is skipped so the leading command is not painted twice. + */ +export function highlightInlineSkillTokens( + line: string, + skillCommandNames: ReadonlySet<string>, + exclude: { start: number; end: number } | null, + token: 'primary', +): string | undefined { + if (skillCommandNames.size === 0) return undefined; + const visible = stripSgr(line); + const ranges = findInlineSkillTokens(visible, { + isKnownSkill: (commandName) => + skillCommandNames.has(commandName) || skillCommandNames.has(`skill:${commandName}`), + includeLeading: true, + }).filter( + (inlineToken) => + exclude === null || inlineToken.start >= exclude.end || inlineToken.end <= exclude.start, + ); + if (ranges.length === 0) return undefined; + return highlightVisibleRanges(line, ranges, token); +} + +function goalCommandPathRanges( + visible: string, + commandEnd: number, +): Array<{ start: number; end: number }> { + const nextRange = readTokenRange(visible, commandEnd); + if (nextRange === null || visible.slice(nextRange.start, nextRange.end) !== 'next') { + return []; + } + const ranges = [nextRange]; + const manageRange = readTokenRange(visible, nextRange.end); + if (manageRange !== null && visible.slice(manageRange.start, manageRange.end) === 'manage') { + ranges.push(manageRange); + } + return ranges; +} + +function readTokenRange(visible: string, start: number): { start: number; end: number } | null { + let tokenStart = start; + while (tokenStart < visible.length && isTokenSpace(visible[tokenStart])) tokenStart++; + if (tokenStart >= visible.length) return null; + let tokenEnd = tokenStart; + while (tokenEnd < visible.length && !isTokenSpace(visible[tokenEnd])) tokenEnd++; + return { start: tokenStart, end: tokenEnd }; +} + +function isTokenSpace(ch: string | undefined): boolean { + return ch === ' ' || ch === '\t'; +} + +function highlightVisibleRanges( + line: string, + ranges: Array<{ start: number; end: number }>, + token: 'primary', +): string { + let out = ''; + let rawCursor = 0; + for (const range of ranges) { + const rawStart = mapVisibleIdxToRaw(line, range.start); + const rawEnd = mapVisibleIdxToRaw(line, range.end); + out += line.slice(rawCursor, rawStart); + out += currentTheme.boldFg(token, line.slice(rawStart, rawEnd)); + rawCursor = rawEnd; + } + return out + line.slice(rawCursor); +} + +// Mirrors the editor's paddingX (see constructor). The hint is spliced into +// the first content line, which starts with this many spaces of left padding. +const EDITOR_LEFT_PADDING = 4; +// pi-tui renders the end-of-input cursor as an inverse-video space. +const CURSOR_BLOCK = '\u001B[7m \u001B[0m'; + +/** + * Splice a dimmed argument-hint ghost string into the first content line. + * + * The hint is purely visual: it is appended after the typed command (and + * after the cursor block when one is rendered) so the cursor stays at the + * end of the real input. It consumes trailing padding space, so the line + * width is preserved; if it would overflow the box it is truncated with an + * ellipsis. Returns the line unchanged when there is no room for a hint. + */ +function injectArgumentHint( + line: string, + hint: string, + realTextLength: number, + width: number, +): string { + const cursorIdx = line.indexOf(CURSOR_BLOCK); + const cursorPresent = cursorIdx !== -1; + const contentWidth = Math.max(1, width - EDITOR_LEFT_PADDING * 2); + // Room left in the content area after the typed text (and cursor). The hint + // must fit within this so the rendered line keeps its width. + const available = contentWidth - realTextLength - (cursorPresent ? 1 : 0); + const trimmed = truncateHint(hint, available); + if (trimmed.length === 0) return line; + const colored = currentTheme.fg('textDim', trimmed); + const insertAt = cursorPresent + ? cursorIdx + CURSOR_BLOCK.length + : mapVisibleIdxToRaw(line, EDITOR_LEFT_PADDING + realTextLength); + // Everything after the insertion point is trailing padding + right padding + // (plain spaces). Replace it with the hint followed by the remaining spaces + // so the visible line width is preserved. + const trailing = line.length - insertAt; + return line.slice(0, insertAt) + colored + ' '.repeat(Math.max(0, trailing - trimmed.length)); +} + +function truncateHint(hint: string, maxLen: number): string { + if (maxLen <= 0) return ''; + if (hint.length <= maxLen) return hint; + if (maxLen === 1) return '…'; + return `${hint.slice(0, maxLen - 1)}…`; +} + +/** + * Overlay a terminal-style `> ` prompt symbol on the first content line. + * Column 0 is reserved for the left vertical border (overlaid later by + * wrapWithSideBorders); column 1 is a single-space gap, so the `>` token + * lives at column 2 with column 3 separating it from content. + * Relies on the editor being configured with `paddingX >= 4` so the line + * starts with at least four literal spaces. Emits no SGR so the terminal's + * default foreground colour renders the symbol. Returns `undefined` if the + * line is too short or doesn't begin with the expected padding. + */ +export function injectPromptSymbol( + line: string, + symbol = '>', + paint?: (s: string) => string, +): string | undefined { + if (line.length < 4) return undefined; + for (let i = 0; i < 4; i++) { + if (line[i] !== ' ') return undefined; + } + const rendered = paint ? paint(symbol) : symbol; + return ' ' + rendered + ' ' + line.slice(4); +} + +/** + * Post-process pi-tui's editor output to draw a full box around it. + * + * pi-tui only renders horizontal top/bottom borders; we wrap them with + * `╭╮╰╯` corners and add vertical `│` bars on each row's outer columns. + * Horizontal-border rows (those whose first visible char is `─`, including + * scroll indicators like `── ↑ N more ──`) are stripped of their existing + * SGR and repainted as a single box-drawn span. Content rows keep their + * inner SGR intact; only column 0 and the last column are overlaid, and + * only if they're literal spaces — that protects the cursor-overflow + * case where the rightmost column is an SGR-tagged inverse cursor. + * + * When `options.label` is set, it is overlaid on the left of the top border + * (e.g. the `! shell mode` badge), replacing the leading dashes. It is only + * applied to a plain dash run, never to a `↑/↓ N more` scroll indicator. + */ +export function wrapWithSideBorders( + lines: string[], + paint: (s: string) => string, + options: { readonly connectedAbove?: boolean; readonly label?: string } = {}, +): string[] { + let seenTop = false; + return lines.map((line) => { + const plain = stripSgr(line); + if (plain.length > 0 && plain[0] === '─') { + const isTop = !seenTop; + const leftCorner = seenTop ? '╰' : options.connectedAbove === true ? '├' : '╭'; + const rightCorner = seenTop ? '╯' : options.connectedAbove === true ? '┤' : '╮'; + seenTop = true; + if (plain.length === 1) return paint(leftCorner); + const middle = plain.slice(1, -1); + if (isTop && options.label !== undefined && /^─+$/.test(middle)) { + const labelWidth = visibleWidth(options.label); + if (labelWidth <= middle.length) { + return ( + paint(leftCorner) + + options.label + + paint('─'.repeat(middle.length - labelWidth)) + + paint(rightCorner) + ); + } + } + return paint(leftCorner + middle + rightCorner); + } + if (line.length === 0) return line; + const firstCh = line[0]; + const lastCh = line.at(-1); + const head = firstCh === ' ' ? paint('│') : (firstCh ?? ''); + const tail = line.length > 1 && lastCh === ' ' ? paint('│') : (lastCh ?? ''); + if (line.length === 1) return head; + return head + line.slice(1, -1) + tail; + }); +} diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..daf72ca6435a8fb25476bc33e6f7617acaa214df --- /dev/null +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -0,0 +1,647 @@ +import { accessSync, constants as fsConstants, readdirSync, statSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; + +import { + CombinedAutocompleteProvider, + fuzzyMatch, + type AutocompleteItem, + type AutocompleteProvider, + type AutocompleteSuggestions, + type SlashCommand, +} from '@moonshot-ai/pi-tui'; + +import { findInlineSkillTokens } from '../../utils/inline-skill-tokens'; + +const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']); +const MAX_FALLBACK_SCAN = 2000; +const MAX_FALLBACK_SUGGESTIONS = 50; + +export interface SlashAutocompleteCommand extends SlashCommand { + readonly aliases?: readonly string[]; +} + +interface FsMentionCandidate { + readonly path: string; + readonly absolutePath: string; + readonly isDirectory: boolean; +} + +/** + * Kimi wrapper around pi-tui's combined autocomplete provider. + * + * File / folder mention behavior uses pi-tui's fd-backed provider whenever fd + * is available, fanning out across the working directory and any additional + * roots so `@` completion pushes the query down to fd instead of enumerating + * every file. A small filesystem fallback is used only while managed fd is + * downloading, when it is unavailable, or if fd fails to spawn. Ordinary path + * completion is still handled by pi-tui's readdir-backed path completer. This + * wrapper also keeps Kimi-specific slash-command guards. + */ +export class FileMentionProvider implements AutocompleteProvider { + private readonly inner: CombinedAutocompleteProvider; + private readonly additionalDirs: readonly string[]; + + constructor( + private readonly slashCommands: SlashAutocompleteCommand[], + private readonly workDir: string, + private readonly fdPath: string | null, + additionalDirs: readonly string[] = [], + private readonly getInputMode: () => 'prompt' | 'bash' = () => 'prompt', + private readonly skillCommandNames?: ReadonlySet<string>, + ) { + this.additionalDirs = additionalDirs.map((dir) => normalizePath(resolve(workDir, dir))); + // Build an expanded list that includes alias entries so that + // inner's argument completion can find commands by alias too. + const expanded: SlashAutocompleteCommand[] = []; + for (const cmd of slashCommands) { + expanded.push(cmd); + for (const alias of cmd.aliases ?? []) { + expanded.push({ ...cmd, name: alias }); + } + } + this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath, this.additionalDirs); + } + + async getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: { signal: AbortSignal; force?: boolean }, + ): Promise<AutocompleteSuggestions | null> { + const currentLine = lines[cursorLine] ?? ''; + const textBeforeCursor = currentLine.slice(0, cursorCol); + + // `@` file / folder mentions take priority over the slash-command guards + // below. Without this, typing `@` inside a slash command's argument text + // (e.g. `/goal Fix the @|checkout docs`) would be swallowed by + // `shouldSuppressSlashArgumentCompletion` before the mention branch ever + // runs, so the file list never opens. + const atPrefix = extractAtPrefix(textBeforeCursor); + if (atPrefix !== null) { + // fd backs `@` completion across every root (cwd + additional dirs). Fall + // back to the filesystem scanner when fd is unavailable, not executable + // (e.g. the managed binary was removed or lost execute permission), or if + // spawning it fails below. A genuine fd no-match still returns null. + if (this.fdPath === null || !isExecutableFd(this.fdPath)) { + return getFsMentionSuggestions( + this.workDir, + this.additionalDirs, + atPrefix, + options.signal, + ); + } + try { + return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + } catch { + // If fd fails to spawn unexpectedly, keep @ completion usable. + return getFsMentionSuggestions( + this.workDir, + this.additionalDirs, + atPrefix, + options.signal, + ); + } + } + + // An inline skill token the cursor is still on stays eligible for skill + // selection even when the input begins with a slash command and has text + // after the cursor — the argument suppression below guards the command's + // own arguments, not an inline skill the user inserts mid-text. Computed + // before the leading-whitespace suppression: an indented inline token + // (` /skill:rev`) is a skill reference, not a path to suppress. + const inlineSkillPrefix = extractInlineSkillPrefix(textBeforeCursor, cursorLine); + + if ( + inlineSkillPrefix === null && + shouldSuppressLeadingWhitespaceSlashPath(textBeforeCursor, options.force) + ) { + return null; + } + + // A `/` at the start of a later line is an inline skill reference, not a + // start-of-message slash command: offer the skill-only picker there. + if ( + cursorLine > 0 && + textBeforeCursor.trim() === '/' && + this.getInputMode() !== 'bash' && + options.force !== true + ) { + return this.getInlineSkillSuggestions('/'); + } + + if ( + inlineSkillPrefix === null && + shouldSuppressSlashArgumentCompletion( + textBeforeCursor, + currentLine.slice(cursorCol), + options.force, + ) + ) { + return null; + } + + // Handle slash-command name completion ourselves so that aliases are + // searchable and visible in the label. Only the first line can host a + // start-of-message slash command; later lines are inline skill territory. + if (!options.force && cursorLine === 0 && textBeforeCursor.startsWith('/')) { + const spaceIndex = textBeforeCursor.indexOf(' '); + if (spaceIndex === -1) { + const tokens = textBeforeCursor + .slice(1) + .trim() + .split(/\s+/) + .filter((t) => t.length > 0); + + type SlashMatch = { + cmd: SlashAutocompleteCommand; + score: number; + viaAlias: boolean; + label: string; + }; + const matches: SlashMatch[] = []; + + for (const cmd of this.slashCommands) { + const nameScore = scoreTokens(tokens, cmd.name); + if (nameScore !== null) { + matches.push({ cmd, score: nameScore, viaAlias: false, label: cmd.name }); + continue; + } + // Aliases only count when the primary name missed; the label then + // lists them so the user can see why the command matched. + const aliases = cmd.aliases ?? []; + let bestAliasScore: number | null = null; + for (const alias of aliases) { + const aliasScore = scoreTokens(tokens, alias); + if (aliasScore !== null && (bestAliasScore === null || aliasScore < bestAliasScore)) { + bestAliasScore = aliasScore; + } + } + if (bestAliasScore !== null) { + matches.push({ + cmd, + score: bestAliasScore, + viaAlias: true, + label: `${cmd.name} (${aliases.join(', ')})`, + }); + } + } + + // Primary-name matches outrank alias matches on score ties. + matches.sort((a, b) => a.score - b.score || Number(a.viaAlias) - Number(b.viaAlias)); + + if (matches.length === 0) return null; + return { + items: matches.map((m) => ({ + value: m.cmd.name, + label: m.label, + description: formatSlashCommandDescription(m.cmd), + })), + prefix: textBeforeCursor, + }; + } + } + + // In bash mode `/` is a path separator, not a slash command. Skip slash + // command argument handling so an absolute path that happens to start with + // a command name (e.g. `/add-dir/...`) completes inside the path instead of + // returning the command's argument completions. + if (this.getInputMode() !== 'bash') { + const slashArgumentSuggestions = await getSlashArgumentSuggestions(this.slashCommands, textBeforeCursor); + if (slashArgumentSuggestions !== null) { + return slashArgumentSuggestions; + } + } + + // Inline skill selection: `/` after whitespace mid-input in prompt mode. + // Runs after slash-command argument handling so known commands such as + // `/add-dir /` keep their own argument completions. + if ( + inlineSkillPrefix !== null && + this.getInputMode() !== 'bash' && + options.force !== true + ) { + // A mid-input `/` in prompt mode is only meaningful as skill selection; + // when no skills are registered, suppress path completion instead of + // offering root directories. + return this.getInlineSkillSuggestions(inlineSkillPrefix); + } + + try { + const inner = await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + if (inner === null || this.getInputMode() !== 'bash') { + return inner; + } + // In bash mode `/` is a path separator; hide dot-prefixed entries to + // match the `/add-dir` directory completer (registry.ts skips any name + // starting with `.`). Ordinary prompt-mode path completion is left as-is. + return { ...inner, items: inner.items.filter((item) => !isDotPrefixedEntry(item)) }; + } catch { + return null; + } + } + + private getInlineSkillSuggestions(prefix: string): AutocompleteSuggestions | null { + if (this.skillCommandNames === undefined || this.skillCommandNames.size === 0) return null; + const names = this.skillCommandNames; + const tokens = prefix + .slice(1) + .trim() + .split(/\s+/) + .filter((t) => t.length > 0); + + const matches: Array<{ cmd: SlashAutocompleteCommand; score: number }> = []; + for (const cmd of this.slashCommands) { + if (!names.has(cmd.name)) continue; + const score = scoreTokens(tokens, cmd.name); + if (score !== null) { + matches.push({ cmd, score }); + } + } + matches.sort((a, b) => a.score - b.score); + + if (matches.length === 0) return null; + return { + items: matches.map((m) => ({ + value: m.cmd.name, + label: m.cmd.name, + description: formatSlashCommandDescription(m.cmd), + data: { inlineSkill: true }, + })), + prefix, + }; + } + + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ): { lines: string[]; cursorLine: number; cursorCol: number } { + // Inline skill selection mid-input: pi-tui's default applyCompletion + // treats mid-line slash prefixes as file paths and drops the `/`. Preserve + // the slash and add a trailing space so the completed token stays a valid + // skill reference (e.g. `hello /rev` -> `hello /skill:review `). + if ( + item.data?.['inlineSkill'] === true && + this.getInputMode() !== 'bash' && + prefix.startsWith('/') + ) { + const currentLine = lines[cursorLine] ?? ''; + const textBeforeCursor = currentLine.slice(0, cursorCol); + if (extractInlineSkillPrefix(textBeforeCursor, cursorLine) === prefix) { + const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); + const afterCursor = currentLine.slice(cursorCol); + const newLines = [...lines]; + newLines[cursorLine] = `${beforePrefix}/${item.value} ${afterCursor}`; + return { + lines: newLines, + cursorLine, + // +2 for the preserved "/" and the appended " ". + cursorCol: beforePrefix.length + item.value.length + 2, + }; + } + } + // In bash mode a leading `/` is a path, but pi-tui's applyCompletion + // mistakes it for a slash command (prefix starts with `/`, nothing before + // it, no second `/`) and prepends another `/`, producing e.g. + // `//Applications/ ` with a trailing space that also blocks further + // completion. Handle path completion ourselves so the value replaces the + // prefix verbatim. `@` mentions keep pi-tui's behaviour. + if (this.getInputMode() === 'bash' && prefix.startsWith('/')) { + return applyPathCompletion(lines, cursorLine, cursorCol, item, prefix); + } + return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + } +} + +/** + * Extract the inline skill prefix (e.g. `/rev`) from `text` when the cursor is + * positioned after a `/` that is preceded by whitespace and not part of the + * leading slash-command area. Returns `null` when the context is not an inline + * skill trigger. + * + * On lines after the first, a `/` at the start of the line always begins an + * inline skill prefix — including the partially typed `/rev` — so the picker + * stays in skill-only mode while the token is completed. + */ +export function extractInlineSkillPrefix(text: string, cursorLine: number = 0): string | null { + if (cursorLine > 0) { + const trimmedStart = text.trimStart(); + const match = /^\/[^\s/]*$/.exec(trimmedStart); + if (match !== null) return match[0]; + } + // findInlineSkillTokens skips the leading slash-command area, so a line such + // as `/skill:review args /` still yields the trailing `/` token. + const tokens = findInlineSkillTokens(text, { + isKnownSkill: () => true, + allowEmpty: true, + }); + const token = tokens.findLast((t) => t.end === text.length); + return token === undefined ? null : text.slice(token.start); +} + +export function extractAtPrefix(text: string): string | null { + let tokenStart = 0; + for (let i = text.length - 1; i >= 0; i -= 1) { + if (PATH_DELIMITERS.has(text[i] ?? '')) { + tokenStart = i + 1; + break; + } + } + if (text[tokenStart] !== '@') return null; + return text.slice(tokenStart); +} + +function isExecutableFd(fdPath: string): boolean { + // Bare command names (for example "fd" discovered on the system PATH) are + // trusted: spawn resolves them through PATH. Only absolute/relative paths are + // probed, which is how the managed fd is referenced and which can go stale. + if (!fdPath.includes('/') && !fdPath.includes('\\')) { + return true; + } + try { + accessSync(fdPath, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Match the `/add-dir` directory completer, which skips every entry whose name + * starts with `.` (see registry.ts). pi-tui's path completer sets `label` to + * the entry basename, with a trailing `/` for directories. + */ +function isDotPrefixedEntry(item: AutocompleteItem): boolean { + const name = item.label.endsWith('/') ? item.label.slice(0, -1) : item.label; + return name.startsWith('.'); +} + +/** + * Replace `prefix` with `item.value` verbatim, mirroring pi-tui's file-path + * branch (no trailing space, so a completed directory can be extended with the + * next `/`). Used in bash mode to avoid pi-tui's slash-command branch, which + * would prepend an extra `/` to a bare leading `/` path. For a quoted + * directory value (path contains spaces), the cursor stays inside the closing + * quote so follow-up `/` completion keeps working. + */ +function applyPathCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, +): { lines: string[]; cursorLine: number; cursorCol: number } { + const currentLine = lines[cursorLine] ?? ''; + const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); + const afterCursor = currentLine.slice(cursorCol); + const newLine = beforePrefix + item.value + afterCursor; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + const isDirectory = item.label.endsWith('/'); + const hasTrailingQuote = item.value.endsWith('"'); + const cursorOffset = + isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length; + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + cursorOffset, + }; +} + +function getFsMentionSuggestions( + workDir: string, + additionalDirs: readonly string[], + atPrefix: string, + signal: AbortSignal, +): AutocompleteSuggestions | null { + if (signal.aborted) return null; + + const query = atPrefix.slice(1); + const candidates = collectFsMentionCandidates(workDir, additionalDirs, signal); + if (candidates.length === 0 || signal.aborted) return null; + + const ranked = rankFsMentionCandidates(candidates, query).slice(0, MAX_FALLBACK_SUGGESTIONS); + if (ranked.length === 0) return null; + + return { + prefix: atPrefix, + items: ranked.map(toMentionItem), + }; +} + +function collectFsMentionCandidates( + workDir: string, + additionalDirs: readonly string[], + signal: AbortSignal, +): FsMentionCandidate[] { + const candidatesByAbsolutePath = new Map<string, FsMentionCandidate>(); + const roots = [ + { root: normalizePath(resolve(workDir)), isAdditionalDir: false }, + ...additionalDirs.map((dir) => ({ + root: normalizePath(resolve(workDir, dir)), + isAdditionalDir: true, + })), + ]; + let scanned = 0; + + for (const { root, isAdditionalDir } of roots) { + const stack = ['']; + + while (stack.length > 0 && scanned < MAX_FALLBACK_SCAN) { + if (signal.aborted) break; + const relativeDir = stack.pop() ?? ''; + const absoluteDir = relativeDir.length === 0 ? root : join(root, relativeDir); + let entries; + try { + entries = readdirSync(absoluteDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (signal.aborted || scanned >= MAX_FALLBACK_SCAN) break; + if (entry.name === '.git') continue; + + const relativePath = normalizePath( + relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name), + ); + const absolutePath = normalizePath(join(absoluteDir, entry.name)); + const isSymlink = entry.isSymbolicLink(); + let isDirectory = entry.isDirectory(); + if (!isDirectory && isSymlink) { + try { + isDirectory = statSync(absolutePath).isDirectory(); + } catch { + // Broken symlink or permission error — keep it as a file candidate. + } + } + + scanned += 1; + if (!candidatesByAbsolutePath.has(absolutePath)) { + candidatesByAbsolutePath.set(absolutePath, { + path: isAdditionalDir ? absolutePath : relativePath, + absolutePath, + isDirectory, + }); + } + if (isDirectory && !isSymlink) { + stack.push(relativePath); + } + } + } + } + + return [...candidatesByAbsolutePath.values()]; +} + +function rankFsMentionCandidates( + candidates: readonly FsMentionCandidate[], + query: string, +): FsMentionCandidate[] { + const lowerQuery = query.toLowerCase(); + const scored: Array<{ candidate: FsMentionCandidate; score: number }> = []; + + for (const candidate of candidates) { + const score = scoreCandidate(candidate, lowerQuery); + if (score > 0) scored.push({ candidate, score }); + } + + scored.sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; + if (a.candidate.isDirectory !== b.candidate.isDirectory) { + return a.candidate.isDirectory ? -1 : 1; + } + return a.candidate.path.localeCompare(b.candidate.path); + }); + + return scored.map((entry) => entry.candidate); +} + +function scoreCandidate(candidate: FsMentionCandidate, lowerQuery: string): number { + if (lowerQuery.length === 0) { + const depthPenalty = candidate.path.split('/').length - 1; + return (candidate.isDirectory ? 120 : 100) - depthPenalty; + } + + const lowerPath = candidate.path.toLowerCase(); + const lowerBase = basename(candidate.path).toLowerCase(); + let score = 0; + if (lowerBase === lowerQuery) score = 100; + else if (lowerBase.startsWith(lowerQuery)) score = 80; + else if (lowerBase.includes(lowerQuery)) score = 50; + else if (lowerPath.includes(lowerQuery)) score = 30; + if (candidate.isDirectory && score > 0) score += 10; + return score; +} + +function toMentionItem(candidate: FsMentionCandidate): AutocompleteItem { + const valuePath = candidate.isDirectory ? `${candidate.path}/` : candidate.path; + const value = valuePath.includes(' ') ? `@"${valuePath}"` : `@${valuePath}`; + const label = `${basename(candidate.path)}${candidate.isDirectory ? '/' : ''}`; + return { + value, + label, + description: candidate.absolutePath, + }; +} + +function normalizePath(path: string): string { + return path.replaceAll('\\', '/'); +} + +async function getSlashArgumentSuggestions( + slashCommands: readonly SlashAutocompleteCommand[], + textBeforeCursor: string, +): Promise<AutocompleteSuggestions | null> { + const parsed = parseSlashArgumentContext(textBeforeCursor, slashCommands); + if (parsed === null) return null; + + const items = await parsed.command.getArgumentCompletions?.(parsed.argumentPrefix); + if (items === undefined || items === null || items.length === 0) return null; + + return { + prefix: parsed.argumentPrefix, + items, + }; +} + +function parseSlashArgumentContext( + textBeforeCursor: string, + slashCommands: readonly SlashAutocompleteCommand[], +): { command: SlashAutocompleteCommand; argumentPrefix: string } | null { + const whitespaceMatch = textBeforeCursor.match(/^\/(\S+)\s+(\S*)$/); + if (whitespaceMatch !== null) { + const [, commandName = '', argumentPrefix = ''] = whitespaceMatch; + const command = findSlashCommand(slashCommands, commandName); + if (command === undefined) return null; + if (!textBeforeCursor.endsWith(' ') && argumentPrefix.length === 0) return null; + return { command, argumentPrefix }; + } + + const pathLikeMatch = textBeforeCursor.match(/^\/([^/\s]+)(\/.*)$/); + const commandName = pathLikeMatch?.[1]; + const argumentPrefix = pathLikeMatch?.[2]; + if (commandName === undefined || argumentPrefix === undefined) return null; + + const command = findSlashCommand(slashCommands, commandName); + if (command === undefined) return null; + return { command, argumentPrefix }; +} + +function findSlashCommand( + slashCommands: readonly SlashAutocompleteCommand[], + commandName: string, +): SlashAutocompleteCommand | undefined { + return slashCommands.find((cmd) => cmd.name === commandName || (cmd.aliases ?? []).includes(commandName)); +} + +function shouldSuppressLeadingWhitespaceSlashPath( + textBeforeCursor: string, + force: boolean | undefined, +): boolean { + if (force === true) return false; + if (textBeforeCursor.startsWith('/')) return false; + return textBeforeCursor.trimStart().startsWith('/'); +} + +function shouldSuppressSlashArgumentCompletion( + textBeforeCursor: string, + textAfterCursor: string, + force: boolean | undefined, +): boolean { + if (force === true) return false; + if (!textBeforeCursor.startsWith('/')) return false; + if (!textBeforeCursor.includes(' ')) return false; + return textAfterCursor.trimStart().length > 0; +} + +/** + * All tokens must fuzzy-match `text`; returns the summed score, or null when + * any token misses. An empty token list matches everything with score 0. + * Mirrors pi-tui fuzzyFilter's token semantics — keep in sync if it changes. + */ +function scoreTokens(tokens: readonly string[], text: string): number | null { + let score = 0; + for (const token of tokens) { + const m = fuzzyMatch(token, text); + if (!m.matches) return null; + score += m.score; + } + return score; +} + +/** + * Mirrors CombinedAutocompleteProvider's description rendering so the + * intercepted name completion keeps showing the argument hint. + */ +function formatSlashCommandDescription(cmd: SlashAutocompleteCommand): string | undefined { + const desc = cmd.description ?? ''; + const full = cmd.argumentHint + ? desc + ? `${cmd.argumentHint} — ${desc}` + : cmd.argumentHint + : desc; + return full || undefined; +} diff --git a/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts b/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6969c63032cfa6726ad79ad1e41b7c410e72cd1 --- /dev/null +++ b/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts @@ -0,0 +1,177 @@ +import { + SelectList, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type SelectItem, + type SelectListLayoutOptions, + type SelectListTheme, +} from '@moonshot-ai/pi-tui'; + +// Mirror pi-tui's private select-list layout constants +// (dist/components/select-list.js); keep in sync when bumping pi-tui. +const DEFAULT_PRIMARY_COLUMN_WIDTH = 32; +const PRIMARY_COLUMN_GAP = 2; +const MIN_DESCRIPTION_WIDTH = 10; + +const DESCRIPTION_MAX_LINES = 2; +const ELLIPSIS = '…'; +const ELLIPSIS_WIDTH = visibleWidth(ELLIPSIS); + +// truncateToWidth appends an ANSI reset whenever it actually truncates. +// Labels and descriptions here are plain text, and the reset would sit +// inside the theme's colour wrapping and reset the rest of the line (e.g. +// a selected row with a truncated name loses its colour after the name), +// so strip it. +// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences +const TRAILING_ANSI_RESET = /(?:\u001B\[0m)+$/; + +function truncatePlainToWidth(text: string, maxWidth: number): string { + return truncateToWidth(text, maxWidth, '').replace(TRAILING_ANSI_RESET, ''); +} + +interface SelectListInternals { + readonly filteredItems: SelectItem[]; + readonly selectedIndex: number; + readonly maxVisible: number; + readonly theme: SelectListTheme; + readonly layout: SelectListLayoutOptions; +} + +/** + * SelectList that wraps item descriptions onto up to two lines instead of + * truncating them to one. Long command / skill descriptions stay readable; + * anything past the second line is ellipsized. + * + * Only `render` is replaced — selection, filtering, and key handling stay in + * pi-tui. pi-tui keeps the row state private, so the renderer reads it + * through a cast, the same idiom CustomEditor uses for autocomplete + * internals. + */ +export class WrappingSelectList extends SelectList { + override render(width: number): string[] { + const { filteredItems, selectedIndex, maxVisible, theme } = this.internals(); + if (filteredItems.length === 0) { + return [theme.noMatch(' No matching commands')]; + } + + const primaryColumnWidth = this.primaryColumnWidth(); + const startIndex = Math.max( + 0, + Math.min(selectedIndex - Math.floor(maxVisible / 2), filteredItems.length - maxVisible), + ); + const endIndex = Math.min(startIndex + maxVisible, filteredItems.length); + + const lines: string[] = []; + for (let i = startIndex; i < endIndex; i++) { + const item = filteredItems[i]; + if (!item) continue; + lines.push(...this.renderItemLines(item, i === selectedIndex, width, primaryColumnWidth)); + } + + if (startIndex > 0 || endIndex < filteredItems.length) { + const scrollText = ` (${selectedIndex + 1}/${filteredItems.length})`; + lines.push(theme.scrollInfo(truncatePlainToWidth(scrollText, width - 2))); + } + return lines; + } + + private renderItemLines( + item: SelectItem, + isSelected: boolean, + width: number, + primaryColumnWidth: number, + ): string[] { + const { theme } = this.internals(); + const prefix = isSelected ? '→ ' : ' '; + const prefixWidth = visibleWidth(prefix); + const description = item.description + ? item.description.replaceAll(/[\r\n]+/g, ' ').trim() + : undefined; + + if (description && width > 40) { + const effectivePrimaryColumnWidth = Math.max( + 1, + Math.min(primaryColumnWidth, width - prefixWidth - 4), + ); + const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP); + const truncatedValue = this.truncatePrimaryValue( + item, + isSelected, + maxPrimaryWidth, + effectivePrimaryColumnWidth, + ); + const truncatedValueWidth = visibleWidth(truncatedValue); + const spacing = ' '.repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth)); + const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length; + const remainingWidth = width - descriptionStart - 2; // -2 for safety, as upstream + if (remainingWidth > MIN_DESCRIPTION_WIDTH) { + const descriptionLines = wrapDescription(description, remainingWidth); + const indent = ' '.repeat(descriptionStart); + if (isSelected) { + return descriptionLines.map((line, index) => + theme.selectedText(index === 0 ? `${prefix}${truncatedValue}${spacing}${line}` : indent + line), + ); + } + return descriptionLines.map((line, index) => + index === 0 + ? prefix + truncatedValue + theme.description(spacing + line) + : theme.description(indent + line), + ); + } + } + + const maxWidth = width - prefixWidth - 2; + const truncatedValue = this.truncatePrimaryValue(item, isSelected, maxWidth, maxWidth); + return [isSelected ? theme.selectedText(`${prefix}${truncatedValue}`) : prefix + truncatedValue]; + } + + private truncatePrimaryValue( + item: SelectItem, + isSelected: boolean, + maxWidth: number, + columnWidth: number, + ): string { + const { layout } = this.internals(); + const displayValue = item.label || item.value; + const truncated = layout.truncatePrimary + ? layout.truncatePrimary({ text: displayValue, maxWidth, columnWidth, item, isSelected }) + : displayValue; + return truncatePlainToWidth(truncated, maxWidth); + } + + private primaryColumnWidth(): number { + const { filteredItems, layout } = this.internals(); + const rawMin = + layout.minPrimaryColumnWidth ?? layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + const rawMax = + layout.maxPrimaryColumnWidth ?? layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + const min = Math.max(1, Math.min(rawMin, rawMax)); + const max = Math.max(1, Math.max(rawMin, rawMax)); + const widest = filteredItems.reduce( + (acc, item) => Math.max(acc, visibleWidth(item.label || item.value) + PRIMARY_COLUMN_GAP), + 0, + ); + return Math.max(min, Math.min(widest, max)); + } + + private internals(): SelectListInternals { + return this as unknown as SelectListInternals; + } +} + +/** + * Wrap `text` to at most DESCRIPTION_MAX_LINES lines of `width` columns. + * When the text needs more lines, the last visible line is rebuilt from the + * remaining text and ellipsized. + */ +function wrapDescription(text: string, width: number): string[] { + const wrapped = wrapTextWithAnsi(text, width); + if (wrapped.length <= DESCRIPTION_MAX_LINES) { + return wrapped; + } + const kept = wrapped.slice(0, DESCRIPTION_MAX_LINES - 1); + const rest = wrapped.slice(DESCRIPTION_MAX_LINES - 1).join(' '); + const clipped = truncatePlainToWidth(rest, width - ELLIPSIS_WIDTH).trimEnd(); + return [...kept, `${clipped}${ELLIPSIS}`]; +} diff --git a/apps/kimi-code/src/tui/components/index.ts b/apps/kimi-code/src/tui/components/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..52f6c052e7bd6ed7f485b851eb49e8af414e7873 --- /dev/null +++ b/apps/kimi-code/src/tui/components/index.ts @@ -0,0 +1,37 @@ +export * from './chrome/device-code-box'; +export * from './chrome/footer'; +export * from './chrome/moon-loader'; +export * from './chrome/todo-panel'; +export * from './chrome/welcome'; +export * from './dialogs/approval-panel'; +export * from './dialogs/choice-picker'; +export * from './dialogs/compaction'; +export * from './dialogs/editor-selector'; +export * from './dialogs/experiments-selector'; +export * from './dialogs/help-panel'; +export * from './dialogs/model-selector'; +export * from './dialogs/permission-selector'; +export * from './dialogs/question-dialog'; +export * from './dialogs/session-picker'; +export * from './dialogs/settings-selector'; +export * from './dialogs/theme-selector'; +export * from './editor/custom-editor'; +export * from './editor/file-mention-provider'; +export * from './media/code-highlight'; +export * from './media/diff-preview'; +export * from './media/image-thumbnail'; +export * from './messages/agent-group'; +export * from './messages/assistant-message'; +export * from './messages/background-agent-status'; +export * from './messages/plan-box'; +export * from './messages/read-group'; +export * from './messages/shell-execution'; +export * from './messages/skill-activation'; +export * from './messages/status-message'; +export * from './messages/swarm-markers'; +export * from './messages/thinking'; +export * from './messages/tool-call'; +export * from './messages/usage-panel'; +export * from './messages/user-message'; +export * from './panes/activity-pane'; +export * from './panes/queue-pane'; diff --git a/apps/kimi-code/src/tui/components/media/code-highlight.ts b/apps/kimi-code/src/tui/components/media/code-highlight.ts new file mode 100644 index 0000000000000000000000000000000000000000..deec7a2591747e450ba8b5fe1830170a22a71044 --- /dev/null +++ b/apps/kimi-code/src/tui/components/media/code-highlight.ts @@ -0,0 +1,54 @@ +/** + * Shared syntax-highlighting helpers for code previews + * (tool-call Write/Edit, approval-panel Write content, etc.). + */ + +import { extname } from 'node:path'; + +import { highlight, supportsLanguage } from 'cli-highlight'; + +import { codeHighlightTheme } from '#/tui/theme/highlight-theme'; + +const EXT_LANG_MAP: Record<string, string> = { + ts: 'typescript', + tsx: 'typescript', + js: 'javascript', + jsx: 'javascript', + py: 'python', + rb: 'ruby', + rs: 'rust', + go: 'go', + java: 'java', + sh: 'bash', + bash: 'bash', + zsh: 'bash', + json: 'json', + yaml: 'yaml', + yml: 'yaml', + toml: 'toml', + md: 'markdown', + css: 'css', + html: 'html', + sql: 'sql', + c: 'c', + cpp: 'cpp', + h: 'c', + hpp: 'cpp', +}; + +export function langFromPath(filePath: string): string | undefined { + const ext = extname(filePath).slice(1).toLowerCase(); + if (ext.length === 0) return undefined; + const lang = EXT_LANG_MAP[ext] ?? ext; + return supportsLanguage(lang) ? lang : undefined; +} + +export function highlightLines(code: string, lang: string | undefined): string[] { + const normalizedLang = lang?.trim().toLowerCase(); + if (!normalizedLang || !supportsLanguage(normalizedLang)) return code.split('\n'); + try { + return highlight(code, { language: normalizedLang, ignoreIllegals: true, theme: codeHighlightTheme }).split('\n'); + } catch { + return code.split('\n'); + } +} diff --git a/apps/kimi-code/src/tui/components/media/diff-preview.ts b/apps/kimi-code/src/tui/components/media/diff-preview.ts new file mode 100644 index 0000000000000000000000000000000000000000..1fec48b26768b0e9783ab0a17f0efcb0b5d34582 --- /dev/null +++ b/apps/kimi-code/src/tui/components/media/diff-preview.ts @@ -0,0 +1,317 @@ +/** + * Diff preview rendering as plain ANSI strings. + * + * Reuses the diff algorithm from approval/DiffPreview.tsx, but outputs + * formatted text lines instead of React elements. + */ + +import chalk from 'chalk'; + +import { currentTheme } from '#/tui/theme'; + +export type DiffLineKind = 'context' | 'add' | 'delete'; + +interface DiffStyles { + add: (s: string) => string; + del: (s: string) => string; + addBold: (s: string) => string; + delBold: (s: string) => string; + gutter: (s: string) => string; + meta: (s: string) => string; +} + +function makeDiffStyles(): DiffStyles { + const palette = currentTheme.palette; + return { + add: (s) => chalk.hex(palette.diffAdded)(s), + del: (s) => chalk.hex(palette.diffRemoved)(s), + addBold: (s) => chalk.bold.hex(palette.diffAddedStrong)(s), + delBold: (s) => chalk.bold.hex(palette.diffRemovedStrong)(s), + gutter: (s) => chalk.hex(palette.diffGutter)(s), + meta: (s) => chalk.hex(palette.diffMeta)(s), + }; +} + +export interface DiffLine { + kind: DiffLineKind; + lineNum: number; + code: string; +} + +export function computeDiffLines( + oldText: string, + newText: string, + oldStart: number = 1, + newStart: number = 1, + isIncomplete: boolean = false, +): DiffLine[] { + const oldLines = oldText ? oldText.split('\n') : []; + const newLines = newText ? newText.split('\n') : []; + const m = oldLines.length; + const n = newLines.length; + + const dp: number[][] = Array.from({ length: m + 1 }, () => + Array.from({ length: n + 1 }, () => 0), + ); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (oldLines[i - 1] === newLines[j - 1]) { + dp[i]![j] = dp[i - 1]![j - 1]! + 1; + } else { + dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!); + } + } + } + + const reversed: DiffLine[] = []; + let i = m; + let j = n; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { + reversed.push({ kind: 'context', lineNum: newStart + j - 1, code: newLines[j - 1]! }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i]![j - 1]! >= dp[i - 1]![j]!)) { + reversed.push({ kind: 'add', lineNum: newStart + j - 1, code: newLines[j - 1]! }); + j--; + } else { + reversed.push({ kind: 'delete', lineNum: oldStart + i - 1, code: oldLines[i - 1]! }); + i--; + } + } + + const result: DiffLine[] = []; + for (let k = reversed.length - 1; k >= 0; k--) { + result.push(reversed[k]!); + } + + // While the text is still streaming, suppress trailing delete lines. + // They are likely artefacts of newText not having arrived yet rather + // than genuine deletions. + if (isIncomplete && result.length > 0) { + let lastNonDelete = result.length - 1; + while (lastNonDelete >= 0 && result[lastNonDelete]!.kind === 'delete') { + lastNonDelete--; + } + if (lastNonDelete >= 0) { + result.length = lastNonDelete + 1; + } else { + // Every line would be shown as deleted; suppress them all so the + // UI doesn't flash a wall of red before newText starts arriving. + result.length = 0; + } + } + + return result; +} + +export function renderDiffLines( + oldText: string, + newText: string, + path: string, + isIncomplete: boolean = false, + oldStart?: number, + newStart?: number, + maxLines?: number, +): string[] { + const s = makeDiffStyles(); + const diffLines = computeDiffLines(oldText, newText, oldStart ?? 1, newStart ?? 1, isIncomplete); + const changedLines = diffLines.filter((l) => l.kind !== 'context'); + const added = changedLines.filter((l) => l.kind === 'add').length; + const removed = changedLines.filter((l) => l.kind === 'delete').length; + + const output: string[] = []; + + let header = ''; + if (added > 0) header += s.addBold(`+${String(added)} `); + if (removed > 0) header += s.delBold(`-${String(removed)} `); + header += path; + output.push(header); + + const shown = + maxLines !== undefined && maxLines >= 0 && changedLines.length > maxLines + ? changedLines.slice(0, maxLines) + : changedLines; + + for (const line of shown) { + const marker = line.kind === 'add' ? '+' : '-'; + const color = line.kind === 'add' ? s.add : s.del; + output.push(s.gutter(String(line.lineNum).padStart(4) + ' ') + color(marker + ' ' + line.code)); + } + + const hidden = changedLines.length - shown.length; + if (hidden > 0) { + output.push( + s.meta( + ` … ${String(hidden)} more change${hidden > 1 ? 's' : ''} hidden (ctrl+o to expand)`, + ), + ); + } + + return output; +} + +export interface ClusteredDiffOptions { + readonly contextLines?: number; + readonly maxLines?: number; + readonly isIncomplete?: boolean; + readonly expandKeyHint?: string; + readonly oldStart?: number; + readonly newStart?: number; +} + +interface Cluster { + readonly start: number; + readonly end: number; +} + +function buildClusters( + diffLines: DiffLine[], + contextLines: number, +): { clusters: Cluster[]; changedCount: number; addedCount: number; removedCount: number } { + const changeIndices: number[] = []; + let added = 0; + let removed = 0; + for (const [i, line] of diffLines.entries()) { + if (line.kind === 'add') { + added++; + changeIndices.push(i); + } else if (line.kind === 'delete') { + removed++; + changeIndices.push(i); + } + } + + const clusters: Cluster[] = []; + if (changeIndices.length === 0) { + return { clusters, changedCount: 0, addedCount: added, removedCount: removed }; + } + + const mergeGap = 2 * contextLines; + let groupStart = changeIndices[0]!; + let groupEnd = changeIndices[0]!; + for (let i = 1; i < changeIndices.length; i++) { + const idx = changeIndices[i]!; + if (idx - groupEnd <= mergeGap) { + groupEnd = idx; + } else { + clusters.push({ + start: Math.max(0, groupStart - contextLines), + end: Math.min(diffLines.length - 1, groupEnd + contextLines), + }); + groupStart = idx; + groupEnd = idx; + } + } + clusters.push({ + start: Math.max(0, groupStart - contextLines), + end: Math.min(diffLines.length - 1, groupEnd + contextLines), + }); + + return { + clusters, + changedCount: changeIndices.length, + addedCount: added, + removedCount: removed, + }; +} + +function formatDiffRow(line: DiffLine, s: DiffStyles): string { + const gutter = s.gutter(String(line.lineNum).padStart(4) + ' '); + if (line.kind === 'add') return gutter + s.add('+ ' + line.code); + if (line.kind === 'delete') return gutter + s.del('- ' + line.code); + return gutter + ' ' + line.code; +} + +/** + * Render a diff with surrounding context, eliding unchanged middle + * regions between change clusters with a `… N unchanged lines …` + * separator. When `maxLines` is set, the body is capped at a cluster + * boundary and a `ctrl+o to expand` footer is appended. + * + * Used by Edit's call preview where we want to show *what changed* + * with enough context to read the change, but not the whole file. + */ +export function renderDiffLinesClustered( + oldText: string, + newText: string, + path: string, + opts: ClusteredDiffOptions = {}, +): string[] { + const s = makeDiffStyles(); + const contextLines = opts.contextLines ?? 3; + const maxLines = opts.maxLines; + const diffLines = computeDiffLines( + oldText, + newText, + opts.oldStart ?? 1, + opts.newStart ?? 1, + opts.isIncomplete ?? false, + ); + const { clusters, changedCount, addedCount, removedCount } = buildClusters( + diffLines, + contextLines, + ); + + const output: string[] = []; + let header = ''; + if (addedCount > 0) header += s.addBold(`+${String(addedCount)} `); + if (removedCount > 0) header += s.delBold(`-${String(removedCount)} `); + header += path; + output.push(header); + + if (clusters.length === 0) return output; + + const cap = maxLines !== undefined && maxLines >= 0 ? maxLines : Number.POSITIVE_INFINITY; + let body = 0; + let prevEnd = -1; + let truncated = false; + let shownChanges = 0; + + outer: for (const cluster of clusters) { + if (body >= cap) { + truncated = true; + break; + } + if (prevEnd >= 0) { + const gap = cluster.start - prevEnd - 1; + if (gap > 0) { + if (body + 1 > cap) { + truncated = true; + break; + } + output.push(s.meta(` … ${String(gap)} unchanged line${gap > 1 ? 's' : ''} …`)); + body++; + } + } + // Emit cluster rows one at a time; allow mid-cluster truncation so + // a single huge cluster (e.g. the whole file replaced inline) still + // shows the leading lines instead of degenerating to "N changes + // hidden" with no body at all. + for (let i = cluster.start; i <= cluster.end; i++) { + if (body >= cap) { + truncated = true; + break outer; + } + const line = diffLines[i]!; + output.push(formatDiffRow(line, s)); + body++; + if (line.kind !== 'context') shownChanges++; + prevEnd = i; + } + } + + if (truncated) { + const hidden = changedCount - shownChanges; + if (hidden > 0) { + const hint = opts.expandKeyHint ?? 'ctrl+o'; + output.push( + s.meta( + ` … ${String(hidden)} more change${hidden > 1 ? 's' : ''} hidden (${hint} to expand)`, + ), + ); + } + } + + return output; +} diff --git a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts new file mode 100644 index 0000000000000000000000000000000000000000..04e80f534edb4a79fb8bc1a0bd0e87b44a80006e --- /dev/null +++ b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts @@ -0,0 +1,89 @@ +/** + * Transcript-side rendering of a pasted image. + * + * On terminals that speak the Kitty graphics protocol or iTerm2 inline + * image protocol (detected by pi-tui's `getCapabilities()`), we show + * the actual image. Everywhere else we fall back to a one-line text + * marker matching the placeholder the user sees in the input box — + * this keeps the transcript readable on Terminal.app / Linux default + * terminals / `script` recordings without extra chrome. + * + * Height is capped at ~12 rows so a single screenshot can't monopolize + * the viewport; pi-tui handles proportional scaling internally. + */ + +import { Container, Image, Text, type ImageTheme, getCapabilities } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; + +const MAX_IMAGE_ROWS = 12; +const MAX_IMAGE_WIDTH = 40; + +export class ImageThumbnail extends Container { + private readonly attachment: ImageAttachment; + private lastRenderWidth = 80; + private lastBuiltWidth: number | undefined; + private lastBuiltInline: boolean | undefined; + + constructor(attachment: ImageAttachment) { + super(); + this.attachment = attachment; + this.buildChildren(this.lastRenderWidth); + } + + private buildChildren(width: number): void { + this.clear(); + const caps = getCapabilities(); + const supportsInline = caps.images === 'kitty' || caps.images === 'iterm2'; + + if (!supportsInline) { + this.addChild(new Text(currentTheme.fg('accent', this.attachment.placeholder), 0, 0)); + this.lastBuiltWidth = width; + this.lastBuiltInline = false; + return; + } + + const theme: ImageTheme = { + fallbackColor: (s: string) => currentTheme.fg('textDim', s), + }; + const base64 = Buffer.from(this.attachment.bytes).toString('base64'); + const image = new Image( + base64, + this.attachment.mime, + theme, + { + maxHeightCells: MAX_IMAGE_ROWS, + maxWidthCells: Math.max(1, Math.min(MAX_IMAGE_WIDTH, width - 2)), + filename: this.attachment.placeholder, + }, + { widthPx: this.attachment.width, heightPx: this.attachment.height }, + ); + this.addChild(image); + this.lastBuiltWidth = width; + this.lastBuiltInline = true; + } + + override render(width: number): string[] { + const safeWidth = Math.max(0, width); + this.lastRenderWidth = safeWidth; + + if (safeWidth < MAX_IMAGE_WIDTH + 2) { + return new Text(currentTheme.fg('accent', this.attachment.placeholder), 0, 0).render( + safeWidth, + ); + } + + const caps = getCapabilities(); + const supportsInline = caps.images === 'kitty' || caps.images === 'iterm2'; + if (this.lastBuiltWidth !== safeWidth || this.lastBuiltInline !== supportsInline) { + this.buildChildren(safeWidth); + } + return super.render(safeWidth); + } + + override invalidate(): void { + this.buildChildren(this.lastRenderWidth); + super.invalidate(); + } +} diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts new file mode 100644 index 0000000000000000000000000000000000000000..983624eec480c00b81864a4c42ee16a80357037c --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -0,0 +1,379 @@ +/** + * AgentGroupComponent renders 2+ Agent tool calls from the same step as one group. + * + * Design: + * - State container: each child Agent keeps its real state in its + * `ToolCallComponent` (subagent meta, phase, sub-tool calls, tokens, text). + * AgentGroup only stores references and does not copy state. Event handlers + * still route through `state.pendingToolComponents.get(parent_tool_call_id)`. + * - Subscription: `attach` registers a snapshot listener on each child so the + * group can refresh when child state changes. + * - Throttling: normal changes are coalesced into one render every 200ms. + * Phase transitions (spawning -> running -> done/failed) flush immediately. + * - Mounting: `KimiTUI` attaches the group to the transcript at the + * right time; the group handles `invalidate` plus `ui.requestRender`. + * - Ungrouping is not implemented. Once formed, a group stays grouped. + */ + +import type { TUI } from '@moonshot-ai/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { formatTokenCount } from '#/utils/usage/usage-format'; + +import type { ToolCallComponent, ToolCallSubagentSnapshot } from './tool-call'; + +const THROTTLE_MS = 200; + +const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background'; + +interface AgentEntry { + readonly toolCallId: string; + readonly tc: ToolCallComponent; +} + +interface PhaseCounts { + readonly done: number; + readonly failed: number; + readonly backgrounded: number; + readonly running: number; + readonly waiting: number; + readonly starting: number; + readonly terminal: number; +} + +export class AgentGroupComponent extends Container { + private readonly entries: AgentEntry[] = []; + private readonly headerText: Text; + private readonly bodyContainer: Container; + private throttleTimer: ReturnType<typeof setTimeout> | null = null; + private lastFlushPhases = new Map<string, ToolCallSubagentSnapshot['phase']>(); + private _invalidating = false; + + constructor(private readonly ui: TUI | undefined) { + super(); + this.addChild(new Spacer(1)); + this.headerText = new Text('', 0, 0); + this.addChild(this.headerText); + this.bodyContainer = new Container(); + this.addChild(this.bodyContainer); + } + + size(): number { + return this.entries.length; + } + + /** + * Exposes the borrowed tool call components so external code (e.g. + * routing background task terminal events back to the corresponding + * Agent card) can reach them — the group renders the tcs' snapshots + * but never mounts the tcs as Container children, so a plain tree + * walk of `transcriptContainer` cannot discover them. + */ + getToolComponents(): readonly ToolCallComponent[] { + return this.entries.map((entry) => entry.tc); + } + + /** + * Borrows a standalone `ToolCallComponent` into the group as a hidden state + * container. Snapshot changes trigger throttled refreshes. Re-attaching the + * same toolCallId is a no-op. + */ + attach(toolCallId: string, tc: ToolCallComponent): void { + if (this.entries.some((e) => e.toolCallId === toolCallId)) return; + this.entries.push({ toolCallId, tc }); + tc.setSnapshotListener(() => { + this.scheduleRender(); + }); + this.flushRender(); + } + + /** + * Schedules a repaint. Real phase transitions force an immediate refresh; + * other changes such as latestActivity, tokens, or toolCount are throttled. + */ + private scheduleRender(): void { + if (this.detectPhaseTransition()) { + this.flushRender(); + return; + } + if (this.throttleTimer !== null) return; + this.throttleTimer = setTimeout(() => { + this.throttleTimer = null; + this.flushRender(); + }, THROTTLE_MS); + } + + /** + * Compares each child's current phase with the phase captured at the last + * flush. Any change is treated as a phase transition. + */ + private detectPhaseTransition(): boolean { + let changed = false; + for (const e of this.entries) { + const phase = e.tc.getSubagentSnapshot().phase; + if (this.lastFlushPhases.get(e.toolCallId) !== phase) { + changed = true; + break; + } + } + return changed; + } + + private flushRender(): void { + if (this.throttleTimer !== null) { + clearTimeout(this.throttleTimer); + this.throttleTimer = null; + } + + const snapshots = this.entries.map((e) => e.tc.getSubagentSnapshot()); + this.headerText.setText(this.buildHeader(snapshots)); + this.bodyContainer.clear(); + snapshots.forEach((snap, idx) => { + const isLast = idx === snapshots.length - 1; + this.appendLines(snap, isLast); + }); + if (this.shouldShowDetachHint(snapshots)) { + this.bodyContainer.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0)); + } + + this.lastFlushPhases.clear(); + this.entries.forEach((entry, i) => { + const snap = snapshots[i]; + if (snap !== undefined) this.lastFlushPhases.set(entry.toolCallId, snap.phase); + }); + + this.invalidate(); + this.ui?.requestRender(); + } + + private buildHeader(snapshots: readonly ToolCallSubagentSnapshot[]): string { + const total = snapshots.length; + const counts = countPhases(snapshots); + const allDone = counts.terminal === total; + const bullet = allDone + ? currentTheme.fg('success', STATUS_BULLET) + : currentTheme.fg('text', STATUS_BULLET); + const elapsedSeconds = maxElapsedSeconds(snapshots); + + if (allDone) { + const types = new Set(snapshots.map((s) => s.agentName).filter((n) => n !== undefined)); + const headerLabel = + types.size === 1 + ? `${String(total)} ${[...types][0]} agents finished` + : `${String(total)} agents finished`; + const totalTools = snapshots.reduce((acc, s) => acc + s.toolCount, 0); + const totalTokens = snapshots.reduce((acc, s) => acc + s.tokens, 0); + const tail = formatHeaderTail({ toolCount: totalTools, tokens: totalTokens, elapsedSeconds }); + return `${bullet}${currentTheme.boldFg('primary', headerLabel)}${tail}`; + } + + const parts = formatBreakdownParts(counts); + const headerText = parts.length > 0 + ? `Running ${String(total)} agents (${parts.join(', ')})` + : `Running ${String(total)} agents`; + const tail = formatHeaderTail({ toolCount: 0, tokens: 0, elapsedSeconds }); + return `${bullet}${currentTheme.boldFg('primary', headerText)}${tail}`; + } + + private appendLines(snap: ToolCallSubagentSnapshot, isLast: boolean): void { + const dim = (text: string) => currentTheme.dim(text); + + // First-level branch line. + const branch1 = isLast ? '└─' : '├─'; + const agentType = snap.agentName ?? 'agent'; + const desc = snap.toolCallDescription || '(no description)'; + const tail = formatLineTail(snap); + const namePart = currentTheme.fg('primary', agentType); + const descPart = dim(`· ${desc}`); + const stats = formatStats(snap); + const line1 = ` ${branch1} ${namePart} ${descPart}${stats}${tail}`; + this.bodyContainer.addChild(new Text(line1, 0, 0)); + + // Second-level line: latest activity, or Error for failures. + const branch2 = isLast ? ' ' : '│ '; + if (snap.phase === 'failed') { + // Show one error line; error messages can be long. + const errLine = (snap.errorText ?? 'Failed').split('\n').at(0) ?? 'Failed'; + const errStr = currentTheme.fg('error', `Error: ${errLine}`); + this.bodyContainer.addChild(new Text(` ${branch2} ${errStr}`, 0, 0)); + return; + } + if (snap.phase === 'done' || snap.phase === 'backgrounded') { + // Terminal states omit the second line. + return; + } + // Running or not-yet-started agents show latest activity, with a fallback. + const activity = snap.latestActivity ?? fallbackActivityForPhase(snap.phase); + this.bodyContainer.addChild(new Text(` ${branch2} ${dim(activity)}`, 0, 0)); + } + + /** + * Show the Ctrl+B hint while at least one agent in the group is still + * running in the foreground (i.e. can be detached). Hide it once every + * agent is done, failed, or already backgrounded. + */ + private shouldShowDetachHint(snapshots: readonly ToolCallSubagentSnapshot[]): boolean { + return snapshots.some( + (s) => + s.phase === 'running' || + s.phase === 'queued' || + s.phase === 'spawning' || + s.phase === undefined, + ); + } + + /** Releases throttle timers so destroyed components cannot refresh later. */ + override invalidate(): void { + if (this._invalidating) { + super.invalidate(); + return; + } + this._invalidating = true; + this.flushRender(); + this._invalidating = false; + } + + dispose(): void { + if (this.throttleTimer !== null) { + clearTimeout(this.throttleTimer); + this.throttleTimer = null; + } + for (const e of this.entries) { + e.tc.setSnapshotListener(undefined); + } + } +} + +function countPhases(snapshots: readonly ToolCallSubagentSnapshot[]): PhaseCounts { + let done = 0; + let failed = 0; + let backgrounded = 0; + let running = 0; + let waiting = 0; + let starting = 0; + + for (const snap of snapshots) { + switch (snap.phase) { + case 'done': + done += 1; + break; + case 'failed': + failed += 1; + break; + case 'backgrounded': + backgrounded += 1; + break; + case 'queued': + waiting += 1; + break; + case 'running': + running += 1; + break; + case 'spawning': + case undefined: + starting += 1; + break; + } + } + + return { + done, + failed, + backgrounded, + running, + waiting, + starting, + terminal: done + failed + backgrounded, + }; +} + +function formatBreakdownParts(counts: PhaseCounts): string[] { + const parts: string[] = []; + if (counts.done > 0) parts.push(`${String(counts.done)} done`); + if (counts.failed > 0) parts.push(`${String(counts.failed)} failed`); + if (counts.backgrounded > 0) parts.push(`${String(counts.backgrounded)} backgrounded`); + if (counts.running > 0) parts.push(`${String(counts.running)} running`); + if (counts.waiting > 0) parts.push(`${String(counts.waiting)} waiting`); + if (counts.starting > 0) parts.push(`${String(counts.starting)} starting`); + return parts; +} + +function formatStats(snap: ToolCallSubagentSnapshot): string { + const parts: string[] = []; + if (snap.model !== undefined) parts.push(snap.model); + if (snap.effort !== undefined) parts.push(snap.effort); + parts.push(`${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`); + if (snap.elapsedSeconds !== undefined) parts.push(formatElapsed(snap.elapsedSeconds)); + if (snap.tokens > 0) parts.push(formatTokens(snap.tokens)); + return currentTheme.dim(` · ${parts.join(' · ')}`); +} + +function formatLineTail(snap: ToolCallSubagentSnapshot): string { + const separator = currentTheme.dim(' · '); + switch (snap.phase) { + case 'done': + return separator + currentTheme.fg('success', '✓ Completed'); + case 'failed': + return separator + currentTheme.fg('error', '✗ Failed'); + case 'backgrounded': + return separator + currentTheme.dim('◐ backgrounded'); + case 'queued': + return separator + currentTheme.fg('primary', 'Waiting'); + case 'running': + return separator + currentTheme.fg('primary', 'Running'); + case 'spawning': + case undefined: + return separator + currentTheme.fg('primary', 'Starting'); + } +} + +function fallbackActivityForPhase(phase: ToolCallSubagentSnapshot['phase']): string { + switch (phase) { + case 'queued': + return 'Waiting to start…'; + case 'running': + return 'Still working…'; + case 'spawning': + case undefined: + return 'Starting…'; + case 'done': + case 'failed': + case 'backgrounded': + return ''; + } +} + +function formatHeaderTail(args: { + readonly toolCount: number; + readonly tokens: number; + readonly elapsedSeconds: number | undefined; +}): string { + const parts: string[] = []; + if (args.toolCount > 0) parts.push(`${String(args.toolCount)} tool${args.toolCount === 1 ? '' : 's'}`); + if (args.tokens > 0) parts.push(formatTokens(args.tokens)); + if (args.elapsedSeconds !== undefined) parts.push(formatElapsed(args.elapsedSeconds)); + return parts.length > 0 ? currentTheme.dim(` · ${parts.join(' · ')}`) : ''; +} + +function maxElapsedSeconds(snapshots: readonly ToolCallSubagentSnapshot[]): number | undefined { + let max: number | undefined; + for (const snap of snapshots) { + const elapsed = snap.elapsedSeconds; + if (elapsed === undefined) continue; + max = max === undefined ? elapsed : Math.max(max, elapsed); + } + return max; +} + +function formatElapsed(seconds: number): string { + if (seconds < 60) return `${String(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return `${String(minutes)}m ${String(remainder)}s`; +} + +function formatTokens(n: number): string { + return `${formatTokenCount(n)} tok`; +} diff --git a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress-estimator.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress-estimator.ts new file mode 100644 index 0000000000000000000000000000000000000000..c61a6dc6464408253bf77c8020501c7f8846aafc --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress-estimator.ts @@ -0,0 +1,455 @@ +const DEFAULT_RATE_WINDOW_MS = 45_000; +const DEFAULT_CATCHUP_TIME_MS = 1_500; +const DEFAULT_WORKLOAD_SPREAD_FACTOR = 1.5; +const DEFAULT_UNFINISHED_PROGRESS_CAP = 0.85; +const DEFAULT_MAX_BOOST_GAIN = 0.75; +const RATE_TOOL_CONFIDENCE_SCALE = 4; +const BOOST_TOOL_CONFIDENCE_SCALE = 3; +const MIN_RATE_FACTOR = 0.25; +const HALF_TICK = 0.5; + +export type AgentSwarmProgressEstimatorPhase = + | 'pending' + | 'queued' + | 'suspended' + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface AgentSwarmProgressEstimatorOptions { + readonly rateWindowMs?: number; + readonly catchupTimeMs?: number; + readonly maxCatchupTicksPerSecond?: number; + readonly workloadSpreadFactor?: number; + readonly unfinishedProgressCap?: number; + readonly maxBoostGain?: number; +} + +export interface AgentSwarmProgressEstimateInput { + readonly memberKey: string; + readonly phase: AgentSwarmProgressEstimatorPhase; + readonly capacityTicks: number; + readonly nowMs: number; +} + +export interface AgentSwarmProgressEstimate { + readonly rawTicks: number; + readonly displayTicks: number; + readonly estimatedTotalToolCalls?: number; + readonly estimatedProgress?: number; + readonly targetProgress?: number; + readonly targetTicks?: number; + readonly boosted: boolean; + readonly confidence?: number; +} + +interface MemberProgressState { + startedAtMs?: number; + pausedAtMs?: number; + pausedDurationMs: number; + terminalAtMs?: number; + terminalKind?: 'completed' | 'failed' | 'cancelled'; + rawTicks: number; + readonly seenToolCallIds: Set<string>; + toolCallActiveTimesMs: number[]; + displayTicks: number; + lastEstimateAtMs?: number; + lastTargetTicks?: number; +} + +interface CompletedSample { + readonly totalMs: number; + readonly rawTicks: number; +} + +interface EstimatePrior { + readonly completedCount: number; + readonly typicalTotalMs: number; + readonly typicalToolCalls: number; + readonly typicalRatePerMs: number; +} + +export class AgentSwarmProgressEstimator { + private readonly members = new Map<string, MemberProgressState>(); + private readonly rateWindowMs: number; + private readonly catchupTimeMs: number; + private readonly maxCatchupTicksPerSecond: number | undefined; + private readonly workloadSpreadFactor: number; + private readonly unfinishedProgressCap: number; + private readonly maxBoostGain: number; + private samplesVersion = 0; + private priorCache: { readonly version: number; readonly prior: EstimatePrior | undefined } | undefined; + + constructor(options: AgentSwarmProgressEstimatorOptions = {}) { + this.rateWindowMs = positiveOrDefault(options.rateWindowMs, DEFAULT_RATE_WINDOW_MS); + this.catchupTimeMs = positiveOrDefault(options.catchupTimeMs, DEFAULT_CATCHUP_TIME_MS); + this.maxCatchupTicksPerSecond = positiveOrUndefined(options.maxCatchupTicksPerSecond); + this.workloadSpreadFactor = spreadFactorOrDefault( + options.workloadSpreadFactor, + DEFAULT_WORKLOAD_SPREAD_FACTOR, + ); + this.unfinishedProgressCap = clampPositiveRatio( + options.unfinishedProgressCap, + DEFAULT_UNFINISHED_PROGRESS_CAP, + ); + this.maxBoostGain = clampPositiveRatio(options.maxBoostGain, DEFAULT_MAX_BOOST_GAIN); + } + + ensureMember(memberKey: string, nowMs: number): void { + void nowMs; + this.getOrCreateMember(memberKey); + } + + removeMissingMembers(memberKeys: readonly string[]): void { + const live = new Set(memberKeys); + for (const memberKey of this.members.keys()) { + if (!live.has(memberKey)) { + this.members.delete(memberKey); + this.samplesVersion += 1; + } + } + } + + markStarted(memberKey: string, nowMs: number): void { + const state = this.getOrCreateMember(memberKey); + this.startWork(state, nowMs); + if (state.rawTicks === 0) { + state.rawTicks = 1; + state.displayTicks = Math.max(state.displayTicks, 1); + } + delete state.terminalAtMs; + delete state.terminalKind; + this.samplesVersion += 1; + } + + markQueued(memberKey: string, nowMs: number): void { + const state = this.getOrCreateMember(memberKey); + if (state.startedAtMs === undefined || state.terminalKind !== undefined) return; + state.pausedAtMs ??= nowMs; + state.lastEstimateAtMs = nowMs; + delete state.lastTargetTicks; + } + + recordToolCall(input: { + readonly memberKey: string; + readonly toolCallId: string; + readonly nowMs: number; + }): { readonly accepted: boolean; readonly rawTicks: number } { + const state = this.getOrCreateMember(input.memberKey); + this.startWork(state, input.nowMs); + if (state.seenToolCallIds.has(input.toolCallId)) { + return { accepted: false, rawTicks: state.rawTicks }; + } + state.seenToolCallIds.add(input.toolCallId); + state.toolCallActiveTimesMs.push(this.activeElapsedMs(state, input.nowMs)); + state.rawTicks += 1; + state.displayTicks = Math.max(state.displayTicks + 1, state.rawTicks); + delete state.terminalAtMs; + delete state.terminalKind; + this.samplesVersion += 1; + return { accepted: true, rawTicks: state.rawTicks }; + } + + markCompleted(memberKey: string, nowMs: number): void { + this.markTerminal(memberKey, nowMs, 'completed'); + } + + markFailed(memberKey: string, nowMs: number): void { + this.markTerminal(memberKey, nowMs, 'failed'); + } + + markCancelled(memberKey: string, nowMs: number): void { + this.markTerminal(memberKey, nowMs, 'cancelled'); + } + + estimate(input: AgentSwarmProgressEstimateInput): AgentSwarmProgressEstimate { + const state = this.getOrCreateMember(input.memberKey); + const capacityTicks = Math.max(1, input.capacityTicks); + const rawTicks = state.rawTicks; + const previousDisplayTicks = Math.max(state.displayTicks, rawTicks); + const prior = this.buildPrior(); + const baseEstimate = { + rawTicks, + displayTicks: previousDisplayTicks, + boosted: false, + }; + + if (input.phase !== 'running' || rawTicks <= 0 || prior === undefined) { + state.displayTicks = previousDisplayTicks; + state.lastEstimateAtMs = input.nowMs; + delete state.lastTargetTicks; + return baseEstimate; + } + + const completedConfidence = this.completedSampleConfidence(prior.completedCount); + const estimatedTotalToolCalls = this.estimateTotalToolCalls( + state, + prior, + input.nowMs, + completedConfidence, + ); + const estimatedProgress = Math.min( + this.unfinishedProgressCap, + rawTicks / estimatedTotalToolCalls, + ); + const rawProgress = Math.min(1, rawTicks / capacityTicks); + if (estimatedProgress <= rawProgress) { + state.displayTicks = previousDisplayTicks; + state.lastEstimateAtMs = input.nowMs; + delete state.lastTargetTicks; + return { + ...baseEstimate, + estimatedTotalToolCalls, + estimatedProgress, + boosted: false, + }; + } + + const toolConfidence = confidence(rawTicks, BOOST_TOOL_CONFIDENCE_SCALE); + const boostConfidence = completedConfidence * toolConfidence; + const boostGain = this.maxBoostGain * boostConfidence; + const targetProgress = rawProgress + boostGain * (estimatedProgress - rawProgress); + const targetTicks = Math.max(rawTicks, targetProgress * capacityTicks); + const displayTicks = this.catchUpDisplayTicks( + state, + previousDisplayTicks, + targetTicks, + capacityTicks, + input.nowMs, + ); + + state.displayTicks = displayTicks; + state.lastEstimateAtMs = input.nowMs; + state.lastTargetTicks = targetTicks; + return { + rawTicks, + displayTicks, + estimatedTotalToolCalls, + estimatedProgress, + targetProgress, + targetTicks, + boosted: displayTicks > rawTicks, + confidence: boostConfidence, + }; + } + + estimateAll( + inputs: readonly AgentSwarmProgressEstimateInput[], + ): Map<string, AgentSwarmProgressEstimate> { + const estimates = new Map<string, AgentSwarmProgressEstimate>(); + for (const input of inputs) { + estimates.set(input.memberKey, this.estimate(input)); + } + return estimates; + } + + hasPendingCatchup(): boolean { + return Array.from(this.members.values()).some( + (state) => state.lastTargetTicks !== undefined && state.lastTargetTicks > state.displayTicks + 0.1, + ); + } + + private markTerminal( + memberKey: string, + nowMs: number, + terminalKind: 'completed' | 'failed' | 'cancelled', + ): void { + const state = this.getOrCreateMember(memberKey); + this.finishPausedInterval(state, nowMs); + state.terminalAtMs = nowMs; + state.terminalKind = terminalKind; + state.displayTicks = Math.max(state.displayTicks, state.rawTicks); + delete state.lastTargetTicks; + this.samplesVersion += 1; + } + + private startWork(state: MemberProgressState, nowMs: number): void { + const wasQueued = state.startedAtMs === undefined || state.pausedAtMs !== undefined; + state.startedAtMs ??= nowMs; + this.finishPausedInterval(state, nowMs); + if (!wasQueued) return; + delete state.lastEstimateAtMs; + delete state.lastTargetTicks; + } + + private finishPausedInterval(state: MemberProgressState, nowMs: number): void { + if (state.pausedAtMs === undefined) return; + state.pausedDurationMs += Math.max(0, nowMs - state.pausedAtMs); + delete state.pausedAtMs; + } + + private activeElapsedMs(state: MemberProgressState, nowMs: number): number { + if (state.startedAtMs === undefined) return 0; + const currentPausedMs = + state.pausedAtMs === undefined ? 0 : Math.max(0, nowMs - state.pausedAtMs); + return Math.max(0, nowMs - state.startedAtMs - state.pausedDurationMs - currentPausedMs); + } + + private getOrCreateMember(memberKey: string): MemberProgressState { + const state = this.members.get(memberKey) ?? { + pausedDurationMs: 0, + rawTicks: 0, + seenToolCallIds: new Set(), + toolCallActiveTimesMs: [], + displayTicks: 0, + }; + this.members.set(memberKey, state); + return state; + } + + private buildPrior(): EstimatePrior | undefined { + const cache = this.priorCache; + if (cache !== undefined && cache.version === this.samplesVersion) return cache.prior; + // The prior depends only on completed members' terminal samples, so it is + // reused across every estimate until a mutation bumps the sample version + // instead of being rebuilt per running cell per frame. + const prior = this.computePrior(); + this.priorCache = { version: this.samplesVersion, prior }; + return prior; + } + + private computePrior(): EstimatePrior | undefined { + const samples = this.completedSamples(); + if (samples.length === 0) return undefined; + return { + completedCount: samples.length, + typicalTotalMs: logMedian(samples.map((sample) => sample.totalMs)), + typicalToolCalls: logMedian(samples.map((sample) => sample.rawTicks)), + typicalRatePerMs: logMedian( + samples.map((sample) => (sample.rawTicks + HALF_TICK) / sample.totalMs), + ), + }; + } + + private completedSamples(): CompletedSample[] { + const samples: CompletedSample[] = []; + for (const state of this.members.values()) { + if (state.terminalKind !== 'completed') continue; + if (state.startedAtMs === undefined || state.terminalAtMs === undefined) continue; + if (state.rawTicks <= 0) continue; + const totalMs = this.activeElapsedMs(state, state.terminalAtMs); + if (totalMs <= 0) continue; + samples.push({ totalMs, rawTicks: state.rawTicks }); + } + return samples; + } + + private estimateTotalToolCalls( + state: MemberProgressState, + prior: EstimatePrior, + nowMs: number, + completedConfidence: number, + ): number { + const elapsedMs = this.activeElapsedMs(state, nowMs); + const localRatePerMs = this.estimateLocalRatePerMs(state, elapsedMs); + const rateWeight = confidence(state.rawTicks, RATE_TOOL_CONFIDENCE_SCALE); + const clampedLocalRatePerMs = Math.max( + localRatePerMs, + prior.typicalRatePerMs * MIN_RATE_FACTOR, + ); + const ratePerMs = geometricInterpolate( + prior.typicalRatePerMs, + clampedLocalRatePerMs, + rateWeight, + ); + const totalMs = Math.max(prior.typicalTotalMs, elapsedMs / this.unfinishedProgressCap); + const estimatedTotalToolCalls = ratePerMs * totalMs; + const boundedTotalToolCalls = this.softBoundTotalToolCalls( + estimatedTotalToolCalls, + prior, + completedConfidence, + ); + return Math.max( + boundedTotalToolCalls, + state.rawTicks / this.unfinishedProgressCap, + 1, + ); + } + + private softBoundTotalToolCalls( + totalToolCalls: number, + prior: EstimatePrior, + completedConfidence: number, + ): number { + const lowerBound = prior.typicalToolCalls / this.workloadSpreadFactor; + const upperBound = prior.typicalToolCalls * this.workloadSpreadFactor; + const bounded = Math.max(lowerBound, Math.min(upperBound, totalToolCalls)); + if (bounded === totalToolCalls) return totalToolCalls; + return geometricInterpolate(totalToolCalls, bounded, completedConfidence); + } + + private estimateLocalRatePerMs( + state: MemberProgressState, + elapsedMs: number, + ): number { + if (elapsedMs <= 0 || state.toolCallActiveTimesMs.length === 0) return 0; + let decayedToolCalls = 0; + for (const timeMs of state.toolCallActiveTimesMs) { + decayedToolCalls += Math.exp(-Math.max(0, elapsedMs - timeMs) / this.rateWindowMs); + } + const decayedElapsedMs = this.rateWindowMs * (1 - Math.exp(-elapsedMs / this.rateWindowMs)); + if (decayedElapsedMs <= 0) return 0; + return decayedToolCalls / decayedElapsedMs; + } + + private catchUpDisplayTicks( + state: MemberProgressState, + previousDisplayTicks: number, + targetTicks: number, + capacityTicks: number, + nowMs: number, + ): number { + if (targetTicks <= previousDisplayTicks) return previousDisplayTicks; + const lastEstimateAtMs = state.lastEstimateAtMs ?? nowMs; + const elapsedMs = Math.max(0, nowMs - lastEstimateAtMs); + if (elapsedMs <= 0) return previousDisplayTicks; + const alpha = 1 - Math.exp(-elapsedMs / this.catchupTimeMs); + const desiredDelta = (targetTicks - previousDisplayTicks) * alpha; + const maxCatchupTicksPerSecond = this.maxCatchupTicksPerSecond ?? capacityTicks / 2; + const maxDelta = Math.max(0, maxCatchupTicksPerSecond * (elapsedMs / 1_000)); + return previousDisplayTicks + Math.min(desiredDelta, maxDelta); + } + + private completedSampleConfidence(completedCount: number): number { + return confidence(completedCount, 1 + this.workloadSpreadFactor); + } +} + +function positiveOrDefault(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback; +} + +function positiveOrUndefined(value: number | undefined): number | undefined { + return value !== undefined && Number.isFinite(value) && value > 0 ? value : undefined; +} + +function spreadFactorOrDefault(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isFinite(value) && value > 1 ? value : fallback; +} + +function clampPositiveRatio(value: number | undefined, fallback: number): number { + const ratio = positiveOrDefault(value, fallback); + return Math.max(0.01, Math.min(0.99, ratio)); +} + +function confidence(count: number, scale: number): number { + return 1 - Math.exp(-Math.max(0, count) / scale); +} + +function geometricInterpolate(low: number, high: number, weight: number): number { + const safeLow = Math.max(Number.EPSILON, low); + const safeHigh = Math.max(Number.EPSILON, high); + return Math.exp((1 - weight) * Math.log(safeLow) + weight * Math.log(safeHigh)); +} + +function logMedian(values: readonly number[]): number { + const logs = values + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.log(value)) + .toSorted((left, right) => left - right); + if (logs.length === 0) return 1; + const middle = Math.floor(logs.length / 2); + if (logs.length % 2 === 1) return Math.exp(logs[middle]!); + return Math.exp((logs[middle - 1]! + logs[middle]!) / 2); +} diff --git a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b8a7cc20b91237d90481a31cea4dec9740c7d82 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -0,0 +1,2024 @@ +import { truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import { + AgentSwarmProgressEstimator, + type AgentSwarmProgressEstimate, + type AgentSwarmProgressEstimatorPhase, +} from '#/tui/components/messages/agent-swarm-progress-estimator'; +import { + MAX_FINAL_OUTPUT_LABEL_CHARS, + MAX_FINAL_OUTPUT_LABEL_CODE_UNITS, +} from '#/tui/constant/rendering'; +import { FAILURE_MARK, SUCCESS_MARK } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; +import { gradientText } from '#/tui/theme/gradient-text'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; + +const TEXT_CELL_PREFERRED_WIDTH = 30; +const CELL_GAP = ' '; +const FRAME_INTERVAL_MS = 80; +const TEXT_BRAILLE_BAR_MIN_WIDTH = 6; +const BRAILLE_BAR_MAX_WIDTH = 8; +const BRAILLE_EMPTY = '⣀'; +const BRAILLE_RIGHT_COLUMN_FULL = '⢸'; +const BRAILLE_LEVELS = ['⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿'] as const; +const PHASE_LABEL_WIDTH = 'Completed'.length; +const MIN_LABEL_WIDTH = PHASE_LABEL_WIDTH; +const MAX_LATEST_MODEL_CHARS = 2_000; +const COMPLETE_FILL_MS = 360; +const FAILED_PLACEHOLDER_RED_FACTOR = 0.75; +const FAILED_PLACEHOLDER_NON_RED_FACTOR = 0.25; +const STATUS_BAR_CHAR = '━'; +const CANCELLED_MARK = '⊘ '; +const TOTAL_STATUS_BAR_GAP = 2; +const PROMPTING_TEXT_TRAILING_GAP = 1; +const ACTIVITY_SPINNER_PLACEHOLDER = ' '; +const AGENT_SWARM_LEFT_INDENT = ' '; +const AGENT_SWARM_RIGHT_GAP = 1; +const AGENT_SWARM_NON_GRID_LINES = 6; +const COMPACT_TERMINAL_MARK_WIDTH = 1; +const ORCHESTRATING_LABEL = 'Orchestrating…'; +const PROMPTING_LABEL = 'Prompting…'; +const WORKING_LABEL = 'Working…'; +const COMPLETED_LABEL = 'Completed.'; +const FAILED_LABEL = 'Failed.'; +const ABORTED_LABEL = 'Aborted.'; +const CANCELLED_LABEL = 'Cancelled.'; +const QUEUED_LABEL = 'Queued…'; +const SUSPENDED_LABEL = 'Rate limited…'; +const RESUMED_ITEM_LABEL = '(resumed)'; +const CANCELLED_LABEL_DARKEN_FACTOR = 0.72; +const AGENT_SWARM_TITLE_ACCENT_BIAS = 1.3; + +const STATUS_BAR_ORDER = [ + 'completed', + 'working', + 'suspended', + 'queued', + 'cancelled', + 'failed', +] as const; + +type AgentSwarmPhase = AgentSwarmProgressEstimatorPhase; +type StatusBarPhase = typeof STATUS_BAR_ORDER[number]; +type TotalStatus = 'working' | 'completed' | 'suspended' | 'failed' | 'aborted'; +type ClearableMemberKey = + | 'completedAtMs' + | 'completedText' + | 'failedAtMs' + | 'failureText' + | 'cancelledLabelText' + | 'cancelledLabelColor' + | 'cancelledMarkColor' + | 'cancelledBarColor' + | 'suspendedReason'; + +const COMPLETED_CLEAR_KEYS = [ + 'failedAtMs', + 'failureText', + 'cancelledLabelText', + 'cancelledLabelColor', + 'cancelledMarkColor', + 'cancelledBarColor', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; +const FAILED_CLEAR_KEYS = [ + 'completedAtMs', + 'completedText', + 'cancelledLabelText', + 'cancelledLabelColor', + 'cancelledMarkColor', + 'cancelledBarColor', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; +const TERMINAL_CLEAR_KEYS = [ + 'completedAtMs', + 'completedText', + 'failedAtMs', + 'failureText', + 'cancelledLabelText', + 'cancelledLabelColor', + 'cancelledMarkColor', + 'cancelledBarColor', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; +const CANCELLED_CLEAR_KEYS = [ + 'completedAtMs', + 'completedText', + 'failedAtMs', + 'failureText', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; + +interface AgentSwarmMember { + readonly id: string; + agentId?: string; + phase: AgentSwarmPhase; + ticks: number; + itemText: string; + latestModelText: string; + completedText?: string; + failureText?: string; + cancelledLabelText?: string; + cancelledLabelColor?: string; + cancelledMarkColor?: string; + cancelledBarColor?: string; + suspendedReason?: string; + completedAtMs?: number; + failedAtMs?: number; + cellCache?: AgentSwarmCellCache; +} + +type AgentSwarmCellCacheKey = readonly (string | number | boolean | ColorPalette | undefined)[]; + +interface AgentSwarmCellCache { + readonly key: AgentSwarmCellCacheKey; + readonly value: string; +} + +interface AgentSwarmSnapshot { + readonly phase: AgentSwarmPhase; + readonly ticks: number; + readonly latestModelText: string; + readonly phaseElapsedMs: number; +} + +interface AgentSwarmResultStatus { + readonly index: number; + readonly status: 'completed' | 'failed' | 'cancelled'; + readonly completedText?: string; + readonly failureText?: string; +} + +export interface AgentSwarmResultSummary { + readonly completed: number; + readonly failed: number; + readonly aborted: number; + readonly parsed: boolean; +} + +interface AgentSwarmSummary { + readonly active: number; + readonly completed: number; + readonly failed: number; + readonly cancelled: number; +} + +interface AgentSwarmRenderCache { + readonly outerWidth: number; + readonly version: number; + readonly palette: ColorPalette; + readonly gridHeight: number | undefined; + readonly lines: string[]; +} + +export interface AgentSwarmGridLayoutInput { + readonly width: number; + readonly height: number; + readonly count: number; +} + +export interface AgentSwarmGridLayout { + readonly renderText: boolean; + readonly barCells: number; + readonly columns: number; + readonly rows: number; + readonly cellWidth: number; + readonly columnGap: number; + readonly leftPadding: number; +} + +export interface AgentSwarmProgressOptions { + readonly description: string; + readonly requestRender?: () => void; + readonly availableGridHeight?: () => number | undefined; +} + +const PHASE_LABELS: Record<AgentSwarmPhase, string> = { + pending: QUEUED_LABEL, + queued: QUEUED_LABEL, + suspended: SUSPENDED_LABEL, + running: 'Running', + completed: 'Completed', + failed: 'Failed', + cancelled: ABORTED_LABEL, +}; + +export class AgentSwarmProgressComponent implements Component { + private members: AgentSwarmMember[]; + private readonly membersByAgentId = new Map<string, AgentSwarmMember>(); + private readonly progressEstimator = new AgentSwarmProgressEstimator(); + private description: string; + private readonly requestRender: (() => void) | undefined; + private readonly availableGridHeight: (() => number | undefined) | undefined; + private modelDisplay = ''; + private effortDisplay = ''; + private inputComplete = false; + private failed = false; + private aborted = false; + private itemsStarted = false; + private toolCallActive = true; + private promptTemplateText = ''; + private activitySpinnerText: (() => string) | undefined; + private timer: ReturnType<typeof setInterval> | undefined; + private renderVersion = 0; + private renderCache: AgentSwarmRenderCache | undefined; + + constructor(options: AgentSwarmProgressOptions) { + this.description = options.description; + this.requestRender = options.requestRender; + this.availableGridHeight = options.availableGridHeight; + this.members = []; + } + + /** Live palette, read on each render so a theme switch recolors the panel. */ + private get colors(): ColorPalette { + return currentTheme.palette; + } + + dispose(): void { + if (this.timer === undefined) return; + clearInterval(this.timer); + this.timer = undefined; + } + + invalidate(): void { + this.renderCache = undefined; + for (const member of this.members) delete member.cellCache; + } + + private markDirty(): void { + this.renderVersion += 1; + } + + setActivitySpinnerText(provider: (() => string) | undefined): void { + if (!this.toolCallActive) return; + this.activitySpinnerText = provider; + this.markDirty(); + } + + /** + * Show the bound model once in the header. Every swarm member binds to the + * same model, so the first child status update wins and later ones (e.g. + * from resumed agents that kept a different binding) do not churn it. + */ + setModelDisplay(modelDisplay: string): void { + if (this.modelDisplay.length > 0 || modelDisplay.length === 0) return; + this.modelDisplay = modelDisplay; + this.markDirty(); + } + + /** + * Show the thinking effort next to the model, same first-wins rule. Only + * ever called with a concrete level (the handler filters the boolean + * states), so its presence already implies a real effort tier. + */ + setEffortDisplay(effortDisplay: string): void { + if (this.effortDisplay.length > 0 || effortDisplay.length === 0) return; + this.effortDisplay = effortDisplay; + this.markDirty(); + } + + markToolCallEnded(): void { + this.toolCallActive = false; + this.activitySpinnerText = undefined; + this.markDirty(); + } + + isToolCallActive(): boolean { + return this.toolCallActive; + } + + isRequestStreaming(): boolean { + return !this.inputComplete; + } + + updateArgs( + args: Record<string, unknown>, + options: { readonly streamingArguments?: string | undefined } = {}, + ): void { + const streamingArguments = options.streamingArguments; + const description = agentSwarmDescriptionFromArgs(args); + if (description.length > 0 || this.description.length === 0) { + this.description = description; + } + const fullRows = [...agentSwarmResumeItemsFromArgs(args), ...agentSwarmItemsFromArgs(args)]; + const partialRows = streamingArguments === undefined + ? [] + : [ + ...agentSwarmPartialResumeItemsFromArguments(streamingArguments), + ...agentSwarmPartialItemsFromArguments(streamingArguments), + ]; + if ( + fullRows.length > 0 || + partialRows.length > 0 || + (streamingArguments !== undefined && agentSwarmWorkItemsStartedFromArguments(streamingArguments)) + ) { + this.itemsStarted = true; + } + const fullPromptTemplate = agentSwarmPromptTemplateFromArgs(args); + const partialPromptTemplate = + streamingArguments === undefined + ? '' + : agentSwarmPartialPromptTemplateFromArguments(streamingArguments); + const promptTemplate = + fullPromptTemplate.length > 0 ? fullPromptTemplate : partialPromptTemplate; + if (promptTemplate.length > 0 || this.promptTemplateText.length === 0) { + this.promptTemplateText = promptTemplate; + } + + const itemCount = Math.max(fullRows.length, partialRows.length); + if (itemCount > 0) this.ensureMemberCount(itemCount); + this.updateItemTexts(fullRows, partialRows); + this.markDirty(); + } + + markInputComplete(): void { + if (!this.inputComplete) { + this.inputComplete = true; + for (const member of this.members) { + if (member.phase === 'pending') member.phase = 'queued'; + } + this.markDirty(); + } + this.startAnimationIfNeeded(); + } + + registerSubagent(input: { + readonly agentId: string; + readonly swarmIndex?: number; + readonly description?: string | undefined; + }): void { + const member = this.findMemberForSubagent(input.agentId, input.swarmIndex); + if (member === undefined) return; + this.assignMemberAgentId(member, input.agentId); + if (member.phase === 'pending') member.phase = 'queued'; + this.markDirty(); + this.startAnimationIfNeeded(); + } + + markStarted(agentId: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined) return; + const nowMs = Date.now(); + this.progressEstimator.markStarted(member.id, nowMs); + member.ticks = Math.max(member.ticks, 1); + this.promoteToRunning(member, nowMs); + this.markDirty(); + this.startAnimationIfNeeded(); + } + + recordToolCall(input: { + readonly agentId: string; + readonly toolCallId: string; + }): void { + const member = this.findMemberByAgentId(input.agentId); + if (member === undefined) return; + const result = this.progressEstimator.recordToolCall({ + memberKey: member.id, + toolCallId: input.toolCallId, + nowMs: Date.now(), + }); + if (!result.accepted) return; + member.ticks = result.rawTicks; + this.promoteToRunning(member); + this.markDirty(); + this.startAnimationIfNeeded(); + } + + appendModelDelta(input: { + readonly agentId: string; + readonly delta: string; + }): void { + const member = this.findMemberByAgentId(input.agentId); + if (member === undefined || input.delta.length === 0) return; + member.latestModelText = `${member.latestModelText}${input.delta}`.slice( + -MAX_LATEST_MODEL_CHARS, + ); + this.promoteToRunning(member, Date.now(), true); + this.markDirty(); + this.startAnimationIfNeeded(); + } + + markCompleted(agentId: string, completedText?: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined || member.phase === 'failed' || member.phase === 'cancelled') return; + const nowMs = Date.now(); + this.completeMember(member, nowMs, completedText); + this.markDirty(); + this.startAnimationIfNeeded(); + } + + markSuspended(input: { + readonly agentId: string; + readonly reason: string; + readonly swarmIndex?: number; + readonly description?: string | undefined; + }): void { + const member = this.findMemberByAgentId(input.agentId) ?? + this.findMemberForSubagent(input.agentId, input.swarmIndex); + if (member === undefined || member.phase === 'completed' || member.phase === 'cancelled') return; + this.assignMemberAgentId(member, input.agentId); + this.progressEstimator.markQueued(member.id, Date.now()); + member.phase = 'suspended'; + clearMemberState(member, ...TERMINAL_CLEAR_KEYS); + this.markDirty(); + this.startAnimationIfNeeded(); + } + + markFailed(agentId: string, failureText?: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined) return; + const nowMs = Date.now(); + this.failMember(member, nowMs, failureText); + this.markDirty(); + this.startAnimationIfNeeded(); + } + + markSwarmFailed(failureText?: string): void { + this.failed = true; + this.aborted = false; + const nowMs = Date.now(); + for (const member of this.members) { + if (isTerminalPhase(member.phase)) continue; + this.failMember(member, nowMs, failureText); + } + this.markDirty(); + this.startAnimationIfNeeded(); + } + + markCancelled(agentId: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined || isTerminalPhase(member.phase)) return; + this.cancelMember(member, Date.now()); + this.markDirty(); + } + + markActiveCancelled(): void { + this.aborted = true; + const nowMs = Date.now(); + for (const member of this.members) { + if (isTerminalPhase(member.phase)) continue; + this.cancelMember(member, nowMs); + } + this.markDirty(); + this.startAnimationIfNeeded(); + } + + applyResult(output: string): boolean { + const statuses = parseAgentSwarmResultStatuses(output); + if (statuses.length === 0) return false; + this.aborted = false; + const nowMs = Date.now(); + for (const entry of statuses) { + this.ensureMemberCount(entry.index); + const member = this.members[entry.index - 1]; + if (member === undefined) continue; + if (entry.status === 'completed') { + this.completeMember(member, nowMs, entry.completedText); + } else if (entry.status === 'failed') { + this.failMember(member, nowMs, entry.failureText); + } else { + this.cancelMember(member, nowMs); + } + } + this.markDirty(); + this.startAnimationIfNeeded(); + return true; + } + + render(width: number): string[] { + const outerWidth = Math.max(1, width); + const innerWidth = Math.max( + 1, + outerWidth - visibleWidth(AGENT_SWARM_LEFT_INDENT) - AGENT_SWARM_RIGHT_GAP, + ); + const palette = currentTheme.palette; + // The empty panel has no grid, so the height callback stays untouched + // there (matching the uncached path); with members its result feeds the + // cache key, since a shrinking dock compresses the grid. + const gridHeight = this.members.length === 0 ? undefined : this.availableGridHeight?.(); + const cache = this.renderCache; + if ( + isRenderCacheEnabled() && + cache !== undefined && + cache.outerWidth === outerWidth && + cache.version === this.renderVersion && + cache.palette === palette && + cache.gridHeight === gridHeight + ) { + return cache.lines; + } + + let lines: string[]; + if (this.members.length === 0) { + lines = this.indentLines( + [ + '', + this.renderHeader(innerWidth, undefined), + '', + this.renderStatusLine(innerWidth), + '', + ], + outerWidth, + ); + } else { + const nowMs = Date.now(); + const snapshots = this.members.map((member): AgentSwarmSnapshot => ({ + phase: member.phase, + ticks: member.ticks, + latestModelText: member.latestModelText, + phaseElapsedMs: terminalPhaseElapsedMs(member, nowMs), + })); + const summary = summarizeSnapshots(snapshots); + lines = this.indentLines( + [ + '', + this.renderHeader(innerWidth, summary), + '', + ...this.renderGrid( + innerWidth, + gridHeight, + snapshots, + nowMs, + ), + '', + this.renderStatusLine(innerWidth), + '', + ], + outerWidth, + ); + this.startAnimationIfNeeded(); + } + if (isRenderCacheEnabled() && !this.hasTimeDependentRender()) { + this.renderCache = { outerWidth, version: this.renderVersion, palette, gridHeight, lines }; + } + return lines; + } + + /** + * Renders that change with wall-clock time even without any mutation must + * never be served from the cache: the activity spinner ticks externally, + * running cells keep drifting toward their estimate, and the completion / + * failure fill animates for a short window. Once the panel is static it + * stays static until the next mutation bumps the version, so a cache hit + * can safely skip `startAnimationIfNeeded()`. + */ + private hasTimeDependentRender(): boolean { + if (this.toolCallActive && this.activitySpinnerText !== undefined) return true; + return this.hasAnimatedMembers(); + } + + private indentLines(lines: readonly string[], width: number): string[] { + const contentWidth = Math.max( + 0, + width - visibleWidth(AGENT_SWARM_LEFT_INDENT) - AGENT_SWARM_RIGHT_GAP, + ); + return lines.map((line) => + truncateToWidth( + AGENT_SWARM_LEFT_INDENT + truncateToWidth(line, contentWidth), + width, + ) + ); + } + + private renderHeader(width: number, _summary: AgentSwarmSummary | undefined): string { + if (width <= 3) return chalk.hex(this.colors.primary)('─'.repeat(width)); + + const title = gradientText('Agent Swarm', this.colors.primary, this.colors.accent, AGENT_SWARM_TITLE_ACCENT_BIAS); + const description = + this.description.length > 0 + ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.text)(this.description) + : ''; + const modelText = + this.effortDisplay.length > 0 + ? `${this.modelDisplay} · ${this.effortDisplay}` + : this.modelDisplay; + const model = + modelText.length > 0 + ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.textDim)(modelText) + : ''; + const prefixText = '─ '; + const labelWidth = Math.max(1, width - visibleWidth(prefixText) - 1); + const label = truncateToWidth(title + description + model, labelWidth); + const suffixWidth = Math.max(0, width - visibleWidth(prefixText) - visibleWidth(label)); + const suffix = suffixWidth === 0 ? '' : ` ${'─'.repeat(Math.max(0, suffixWidth - 1))}`; + return chalk.hex(this.colors.primary)(prefixText) + label + chalk.hex(this.colors.primary)(suffix); + } + + private renderStatusLine(width: number): string { + const status = totalStatus(this.members, { + failed: this.failed, + aborted: this.aborted, + }); + const prefix = this.renderActivityPrefix(status); + if (prefix.length > 0) { + const contentWidth = Math.max(0, width - visibleWidth(prefix)); + if (contentWidth <= 0) return truncateToWidth(prefix, width); + return truncateToWidth(`${prefix}${this.renderStatusLineContent(contentWidth, status)}`, width); + } + return this.renderStatusLineContent(width, status); + } + + private renderActivityPrefix(status: TotalStatus): string { + if (this.toolCallActive) return this.activitySpinnerText?.() ?? ''; + return activityPrefixForTotalStatus(status, this.colors); + } + + private renderStatusLineContent(width: number, status: TotalStatus): string { + if (status !== 'working') return this.renderProgressStatusLine(width, status); + + if (!this.inputComplete) { + return this.renderOrchestratingStatusLine(width); + } + + return this.renderProgressStatusLine(width, status); + } + + private renderProgressStatusLine(width: number, status: TotalStatus): string { + const label = renderStatusLabel( + totalStatusLabel(status), + totalStatusLabelColor(status, this.members, this.colors), + ); + if (this.members.length === 0) return truncateToWidth(label, width); + const barWidth = Math.max(0, width - visibleWidth(label) - TOTAL_STATUS_BAR_GAP); + if (barWidth <= 0) return truncateToWidth(label, width); + return truncateToWidth( + `${label}${' '.repeat(TOTAL_STATUS_BAR_GAP)}${renderStatusPipBar(this.members, barWidth, this.colors)}`, + width, + ); + } + + private renderOrchestratingStatusLine(width: number): string { + if (this.itemsStarted) { + return truncateToWidth( + renderStatusLabel(ORCHESTRATING_LABEL, this.colors.primary), + width, + ); + } + + const promptTemplate = collapseWhitespace(this.promptTemplateText); + const label = renderStatusLabel( + promptTemplate.length > 0 ? PROMPTING_LABEL : ORCHESTRATING_LABEL, + this.colors.primary, + ); + if (promptTemplate.length === 0) return truncateToWidth(label, width); + + const availablePromptWidth = Math.max( + 0, + width - visibleWidth(label) - PROMPTING_TEXT_TRAILING_GAP, + ); + const separator = visibleWidth(promptTemplate) <= availablePromptWidth - 1 ? ' ' : ' '; + const promptWidth = Math.max(0, availablePromptWidth - visibleWidth(separator)); + if (promptWidth <= 0) return truncateToWidth(label, width); + const prompt = chalk.hex(this.colors.textDim)(truncateStartToWidth(promptTemplate, promptWidth)); + return truncateToWidth(`${label}${separator}${prompt}`, width); + } + + private renderGrid( + width: number, + height: number | undefined, + snapshots: readonly AgentSwarmSnapshot[], + nowMs: number, + ): string[] { + const layout = calculateAgentSwarmGridLayout({ + width, + height: height ?? Number.POSITIVE_INFINITY, + count: this.members.length, + }); + const columns = Math.max(1, layout.columns); + const rows = layout.rows; + const cellGap = ' '.repeat(layout.columnGap); + const leftPadding = ' '.repeat(layout.leftPadding); + const lines: string[] = []; + + for (let row = 0; row < rows; row += 1) { + const cells: string[] = []; + for (let col = 0; col < columns; col += 1) { + const index = row * columns + col; + const member = this.members[index]; + const snapshot = snapshots[index]; + if (member === undefined || snapshot === undefined) continue; + cells.push(this.renderCell(member, snapshot, layout, nowMs)); + } + lines.push(leftPadding + cells.join(cellGap)); + } + return lines; + } + + private renderCell( + member: AgentSwarmMember, + snapshot: AgentSwarmSnapshot, + layout: AgentSwarmGridLayout, + nowMs: number, + ): string { + const needsEstimate = !( + snapshot.phase === 'pending' || + (snapshot.phase === 'cancelled' && snapshot.ticks <= 0) || + (layout.renderText && snapshot.phase === 'queued' && snapshot.ticks <= 0) + ); + const estimate = needsEstimate + ? this.progressEstimator.estimate({ + memberKey: member.id, + phase: snapshot.phase, + capacityTicks: layout.barCells * BRAILLE_LEVELS.length, + nowMs, + }) + : undefined; + // Terminal and queued cells are fully determined by this key, so repeated + // frames of an animating panel reuse them; the elapsed-time clamp keeps + // hits valid once the completion fill window has passed. + const key: AgentSwarmCellCacheKey = [ + currentTheme.palette, + snapshot.phase, + member.ticks, + member.latestModelText, + member.itemText, + member.completedText, + member.failureText, + member.cancelledLabelText, + member.cancelledLabelColor, + member.cancelledMarkColor, + member.cancelledBarColor, + layout.renderText, + layout.cellWidth, + layout.barCells, + estimate === undefined ? member.ticks : estimate.displayTicks, + Math.min(snapshot.phaseElapsedMs, COMPLETE_FILL_MS), + ]; + const cached = member.cellCache; + if (isRenderCacheEnabled() && cached !== undefined && cellCacheKeyEquals(cached.key, key)) { + return cached.value; + } + const value = padAnsi( + this.renderCellContent(member, snapshot, layout, estimate), + layout.cellWidth, + ); + if (isRenderCacheEnabled()) member.cellCache = { key, value }; + return value; + } + + private renderCellContent( + member: AgentSwarmMember, + snapshot: AgentSwarmSnapshot, + layout: AgentSwarmGridLayout, + estimate: AgentSwarmProgressEstimate | undefined, + ): string { + const width = layout.cellWidth; + if (snapshot.phase === 'pending') { + return renderPendingCell(member, width, this.colors); + } + if (snapshot.phase === 'cancelled' && snapshot.ticks <= 0) { + return renderCancelledUnstartedCell(member, width, this.colors); + } + if (layout.renderText && snapshot.phase === 'queued' && snapshot.ticks <= 0) { + return renderQueuedCell(member, width, this.colors); + } + const id = chalk.hex(this.colors.primary)(member.id); + const bar = brailleBar( + estimate!.displayTicks, + snapshot.phase, + layout.barCells, + this.colors, + snapshot.phaseElapsedMs, + cancelledProgressColor(member, snapshot.phase, this.colors), + ); + if (!layout.renderText) { + return `${id} ${bar}${compactTerminalMark(member, snapshot.phase, this.colors)}`; + } + const prefix = `${id} ${bar} `; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + const label = renderCellLabel(member, snapshot, labelWidth, this.colors); + return prefix + label; + } + + private findMemberForSubagent( + agentId: string, + swarmIndex: number | undefined, + ): AgentSwarmMember | undefined { + const existing = this.findMemberByAgentId(agentId); + if (existing !== undefined) return existing; + + if (swarmIndex !== undefined && Number.isInteger(swarmIndex) && swarmIndex > 0) { + this.ensureMemberCount(swarmIndex); + const byIndex = this.members[swarmIndex - 1]; + if (byIndex !== undefined) return byIndex; + } + + const unassigned = this.members.find((member) => member.agentId === undefined); + if (unassigned !== undefined) return unassigned; + + this.ensureMemberCount(this.members.length + 1); + return this.members.at(-1); + } + + private findMemberByAgentId(agentId: string): AgentSwarmMember | undefined { + return this.membersByAgentId.get(agentId); + } + + private assignMemberAgentId(member: AgentSwarmMember, agentId: string): void { + if (member.agentId !== undefined && member.agentId !== agentId) { + this.membersByAgentId.delete(member.agentId); + } + member.agentId = agentId; + this.membersByAgentId.set(agentId, member); + } + + private ensureMemberCount(count: number): void { + if (count <= this.members.length) return; + const previousLength = this.members.length; + this.members = [ + ...this.members, + ...createMembers(count, this.inputComplete ? 'queued' : 'pending').slice(this.members.length), + ]; + const nowMs = Date.now(); + for (let index = previousLength; index < this.members.length; index += 1) { + const member = this.members[index]; + if (member !== undefined) this.progressEstimator.ensureMember(member.id, nowMs); + } + } + + private updateItemTexts(fullItems: readonly string[], partialItems: readonly string[]): void { + const count = Math.max(fullItems.length, partialItems.length, this.members.length); + for (let index = 0; index < count; index += 1) { + const member = this.members[index]; + if (member === undefined) continue; + const itemText = fullItems[index] ?? partialItems[index]; + if (itemText !== undefined) member.itemText = itemText; + } + } + + private startAnimationIfNeeded(): void { + if (this.requestRender === undefined || this.timer !== undefined) return; + if (!this.hasAnimatedMembers()) return; + const requestRender = this.requestRender; + this.timer = setInterval(() => { + requestRender(); + if (!this.hasAnimatedMembers()) this.dispose(); + }, FRAME_INTERVAL_MS); + if (typeof this.timer === 'object' && 'unref' in this.timer) { + this.timer.unref(); + } + } + + private hasAnimatedMembers(): boolean { + const now = Date.now(); + // Running cells and estimator catch-up only animate while the tool call + // is live: no further progress arrives once it ends (an unparsable result + // leaves members running), so ticking would repaint the tree forever. + if ( + this.toolCallActive && + (this.progressEstimator.hasPendingCatchup() || + this.members.some((member) => member.phase === 'running')) + ) { + return true; + } + return this.members.some((member) => + ( + member.phase === 'completed' && + member.completedAtMs !== undefined && + now - member.completedAtMs < COMPLETE_FILL_MS + ) || + ( + member.phase === 'failed' && + member.failedAtMs !== undefined && + now - member.failedAtMs < COMPLETE_FILL_MS + ), + ); + } + + private promoteToRunning(member: AgentSwarmMember, nowMs?: number, setTicks = false): void { + if (member.phase === 'pending' || member.phase === 'queued' || member.phase === 'suspended') { + member.phase = 'running'; + if (nowMs !== undefined) this.progressEstimator.markStarted(member.id, nowMs); + if (setTicks) member.ticks = Math.max(member.ticks, 1); + } + delete member.suspendedReason; + } + + private completeMember(member: AgentSwarmMember, nowMs: number, completedText?: string): void { + if (member.phase !== 'completed') { + this.progressEstimator.markCompleted(member.id, nowMs); + member.completedAtMs = nowMs; + } + // Terminal cells render a single-line label, so only a bounded prefix of + // the final output is worth keeping; full outputs must not accumulate on + // the component for the lifetime of the transcript. The latest-model-text + // fallback is baked in here because latestModelText is released below. + const normalizedCompletedText = + normalizeFinalOutputText(completedText) ?? + normalizeFinalOutputText(latestNonEmptyLine(member.latestModelText)); + if (normalizedCompletedText !== undefined) { + member.completedText = capFinalOutputLabel(normalizedCompletedText); + } + member.phase = 'completed'; + releaseTerminalMemberText(member); + clearMemberState(member, ...COMPLETED_CLEAR_KEYS); + } + + private failMember(member: AgentSwarmMember, nowMs: number, failureText?: string): void { + if (member.phase !== 'failed') { + this.progressEstimator.markFailed(member.id, nowMs); + member.failedAtMs = nowMs; + } + const normalizedFailureText = normalizeFailureText(failureText); + if (normalizedFailureText !== undefined) { + member.failureText = capFinalOutputLabel(normalizedFailureText); + } + member.phase = 'failed'; + releaseTerminalMemberText(member); + clearMemberState(member, ...FAILED_CLEAR_KEYS); + } + + private cancelMember(member: AgentSwarmMember, nowMs: number): void { + const previousPhase = member.phase; + this.progressEstimator.markCancelled(member.id, nowMs); + member.phase = 'cancelled'; + clearMemberState(member, ...CANCELLED_CLEAR_KEYS); + if (previousPhase === 'pending' || previousPhase === 'queued' || previousPhase === 'suspended') { + member.cancelledLabelText = CANCELLED_LABEL; + member.cancelledLabelColor = cancelledLabelColor(this.colors); + member.cancelledMarkColor = this.colors.warning; + member.cancelledBarColor = this.colors.warning; + } else if (previousPhase === 'running') { + member.cancelledLabelText = capFinalOutputLabel( + runningCellLabelText(member, latestNonEmptyLine(member.latestModelText)), + ); + member.cancelledLabelColor = cancelledLabelColor(this.colors); + member.cancelledMarkColor = this.colors.warning; + member.cancelledBarColor = this.colors.warning; + } else { + member.cancelledLabelText = ABORTED_LABEL; + member.cancelledLabelColor = this.colors.warning; + member.cancelledMarkColor = this.colors.warning; + member.cancelledBarColor = this.colors.warning; + } + releaseTerminalMemberText(member); + } +} + +function createMembers(count: number, phase: AgentSwarmPhase): AgentSwarmMember[] { + return Array.from({ length: count }, (_item, index) => ({ + id: String(index + 1).padStart(3, '0'), + phase, + ticks: 0, + itemText: '', + latestModelText: '', + })); +} + +function clearMemberState(member: AgentSwarmMember, ...keys: ClearableMemberKey[]): void { + for (const key of keys) delete member[key]; +} + +// Terminal cells no longer stream, so the rolling model-text window and the +// stale cell memo (whose key referenced it) are dropped; the next render +// re-memoizes against the bounded terminal label. +function releaseTerminalMemberText(member: AgentSwarmMember): void { + member.latestModelText = ''; + delete member.cellCache; +} + +// Display width alone does not bound memory: ANSI sequences and zero-width +// graphemes add unbounded code units within a single column, so the retained +// label is additionally capped by storage length. +function capFinalOutputLabel(text: string): string { + return capCodeUnits( + truncateToWidth(text, MAX_FINAL_OUTPUT_LABEL_CHARS, ''), + MAX_FINAL_OUTPUT_LABEL_CODE_UNITS, + ); +} + +function capCodeUnits(text: string, maxCodeUnits: number): string { + if (text.length <= maxCodeUnits) return text; + const graphemeEnd = graphemeSafeEnd(text, maxCodeUnits); + let end = graphemeEnd > 0 ? graphemeEnd : maxCodeUnits; + let osc8CloseSuffix = ''; + let sgrActive = false; + let index = text.indexOf('\u001B'); + while (index >= 0 && index < end) { + const sequenceEnd = ansiSequenceEnd(text, index); + if (sequenceEnd === undefined || sequenceEnd > end) { + end = index; + break; + } + const sequence = text.slice(index, sequenceEnd); + const osc8Close = osc8CloseAfterSequence(sequence); + if (osc8Close !== undefined) osc8CloseSuffix = osc8Close ?? ''; + sgrActive = sgrActiveAfterSequence(sequence, sgrActive); + index = text.indexOf('\u001B', sequenceEnd); + } + // A cut landing on a lead surrogate reads as the astral code point; back + // off so the retained label never ends in an unpaired surrogate. + const codePoint = text.codePointAt(end - 1); + if (codePoint !== undefined && codePoint > 0xffff) end -= 1; + return `${text.slice(0, end)}${osc8CloseSuffix}${sgrActive ? '\u001B[0m' : ''}`; +} + +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + +// The storage cap must not slice through a grapheme cluster: a ZWJ sequence +// (a family emoji runs about eleven code units per two columns) cut in half +// renders as its separate parts. Back off to the nearest whole cluster; the +// ANSI walk above only ever lands on escape boundaries, which are cluster +// boundaries too. A single cluster larger than the budget falls back to the +// raw cut. +function graphemeSafeEnd(text: string, end: number): number { + let boundary = 0; + for (const { index, segment } of graphemeSegmenter.segment(text)) { + if (index + segment.length > end) break; + boundary = index + segment.length; + } + return boundary; +} + +// Mirrors pi-tui's OSC 8 bookkeeping: a sequence with a non-empty URI opens a +// hyperlink (closed with the opener's terminator), an empty URI closes one. +// SGR resets do not close hyperlinks, so a sliced opener would otherwise +// leak the link onto later cells of the grid row. +function osc8CloseAfterSequence(sequence: string): string | null | undefined { + if (!sequence.startsWith('\u001B]8;')) return undefined; + const terminator = sequence.endsWith('\u0007') ? '\u0007' : '\u001B\\'; + const body = sequence.slice(4, sequence.length - terminator.length); + const separatorIndex = body.indexOf(';'); + if (separatorIndex < 0) return undefined; + return body.slice(separatorIndex + 1).length > 0 ? `\u001B]8;;${terminator}` : null; +} + +// SGR state is cumulative: a parameter of 0 (or an empty parameter, which +// defaults to 0) resets every attribute, anything else activates one, so a +// sliced label with active styling needs a full reset appended. +function sgrActiveAfterSequence(sequence: string, active: boolean): boolean { + if (!sequence.startsWith('\u001B[') || !sequence.endsWith('m')) return active; + const body = sequence.slice(2, -1); + if (body.length === 0) return false; + for (const param of body.split(';')) { + active = param !== '' && Number(param) !== 0; + } + return active; +} + +function ansiSequenceEnd(text: string, start: number): number | undefined { + if (text[start] !== '\u001B') return undefined; + const kind = text[start + 1]; + if (kind === undefined) return undefined; + if (kind === '[') { + for (let index = start + 2; index < text.length; index += 1) { + const ch = text.charAt(index); + if (ch >= '@' && ch <= '~') return index + 1; + } + return undefined; + } + if (kind === ']' || kind === '_') { + for (let index = start + 2; index < text.length; index += 1) { + if (text[index] === '\u0007') return index + 1; + if (text[index] === '\u001B' && text[index + 1] === '\\') return index + 2; + } + return undefined; + } + let index = start + 1; + while (index < text.length) { + const ch = text.charAt(index); + if (ch >= ' ' && ch <= '/') { + index += 1; + continue; + } + return ch >= '0' && ch <= '~' ? index + 1 : undefined; + } + return undefined; +} + +function isTerminalPhase(phase: AgentSwarmPhase): boolean { + return phase === 'completed' || phase === 'failed' || phase === 'cancelled'; +} + +function terminalPhaseElapsedMs(member: AgentSwarmMember, nowMs: number): number { + const startedAtMs = member.phase === 'completed' + ? member.completedAtMs + : member.phase === 'failed' + ? member.failedAtMs + : undefined; + return startedAtMs === undefined ? 0 : Math.max(0, nowMs - startedAtMs); +} + +export function agentSwarmItemsFromArgs(args: Record<string, unknown>): string[] { + const items = args['items']; + if (!Array.isArray(items)) return []; + return items.map(String); +} + +function agentSwarmResumeItemsFromArgs(args: Record<string, unknown>): string[] { + const resumeAgentIds = args['resume_agent_ids']; + if ( + typeof resumeAgentIds !== 'object' || + resumeAgentIds === null || + Array.isArray(resumeAgentIds) + ) { + return []; + } + return Object.keys(resumeAgentIds).map(() => RESUMED_ITEM_LABEL); +} + +export function agentSwarmPartialItemsCountFromArguments(argumentsText: string): number { + return agentSwarmPartialItemsFromArguments(argumentsText).length; +} + +function agentSwarmWorkItemsStartedFromArguments(argumentsText: string): boolean { + return /"items"\s*:/.test(argumentsText) || /"resume_agent_ids"\s*:/.test(argumentsText); +} + +export function agentSwarmPartialItemsFromArguments(argumentsText: string): string[] { + const match = /"items"\s*:\s*\[/.exec(argumentsText); + if (match === null) return []; + const items: string[] = []; + for (let i = match.index + match[0].length; i < argumentsText.length; i += 1) { + const ch = argumentsText[i]; + if (ch === ']') return items; + if (ch !== '"') continue; + + const parsed = parsePartialJsonString(argumentsText, i + 1); + items.push(parsed.value); + if (parsed.closed) { + i = parsed.nextIndex; + continue; + } + return items; + } + return items; +} + +function agentSwarmPartialResumeItemsFromArguments(argumentsText: string): string[] { + const match = /"resume_agent_ids"\s*:\s*\{/.exec(argumentsText); + if (match === null) return []; + return Array.from( + { length: countPartialJsonObjectEntries(argumentsText, match.index + match[0].length) }, + () => RESUMED_ITEM_LABEL, + ); +} + +export function agentSwarmDescriptionFromArgs(args: Record<string, unknown>): string { + const description = args['description']; + return typeof description === 'string' ? description : ''; +} + +function agentSwarmPromptTemplateFromArgs(args: Record<string, unknown>): string { + const promptTemplate = args['prompt_template']; + return typeof promptTemplate === 'string' ? promptTemplate : ''; +} + +function agentSwarmPartialPromptTemplateFromArguments(argumentsText: string): string { + const match = /"prompt_template"\s*:\s*"/.exec(argumentsText); + if (match === null) return ''; + return parsePartialJsonString(argumentsText, match.index + match[0].length).value; +} + +export function agentSwarmResultSummaryFromOutput(output: string): AgentSwarmResultSummary { + const statuses = parseAgentSwarmResultStatuses(output); + let completed = 0; + let failed = 0; + let aborted = 0; + for (const status of statuses) { + if (status.status === 'completed') completed += 1; + if (status.status === 'failed') failed += 1; + if (status.status === 'cancelled') aborted += 1; + } + return { + completed, + failed, + aborted, + parsed: statuses.length > 0, + }; +} + +function parseAgentSwarmResultStatuses(output: string): AgentSwarmResultStatus[] { + const xmlStatuses = parseAgentSwarmXmlResultStatuses(output); + if (xmlStatuses.length > 0) return xmlStatuses; + return parseAgentSwarmLegacyResultStatuses(output); +} + +function forEachSubagentTag<T>( + output: string, + callback: (attrs: string, body: string, index: number) => T | undefined, +): T[] { + const result: T[] = []; + const tagPattern = /<subagent\b([^>]*)>/g; + let match: RegExpExecArray | null; + let index = 0; + while ((match = tagPattern.exec(output)) !== null) { + const attrs = match[1] ?? ''; + const closeIndex = output.indexOf('</subagent>', tagPattern.lastIndex); + if (closeIndex < 0) break; + const body = output.slice(tagPattern.lastIndex, closeIndex); + index += 1; + const value = callback(attrs, body, index); + if (value !== undefined) result.push(value); + tagPattern.lastIndex = closeIndex + '</subagent>'.length; + } + return result; +} + +function parseAgentSwarmXmlResultStatuses(output: string): AgentSwarmResultStatus[] { + return forEachSubagentTag(output, (attrs, body, tagIndex) => { + const explicitIndex = Number(xmlAttribute(attrs, 'index')); + const index = + Number.isInteger(explicitIndex) && explicitIndex > 0 ? explicitIndex : tagIndex; + const outcome = xmlAttribute(attrs, 'outcome'); + if ( + outcome !== 'completed' && + outcome !== 'failed' && + outcome !== 'aborted' && + outcome !== 'cancelled' + ) { + return undefined; + } + return { + index, + status: outcome === 'aborted' || outcome === 'cancelled' ? 'cancelled' : outcome, + completedText: outcome === 'completed' ? body : undefined, + failureText: outcome === 'failed' ? body : undefined, + }; + }); +} + +function xmlAttribute(attrs: string, name: string): string | undefined { + const match = new RegExp(`\\b${name}="([^"]*)"`).exec(attrs); + return match?.[1]; +} + +function forEachAgentBlock<T>( + output: string, + callback: (block: string, index: number) => T | undefined, +): T[] { + const result: T[] = []; + for (const block of output.split(/\n(?=\[agent \d+\]\n)/)) { + const indexMatch = /^\[agent (\d+)\]$/m.exec(block); + if (indexMatch === null) continue; + const value = callback(block, Number(indexMatch[1])); + if (value !== undefined) result.push(value); + } + return result; +} + +function parseAgentSwarmLegacyResultStatuses(output: string): AgentSwarmResultStatus[] { + return forEachAgentBlock(output, (block, index) => { + const statusMatch = /^status: (completed|failed|aborted|cancelled)$/m.exec(block); + if (statusMatch === null) return undefined; + const status = statusMatch[1] as 'completed' | 'failed' | 'aborted' | 'cancelled'; + return { + index, + status: status === 'aborted' || status === 'cancelled' ? 'cancelled' : status, + completedText: status === 'completed' ? parseAgentSwarmCompletedText(block) : undefined, + failureText: status === 'failed' ? parseAgentSwarmFailureText(block) : undefined, + }; + }); +} + +function parseAgentSwarmCompletedText(block: string): string | undefined { + const marker = '\n[summary]\n'; + const markerIndex = block.indexOf(marker); + if (markerIndex < 0) return undefined; + return normalizeFinalOutputText(block.slice(markerIndex + marker.length)); +} + +function parseAgentSwarmFailureText(block: string): string | undefined { + const match = /^subagent error:\s*([\s\S]*)$/m.exec(block); + if (match === null) return undefined; + return normalizeFailureText(match[1]); +} + +function textGridLayout( + columns: number, + rows: number, + cellWidth: number, + gapWidth: number, + idWidth: number, +): AgentSwarmGridLayout { + return { + renderText: true, + barCells: barCellsForTextCellWidth(cellWidth, idWidth), + columns, + rows, + cellWidth, + columnGap: gapWidth, + leftPadding: 0, + }; +} + +export function calculateAgentSwarmGridLayout( + input: AgentSwarmGridLayoutInput, +): AgentSwarmGridLayout { + const count = Math.max(0, Math.floor(input.count)); + const width = Math.max(0, Math.floor(input.width)); + const height = Math.max(0, Math.floor(input.height)); + const idWidth = agentSwarmGridIdWidth(count); + + if (count === 0) { + return { + renderText: true, + barCells: 1, + columns: 0, + rows: 0, + cellWidth: 0, + columnGap: 0, + leftPadding: 0, + }; + } + + const textGapWidth = visibleWidth(CELL_GAP); + const compactGapWidth = textGapWidth; + const textColumns = columnsForCellWidth(width, count, TEXT_CELL_PREFERRED_WIDTH, textGapWidth); + const textRows = rowsForColumns(count, textColumns); + const textCellWidth = gridCellWidth(width, textColumns, textGapWidth); + if (textRows <= height && textCellWidth >= minTextCellWidth(idWidth)) { + return textGridLayout(textColumns, textRows, textCellWidth, textGapWidth, idWidth); + } + const targetTextColumns = height <= 0 ? count : Math.min(count, Math.ceil(count / height)); + const targetTextCellWidth = gridCellWidth(width, targetTextColumns, textGapWidth); + const targetTextRows = rowsForColumns(count, targetTextColumns); + if (height > 0 && targetTextRows <= height && targetTextCellWidth >= minTextCellWidth(idWidth)) { + return textGridLayout(targetTextColumns, targetTextRows, targetTextCellWidth, textGapWidth, idWidth); + } + + const compactColumns = compactColumnsForLayout(width, count, height, idWidth, compactGapWidth); + const compactCellWidthBudget = gridCellWidth(width, compactColumns, compactGapWidth); + const compactBarCells = compactBarCellsForCellWidth(compactCellWidthBudget, idWidth); + const compactActualCellWidth = compactCellWidth(idWidth, compactBarCells); + return { + renderText: false, + barCells: compactBarCells, + columns: compactColumns, + rows: rowsForColumns(count, compactColumns), + cellWidth: compactActualCellWidth, + columnGap: compactGapWidth, + leftPadding: 0, + }; +} + +export function agentSwarmGridHeightForTerminalRows( + rows: number | undefined, + followingRows = 0, +): number | undefined { + if (rows === undefined || !Number.isFinite(rows)) return undefined; + const rowsAfterSwarm = Number.isFinite(followingRows) + ? Math.max(0, Math.floor(followingRows)) + : 0; + return Math.max(0, Math.floor(rows) - rowsAfterSwarm - AGENT_SWARM_NON_GRID_LINES); +} + +function agentSwarmGridIdWidth(count: number): number { + return Math.max(3, String(Math.max(1, count)).length); +} + +function columnsForCellWidth( + width: number, + count: number, + cellWidth: number, + gapWidth: number, +): number { + if (count <= 1) return count <= 0 ? 0 : 1; + const columns = Math.floor((width + gapWidth) / (Math.max(1, cellWidth) + gapWidth)); + return Math.max(1, Math.min(count, columns)); +} + +function rowsForColumns(count: number, columns: number): number { + if (count <= 0) return 0; + return Math.ceil(count / Math.max(1, columns)); +} + +function gridCellWidth(width: number, columns: number, gapWidth: number): number { + if (columns <= 0) return 0; + return Math.max( + 1, + Math.floor((width - gapWidth * Math.max(0, columns - 1)) / columns), + ); +} + +function minTextCellWidth(idWidth: number): number { + return idWidth + TEXT_BRAILLE_BAR_MIN_WIDTH + 4 + MIN_LABEL_WIDTH; +} + +function barCellsForTextCellWidth(cellWidth: number, idWidth: number): number { + const fixedWidth = idWidth + 1 + 2 + 1 + MIN_LABEL_WIDTH; + const availableForBar = cellWidth - fixedWidth; + return availableForBar >= TEXT_BRAILLE_BAR_MIN_WIDTH + ? Math.min(BRAILLE_BAR_MAX_WIDTH, availableForBar) + : TEXT_BRAILLE_BAR_MIN_WIDTH; +} + +function compactColumnsForLayout( + width: number, + count: number, + height: number, + idWidth: number, + gapWidth: number, +): number { + const maxColumns = columnsForCellWidth(width, count, compactCellWidth(idWidth, 1), gapWidth); + if (height <= 0) return maxColumns; + const targetColumns = Math.min(count, Math.ceil(count / height)); + return Math.max(1, Math.min(targetColumns, maxColumns)); +} + +function compactBarCellsForCellWidth(cellWidth: number, idWidth: number): number { + return Math.max( + 1, + cellWidth - compactFixedWidth(idWidth) - COMPACT_TERMINAL_MARK_WIDTH, + ); +} + +function compactCellWidth(idWidth: number, barCells: number): number { + return compactFixedWidth(idWidth) + Math.max(1, barCells) + COMPACT_TERMINAL_MARK_WIDTH; +} + +function compactFixedWidth(idWidth: number): number { + return idWidth + 1 + 2; +} + +function summarizeSnapshots(snapshots: readonly AgentSwarmSnapshot[]): AgentSwarmSummary { + let completed = 0; + let failed = 0; + let cancelled = 0; + for (const snapshot of snapshots) { + if (snapshot.phase === 'completed') completed += 1; + if (snapshot.phase === 'failed') failed += 1; + if (snapshot.phase === 'cancelled') cancelled += 1; + } + return { + active: snapshots.length - completed - failed - cancelled, + completed, + failed, + cancelled, + }; +} + +function brailleBar( + ticks: number, + phase: AgentSwarmPhase, + width: number, + colors: ColorPalette, + phaseElapsedMs: number, + phaseColorOverride?: string, +): string { + const innerWidth = Math.max(1, width); + if (phase === 'pending') return ''; + if (phase === 'failed') return bracketBar(failedBrailleBar(ticks, innerWidth, phaseElapsedMs, colors), colors); + const displayTicks = phase === 'completed' ? completedDisplayTicks(ticks, innerWidth, phaseElapsedMs) : ticks; + if (phase === 'cancelled') { + const cancelledColor = phaseColorOverride ?? colors.warning; + return bracketBar( + accumulatedBrailleBar(displayTicks, innerWidth, cancelledColor, colors, () => cancelledColor), + colors, + ); + } + const colorMap: Record<Exclude<AgentSwarmPhase, 'pending' | 'failed' | 'cancelled'>, string> = { + queued: colors.textDim, + suspended: colors.textDim, + running: colors.success, + completed: colors.success, + }; + return bracketBar(accumulatedBrailleBar(displayTicks, innerWidth, colorMap[phase], colors), colors); +} + +function cancelledProgressColor( + member: AgentSwarmMember, + phase: AgentSwarmPhase, + colors: ColorPalette, +): string | undefined { + if (phase !== 'cancelled') return undefined; + return member.cancelledBarColor ?? colors.warning; +} + +function bracketBar(content: string, colors: ColorPalette): string { + const bracket = chalk.hex(colors.textMuted); + return bracket('[') + content + bracket(']'); +} + +function phaseColor(phase: AgentSwarmPhase, colors: ColorPalette): string { + const map: Record<AgentSwarmPhase, string> = { + pending: colors.textDim, + queued: colors.textDim, + suspended: colors.textDim, + running: colors.textDim, + completed: colors.success, + failed: colors.error, + cancelled: colors.warning, + }; + return map[phase]; +} + +interface StatusBarCount { + readonly phase: StatusBarPhase; + readonly count: number; +} + +function renderStatusPipBar( + members: readonly AgentSwarmMember[], + width: number, + colors: ColorPalette, +): string { + const safeWidth = Math.max(1, width); + const counts = statusBarCounts(members); + if (counts.length === 0) { + return chalk.hex(colors.textMuted)(STATUS_BAR_CHAR.repeat(safeWidth)); + } + + const segmentWidths = allocateSegmentWidths(counts.map((entry) => entry.count), safeWidth); + return counts.map((entry, index) => { + const segmentWidth = segmentWidths[index] ?? 0; + if (segmentWidth <= 0) return ''; + return chalk.hex(statusBarColor(entry.phase, colors))(STATUS_BAR_CHAR.repeat(segmentWidth)); + }).join(''); +} + +function renderStatusLabel(label: string, color: string): string { + return ` ${chalk.hex(color)(label)}`; +} + +function activityPrefixForTotalStatus(status: TotalStatus, colors: ColorPalette): string { + const marks: Record<TotalStatus, string> = { + completed: SUCCESS_MARK.trimEnd(), + failed: FAILURE_MARK.trimEnd(), + aborted: CANCELLED_MARK.trimEnd(), + working: '', + suspended: '', + }; + const mark = marks[status]; + return mark.length > 0 + ? ` ${chalk.hex(totalStatusColor(status, colors))(mark)}` + : ACTIVITY_SPINNER_PLACEHOLDER; +} + +function statusBarCounts(members: readonly AgentSwarmMember[]): StatusBarCount[] { + const counts = new Map<StatusBarPhase, number>(); + for (const member of members) { + const phase = statusBarPhase(member.phase); + counts.set(phase, (counts.get(phase) ?? 0) + 1); + } + return STATUS_BAR_ORDER.flatMap((phase) => { + const count = counts.get(phase) ?? 0; + return count > 0 ? [{ phase, count }] : []; + }); +} + +function statusBarPhase(phase: AgentSwarmPhase): StatusBarPhase { + const map: Record<AgentSwarmPhase, StatusBarPhase> = { + pending: 'queued', + queued: 'queued', + suspended: 'suspended', + running: 'working', + completed: 'completed', + failed: 'failed', + cancelled: 'cancelled', + }; + return map[phase]; +} + +function statusBarColor(phase: StatusBarPhase, colors: ColorPalette): string { + const map: Record<StatusBarPhase, string> = { + queued: colors.textMuted, + working: colors.primary, + suspended: colors.textMuted, + completed: colors.success, + failed: colors.error, + cancelled: colors.warning, + }; + return map[phase]; +} + +function totalStatus( + members: readonly AgentSwarmMember[], + force: { readonly failed: boolean; readonly aborted: boolean }, +): TotalStatus { + if (force.aborted) return 'aborted'; + const phases = new Set(members.map((m) => m.phase)); + const hasActive = phases.has('pending') || phases.has('queued') || phases.has('suspended') || phases.has('running'); + if (!hasActive && members.length > 0) { + if (phases.has('cancelled')) return 'aborted'; + if (phases.has('completed')) return 'completed'; + return 'failed'; + } + if (force.failed) return 'failed'; + if (phases.has('suspended') && !phases.has('running')) return 'suspended'; + return 'working'; +} + +function totalStatusLabel(status: TotalStatus): string { + const map: Record<TotalStatus, string> = { + working: WORKING_LABEL, + completed: COMPLETED_LABEL, + suspended: SUSPENDED_LABEL, + failed: FAILED_LABEL, + aborted: ABORTED_LABEL, + }; + return map[status]; +} + +function totalStatusColor(status: TotalStatus, colors: ColorPalette): string { + const map: Record<TotalStatus, string> = { + working: colors.success, + completed: colors.success, + suspended: colors.textDim, + failed: colors.error, + aborted: colors.warning, + }; + return map[status]; +} + +function totalStatusLabelColor( + status: TotalStatus, + members: readonly AgentSwarmMember[], + colors: ColorPalette, +): string { + if (status === 'working' && !members.some((member) => member.phase === 'completed')) { + return colors.primary; + } + return totalStatusColor(status, colors); +} + +function allocateSegmentWidths(counts: readonly number[], width: number): number[] { + const total = counts.reduce((sum, count) => sum + count, 0); + if (total <= 0 || width <= 0) return counts.map(() => 0); + + const exact = counts.map((count) => count * width / total); + const widths = exact.map(Math.floor); + let remaining = width - widths.reduce((sum, value) => sum + value, 0); + const order = exact + .map((value, index) => ({ index, fraction: value - Math.floor(value) })) + .toSorted((a, b) => b.fraction - a.fraction || a.index - b.index); + + for (const entry of order) { + if (remaining <= 0) break; + widths[entry.index] = (widths[entry.index] ?? 0) + 1; + remaining -= 1; + } + return widths; +} + +function renderCellLabel( + member: AgentSwarmMember, + snapshot: AgentSwarmSnapshot, + width: number, + colors: ColorPalette, +): string { + if (snapshot.phase === 'running') { + const latestLine = latestNonEmptyLine(snapshot.latestModelText); + return truncateWithColor(runningCellLabelText(member, latestLine), width, colors.textDim); + } + if (snapshot.phase === 'failed' && member.failureText !== undefined) { + return truncateWithColor(`${FAILURE_MARK}${member.failureText}`, width, colors.error); + } + if (snapshot.phase === 'completed') { + return renderCompletedCellLabel( + member.completedText ?? latestNonEmptyLine(snapshot.latestModelText), + width, + colors, + ); + } + if (snapshot.phase === 'cancelled') { + return renderCancelledCellLabel(member, width, colors); + } + return truncateWithColor(PHASE_LABELS[snapshot.phase], width, phaseColor(snapshot.phase, colors)); +} + +function runningCellLabelText(member: AgentSwarmMember, latestLine: string): string { + const itemText = collapseWhitespace(member.itemText); + const text = latestLine.length > 0 ? latestLine : itemText; + return text.length > 0 ? text : PHASE_LABELS.running; +} + +function renderCancelledCellLabel( + member: AgentSwarmMember, + width: number, + colors: ColorPalette, +): string { + const labelText = member.cancelledLabelText ?? ABORTED_LABEL; + const labelColor = member.cancelledLabelColor ?? colors.warning; + const markColor = member.cancelledMarkColor ?? colors.warning; + const labelStyle = chalk.hex(labelColor); + return truncateToWidth( + chalk.hex(markColor)(CANCELLED_MARK) + labelStyle(labelText), + width, + labelStyle('…'), + ); +} + +function renderCompletedCellLabel( + text: string, + width: number, + colors: ColorPalette, +): string { + const finalText = normalizeFinalOutputText(text); + const label = finalText === undefined ? SUCCESS_MARK.trimEnd() : `${SUCCESS_MARK}${finalText}`; + return truncateWithColor(label, width, colors.success); +} + +function compactTerminalMark( + member: AgentSwarmMember, + phase: AgentSwarmPhase, + colors: ColorPalette, +): string { + if (phase === 'completed') return chalk.hex(colors.success)(SUCCESS_MARK.trimEnd()); + if (phase === 'failed') return chalk.hex(colors.error)(FAILURE_MARK.trimEnd()); + if (phase === 'cancelled') { + return chalk.hex(member.cancelledMarkColor ?? colors.warning)(CANCELLED_MARK.trimEnd()); + } + return ''; +} + +function renderPendingCell( + member: AgentSwarmMember, + width: number, + colors: ColorPalette, +): string { + const id = chalk.hex(colors.primary)(member.id); + const prefix = `${id} `; + const itemText = collapseWhitespace(member.itemText); + const label = itemText.length > 0 ? itemText : QUEUED_LABEL; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + return prefix + truncateWithColor(label, labelWidth, colors.textDim); +} + +function renderQueuedCell( + member: AgentSwarmMember, + width: number, + colors: ColorPalette, +): string { + const id = chalk.hex(colors.primary)(member.id); + const prefix = `${id} `; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + return prefix + truncateWithColor(QUEUED_LABEL, labelWidth, colors.textDim); +} + +function renderCancelledUnstartedCell( + member: AgentSwarmMember, + width: number, + colors: ColorPalette, +): string { + const id = chalk.hex(colors.primary)(member.id); + const prefix = `${id} `; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + return prefix + renderCancelledCellLabel(member, labelWidth, colors); +} + +function truncateWithColor(text: string, width: number, color: string): string { + const colorize = chalk.hex(color); + return truncateToWidth(colorize(text), width, colorize('…')); +} + +function truncateStartToWidth(text: string, width: number): string { + if (visibleWidth(text) <= width) return text; + const ellipsis = '…'; + const ellipsisWidth = visibleWidth(ellipsis); + if (width <= ellipsisWidth) return truncateToWidth(ellipsis, width); + + const targetWidth = width - ellipsisWidth; + const segments = Array.from(text); + let tail = ''; + let tailWidth = 0; + for (let index = segments.length - 1; index >= 0; index -= 1) { + const segment = segments[index] ?? ''; + const segmentWidth = visibleWidth(segment); + if (tailWidth + segmentWidth > targetWidth) break; + tail = segment + tail; + tailWidth += segmentWidth; + } + return ellipsis + tail; +} + +function collapseWhitespace(text: string): string { + return text.replaceAll(/\s+/g, ' ').trim(); +} + +function normalizeFailureText(text: string | undefined): string | undefined { + if (text === undefined) return undefined; + const nestedFailureText = nestedAgentSwarmFailureText(text); + const normalized = stripAgentSwarmPrefix(collapseWhitespace(nestedFailureText ?? text)); + return normalized.length > 0 ? normalized : undefined; +} + +function nestedAgentSwarmFailureText(text: string): string | undefined { + const xmlFailureText = nestedAgentSwarmXmlFailureText(text); + if (xmlFailureText !== undefined) return nestedAgentSwarmFailureText(xmlFailureText) ?? xmlFailureText; + + if (!/^\s*agent_swarm:\s*failed\b/m.test(text)) return undefined; + const match = /^\s*subagent error:\s*([\s\S]*?)(?=\n\[agent \d+\]\n|$)/m.exec(text); + if (match === null) return undefined; + const failureText = match[1]; + if (failureText === undefined) return undefined; + return nestedAgentSwarmFailureText(failureText) ?? failureText; +} + +function nestedAgentSwarmXmlFailureText(text: string): string | undefined { + if (!/<agent_swarm_result\b/.test(text)) return undefined; + const failed = parseAgentSwarmXmlResultStatuses(text).find((entry) => { + return entry.status === 'failed' && entry.failureText !== undefined; + }); + return failed?.failureText; +} + +function stripAgentSwarmPrefix(text: string): string { + return text.replace(/^agent_swarm:\s*(?:failed|completed)?\s*/i, '').trim(); +} + +function normalizeFinalOutputText(text: string | undefined): string | undefined { + if (text === undefined) return undefined; + const normalized = collapseWhitespace(text); + return normalized.length > 0 ? normalized : undefined; +} + +function latestNonEmptyLine(text: string): string { + const lines = text.split(/\r?\n/); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = collapseWhitespace(lines[index] ?? ''); + if (line.length > 0) return line; + } + return ''; +} + +function countPartialJsonObjectEntries(text: string, startIndex: number): number { + let count = 0; + let expectKey = true; + for (let i = startIndex; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '}') return count; + if (ch === ',') { + expectKey = true; + continue; + } + if (ch !== '"') continue; + + const parsed = parsePartialJsonString(text, i + 1); + if (expectKey) { + if (parsed.closed || parsed.value.length > 0) count += 1; + expectKey = false; + } + if (!parsed.closed) return count; + i = parsed.nextIndex; + } + return count; +} + +function parsePartialJsonString( + text: string, + startIndex: number, +): { value: string; closed: boolean; nextIndex: number } { + let value = ''; + for (let i = startIndex; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '"') return { value, closed: true, nextIndex: i }; + if (ch !== '\\') { + value += ch; + continue; + } + + const escaped = text[i + 1]; + if (escaped === undefined) return { value, closed: false, nextIndex: i }; + switch (escaped) { + case 'n': value += '\n'; break; + case 't': value += '\t'; break; + case 'r': value += '\r'; break; + case 'b': value += '\b'; break; + case 'f': value += '\f'; break; + case '"': + case '\\': + case '/': + value += escaped; + break; + case 'u': { + const hex = text.slice(i + 2, i + 6); + if (hex.length < 4) return { value, closed: false, nextIndex: i }; + const code = Number.parseInt(hex, 16); + if (Number.isNaN(code)) return { value, closed: false, nextIndex: i }; + value += String.fromCodePoint(code); + i += 4; + break; + } + default: + value += escaped; + } + i += 1; + } + return { value, closed: false, nextIndex: text.length }; +} + +function padAnsi(text: string, width: number): string { + const truncated = truncateToWidth(text, width); + return truncated + ' '.repeat(Math.max(0, width - visibleWidth(truncated))); +} + +function cellCacheKeyEquals(a: AgentSwarmCellCacheKey, b: AgentSwarmCellCacheKey): boolean { + if (a.length !== b.length) return false; + for (let index = 0; index < a.length; index += 1) { + if (a[index] !== b[index]) return false; + } + return true; +} + +function completedDisplayTicks(ticks: number, width: number, phaseElapsedMs: number): number { + const fullBarTicks = width * BRAILLE_LEVELS.length; + if (ticks >= fullBarTicks) return fullBarTicks; + const fillProgress = Math.max(0, Math.min(1, phaseElapsedMs / COMPLETE_FILL_MS)); + return Math.min(fullBarTicks, Math.ceil(ticks + (fullBarTicks - ticks) * fillProgress)); +} + +function failedBrailleBar( + ticks: number, + width: number, + phaseElapsedMs: number, + colors: ColorPalette, +): string { + const redCellCount = Math.ceil( + completedDisplayTicks(ticks, width, phaseElapsedMs) / BRAILLE_LEVELS.length, + ); + const placeholderColor = darkenRedHexColor(colors.error); + return accumulatedBrailleBar( + ticks, + width, + colors.error, + colors, + (cellIndex) => cellIndex < redCellCount ? placeholderColor : colors.textDim, + ); +} + +function darkenRedHexColor(hex: string): string { + return darkenHexColor( + hex, + FAILED_PLACEHOLDER_RED_FACTOR, + FAILED_PLACEHOLDER_NON_RED_FACTOR, + FAILED_PLACEHOLDER_NON_RED_FACTOR, + ); +} + +function cancelledLabelColor(colors: ColorPalette): string { + return darkenHexColor(colors.warning, CANCELLED_LABEL_DARKEN_FACTOR); +} + +function darkenHexColor( + hex: string, + redFactor: number, + greenFactor = redFactor, + blueFactor = redFactor, +): string { + const match = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex); + if (match === null) return hex; + const darken = (channel: string, factor: number): string => + Math.max(0, Math.min(255, Math.round(Number.parseInt(channel, 16) * factor))) + .toString(16) + .padStart(2, '0'); + return `#${darken(match[1]!, redFactor)}${darken(match[2]!, greenFactor)}${darken( + match[3]!, + blueFactor, + )}`; +} + +function accumulatedBrailleBar( + ticks: number, + width: number, + filledColor: string, + colors: ColorPalette, + emptyColorForCell?: (cellIndex: number) => string, +): string { + const dotsPerCell = BRAILLE_LEVELS.length; + const cycleSize = width * dotsPerCell; + const safeTicks = Math.max(0, Math.ceil(ticks)); + const completedCycles = Math.floor(safeTicks / cycleSize); + const cycleTicks = safeTicks % cycleSize; + const activeCells = cycleTicks === 0 ? 0 : Math.ceil(cycleTicks / dotsPerCell); + const separatorIndex = completedCycles > 0 && activeCells > 0 && activeCells < width + ? activeCells + : -1; + + let out = ''; + let pending = ''; + let pendingColor: string | undefined; + const flush = (): void => { + if (pending.length === 0 || pendingColor === undefined) return; + out += chalk.hex(pendingColor)(pending); + pending = ''; + }; + const append = (char: string, color: string): void => { + if (pendingColor !== color) { + flush(); + pendingColor = color; + } + pending += char; + }; + + for (let i = 0; i < width; i += 1) { + if (i === separatorIndex) { + append(BRAILLE_RIGHT_COLUMN_FULL, filledColor); + continue; + } + + const cellStart = i * dotsPerCell; + const countThisCycle = Math.max(0, Math.min(dotsPerCell, cycleTicks - cellStart)); + const count = countThisCycle > 0 ? countThisCycle : completedCycles > 0 ? dotsPerCell : 0; + append( + count === 0 ? BRAILLE_EMPTY : BRAILLE_LEVELS[count - 1]!, + count === 0 ? emptyColorForCell?.(i) ?? colors.textDim : filledColor, + ); + } + flush(); + return out; +} diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..64ed6bbf8d826f68fe6a518e4a84868de317c213 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -0,0 +1,134 @@ +/** + * Renders an assistant message using pi-tui Markdown. + * + * Displays a white bullet prefix with markdown content indented + * to align after the bullet. + */ + +import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; + +import { MESSAGE_INDENT } from '#/tui/constant/rendering'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { createMarkdownOptions } from '#/tui/utils/markdown-options'; +import { markOsc133Zone } from '#/tui/utils/osc133'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; + +type AssistantMarkdownOptions = { + transient?: boolean; +}; + +export class AssistantMessageComponent implements Component { + private contentContainer: Container; + private markdown: Markdown | undefined; + private markdownTransient = false; + private lastText = ''; + private lastTransient = false; + private showBullet: boolean; + + private renderCache: { width: number; lines: string[] } | undefined; + + constructor(showBullet: boolean = true) { + this.showBullet = showBullet; + this.contentContainer = new Container(); + } + + private markRenderDirty(): void { + this.renderCache = undefined; + } + + setShowBullet(show: boolean): void { + if (this.showBullet === show) return; + this.showBullet = show; + this.markRenderDirty(); + } + + updateContent(text: string, opts?: AssistantMarkdownOptions): void { + const displayText = text.trim(); + const transient = opts?.transient === true; + + if (displayText === this.lastText && transient === this.lastTransient) return; + + this.lastText = displayText; + this.lastTransient = transient; + this.markRenderDirty(); + + if (displayText.length === 0) { + this.contentContainer.clear(); + this.markdown = undefined; + this.markdownTransient = false; + return; + } + + if (this.markdown === undefined || this.markdownTransient !== transient) { + this.contentContainer.clear(); + this.markdown = new Markdown( + displayText, + 0, + 0, + createMarkdownTheme({ transient }), + undefined, + createMarkdownOptions(), + ); + this.markdownTransient = transient; + this.contentContainer.addChild(this.markdown); + return; + } + + this.markdown.setText(displayText); + } + + invalidate(): void { + // Markdown caches ANSI colour codes keyed on (text, width). When the + // theme changes the cached strings contain stale colours, so we rebuild + // the Markdown child with the new theme while preserving transient mode. + this.markRenderDirty(); + this.contentContainer.clear(); + this.markdown = undefined; + + if (this.lastText.trim().length > 0) { + this.markdown = new Markdown( + this.lastText.trim(), + 0, + 0, + createMarkdownTheme({ transient: this.lastTransient }), + undefined, + createMarkdownOptions(), + ); + this.markdownTransient = this.lastTransient; + this.contentContainer.addChild(this.markdown); + } + } + + render(width: number): string[] { + if (this.lastText.trim().length === 0) return []; + + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + if ( + isRenderCacheEnabled() && + this.renderCache !== undefined && + this.renderCache.width === safeWidth + ) { + return this.renderCache.lines; + } + + const prefix = this.showBullet ? STATUS_BULLET : MESSAGE_INDENT; + const contentWidth = Math.max(1, safeWidth - visibleWidth(prefix)); + const contentLines = this.contentContainer.render(contentWidth); + + const lines: string[] = ['']; + for (let i = 0; i < contentLines.length; i++) { + const p = + i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT; + lines.push(p + contentLines[i]); + } + const rendered = markOsc133Zone(lines.map((line) => truncateToWidth(line, safeWidth, '…'))); + if (isRenderCacheEnabled()) { + this.renderCache = { width: safeWidth, lines: rendered }; + } + return rendered; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..24e9dd2f953310684feac08220df453c89e9bf5c --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts @@ -0,0 +1,43 @@ +import { Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; + +import { MESSAGE_INDENT } from '#/tui/constant/rendering'; +import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; +import type { BackgroundAgentStatusData } from '#/tui/types'; + +export class BackgroundAgentStatusComponent implements Component { + constructor(private readonly data: BackgroundAgentStatusData) {} + + invalidate(): void {} + + render(width: number): string[] { + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + const tone: keyof ColorPalette = + this.data.phase === 'started' + ? 'primary' + : this.data.phase === 'completed' + ? 'success' + : 'error'; + + const bullet = + this.data.phase === 'failed' || this.data.phase === 'killed' + ? currentTheme.fg(tone, FAILURE_MARK) + : currentTheme.fg(tone, STATUS_BULLET); + const text = + currentTheme.fg(tone, this.data.headline) + + (this.data.detail !== undefined && this.data.detail.length > 0 + ? currentTheme.fg('textDim', ` (${this.data.detail})`) + : ''); + + const textComponent = new Text(text, 0, 0); + const contentWidth = Math.max(1, safeWidth - MESSAGE_INDENT.length); + const contentLines = textComponent.render(contentWidth); + return [ + '', + ...contentLines.map((line, index) => (index === 0 ? bullet : MESSAGE_INDENT) + line), + ].map((line) => truncateToWidth(line, safeWidth, '…')); + } +} diff --git a/apps/kimi-code/src/tui/components/messages/cron-message.ts b/apps/kimi-code/src/tui/components/messages/cron-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..8cb81f5d3adc9b8c9a0df80c9d5584cc35c0e87e --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/cron-message.ts @@ -0,0 +1,84 @@ +import type { Component } from '@moonshot-ai/pi-tui'; +import { Spacer, Text, visibleWidth } from '@moonshot-ai/pi-tui'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; +import type { CronTranscriptData } from '#/tui/types'; + +export class CronMessageComponent implements Component { + private readonly spacer = new Spacer(1); + private readonly data: CronTranscriptData; + private readonly title: string; + private readonly detail: string | undefined; + private readonly promptText: Text; + private readonly prompt: string; + + constructor( + prompt: string, + data: CronTranscriptData, + ) { + const missed = data.missedCount !== undefined; + this.data = data; + this.title = missed ? 'Missed scheduled reminders' : 'Scheduled reminder fired'; + this.detail = cronDetail(data); + this.prompt = prompt; + this.promptText = new Text(currentTheme.fg('text', prompt), 0, 0); + } + + invalidate(): void { + this.promptText.setText(currentTheme.fg('text', this.prompt)); + this.promptText.invalidate(); + } + + render(width: number): string[] { + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + const missed = this.data.missedCount !== undefined; + const titleToken: keyof ColorPalette = this.data.stale === true || missed ? 'warning' : 'accent'; + const bullet = currentTheme.boldFg(titleToken, STATUS_BULLET); + const bulletWidth = visibleWidth(bullet); + const contentWidth = Math.max(1, safeWidth - bulletWidth); + const continuationIndent = ' '.repeat(bulletWidth); + const lines: string[] = []; + + for (const line of this.spacer.render(safeWidth)) { + lines.push(line); + } + + const titleLines = new Text(currentTheme.boldFg(titleToken, this.title), 0, 0).render(contentWidth); + for (let i = 0; i < titleLines.length; i += 1) { + lines.push(`${i === 0 ? bullet : continuationIndent}${titleLines[i]}`); + } + + if (this.detail !== undefined) { + const detailLines = new Text(currentTheme.fg('textDim', this.detail), 0, 0).render(contentWidth); + for (const line of detailLines) { + lines.push(`${continuationIndent}${line}`); + } + } + + const promptLines = this.promptText.render(contentWidth); + for (const line of promptLines) { + lines.push(`${continuationIndent}${line}`); + } + + return lines; + } +} + +function cronDetail(data: CronTranscriptData): string | undefined { + const parts: string[] = []; + if (data.cron !== undefined && data.cron.length > 0) parts.push(data.cron); + if (data.jobId !== undefined && data.jobId.length > 0) parts.push(`job ${data.jobId}`); + if (data.recurring === false) parts.push('one-shot'); + if (data.coalescedCount !== undefined && data.coalescedCount > 1) { + parts.push(`${String(data.coalescedCount)} fires coalesced`); + } + if (data.missedCount !== undefined) { + parts.push(`${String(data.missedCount)} missed`); + } + if (data.stale === true) parts.push('final delivery'); + return parts.length > 0 ? parts.join(' | ') : undefined; +} diff --git a/apps/kimi-code/src/tui/components/messages/goal-format.ts b/apps/kimi-code/src/tui/components/messages/goal-format.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a5933425914deee660c940e83267fa4cc5acee2 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/goal-format.ts @@ -0,0 +1,13 @@ +export function formatGoalElapsed(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${String(totalSeconds)}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) return `${String(minutes)}m ${seconds.toString().padStart(2, '0')}s`; + const hours = Math.floor(minutes / 60); + return `${String(hours)}h ${(minutes % 60).toString().padStart(2, '0')}m`; +} + +export function pluralizeGoalCount(n: number, singular: string, plural?: string): string { + return `${String(n)} ${n === 1 ? singular : (plural ?? `${singular}s`)}`; +} diff --git a/apps/kimi-code/src/tui/components/messages/goal-markers.ts b/apps/kimi-code/src/tui/components/messages/goal-markers.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4482941f5cffe23e36010e938f0ed8a3a638aff --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/goal-markers.ts @@ -0,0 +1,187 @@ +/** + * Low-profile transcript markers for the autonomous goal loop. + * + * Lifecycle changes (paused / resumed / cancelled) and `no_progress` verdicts + * render as a single dim line — `◦ Goal paused` — that expands (ctrl+o, shared + * with tool output) to show the reason when there is one. Terminal outcomes use + * the richer completion card (the `/goal` box), not this marker. + */ + +import { truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; +import type { GoalChange } from '@moonshot-ai/kimi-code-sdk'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; + +const HEAD_INDENT = ' '; +const DETAIL_INDENT = ' '; + +type GoalMarkerActor = 'user' | 'model' | 'runtime' | 'system'; + +interface GoalMarkerOptions { + readonly marker?: string; + readonly textToken?: ColorToken; + readonly expandable?: boolean; + readonly indent?: string; + readonly leadingBlank?: boolean; +} + +export class GoalMarkerComponent implements Component { + private expanded = false; + private readonly marker: string; + private readonly textToken: ColorToken; + private readonly expandable: boolean; + private readonly indent: string; + private readonly leadingBlank: boolean; + + constructor( + private readonly headline: string, + private readonly detail: string | undefined, + private readonly accentToken: ColorToken, + options: GoalMarkerOptions = {}, + ) { + this.marker = options.marker ?? '◦'; + this.textToken = options.textToken ?? 'textDim'; + this.expandable = options.expandable ?? true; + this.indent = options.indent ?? HEAD_INDENT; + this.leadingBlank = options.leadingBlank ?? false; + } + + invalidate(): void {} + + setExpanded(expanded: boolean): void { + this.expanded = expanded; + } + + render(width: number): string[] { + const dot = currentTheme.fg(this.accentToken, this.marker); + const head = currentTheme.fg(this.textToken, this.headline); + const hasDetail = this.detail !== undefined && this.detail.length > 0; + if (!hasDetail) return this.clampToWidth([`${this.indent}${dot} ${head}`], width); + + if (!this.expandable) { + return this.clampToWidth([`${this.indent}${dot} ${head}`], width); + } + if (!this.expanded) { + return this.clampToWidth( + [`${this.indent}${dot} ${head} ${currentTheme.fg('textMuted', '(ctrl+o)')}`], + width, + ); + } + const out = [`${this.indent}${dot} ${head}`]; + const wrapWidth = Math.max(20, width - DETAIL_INDENT.length); + for (const line of wrap(this.detail!, wrapWidth)) { + out.push(DETAIL_INDENT + currentTheme.fg('textDim', line)); + } + return this.clampToWidth(out, width); + } + + private clampToWidth(lines: string[], width: number): string[] { + const withBlank = this.withLeadingBlank(lines); + if (width <= 0) return withBlank.map(() => ''); + return withBlank.map((line) => truncateToWidth(line, width)); + } + + private withLeadingBlank(lines: string[]): string[] { + return this.leadingBlank ? ['', ...lines] : lines; + } +} + +/** + * Builds a marker for a lifecycle change (paused / resumed / blocked), or `null` + * when the change should be silent (a `completion` change posts its own message, + * not a marker). `expanded` seeds the initial ctrl+o state. + */ +export function buildGoalMarker( + change: GoalChange, + expanded: boolean, + actor?: GoalMarkerActor, +): GoalMarkerComponent | null { + const spec = markerSpec(change, actor); + if (spec === null) return null; + const marker = new GoalMarkerComponent( + spec.headline, + spec.detail ?? change.reason, + spec.accentToken, + spec.options, + ); + marker.setExpanded(expanded); + return marker; +} + +function markerSpec( + change: GoalChange, + actor?: GoalMarkerActor, +): { + headline: string; + accentToken: ColorToken; + detail?: string | undefined; + options?: GoalMarkerOptions | undefined; +} | null { + if (change.kind === 'lifecycle') { + switch (change.status) { + case 'paused': + return prominentMarker(pausedHeadline(change.reason, actor), 'warning'); + case 'active': + return prominentMarker(resumedHeadline(actor), 'primary'); + case 'blocked': + // The system stopped pursuing the goal; resumable via `/goal resume`. + return { headline: 'Goal blocked', accentToken: 'warning' }; + default: + return null; + } + } + return null; // completion -> posts its own message, not a marker +} + +function prominentMarker(headline: string, accentToken: ColorToken) { + return { + headline, + accentToken, + detail: undefined, + options: { + marker: STATUS_BULLET.trimEnd(), + textToken: accentToken, + expandable: false, + indent: '', + leadingBlank: true, + }, + }; +} + +function pausedHeadline(reason: string | undefined, actor: GoalMarkerActor | undefined): string { + if (reason === 'Paused after interruption') return "Goal paused due to user's interruption"; + if (actor === 'user') return 'Goal paused by the user.'; + if (reason?.startsWith('Paused ') === true) return `Goal ${lowercaseFirst(reason)}`; + if (reason !== undefined && reason.length > 0) return `Goal paused: ${reason}`; + if (actor === 'model') return 'Goal paused by the agent.'; + return 'Goal paused'; +} + +function resumedHeadline(actor: GoalMarkerActor | undefined): string { + if (actor === 'user') return 'Goal resumed by the user.'; + if (actor === 'model') return 'Goal resumed by the agent.'; + return 'Goal resumed'; +} + +function lowercaseFirst(text: string): string { + return text.length === 0 ? text : `${text[0]!.toLowerCase()}${text.slice(1)}`; +} + +function wrap(text: string, width: number): string[] { + const words = text.replaceAll(/\s+/g, ' ').trim().split(' '); + const lines: string[] = []; + let current = ''; + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (candidate.length > width && current.length > 0) { + lines.push(current); + current = word; + } else { + current = candidate; + } + } + if (current.length > 0) lines.push(current); + return lines.length > 0 ? lines : ['']; +} diff --git a/apps/kimi-code/src/tui/components/messages/goal-panel.ts b/apps/kimi-code/src/tui/components/messages/goal-panel.ts new file mode 100644 index 0000000000000000000000000000000000000000..d01f580faa2a616aaf8472da4b147e9a985209b9 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/goal-panel.ts @@ -0,0 +1,222 @@ +/** + * Builds the line content for the `/goal` status box. The lines are rendered + * inside a {@link UsagePanelComponent} (the same bordered box as `/usage`), so + * this module only owns the goal-specific layout: + * + * ▌ <objective> (blockquote left-trail, wrapped) + * ▌ ✓ <completion criterion> + * + * Status complete — <reason> (terminal goals only) + * Running 4m 12s + * Turns 7 + * Tokens 128.4k + * Stop after 20 turns (7/20) (or a dim "no stop condition" note) + */ + +import { + Text, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type Component, +} from '@moonshot-ai/pi-tui'; +import type { GoalSnapshot, GoalStatus } from '@moonshot-ai/kimi-code-sdk'; + +import { MESSAGE_INDENT } from '#/tui/constant/rendering'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; +import { formatTokenCount } from '#/utils/usage/usage-format'; +import { formatGoalElapsed } from './goal-format'; +import { UsagePanelComponent } from './usage-panel'; + +const WRAP_WIDTH = 72; +const MAX_OBJECTIVE_LINES = 6; +const MAX_CRITERION_LINES = 3; +const LABEL_WIDTH = 11; + +function renderLifecycleLine(label: string, width: number): string[] { + if (width <= 0) return ['']; + + const marker = currentTheme.boldFg('primary', STATUS_BULLET); + const text = new Text(currentTheme.boldFg('primary', label), 0, 0); + const contentWidth = Math.max(1, width - visibleWidth(STATUS_BULLET)); + return [ + '', + ...text + .render(contentWidth) + .map((line, index) => (index === 0 ? marker : MESSAGE_INDENT) + line.trimEnd()), + ]; +} + +/** + * The "Goal set" confirmation shown after `/goal <objective>`. The objective is + * rendered as the following user prompt, so this message only marks the state + * change in the transcript. + */ +export class GoalSetMessageComponent implements Component { + invalidate(): void {} + + render(width: number): string[] { + return renderLifecycleLine('Goal set', width); + } +} + +export class UpcomingGoalAddedMessageComponent implements Component { + invalidate(): void {} + + render(width: number): string[] { + return renderLifecycleLine( + 'Upcoming goal added. It will start after the current goal is complete.', + width, + ); + } +} + +export class GoalCompletionMessageComponent implements Component { + constructor(private readonly message: string) {} + + invalidate(): void {} + + render(width: number): string[] { + const [headline = '', ...details] = this.message.trim().split(/\r?\n/); + if (headline.length === 0) return []; + + const bullet = currentTheme.boldFg('success', STATUS_BULLET); + const bulletWidth = visibleWidth(STATUS_BULLET); + const contentWidth = Math.max(1, width - bulletWidth); + const lines: string[] = ['']; + + const headlineText = new Text(currentTheme.boldFg('success', headline), 0, 0); + const headlineLines = headlineText.render(contentWidth); + for (let i = 0; i < headlineLines.length; i += 1) { + lines.push((i === 0 ? bullet : MESSAGE_INDENT) + headlineLines[i]); + } + + const detailText = details.join('\n').trim(); + if (detailText.length > 0) { + const detailLines = new Text(currentTheme.fg('textDim', detailText), 0, 0).render( + contentWidth, + ); + for (const line of detailLines) { + lines.push(MESSAGE_INDENT + line); + } + } + + return lines; + } +} + +export class GoalStatusMessageComponent implements Component { + constructor(private readonly goal: GoalSnapshot) {} + + invalidate(): void {} + + render(width: number): string[] { + const panelContentWidth = Math.max(1, width - 6); + const panel = new UsagePanelComponent( + () => buildGoalReportLines(this.goal, panelContentWidth), + 'primary', + goalPanelTitle(this.goal), + ); + return ['', ...panel.render(width)]; + } +} + +/** Box title, e.g. ` Goal · active `. */ +export function goalPanelTitle(goal: GoalSnapshot): string { + return ` Goal · ${goal.status} `; +} + +export function buildGoalReportLines(goal: GoalSnapshot, wrapWidth: number = WRAP_WIDTH): string[] { + const statusColor = statusToken(goal.status); + const bar = (s: string) => currentTheme.fg(statusColor, s); + const value = (s: string) => currentTheme.fg('text', s); + const muted = (s: string) => currentTheme.fg('textDim', s); + // `complete` is the terminal outcome (the completion card); everything else + // (active / paused / blocked) is a persisted, resumable goal that still shows + // its stop condition. A reason is worth surfacing for stopped / complete states. + const isComplete = goal.status === 'complete'; + const reason = goal.terminalReason; + const showReason = + (goal.status === 'paused' && reason !== undefined) || goal.status === 'blocked' || isComplete; + const lines: string[] = []; + + // Condition as a blockquote left-trail. Reserve the visible "▌ " prefix before + // wrapping so the panel doesn't clip rows that exactly fit the panel interior. + const blockquoteWrapWidth = Math.max(1, wrapWidth - visibleWidth('▌ ')); + for (const line of wrap(goal.objective, blockquoteWrapWidth, MAX_OBJECTIVE_LINES)) { + lines.push(`${bar('▌')} ${value(line)}`); + } + if (goal.completionCriterion !== undefined) { + for (const line of wrap(`✓ ${goal.completionCriterion}`, blockquoteWrapWidth, MAX_CRITERION_LINES)) { + lines.push(`${bar('▌')} ${muted(line)}`); + } + } + lines.push(''); + + const row = (label: string, val: string): string => `${muted(label.padEnd(LABEL_WIDTH))}${val}`; + + if (showReason) { + lines.push( + row( + 'Status', + currentTheme.fg(statusColor, goal.status) + + (reason !== undefined ? muted(` — ${reason}`) : ''), + ), + ); + } + lines.push(row('Running', value(formatGoalElapsed(goal.wallClockMs)))); + lines.push(row('Turns', value(`${goal.turnsUsed}`))); + lines.push(row('Tokens', value(formatTokenCount(goal.tokensUsed)))); + if (!isComplete) { + const stop = formatStopRow(goal); + lines.push( + stop !== null + ? row('Stop', value(stop)) + : muted('No stop condition — runs until evaluated complete.'), + ); + } + return lines; +} + +/** The configured hard stop(s), or null when the goal is unbounded. */ +function formatStopRow(goal: GoalSnapshot): string | null { + const { budget } = goal; + const parts: string[] = []; + if (budget.turnBudget !== null) { + parts.push(`after ${budget.turnBudget} turns (${goal.turnsUsed}/${budget.turnBudget})`); + } + if (budget.tokenBudget !== null) { + parts.push(`at ${formatTokenCount(budget.tokenBudget)} tokens`); + } + if (budget.wallClockBudgetMs !== null) { + parts.push(`after ${formatGoalElapsed(budget.wallClockBudgetMs)}`); + } + return parts.length > 0 ? parts.join(', ') : null; +} + +function statusToken(status: GoalStatus): ColorToken { + switch (status) { + case 'active': + return 'primary'; + case 'complete': + return 'success'; + case 'blocked': + return 'warning'; + case 'paused': + return 'textDim'; + } +} + +/** Word-wrap to `width`, capped at `maxLines` (last line gets an ellipsis when clipped). */ +function wrap(text: string, width: number, maxLines: number): string[] { + const safeWidth = Math.max(1, width); + const lines = wrapTextWithAnsi(text.replaceAll(/\s+/g, ' ').trim(), safeWidth); + if (lines.length === 0) return ['']; + if (lines.length <= maxLines) return lines; + const clipped = lines.slice(0, maxLines); + const lastLine = clipped[maxLines - 1] ?? ''; + clipped[maxLines - 1] = truncateToWidth(`${lastLine}…`, safeWidth, '…'); + return clipped; +} diff --git a/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts b/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts new file mode 100644 index 0000000000000000000000000000000000000000..09829f19e0911f32116c93e843b931534f12820e --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts @@ -0,0 +1,156 @@ +import type { McpServerInfo } from '@moonshot-ai/kimi-code-sdk'; + +import { currentTheme } from '#/tui/theme'; + +export interface McpStatusReportOptions { + readonly servers: readonly McpServerInfo[]; +} + +const STATUS_PRIORITY: Record<McpServerInfo['status'], number> = { + failed: 0, + 'needs-auth': 1, + pending: 2, + connected: 3, + disabled: 4, + removed: 5, +}; + +const STATUS_LABEL: Record<McpServerInfo['status'], string> = { + connected: 'connected', + pending: 'pending', + 'needs-auth': 'needs auth', + failed: 'failed', + disabled: 'disabled', + removed: 'removed', +}; + +const SUMMARY_ORDER: readonly McpServerInfo['status'][] = [ + 'connected', + 'pending', + 'needs-auth', + 'failed', + 'disabled', + 'removed', +]; + +function statusPainter( + status: McpServerInfo['status'], +): (text: string) => string { + switch (status) { + case 'connected': + return (text) => currentTheme.fg('success', text); + case 'failed': + return (text) => currentTheme.fg('error', text); + case 'needs-auth': + case 'pending': + return (text) => currentTheme.fg('warning', text); + case 'disabled': + case 'removed': + return (text) => currentTheme.fg('textDim', text); + } +} + +function formatToolCount(server: McpServerInfo): string { + if (server.status === 'disabled' || server.status === 'removed') return '—'; + return `${server.toolCount} tool${server.toolCount === 1 ? '' : 's'}`; +} + +function formatToolsAvailable(count: number): string { + return `${count} tool${count === 1 ? '' : 's'} available`; +} + +/** + * Collapse a (possibly multi-line) MCP error into a single line. The status + * panel renders each returned string as exactly one boxed row (see + * `UsagePanelComponent.render`), so an embedded newline — e.g. the + * `\nstderr: ...` a failed stdio server appends — would drop the trailing + * text to column 0 and punch through the rounded border. Folding every run + * of whitespace to a single space keeps the error on one row, which the + * panel then truncates to the available width. + */ +function formatErrorLine(error: string): string { + return error.trim().replaceAll(/\s+/g, ' '); +} + +function sortedServers(servers: readonly McpServerInfo[]): McpServerInfo[] { + return servers.toSorted( + (a, b) => + STATUS_PRIORITY[a.status] - STATUS_PRIORITY[b.status] || a.name.localeCompare(b.name), + ); +} + +function buildSummary(servers: readonly McpServerInfo[]): string { + const counts: Partial<Record<McpServerInfo['status'], number>> = {}; + let toolsAvailable = 0; + for (const server of servers) { + counts[server.status] = (counts[server.status] ?? 0) + 1; + if (server.status === 'connected') toolsAvailable += server.toolCount; + } + const parts: string[] = []; + for (const status of SUMMARY_ORDER) { + const n = counts[status]; + if (n === undefined || n === 0) continue; + parts.push(`${n} ${STATUS_LABEL[status]}`); + } + parts.push(formatToolsAvailable(toolsAvailable)); + return parts.join(' · '); +} + +export function buildMcpStatusReportLines(options: McpStatusReportOptions): string[] { + const servers = sortedServers(options.servers); + const accent = (text: string) => currentTheme.boldFg('primary', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const error = (text: string) => currentTheme.fg('error', text); + + const lines: string[] = [accent('Servers')]; + + if (servers.length === 0) { + lines.push(muted(' No MCP servers configured. Run /mcp-config to add one.')); + return lines; + } + + const nameWidth = Math.max('Name'.length, ...servers.map((server) => server.name.length)); + const statusWidth = Math.max( + 'Status'.length, + ...servers.map((server) => STATUS_LABEL[server.status].length), + ); + const transportWidth = Math.max( + 'Transport'.length, + ...servers.map((server) => server.transport.length), + ); + + lines.push( + ` ${muted('Name'.padEnd(nameWidth))} ${muted('Status'.padEnd(statusWidth))} ${muted( + 'Transport'.padEnd(transportWidth), + )} ${muted('Tools')}`, + ); + + for (const server of servers) { + const status = statusPainter( + server.status, + )(STATUS_LABEL[server.status].padEnd(statusWidth)); + lines.push( + ` ${value(server.name.padEnd(nameWidth))} ${status} ${muted( + server.transport.padEnd(transportWidth), + )} ${value(formatToolCount(server))}`, + ); + + if ( + server.status === 'failed' && + server.error !== undefined && + server.error.trim().length > 0 + ) { + lines.push(` ${muted('error:')} ${error(formatErrorLine(server.error))}`); + } + if (server.status === 'needs-auth') { + lines.push(` ${muted('action:')} ${value(`run /mcp-config login ${server.name}`)}`); + } + } + + lines.push(''); + lines.push(` ${value(buildSummary(servers))}`); + lines.push(` ${muted('Configure with')} ${value('/mcp-config')}`); + + return lines; +} diff --git a/apps/kimi-code/src/tui/components/messages/plan-box.ts b/apps/kimi-code/src/tui/components/messages/plan-box.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b46b31d366956286746fe3250ea0869c21109c5 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/plan-box.ts @@ -0,0 +1,125 @@ +/** + * PlanBoxComponent — renders an ExitPlanMode plan inside a full box + * border, width-aware. The plan text is parsed as Markdown so headings, + * lists, bold, inline code etc. render the same way assistant messages do. + */ + +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import { createMarkdownOptions } from '#/tui/utils/markdown-options'; +import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; + +const LEFT_MARGIN = 2; // two-space indent matching other tool call children +const SIDE_PADDING = 1; // space between the │ and the content on each side +const TITLE_PREFIX = ' plan: '; +const TITLE_SUFFIX = ' '; + +export interface PlanBoxOptions { + status?: { + readonly label: string; + readonly colorHex: string; + }; +} + +export class PlanBoxComponent implements Component { + private readonly markdown: Markdown; + private readonly status: PlanBoxOptions['status']; + private cachedWidth: number | undefined; + private cachedLines: string[] | undefined; + + constructor( + plan: string, + markdownTheme: MarkdownTheme, + private readonly borderHex: string, + private readonly planPath?: string, + opts?: PlanBoxOptions, + ) { + // Build the Markdown instance once — pi-tui's Markdown caches its own + // parse + wrap output keyed on (text, width), so reusing the same + // instance means repeated render() calls from the parent Container + // hit the cache instead of re-parsing on every frame. + this.markdown = new Markdown(plan.trim(), 0, 0, markdownTheme, undefined, createMarkdownOptions()); + this.status = opts?.status; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + this.markdown.invalidate?.(); + } + + render(width: number): string[] { + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + if (safeWidth < LEFT_MARGIN + 4) { + return this.markdown.render(Math.max(1, safeWidth)).map((line) => truncateToWidth(line, safeWidth, '…')); + } + + if (this.cachedLines !== undefined && this.cachedWidth === width) { + return this.cachedLines; + } + + // Box layout: " ┌──...──┐" + // " │ <content> │" + // " └──...──┘" + // width = LEFT_MARGIN + 1 + horzLen + 1 ⇒ horzLen = width - 4 + // content width = horzLen - 2 * SIDE_PADDING = width - 6 + const horzLen = Math.max(2, safeWidth - LEFT_MARGIN - 2); + const contentWidth = Math.max(1, horzLen - 2 * SIDE_PADDING); + + const paint = (s: string): string => chalk.hex(this.borderHex)(s); + const indent = ' '.repeat(LEFT_MARGIN); + + const title = this.buildTitle(horzLen); + const trailingDashLen = Math.max(0, horzLen - visibleWidth(title)); + const top = + indent + paint('┌') + paint(title) + paint('─'.repeat(trailingDashLen)) + paint('┐'); + const bottom = indent + paint('└' + '─'.repeat(horzLen) + '┘'); + + const rawLines = this.markdown.render(contentWidth); + + const lines: string[] = [top]; + for (const raw of rawLines) { + const pad = Math.max(0, contentWidth - visibleWidth(raw)); + lines.push(indent + paint('│') + ' ' + raw + ' '.repeat(pad) + ' ' + paint('│')); + } + lines.push(bottom); + + const fitted = lines.map((line) => truncateToWidth(line, safeWidth, '…')); + this.cachedWidth = width; + this.cachedLines = fitted; + return fitted; + } + + private buildTitle(horzLen: number): string { + const fallback = ' plan '; + const statusSuffix = this.buildStatusSuffix(); + const fallbackWithStatus = ` plan${statusSuffix} `; + const budget = Math.max(0, horzLen - 1); + const fallbackTitle = truncateToWidth( + visibleWidth(fallbackWithStatus) <= budget ? fallbackWithStatus : fallback, + budget, + '…', + ); + const planPath = this.planPath; + if (planPath === undefined || planPath.length === 0) return fallbackTitle; + const basename = path.basename(planPath); + if (basename.length === 0) return fallbackTitle; + const linked = path.isAbsolute(planPath) + ? toTerminalHyperlink(basename, pathToFileURL(planPath).href) + : basename; + const title = TITLE_PREFIX + linked + statusSuffix + TITLE_SUFFIX; + if (visibleWidth(title) > budget) return fallbackTitle; + return title; + } + + private buildStatusSuffix(): string { + const status = this.status; + if (status === undefined || status.label.length === 0) return ''; + return ` · ${chalk.hex(status.colorHex)(status.label)}`; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/plugin-command.ts b/apps/kimi-code/src/tui/components/messages/plugin-command.ts new file mode 100644 index 0000000000000000000000000000000000000000..afbc2b44479c134fb3259608e080f2072776f25f --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/plugin-command.ts @@ -0,0 +1,58 @@ +/** + * Plugin command invocation card. + * + * When the user runs `/plugin:command args`, the TUI renders a compact card + * instead of expanding the command body into the user bubble: + * + * ▶ /plugin:command + * args + * + * The args line is optional. Core expands the command body into the LLM + * context; the TUI only consumes the `plugin_command.activated` event. + */ + +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +const ARGS_PREVIEW_MAX = 200; + +export class PluginCommandComponent extends Container { + private headText: Text; + private previewText?: Text; + private readonly label: string; + private readonly args?: string; + + constructor(pluginId: string, commandName: string, args?: string) { + super(); + this.label = `/${pluginId}:${commandName}`; + this.args = args; + this.addChild(new Spacer(1)); + const head = + currentTheme.boldFg('primary', '▶ Invoked command: ') + + currentTheme.boldFg('roleUser', this.label); + this.headText = new Text(head, 0, 0); + this.addChild(this.headText); + const trimmed = args?.trim() ?? ''; + if (trimmed.length > 0) { + const preview = + trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; + this.previewText = new Text(' ' + currentTheme.fg('textDim', preview), 0, 0); + this.addChild(this.previewText); + } + } + + override invalidate(): void { + const head = + currentTheme.boldFg('primary', '▶ Invoked command: ') + + currentTheme.boldFg('roleUser', this.label); + this.headText.setText(head); + if (this.previewText !== undefined && this.args !== undefined) { + const trimmed = this.args.trim(); + const preview = + trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; + this.previewText.setText(' ' + currentTheme.fg('textDim', preview)); + } + super.invalidate(); + } +} diff --git a/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts new file mode 100644 index 0000000000000000000000000000000000000000..4654ee82dfe25b8ed6c3d7f7f41e2618873aed3b --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts @@ -0,0 +1,167 @@ +import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; + +import { currentTheme } from '#/tui/theme'; +import { + CURATED_BADGE, + OFFICIAL_BADGE, + THIRD_PARTY_BADGE, + type PluginTrustLabel, + formatPluginSourceLabel, + pluginTrustLabel, +} from '../../utils/plugin-source-label'; + +export interface PluginsListPanelInput { + readonly plugins: readonly PluginSummary[]; +} + +export function buildPluginsListLines(input: PluginsListPanelInput): readonly string[] { + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const success = (text: string) => currentTheme.fg('success', text); + const primary = (text: string) => currentTheme.fg('primary', text); + const warning = (text: string) => currentTheme.fg('warning', text); + if (input.plugins.length === 0) { + return [ + muted('No plugins installed.'), + '', + value('Run /plugins to install one.'), + ]; + } + const renderTrustBadge = (label: PluginTrustLabel): string => { + if (label === 'official') return success(`[${OFFICIAL_BADGE}]`); + if (label === 'curated') return primary(`[${CURATED_BADGE}]`); + return muted(`[${THIRD_PARTY_BADGE}]`); + }; + const lines: string[] = []; + for (const plugin of input.plugins) { + const enabled = plugin.enabled ? success('enabled') : muted('disabled'); + const state = plugin.state === 'ok' ? '' : ` [${plugin.state}]`; + const version = plugin.version ?? '-'; + const diagnostics = plugin.hasErrors ? warning(' | diagnostics: see /plugins info') : ''; + const sourceTag = muted(`[${formatPluginSourceLabel(plugin)}]`); + const trustBadge = ` ${renderTrustBadge(pluginTrustLabel(plugin))}`; + lines.push( + `${value(plugin.displayName)} (${muted(plugin.id)}) ${muted(version)} ${sourceTag}${trustBadge} | ${enabled}${state}`, + ); + const mcp = + plugin.mcpServerCount > 0 + ? ` | ${plugin.enabledMcpServerCount}/${plugin.mcpServerCount} mcp` + : ''; + lines.push(` ${muted('skills:')} ${value(String(plugin.skillCount))}${muted(mcp)}${diagnostics}`); + } + return lines; +} + + +export interface PluginsInfoPanelInput { + readonly info: PluginInfo; +} + +export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly string[] { + const { info } = input; + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const success = (text: string) => currentTheme.fg('success', text); + const warning = (text: string) => currentTheme.fg('warning', text); + const error = (text: string) => currentTheme.fg('error', text); + const primary = (text: string) => currentTheme.fg('primary', text); + const status = info.enabled ? success('enabled') : muted('disabled'); + const trustLine = (() => { + const label = pluginTrustLabel(info); + if (label === 'official') { + return `${muted('Trust:')} ${success(OFFICIAL_BADGE)} ${muted('(Kimi-built and -maintained)')}`; + } + if (label === 'curated') { + return `${muted('Trust:')} ${primary(CURATED_BADGE)} ${muted('(Kimi-reviewed, upstream-maintained)')}`; + } + return `${muted('Trust:')} ${muted(THIRD_PARTY_BADGE)}`; + })(); + const lines: string[] = [ + `${value(info.displayName)} (${muted(info.id)}) ${muted(info.version ?? '')}`.trim(), + `${muted('Status:')} ${status} | ${muted('state:')} ${stateText(info.state)}`, + trustLine, + `${muted('Source:')} ${value(info.source)}`, + `${muted('Root:')} ${value(info.root)}`, + ]; + if (info.source === 'github' && info.github !== undefined) { + const refLabel = `${info.github.ref.kind}:${info.github.ref.value}`; + lines.push(`${muted('GitHub:')} ${value(`${info.github.owner}/${info.github.repo}`)} ${muted(`@${refLabel}`)}`); + if (info.github.installedSha !== undefined) { + lines.push(`${muted('Installed SHA:')} ${value(info.github.installedSha)}`); + } + } + if (info.originalSource !== undefined) lines.push(`${muted('Original source:')} ${value(info.originalSource)}`); + lines.push(`${muted('Installed at:')} ${value(info.installedAt)}`); + if (info.updatedAt !== undefined && info.updatedAt !== info.installedAt) { + lines.push(`${muted('Last updated:')} ${value(info.updatedAt)}`); + } + if (info.manifestPath !== undefined) { + const kindSuffix = info.manifestKind !== undefined ? ` ${muted(`(${info.manifestKind})`)}` : ''; + lines.push(`${muted('Manifest:')} ${value(info.manifestPath)}${kindSuffix}`); + } + if (info.shadowedManifestPath !== undefined) { + lines.push(`${muted('Shadowed:')} ${value(info.shadowedManifestPath)}`); + } + const sessionStartSkill = info.manifest?.sessionStart?.skill; + if (sessionStartSkill !== undefined) { + lines.push(`${muted('Session start:')} ${value(sessionStartSkill)}`); + } + if (info.manifest?.skillInstructions !== undefined) { + lines.push(`${muted('Skill instructions:')} ${value('present')}`); + } + lines.push(''); + lines.push(value(`Skills (${info.manifest?.skills?.length ?? 0}):`)); + for (const dir of info.manifest?.skills ?? []) lines.push(` ${muted('-')} ${value(dir)}`); + + if (info.mcpServers.length > 0) { + lines.push(''); + lines.push(value(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled):`)); + lines.push(muted(` Enabled by default; disable with /plugins mcp disable ${info.id} <server>.`)); + for (const server of info.mcpServers) { + const enabled = server.enabled ? success('enabled') : muted('disabled'); + lines.push(` ${muted('-')} ${value(server.name)} ${enabled} ${muted(`(${server.runtimeName})`)}`); + if (server.transport === 'stdio') { + const args = server.args !== undefined && server.args.length > 0 ? ` ${server.args.join(' ')}` : ''; + lines.push(` ${muted('command:')} ${value(`${server.command ?? ''}${args}`.trim())}`); + if (server.cwd !== undefined) lines.push(` ${muted('cwd:')} ${value(server.cwd)}`); + if (server.envKeys !== undefined && server.envKeys.length > 0) { + lines.push(` ${muted('env:')} ${value(server.envKeys.join(', '))}`); + } + } else { + lines.push(` ${muted('url:')} ${value(server.url ?? '')}`); + if (server.headerKeys !== undefined && server.headerKeys.length > 0) { + lines.push(` ${muted('headers:')} ${value(server.headerKeys.join(', '))}`); + } + } + } + } + + const iface = info.manifest?.interface; + if (iface !== undefined) { + lines.push(''); + lines.push(value('Display:')); + if (iface.shortDescription !== undefined) lines.push(` ${muted('-')} ${value(iface.shortDescription)}`); + if (iface.developerName !== undefined) lines.push(` ${muted('-')} ${value(`by ${iface.developerName}`)}`); + if (iface.websiteURL !== undefined) lines.push(` ${muted('-')} ${value(iface.websiteURL)}`); + } + + if (info.manifest?.keywords !== undefined && info.manifest.keywords.length > 0) { + lines.push(''); + lines.push(muted(`Keywords: ${info.manifest.keywords.join(', ')}`)); + } + + if (info.diagnostics.length > 0) { + lines.push(''); + lines.push(value('Diagnostics:')); + for (const d of info.diagnostics) { + const paint = d.severity === 'error' ? error : d.severity === 'warn' ? warning : muted; + lines.push(` ${paint(`[${d.severity}]`)} ${value(d.message)}`); + } + } + return lines; +} + +function stateText(state: PluginInfo['state']): string { + if (state === 'ok') return currentTheme.fg('success', state); + return currentTheme.fg('error', state); +} diff --git a/apps/kimi-code/src/tui/components/messages/read-group.ts b/apps/kimi-code/src/tui/components/messages/read-group.ts new file mode 100644 index 0000000000000000000000000000000000000000..11104a9545fe75da6b2e1d3df249704d29f6fe5c --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/read-group.ts @@ -0,0 +1,229 @@ +/** + * ReadGroupComponent renders 2+ Read tool calls from the same step as one group. + * + * It follows the same structure as `AgentGroupComponent`, with a smaller + * surface: + * - one summary header and a tree body listing each file path and status; + * - permanently grouped; the body is shown only while expanded (ctrl+o), + * the collapsed group is the header line alone; + * - 200ms throttling, matching AgentGroup; + * - state stays in each `ToolCallComponent`; the group only reads snapshots. + * + * Header forms: + * pending > 0: Reading {N} files + * all done: Read {N} files · {L} lines + * some failed: append · {F} failed + * all failed: Read {N} files · failed + * + * Body lines follow AgentGroup's branch style: + * src/main.ts · 51 lines + * src/cli.ts · reading + * src/missing.ts · failed + */ + +import type { TUI } from '@moonshot-ai/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; + +import type { ToolCallComponent, ToolCallReadSnapshot } from './tool-call'; +import { TruncatedHeaderLine, type HeaderContent } from './truncated-header-line'; + +const THROTTLE_MS = 200; +// One shared reference: the header line compares segment styles by identity +// to keep its render cache across rebuilds; the palette is read at call time. +const dimHeaderStyle = (text: string): string => currentTheme.dim(text); + +interface ReadEntry { + readonly toolCallId: string; + readonly tc: ToolCallComponent; +} + +export class ReadGroupComponent extends Container { + private readonly entries: ReadEntry[] = []; + private readonly headerText: TruncatedHeaderLine; + private readonly bodyContainer: Container; + private throttleTimer: ReturnType<typeof setTimeout> | null = null; + private lastFlushPhases = new Map<string, ToolCallReadSnapshot['phase']>(); + private _invalidating = false; + private expanded = false; + + constructor(private readonly ui: TUI | undefined) { + super(); + this.addChild(new Spacer(1)); + this.headerText = new TruncatedHeaderLine(''); + this.addChild(this.headerText); + this.bodyContainer = new Container(); + this.addChild(this.bodyContainer); + } + + size(): number { + return this.entries.length; + } + + /** Global ctrl+o toggle: the per-file body is only rendered while expanded. */ + setExpanded(expanded: boolean): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + this.flushRender(); + } + + /** The per-file bodies only render while expanded, so any attached Read is hidden content. */ + hasHiddenContent(): boolean { + return this.entries.length > 0; + } + + isExpanded(): boolean { + return this.expanded; + } + + /** + * Borrows a standalone `ToolCallComponent` into the group as a hidden state + * container. Snapshot changes trigger throttled refreshes. Re-attaching the + * same toolCallId is a no-op. + */ + attach(toolCallId: string, tc: ToolCallComponent): void { + if (this.entries.some((e) => e.toolCallId === toolCallId)) return; + this.entries.push({ toolCallId, tc }); + tc.setSnapshotListener(() => { + this.scheduleRender(); + }); + this.flushRender(); + } + + /** + * The pending -> done/failed transition is the important visible change, so + * it refreshes immediately. Other changes are throttled. + */ + private scheduleRender(): void { + if (this.detectPhaseTransition()) { + this.flushRender(); + return; + } + if (this.throttleTimer !== null) return; + this.throttleTimer = setTimeout(() => { + this.throttleTimer = null; + this.flushRender(); + }, THROTTLE_MS); + } + + private detectPhaseTransition(): boolean { + for (const e of this.entries) { + const phase = e.tc.getReadSnapshot().phase; + if (this.lastFlushPhases.get(e.toolCallId) !== phase) return true; + } + return false; + } + + private flushRender(): void { + if (this.throttleTimer !== null) { + clearTimeout(this.throttleTimer); + this.throttleTimer = null; + } + + const snapshots = this.entries.map((e) => e.tc.getReadSnapshot()); + let pending = 0; + let failed = 0; + let totalLines = 0; + for (const snap of snapshots) { + if (snap.phase === 'pending') pending += 1; + else if (snap.phase === 'failed') failed += 1; + else totalLines += snap.lines; + } + this.headerText.setText(this.buildHeader(snapshots.length, pending, failed, totalLines)); + + this.bodyContainer.clear(); + if (this.expanded) { + const visibleSnapshots = snapshots.filter( + (snap) => snap.filePath !== undefined && snap.filePath.length > 0, + ); + visibleSnapshots.forEach((snap, idx) => { + const isLast = idx === visibleSnapshots.length - 1; + this.bodyContainer.addChild(new Text(this.buildBodyLine(snap, isLast), 0, 0)); + }); + } + + this.lastFlushPhases.clear(); + this.entries.forEach((entry, i) => { + const snap = snapshots[i]; + if (snap !== undefined) this.lastFlushPhases.set(entry.toolCallId, snap.phase); + }); + + this.invalidate(); + this.ui?.requestRender(); + } + + private buildHeader( + total: number, + pending: number, + failed: number, + totalLines: number, + ): HeaderContent { + if (pending > 0) { + const bullet = currentTheme.fg('text', STATUS_BULLET); + const label = currentTheme.boldFg('primary', `Reading ${String(total)} files…`); + return `${bullet}${label}`; + } + + // All reads have finished, either successfully or with failures. + if (failed === total) { + const bullet = currentTheme.fg('error', '✗ '); + const label = currentTheme.boldFg('error', `Read ${String(total)} files`); + return `${bullet}${label}${currentTheme.fg('error', ' · failed')}`; + } + + // Three segments so a narrow row drops the line count before the failure + // count: with the per-file body hidden while collapsed, that tail is the + // only sign that some of the reads failed. + const bullet = currentTheme.fg('success', STATUS_BULLET); + const label = currentTheme.boldFg('primary', `Read ${String(total)} files`); + return { + head: `${bullet}${label}`, + flex: { + text: ` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`, + style: dimHeaderStyle, + keep: 'head', + }, + tail: failed > 0 ? currentTheme.fg('error', ` · ${String(failed)} failed`) : '', + }; + } + + private buildBodyLine(snap: ToolCallReadSnapshot, isLast: boolean): string { + const dim = (text: string): string => currentTheme.dim(text); + const branch = isLast ? '└─' : '├─'; + const path = snap.filePath ?? ''; + const pathPart = currentTheme.fg('text', path); + + let tail: string; + if (snap.phase === 'pending') { + tail = dim(' · reading…'); + } else if (snap.phase === 'failed') { + tail = currentTheme.fg('error', ' · failed'); + } else { + tail = dim(` · ${String(snap.lines)} ${snap.lines === 1 ? 'line' : 'lines'}`); + } + return ` ${branch} ${pathPart}${tail}`; + } + + override invalidate(): void { + if (this._invalidating) { + super.invalidate(); + return; + } + this._invalidating = true; + this.flushRender(); + this._invalidating = false; + } + + /** Releases throttle timers so destroyed components cannot refresh later. */ + dispose(): void { + if (this.throttleTimer !== null) { + clearTimeout(this.throttleTimer); + this.throttleTimer = null; + } + for (const e of this.entries) { + e.tc.setSnapshotListener(undefined); + } + } +} diff --git a/apps/kimi-code/src/tui/components/messages/shell-execution.ts b/apps/kimi-code/src/tui/components/messages/shell-execution.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e49b97cf91abe35c1920d4992f721464be7ffcc --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -0,0 +1,109 @@ +import type { Component } from '@moonshot-ai/pi-tui'; +import { Container, Text } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; + +import type { ResultRenderer } from './tool-renderers/types'; +import { isSpilledToolOutput, PREVIEW_LINES } from './tool-renderers/types'; +import { outcomeRows } from './tool-renderers/outcome'; +import { TruncatedOutputComponent } from './tool-renderers/truncated'; + +export interface ShellExecutionOptions { + readonly command?: string; + readonly result?: ToolResultBlockData; + readonly expanded?: boolean; + readonly showCommand?: boolean; + /** + * Max command lines to render. `undefined` means no cap — used by the + * ctrl+o expanded view so the user can see the full multi-line command + * even when the header preview was truncated. + */ + readonly commandPreviewLines?: number; + readonly expandHint?: boolean; +} + +export class ShellExecutionComponent extends Container { + constructor(options: ShellExecutionOptions) { + super(); + + if (options.showCommand === true) { + this.addCommandPreview(options.command ?? '', options.commandPreviewLines); + } + + if (options.result !== undefined) { + this.addResultPreview(options.result, options.expanded ?? false, options.expandHint ?? true); + } + } + + private addCommandPreview(command: string, previewLines: number | undefined): void { + if (command.length === 0) return; + const allLines = command.split('\n'); + const lines = previewLines === undefined ? allLines : allLines.slice(0, previewLines); + for (const [i, line] of lines.entries()) { + // Distinguish the command (input) from the result (output): the `$` + // prompt uses the dedicated shell-mode hue, the command body uses + // `textDim`, and the result below is rendered one step dimmer in + // `textMuted` so the two stay separable without a connecting glyph. + const text = + i === 0 + ? currentTheme.fg('shellMode', '$ ') + currentTheme.dim(line) + : ` ${currentTheme.dim(line)}`; + this.addChild(new Text(text, 2, 0)); + } + } + + private addResultPreview( + result: ToolResultBlockData, + expanded: boolean, + expandHint: boolean, + ): void { + if (!result.output) return; + this.addChild( + new TruncatedOutputComponent(result.output, { + expanded, + isError: result.is_error ?? false, + maxLines: PREVIEW_LINES, + expandHint, + color: 'textMuted', + }), + ); + } + + /** Whether the collapsed result preview last cut rows away; drives the footer's ctrl+o hint. */ + wasTruncated(): boolean { + return this.children.some( + (child) => child instanceof TruncatedOutputComponent && child.wasTruncated(), + ); + } +} + +export const shellExecutionResultRenderer: ResultRenderer = ( + _toolCall: ToolCallBlockData, + result: ToolResultBlockData, + ctx, +): Component[] => { + // Collapsed: short output is shown whole; longer output contributes its + // last line (most commands conclude on their last line) and the rest waits + // for ctrl+o. A background or detached start returns a metadata block + // (task_id first, internal next_step/human_shell_hint lines last), so it + // shows its first line to identify the task instead of the trailing hint; + // an oversized result's truncation envelope likewise leads with the line + // that says the output was saved to a file. + // A failing command keeps its multi-line preview so the error is visible. + if (!ctx.expanded && result.is_error !== true) { + const leadsWithMetadata = + result.output.startsWith('task_id:') || isSpilledToolOutput(result.output); + return outcomeRows(result.output, leadsWithMetadata ? 'first' : 'last'); + } + // Result only. The command preview is owned by ToolCallComponent's + // buildCallPreview across the whole lifecycle (streaming, running, and + // done); rendering it here too would duplicate the command once the result + // lands. + return [ + new ShellExecutionComponent({ + result, + expanded: ctx.expanded, + }), + ]; +}; diff --git a/apps/kimi-code/src/tui/components/messages/shell-run.ts b/apps/kimi-code/src/tui/components/messages/shell-run.ts new file mode 100644 index 0000000000000000000000000000000000000000..726a6deaaeb50696073f28b24bd6b80480897d27 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/shell-run.ts @@ -0,0 +1,210 @@ +import { Container, Text } from '@moonshot-ai/pi-tui'; + +import { SHELL_OUTPUT_PREVIEW_LINES } from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; + +import { formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output'; + +import { TruncatedOutputComponent } from './tool-renderers/truncated'; + +const RUNNING_TAIL_LINES = 5; +const TIMER_INTERVAL_MS = 1000; +// Cap the live running buffer so a command that spews output for minutes can't +// grow memory without bound or make every render re-strip a multi-MB string. +// Only affects the transient running tail; the final view uses the full +// captured stdout/stderr passed to finish(). When the cap drops older output, +// the expanded running view says so via TRUNCATED_RUNNING_NOTICE. +const MAX_COMBINED_CHARS = 256 * 1024; +const KEEP_COMBINED_CHARS = 64 * 1024; + +const TRUNCATED_RUNNING_NOTICE = '… (output truncated)'; + +/** + * Live view for a user-initiated `!` shell command. Two phases: + * + * - running: dim, ANSI-stripped tail of the combined output (the last + * RUNNING_TAIL_LINES lines, or the whole buffer when expanded via + * ctrl+o), a `+N lines` overflow marker, an elapsed `(Xs)` timer that + * ticks every second, and a `(ctrl+b to run in background)` hint — + * matching claude-code's running card so warnings are grey rather than + * red while the command works. + * - finished: the standard `formatBashOutputForDisplay` view (stderr red only + * on failure) through the shared TruncatedOutputComponent — collapsed to + * the first SHELL_OUTPUT_PREVIEW_LINES visual rows, expanded to the full + * output by the global ctrl+o toggle. + * + * Hardened so a misbehaving command can never crash the TUI: the running + * buffer is capped, and every render/render-request path swallows errors. + */ +export class ShellRunComponent extends Container { + private readonly textComponent: Text; + private finalOutput = ''; + private combined = ''; + private combinedTruncated = false; + private running = true; + private backgrounded = false; + private disposed = false; + private expanded = false; + // Whether the collapsed running tail leaves rows (or a capped buffer) behind; refreshed by renderText(). + private runningHidesRows = false; + private readonly startedAt = Date.now(); + private timer: ReturnType<typeof setInterval> | undefined; + + constructor(private readonly requestRender: () => void) { + super(); + this.textComponent = new Text(this.renderText(), 0, 0); + this.addChild(this.textComponent); + this.timer = setInterval(() => this.tick(), TIMER_INTERVAL_MS); + } + + append(text: string): void { + if (this.disposed || !this.running || text.length === 0) return; + this.combined += text; + if (this.combined.length > MAX_COMBINED_CHARS) { + this.combined = this.combined.slice(-KEEP_COMBINED_CHARS); + this.combinedTruncated = true; + } + this.flush(); + } + + finish(stdout: string, stderr: string, isError?: boolean): void { + if (this.disposed || !this.running) return; + this.running = false; + this.clearTimer(); + this.finalOutput = formatBashOutputForDisplay(stdout, stderr, isError); + this.rebuildResult(); + this.flush(); + } + + /** + * Whether ctrl+o would change the card: a running tail with earlier rows + * (or a capped buffer) behind it, or a finished preview cut to its row cap. + * Drives the footer's ctrl+o hint. + */ + isExpanded(): boolean { + return this.expanded; + } + + hasHiddenContent(): boolean { + if (this.disposed || this.backgrounded) return false; + if (this.running) return this.runningHidesRows; + // More physical lines than the collapsed cap is hidden for certain, even + // for a card that finished while already expanded; a wrapped overflow + // shows up once a collapsed render has recorded it. + return ( + this.finalOutput.split('\n').length > SHELL_OUTPUT_PREVIEW_LINES || + this.children.some( + (child) => child instanceof TruncatedOutputComponent && child.wasTruncated(), + ) + ); + } + + finishBackgrounded(): void { + if (this.disposed || !this.running) return; + this.running = false; + this.backgrounded = true; + this.clearTimer(); + this.flush(); + } + + dispose(): void { + this.disposed = true; + this.clearTimer(); + } + + setExpanded(expanded: boolean): void { + if (this.disposed || this.expanded === expanded) return; + this.expanded = expanded; + // Running and backgrounded views re-render in place; only a finished + // card rebuilds its result component with the new state. + if (this.running || this.backgrounded) { + this.flush(); + return; + } + this.rebuildResult(); + this.flush(); + } + + // Rebuild-on-toggle, mirroring ToolCallComponent: the result component is + // immutable, so a new expansion state means a new component instance. + private rebuildResult(): void { + try { + // Build before clearing: if the constructor throws, the old view stays. + const next = new TruncatedOutputComponent(this.finalOutput, { + expanded: this.expanded, + // The stream colours are already baked into the formatted text, so + // the component must not re-colour the whole block as an error. + isError: false, + maxLines: SHELL_OUTPUT_PREVIEW_LINES, + expandHint: true, + }); + this.clear(); + this.addChild(next); + } catch { + // finish() runs in a promise continuation and setExpanded() in a key + // handler — an escaping error would surface as an unhandled rejection + // or take down the TUI. + } + } + + private tick(): void { + if (!this.running) return; + this.flush(); + } + + private flush(): void { + if (this.disposed) return; + try { + if (this.running || this.backgrounded) { + this.textComponent.setText(this.renderText()); + } + this.requestRender(); + } catch { + // Never let a render/render-request error escape into a timer or event + // handler — an uncaught exception there can take down the whole TUI. + } + } + + private clearTimer(): void { + if (this.timer !== undefined) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + private renderText(): string { + try { + if (this.backgrounded) { + return ` ${currentTheme.fg('textDim', 'Moved to background.')}`; + } + const elapsed = Math.floor((Date.now() - this.startedAt) / 1000); + const dim = (s: string): string => currentTheme.fg('textDim', s); + const trimmed = sanitizeShellOutput(this.combined).trimEnd(); + const lineCount = trimmed.length === 0 ? 0 : trimmed.split('\n').length; + this.runningHidesRows = this.combinedTruncated || lineCount > RUNNING_TAIL_LINES; + let body: string; + let extra = 0; + if (trimmed.length === 0) { + body = ` ${dim('Running…')}`; + } else if (this.expanded) { + const notice = this.combinedTruncated ? ` ${dim(TRUNCATED_RUNNING_NOTICE)}\n` : ''; + body = + notice + + trimmed + .split('\n') + .map((line) => ` ${dim(line)}`) + .join('\n'); + } else { + const lines = trimmed.split('\n'); + const tail = lines.slice(-RUNNING_TAIL_LINES); + extra = Math.max(0, lines.length - RUNNING_TAIL_LINES); + body = tail.map((line) => ` ${dim(line)}`).join('\n'); + } + const timing = ` ${dim(`${extra > 0 ? `+${extra} lines ` : ''}(${elapsed}s)`)}`; + const hint = ` ${dim('(ctrl+b to run in background)')}`; + return `${body}\n${timing}\n${hint}`; + } catch { + return ' (output unavailable)'; + } + } +} diff --git a/apps/kimi-code/src/tui/components/messages/skill-activation.ts b/apps/kimi-code/src/tui/components/messages/skill-activation.ts new file mode 100644 index 0000000000000000000000000000000000000000..f977d21d92d8eb07887e9418c0fee6b90ce243eb --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/skill-activation.ts @@ -0,0 +1,64 @@ +/** + * Skill activation card. + * + * When the user runs `/skill:foo bar`, the TUI renders a compact card instead + * of expanding the SKILL.md body into the user bubble: + * + * ▶ Activated skill: foo + * bar + * + * The args line is optional. Core expands the skill body into the LLM context; + * the TUI only consumes the `skill.activated` event and user_message origin + * metadata. + */ + +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import type { SkillActivationTrigger } from '#/tui/types'; + +const ARGS_PREVIEW_MAX = 200; + +export class SkillActivationComponent extends Container { + private headText: Text; + private previewText?: Text; + private name: string; + private args?: string; + + constructor( + name: string, + args: string | undefined, + readonly trigger?: SkillActivationTrigger, + ) { + super(); + this.name = name; + this.args = args; + this.addChild(new Spacer(1)); + const head = + currentTheme.boldFg('primary', '▶ Activated skill: ') + + currentTheme.boldFg('roleUser', name); + this.headText = new Text(head, 0, 0); + this.addChild(this.headText); + const trimmed = args?.trim() ?? ''; + if (trimmed.length > 0) { + const preview = + trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; + this.previewText = new Text(' ' + currentTheme.fg('textDim', preview), 0, 0); + this.addChild(this.previewText); + } + } + + override invalidate(): void { + const head = + currentTheme.boldFg('primary', '▶ Activated skill: ') + + currentTheme.boldFg('roleUser', this.name); + this.headText.setText(head); + if (this.previewText !== undefined && this.args !== undefined) { + const trimmed = this.args.trim(); + const preview = + trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; + this.previewText.setText(' ' + currentTheme.fg('textDim', preview)); + } + super.invalidate(); + } +} diff --git a/apps/kimi-code/src/tui/components/messages/status-message.ts b/apps/kimi-code/src/tui/components/messages/status-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a0523f58f0ee14fd427d4606a2d84a98ac5b07a --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/status-message.ts @@ -0,0 +1,79 @@ +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; + +export class StatusMessageComponent extends Container { + private textComponent: Text; + private content: string; + private color?: ColorToken; + + constructor(content: string, color?: ColorToken) { + super(); + this.content = content; + this.color = color; + this.textComponent = new Text(this.renderText(), 0, 0); + this.addChild(this.textComponent); + } + + // Update the body in place (used for live-streamed `!` shell output) without + // remounting the component. + updateContent(content: string): void { + this.content = content; + this.textComponent.setText(this.renderText()); + } + + override invalidate(): void { + this.textComponent.setText(this.renderText()); + super.invalidate(); + } + + // Indent every line, not just the first. The `content` may be multi-line + // (e.g. `!` shell output); prefixing the whole string once would only indent + // the first line and leave the rest at column 0. Strip carriage returns + // first: a trailing `\r` (e.g. from CRLF server error pages) is zero-width + // for the line wrapper, so the padding spaces appended after it overwrite + // the visible content and the line renders blank. + private renderText(): string { + const colored = + this.color === undefined + ? currentTheme.fg('textDim', this.content) + : currentTheme.fg(this.color, this.content); + return colored.replaceAll('\r', '').split('\n').map((line) => ` ${line}`).join('\n'); + } +} + +export class NoticeMessageComponent extends Container { + private titleText: Text; + private detailText?: Text; + private title: string; + private detail?: string; + + constructor(title: string, detail: string | undefined) { + super(); + this.title = title; + this.detail = detail; + this.addChild(new Spacer(1)); + this.titleText = new Text(` ${currentTheme.fg('textStrong', title)}`, 0, 0); + this.addChild(this.titleText); + if (detail !== undefined && detail.length > 0) { + this.detailText = new Text(this.renderDetail(detail), 0, 0); + this.addChild(this.detailText); + } + } + + override invalidate(): void { + this.titleText.setText(` ${currentTheme.fg('textStrong', this.title)}`); + if (this.detailText !== undefined && this.detail !== undefined) { + this.detailText.setText(this.renderDetail(this.detail)); + } + super.invalidate(); + } + + // Indent every line, not just the first. The `detail` may be multi-line; + // prefixing the whole string once would only indent the first line and leave + // the rest at column 0 (same handling as StatusMessageComponent). + private renderDetail(detail: string): string { + return currentTheme.fg('textDim', detail).split('\n').map((line) => ` ${line}`).join('\n'); + } +} diff --git a/apps/kimi-code/src/tui/components/messages/status-panel.ts b/apps/kimi-code/src/tui/components/messages/status-panel.ts new file mode 100644 index 0000000000000000000000000000000000000000..08058fe87daef5fd048d89c343dc401577bd82f7 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/status-panel.ts @@ -0,0 +1,173 @@ +/** + * Status report line builder for `/status`. + * + * It mirrors `/usage` visual language but keeps runtime status formatting + * separate from the TUI orchestration layer. + */ + +import { + effectiveModelAlias, + type ModelAlias, + type PermissionMode, + type SessionStatus, + type ThinkingEffort, +} from '@moonshot-ai/kimi-code-sdk'; + +import { PRODUCT_NAME } from '#/constant/app'; +import { currentTheme } from '#/tui/theme'; +import { PERMISSION_MODE_DISPLAY_NAMES } from '#/tui/utils/permission-mode'; +import { + formatTokenCount, + ratioSeverity, + renderProgressBar, + safeUsageRatio, + usagePercent, +} from '#/utils/usage/usage-format'; + +import { + buildExtraUsageSection, + buildManagedUsageReportLines, + type ManagedUsageReport, +} from './usage-panel'; + +interface FieldRow { + readonly label: string; + readonly value: string; + readonly severity?: 'error'; +} + +export interface StatusReportOptions { + readonly version: string; + readonly model: string; + readonly workDir: string; + readonly sessionId: string; + readonly sessionTitle: string | null; + readonly thinkingEffort: ThinkingEffort; + readonly permissionMode: PermissionMode; + readonly planMode: boolean; + readonly towerMode: boolean; + /** Whether the tower experiment is enabled on engine v2 — gates the Tower mode row. */ + readonly towerAvailable: boolean; + readonly contextUsage: number; + readonly contextTokens: number; + readonly maxContextTokens: number; + readonly availableModels: Record<string, ModelAlias>; + readonly status?: SessionStatus; + readonly statusError?: string; + readonly managedUsage?: ManagedUsageReport; + readonly managedUsageError?: string; +} + +type Colorize = (text: string) => string; + +function displayModelName(alias: string, models: Record<string, ModelAlias>): string { + const model = models[alias]; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.displayName ?? effective?.model ?? alias; +} + +function formatModelStatus(options: StatusReportOptions): string { + const model = options.status?.model ?? options.model; + if (model.trim().length === 0) return 'not set'; + + const effort = options.status?.thinkingEffort ?? options.thinkingEffort; + return `${displayModelName(model, options.availableModels)} (thinking ${effort})`; +} + +function addFieldRows( + lines: string[], + rows: readonly FieldRow[], + muted: Colorize, + value: Colorize, + errorStyle: Colorize, +): void { + const labelWidth = Math.max(10, ...rows.map((row) => row.label.length)); + for (const row of rows) { + const colorize = row.severity === 'error' ? errorStyle : value; + lines.push(` ${muted(row.label.padEnd(labelWidth, ' '))} ${colorize(row.value)}`); + } +} + +function contextValues(options: StatusReportOptions): { + ratio: number; + tokens: number; + maxTokens: number; +} { + return { + ratio: options.status?.contextUsage ?? options.contextUsage, + tokens: options.status?.contextTokens ?? options.contextTokens, + maxTokens: options.status?.maxContextTokens ?? options.maxContextTokens, + }; +} + +export function buildStatusReportLines(options: StatusReportOptions): string[] { + const accent = (text: string) => currentTheme.boldFg('primary', text); + const value = (text: string) => currentTheme.fg('text', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); + const severityToken = (sev: 'ok' | 'warn' | 'danger'): 'error' | 'warning' | 'success' => + sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; + + const permission = options.status?.permission ?? options.permissionMode; + const planMode = options.status?.planMode ?? options.planMode; + const towerMode = options.status?.towerMode ?? options.towerMode; + const sessionId = options.sessionId.trim().length > 0 ? options.sessionId : 'none'; + const rows: FieldRow[] = [ + { label: 'Model', value: formatModelStatus(options) }, + { label: 'Directory', value: options.workDir }, + { label: 'Permissions', value: PERMISSION_MODE_DISPLAY_NAMES[permission] }, + { label: 'Plan mode', value: planMode ? 'on' : 'off' }, + ]; + if (options.towerAvailable) { + rows.push({ label: 'Tower mode', value: towerMode ? 'on' : 'off' }); + } + rows.push({ label: 'Session', value: sessionId }); + const title = options.sessionTitle?.trim(); + if (title !== undefined && title.length > 0) rows.push({ label: 'Title', value: title }); + if (options.statusError !== undefined) { + rows.push({ label: 'Warning', value: options.statusError, severity: 'error' }); + } + + const lines: string[] = [ + `${accent(`>_ ${PRODUCT_NAME}`)} ${muted(`(v${options.version})`)}`, + '', + ]; + addFieldRows(lines, rows, muted, value, errorStyle); + + const { ratio, tokens, maxTokens } = contextValues(options); + lines.push(''); + lines.push(accent('Context window')); + if (maxTokens > 0) { + const safeRatio = safeUsageRatio(ratio); + const bar = renderProgressBar(safeRatio, 20); + const barColoured = currentTheme.fg(severityToken(ratioSeverity(safeRatio)), bar); + lines.push( + ` ${barColoured} ${value(`${String(usagePercent(tokens, maxTokens))}%`.padStart(6, ' '))} ` + + muted(`(${formatTokenCount(tokens)} / ${formatTokenCount(maxTokens)})`), + ); + } else { + lines.push(` ${muted('No context window data available.')}`); + } + + const managedSection = buildManagedUsageReportLines({ + managedUsage: options.managedUsage, + managedUsageError: options.managedUsageError, + }); + if (managedSection.length > 0) { + lines.push(''); + lines.push(...managedSection); + } + + const extraSection = buildExtraUsageSection( + options.managedUsage?.extraUsage, + accent, + value, + muted, + ); + if (extraSection.length > 0) { + lines.push(''); + lines.push(...extraSection); + } + + return lines; +} diff --git a/apps/kimi-code/src/tui/components/messages/step-summary.ts b/apps/kimi-code/src/tui/components/messages/step-summary.ts new file mode 100644 index 0000000000000000000000000000000000000000..62d5add2f76073b33a5f7c6bde31885b92ba0bf7 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/step-summary.ts @@ -0,0 +1,36 @@ +import type { Component } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +/** + * A collapsed summary of older content within a turn. Accumulates counts of + * merged steps (thinking blocks and tool calls) and folded assistant messages, + * rendering them as a single muted line, e.g. + * `… thinking 5 times, call 50 tools, 12 messages`. + */ +export class StepSummaryComponent implements Component { + private thinking = 0; + private tool = 0; + private message = 0; + + get isEmpty(): boolean { + return this.thinking === 0 && this.tool === 0 && this.message === 0; + } + + addCounts(thinking: number, tool: number, message = 0): void { + this.thinking += thinking; + this.tool += tool; + this.message += message; + } + + invalidate(): void {} + + render(_width: number): string[] { + const parts: string[] = []; + if (this.thinking > 0) parts.push(`thinking ${this.thinking} times`); + if (this.tool > 0) parts.push(`call ${this.tool} tools`); + if (this.message > 0) parts.push(`${this.message} messages`); + if (parts.length === 0) return []; + return [currentTheme.dim(`\u2026 ${parts.join(', ')}`)]; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts new file mode 100644 index 0000000000000000000000000000000000000000..4fee9629fe65b3e633b8bc1655e9cd256a1153bc --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts @@ -0,0 +1,33 @@ +import { truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; + +export type SwarmModeMarkerState = 'active' | 'inactive' | 'ended'; + +export class SwarmModeMarkerComponent implements Component { + constructor(private readonly state: SwarmModeMarkerState) {} + + invalidate(): void {} + + render(width: number): string[] { + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + const token = this.state === 'inactive' ? 'textDim' : 'success'; + const marker = currentTheme.boldFg(token, STATUS_BULLET); + const label = currentTheme.boldFg(token, swarmMarkerLabel(this.state)); + return ['', truncateToWidth(marker + label, safeWidth, '…')]; + } +} + +function swarmMarkerLabel(state: SwarmModeMarkerState): string { + switch (state) { + case 'active': + return 'Swarm activated'; + case 'inactive': + return 'Swarm deactivated'; + case 'ended': + return 'Swarm ended'; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/thinking.ts b/apps/kimi-code/src/tui/components/messages/thinking.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1208b55fe4730d3d2e40d0a47f6625fec1594ef --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -0,0 +1,160 @@ +/** + * Renders thinking content in the transcript. + * Supports live in-place updates while thinking streams, then finalizes + * without replacing the component. + * Supports expand/collapse via Ctrl+O (shared with tool output). + */ + +import { Text, truncateToWidth, type Component, type TUI } from '@moonshot-ai/pi-tui'; + +import { + BRAILLE_SPINNER_FRAMES, + BRAILLE_SPINNER_INTERVAL_MS, + MESSAGE_INDENT, + THINKING_PREVIEW_LINES, +} from '#/tui/constant/rendering'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; + +export type ThinkingRenderMode = 'live' | 'finalized'; + +export class ThinkingComponent implements Component { + private text: string; + private showMarker: boolean; + private mode: ThinkingRenderMode; + private expanded = false; + private readonly ui: TUI | undefined; + private spinnerFrame = 0; + private spinnerInterval: ReturnType<typeof setInterval> | undefined; + // Hold a single Text instance so pi-tui's (text, width) → lines cache + // actually survives across renders. Re-constructing per render destroys + // the cache and forces full re-wrap on every frame, which dominates CPU + // once the transcript accumulates many finalized thinking blocks. + private readonly textComponent: Text; + + private renderCache: { width: number; lines: string[] } | undefined; + + constructor( + text: string, + showMarker: boolean = true, + mode: ThinkingRenderMode = 'finalized', + ui?: TUI, + ) { + this.text = text; + this.showMarker = showMarker; + this.mode = mode; + this.ui = ui; + this.textComponent = new Text(this.styled(text), 0, 0); + if (mode === 'live') { + this.startSpinner(); + } + } + + private markRenderDirty(): void { + this.renderCache = undefined; + } + + invalidate(): void { + this.markRenderDirty(); + this.textComponent.setText(this.styled(this.text)); + } + + setText(text: string): void { + if (this.text === text) return; + this.text = text; + this.markRenderDirty(); + this.textComponent.setText(this.styled(text)); + } + + private styled(text: string): string { + return currentTheme.italicFg('textDim', text); + } + + finalize(): void { + this.mode = 'finalized'; + this.markRenderDirty(); + this.stopSpinner(); + } + + dispose(): void { + this.stopSpinner(); + } + + setExpanded(expanded: boolean): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + this.markRenderDirty(); + } + + render(width: number): string[] { + if ( + isRenderCacheEnabled() && + this.renderCache !== undefined && + this.renderCache.width === width + ) { + return this.renderCache.lines; + } + + const contentWidth = Math.max(1, width - MESSAGE_INDENT.length); + const contentLines = this.text.length > 0 ? this.textComponent.render(contentWidth) : ['']; + + let rendered: string[]; + if (this.mode === 'live') { + const visibleLines = + contentLines.length > THINKING_PREVIEW_LINES + ? contentLines.slice(contentLines.length - THINKING_PREVIEW_LINES) + : contentLines; + const spinner = currentTheme.fg( + 'textDim', + `${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `, + ); + rendered = [ + '', + spinner + currentTheme.fg('textDim', 'thinking…'), + ...visibleLines.map((line) => MESSAGE_INDENT + line), + ]; + } else { + const lines: string[] = ['']; + for (let i = 0; i < contentLines.length; i++) { + const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT; + lines.push(p + contentLines[i]); + } + + if (this.expanded || contentLines.length <= THINKING_PREVIEW_LINES) { + rendered = lines; + } else { + // Leading blank + first PREVIEW_LINES content lines + hint line. + const truncated = lines.slice(0, 1 + THINKING_PREVIEW_LINES); + const remaining = contentLines.length - THINKING_PREVIEW_LINES; + const hint = `… (${String(remaining)} more lines, ctrl+o to expand)`; + const indentWidth = Math.min(MESSAGE_INDENT.length, Math.max(0, width)); + const hintWidth = Math.max(0, width - indentWidth); + truncated.push( + ' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')), + ); + rendered = truncated; + } + } + + if (isRenderCacheEnabled()) { + this.renderCache = { width, lines: rendered }; + } + return rendered; + } + + private startSpinner(): void { + if (this.ui === undefined || this.spinnerInterval !== undefined) return; + this.spinnerInterval = setInterval(() => { + this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; + this.markRenderDirty(); + this.ui?.requestRender(); + }, BRAILLE_SPINNER_INTERVAL_MS); + } + + private stopSpinner(): void { + if (this.spinnerInterval === undefined) return; + clearInterval(this.spinnerInterval); + this.spinnerInterval = undefined; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts new file mode 100644 index 0000000000000000000000000000000000000000..12bd465b04ddf3ca9a3be3831948efebb0e028df --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -0,0 +1,2661 @@ +/** + * Renders a tool call entry in the transcript. + * Supports expand/collapse via Ctrl+O. + */ + +import { isAbsolute, relative, sep } from 'node:path'; + +import { Container, Markdown, Spacer, Text, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; +import type { Component, TUI } from '@moonshot-ai/pi-tui'; +import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; +import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; +import { + BRAILLE_SPINNER_FRAMES, + BRAILLE_SPINNER_INTERVAL_MS, + COMMAND_PREVIEW_LINES, + OUTCOME_MAX_LINES, + RESULT_PREVIEW_LINES, + THINKING_PREVIEW_LINES, +} from '#/tui/constant/rendering'; +import { + STREAMING_ARGS_FIELD_RE, + STREAMING_ARGS_PREVIEW_MAX_CHARS, +} from '#/tui/constant/streaming'; +import { FAILURE_MARK, STATUS_BULLET, SUCCESS_MARK } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; +import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk'; +import { appendStreamingArgsPreview } from '#/tui/utils/event-payload'; +import { createMarkdownOptions } from '#/tui/utils/markdown-options'; +import { notifyResultState } from '#/tui/utils/notify-result'; +import { isExperimentalFlagEnabled } from '#/tui/commands/experimental-flags'; +import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; +import { formatTokenCount } from '#/utils/usage/usage-format'; + +import { agentSwarmResultSummaryFromOutput } from './agent-swarm-progress'; +import { PlanBoxComponent } from './plan-box'; +import { TruncatedHeaderLine, type HeaderContent } from './truncated-header-line'; +import { ShellExecutionComponent } from './shell-execution'; +import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; +import { buildGoalToolHeader, parseGoalToolOutput } from './tool-renderers/goal'; +import { searchNoticeOnly } from './tool-renderers/grep-output'; +import { parseReadMediaOutput } from './tool-renderers/media'; +import { computeWriteStats } from './tool-renderers/chip'; +import { nonEmptyLines, outcomeLine } from './tool-renderers/outcome'; +import { TruncatedOutputComponent } from './tool-renderers/truncated'; +import { isSpilledToolOutput } from './tool-renderers/types'; +import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry'; +import { buildWaitForHeader, parseWaitForOutput } from './tool-renderers/wait-for'; + +const MAX_ARG_LENGTH = 60; +const MAX_SUB_TOOL_CALLS_SHOWN = 4; +// Cap the Agent `description` in the single-subagent header so a long prompt +// cannot wrap the header onto a second row and break the card's stable height. +const MAX_SUBAGENT_DESCRIPTION_LENGTH = 60; +const APPROVED_PLAN_MARKER = '## Approved Plan:'; +const AUTO_APPROVED_PLAN_MARKER = '## Plan (auto-approved, not user-reviewed):'; +const STREAMING_PROGRESS_INTERVAL_MS = 1000; +const PROGRESS_URL_RE = /https?:\/\/\S+/g; +const ABORTED_MARK = '⊘'; +const MAX_LIVE_OUTPUT_CHARS = 50_000; +// One shared reference: the header line compares segment styles by identity +// to keep its render cache across rebuilds, and the palette is read at call +// time so theme switches still apply. +const dimHeaderStyle = (text: string): string => currentTheme.dim(text); + +/** Delay before a long-running foreground Bash/Agent card advertises Ctrl+B. */ +const DETACH_HINT_DELAY_MS = 10_000; +const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background'; + +type SubagentTextKind = 'thinking' | 'text'; +type SubagentPhase = 'queued' | 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded'; + +interface FinishedSubCall { + readonly name: string; + readonly args: Record<string, unknown>; + readonly output: string; + readonly isError: boolean; +} + +interface OngoingSubCall { + readonly name: string; + readonly args: Record<string, unknown>; + readonly streamingArguments?: string | undefined; +} + +interface SubToolActivity { + readonly id: string; + name: string; + args: Record<string, unknown>; + phase: 'ongoing' | 'done' | 'failed'; + output?: string; + readonly orderSeq: number; +} + +/** + * Immutable subagent state snapshot. `AgentGroupComponent` reads one-time + * views via `ToolCallComponent.getSubagentSnapshot()` and renders its own + * branch lines; `onSnapshotChange` notifies it when state changes. + * + * `latestActivity` priority, used only while running: + * 1. latest ongoing sub-tool (`Using {name} ({keyArg})`) + * 2. latest finished sub-tool (`Used {name} ({keyArg})`) + * 3. last non-empty line from accumulated subagent text + */ +export interface ToolCallSubagentSnapshot { + readonly toolCallId: string; + readonly toolName: string; + readonly toolCallDescription: string; + readonly agentName: string | undefined; + /** Display name of the model the subagent is bound to, when known (live only). */ + readonly model?: string; + /** Thinking effort, present only for concrete levels (on/off hidden). */ + readonly effort?: string; + readonly phase: SubagentPhase | undefined; + readonly toolCount: number; + readonly elapsedSeconds: number | undefined; + readonly tokens: number; + readonly isError: boolean; + readonly errorText: string | undefined; + readonly latestActivity: string | undefined; +} + +/** + * Immutable Read tool state snapshot. `ReadGroupComponent` reads one-time + * views via `ToolCallComponent.getReadSnapshot()` and sums lines for the group + * header. `lines` is 0 while pending or failed, and the non-empty result line + * count when done, matching the single-card chip. + */ +export interface ToolCallReadSnapshot { + readonly toolCallId: string; + readonly filePath: string | undefined; + readonly phase: 'pending' | 'done' | 'failed'; + readonly lines: number; +} + +function backgroundFailureMessage( + status: 'completed' | 'failed' | 'timed_out' | 'killed' | 'lost' | undefined, +): string | undefined { + switch (status) { + case 'lost': + return 'Background agent lost (session restarted before completion)'; + case 'killed': + return 'Background agent killed'; + case 'timed_out': + return 'Background agent timed out'; + case 'failed': + return 'Background agent failed'; + case 'completed': + case undefined: + return undefined; + } +} + +function str(v: unknown): string { + return typeof v === 'string' ? v : ''; +} + +function formatSubagentContextTokens(contextTokens: number | undefined): string | undefined { + if (contextTokens === undefined || contextTokens <= 0) return undefined; + return `${formatTokenCount(contextTokens)} tok`; +} + +function usageInputTotal(usage: TokenUsage): number { + return (usage.inputOther ?? 0) + (usage.inputCacheRead ?? 0) + (usage.inputCacheCreation ?? 0); +} + +function usageTotal(usage: TokenUsage | undefined): number { + if (usage === undefined) return 0; + return usageInputTotal(usage) + usage.output; +} + +function formatSubagentTokens(usage: TokenUsage | undefined): string | undefined { + const total = usageTotal(usage); + if (total <= 0) return undefined; + return `${formatTokenCount(total)} tok`; +} + +function formatByteSize(bytes: number): string { + if (bytes < 1024) return `${String(bytes)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function formatElapsed(seconds: number): string { + if (seconds < 60) return `${String(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return `${String(minutes)}m ${String(remainder)}s`; +} + +function extractApprovedPlan(output: string): string { + const marker = output.includes(AUTO_APPROVED_PLAN_MARKER) + ? AUTO_APPROVED_PLAN_MARKER + : APPROVED_PLAN_MARKER; + const markerIndex = output.indexOf(marker); + if (markerIndex < 0) return ''; + return output.slice(markerIndex + marker.length).trim(); +} + +interface ExitPlanModeOutcome { + readonly kind: 'approved' | 'auto_approved' | 'rejected'; + readonly chosen?: string; + readonly feedback?: string; + readonly path?: string; +} + +const REJECT_PREFIX = 'User rejected the plan.'; +const REJECT_FEEDBACK_PREFIX = 'User rejected the plan. Feedback:'; +const APPROVED_OPTION_RE = /^User approved option "([^"]+)"\./; +const PLAN_REJECT_PREFIX = 'Plan rejected by user.'; +const SELECTED_APPROACH_RE = /^Exited plan mode\. Selected approach: ([^\n]+)\n/; +const PLAN_SAVED_TO_RE = /\nPlan saved to: ([^\n]+)\n/; + +/** + * Parses the ExitPlanMode result content string to recover the approval outcome + * and optional plan path. Core-side templates live in + * `packages/agent-core-v2/src/features/plan/tools/exit-plan-mode/exitPlanModeTool.ts` and + * `packages/agent-core-v2/src/features/plan/exitPlanModeReview.ts`: + * - Approved output starts with 'Exited plan mode.' and selected options + * are reported as 'Selected approach: <label>'. Older outputs may start + * with 'User approved option "<label>".' Plan-file mode may include + * 'Plan saved to: <path>'. + * - Auto-approved output (auto permission mode skips the review ask) also + * starts with 'Exited plan mode.' but marks the plan body with + * '## Plan (auto-approved, not user-reviewed):' instead of + * '## Approved Plan:' — the user never saw or approved the plan. + * - Rejected output starts with 'Plan rejected by user.' or older + * 'User rejected the plan.'; feedback uses 'User rejected the plan. + * Feedback:\n\n<text>'. + * This is a string protocol rather than a structured payload. Prefer a + * structured event payload if core starts emitting one. + */ +function interpretExitPlanModeOutcome(output: string): ExitPlanModeOutcome { + if (output.startsWith(REJECT_PREFIX)) { + if (output.startsWith(REJECT_FEEDBACK_PREFIX)) { + const feedback = output.slice(REJECT_FEEDBACK_PREFIX.length).trimStart(); + return { kind: 'rejected', feedback }; + } + return { kind: 'rejected' }; + } + if (output.startsWith(PLAN_REJECT_PREFIX)) { + return { kind: 'rejected' }; + } + const pathMatch = PLAN_SAVED_TO_RE.exec(output); + const path = pathMatch?.[1]?.trim(); + if (output.includes(AUTO_APPROVED_PLAN_MARKER)) { + return path !== undefined && path.length > 0 + ? { kind: 'auto_approved', path } + : { kind: 'auto_approved' }; + } + const optionMatch = SELECTED_APPROACH_RE.exec(output) ?? APPROVED_OPTION_RE.exec(output); + if (optionMatch !== null) { + return path !== undefined && path.length > 0 + ? { kind: 'approved', chosen: optionMatch[1], path } + : { kind: 'approved', chosen: optionMatch[1] }; + } + return path !== undefined && path.length > 0 ? { kind: 'approved', path } : { kind: 'approved' }; +} + +function isExitPlanModeOutcomeOutput(output: string): boolean { + return ( + output.startsWith(REJECT_PREFIX) || + output.startsWith(PLAN_REJECT_PREFIX) || + output.startsWith('Exited plan mode.') || + APPROVED_OPTION_RE.test(output) || + output.includes(APPROVED_PLAN_MARKER) || + output.includes(AUTO_APPROVED_PLAN_MARKER) + ); +} + +function unescapeJsonString(s: string): string { + return s.replaceAll(/\\(["\\/bfnrt])/g, (_, ch: string) => { + switch (ch) { + case 'n': + return '\n'; + case 't': + return '\t'; + case 'r': + return '\r'; + case 'b': + return '\b'; + case 'f': + return '\f'; + case '"': + return '"'; + case '\\': + return '\\'; + case '/': + return '/'; + default: + return ch; + } + }); +} + +/** + * Pull the live value of a JSON string field out of partially-streamed + * arguments, even if the closing quote hasn't arrived yet. Handles the + * common JSON string escapes so `\n` in a streamed `content` becomes a + * real newline we can highlight. Returns `undefined` if the field hasn't + * started streaming yet. + */ +export function extractPartialStringField(text: string, key: string): string | undefined { + const opener = new RegExp(`"${key}"\\s*:\\s*"`); + const match = opener.exec(text); + if (match === null) return undefined; + const start = match.index + match[0].length; + let out = ''; + let i = start; + while (i < text.length) { + const ch = text[i]; + if (ch === '\\') { + const next = text[i + 1]; + if (next === undefined) return out; + switch (next) { + case 'n': + out += '\n'; + break; + case 't': + out += '\t'; + break; + case 'r': + out += '\r'; + break; + case 'b': + out += '\b'; + break; + case 'f': + out += '\f'; + break; + case '"': + out += '"'; + break; + case '\\': + out += '\\'; + break; + case '/': + out += '/'; + break; + case 'u': { + if (i + 5 >= text.length) return out; + const hex = text.slice(i + 2, i + 6); + const code = Number.parseInt(hex, 16); + if (Number.isNaN(code)) return out; + out += String.fromCodePoint(code); + i += 6; + continue; + } + default: + out += next; + } + i += 2; + continue; + } + if (ch === '"') return out; + out += ch; + i++; + } + return out; +} + +function parseArgsPreview(value: string): Record<string, unknown> { + const previewText = value.slice(0, STREAMING_ARGS_PREVIEW_MAX_CHARS); + if (previewText.trim().length === 0) return {}; + if ( + value.length <= STREAMING_ARGS_PREVIEW_MAX_CHARS && + previewText.trimEnd().endsWith('}') + ) { + try { + const parsed = JSON.parse(previewText) as unknown; + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record<string, unknown>; + } + } catch { + // fall through to partial scan + } + } + const result: Record<string, unknown> = {}; + for (const match of previewText.matchAll(STREAMING_ARGS_FIELD_RE)) { + const key = match[1]; + const rawValue = match[2]; + if (key === undefined || rawValue === undefined) continue; + if (!(key in result)) result[key] = unescapeJsonString(rawValue); + } + return result; +} + +const PATH_KEYS = new Set(['path', 'file_path']); + +function truncateArgValue(key: string, value: string): string { + if (value.length <= MAX_ARG_LENGTH) return value; + if (PATH_KEYS.has(key)) { + // Preserve the tail (filename) — drop the prefix so the user can + // still tell which file is being touched. + return '…' + value.slice(value.length - (MAX_ARG_LENGTH - 1)); + } + return value.slice(0, MAX_ARG_LENGTH - 1) + '…'; +} + +function makeWorkspaceRelativePath(filePath: string, workspaceDir: string | undefined): string { + if (workspaceDir === undefined || workspaceDir.length === 0 || !isAbsolute(filePath)) { + return filePath; + } + const relativePath = relative(workspaceDir, filePath); + if ( + relativePath.length === 0 || + relativePath === '..' || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + return filePath; + } + return relativePath; +} + +function displayKeyArgument( + toolName: string, + key: string, + value: string, + workspaceDir: string | undefined, +): string { + return toolName === 'Read' && PATH_KEYS.has(key) + ? makeWorkspaceRelativePath(value, workspaceDir) + : value; +} + +/** + * The header's key argument, untruncated: the width-aware header line sizes + * it to the terminal. `keep` says which end must survive a cut — paths keep + * their file name, everything else keeps its start. + */ +export interface KeyArgument { + readonly text: string; + readonly keep: 'head' | 'tail'; +} + +/** + * Capped variant for contexts without a width-aware header (subagent + * summaries, the activity viewer): the first {@link MAX_ARG_LENGTH} + * characters, keeping a path's file name. + */ +export function extractKeyArgument( + toolName: string, + args: Record<string, unknown>, + workspaceDir?: string, +): string | null { + const detail = extractKeyArgumentDetail(toolName, args, workspaceDir); + if (detail === null) return null; + return truncateArgValue(detail.keep === 'tail' ? 'path' : 'value', detail.text); +} + +export function extractKeyArgumentDetail( + toolName: string, + args: Record<string, unknown>, + workspaceDir?: string, +): KeyArgument | null { + const keyMap: Record<string, string[]> = { + Bash: ['command'], + Read: ['path', 'file_path'], + Write: ['path', 'file_path'], + Edit: ['path', 'file_path'], + Grep: ['pattern'], + Glob: ['pattern'], + FetchURL: ['url'], + WebSearch: ['query'], + // Prefer the short `description` so the header preview never spills a + // multi-line `prompt` into the TUI chrome. + Agent: ['description', 'prompt'], + NotifyUser: ['message'], + }; + + // Glob: concatenate multiple args into a single summary so the header + // shows pattern, optional explicit path, and ignored-file inclusion. + if (toolName === 'Glob') { + const pattern = args['pattern']; + if (typeof pattern !== 'string' || pattern.length === 0) return null; + let summary = pattern; + const path = args['path']; + if (typeof path === 'string' && path.length > 0) { + summary += ` · ${makeWorkspaceRelativePath(path, workspaceDir)}`; + } + if (args['include_ignored'] === true) { + summary += ' · include ignored'; + } + return { text: summary, keep: 'head' }; + } + + const candidates = keyMap[toolName] ?? Object.keys(args); + for (const key of candidates) { + const val = args[key]; + if (typeof val === 'string' && val.length > 0) { + const firstLine = val.split('\n')[0] ?? val; + const displayValue = + toolName === 'Bash' && val.includes('\n') ? `${firstLine}…` : firstLine; + return { + text: displayKeyArgument(toolName, key, displayValue, workspaceDir), + keep: PATH_KEYS.has(key) ? 'tail' : 'head', + }; + } + } + return null; +} + +function formatSubagentLabel(agentName: string | undefined): string { + const raw = agentName?.trim(); + if (raw === undefined || raw.length === 0) return 'SubAgent'; + const label = raw + .split(/[-_\s]+/) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + if (/\bagent$/i.test(label)) return label; + return `${label} Agent`; +} + +function tailNonEmptyLines(text: string, maxLines: number): string[] { + if (text.length === 0) return []; + return text + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0) + .slice(-maxLines); +} + +class PrefixedWrappedLine implements Component { + private renderCache: { width: number; lines: string[] } | undefined; + + constructor( + private readonly firstPrefix: string, + private readonly continuationPrefix: string, + private readonly text: string, + // When set, only the last N wrapped display rows are kept, so a long + // unwrapped paragraph scrolls within a fixed window instead of growing + // unbounded. The first kept row still gets `firstPrefix`. + private readonly tailLines?: number, + // When set, the output is padded with empty continuation rows until it + // reaches this many display rows, so a short paragraph still fills a + // fixed-height window. Applied after `tailLines`. + private readonly minLines?: number, + ) { } + + invalidate(): void { + this.renderCache = undefined; + } + + render(width: number): string[] { + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + if (isRenderCacheEnabled() && this.renderCache?.width === safeWidth) { + return this.renderCache.lines; + } + + const prefixWidth = Math.max( + visibleWidth(this.firstPrefix), + visibleWidth(this.continuationPrefix), + ); + const contentWidth = Math.max(1, safeWidth - prefixWidth); + const wrapped = new Text(this.text, 0, 0).render(contentWidth); + const lines = + this.tailLines !== undefined && wrapped.length > this.tailLines + ? wrapped.slice(wrapped.length - this.tailLines) + : wrapped; + if (this.minLines !== undefined) { + while (lines.length < this.minLines) lines.push(''); + } + const rendered = lines + .map((line, index) => + index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`, + ) + .map((line) => truncateToWidth(line, safeWidth, '…')); + if (isRenderCacheEnabled()) { + this.renderCache = { width: safeWidth, lines: rendered }; + } + return rendered; + } +} + +export class ToolCallComponent extends Container { + private expanded = false; + private toolCall: ToolCallBlockData; + private readonly markdownTheme = createMarkdownTheme(); + /** + * Memo for hasHiddenContent(); reset whenever the body or the result-driven + * content is rebuilt, or live output grows. + */ + private hiddenContent: boolean | undefined = undefined; + /** Width-dependent half of hasHiddenContent(); recomputed on every collapsed render. */ + private truncatedAtLastRender = false; + private result: ToolResultBlockData | undefined; + private ui: TUI | undefined; + private planPath: string | undefined; + /** + * Fallback plan body used when the LLM uses plan-file mode and + * `args.plan` is empty. `KimiTUI` calls `setPlanInfo` with + * `session.getPlan()` content so the plan box can render while + * approval is pending, and so rejected or revised results still show + * the plan body even without a `## Approved Plan:` marker. + */ + private currentPlan: string | undefined; + private headerText: TruncatedHeaderLine; + private callPreviewEndIndex = 0; + + // ── Subagent state ─────────────────────────────────────────────── + // + // Populated by `setSubagentMeta` / `appendSubToolCall` / `finishSubToolCall` + // when KimiTUI routes a `subagent.event` with this tool call + // id as its `parent_tool_call_id`. Rendered at the tail of + // buildContent so it shows up both during streaming and after the + // parent tool call resolves. + private subagentAgentId: string | undefined; + private subagentAgentName: string | undefined; + private readonly ongoingSubCalls = new Map<string, OngoingSubCall>(); + private readonly finishedSubCalls: FinishedSubCall[] = []; + private readonly subToolActivities = new Map<string, SubToolActivity>(); + private subToolOrderSeq = 0; + private hiddenSubCallCount = 0; + /** + * Recent normal-output lines from the child agent. Historical replay can also + * store mixed text here. + */ + private subagentText = ''; + private subagentThinkingText = ''; + /** Tracks whether the child agent's latest streamed delta was text or thinking, + * so the active window can follow whichever is currently live. */ + private lastSubagentStreamKind: SubagentTextKind = 'text'; + // ── Subagent lifecycle state from subagent.spawned/started/completed/failed ── + private subagentPhase: SubagentPhase | undefined; + /** + * Distinguishes a foreground subagent that the user detached via Ctrl+B from + * one that started in the background. Both set `subagentPhase = 'backgrounded'`, + * but only the detached one should keep showing `◐ backgrounded` after its + * spawn-success ToolResult lands — a started-in-background agent reads as + * `done` once its result arrives. + */ + private detachedFromForeground = false; + /** + * Authoritative terminal phase for a backgrounded subagent. Set from + * `BackgroundTaskInfo.status` via `setBackgroundTaskTerminalStatus` once + * the backing task reaches a terminal state — either live (a bg agent + * fails / is killed) or on resume (reconcile reclassifies a still-running + * task as `lost`). Beats the spawn-success ToolResult in both render + * paths (`getDerivedSubagentPhase` for standalone, `getSubagentSnapshot` + * for grouped), which would otherwise mislabel every terminated + * background agent — including lost ones — as `✓ Completed`. + */ + private backgroundTaskTerminalPhase: 'done' | 'failed' | undefined; + private subagentContextTokens: number | undefined; + private subagentUsage: TokenUsage | undefined; + /** Display name of the model the subagent is bound to (from its `agent.status.updated`). */ + private subagentModel: string | undefined; + /** Thinking effort, set only for concrete levels (boolean on/off hidden). */ + private subagentEffort: string | undefined; + private subagentResultSummary: string | undefined; + private subagentError: string | undefined; + private streamingProgressTimer: ReturnType<typeof setInterval> | undefined; + private subagentElapsedTimer: ReturnType<typeof setInterval> | undefined; + private subagentStartedAtMs: number | undefined; + private subagentEndedAtMs: number | undefined; + private subagentSpinnerFrame = 0; + + // ── Live progress lines ────────────────────────────────────────── + // + // Populated by `appendProgress` whenever the tool emits an + // `onUpdate({kind:'status', text})` while still running. Used by + // long-blocking tools (e.g. the MCP `authenticate` synthetic tool + // whose 15-minute browser wait would otherwise display only a + // spinner). Cleared when the result lands — the result is the + // authoritative final state. + private progressLines: string[] = []; + private progressStatusRows = 0; + private static readonly MAX_PROGRESS_LINES = 24; + private liveOutput = ''; + + /** + * Advertises `Ctrl+B` on a foreground Bash/Agent card that has been running + * for {@link DETACH_HINT_DELAY_MS}. Cleared when the result lands. + */ + private detachHintTimer: ReturnType<typeof setTimeout> | undefined; + private detachHintVisible = false; + + /** + * Registered by a group container (`AgentGroupComponent` or + * `ReadGroupComponent`) when this component is borrowed as a hidden state + * container. Any state change (subagent meta, phase, sub-tool, result, etc.) + * triggers a throttled group re-render. `undefined` means no group is + * subscribed and standalone rendering is unaffected. A ToolCallComponent can + * only belong to one group at a time, so one listener slot is enough. + */ + private onSnapshotChange: (() => void) | undefined; + + constructor( + toolCall: ToolCallBlockData, + result: ToolResultBlockData | undefined, + ui?: TUI, + private readonly workspaceDir?: string, + ) { + super(); + this.toolCall = toolCall; + this.result = result; + this.ui = ui; + this.applySubagentReplay(toolCall.subagent); + + this.addChild(new Spacer(1)); + this.headerText = new TruncatedHeaderLine(this.buildHeader()); + this.addChild(this.headerText); + this.buildCallPreview(); + this.callPreviewEndIndex = this.children.length; + this.buildProgressBlock(); + this.buildLiveOutputBlock(); + this.buildContent(); + this.buildSubagentBlock(); + this.syncStreamingProgressTimer(); + this.syncSubagentElapsedTimer(); + this.startDetachHintTimer(); + } + + private renderCache: + | { width: number; lines: string[]; childRefs: Component[]; childLines: string[][] } + | undefined; + + override render(width: number): string[] { + const cache = this.renderCache; + const cacheValid = + isRenderCacheEnabled() && + cache !== undefined && + cache.width === width && + cache.childRefs.length === this.children.length; + + const childRefs: Component[] = []; + const childLines: string[][] = []; + let allReused = cacheValid; + + let i = 0; + for (const child of this.children) { + const lines = child.render(width); + childRefs.push(child); + childLines.push(lines); + if (cacheValid && (cache.childRefs[i] !== child || cache.childLines[i] !== lines)) { + allReused = false; + } + i++; + } + + // An outcome row cut to this width hides the remainder of a long line, and + // an error preview cut to its row cap hides the rest of a wrapped error; + // ctrl+o reveals both. The header (child 1) is excluded — a cut key + // argument is not what ctrl+o reveals for most tools; Bash is the + // exception, its full command renders in the body once expanded. The + // value is kept while expanded so the footer can still offer collapse. + if (!this.expanded) { + this.truncatedAtLastRender = this.children.some( + (child, index) => + (child instanceof TruncatedHeaderLine && + (index !== 1 || + (this.toolCall.name === 'Bash' && this.toolCall.truncated !== true)) && + child.wasTruncated()) || + ((child instanceof TruncatedOutputComponent || child instanceof ShellExecutionComponent) && + child.wasTruncated()), + ); + } + + if (allReused) { + return cache!.lines; + } + + const out: string[] = []; + for (const lines of childLines) { + for (const line of lines) out.push(line); + } + if (isRenderCacheEnabled()) { + this.renderCache = { width, lines: out, childRefs, childLines }; + } + return out; + } + + override invalidate(): void { + this.renderCache = undefined; + this.headerText.setText(this.buildHeader()); + this.rebuildBody(); + super.invalidate(); + } + + setExpanded(expanded: boolean): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + // rebuildBody (not rebuildContent) so the args-driven call preview + // — which is what carries Write content / Edit diff — re-renders + // with the new line cap. rebuildContent only touches result-driven + // children and would leave the call preview stuck at its initial + // collapsed size. + this.rebuildBody(); + } + + /** + * Whether ctrl+o would reveal anything this card keeps out of its collapsed + * form. Mirrors the collapsed rules of buildCallPreview and the result + * renderers (short output shown whole, bodies that only render expanded); + * the footer reads it to decide whether to advertise ctrl+o. + */ + hasHiddenContent(): boolean { + this.hiddenContent ??= this.computeHiddenContent(); + return this.hiddenContent || this.truncatedAtLastRender; + } + + /** Whether the global ctrl+o toggle currently has this card expanded. */ + isExpanded(): boolean { + return this.expanded; + } + + private computeHiddenContent(): boolean { + const { name, args } = this.toolCall; + // A solo Agent card with subagent state never renders its result body and + // its subagent block is a fixed-height window either way, so ctrl+o + // changes nothing there. + if (this.isSingleSubagentView()) return false; + // Arguments cut off by max_tokens: the card shows a fixed "call never + // executed" note in place of any preview, so there is nothing to expand. + if (this.toolCall.truncated === true && this.result === undefined) return false; + if (this.result === undefined && this.toolCall.streamingArguments !== undefined) { + // While the arguments stream, the Write tail and the Edit progress row + // ignore the toggle; only Bash reveals its partial command when expanded. + return ( + name === 'Bash' && + (extractPartialStringField(this.toolCall.streamingArguments, 'command') ?? '').length > 0 + ); + } + if (this.callPreviewHidesContent()) return true; + const { result } = this; + if (result === undefined) return nonEmptyLines(this.liveOutput).length > 1; + if (result.output.length === 0) return false; + if (result.output.trimStart().startsWith('<system-reminder>')) return false; + if (result.is_error === true) return nonEmptyLines(result.output).length > RESULT_PREVIEW_LINES; + switch (name) { + case 'ReadMediaFile': + // A media envelope renders its body only when expanded; anything else + // falls back to the generic renderer and its line-count rule. + return ( + parseReadMediaOutput(result.output) !== null || + nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); + case 'Grep': + case 'Glob': + // A notice-only search (cut short, or only filtered sensitive files) + // shows that notice the same way in both states; every other result + // hides its body. + return ( + !searchNoticeOnly(this.toolCall, result.output) || + nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); + case 'WaitFor': + // A parsed wait renders its glance in both states but appends the raw + // result only when expanded; anything else follows the line-count rule. + return ( + parseWaitForOutput(result.output) !== undefined || + nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); + case 'Read': + case 'FetchURL': + case 'WebSearch': + case 'Think': + return true; + case 'ExitPlanMode': + // An approved plan is fully rendered by the call preview and the + // outcome body is expansion-independent; only a non-outcome result + // (an error message) can have more to show behind ctrl+o. + return ( + !isExitPlanModeOutcomeOutput(result.output) && + nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); + case 'AskUserQuestion': + // A foreground question renders its answers in an expansion-independent + // view; a background one returns a metadata block through the generic + // renderer and follows the line-count rule (the legacy engine's block + // runs past the outcome rows). + return ( + args['background'] === true && nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); + case 'CreateGoal': + case 'GetGoal': + // A parsed goal renders the same fixed snapshot in both states; only an + // unparsable result falls back to the line-count rule. + return ( + parseGoalToolOutput(result.output) === undefined && + nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); + case 'Edit': + case 'Write': + // The result body renders the same way in both states (the call + // preview is checked above), so only the preview can hide content. + return false; + case 'SetGoalBudget': + case 'UpdateGoal': + case 'AgentSwarm': + case 'TodoList': + case 'EnterPlanMode': + return false; + default: + return nonEmptyLines(result.output).length > OUTCOME_MAX_LINES; + } + } + + /** + * Whether the args-driven call preview keeps content out of the collapsed + * card whatever the result: a multi-line Bash command shows only its first + * line in the header, and the Edit diff and Write content previews are + * capped, so a failed call can still have more to show behind ctrl+o. + */ + private callPreviewHidesContent(): boolean { + const { name, args } = this.toolCall; + switch (name) { + case 'NotifyUser': + return isExperimentalFlagEnabled('notify_user') && str(args['message']).trim().length > 0; + case 'Bash': + return str(args['command']).includes('\n'); + case 'Edit': { + const oldStr = str(args['old_string']); + const newStr = str(args['new_string']); + if (oldStr.length === 0 && newStr.length === 0) return false; + // Mirror buildCallPreview exactly by rendering both ways: the cap + // applies to body rows at cluster boundaries under a header row, so + // the capped render differs from the full one only when it cut rows + // (its trailer then replaces them). + const filePath = str(args['file_path'] ?? args['path']); + const full = renderDiffLinesClustered(oldStr, newStr, filePath, { contextLines: 3 }); + const capped = renderDiffLinesClustered(oldStr, newStr, filePath, { + contextLines: 3, + maxLines: COMMAND_PREVIEW_LINES, + }); + return capped.length !== full.length || capped.at(-1) !== full.at(-1); + } + case 'Write': + return computeWriteStats(args).lines > COMMAND_PREVIEW_LINES; + default: + return false; + } + } + + setResult(result: ToolResultBlockData): void { + this.result = result; + // Result supersedes any live progress chatter; the result body is the + // authoritative final state. Without this clear, a finished tool would + // show both the streamed status lines and the final output stacked. + this.progressLines = []; + this.progressStatusRows = 0; + this.liveOutput = ''; + this.detachHintVisible = false; + this.stopDetachHintTimer(); + this.finalizeSubagentElapsedIfNeeded(); + this.syncStreamingProgressTimer(); + this.syncSubagentElapsedTimer(); + this.headerText.setText(this.buildHeader()); + // rebuildBody (not rebuildContent) so the call preview re-renders + // with the collapsed cap applied — Write streaming previews and + // Edit's progress placeholder needs to snap to the final preview on + // result. + this.rebuildBody(); + // Final results affect group summaries, especially failed/done counts. + this.notifySnapshotChange(); + } + + updateToolCall(toolCall: ToolCallBlockData): void { + this.toolCall = toolCall; + this.syncStreamingProgressTimer(); + this.headerText.setText(this.buildHeader()); + this.rebuildBody(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + /** + * Append a live progress line emitted by the tool via + * `onUpdate({kind:'status', text})`. Splits on newlines so multi-line + * status payloads render row-by-row. With `options.replace`, the previous + * replaceable status block is swapped out first — periodic "still + * waiting" updates would otherwise pile up to the cap with stale rows. + * Old lines are dropped once the + * buffer fills past {@link ToolCallComponent.MAX_PROGRESS_LINES} so a + * misbehaving tool can't grow the box unboundedly. + */ + appendProgress(text: string, options?: { readonly replace?: boolean }): void { + if (this.result !== undefined) return; + if (options?.replace === true && this.progressStatusRows > 0) { + this.progressLines.splice( + Math.max(0, this.progressLines.length - this.progressStatusRows), + this.progressStatusRows, + ); + } + const lines = text.split('\n'); + for (const line of lines) { + this.progressLines.push(line); + } + this.progressStatusRows = options?.replace === true ? lines.length : 0; + while (this.progressLines.length > ToolCallComponent.MAX_PROGRESS_LINES) { + this.progressLines.shift(); + } + this.rebuildBody(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + appendLiveOutput(text: string): void { + if (this.result !== undefined || text.length === 0) return; + this.liveOutput += text; + this.hiddenContent = undefined; + if (this.liveOutput.length > MAX_LIVE_OUTPUT_CHARS) { + this.liveOutput = `[…truncated]\n${this.liveOutput.slice( + this.liveOutput.length - MAX_LIVE_OUTPUT_CHARS, + )}`; + } + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + dispose(): void { + this.stopStreamingProgressTimer(); + this.stopSubagentElapsedTimer(); + this.stopDetachHintTimer(); + } + + /** + * Injects plan body/path asynchronously. Only ExitPlanMode cards use + * this: plan-file mode leaves `args.plan` empty, so `KimiTUI` fetches + * the plan via `session.getPlan()` and calls this method to render the + * plan box. + */ + setPlanInfo(info: { plan?: string; path?: string }): void { + if (this.toolCall.name !== 'ExitPlanMode') return; + let changed = false; + if (info.plan !== undefined && info.plan.length > 0 && this.currentPlan !== info.plan) { + this.currentPlan = info.plan; + changed = true; + } + if (info.path !== undefined && info.path.length > 0 && this.planPath !== info.path) { + this.planPath = info.path; + changed = true; + } + if (!changed) return; + this.rebuildBody(); + this.ui?.requestRender(); + } + + private applySubagentReplay(subagent: ToolCallBlockData['subagent']): void { + if (subagent === undefined) return; + this.subagentAgentId = subagent.id; + this.subagentAgentName = subagent.name; + this.subagentText = subagent.text ?? ''; + for (const call of subagent.toolCalls ?? []) { + if (call.result === undefined) { + this.ongoingSubCalls.set(call.id, { name: call.name, args: call.args }); + this.upsertSubToolActivity(call.id, call.name, call.args, 'ongoing'); + continue; + } + this.finishedSubCalls.push({ + name: call.name, + args: call.args, + output: call.result.output, + isError: call.result.is_error ?? false, + }); + this.upsertSubToolActivity( + call.id, + call.name, + call.args, + call.result.is_error === true ? 'failed' : 'done', + call.result.output, + ); + } + while (this.finishedSubCalls.length > MAX_SUB_TOOL_CALLS_SHOWN) { + this.finishedSubCalls.shift(); + this.hiddenSubCallCount += 1; + } + } + + // ── Subagent API (called by KimiTUI event routing) ─────────────── + + setSubagentMeta(agentId: string, agentName?: string): void { + if (this.subagentAgentId === agentId && this.subagentAgentName === agentName) return; + this.subagentAgentId = agentId; + this.subagentAgentName = agentName; + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + /** + * Lets group containers (AgentGroup or ReadGroup) subscribe to this card's + * state changes. Registration immediately calls back so the group receives + * the current snapshot without separately calling getSubagentSnapshot or + * getReadSnapshot. Pass `undefined` to unsubscribe. + */ + setSnapshotListener(cb: (() => void) | undefined): void { + this.onSnapshotChange = cb; + if (cb !== undefined) cb(); + } + + getSubagentSnapshot(): ToolCallSubagentSnapshot { + const finished = this.finishedSubCalls.length + this.hiddenSubCallCount; + const contextTokens = this.subagentContextTokens; + const tokens = + contextTokens && contextTokens > 0 + ? contextTokens + : (this.subagentUsage === undefined ? 0 : usageTotal(this.subagentUsage)); + const latestActivity = computeLatestActivity( + this.ongoingSubCalls, + this.finishedSubCalls, + this.getCombinedSubagentText(), + this.workspaceDir, + ); + // Terminal-state priority: SDK `tool.result` is authoritative for Agent + // tool calls. Once it arrives, force done/failed over intermediate + // spawning/running states for two reasons: + // 1. Replay does not replay spawned/completed/failed events, so + // `subagentPhase` stays undefined and result must be used. + // 2. Live type-validation failures may skip `subagent.failed`, or + // `tool.result` may arrive first; otherwise the UI can stay stuck at + // 'spawning' and keep showing `Initializing...`. + // Intermediate states without a result still use `subagentPhase`. + // `backgrounded` has no result because background agents do not enter the + // transcript — but a foreground subagent detached via Ctrl+B keeps + // `subagentPhase === 'backgrounded'` even after its ToolResult lands, so + // the group card shows `◐ backgrounded` rather than `✓ Completed`. Reuse + // the standalone derivation so both paths agree. + const derivedPhase = this.getDerivedSubagentPhase(); + const errorText = + this.subagentError ?? (derivedPhase === 'failed' ? this.result?.output : undefined); + return { + toolCallId: this.toolCall.id, + toolName: this.toolCall.name, + toolCallDescription: str(this.toolCall.args['description']) || str(this.toolCall.description), + agentName: this.subagentAgentName, + model: this.subagentModel, + effort: this.subagentEffort, + phase: derivedPhase, + toolCount: finished, + elapsedSeconds: this.getSubagentElapsedSeconds(), + tokens, + isError: derivedPhase === 'failed', + errorText, + latestActivity, + }; + } + + /** + * Used by `ReadGroupComponent` to sum line counts across same-step Read + * cards. `lines` matches the single-card chip + * (`pluralize(countNonEmptyLines(...), 'line')`) so group and card counts do + * not drift. + */ + getReadSnapshot(): ToolCallReadSnapshot { + const args = this.toolCall.args; + const filePathRaw = args['file_path'] ?? args['path']; + const filePath = + typeof filePathRaw === 'string' + ? makeWorkspaceRelativePath(filePathRaw, this.workspaceDir) + : undefined; + if (this.result === undefined) { + return { toolCallId: this.toolCall.id, filePath, phase: 'pending', lines: 0 }; + } + if (this.result.is_error === true) { + return { toolCallId: this.toolCall.id, filePath, phase: 'failed', lines: 0 }; + } + return { + toolCallId: this.toolCall.id, + filePath, + phase: 'done', + lines: countNonEmptyLines(this.result.output), + }; + } + + // Readonly view for group access to toolCall metadata (id, name, description). + get toolCallView(): Readonly<ToolCallBlockData> { + return this.toolCall; + } + + /** Notifies the listener when internal state changes, if a group is attached. */ + private notifySnapshotChange(): void { + this.onSnapshotChange?.(); + } + + private upsertSubToolActivity( + id: string, + name: string, + args: Record<string, unknown>, + phase: SubToolActivity['phase'], + output?: string, + ): void { + const existing = this.subToolActivities.get(id); + if (existing !== undefined) { + existing.name = name; + existing.args = args; + existing.phase = phase; + if (output !== undefined) existing.output = output; + return; + } + this.subToolActivities.set(id, { + id, + name, + args, + phase, + ...(output !== undefined ? { output } : {}), + orderSeq: ++this.subToolOrderSeq, + }); + } + + private getCombinedSubagentText(): string { + return [this.subagentThinkingText, this.subagentText].filter((s) => s.length > 0).join('\n'); + } + + private isStreamingEditPreview(): boolean { + return ( + this.toolCall.name === 'Edit' && + this.result === undefined && + this.toolCall.streamingArguments !== undefined + ); + } + + private syncStreamingProgressTimer(): void { + if (!this.isStreamingEditPreview()) { + this.stopStreamingProgressTimer(); + return; + } + if (this.ui === undefined || this.streamingProgressTimer !== undefined) return; + this.streamingProgressTimer = setInterval(() => { + if (!this.isStreamingEditPreview()) { + this.stopStreamingProgressTimer(); + return; + } + this.rebuildBody(); + this.ui?.requestRender(); + }, STREAMING_PROGRESS_INTERVAL_MS); + } + + private stopStreamingProgressTimer(): void { + if (this.streamingProgressTimer === undefined) return; + clearInterval(this.streamingProgressTimer); + this.streamingProgressTimer = undefined; + } + + /** Only foreground Bash/Agent calls can be detached via Ctrl+B. */ + private isDetachHintEligible(): boolean { + return this.toolCall.name === 'Bash' || this.toolCall.name === 'Agent'; + } + + private startDetachHintTimer(): void { + if (!this.isDetachHintEligible()) return; + if (this.result !== undefined) return; + if (this.ui === undefined) return; + if (this.toolCall.name === 'Agent') { + // Subagents are long-running by nature; advertise Ctrl+B immediately + // instead of waiting out the delay used for short Bash commands. + if (this.detachHintVisible) return; + this.detachHintVisible = true; + this.rebuildBody(); + this.ui?.requestRender(); + return; + } + if (this.detachHintTimer !== undefined) return; + this.detachHintTimer = setTimeout(() => { + this.detachHintTimer = undefined; + if (this.result !== undefined) return; + this.detachHintVisible = true; + this.rebuildBody(); + this.ui?.requestRender(); + }, DETACH_HINT_DELAY_MS); + } + + private stopDetachHintTimer(): void { + if (this.detachHintTimer === undefined) return; + clearTimeout(this.detachHintTimer); + this.detachHintTimer = undefined; + } + + private buildDetachHintBlock(): void { + if (!this.detachHintVisible) return; + if (this.result !== undefined) return; + this.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0)); + } + + private syncSubagentElapsedTimer(): void { + const phase = this.getDerivedSubagentPhase(); + const shouldTick = + this.isSingleSubagentView() && + this.subagentStartedAtMs !== undefined && + (phase === 'queued' || phase === 'spawning' || phase === 'running'); + if (!shouldTick) { + this.stopSubagentElapsedTimer(); + return; + } + if (this.ui === undefined || this.subagentElapsedTimer !== undefined) return; + this.subagentElapsedTimer = setInterval(() => { + const latestPhase = this.getDerivedSubagentPhase(); + if (latestPhase !== 'queued' && latestPhase !== 'spawning' && latestPhase !== 'running') { + this.stopSubagentElapsedTimer(); + return; + } + // Drives both the braille spinner in the header and the elapsed-seconds + // refresh. Only the header text changes on a tick, so we avoid rebuilding + // the body (which would defeat the per-component render caches). + this.subagentSpinnerFrame = (this.subagentSpinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; + this.headerText.setText(this.buildHeader()); + this.notifySnapshotChange(); + this.ui?.requestRender(); + }, BRAILLE_SPINNER_INTERVAL_MS); + } + + private stopSubagentElapsedTimer(): void { + if (this.subagentElapsedTimer === undefined) return; + clearInterval(this.subagentElapsedTimer); + this.subagentElapsedTimer = undefined; + } + + private finalizeSubagentElapsedIfNeeded(): void { + if ( + this.toolCall.name === 'Agent' && + this.subagentStartedAtMs !== undefined && + this.subagentEndedAtMs === undefined + ) { + this.subagentEndedAtMs = Date.now(); + } + } + + /** + * Handles SDK `subagent.spawned`. The child agent is registered with the + * parent call, but its prompt may still be queued behind other subagents. + * `subagent.started` moves it to 'running' when the child turn actually + * begins. + */ + onSubagentSpawned(meta: { + agentId: string; + agentName?: string | undefined; + runInBackground: boolean; + }): void { + this.subagentAgentId = meta.agentId; + this.subagentAgentName = meta.agentName; + this.subagentPhase = meta.runInBackground ? 'backgrounded' : 'queued'; + this.subagentStartedAtMs = Date.now(); + this.subagentEndedAtMs = undefined; + this.syncSubagentElapsedTimer(); + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + /** Handles SDK `subagent.started` once a queued child turn begins. */ + onSubagentStarted(meta: { + agentId: string; + agentName?: string | undefined; + runInBackground: boolean; + }): void { + this.subagentAgentId = meta.agentId; + this.subagentAgentName = meta.agentName; + if ( + !meta.runInBackground && + (this.subagentPhase === undefined || this.subagentPhase === 'queued') + ) { + this.subagentPhase = 'running'; + } + this.syncSubagentElapsedTimer(); + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + /** + * Handles SDK `subagent.completed`. Moves the phase to 'done' and records + * token usage plus the result summary for the header chip and tail summary. + */ + onSubagentCompleted(payload: { + contextTokens?: number | undefined; + usage?: TokenUsage | undefined; + resultSummary: string; + }): void { + this.subagentPhase = 'done'; + this.subagentEndedAtMs ??= Date.now(); + if (payload.contextTokens !== undefined && payload.contextTokens > 0) { + this.subagentContextTokens = payload.contextTokens; + } + this.subagentUsage = payload.usage; + this.subagentResultSummary = + payload.resultSummary.length > 0 ? payload.resultSummary : undefined; + if (this.subagentText.trim().length === 0 && this.subagentResultSummary !== undefined) { + this.subagentText = this.subagentResultSummary; + } + this.syncSubagentElapsedTimer(); + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + /** Handles SDK `agent.status.updated` from the child agent. */ + updateSubagentMetrics(payload: { + contextTokens?: number | undefined; + usage?: TokenUsage | undefined; + modelDisplay?: string | undefined; + effortDisplay?: string | undefined; + }): void { + if (payload.contextTokens !== undefined && payload.contextTokens > 0) { + this.subagentContextTokens = payload.contextTokens; + } + if (payload.usage !== undefined) { + this.subagentUsage = payload.usage; + } + if (payload.modelDisplay !== undefined) { + this.subagentModel = payload.modelDisplay; + } + if (payload.effortDisplay !== undefined) { + this.subagentEffort = payload.effortDisplay; + } + this.headerText.setText(this.buildHeader()); + this.invalidate(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + /** Handles SDK `subagent.failed`. */ + onSubagentFailed(payload: { error: string }): void { + this.subagentPhase = 'failed'; + this.subagentEndedAtMs ??= Date.now(); + this.subagentError = payload.error; + this.syncSubagentElapsedTimer(); + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + /** + * Records the actual terminal status of the backing background task so + * the snapshot phase no longer relies on the spawn-success ToolResult. + * Called for `agent-*` background tasks both live (when the bg agent + * terminates non-successfully) and on resume (when reconcile + * reclassifies a previously-running task as `lost`). + */ + setBackgroundTaskTerminalStatus( + status: 'completed' | 'failed' | 'timed_out' | 'killed' | 'lost', + options: { errorText?: string | undefined } = {}, + ): void { + const phase: 'done' | 'failed' = status === 'completed' ? 'done' : 'failed'; + const { errorText } = options; + const phaseUnchanged = this.backgroundTaskTerminalPhase === phase; + let errorChanged = false; + if (phase === 'failed') { + // Surface the failure line through the same `subagentError` slot that + // `onSubagentFailed` writes. The standalone card reads this in + // `buildSingleSubagentBlock`; the group card reads it via `errorText` + // in `getSubagentSnapshot`. Priority: + // 1. Explicit `errorText` from the caller (the real message from a + // live `subagent.failed` event) always wins — it is the most + // informative. + // 2. Existing `subagentError` (could be from a prior + // `onSubagentFailed` or an earlier explicit override) is kept. + // 3. Fall back to a friendly generic so the failure has SOME + // visible explanation when no source has supplied one. + if (errorText !== undefined && this.subagentError !== errorText) { + this.subagentError = errorText; + errorChanged = true; + } else if (this.subagentError === undefined) { + const generic = backgroundFailureMessage(status); + if (generic !== undefined) { + this.subagentError = generic; + errorChanged = true; + } + } + } + if (phaseUnchanged && !errorChanged) return; + this.backgroundTaskTerminalPhase = phase; + this.subagentEndedAtMs ??= Date.now(); + this.syncSubagentElapsedTimer(); + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + } + + /** + * Mark a foreground subagent as detached-to-background. Called when a + * `background.task.started` event arrives for this agent (i.e. the user + * pressed Ctrl+B). Keeps the card showing `◐ backgrounded` instead of + * flipping to `✓ Completed` when the spawn-success ToolResult lands. + */ + markBackgrounded(): void { + if (this.detachedFromForeground) return; + this.detachedFromForeground = true; + this.subagentPhase = 'backgrounded'; + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + /** + * Subagent id for the backing AgentTool call, used by routing to find a + * tool call's backing subagent when reconciling background task lifecycle + * events. + * + * Two writers, in priority order: + * 1. In-memory `subagentAgentId` — wired by `setSubagentMeta` / + * `onSubagentSpawned` for foreground agents. For backgrounded agents + * this stays undefined: `handleSubagentSpawned` early-returns before + * calling `tc.onSubagentSpawned`, and `applySubagentReplay` early- + * returns when the wire payload omits the `subagent` block — which + * it does for every replayed Agent call. + * 2. The spawn-success ToolResult body — AgentTool unconditionally + * emits `agent_id: agent-N` for every Agent call (foreground and + * background). Parsing it gives the stable identifier even when the + * in-memory field is empty, which is the only way the resume path + * can reliably route a `background.task.terminated` to the right + * card and the only way the live path avoids matching by description + * and accidentally updating an unrelated Agent card that happens to + * share the same `args.description`. + */ + getSubagentAgentId(): string | undefined { + if (this.subagentAgentId !== undefined) return this.subagentAgentId; + if (this.toolCall.name !== 'Agent' || this.result === undefined) return undefined; + const match = this.result.output.match(/^agent_id:\s*(agent-[A-Za-z0-9_-]+)/m); + return match?.[1]; + } + + /** `args.description` for `Agent` tool calls, used as a resume-path + * fallback when the wire format pre-dates persisted subagent ids and + * the only stable cross-restart identifier is the description string. */ + getAgentToolDescription(): string | undefined { + if (this.toolCall.name !== 'Agent') return undefined; + const desc = this.toolCall.args['description']; + return typeof desc === 'string' ? desc : undefined; + } + + appendSubagentText(text: string, kind: SubagentTextKind = 'text'): void { + this.lastSubagentStreamKind = kind; + if (kind === 'thinking') { + this.subagentThinkingText += text; + } else { + this.subagentText += text; + } + // Child-agent activity means it is running unless already terminal/backgrounded. + if ( + this.subagentPhase === undefined || + this.subagentPhase === 'queued' || + this.subagentPhase === 'spawning' + ) { + this.subagentPhase = 'running'; + } + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + appendSubToolCall(call: { id: string; name: string; args: Record<string, unknown> }): void { + const existing = this.ongoingSubCalls.get(call.id); + this.ongoingSubCalls.set(call.id, { + name: call.name, + args: call.args, + ...(existing?.streamingArguments !== undefined + ? { streamingArguments: existing.streamingArguments } + : {}), + }); + this.upsertSubToolActivity(call.id, call.name, call.args, 'ongoing'); + if ( + this.subagentPhase === undefined || + this.subagentPhase === 'queued' || + this.subagentPhase === 'spawning' + ) { + this.subagentPhase = 'running'; + } + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + appendSubToolCallDelta(delta: { + id: string; + name?: string | undefined; + argumentsPart: string | null; + }): void { + const existing = this.ongoingSubCalls.get(delta.id); + const nextArgsText = appendStreamingArgsPreview( + existing?.streamingArguments, + delta.argumentsPart, + ); + const parsed = parseArgsPreview(nextArgsText); + this.ongoingSubCalls.set(delta.id, { + name: delta.name ?? existing?.name ?? 'Tool', + args: parsed, + streamingArguments: nextArgsText, + }); + this.upsertSubToolActivity(delta.id, delta.name ?? existing?.name ?? 'Tool', parsed, 'ongoing'); + if ( + this.subagentPhase === undefined || + this.subagentPhase === 'queued' || + this.subagentPhase === 'spawning' + ) { + this.subagentPhase = 'running'; + } + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + appendSubToolLiveOutput(id: string, text: string, options?: { readonly replace?: boolean }): void { + if (text.length === 0) return; + const activity = this.subToolActivities.get(id); + const ongoing = this.ongoingSubCalls.get(id); + if (activity === undefined && ongoing === undefined) return; + const name = activity?.name ?? ongoing?.name ?? 'Tool'; + const args = activity?.args ?? ongoing?.args ?? {}; + const existingOutput = options?.replace === true ? '' : (activity?.output ?? ''); + let output = existingOutput + text; + if (output.length > MAX_LIVE_OUTPUT_CHARS) { + output = `[…truncated]\n${output.slice(output.length - MAX_LIVE_OUTPUT_CHARS)}`; + } + this.upsertSubToolActivity(id, name, args, activity?.phase ?? 'ongoing', output); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + finishSubToolCall(result: { + tool_call_id: string; + output: string; + is_error?: boolean | undefined; + }): void { + const ongoing = this.ongoingSubCalls.get(result.tool_call_id); + if (ongoing === undefined) return; + this.ongoingSubCalls.delete(result.tool_call_id); + this.finishedSubCalls.push({ + name: ongoing.name, + args: ongoing.args, + output: result.output, + isError: result.is_error ?? false, + }); + this.upsertSubToolActivity( + result.tool_call_id, + ongoing.name, + ongoing.args, + result.is_error === true ? 'failed' : 'done', + result.output, + ); + while (this.finishedSubCalls.length > MAX_SUB_TOOL_CALLS_SHOWN) { + this.finishedSubCalls.shift(); + this.hiddenSubCallCount += 1; + } + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + private buildHeader(): HeaderContent { + const { toolCall, result } = this; + const isFinished = result !== undefined; + const isError = result?.is_error ?? false; + const isTruncated = toolCall.truncated === true && !isFinished; + + let bullet: string; + if (isFinished) { + bullet = isError ? currentTheme.fg('error', '✗ ') : currentTheme.fg('success', STATUS_BULLET); + } else if (isTruncated) { + bullet = currentTheme.fg('error', '✗ '); + } else { + // Solid bullet for in-flight tools — the previous marker ↔ blank + // toggle caused visible flicker on every re-render. + bullet = currentTheme.fg('text', STATUS_BULLET); + } + + if (toolCall.name === 'ExitPlanMode') { + const label = currentTheme.boldFg('primary', 'Current plan'); + if (!isFinished || result === undefined || result.is_error === true) { + return label; + } + const outcome = interpretExitPlanModeOutcome(result.output); + if (outcome.kind === 'approved') { + const chipText = + outcome.chosen !== undefined && outcome.chosen.length > 0 + ? `Approved: ${outcome.chosen}` + : 'Approved'; + return `${label}${currentTheme.fg('success', ` · ${chipText}`)}`; + } + if (outcome.kind === 'auto_approved') { + // Auto permission mode let the plan through without user review — + // a warning-toned chip keeps "the user approved this" out of the UI. + return `${label}${currentTheme.fg('warning', ' · Auto-approved')}`; + } + return label; + } + + if (toolCall.name === 'AskUserQuestion') { + const isBackgroundAsk = toolCall.args['background'] === true; + const label = isFinished + ? isError + ? 'Could not collect your input' + : isBackgroundAsk + ? 'Started background question' + : 'Collected your answers' + : isBackgroundAsk + ? 'Starting background question' + : 'Waiting for your input'; + const tone = isError ? 'error' : 'primary'; + return `${bullet}${currentTheme.boldFg(tone, label)}`; + } + + if (toolCall.name === 'NotifyUser' && isExperimentalFlagEnabled('notify_user')) { + // The update itself lives in the panel above the input box; the card + // is the durable trace in the transcript, so the header carries the + // first line and ctrl+o shows the whole message. + if (isTruncated) { + // max_tokens cut the arguments short: the call never ran and the + // panel entry was dropped, so the card must not read as in flight. + return `${bullet}${currentTheme.boldFg('error', 'Update cut off')}${currentTheme.dim(' (arguments truncated by max_tokens)')}`; + } + const delivery = notifyResultState(result?.output); + const label = isFinished + ? isError + ? 'Could not send you an update' + : delivery === 'displayed' + ? 'Sent you an update' + : delivery === 'suppressed' + ? 'Update not displayed' + : 'Update completed' + : 'Sending you an update'; + const tone = isError ? 'error' : 'primary'; + const preview = extractKeyArgumentDetail(toolCall.name, toolCall.args, this.workspaceDir); + const head = `${bullet}${currentTheme.boldFg(tone, label)}`; + if (preview === null) return head; + return { + head: `${head}${currentTheme.dim(' (')}`, + flex: { text: preview.text, style: dimHeaderStyle, keep: 'head' }, + tail: currentTheme.dim(')'), + }; + } + + if (toolCall.name === 'Bash') { + // The collapsed card is this header plus its outcome rows, so the header + // carries the command's first line; the full command and its output only + // render in the body once expanded (ctrl+o). Wording mirrors the other label-only + // headers (e.g. AskUserQuestion): the whole label takes the tone colour. + if (isTruncated) { + return `${bullet}${currentTheme.fg('error', 'Truncated')} ${currentTheme.boldFg('primary', 'Bash')}`; + } + const label = isFinished ? 'Ran a command' : 'Running a command'; + const tone = isError ? 'error' : 'primary'; + const command = extractKeyArgumentDetail(toolCall.name, toolCall.args, this.workspaceDir); + const chipStr = isFinished && result !== undefined ? this.buildHeaderChip(result) : ''; + const head = `${bullet}${currentTheme.boldFg(tone, label)}`; + if (command === null) return `${head}${chipStr}`; + // The command takes whatever width the label and the chip leave over, + // so it fills a wide terminal and the chip survives a narrow one. + return { + head: `${head}${currentTheme.dim(' · $ ')}`, + flex: { text: command.text, style: dimHeaderStyle, keep: 'head' }, + tail: chipStr, + }; + } + + const goalHeader = buildGoalToolHeader({ + toolCall, + result, + bullet, + chip: isFinished && result !== undefined ? this.buildHeaderChip(result) : '', + }); + if (goalHeader !== undefined) return goalHeader; + + const waitForHeader = buildWaitForHeader({ + toolCall, + result, + bullet, + chip: isFinished && result !== undefined ? this.buildHeaderChip(result) : '', + }); + if (waitForHeader !== undefined) return waitForHeader; + + if (this.isSingleSubagentView()) { + return this.buildSingleSubagentHeader(); + } + + const verb = isFinished ? 'Used' : isTruncated ? 'Truncated' : 'Using'; + const keyArg = extractKeyArgumentDetail(toolCall.name, toolCall.args, this.workspaceDir); + const decoded = decodeMcpToolName(toolCall.name); + const verbStyled = isTruncated + ? currentTheme.fg('error', verb) + : verb; + const toolLabel = + decoded !== null + ? `${currentTheme.boldFg('primary', decoded.toolName)}${currentTheme.dim(` · MCP/${decoded.serverName}`)}` + : currentTheme.boldFg('primary', toolCall.name); + let chipStr = ''; + if (isFinished && result) chipStr = this.buildHeaderChip(result); + const head = `${bullet}${verbStyled} ${toolLabel}`; + if (keyArg === null) return `${head}${chipStr}`; + return { + head: `${head}${currentTheme.dim(' (')}`, + flex: { text: keyArg.text, style: dimHeaderStyle, keep: keyArg.keep }, + tail: `${currentTheme.dim(')')}${chipStr}`, + }; + } + + private buildHeaderChip(result: ToolResultBlockData): string { + // The truncation envelope of an oversized result is not countable data. + if (isSpilledToolOutput(result.output)) return ''; + const provider = pickChip(this.toolCall.name); + if (provider === undefined) return ''; + const text = provider(this.toolCall, result); + if (text.length === 0) return ''; + if (result.is_error) return currentTheme.fg('error', ` · ${text}`); + return currentTheme.dim(` · ${text}`); + } + + private rebuildContent(): void { + this.hiddenContent = undefined; + while (this.children.length > this.callPreviewEndIndex) { + this.children.pop(); + } + this.buildProgressBlock(); + this.buildDetachHintBlock(); + this.buildLiveOutputBlock(); + this.buildContent(); + this.buildSubagentBlock(); + } + + private rebuildBody(): void { + this.hiddenContent = undefined; + while (this.children.length > 2) { + this.children.pop(); + } + this.buildCallPreview(); + this.callPreviewEndIndex = this.children.length; + this.buildProgressBlock(); + this.buildDetachHintBlock(); + this.buildLiveOutputBlock(); + this.buildContent(); + this.buildSubagentBlock(); + } + + /** + * Render the accumulated `progressLines` between the call preview and + * the result body. URLs inside a line are wrapped in an OSC 8 hyperlink + * sequence so terminals that support it (iTerm2, Ghostty, kitty, modern + * Terminal.app, VS Code) make the URL Cmd-clickable and expose + * "Copy Link" via the context menu — even when pi-tui soft-wraps the + * URL across multiple rows (pi-tui's wrapTextWithAnsi re-opens the + * active OSC 8 link on each continuation line). Each embedded URL is + * styled individually so surrounding prose keeps its default dim tone. + */ + private buildProgressBlock(): void { + if (this.progressLines.length === 0) return; + if (this.result !== undefined) return; + for (const raw of this.progressLines) { + if (raw.length === 0) { + this.addChild(new Text('', 2, 0)); + continue; + } + PROGRESS_URL_RE.lastIndex = 0; + const styled = PROGRESS_URL_RE.test(raw) + ? raw.replace(PROGRESS_URL_RE, (url) => { + const visible = currentTheme.underlineFg('warning', url); + return `\u001B]8;;${url}\u001B\\${visible}\u001B]8;;\u001B\\`; + }) + : currentTheme.dim(raw); + PROGRESS_URL_RE.lastIndex = 0; + this.addChild(new Text(styled, 2, 0)); + } + } + + private buildLiveOutputBlock(): void { + if (this.result !== undefined) return; + if (this.liveOutput.length === 0) return; + // Collapsed: the newest output line is the card's outcome row while the + // command runs, so progress stays visible; the result's last line takes + // the same row once it lands. ctrl+o shows the whole live tail. + if (!this.expanded) { + const lines = nonEmptyLines(this.liveOutput); + const latest = lines.at(-1); + // With earlier output above it, the newest line carries the same + // leading ellipsis the finished card's last-line row uses. + if (latest !== undefined) { + this.addChild(outcomeLine(latest, lines.length > 1 ? 'above' : undefined)); + } + return; + } + this.addChild( + new ShellExecutionComponent({ + result: { + tool_call_id: this.toolCall.id, + output: this.liveOutput, + is_error: false, + }, + expanded: true, + }), + ); + } + + private buildSubagentBlock(): void { + if ( + this.subagentAgentId === undefined && + this.ongoingSubCalls.size === 0 && + this.finishedSubCalls.length === 0 && + this.subagentText.length === 0 && + this.subagentPhase === undefined && + this.backgroundTaskTerminalPhase === undefined + ) { + return; + } + + if (this.isSingleSubagentView()) { + this.buildSingleSubagentBlock(); + return; + } + + const phaseChip = this.formatPhaseChip(); + const headerLabel = + this.subagentAgentName !== undefined + ? `subagent ${this.subagentAgentName} (${this.formatAgentId()})` + : `subagent (${this.formatAgentId()})`; + this.addChild(new Text(` ${currentTheme.dim(`↳ ${headerLabel}`)}${phaseChip}`, 0, 0)); + + if (this.hiddenSubCallCount > 0) { + const suffix = this.hiddenSubCallCount > 1 ? 's' : ''; + this.addChild( + new Text( + currentTheme.italic(currentTheme.dim(` ${String(this.hiddenSubCallCount)} more tool call${suffix} …`)), + 0, + 0, + ), + ); + } + + for (const sub of this.finishedSubCalls) { + const mark = sub.isError + ? currentTheme.fg('error', '✗') + : currentTheme.fg('success', '•'); + const keyArg = extractKeyArgument(sub.name, sub.args, this.workspaceDir); + const nameCol = currentTheme.fg('primary', sub.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; + this.addChild(new Text(` ${mark} Used ${nameCol}${argCol}`, 0, 0)); + } + + for (const [id, call] of this.ongoingSubCalls) { + const keyArg = extractKeyArgument(call.name, call.args, this.workspaceDir); + const nameCol = currentTheme.fg('primary', call.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; + void id; + this.addChild(new Text(` ${currentTheme.dim('…')} Using ${nameCol}${argCol}`, 0, 0)); + } + + if (this.subagentText.length > 0) { + const tailLines = this.subagentText.split('\n').slice(-3); + for (const line of tailLines) { + this.addChild(new Text(` ${currentTheme.dim(line)}`, 0, 0)); + } + } + + // Result summary from subagent.completed. + if (this.subagentPhase === 'done' && this.subagentResultSummary !== undefined) { + const summaryLines = this.subagentResultSummary.split('\n').slice(0, 2); + for (const line of summaryLines) { + this.addChild(new Text(` ${currentTheme.dim('└')} ${line}`, 0, 0)); + } + } + + // Full error text from subagent.failed; do not collapse it. + if (this.subagentPhase === 'failed' && this.subagentError !== undefined) { + const errLines = this.subagentError.split('\n'); + for (const line of errLines) { + this.addChild(new Text(` ${currentTheme.fg('error', '└')} ${line}`, 0, 0)); + } + } + } + + /** + * Header phase/token chip. No chip is shown when phase is undefined. + * queued -> queued + * spawning -> starting + * running -> running + * done -> N tools, 8.4k tok + * failed -> failed + * backgrounded -> backgrounded + */ + private formatPhaseChip(): string { + if (this.subagentPhase === undefined) return ''; + const parts: string[] = []; + switch (this.subagentPhase) { + case 'queued': + parts.push('○ queued'); + break; + case 'spawning': + parts.push('↻ starting…'); + break; + case 'running': + parts.push('↻ running'); + break; + case 'done': { + parts.push(currentTheme.fg('success', '✓ done')); + const toolCount = this.finishedSubCalls.length + this.hiddenSubCallCount; + if (toolCount > 0) parts.push(`${String(toolCount)} tool${toolCount > 1 ? 's' : ''}`); + const tokens = + formatSubagentContextTokens(this.subagentContextTokens) ?? + formatSubagentTokens(this.subagentUsage); + if (tokens !== undefined) parts.push(tokens); + break; + } + case 'failed': + parts.push(currentTheme.fg('error', '✗ failed')); + break; + case 'backgrounded': + parts.push('◐ backgrounded'); + break; + } + return parts.length > 0 ? currentTheme.dim(` · ${parts.join(' · ')}`) : ''; + } + + private formatAgentId(): string { + const id = this.subagentAgentId ?? ''; + return id.length > 10 ? id.slice(0, 10) + '…' : id; + } + + private hasSubagentState(): boolean { + return ( + this.subagentAgentId !== undefined || + this.ongoingSubCalls.size > 0 || + this.finishedSubCalls.length > 0 || + this.subToolActivities.size > 0 || + this.subagentText.length > 0 || + this.subagentThinkingText.length > 0 || + this.subagentPhase !== undefined || + this.backgroundTaskTerminalPhase !== undefined + ); + } + + private isSingleSubagentView(): boolean { + return this.toolCall.name === 'Agent' && this.hasSubagentState(); + } + + private getDerivedSubagentPhase(): SubagentPhase | undefined { + if (this.backgroundTaskTerminalPhase !== undefined) { + return this.backgroundTaskTerminalPhase; + } + // A foreground subagent detached via Ctrl+B keeps showing `backgrounded` + // even after its spawn-success ToolResult lands, so the card doesn't flip + // to `✓ Completed` and look like the work actually finished. Agents that + // started in the background (`detachedFromForeground === false`) read as + // `done` once their result lands. + if (this.detachedFromForeground && this.subagentPhase === 'backgrounded') { + return 'backgrounded'; + } + if (this.result !== undefined) return this.result.is_error ? 'failed' : 'done'; + return this.subagentPhase; + } + + private buildSingleSubagentHeader(): string { + const phase = this.getDerivedSubagentPhase(); + const isDone = phase === 'done'; + const marker = this.buildSingleSubagentMarker(phase); + const labelText = formatSubagentLabel(this.subagentAgentName); + const label = currentTheme.boldFg('primary', labelText); + const status = this.formatSingleSubagentStatus(phase); + const rawDescription = str(this.toolCall.args['description']); + const description = + rawDescription.length > MAX_SUBAGENT_DESCRIPTION_LENGTH + ? `${rawDescription.slice(0, MAX_SUBAGENT_DESCRIPTION_LENGTH - 1)}…` + : rawDescription; + const descriptionPlain = description.length > 0 ? ` (${description})` : ''; + const descriptionText = descriptionPlain.length > 0 ? currentTheme.dim(descriptionPlain) : ''; + const statsText = this.formatSingleSubagentStatsText(); + if (isDone) { + return `${marker}${currentTheme.boldFg('success', labelText)} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`; + } + const stats = currentTheme.dim(statsText); + return `${marker}${label} ${status}${descriptionText}${stats}`; + } + + private formatSingleSubagentStatus(phase: SubagentPhase | undefined): string { + switch (phase) { + case 'done': + return currentTheme.fg('success', 'Completed'); + case 'failed': + return currentTheme.fg('error', 'Failed'); + case 'running': + return currentTheme.fg('primary', 'Running'); + case 'backgrounded': + return 'Backgrounded'; + case 'queued': + return currentTheme.fg('primary', 'Queued'); + case 'spawning': + case undefined: + return currentTheme.fg('primary', 'Starting'); + } + } + + private formatSingleSubagentStatsText(): string { + const parts: string[] = []; + if (this.subagentModel !== undefined) parts.push(this.subagentModel); + if (this.subagentEffort !== undefined) parts.push(this.subagentEffort); + parts.push(`${String(this.subToolActivities.size)} tool${this.subToolActivities.size === 1 ? '' : 's'}`); + const elapsed = this.getSubagentElapsedSeconds(); + if (elapsed !== undefined) parts.push(formatElapsed(elapsed)); + const tokens = + this.subagentContextTokens && this.subagentContextTokens > 0 + ? this.subagentContextTokens + : this.subagentUsage === undefined + ? 0 + : usageTotal(this.subagentUsage); + if (tokens > 0) parts.push(formatTokens(tokens)); + return ` · ${parts.join(' · ')}`; + } + + private getSubagentElapsedSeconds(): number | undefined { + if (this.subagentStartedAtMs === undefined) return undefined; + const end = this.subagentEndedAtMs ?? Date.now(); + return Math.max(0, Math.floor((end - this.subagentStartedAtMs) / 1000)); + } + + private buildSingleSubagentMarker(phase: SubagentPhase | undefined): string { + if (phase === 'failed') return currentTheme.fg('error', '✗ '); + if (phase === 'done') return currentTheme.fg('success', STATUS_BULLET); + if (phase === 'backgrounded') return currentTheme.dim('◐ '); + // Active (queued / spawning / running): a braille spinner reads as alive + // where a static bullet looked frozen. + const frame = BRAILLE_SPINNER_FRAMES[this.subagentSpinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]; + return currentTheme.fg('primary', `${frame} `); + } + + private buildSingleSubagentBlock(): void { + const phase = this.getDerivedSubagentPhase(); + + // Every state shares the same skeleton — header, a one-line tool summary, + // and a fixed two-row content window — so the card height is identical + // while running and after it finishes (no end-of-run shrink). + this.addChild(new Text(this.buildSingleSubagentSummaryLine(), 0, 0)); + + if (phase === 'failed') { + this.addChild(this.buildSingleSubagentResultWindow('error')); + return; + } + if (phase === 'done' || phase === 'backgrounded') { + this.addChild(this.buildSingleSubagentResultWindow('output')); + return; + } + this.addChild(this.buildSingleSubagentActiveWindow()); + } + + /** Most-recently-started sub-tool, preferring one that is still running. */ + private getCurrentSubToolActivity(): SubToolActivity | undefined { + let latestOngoing: SubToolActivity | undefined; + let latest: SubToolActivity | undefined; + for (const activity of this.subToolActivities.values()) { + if (latest === undefined || activity.orderSeq > latest.orderSeq) latest = activity; + if ( + activity.phase === 'ongoing' && + (latestOngoing === undefined || activity.orderSeq > latestOngoing.orderSeq) + ) { + latestOngoing = activity; + } + } + return latestOngoing ?? latest; + } + + /** + * The single live stream shown in the active window. A running sub-tool with + * previewable output (Bash or any tool without a dedicated renderer) wins; + * otherwise the most-recently-updated of the child agent's text / thinking. + */ + private getActiveSubagentContent(): { text: string; tone: 'text' | 'thinking' } | undefined { + const current = this.getCurrentSubToolActivity(); + if ( + current?.phase === 'ongoing' && + current.output !== undefined && + current.output.trim().length > 0 && + (current.name === 'Bash' || current.name === 'WaitFor' || isGenericToolResult(current.name)) + ) { + return { text: current.output, tone: 'text' }; + } + if (this.lastSubagentStreamKind === 'thinking' && this.subagentThinkingText.trim().length > 0) { + return { text: this.subagentThinkingText.trimEnd(), tone: 'thinking' }; + } + if (this.subagentText.trim().length > 0) { + return { text: this.subagentText, tone: 'text' }; + } + if (this.subagentThinkingText.trim().length > 0) { + return { text: this.subagentThinkingText.trimEnd(), tone: 'thinking' }; + } + return undefined; + } + + private buildSingleSubagentSummaryLine(): string { + const toolCount = this.subToolActivities.size; + const countLabel = `${String(toolCount)} tool${toolCount === 1 ? '' : 's'}`; + const current = this.getCurrentSubToolActivity(); + if (current === undefined) { + return currentTheme.dim(` · ${countLabel}`); + } + const verb = current.phase === 'ongoing' ? 'Using' : 'Used'; + const keyArg = extractKeyArgument(current.name, current.args, this.workspaceDir); + const nameCol = currentTheme.fg('primary', current.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; + const mark = + current.phase === 'failed' + ? currentTheme.fg('error', ' ✗') + : current.phase === 'done' + ? currentTheme.fg('success', ' ✓') + : ''; + return `${currentTheme.dim(` · ${countLabel} · `)}${verb} ${nameCol}${argCol}${mark}`; + } + + private buildSingleSubagentActiveWindow(): Component { + const gutter = currentTheme.dim('│'); + const content = this.getActiveSubagentContent(); + // Keep both tones muted: a bright `fg('text')` here flashed white whenever + // the window flipped between thinking and a brief text/tool-output segment. + const styled = + content === undefined + ? currentTheme.dim('…') + : content.tone === 'thinking' + ? currentTheme.dim(content.text) + : currentTheme.fg('textDim', content.text); + // Always exactly two rows (padded when short) so the live window matches + // the finished card's height. + return new PrefixedWrappedLine( + ` ${gutter} `, + ` ${gutter} `, + styled, + THINKING_PREVIEW_LINES, + THINKING_PREVIEW_LINES, + ); + } + + private buildSingleSubagentResultWindow(kind: 'output' | 'error'): Component { + const gutter = currentTheme.dim('│'); + const source = kind === 'error' ? this.subagentError : this.subagentText; + const text = source === undefined ? '' : tailNonEmptyLines(source, 2).join('\n'); + const styled = + kind === 'error' ? currentTheme.fg('error', text) : currentTheme.fg('text', text); + return new PrefixedWrappedLine( + ` ${gutter} `, + ` ${gutter} `, + styled, + THINKING_PREVIEW_LINES, + THINKING_PREVIEW_LINES, + ); + } + + private buildCallPreview(): void { + const name = this.toolCall.name; + if (name === 'ExitPlanMode') { + this.buildPlanPreview(); + return; + } + if (this.result === undefined && this.toolCall.truncated === true) { + this.addChild( + new Text( + currentTheme.dim('Tool call arguments truncated by max_tokens — call never executed.'), + 2, + 0, + ), + ); + return; + } + if (name === 'NotifyUser' && isExperimentalFlagEnabled('notify_user')) { + // Collapsed: header only (the panel shows the live text). Expanded: + // the full message as Markdown, indented under the header. + if (!this.expanded) return; + const message = str(this.toolCall.args['message']).trim(); + if (message.length === 0) return; + this.addChild( + new Markdown(message, 2, 0, this.markdownTheme, undefined, createMarkdownOptions()), + ); + return; + } + if (this.result === undefined && this.toolCall.streamingArguments !== undefined) { + this.buildStreamingPreview(this.toolCall.streamingArguments); + return; + } + // Cap Edit's diff as soon as args finalize, not only when the result + // lands — mirroring Write's writeShouldCap below. Otherwise the render + // tick between finalized args (streamingArguments cleared by the + // `tool.call.started` payload) and the result draws the full diff, then + // snaps back to the cap: a height collapse that triggers pi-tui's full + // redraw and wipes scrollback. Streaming frames (streamingArguments set) + // still take buildStreamingPreview above and never reach here. + const shouldCap = !this.expanded; + if (name === 'Write') { + const content = str(this.toolCall.args['content']); + if (content.length === 0) return; + const filePath = str(this.toolCall.args['file_path'] ?? this.toolCall.args['path']); + const lang = langFromPath(filePath); + const allLines = highlightLines(content, lang); + // Cap as soon as args finalize, not just when result lands. Otherwise the + // brief render tick between finalized args and result draws the full file, + // and the snap back to the collapsed cap triggers pi-tui's full-redraw + // path which wipes the terminal scrollback (pre-TUI history). + const writeShouldCap = !this.expanded; + const shown = writeShouldCap ? allLines.slice(0, COMMAND_PREVIEW_LINES) : allLines; + const remaining = allLines.length - shown.length; + for (const [i, line] of shown.entries()) { + const lineNum = currentTheme.dim(String(i + 1).padStart(4) + ' '); + this.addChild(new Text(lineNum + line, 2, 0)); + } + if (writeShouldCap && remaining > 0) { + this.addChild( + new Text( + currentTheme.dim( + `… (${String(remaining)} more lines, ${String(allLines.length)} total, ctrl+o to expand)`, + ), + 2, + 0, + ), + ); + } + } else if (name === 'Edit') { + const oldStr = str(this.toolCall.args['old_string']); + const newStr = str(this.toolCall.args['new_string']); + if (oldStr.length === 0 && newStr.length === 0) return; + const filePath = str(this.toolCall.args['file_path'] ?? this.toolCall.args['path']); + const lines = renderDiffLinesClustered(oldStr, newStr, filePath, { + contextLines: 3, + ...(shouldCap ? { maxLines: COMMAND_PREVIEW_LINES } : {}), + }); + for (const line of lines) { + this.addChild(new Text(line, 2, 0)); + } + } else if (name === 'Bash') { + // Collapsed: the header already carries the command's first line, so no + // command body is added; the outcome row comes from the live tail or the + // result renderer. Expanded: the full command, across the whole lifecycle. + // Owned solely by buildCallPreview so the command never renders twice; + // shellExecutionResultRenderer renders the result only. + if (!this.expanded) return; + const command = str(this.toolCall.args['command']); + if (command.length === 0) return; + this.addChild( + new ShellExecutionComponent({ + command, + showCommand: true, + commandPreviewLines: undefined, + }), + ); + } + } + + /** + * Live-rendering during the `tool.call.delta` streaming window. + * + * For tools we recognise, we reach into the partial JSON (via + * `extractPartialStringField`) and render a stable high-signal + * preview: Write's `content` as highlighted code, Edit's argument + * receive progress, Bash's `$ command`, etc. While args are still + * streaming we render from a bounded preview buffer; once the result lands, + * the preview snaps to the collapsed cap unless the user has expanded. + */ + private buildStreamingPreview(streamText: string): void { + const name = this.toolCall.name; + const previewText = streamText.slice(0, STREAMING_ARGS_PREVIEW_MAX_CHARS); + if (name === 'Write') { + const content = extractPartialStringField(previewText, 'content'); + if (content === undefined || content.length === 0) return; + const filePath = + extractPartialStringField(previewText, 'file_path') ?? + extractPartialStringField(previewText, 'path') ?? + ''; + const lang = langFromPath(filePath); + const allLines = highlightLines(content, lang); + const maxLines = COMMAND_PREVIEW_LINES; + const scrollLines = + allLines.length > maxLines + ? allLines.slice(allLines.length - maxLines) + : allLines; + for (const [i, line] of scrollLines.entries()) { + const originalLineNumber = + allLines.length > maxLines + ? allLines.length - maxLines + i + : i; + const lineNum = currentTheme.dim(String(originalLineNumber + 1).padStart(4) + ' '); + this.addChild(new Text(lineNum + line, 2, 0)); + } + return; + } + if (name === 'Edit') { + const filePath = + extractPartialStringField(previewText, 'file_path') ?? + extractPartialStringField(previewText, 'path') ?? + ''; + const bytes = Buffer.byteLength(previewText, 'utf8'); + const startedAtMs = this.toolCall.streamingStartedAtMs; + const elapsedSeconds = + startedAtMs === undefined ? 0 : Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)); + const target = filePath.length > 0 ? ` for ${filePath}` : ''; + const progress = `Preparing changes${target}... ${formatByteSize(bytes)} · ${formatElapsed( + elapsedSeconds, + )} elapsed`; + this.addChild(new Text(currentTheme.dim(progress), 2, 0)); + return; + } + if (name === 'Bash' && this.expanded) { + const cmd = extractPartialStringField(previewText, 'command'); + if (cmd === undefined || cmd.length === 0) return; + this.addChild( + new ShellExecutionComponent({ + command: cmd, + showCommand: true, + commandPreviewLines: undefined, + }), + ); + } + // Unknown tools: nothing sensible to stream without a schema, so + // leave the body blank and let the header do the talking. + } + + private buildPlanPreview(): void { + // Priority: inline `args.plan`, approved plan parsed from result, then + // asynchronously injected currentPlan used while approval is in flight. + // Once a plan is found, PlanBoxComponent renders it. + const plan = this.resolvePlanForPreview(); + if (plan.length === 0) return; + const path = this.resolvePlanPath(); + this.addChild( + new PlanBoxComponent(plan, this.markdownTheme, currentTheme.color('success'), path, { + status: this.resolvePlanBoxStatus(), + }), + ); + } + + private resolvePlanForPreview(): string { + const inlinePlan = str(this.toolCall.args['plan']); + if (inlinePlan.length > 0) return inlinePlan; + if (this.result !== undefined && !this.result.is_error) { + const approved = extractApprovedPlan(this.result.output); + if (approved.length > 0) return approved; + } + return this.currentPlan ?? ''; + } + + // Priority: approved result.output with 'Plan saved to: <path>', then the + // planPath asynchronously injected by setPlanInfo while approval is in flight. + private resolvePlanPath(): string | undefined { + if (this.result !== undefined && !this.result.is_error) { + const fromResult = interpretExitPlanModeOutcome(this.result.output).path; + if (fromResult !== undefined && fromResult.length > 0) return fromResult; + } + return this.planPath; + } + + private resolvePlanBoxStatus(): { label: string; colorHex: string } | undefined { + const result = this.result; + if (this.toolCall.name !== 'ExitPlanMode' || result === undefined) return undefined; + if (!isExitPlanModeOutcomeOutput(result.output)) return undefined; + const outcome = interpretExitPlanModeOutcome(result.output); + if (outcome.kind !== 'rejected') return undefined; + return { label: 'Rejected', colorHex: currentTheme.color('error') }; + } + + private buildContent(): void { + const { result } = this; + if (result === undefined) return; + + if (this.toolCall.name === 'AgentSwarm') { + this.buildAgentSwarmResultSummary(result); + return; + } + + if (!result.output) return; + + if (this.isSingleSubagentView()) { + return; + } + + // Outputs that start with a `<system-reminder>` tag are harness-injected + // reminders piggy-backing on a tool result (e.g. a finalize hook rewrote + // the output). They are noise for the user, so suppress the body while + // keeping the header chip intact. Match the full reminder tag only: tool + // metadata no longer travels inside `output` (it rides the result's + // `note` side channel), so real output starting with a literal `<system>` + // is user data and must stay visible. + if (result.output.trimStart().startsWith('<system-reminder>')) { + return; + } + + if (this.toolCall.name === 'ExitPlanMode' && isExitPlanModeOutcomeOutput(result.output)) { + // Approved plans are already rendered by buildCallPreview via + // resolvePlanForPreview. Rejected or revise feedback uses a warning label + // plus normal body text so it remains visible in the transcript. + const outcome = interpretExitPlanModeOutcome(result.output); + if (outcome.kind === 'rejected' && outcome.feedback !== undefined) { + const trimmed = outcome.feedback.trim(); + if (trimmed.length > 0) { + const labelTone = (text: string) => currentTheme.boldFg('warning', text); + this.addChild(new Text(labelTone('↪ Suggestion'), 2, 0)); + for (const line of trimmed.split('\n')) { + this.addChild(new Text(line, 4, 0)); + } + } + } + return; + } + + // TodoList: the authoritative list is shown in the dedicated + // TodoPanel before the input area, so repeating the text dump here is + // pure clutter. Keep the headline, drop the body. + if (this.toolCall.name === 'TodoList' && !result.is_error) { + return; + } + + if (this.toolCall.name === 'EnterPlanMode' && !result.is_error) { + return; + } + + // NotifyUser: the message is the call's argument (rendered by + // buildCallPreview when expanded); the acknowledgement output is noise. + if ( + this.toolCall.name === 'NotifyUser' && + isExperimentalFlagEnabled('notify_user') && + !result.is_error && + notifyResultState(result.output) === 'displayed' + ) { + return; + } + + if ( + this.toolCall.name === 'AskUserQuestion' && + this.toolCall.args['background'] !== true && + !result.is_error && + this.renderAskUserQuestionResult(result.output) + ) { + return; + } + + const renderer = pickResultRenderer(this.toolCall.name); + const components = renderer(this.toolCall, result, { + expanded: this.expanded, + }); + for (const component of components) { + this.addChild(component); + } + } + + private buildAgentSwarmResultSummary(result: ToolResultBlockData): void { + const summary = agentSwarmResultSummaryFromOutput(result.output); + const dim = (s: string): string => currentTheme.fg('textDim', s); + const segments: string[] = []; + + if (summary.completed > 0) { + segments.push( + currentTheme.fg('success', `${SUCCESS_MARK.trimEnd()} ${String(summary.completed)} completed`), + ); + } + if (summary.failed > 0) { + segments.push( + currentTheme.fg('error', `${FAILURE_MARK.trimEnd()} ${String(summary.failed)} failed`), + ); + } + if (summary.aborted > 0) { + segments.push( + currentTheme.fg('warning', `${ABORTED_MARK} ${String(summary.aborted)} aborted`), + ); + } + + if (segments.length > 0) { + this.addChild(new Text(`${dim('Agent swarm: ')}${segments.join(dim(' · '))}`, 2, 0)); + return; + } + + const isAborted = result.is_error === true && /\b(?:aborted|cancelled)\b/i.test(result.output); + const colorToken = isAborted ? 'warning' : result.is_error === true ? 'error' : 'success'; + const label = isAborted + ? `${ABORTED_MARK} Aborted.` + : result.is_error === true + ? `${FAILURE_MARK.trimEnd()} Failed.` + : `${SUCCESS_MARK.trimEnd()} Completed.`; + this.addChild(new Text(`${dim('Agent swarm: ')}${currentTheme.fg(colorToken, label)}`, 2, 0)); + } + + /** + * Render AskUserQuestion's JSON payload as a friendly Q/A list. + * Returns true on success (caller skips the default JSON dump); + * false on parse failure (caller falls back to raw display). + */ + private renderAskUserQuestionResult(output: string): boolean { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return false; + } + if (typeof parsed !== 'object' || parsed === null) return false; + + const accent = (text: string) => currentTheme.fg('primary', text); + + const answers = (parsed as { answers?: unknown }).answers; + const note = (parsed as { note?: unknown }).note; + + const hasAnswers = + typeof answers === 'object' && answers !== null && Object.keys(answers).length > 0; + + if (!hasAnswers) { + const noteText = + typeof note === 'string' && note.length > 0 ? note : 'User dismissed the question.'; + this.addChild(new Text(currentTheme.dim(` ${noteText}`), 0, 0)); + return true; + } + + for (const [question, answer] of Object.entries(answers as Record<string, unknown>)) { + const answerText = typeof answer === 'string' ? answer : JSON.stringify(answer); + this.addChild(new Text(` ${currentTheme.dim('Q')} ${question}`, 0, 0)); + this.addChild(new Text(` ${accent('→')} ${answerText}`, 0, 0)); + } + return true; + } +} + +/** + * Computes the second-level "latest activity" line for group rows: + * 1. latest ongoing sub-tool (`Using {name} ({keyArg})`) + * 2. latest finished sub-tool (`Used {name} ({keyArg})`) + * 3. last non-empty line from accumulated subagent text + */ +function computeLatestActivity( + ongoing: ReadonlyMap<string, OngoingSubCall>, + finished: readonly FinishedSubCall[], + text: string, + workspaceDir?: string, +): string | undefined { + if (ongoing.size > 0) { + const lastOngoing = [...ongoing.values()].at(-1); + if (lastOngoing !== undefined) { + return formatActivityLine('Using', lastOngoing.name, lastOngoing.args, workspaceDir); + } + } + if (finished.length > 0) { + const last = finished.at(-1); + if (last !== undefined) { + return formatActivityLine('Used', last.name, last.args, workspaceDir); + } + } + if (text.length > 0) { + const tail = text + .split('\n') + .toReversed() + .find((l) => l.trim().length > 0); + if (tail !== undefined) return tail.trim(); + } + return undefined; +} + +function formatTokens(n: number): string { + return `${formatTokenCount(n)} tok`; +} + +function formatActivityLine( + verb: string, + toolName: string, + args: Record<string, unknown>, + workspaceDir?: string, +): string { + const keyArg = extractKeyArgument(toolName, args, workspaceDir); + return keyArg ? `${verb} ${toolName} (${keyArg})` : `${verb} ${toolName}`; +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts new file mode 100644 index 0000000000000000000000000000000000000000..80e3beaf9f0bd80f377792a2b79cb3c3652a44e1 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts @@ -0,0 +1,168 @@ +/** + * Header chip providers — produce a short "stat" suffix appended to the + * tool call header once a result has arrived. Chips own the *numeric* + * summary (line counts, exit codes, byte sizes), so summary renderers + * below don't repeat them. + * + * A chip returning `''` is suppressed; tools without an entry in the + * registry get no chip at all. + */ + +import { computeDiffLines } from '#/tui/components/media/diff-preview'; +import { OUTCOME_MAX_LINES } from '#/tui/constant/rendering'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; + +import { goalStatusChip } from './goal'; +import { parseGlobOutput, parseGrepOutput, searchNoticeOnly } from './grep-output'; +import { readMediaChip } from './media'; +import { nonEmptyLines } from './outcome'; +import { strArg, stripSpillPointer } from './types'; +import { waitForChip } from './wait-for'; + +export type ChipProvider = (toolCall: ToolCallBlockData, result: ToolResultBlockData) => string; + +export function countNonEmptyLines(text: string): number { + if (text.length === 0) return 0; + let n = 0; + for (const line of text.split('\n')) if (line.length > 0) n++; + return n; +} + +// `partial` marks a lower bound (`12+ files`) when the tool reported an incomplete result set. +function pluralize(n: number, singular: string, plural?: string, partial = false): string { + return `${String(n)}${partial ? '+' : ''} ${n === 1 ? singular : (plural ?? `${singular}s`)}`; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${String(bytes)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +export interface EditStats { + readonly added: number; + readonly removed: number; +} + +export interface WriteStats { + readonly lines: number; +} + +export function computeEditStats(args: Record<string, unknown>): EditStats { + const oldStr = strArg(args, 'old_string'); + const newStr = strArg(args, 'new_string'); + if (oldStr.length === 0 && newStr.length === 0) return { added: 0, removed: 0 }; + const diff = computeDiffLines(oldStr, newStr); + let added = 0; + let removed = 0; + for (const line of diff) { + if (line.kind === 'add') added++; + else if (line.kind === 'delete') removed++; + } + return { added, removed }; +} + +export function computeWriteStats(args: Record<string, unknown>): WriteStats { + const content = strArg(args, 'content'); + const normalized = content.endsWith('\n') ? content.slice(0, -1) : content; + const lines = normalized.length > 0 ? normalized.split('\n').length : 0; + return { lines }; +} + +export function formatEditChip(stats: EditStats): string { + const parts: string[] = []; + if (stats.added > 0) parts.push(`+${String(stats.added)}`); + if (stats.removed > 0) parts.push(`-${String(stats.removed)}`); + return parts.join(' '); +} + +export function formatWriteChip(stats: WriteStats): string { + return pluralize(stats.lines, 'line'); +} + +const editChip: ChipProvider = (toolCall) => { + const stats = computeEditStats(toolCall.args); + if (stats.added === 0 && stats.removed === 0) return ''; + return formatEditChip(stats); +}; + +const writeChip: ChipProvider = (toolCall) => formatWriteChip(computeWriteStats(toolCall.args)); + +const readChip: ChipProvider = (_toolCall, result) => + pluralize(nonEmptyLines(result.output).length, 'line'); + +// A collapsed Bash card shows its output whole when it fits the outcome +// rows; once one line stands in for the rest, the chip counts the hidden +// lines, not the total. A failed command keeps its multi-line preview, whose +// own trailer already counts what is left, so the chip stays out of its way. +const bashChip: ChipProvider = (_toolCall, result) => { + if (result.is_error === true) return ''; + // Counted the way the outcome rows are, so whitespace-only rows neither + // count as hidden nor leave the chip claiming more than the card holds. + const lines = nonEmptyLines(result.output).length; + return lines <= OUTCOME_MAX_LINES ? '' : pluralize(lines - 1, 'more line'); +}; + +// Grep's default mode lists files, so the chip counts what the mode +// returns: files, or matches and the files they fall in. Unnumbered content +// with context flags mixes match and context rows, so only the file count +// is exact there. +const grepChip: ChipProvider = (toolCall, result) => { + // A notice-only result (cut short, or only filtered sensitive files) is not + // an empty search; the glance shows the notice and the chip stays out of + // its way. A paginated count-mode page past the last row still carries + // the totals, so the emptiness check reads the summary-backed file count. + if (searchNoticeOnly(toolCall, result.output)) return ''; + const stats = parseGrepOutput(toolCall, result.output); + if (stats.files === 0) return 'no matches'; + if (stats.mode === 'files_with_matches') return pluralize(stats.files, 'file', undefined, stats.partial); + if (stats.matches === null) return pluralize(stats.files, 'file', undefined, stats.partial); + const matches = pluralize(stats.matches, 'match', 'matches', stats.partial); + // A paginated content result only shows the files on its page. + if (stats.filesPartial) return matches; + return stats.files === 1 + ? `${matches} in 1 file` + : `${matches} across ${pluralize(stats.files, 'file', undefined, stats.partial)}`; +}; + +const globChip: ChipProvider = (toolCall, result) => { + if (searchNoticeOnly(toolCall, result.output)) return ''; + const { entries, partial } = parseGlobOutput(result.output); + if (entries.length === 0) return 'no files'; + return pluralize(entries.length, 'file', undefined, partial); +}; + +const fetchChip: ChipProvider = (_toolCall, result) => + formatBytes(Buffer.byteLength(stripSpillPointer(result.output), 'utf8')); + +const webSearchChip: ChipProvider = (_toolCall, result) => { + const lines = result.output.split('\n').filter((l) => l.trim().length > 0); + let count = 0; + for (const line of lines) { + if (/^\s*(\d+\.|[-*])\s+/.test(line)) count++; + } + if (count === 0) return lines.length === 0 ? 'no results' : 'web result'; + return pluralize(count, 'result'); +}; + +const goalStatusOutputChip: ChipProvider = (_toolCall, result) => + result.is_error ? '' : goalStatusChip(result.output); + +const REGISTRY: Record<string, ChipProvider> = { + Bash: bashChip, + Edit: editChip, + Write: writeChip, + Read: readChip, + ReadMediaFile: readMediaChip, + Grep: grepChip, + Glob: globChip, + FetchURL: fetchChip, + WebSearch: webSearchChip, + CreateGoal: goalStatusOutputChip, + GetGoal: goalStatusOutputChip, + WaitFor: waitForChip, +}; + +export function pickChip(toolName: string): ChipProvider | undefined { + return REGISTRY[toolName]; +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b36ed5489a704c2f407bb53381f632b51c48c8f --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts @@ -0,0 +1,224 @@ +import { Text } from '@moonshot-ai/pi-tui'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; +import { formatTokenCount } from '#/utils/usage/usage-format'; + +import { formatGoalElapsed, pluralizeGoalCount } from '../goal-format'; +import { renderTruncated } from './truncated'; +import type { ResultRenderer } from './types'; + +type GoalToolName = 'CreateGoal' | 'GetGoal' | 'SetGoalBudget' | 'UpdateGoal'; + +interface GoalSnapshotView { + readonly objective: string; + readonly status: string; + readonly turnsUsed: number; + readonly tokensUsed: number; + readonly wallClockMs: number; + readonly terminalReason?: string | undefined; +} + +const GOAL_TOOLS = new Set<string>([ + 'CreateGoal', + 'GetGoal', + 'SetGoalBudget', + 'UpdateGoal', +]); + +export function isGoalToolName(toolName: string): toolName is GoalToolName { + return GOAL_TOOLS.has(toolName); +} + +export const goalSummary: ResultRenderer = (toolCall, result, ctx) => { + if (result.is_error) return renderTruncated(toolCall, result, ctx); + + switch (toolCall.name) { + case 'CreateGoal': + case 'GetGoal': + return renderGoalSnapshot(toolCall, result, ctx); + case 'SetGoalBudget': + case 'UpdateGoal': + return []; + default: + return renderTruncated(toolCall, result, ctx); + } +}; + +export function buildGoalToolHeader(options: { + readonly toolCall: ToolCallBlockData; + readonly result: ToolResultBlockData | undefined; + readonly bullet: string; + readonly chip: string; +}): string | undefined { + const { toolCall, result, bullet, chip } = options; + if (!isGoalToolName(toolCall.name)) return undefined; + + const tone = result?.is_error === true ? 'error' : 'primary'; + const label = currentTheme.boldFg(tone, goalToolLabel(toolCall.name, result, toolCall.args)); + const marker = + result !== undefined && result.is_error !== true + ? currentTheme.fg('primary', STATUS_BULLET) + : bullet; + const arg = + toolCall.name === 'UpdateGoal' + ? undefined + : formatGoalToolArgument(toolCall.name, toolCall.args); + const argText = arg === undefined ? '' : currentTheme.dimFg('textDim', ` (${arg})`); + return `${marker}${label}${argText}${chip}`; +} + +function formatGoalBudgetArg(args: Record<string, unknown>): string | undefined { + const value = args['value']; + const unit = args['unit']; + if (typeof value !== 'number' || !Number.isFinite(value) || typeof unit !== 'string') { + return undefined; + } + if (unit.length === 0) return undefined; + const normalized = unit === 'turns' || unit === 'tokens' + ? Math.max(1, Math.round(value)) + : value; + const singular = unit.endsWith('s') ? unit.slice(0, -1) : unit; + return `${String(normalized)} ${normalized === 1 ? singular : unit}`; +} + +export function goalStatusChip(output: string): string { + const goal = parseGoalValue(output); + if (goal === undefined) return ''; + if (goal === null) return 'no goal'; + return stringField(goal, 'status') ?? ''; +} + +function renderGoalSnapshot( + toolCall: ToolCallBlockData, + result: ToolResultBlockData, + _ctx: Parameters<ResultRenderer>[2], +) { + const goal = parseGoalToolOutput(result.output); + if (goal === undefined) return renderTruncated(toolCall, result, _ctx); + + const muted = (s: string) => currentTheme.dimFg('textDim', s); + const value = (s: string) => currentTheme.fg('text', s); + if (goal === null) return [new Text(muted(' No current goal.'), 0, 0)]; + + const lines = [ + ` ${value(`Goal ${goal.status}: ${truncateOneLine(goal.objective, 96)}`)}`, + ` ${muted(formatGoalStats(goal))}`, + ]; + if (goal.terminalReason !== undefined && goal.terminalReason.length > 0) { + lines.push(` ${muted(goal.terminalReason)}`); + } + return lines.map((line) => new Text(line, 0, 0)); +} + +function goalToolLabel( + toolName: GoalToolName, + result: ToolResultBlockData | undefined, + args: Record<string, unknown>, +): string { + const failed = result?.is_error === true; + const finished = result !== undefined; + switch (toolName) { + case 'CreateGoal': + return failed ? 'Could not start goal' : finished ? 'Started goal' : 'Starting goal'; + case 'GetGoal': + return failed ? 'Could not check goal' : finished ? 'Checked goal' : 'Checking goal'; + case 'SetGoalBudget': + return failed + ? 'Could not set goal budget' + : finished + ? 'Set goal budget' + : 'Setting goal budget'; + case 'UpdateGoal': { + const status = stringArg(args, 'status'); + const suffix = status ?? 'status'; + return failed + ? `Could not report goal ${suffix}` + : finished + ? `Reported goal ${suffix}` + : `Reporting goal ${suffix}`; + } + } +} + +function formatGoalToolArgument( + toolName: GoalToolName, + args: Record<string, unknown>, +): string | undefined { + switch (toolName) { + case 'CreateGoal': { + const objective = stringArg(args, 'objective'); + return objective === undefined ? undefined : truncateOneLine(objective, 60); + } + case 'SetGoalBudget': + return formatGoalBudgetArg(args); + case 'UpdateGoal': + return stringArg(args, 'status'); + case 'GetGoal': + return undefined; + } +} + +export function parseGoalToolOutput(output: string): GoalSnapshotView | null | undefined { + const goal = parseGoalValue(output); + if (goal === undefined || goal === null) return goal; + const objective = stringField(goal, 'objective'); + const status = stringField(goal, 'status'); + if (objective === undefined || status === undefined) return undefined; + return { + objective, + status, + turnsUsed: numberField(goal, 'turnsUsed'), + tokensUsed: numberField(goal, 'tokensUsed'), + wallClockMs: numberField(goal, 'wallClockMs'), + terminalReason: stringField(goal, 'terminalReason'), + }; +} + +function parseGoalValue(output: string): Record<string, unknown> | null | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return undefined; + } + if (!isRecord(parsed) || !('goal' in parsed)) return undefined; + const goal = parsed['goal']; + if (goal === null) return null; + if (!isRecord(goal)) return undefined; + return goal; +} + +function formatGoalStats(goal: GoalSnapshotView): string { + return [ + pluralizeGoalCount(goal.turnsUsed, 'turn'), + `${formatTokenCount(goal.tokensUsed)} tokens`, + formatGoalElapsed(goal.wallClockMs), + ].join(' · '); +} + +function truncateOneLine(text: string, max: number): string { + const firstLine = text.replaceAll(/\s+/g, ' ').trim(); + if (firstLine.length <= max) return firstLine; + return `${firstLine.slice(0, Math.max(0, max - 1))}…`; +} + +function stringArg(args: Record<string, unknown>, key: string): string | undefined { + const value = args[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringField(record: Record<string, unknown>, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' ? value : undefined; +} + +function numberField(record: Record<string, unknown>, key: string): number { + const value = record[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts new file mode 100644 index 0000000000000000000000000000000000000000..36111c3930348be984518263bfcd94c5e66a3680 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts @@ -0,0 +1,212 @@ +/** + * Shape-aware reading of Grep and Glob output for the header chip and the + * glance row. Both tools append notices (pagination, sensitive-file + * filtering, timeouts) and an empty-result sentence around the result lines; + * those must stay out of the counts and the path samples. + */ + +import type { ToolCallBlockData } from '#/tui/types'; + +import { strArg, stripSpillPointer } from './types'; + +export type GrepMode = 'files_with_matches' | 'content' | 'count_matches'; + +export interface GrepEntry { + /** File the entry belongs to. */ + readonly path: string; + /** What the glance shows for it: `path`, `path:line`, or `path:count`. */ + readonly label: string; +} + +export interface GrepStats { + readonly mode: GrepMode; + /** Glance samples in output order; unnumbered content rows collapse to one entry per file. */ + readonly entries: readonly GrepEntry[]; + /** + * Entries in the whole result set — the tool-reported total when the + * result is paginated — which the glance counts its "+N more" against. + */ + readonly total: number; + /** + * What the mode counts: files in `files_with_matches`, matching lines in + * `content`, the summed per-file counts in `count_matches`. `null` when the + * count is not derivable from the text: unnumbered content rows with + * context flags are indistinguishable from context rows. + */ + readonly matches: number | null; + /** Files in the whole result set when the tool reported a total (paginated results), else the files seen. */ + readonly files: number; + /** True when a paginated content result only shows the files on its page, so `files` is a lower bound. */ + readonly filesPartial: boolean; + /** True when the tool reported an incomplete result set (timeout or output cap): every count is a lower bound. */ + readonly partial: boolean; +} + +export interface GlobStats { + readonly entries: readonly string[]; + /** True when Glob timed out or hit its match cap: the count is a lower bound. */ + readonly partial: boolean; +} + +// Lines the tools add around the results: the empty-result sentence, the +// count-mode summary, and the pagination / filtering / timeout notices. +// Glob prepends its own diagnostics (timeout, truncation, read warnings whose +// ripgrep stderr continues on `rg:` lines) and appends an exact-cap count line. +const NOTICE = + /^(?:No matches found|No non-sensitive matches found|Found \d+ total (?:non-sensitive )?occurrences? across |Found \d+ matches$|Filtered \d+ sensitive file|Results truncated to \d+ lines|\[Output truncated at \d+ bytes|Grep timed out after |Glob timed out after |Glob completed with warnings|\[stdout truncated at |\[Truncated at |Only the first |rg: )/; + +// Totals the tool reports for the whole result set when it paginates: the +// count-mode summary covers every file, and the pagination notice's total is +// the full line count — the file count in files mode. +const COUNT_SUMMARY = /^Found (\d+) total (?:non-sensitive )?occurrences? across (\d+) files?\.$/m; +const PAGINATION_TOTAL = /^Results truncated to \d+ lines \(total: (\d+)/m; +// Notices that mark the result set itself as incomplete, as opposed to merely paginated. +const INCOMPLETE = + /^(?:\[Output truncated at \d+ bytes|Grep timed out after |Glob timed out after |Glob completed with warnings|\[stdout truncated at |\[Truncated at \d+ matches|Only the first \d+ matches)/m; + +const GLOB_PAGE = /^Showing matches (\d+)–(\d+) of (\d+)( collected matches \(partial result set\))?\.$/m; +const GLOB_CONTINUATION = /^(?:Continue with the same search arguments and offset=\d+\.|(?:To retrieve all collected matches in one search|To remove the match-count limit), omit offset and use head_limit=0\.|Character limit reached; only complete paths are returned\.)$/; +const GLOB_EMPTY = /^(?:No more matches at offset=\d+ in the (?:current|collected partial) result set \(\d+ matches\)\.|No matches collected; search incomplete\.)$/m; + +// `path:line:text`; context lines use `-` separators and are not matches. +const CONTENT_MATCH = /^(.+?):(\d+):/; +const COUNT_LINE = /^(.+):(\d+)$/; +// A Windows drive letter carries its own colon; the separator search skips it. +const DRIVE_PREFIX = /^[A-Za-z]:[\\/]/; + +function resultLines(output: string): string[] { + if (output.length === 0) return []; + return stripSpillPointer(output) + .split('\n') + .filter((line) => line.length > 0 && line !== '--' && !NOTICE.test(line)); +} + +export function grepMode(toolCall: ToolCallBlockData): GrepMode { + const mode = strArg(toolCall.args, 'output_mode'); + return mode === 'content' || mode === 'count_matches' ? mode : 'files_with_matches'; +} + +export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): GrepStats { + const mode = grepMode(toolCall); + const lines = resultLines(output); + const partial = INCOMPLETE.test(output); + + if (mode === 'files_with_matches') { + const entries = lines.map((path) => ({ path, label: path })); + const total = PAGINATION_TOTAL.exec(output)?.[1]; + const files = total === undefined ? entries.length : Number(total); + return { mode, entries, total: files, matches: files, files, filesPartial: false, partial }; + } + + if (mode === 'count_matches') { + const entries: GrepEntry[] = []; + let matches = 0; + for (const line of lines) { + const [, path, count] = COUNT_LINE.exec(line) ?? []; + if (path === undefined || count === undefined) continue; + entries.push({ path, label: line }); + matches += Number(count); + } + const [, totalMatches, totalFiles] = COUNT_SUMMARY.exec(output) ?? []; + if (totalMatches !== undefined && totalFiles !== undefined) { + return { + mode, + entries, + total: Number(totalFiles), + matches: Number(totalMatches), + files: Number(totalFiles), + filesPartial: false, + partial, + }; + } + return { + mode, + entries, + total: entries.length, + matches, + files: entries.length, + filesPartial: false, + partial, + }; + } + + // Content mode: with line numbers (the default) only `path:line:` rows are + // matches; without them every match row is `path:text`, and context rows + // (`-A`/`-B`/`-C`) look exactly the same — the backend separates fields + // with ':' unconditionally — so an exact match count is unknowable then. + const numbered = toolCall.args['-n'] !== false; + // The schema allows zero, which asks for no context rows at all, and a + // defined `-C` makes the backend drop `-A`/`-B` entirely. + const positive = (flag: string): boolean => { + const value = toolCall.args[flag]; + return typeof value === 'number' && value > 0; + }; + const hasContext = + typeof toolCall.args['-C'] === 'number' ? positive('-C') : positive('-A') || positive('-B'); + const countable = numbered || !hasContext; + const entries: GrepEntry[] = []; + const paths = new Set<string>(); + let rows = 0; + for (const line of lines) { + if (numbered) { + const [, path, lineNumber] = CONTENT_MATCH.exec(line) ?? []; + if (path === undefined || lineNumber === undefined) continue; + rows++; + paths.add(path); + entries.push({ path, label: `${path}:${lineNumber}` }); + continue; + } + // Unnumbered rows are labelled by their path alone, so the glance lists + // each file once instead of repeating it per match or context row. + const idx = line.indexOf(':', DRIVE_PREFIX.test(line) ? 2 : 0); + const path = idx > 0 ? line.slice(0, idx) : line; + rows++; + if (paths.has(path)) continue; + paths.add(path); + entries.push({ path, label: path }); + } + // Without context flags every paginated row is a match, so the tool's + // total is the exact match count; the files beyond the page stay unknown. + const paginatedTotal = hasContext ? undefined : PAGINATION_TOTAL.exec(output)?.[1]; + const matches = countable ? (paginatedTotal === undefined ? rows : Number(paginatedTotal)) : null; + return { + mode, + entries, + total: numbered && matches !== null ? matches : paths.size, + matches, + files: paths.size, + filesPartial: paginatedTotal !== undefined, + partial, + }; +} + +export function parseGlobOutput(output: string): GlobStats { + const page = GLOB_PAGE.exec(output); + const entries = resultLines(output).filter((line) => + !GLOB_PAGE.test(line) && !GLOB_CONTINUATION.test(line) && !GLOB_EMPTY.test(line), + ); + const partial = INCOMPLETE.test(output) || + (page !== null && (Number(page[2]) < Number(page[3]) || page[4] !== undefined)); + return { entries, partial }; +} + +// Every match was a file the tool excludes as sensitive: the search did find +// something, and the notice says why nothing is listed. +const SENSITIVE_ONLY = /^No non-sensitive matches found/m; + +/** + * Whether a Grep or Glob result is only the tool's notice: the search was cut + * short (timeout, output cap, unreadable directories) before any row, or every + * match was a filtered sensitive file. Such a card shows the notice as a plain + * outcome row, the same way in both states, and carries no count. + */ +export function searchNoticeOnly(toolCall: ToolCallBlockData, output: string): boolean { + const noRows = + toolCall.name === 'Glob' + ? parseGlobOutput(output).entries.length === 0 + : parseGrepOutput(toolCall, output).entries.length === 0; + return noRows && ( + INCOMPLETE.test(output) || SENSITIVE_ONLY.test(output) || + (toolCall.name === 'Glob' && GLOB_EMPTY.test(output)) + ); +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts new file mode 100644 index 0000000000000000000000000000000000000000..528e24fe204579f46a43f303682f6a823df84468 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts @@ -0,0 +1,144 @@ +/** + * ReadMediaFile renderer. + * + * The ReadMediaFile tool `output` is the JSON-serialized array of + * content parts the tool returned — which includes the full base64 of + * the image/video. Dumping that string into the transcript blasts a + * multi-screen blob of base64. This renderer parses the envelope and + * surfaces just the human-readable bits (kind, path, mime, size) via + * a header chip + a tiny expanded body. It never emits the base64. + * + * On error, or when the output isn't the expected media envelope, we + * fall back to the truncated renderer so the user still sees the raw + * message. + */ + +import type { Component } from '@moonshot-ai/pi-tui'; +import { Text } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +import type { ChipProvider } from './chip'; +import { renderTruncated } from './truncated'; +import type { ResultRenderer } from './types'; + +export interface ReadMediaSummary { + kind: 'image' | 'video'; + path?: string; + mimeType?: string; + bytes?: number; + url?: string; +} + +const PATH_TAG_RE = /^<(image|video)\s+path="([^"]+)">$/; +const DATA_URL_RE = /^data:([^;]+);base64,(.*)$/s; + +function bytesFromBase64(b64: string): number { + const len = b64.length; + if (len === 0) return 0; + const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0; + return Math.floor((len * 3) / 4) - padding; +} + +export function parseReadMediaOutput(output: string): ReadMediaSummary | null { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return null; + } + if (!Array.isArray(parsed)) return null; + + let kind: 'image' | 'video' | undefined; + let path: string | undefined; + let mimeType: string | undefined; + let bytes: number | undefined; + let url: string | undefined; + let foundMedia = false; + + for (const raw of parsed) { + if (typeof raw !== 'object' || raw === null) continue; + const part = raw as Record<string, unknown>; + const type = part['type']; + + if (type === 'text' && typeof part['text'] === 'string') { + const tag = PATH_TAG_RE.exec(part['text']); + if (tag) { + kind = tag[1] as 'image' | 'video'; + path = tag[2]; + } + continue; + } + + if (type === 'image_url' || type === 'video_url') { + foundMedia = true; + kind = type === 'image_url' ? 'image' : 'video'; + const holder = part[type === 'image_url' ? 'imageUrl' : 'videoUrl']; + if (typeof holder === 'object' && holder !== null) { + const h = holder as Record<string, unknown>; + const u = h['url']; + if (typeof u === 'string') { + const data = DATA_URL_RE.exec(u); + if (data && data[1] !== undefined && data[2] !== undefined) { + mimeType = data[1]; + bytes = bytesFromBase64(data[2]); + } else { + url = u; + } + } + } + } + } + + if (!foundMedia || kind === undefined) return null; + + const summary: ReadMediaSummary = { kind }; + if (path !== undefined) summary.path = path; + if (mimeType !== undefined) summary.mimeType = mimeType; + if (bytes !== undefined) summary.bytes = bytes; + if (url !== undefined) summary.url = url; + return summary; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${String(bytes)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function metaSegments(summary: ReadMediaSummary): string[] { + const segs: string[] = []; + if (summary.mimeType !== undefined) segs.push(summary.mimeType); + if (summary.bytes !== undefined) segs.push(formatBytes(summary.bytes)); + return segs; +} + +export const readMediaChip: ChipProvider = (_toolCall, result) => { + if (result.is_error) return ''; + const summary = parseReadMediaOutput(result.output); + if (summary === null) return ''; + const meta = metaSegments(summary); + if (meta.length === 0) { + return summary.url !== undefined ? `${summary.kind} · uploaded` : summary.kind; + } + return `${summary.kind} (${meta.join(', ')})`; +}; + +export const readMediaSummary: ResultRenderer = (toolCall, result, ctx) => { + if (result.is_error) return renderTruncated(toolCall, result, ctx); + const summary = parseReadMediaOutput(result.output); + if (summary === null) return renderTruncated(toolCall, result, ctx); + if (!ctx.expanded) return []; + + const dim = (text: string): string => currentTheme.dim(text); + const out: Component[] = []; + if (summary.path !== undefined) { + out.push(new Text(` ${dim(summary.path)}`, 0, 0)); + } + const meta = metaSegments(summary); + const tail: string[] = [summary.kind]; + if (meta.length > 0) tail.push(meta.join(', ')); + if (summary.url !== undefined) tail.push(summary.url); + out.push(new Text(` ${dim(tail.join(' · '))}`, 0, 0)); + return out; +}; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca57a80253cfe1dbd332e8c66eea9a434b8c2e4e --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts @@ -0,0 +1,69 @@ +/** + * The collapsed card's outcome rows: dim, width-truncated lines under the + * header that state what came of the call. Output short enough to fit + * (`OUTCOME_MAX_LINES`) is shown whole; longer output contributes one telling + * line — a command's last line, an MCP tool's first line — marked with an + * ellipsis on the side it was cut from, and the rest waits for ctrl+o. Cards + * without any output stay single-row. + */ + +import type { Component } from '@moonshot-ai/pi-tui'; + +import { OUTCOME_MAX_LINES, OUTCOME_ROW_INDENT, TRUNCATION_ELLIPSIS } from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; +import { sanitizeShellOutput } from '#/tui/utils/shell-output'; + +import { TruncatedHeaderLine } from '../truncated-header-line'; +import { stripSpillPointer } from './types'; + +// One shared reference so the line's render cache survives rebuilds (segment +// styles are compared by identity); the palette is read at call time. +const dimOutcomeStyle = (text: string): string => currentTheme.dim(text); + +/** + * Output lines worth a row, with terminal control sequences removed: an + * outcome row is a dim one-line digest, so a tool's own colours are noise + * there, and a colour left open past the width cut would bleed into the + * row's ellipsis and tail. The per-line spill pointer is metadata, not + * output. Expanded bodies keep the raw output. + */ +export function nonEmptyLines(text: string): string[] { + return sanitizeShellOutput(stripSpillPointer(text)) + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => line.trimEnd()); +} + +/** One outcome row with a custom fixed tail (the Grep glance's `, +N more`). */ +export function outcomeRow(head: string, text: string, tail: string): Component { + return new TruncatedHeaderLine({ + head, + flex: { text, style: dimOutcomeStyle, keep: 'head' }, + tail: tail.length > 0 ? dimOutcomeStyle(tail) : '', + }); +} + +/** + * One outcome row. `more` marks hidden output with an ellipsis on the side it + * was cut from — `above` when this is the last line of a longer output, + * `below` when it is the first. The marker lives in the fixed head/tail so a + * width cut never eats it. + */ +export function outcomeLine(text: string, more?: 'above' | 'below'): Component { + return outcomeRow( + more === 'above' ? `${OUTCOME_ROW_INDENT}${TRUNCATION_ELLIPSIS} ` : OUTCOME_ROW_INDENT, + text, + more === 'below' ? ` ${TRUNCATION_ELLIPSIS}` : '', + ); +} + +/** + * Rows for a finished call's output: every line when there are at most + * `OUTCOME_MAX_LINES`, otherwise the one line named by `keep`. + */ +export function outcomeRows(output: string, keep: 'first' | 'last'): Component[] { + const lines = nonEmptyLines(output); + if (lines.length <= OUTCOME_MAX_LINES) return lines.map((line) => outcomeLine(line)); + const line = keep === 'first' ? lines[0] : lines.at(-1); + return line === undefined ? [] : [outcomeLine(line, keep === 'first' ? 'below' : 'above')]; +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts new file mode 100644 index 0000000000000000000000000000000000000000..1da165e2daab07920dd9da2df23ce457b80faa50 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts @@ -0,0 +1,74 @@ +/** + * Tool result renderer registry. + * + * Each tool name maps to a `ResultRenderer` that turns the tool's + * `ToolResultBlockData` into renderable Components. Tools without an + * explicit entry fall through to `renderTruncated` (short output shown whole + * when collapsed, otherwise its first line; full output on ctrl+o; errors + * always previewed). + * + * Keep this dispatch flat — tool names live next to the renderer they + * choose, so adding a new tool means appending one case. + */ + +import { readMediaSummary } from './media'; +import { shellExecutionResultRenderer } from '../shell-execution'; +import { goalSummary } from './goal'; +import { waitForSummary } from './wait-for'; +import { + fetchSummary, + fileChangeSummary, + globSummary, + grepSummary, + readSummary, + thinkSummary, + webSearchSummary, +} from './summary'; +import { renderTruncated } from './truncated'; +import type { ResultRenderer } from './types'; + +/** + * True when a tool has no dedicated renderer and falls back to the generic + * truncated output (every MCP tool and any tool not listed below). Used to + * decide whether subagent sub-tool output should be previewed the same way + * the main agent previews it. + */ +export function isGenericToolResult(toolName: string): boolean { + return pickResultRenderer(toolName) === renderTruncated; +} + +export function pickResultRenderer(toolName: string): ResultRenderer { + switch (toolName) { + case 'Read': + return readSummary; + case 'ReadMediaFile': + return readMediaSummary; + case 'Grep': + return grepSummary; + case 'Glob': + return globSummary; + case 'FetchURL': + return fetchSummary; + case 'WebSearch': + return webSearchSummary; + case 'Bash': + return shellExecutionResultRenderer; + case 'Think': + return thinkSummary; + case 'Edit': + return fileChangeSummary; + case 'Write': + return fileChangeSummary; + case 'CreateGoal': + case 'GetGoal': + case 'SetGoalBudget': + case 'UpdateGoal': + return goalSummary; + case 'WaitFor': + return waitForSummary; + default: + return renderTruncated; + } +} + +export type { ResultRenderer } from './types'; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts new file mode 100644 index 0000000000000000000000000000000000000000..cbf38edb642bd418ace31f3b6ff411ba8961a52f --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -0,0 +1,113 @@ +/** + * Summary-style renderers — produce an inline glance for tools whose raw + * output is high-volume but low-information (Grep, Glob). The numeric + * summary (line counts, sizes) lives in the header chip (see chip.ts); the + * glance is the collapsed card's outcome row, and the raw output only + * appears when the global expand toggle is on. + * + * Errors always fall through to the truncated renderer so the user + * sees the actual error message, not a synthetic summary. + */ + +import type { Component } from '@moonshot-ai/pi-tui'; +import { Text } from '@moonshot-ai/pi-tui'; + +import { OUTCOME_GLANCE_SAMPLES, OUTCOME_ROW_INDENT } from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; + +import { parseGlobOutput, parseGrepOutput, searchNoticeOnly } from './grep-output'; +import { outcomeRow, outcomeRows } from './outcome'; +import { renderTruncated } from './truncated'; +import { isSpilledToolOutput, type ResultRenderer } from './types'; + +interface Glance { + readonly samples: string; + readonly moreCount: number; +} + +// `'fallback'` hands the result to the generic renderer: a search the tool cut +// short before any row is only its notice, which beats an exact-looking +// empty glance. +type GlanceFn = ( + toolCall: Parameters<ResultRenderer>[0], + result: Parameters<ResultRenderer>[1], +) => Glance | null | 'fallback'; + +function withGlance(glance: GlanceFn | null): ResultRenderer { + return (toolCall, result, ctx) => { + // A spilled result is the truncation envelope, not data: its first line + // tells the user the output was saved to a file. + if (result.is_error || isSpilledToolOutput(result.output)) { + return renderTruncated(toolCall, result, ctx); + } + + const out: Component[] = []; + // Collapsed: the glance is the card's outcome row — path samples in the + // flexible middle and the "+N more" count in the fixed tail, so a width + // cut drops samples, never the count. Expanded: one joined line above + // the raw output. + if (glance !== null) { + const parts = glance(toolCall, result); + if (parts === 'fallback') return renderTruncated(toolCall, result, ctx); + if (parts !== null) { + const tail = parts.moreCount > 0 ? `, +${String(parts.moreCount)} more` : ''; + out.push( + ctx.expanded + ? new Text(` ${currentTheme.dim(`${parts.samples}${tail}`)}`, 0, 0) + : outcomeRow(OUTCOME_ROW_INDENT, parts.samples, tail), + ); + } + } + if (ctx.expanded && result.output.length > 0) { + out.push(new Text(currentTheme.dim(result.output), 4, 0)); + } + return out; + }; +} + +function sampleList(labels: readonly string[], total = labels.length): Glance | null { + if (labels.length === 0) return null; + const samples = labels.slice(0, OUTCOME_GLANCE_SAMPLES); + return { samples: samples.join(', '), moreCount: total - samples.length }; +} + +// Path samples in the shape the mode returns — `path`, `path:line` (the +// matched text is dropped), or `path:count` — with the tool's notices left +// out. A paginated result counts "+N more" against the tool-reported total, +// not just the page. +const grepGlance: GlanceFn = (toolCall, result) => { + if (searchNoticeOnly(toolCall, result.output)) return 'fallback'; + const stats = parseGrepOutput(toolCall, result.output); + const labels = stats.entries.map((entry) => entry.label); + return sampleList(labels, Math.max(labels.length, stats.total)); +}; + +const globGlance: GlanceFn = (toolCall, result) => { + if (searchNoticeOnly(toolCall, result.output)) return 'fallback'; + return sampleList(parseGlobOutput(result.output).entries); +}; + +// ── Exports ────────────────────────────────────────────────────────── + +// Tools whose chip already conveys everything — the body is empty in +// the collapsed state and only the raw output appears when expanded. +export const readSummary: ResultRenderer = withGlance(null); +export const fetchSummary: ResultRenderer = withGlance(null); +export const webSearchSummary: ResultRenderer = withGlance(null); +export const thinkSummary: ResultRenderer = withGlance(null); + +// Edit and Write acknowledge success with one line the card already tells +// (`Replaced N occurrences in path`, `Wrote N bytes to path`): the header +// carries the path, the chip the size, and the call preview the change. Any +// other successful output (`No changes to make…`) is worth a row, shown the +// same way in both states so ctrl+o has nothing to add. +const FILE_CHANGE_ACK = /^(?:Replaced \d+ occurrences? in |(?:Wrote|Appended) \d+ bytes to )/; +export const fileChangeSummary: ResultRenderer = (toolCall, result, ctx) => { + if (result.is_error) return renderTruncated(toolCall, result, ctx); + if (FILE_CHANGE_ACK.test(result.output)) return []; + return outcomeRows(result.output, 'first'); +}; + +// Tools that benefit from inline path samples below the chip. +export const grepSummary: ResultRenderer = withGlance(grepGlance); +export const globSummary: ResultRenderer = withGlance(globGlance); diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a7db98d3fbe65b83ab0d7190c0caee5b5577b74 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -0,0 +1,125 @@ +import { Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; + +import { outcomeRows } from './outcome'; +import type { ResultRenderer } from './types'; +import { PREVIEW_LINES } from './types'; + +const DEFAULT_INDENT = 2; + +export function trimTrailingEmptyLines(lines: string[]): string[] { + let end = lines.length; + while (end > 0) { + const line = lines[end - 1]; + if (line === undefined || line.length > 0) break; + end--; + } + return lines.slice(0, end); +} + +/** + * Component that renders tool output with wrap-aware line truncation. + * Uses pi-tui's Text component to compute actual visual wrapped lines, + * then caps at PREVIEW_LINES. This handles long single-line output (e.g. + * JSON blobs) that would otherwise wrap to dozens of visual rows. + */ +export class TruncatedOutputComponent implements Component { + private textComponent: Text; + private readonly expanded: boolean; + private readonly maxLines: number; + private readonly indent: number; + private readonly expandHint: boolean; + private readonly tail: boolean; + /** Whether the last collapsed render cut rows; kept while expanded so the footer can still offer collapse. */ + private truncatedAtLastRender = false; + + constructor( + output: string, + options: { + expanded: boolean; + isError: boolean | undefined; + maxLines?: number; + indent?: number; + // When false, the truncation footer omits the "ctrl+o to expand" promise + // (for contexts whose output is fixed-truncated and never expands). + expandHint?: boolean; + // When true, collapsed rendering keeps the latest visual rows instead of + // the first rows. This is useful for live output from a running command. + tail?: boolean; + // Foreground colour for successful (non-error) output. Defaults to + // `textDim`; Bash passes `textMuted` so its result sits one shade below + // the `textDim` command. Error output always uses `error`. + color?: keyof ColorPalette; + }, + ) { + this.expanded = options.expanded; + this.maxLines = options.maxLines ?? PREVIEW_LINES; + this.indent = options.indent ?? DEFAULT_INDENT; + this.expandHint = options.expandHint ?? true; + this.tail = options.tail ?? false; + const cleaned = trimTrailingEmptyLines(output.split('\n')).join('\n'); + const successColor = options.color ?? 'textDim'; + this.textComponent = new Text( + options.isError ? currentTheme.fg('error', cleaned) : currentTheme.fg(successColor, cleaned), + this.indent, + 0, + ); + } + + invalidate(): void { + // Text component caches wrapped lines; invalidate on terminal resize. + this.textComponent.invalidate(); + } + + private renderHint(width: number, hint: string): string { + const indentWidth = Math.min(this.indent, Math.max(0, width)); + const hintWidth = Math.max(0, width - indentWidth); + return ' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')); + } + + /** Whether the collapsed preview last cut rows away, which ctrl+o reveals. */ + wasTruncated(): boolean { + return this.truncatedAtLastRender; + } + + render(width: number): string[] { + const contentLines = this.textComponent.render(width); + if (!this.expanded) this.truncatedAtLastRender = contentLines.length > this.maxLines; + + if (this.expanded || contentLines.length <= this.maxLines) { + return contentLines; + } + + const remaining = contentLines.length - this.maxLines; + if (this.tail) { + const shown = contentLines.slice(contentLines.length - this.maxLines); + return [ + this.renderHint(width, `… (${String(remaining)} earlier lines)`), + ...shown, + ]; + } + + const shown = contentLines.slice(0, this.maxLines); + const hint = this.expandHint + ? `… (${String(remaining)} more lines, ctrl+o to expand)` + : `… (${String(remaining)} more lines)`; + return [...shown, this.renderHint(width, hint)]; + } +} + +// Collapsed cards show the header plus the outcome rows: a successful result +// is shown whole when short, otherwise contributes its first non-empty line, +// and the rest waits for the global ctrl+o expand; errors always keep their +// multi-line preview so a failure is never reduced to a single line. +export const renderTruncated: ResultRenderer = (_toolCall, result, ctx) => { + if (!result.output) return []; + if (!ctx.expanded && result.is_error !== true) return outcomeRows(result.output, 'first'); + return [ + new TruncatedOutputComponent(result.output, { + expanded: ctx.expanded, + isError: result.is_error ?? false, + }), + ]; +}; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2844f70e934d2dbffd2310ad69bed141e46a611 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts @@ -0,0 +1,47 @@ +import type { Component } from '@moonshot-ai/pi-tui'; + +import { RESULT_PREVIEW_LINES } from '#/tui/constant/rendering'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; + +export interface RendererContext { + readonly expanded: boolean; +} + +export type ResultRenderer = ( + toolCall: ToolCallBlockData, + result: ToolResultBlockData, + ctx: RendererContext, +) => Component[]; + +export const PREVIEW_LINES = RESULT_PREVIEW_LINES; + +/** + * Whether a tool result is the truncation envelope agent-core substitutes for + * output over its size cap (metadata, `output_path`, and a head/tail preview). + * Renderers that count or sample result lines must not read it as data. + */ +export function isSpilledToolOutput(output: string): boolean { + return output.startsWith('Tool output exceeded '); +} + +export function strArg(args: Record<string, unknown>, ...keys: string[]): string { + for (const key of keys) { + const v = args[key]; + if (typeof v === 'string' && v.length > 0) return v; + } + return ''; +} + +const PER_LINE_SPILL_POINTER = '[Per-line truncation occurred;'; + +/** + * Drop the pointer agent-core appends when an oversized result kept its + * shape but had long lines cut: three bracketed lines, `output_path` and + * `next_step` included, that are metadata rather than output. Counts and + * outcome rows read the output without it; the expanded body keeps it. + */ +export function stripSpillPointer(output: string): string { + if (output.startsWith(PER_LINE_SPILL_POINTER)) return ''; + const at = output.indexOf(`\n${PER_LINE_SPILL_POINTER}`); + return at < 0 ? output : output.slice(0, at); +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/wait-for.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/wait-for.ts new file mode 100644 index 0000000000000000000000000000000000000000..04f71e79e28d80af5dbc53421c8c9b4f95740ba4 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/wait-for.ts @@ -0,0 +1,179 @@ +/** + * WaitFor renderer — the wait result is a timeline (header fields, then + * `[finished]` / `[completed_during_wait]` / `[still_running]` sections), + * so the collapsed body shows what the wait came back with instead of the + * raw key-value dump: the finished task with its outcome, plus counts of + * tasks that finished alongside or are still running. A timeout is not an + * error (the tool says so itself), so it renders in the warning tone. + */ + +import { Text, type Component } from '@moonshot-ai/pi-tui'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; + +import { formatGoalElapsed } from '../goal-format'; +import { renderTruncated } from './truncated'; +import type { ResultRenderer } from './types'; + +const DESCRIPTION_MAX = 72; +const RUNNING_SAMPLES = 3; + +type WaitForStatus = 'completed' | 'timed_out' | 'no_tasks'; + +interface WaitForResultView { + readonly status: WaitForStatus; + readonly waitedMs: number; + readonly finishedTaskId?: string; + readonly finishedStatus?: string; + readonly finishedDescription?: string; + readonly extraCount: number; + readonly runningCount: number; + readonly runningSamples: readonly string[]; +} + +export const waitForSummary: ResultRenderer = (toolCall, result, ctx) => { + if (result.is_error) return renderTruncated(toolCall, result, ctx); + const view = parseWaitForOutput(result.output); + if (view === undefined) return renderTruncated(toolCall, result, ctx); + + const out: Component[] = []; + for (const line of glanceLines(view)) { + out.push(new Text(` ${currentTheme.dim(line)}`, 0, 0)); + } + if (ctx.expanded && result.output.length > 0) { + out.push(new Text(currentTheme.dim(result.output), 4, 0)); + } + return out; +}; + +export function buildWaitForHeader(options: { + readonly toolCall: ToolCallBlockData; + readonly result: ToolResultBlockData | undefined; + readonly bullet: string; + readonly chip: string; +}): string | undefined { + const { toolCall, result, bullet, chip } = options; + if (toolCall.name !== 'WaitFor') return undefined; + + const taskId = typeof toolCall.args['task_id'] === 'string' ? toolCall.args['task_id'] : undefined; + const argText = + taskId === undefined ? '' : currentTheme.dimFg('textDim', ` (${taskId})`); + + if (result === undefined) { + const label = + taskId === undefined ? 'Waiting for any background task' : 'Waiting for background task'; + return `${bullet}${currentTheme.boldFg('primary', label)}${argText}`; + } + if (result.is_error === true) { + return `${bullet}${currentTheme.boldFg('error', 'Could not wait for background task')}${argText}`; + } + + const status = parseWaitForOutput(result.output)?.status; + if (status === 'timed_out') { + return `${currentTheme.fg('warning', STATUS_BULLET)}${currentTheme.boldFg('warning', 'Wait timed out')}${argText}${chip}`; + } + if (status === 'no_tasks') { + return `${bullet}${currentTheme.boldFg('primary', 'No background tasks running')}${chip}`; + } + const label = taskId === undefined ? 'Waited for a background task' : 'Waited for background task'; + return `${bullet}${currentTheme.boldFg('primary', label)}${argText}${chip}`; +} + +export const waitForChip = (_toolCall: ToolCallBlockData, result: ToolResultBlockData): string => { + if (result.is_error === true) return ''; + const view = parseWaitForOutput(result.output); + if (view === undefined || view.status === 'no_tasks') return ''; + return formatGoalElapsed(view.waitedMs); +}; + +function glanceLines(view: WaitForResultView): string[] { + switch (view.status) { + case 'no_tasks': + return []; + case 'timed_out': { + if (view.runningCount === 0) return []; + const summary = `${pluralizeTasks(view.runningCount)} still running`; + if (view.runningSamples.length === 0) return [summary]; + const remaining = view.runningCount - view.runningSamples.length; + const tail = remaining > 0 ? `, +${String(remaining)} more` : ''; + return [`${summary}: ${view.runningSamples.join(', ')}${tail}`]; + } + case 'completed': { + const taskId = view.finishedTaskId ?? 'task'; + const status = view.finishedStatus ?? 'completed'; + const marker = status === 'completed' ? '✓' : '✗'; + const description = + view.finishedDescription === undefined + ? '' + : ` · ${truncateOneLine(view.finishedDescription, DESCRIPTION_MAX)}`; + const lines = [`${marker} ${taskId} ${status}${description}`]; + const parts: string[] = []; + if (view.extraCount > 0) parts.push(`+${String(view.extraCount)} more finished during wait`); + if (view.runningCount > 0) parts.push(`${pluralizeTasks(view.runningCount)} still running`); + if (parts.length > 0) lines.push(parts.join(' · ')); + return lines; + } + } +} + +function pluralizeTasks(count: number): string { + return `${String(count)} background task${count === 1 ? '' : 's'}`; +} + +export function parseWaitForOutput(output: string): WaitForResultView | undefined { + const status = field(output, 'wait_status'); + if (status !== 'completed' && status !== 'timed_out' && status !== 'no_tasks') return undefined; + const waitedMs = Number(field(output, 'waited_ms') ?? 0); + const finished = section(output, 'finished'); + const duringWait = section(output, 'completed_during_wait'); + const stillRunning = section(output, 'still_running'); + const runningCount = stillRunning === undefined ? 0 : countField(stillRunning, 'active_background_tasks'); + return { + status, + waitedMs: Number.isFinite(waitedMs) ? waitedMs : 0, + finishedTaskId: field(output, 'task_id'), + finishedStatus: finished === undefined ? undefined : field(finished, 'status'), + finishedDescription: finished === undefined ? undefined : field(finished, 'description'), + extraCount: duringWait === undefined ? 0 : countOccurrences(duringWait, /^task_id: /gm), + runningCount, + runningSamples: + stillRunning === undefined ? [] : sampleDescriptions(stillRunning, runningCount), + }; +} + +function field(text: string, name: string): string | undefined { + const match = new RegExp(`^${name}: (.+)$`, 'm').exec(text); + return match?.[1]; +} + +function countField(text: string, name: string): number { + const value = Number(field(text, name) ?? 0); + return Number.isFinite(value) ? value : 0; +} + +function section(output: string, name: string): string | undefined { + const match = new RegExp(`^\\[${name}\\]$`, 'm').exec(output); + if (match === null) return undefined; + const rest = output.slice(match.index + match[0].length); + const next = /^\[/m.exec(rest); + return (next === null ? rest : rest.slice(0, next.index)).trim(); +} + +function countOccurrences(text: string, pattern: RegExp): number { + return text.match(pattern)?.length ?? 0; +} + +function sampleDescriptions(stillRunning: string, runningCount: number): readonly string[] { + const descriptions = [...stillRunning.matchAll(/^description: (.+)$/gm)].map((match) => + truncateOneLine(match[1] ?? '', 40), + ); + return descriptions.slice(0, Math.min(RUNNING_SAMPLES, runningCount)); +} + +function truncateOneLine(text: string, max: number): string { + const firstLine = text.replaceAll(/\s+/g, ' ').trim(); + if (firstLine.length <= max) return firstLine; + return `${firstLine.slice(0, Math.max(0, max - 1))}…`; +} diff --git a/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts b/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts new file mode 100644 index 0000000000000000000000000000000000000000..e22e003af65ab4a1485d1ac0909de16a8756619b --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts @@ -0,0 +1,226 @@ +/** + * Single-row line shared by the tool card header, the Read group header and + * the collapsed card's outcome row. + * + * A header is either a plain string, truncated at the render width, or three + * segments: a fixed head (bullet + label), a flexible middle (command, update + * preview, key argument) and a fixed tail (the result chip). The middle gets + * whatever width is left after the head and the tail, so on a wide terminal + * it fills the row and on a narrow one the chip still survives. `keep` + * decides which end of the middle survives a cut: commands keep their start, + * paths keep their file name. + */ + +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; + +import { + ANSI_ESCAPE_PATTERN, + TAIL_WINDOW_UNITS_PER_CELL, + TRUNCATION_ELLIPSIS, +} from '#/tui/constant/rendering'; + +export interface HeaderFlex { + /** Plain text; `style` is applied after the cut so the ellipsis is styled too. */ + readonly text: string; + readonly style?: (text: string) => string; + readonly keep: 'head' | 'tail'; +} + +export interface HeaderSegments { + readonly head: string; + readonly flex: HeaderFlex; + readonly tail: string; +} + +export type HeaderContent = string | HeaderSegments; + +// The middle is plain text and gets styled after the cut, so it is cut by +// hand here: pi-tui's truncateToWidth wraps its ellipsis in a reset sequence, +// which would break the caller's styling around it. + +interface TextUnit { + readonly text: string; + readonly width: number; +} + +/** Grapheme clusters and whole escape sequences, in order; escape sequences measure zero width. */ +function* textUnits(text: string): Generator<TextUnit> { + const segmenter = new Intl.Segmenter(); + let offset = 0; + for (const match of text.matchAll(ANSI_ESCAPE_PATTERN)) { + if (match.index > offset) { + for (const segment of segmenter.segment(text.slice(offset, match.index))) { + yield { text: segment.segment, width: visibleWidth(segment.segment) }; + } + } + yield { text: match[0], width: 0 }; + offset = match.index + match[0].length; + } + for (const segment of segmenter.segment(text.slice(offset))) { + yield { text: segment.segment, width: visibleWidth(segment.segment) }; + } +} + +/** Keep the start of `text` up to a trailing ellipsis, within `width` cells. */ +function keepHead(text: string, width: number): string { + const budget = width - visibleWidth(TRUNCATION_ELLIPSIS); + let out = ''; + let used = 0; + let truncated = false; + // Lazy iteration: only about one row of clusters is ever walked, so a huge + // argument (a base64 payload in an MCP call) costs nothing here. + for (const unit of textUnits(text)) { + if (used + unit.width > budget) { + truncated = true; + break; + } + out += unit.text; + used += unit.width; + } + return truncated ? `${out}${TRUNCATION_ELLIPSIS}` : out; +} + +/** Keep the end of `text` behind a leading ellipsis, within `width` cells. */ +function keepTail(text: string, width: number): string { + const budget = width - visibleWidth(TRUNCATION_ELLIPSIS); + // The segmented slice stays bounded by the terminal width instead of the + // whole argument. ZWJ emoji and combining sequences pack many code units + // into one cell, so the window keeps TAIL_WINDOW_UNITS_PER_CELL per cell + // plus headroom for zero-width escape sequences; only sequences denser than + // that lose fitting clusters to the cut. + const window = budget * TAIL_WINDOW_UNITS_PER_CELL + 64; + const windowed = text.length > window ? text.slice(-window) : text; + const units = [...textUnits(windowed)]; + // The window edge may have split a grapheme or an escape sequence; drop + // whatever partial unit it left behind the leading ellipsis. + if (windowed.length < text.length) units.shift(); + let out = ''; + let used = 0; + let truncated = windowed.length < text.length; + for (const unit of units.toReversed()) { + if (used + unit.width > budget) { + truncated = true; + break; + } + out = unit.text + out; + used += unit.width; + } + return truncated ? `${TRUNCATION_ELLIPSIS}${out}` : out; +} + +/** Whether `text` fits `width` cells, measured lazily so a huge argument is never walked whole. */ +function fits(text: string, width: number): boolean { + let used = 0; + for (const unit of textUnits(text)) { + used += unit.width; + if (used > width) return false; + } + return true; +} + +function fitFlex(flex: HeaderFlex, width: number): string { + if (fits(flex.text, width)) return flex.text; + return flex.keep === 'tail' ? keepTail(flex.text, width) : keepHead(flex.text, width); +} + +function layoutHeaderContent( + content: HeaderContent, + width: number, +): { line: string; truncated: boolean } { + const safeWidth = Math.max(1, width); + if (typeof content === 'string') { + return { + line: truncateToWidth(content, safeWidth, TRUNCATION_ELLIPSIS), + truncated: visibleWidth(content) > safeWidth, + }; + } + const { head, flex, tail } = content; + const style = flex.style ?? ((text: string) => text); + const available = safeWidth - visibleWidth(head) - visibleWidth(tail); + // Below two cells there is no room for even an ellipsis plus one character + // of the middle: drop the middle and keep the fixed parts, cutting the head + // from its end when even those overflow, so the tail (the result chip) + // stays visible whenever it can fit at all. + if (available < 2) { + const headWidth = visibleWidth(head); + const tailWidth = visibleWidth(tail); + if (headWidth + tailWidth <= safeWidth) { + const marker = + flex.text.length > 0 && safeWidth - headWidth - tailWidth >= 1 + ? style(TRUNCATION_ELLIPSIS) + : ''; + return { line: `${head}${marker}${tail}`, truncated: flex.text.length > 0 }; + } + if (safeWidth - tailWidth >= 2) { + // The head is already styled, so pi-tui's cutter (which resets styles + // around its ellipsis) is the right tool here. + return { + line: `${truncateToWidth(head, safeWidth - tailWidth, TRUNCATION_ELLIPSIS)}${tail}`, + truncated: true, + }; + } + return { + line: truncateToWidth(`${head}${tail}`, safeWidth, TRUNCATION_ELLIPSIS), + truncated: true, + }; + } + const fitted = fitFlex(flex, available); + return { line: `${head}${style(fitted)}${tail}`, truncated: fitted !== flex.text }; +} + +export function renderHeaderContent(content: HeaderContent, width: number): string { + return layoutHeaderContent(content, width).line; +} + +function sameContent(a: HeaderContent, b: HeaderContent): boolean { + if (typeof a === 'string' || typeof b === 'string') return a === b; + return ( + a.head === b.head && + a.tail === b.tail && + a.flex.text === b.flex.text && + a.flex.keep === b.flex.keep && + a.flex.style === b.flex.style + ); +} + +export class TruncatedHeaderLine implements Component { + // The card and the gutter container reuse a child's output by array + // identity, so an unchanged header must hand back the same array — a fresh + // one per frame would defeat both caches on every paint. + private cache: + | { content: HeaderContent; width: number; lines: string[]; truncated: boolean } + | undefined; + + constructor(private content: HeaderContent) {} + + setText(content: HeaderContent): void { + if (sameContent(this.content, content)) return; + this.content = content; + this.cache = undefined; + } + + invalidate(): void { + this.cache = undefined; + } + + /** + * Whether the last render cut any part of the row — an outcome row cut to + * the terminal width hides the remainder of a long line, which ctrl+o + * reveals wrapped. Drives the footer's ctrl+o hint. + */ + wasTruncated(): boolean { + return this.cache?.truncated ?? false; + } + + render(width: number): string[] { + const cache = this.cache; + if (cache !== undefined && cache.content === this.content && cache.width === width) { + return cache.lines; + } + const { line, truncated } = layoutHeaderContent(this.content, width); + const lines = [line]; + this.cache = { content: this.content, width, lines, truncated }; + return lines; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b615ac569e52301321a1b941b756e7cb9d91349 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -0,0 +1,362 @@ +/** + * UsagePanelComponent — wraps pre-coloured `/usage` lines in a blue box + * border with a left indent, mirroring the PlanBoxComponent layout so + * the pattern stays consistent across command-triggered panels. + */ + +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; +import { formatDuration } from '@moonshot-ai/kimi-code-oauth'; +import type { SessionUsage, TokenUsage } from '@moonshot-ai/kimi-code-sdk'; + +import { + formatTokenCount, + ratioSeverity, + renderProgressBar, + safeUsageRatio, + usagePercent, + type QuotaUsageRow, +} from '#/utils/usage/usage-format'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; + +const LEFT_MARGIN = 2; +const SIDE_PADDING = 1; +const BOX_OVERHEAD = LEFT_MARGIN + 2 + 2 * SIDE_PADDING; + +type Colorize = (text: string) => string; + +function usageRowResetHint(row: QuotaUsageRow): string | undefined { + const resetAt = row.resetAt; + if (resetAt === undefined) return undefined; + const parsed = Date.parse(resetAt); + if (!Number.isFinite(parsed)) return undefined; + const diffSec = Math.floor((parsed - Date.now()) / 1000); + if (diffSec <= 0) return 'reset'; + return `resets in ${formatDuration(diffSec)}`; +} + +export interface BoosterWalletInfo { + readonly balanceCents: number; + readonly totalCents: number; + readonly monthlyChargeLimitEnabled: boolean; + readonly monthlyChargeLimitCents: number; + readonly monthlyUsedCents: number; + readonly currency: string; +} + +export interface ManagedUsageReport { + readonly rows: readonly QuotaUsageRow[]; + readonly extraUsage?: BoosterWalletInfo | null; +} + +export interface UsageReportOptions { + readonly sessionUsage?: SessionUsage; + readonly sessionUsageError?: string; + readonly contextUsage: number; + readonly contextTokens: number; + readonly maxContextTokens: number; + readonly managedUsage?: ManagedUsageReport; + readonly managedUsageError?: string; +} + +export interface ManagedUsageReportLineOptions { + readonly managedUsage?: ManagedUsageReport; + readonly managedUsageError?: string; +} + +function usageNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; +} + +function usageInputTotal(usage: TokenUsage): number { + return ( + usageNumber(usage.inputOther) + + usageNumber(usage.inputCacheRead) + + usageNumber(usage.inputCacheCreation) + ); +} + +function buildSessionUsageSection( + usage: SessionUsage | undefined, + error: string | undefined, + value: Colorize, + muted: Colorize, +): string[] { + if (error !== undefined) return [muted(` ${error}`)]; + const byModel = (usage as { readonly byModel?: Record<string, TokenUsage> } | undefined) + ?.byModel; + const entries = Object.entries(byModel ?? {}); + if (entries.length === 0) return [muted(' No token usage recorded yet.')]; + + const lines: string[] = []; + let totalInput = 0; + let totalOutput = 0; + for (const [model, row] of entries) { + const input = usageInputTotal(row); + const output = usageNumber(row.output); + totalInput += input; + totalOutput += output; + lines.push( + ` ${muted(model)} input ${value(formatTokenCount(input))} output ${value( + formatTokenCount(output), + )} total ${value(formatTokenCount(input + output))}`, + ); + } + if (entries.length > 1) { + lines.push( + ` ${muted('total')} input ${value(formatTokenCount(totalInput))} output ${value( + formatTokenCount(totalOutput), + )} total ${value(formatTokenCount(totalInput + totalOutput))}`, + ); + } + return lines; +} + +function buildManagedUsageSection( + usage: ManagedUsageReport | undefined, + error: string | undefined, + accent: Colorize, + value: Colorize, + muted: Colorize, + errorStyle: Colorize, +): string[] { + if (error !== undefined) return [accent('Plan usage'), errorStyle(` ${error}`)]; + if (usage === undefined) return []; + const { rows } = usage; + if (rows.length === 0) { + return [accent('Plan usage'), muted(' No usage data available.')]; + } + + const labels = rows.map((r) => r.name); + const labelWidth = Math.max(10, ...labels.map((l) => l.length)); + const rowRatio = (r: QuotaUsageRow): number => safeUsageRatio(r.usedRatio); + const pctWidth = Math.max(...rows.map((r) => `${Math.round(rowRatio(r) * 100)}% used`.length)); + + const out: string[] = [accent('Plan usage')]; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]!; + const ratioUsed = rowRatio(row); + const bar = renderProgressBar(ratioUsed, 20); + const pct = `${Math.round(ratioUsed * 100)}% used`; + const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratioUsed)), bar); + const label = labels[i]!.padEnd(labelWidth, ' '); + const resetHint = usageRowResetHint(row); + const resetStr = resetHint !== undefined ? ` ${muted(resetHint)}` : ''; + out.push(` ${muted(label)} ${barColoured} ${value(pct.padEnd(pctWidth, ' '))}${resetStr}`); + const breakdown = row.breakdown; + if (breakdown !== undefined) { + const kimi = Math.round(safeUsageRatio(breakdown.kimiRatio) * 100); + const code = Math.round(safeUsageRatio(breakdown.codeRatio) * 100); + out.push(` ${' '.repeat(labelWidth)} ${muted(`kimi ${String(kimi)}% · code ${String(code)}%`)}`); + } + } + return out; +} + +function severityColor(sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' { + return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; +} + +function currencySymbol(currency: string): string { + switch (currency.toUpperCase()) { + case 'CNY': + return '¥'; + case 'USD': + return '$'; + default: + return ''; + } +} + +interface CurrencyParts { + readonly symbol: string; + readonly number: string; +} + +function formatCurrencyParts(cents: number, currency: string): CurrencyParts { + const symbol = currencySymbol(currency); + const main = cents / 100; + const formatted = main.toFixed(2); + return symbol.length > 0 + ? { symbol, number: formatted } + : { symbol: '', number: `${formatted} ${currency}` }; +} + +export function buildExtraUsageSection( + extraUsage: BoosterWalletInfo | undefined | null, + accent: Colorize, + value: Colorize, + muted: Colorize, +): string[] { + if (extraUsage === undefined || extraUsage === null) return []; + + const hasMonthlyLimit = + extraUsage.monthlyChargeLimitEnabled && extraUsage.monthlyChargeLimitCents > 0; + + const balance = formatCurrencyParts(extraUsage.balanceCents, extraUsage.currency); + const used = formatCurrencyParts(extraUsage.monthlyUsedCents, extraUsage.currency); + const rows: Array<{ label: string; symbol: string; number: string }> = []; + let barLine: string | null = null; + + if (hasMonthlyLimit) { + const ratio = Math.max( + 0, + Math.min(extraUsage.monthlyUsedCents / extraUsage.monthlyChargeLimitCents, 1), + ); + const bar = renderProgressBar(ratio, 20); + barLine = ` ${currentTheme.fg(severityColor(ratioSeverity(ratio)), bar)}`; + const limit = formatCurrencyParts(extraUsage.monthlyChargeLimitCents, extraUsage.currency); + rows.push({ label: 'Used this month', ...used }); + rows.push({ label: 'Monthly limit', ...limit }); + rows.push({ label: 'Balance', ...balance }); + } else { + rows.push({ label: 'Used this month', ...used }); + rows.push({ label: 'Monthly limit', symbol: '', number: 'Unlimited' }); + rows.push({ label: 'Balance', ...balance }); + } + + // `Used this month` is the longest label; size the column to the widest label + // so the currency symbol starts in the same column on every row. + const labelWidth = Math.max(...rows.map((r) => r.label.length)); + // Right-align the numeric part of currency rows against each other so the + // decimal points line up (e.g. `¥ 50.00` / `¥200.00`). Text-only rows such as + // `Unlimited` carry no currency symbol, so they must not widen the numeric + // column — otherwise money values get padded with stray spaces. + const numberWidth = Math.max( + 0, + ...rows.filter((r) => r.symbol.length > 0).map((r) => visibleWidth(r.number)), + ); + const row = (label: string, symbol: string, number: string): string => { + const cell = symbol.length > 0 ? symbol + number.padStart(numberWidth, ' ') : number; + return ` ${muted(label.padEnd(labelWidth, ' '))} ${value(cell)}`; + }; + + const lines: string[] = [accent('Extra Usage')]; + if (barLine !== null) lines.push(barLine); + for (const r of rows) lines.push(row(r.label, r.symbol, r.number)); + + return lines; +} + +export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] { + const accent = (text: string) => currentTheme.boldFg('primary', text); + const value = (text: string) => currentTheme.fg('text', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); + + return buildManagedUsageSection( + options.managedUsage, + options.managedUsageError, + accent, + value, + muted, + errorStyle, + ); +} + +export function buildUsageReportLines(options: UsageReportOptions): string[] { + const accent = (text: string) => currentTheme.boldFg('primary', text); + const value = (text: string) => currentTheme.fg('text', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + + const lines: string[] = [ + accent('Session usage'), + ...buildSessionUsageSection(options.sessionUsage, options.sessionUsageError, value, muted), + ]; + + if (options.maxContextTokens > 0) { + const ratio = safeUsageRatio(options.contextUsage); + const bar = renderProgressBar(ratio, 20); + const pct = `${String(usagePercent(options.contextTokens, options.maxContextTokens))}%`; + const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratio)), bar); + lines.push(''); + lines.push(accent('Context window')); + lines.push( + ` ${barColoured} ${value(pct.padStart(6, ' '))} ` + + muted( + `(${formatTokenCount(options.contextTokens)} / ${formatTokenCount( + options.maxContextTokens, + )})`, + ), + ); + } + + const managedSection = buildManagedUsageReportLines({ + managedUsage: options.managedUsage, + managedUsageError: options.managedUsageError, + }); + if (managedSection.length > 0) { + lines.push(''); + lines.push(...managedSection); + } + + const extraSection = buildExtraUsageSection( + options.managedUsage?.extraUsage, + accent, + value, + muted, + ); + if (extraSection.length > 0) { + lines.push(''); + lines.push(...extraSection); + } + + return lines; +} + +export class UsagePanelComponent implements Component { + /** Cached coloured lines; rebuilt from `buildLines` on every invalidate. */ + private lines: readonly string[]; + + constructor( + private readonly buildLines: () => readonly string[], + private readonly borderToken: ColorToken, + private readonly title: string = ' Usage ', + ) { + this.lines = buildLines(); + } + + invalidate(): void { + // Report bodies embed palette colours, so a theme switch must re-run the + // builder to repaint the cached lines (the data itself is captured). + this.lines = this.buildLines(); + } + + render(width: number): string[] { + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + const paint = (s: string): string => currentTheme.fg(this.borderToken, s); + const availableInterior = safeWidth - BOX_OVERHEAD; + if (availableInterior < 1) { + return [ + truncateToWidth(this.title.trim(), safeWidth, '…'), + ...this.lines.map((line) => truncateToWidth(line, safeWidth, '…')), + ]; + } + + const indent = ' '.repeat(LEFT_MARGIN); + const longestLine = this.lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0); + const contentWidth = Math.max( + 1, + Math.min(availableInterior, Math.max(longestLine, visibleWidth(this.title))), + ); + const horzLen = contentWidth + 2 * SIDE_PADDING; + const title = truncateToWidth(this.title, horzLen, '…'); + + const trailingDashLen = Math.max(0, horzLen - visibleWidth(title)); + const top = + indent + paint('╭') + paint(title) + paint('─'.repeat(trailingDashLen)) + paint('╮'); + const bottom = indent + paint('╰' + '─'.repeat(horzLen) + '╯'); + + const out: string[] = [top]; + for (const line of this.lines) { + const clipped = visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth) : line; + const pad = Math.max(0, contentWidth - visibleWidth(clipped)); + out.push(indent + paint('│') + ' ' + clipped + ' '.repeat(pad) + ' ' + paint('│')); + } + out.push(bottom); + return out.map((line) => truncateToWidth(line, safeWidth, '…')); + } +} diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e61ab15c6a2eeac5de5b9d0d41b56ffcd487e97 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -0,0 +1,115 @@ +/** + * Renders a user message in the transcript. + */ + +import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; + +import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; +import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; +import { markOsc133Zone } from '#/tui/utils/osc133'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; + +export class UserMessageComponent implements Component { + private text: string; + private readonly bullet?: string; + private spacerComponent: Spacer; + private imageThumbnails: ImageThumbnail[]; + + private renderCache: { width: number; lines: string[] } | undefined; + + constructor(text: string, images?: ImageAttachment[], bullet?: string) { + this.text = text; + this.bullet = bullet; + this.spacerComponent = new Spacer(1); + this.imageThumbnails = images?.map((img) => new ImageThumbnail(img)) ?? []; + } + + private markRenderDirty(): void { + this.renderCache = undefined; + } + + invalidate(): void { + this.markRenderDirty(); + for (const img of this.imageThumbnails) { + img.invalidate?.(); + } + } + + render(width: number): string[] { + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + if ( + isRenderCacheEnabled() && + this.renderCache !== undefined && + this.renderCache.width === safeWidth + ) { + return this.renderCache.lines; + } + + const marker = this.bullet ?? USER_MESSAGE_BULLET; + const bullet = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : ''; + const bulletWidth = visibleWidth(bullet); + const contentWidth = Math.max(1, safeWidth - bulletWidth); + + const lines: string[] = []; + + // Spacer + for (const line of this.spacerComponent.render(safeWidth)) { + lines.push(line); + } + + // Text is re-dyed from the current theme; invalidate() (theme change) clears + // the render cache so the new colours are picked up on the next render. + const coloredText = currentTheme.boldFg('roleUser', this.text); + const textLines = new Text(coloredText, 0, 0).render(contentWidth); + for (let i = 0; i < textLines.length; i++) { + const prefix = i === 0 ? bullet : ' '.repeat(bulletWidth); + lines.push(prefix + textLines[i]); + } + + // Images — indented to align with text after the bullet + for (const thumbnail of this.imageThumbnails) { + const imageLines = thumbnail.render(contentWidth); + for (const line of imageLines) { + lines.push(' '.repeat(bulletWidth) + line); + } + } + + const rendered = markOsc133Zone( + lines.map((line) => { + // Inline image sequences (Kitty / iTerm2) carry their own placement + // information and have zero visible width, but pi-tui's truncateToWidth + // treats the embedded base64 payload as visible text and would chop the + // escape sequence in half, leaving garbage like "0m...". Skip truncation + // for those lines; the image itself already respects maxWidthCells. + if (isImageLine(line)) return line; + return truncateToWidth(line, safeWidth, '…'); + }), + ); + if (isRenderCacheEnabled()) { + this.renderCache = { width: safeWidth, lines: rendered }; + } + return rendered; + } +} + +function isImageLine(line: string): boolean { + return line.includes('\u001B_G') || line.includes('\u001B]1337;File='); +} + +/** + * Invisible turn-boundary marker for replay. Some replayed records start a + * new turn without anything to show — the goal driver's synthetic + * continuation prompt is model-facing and never rendered live — but the + * transcript still needs a mounted boundary component so step/assistant + * folding (and window trimming) can find the turn edges. Renders zero lines. + */ +export class ReplayTurnBoundaryComponent implements Component { + invalidate(): void {} + render(_width: number): string[] { + return []; + } +} diff --git a/apps/kimi-code/src/tui/components/panes/activity-pane.ts b/apps/kimi-code/src/tui/components/panes/activity-pane.ts new file mode 100644 index 0000000000000000000000000000000000000000..43f9ece4126546a514f68916636a65bdf295ca40 --- /dev/null +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -0,0 +1,47 @@ +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; + +import type { MoonLoader } from '#/tui/components/chrome/moon-loader'; +import { ACTIVITY_DETAIL_INDENT } from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; + +export type ActivityPaneMode = 'hidden' | 'waiting' | 'thinking' | 'composing' | 'tool'; + +export interface ActivityPaneOptions { + readonly mode: ActivityPaneMode; + readonly spinner?: MoonLoader; + readonly tip?: string; + /** Extra dim line rendered under the spinner (e.g. step retry error detail). */ + readonly detail?: string; +} + +export function formatActivitySpinnerTip(tip: string | undefined): string { + return tip === undefined || tip.length === 0 ? '' : ` · Tip: ${tip}`; +} + +export class ActivityPaneComponent extends Container { + private spinnerRef?: MoonLoader; + + constructor(options: ActivityPaneOptions) { + super(); + this.spinnerRef = options.spinner; + + if ( + (options.mode === 'waiting' || options.mode === 'tool' || options.mode === 'composing') && + options.spinner !== undefined + ) { + this.addChild(new Spacer(1)); + options.spinner.setTip(formatActivitySpinnerTip(options.tip)); + this.addChild(options.spinner); + if (options.detail !== undefined && options.detail.length > 0) { + this.addChild(new Text(currentTheme.fg('textDim', options.detail), ACTIVITY_DETAIL_INDENT, 0)); + } + } + } + + override render(width: number): string[] { + if (this.spinnerRef && 'setAvailableWidth' in this.spinnerRef) { + this.spinnerRef.setAvailableWidth(width); + } + return super.render(width); + } +} diff --git a/apps/kimi-code/src/tui/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts new file mode 100644 index 0000000000000000000000000000000000000000..1982b5d1bd843806f82eef6c307552387dfaae5a --- /dev/null +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -0,0 +1,257 @@ +import type { Component, MarkdownTheme } from '@moonshot-ai/pi-tui'; +import { + Markdown, + Text, + truncateToWidth, + visibleWidth, +} from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import { THINKING_PREVIEW_LINES } from '../../constant/rendering'; +import { currentTheme } from '../../theme'; +import type { InlineSkillActivation } from '../../types'; +import { createMarkdownOptions } from '../../utils/markdown-options'; + +type BtwPanelPhase = 'running' | 'done' | 'failed'; + +const MIN_COLLAPSED_PANEL_LINES = 3; + +interface BtwTurn { + readonly prompt: string; + answer: string; + thinking: string; + error?: string | undefined; + phase: BtwPanelPhase; +} + +interface BtwBodyRender { + readonly lines: string[]; + readonly truncated: boolean; +} + +export interface BtwPanelOptions { + readonly markdownTheme: MarkdownTheme; + readonly canUseScrollKeys: () => boolean; + readonly onPrompt: ( + prompt: string, + inlineSkillActivations?: readonly InlineSkillActivation[], + ) => void; + readonly terminalRows: () => number; +} + +export class BtwPanelComponent implements Component { + private readonly turns: BtwTurn[] = []; + private readonly transientNotices: string[] = []; + private minBodyLines = 0; + private followTail = true; + private scrollTop = 0; + private maxScrollTop = 0; + + constructor(private readonly options: BtwPanelOptions) {} + + submit(prompt: string, inlineSkillActivations?: readonly InlineSkillActivation[]): void { + const normalized = prompt.trim(); + if (normalized.length === 0 || this.isRunning()) return; + this.followTail = true; + this.scrollTop = 0; + this.transientNotices.length = 0; + this.turns.push({ + prompt: normalized, + answer: '', + thinking: '', + phase: 'running', + }); + this.options.onPrompt(normalized, inlineSkillActivations); + } + + addTransientNotice(message: string): void { + this.transientNotices.push(message); + this.followTail = true; + } + + appendAnswer(delta: string): void { + const turn = this.currentTurn(); + if (turn === undefined) return; + turn.answer += delta; + } + + appendThinking(delta: string): void { + const turn = this.currentTurn(); + if (turn === undefined) return; + turn.thinking += delta; + } + + markDone(resultSummary?: string | undefined): void { + const turn = this.currentTurn(); + if (turn === undefined) return; + if (turn.answer.trim().length === 0 && resultSummary !== undefined) { + turn.answer = resultSummary; + } + this.transientNotices.length = 0; + turn.phase = 'done'; + } + + markFailed(error: string): void { + const turn = this.currentTurn(); + if (turn === undefined || turn.phase !== 'running') { + this.turns.push({ + prompt: '', + answer: '', + thinking: '', + error, + phase: 'failed', + }); + this.transientNotices.length = 0; + return; + } + turn.error = error; + this.transientNotices.length = 0; + turn.phase = 'failed'; + } + + invalidate(): void {} + + render(width: number): string[] { + const safeWidth = Math.max(4, width); + const contentWidth = Math.max(1, safeWidth - 4); + const body = this.renderBody(contentWidth); + const lines = [this.renderTopBorder(safeWidth, body.truncated)]; + for (const line of body.lines) { + lines.push(this.renderBodyLine(line, safeWidth)); + } + return lines; + } + + private renderTopBorder(width: number, truncated: boolean): string { + const paint = (s: string): string => chalk.hex(currentTheme.palette.border)(s); + const hint = truncated && this.options.canUseScrollKeys() + ? 'Esc close · ↑↓ scroll ' + : 'Esc close '; + const title = + chalk.hex(currentTheme.palette.accent).bold(' BTW ') + + paint('─ ') + + chalk.hex(currentTheme.palette.textMuted)(hint); + const innerWidth = Math.max(1, width - 2); + const clippedTitle = + visibleWidth(title) > innerWidth ? truncateToWidth(title, innerWidth, '') : title; + const dashCount = Math.max(0, innerWidth - visibleWidth(clippedTitle)); + return paint('╭') + clippedTitle + paint('─'.repeat(dashCount)) + paint('╮'); + } + + private renderBody(width: number): BtwBodyRender { + const lines: string[] = []; + for (const [index, turn] of this.turns.entries()) { + if (index > 0) lines.push(''); + lines.push(...this.renderTurn(turn, width)); + } + if (this.turns.length === 0) { + lines.push(chalk.hex(currentTheme.palette.textDim)('Ready for a side question…')); + } + lines.push(...this.renderTransientNotices(width)); + return this.fitBodyLines(lines); + } + + private renderTransientNotices(width: number): string[] { + const lines: string[] = []; + for (const notice of this.transientNotices) { + lines.push(...new Text(chalk.hex(currentTheme.palette.textDim)(notice), 0, 0).render(width)); + } + return lines; + } + + private fitBodyLines(lines: string[]): BtwBodyRender { + const bodyLimit = this.collapsedBodyLimit(); + const targetUncapped = Math.max(this.minBodyLines, lines.length); + const target = + bodyLimit === undefined ? targetUncapped : Math.min(bodyLimit, targetUncapped); + this.minBodyLines = Math.max(this.minBodyLines, target); + + if (lines.length > target) { + this.maxScrollTop = lines.length - target; + if (this.followTail) { + this.scrollTop = this.maxScrollTop; + } else { + this.scrollTop = Math.min(this.scrollTop, this.maxScrollTop); + } + const start = this.scrollTop; + return { lines: lines.slice(start, start + target), truncated: true }; + } + + this.followTail = true; + this.scrollTop = 0; + this.maxScrollTop = 0; + const padded = [...lines]; + while (padded.length < target) { + padded.push(''); + } + return { lines: padded, truncated: false }; + } + + private collapsedBodyLimit(): number | undefined { + const terminalRows = this.options.terminalRows(); + if (!Number.isFinite(terminalRows) || terminalRows <= 0) return undefined; + const maxPanelLines = Math.max(MIN_COLLAPSED_PANEL_LINES, Math.floor(terminalRows / 3)); + return Math.max(1, maxPanelLines - 1); + } + + private renderTurn(turn: BtwTurn, width: number): string[] { + const prompt = chalk.hex(currentTheme.palette.accent)(`Q: ${turn.prompt}`); + const lines = [...new Text(prompt, 0, 0).render(width)]; + const answer = turn.answer.trim(); + const thinking = turn.thinking.trim(); + if (answer.length > 0) { + lines.push( + ...new Markdown(answer, 0, 0, this.options.markdownTheme, undefined, createMarkdownOptions()).render(width), + ); + } else if (thinking.length > 0) { + const thinkingLines = new Text(chalk.hex(currentTheme.palette.textDim)(thinking), 0, 0).render( + width, + ); + const visibleThinking = + thinkingLines.length > THINKING_PREVIEW_LINES + ? thinkingLines.slice(thinkingLines.length - THINKING_PREVIEW_LINES) + : thinkingLines; + lines.push(...visibleThinking); + } else if (turn.error === undefined) { + lines.push(chalk.hex(currentTheme.palette.textDim)('Waiting for answer…')); + } + if (turn.error !== undefined) { + const error = chalk.hex(currentTheme.palette.error)(turn.error); + lines.push(...new Text(error, 0, 0).render(width)); + } + return lines; + } + + private renderBodyLine(line: string, width: number): string { + const paint = (s: string): string => chalk.hex(currentTheme.palette.border)(s); + const contentWidth = Math.max(1, width - 4); + const clipped = + visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth, '…') : line; + const padding = Math.max(0, contentWidth - visibleWidth(clipped)); + return paint('│') + ' ' + clipped + ' '.repeat(padding) + ' ' + paint('│'); + } + + private currentTurn(): BtwTurn | undefined { + return this.turns.at(-1); + } + + isRunning(): boolean { + return this.currentTurn()?.phase === 'running'; + } + + isEmpty(): boolean { + return this.turns.length === 0; + } + + scroll(direction: 'up' | 'down'): boolean { + if (this.maxScrollTop <= 0) return false; + const current = this.followTail ? this.maxScrollTop : this.scrollTop; + const next = + direction === 'up' + ? Math.max(0, current - 1) + : Math.min(this.maxScrollTop, current + 1); + this.scrollTop = next; + this.followTail = next === this.maxScrollTop; + return true; + } +} diff --git a/apps/kimi-code/src/tui/components/panes/queue-pane.ts b/apps/kimi-code/src/tui/components/panes/queue-pane.ts new file mode 100644 index 0000000000000000000000000000000000000000..209c902666e345a059f2a106bec348568da9df6a --- /dev/null +++ b/apps/kimi-code/src/tui/components/panes/queue-pane.ts @@ -0,0 +1,67 @@ +import { Container, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; + +import { SELECT_POINTER } from '../../constant/symbols'; +import type { QueuedMessage } from '../../types'; +import { currentTheme } from '#/tui/theme'; + +export interface QueuePaneOptions { + readonly messages: readonly QueuedMessage[]; + readonly isCompacting: boolean; + readonly isStreaming: boolean; + readonly canSteerImmediately: boolean; +} + +const ELLIPSIS = '…'; + +export class QueuePaneComponent extends Container { + private readonly messages: readonly QueuedMessage[]; + private readonly hint: string | undefined; + + constructor(options: QueuePaneOptions) { + super(); + this.messages = options.messages; + + if (options.messages.length > 0) { + // Bash commands (`! …`) are not steerable, so only advertise Ctrl-S when + // there is at least one plain-text or skill item steering would send. + const hasSteerable = options.messages.some((m) => m.mode !== 'bash'); + const canSteer = options.canSteerImmediately && hasSteerable; + this.hint = + options.isCompacting && !options.isStreaming + ? ' ↑ to edit · will send after compaction' + : canSteer + ? ' ↑ to edit · ctrl-s to steer immediately' + : ' ↑ to edit · will send after current task'; + } + } + + override render(width: number): string[] { + const accent = (text: string) => currentTheme.fg('accent', text); + const shell = (text: string) => currentTheme.fg('shellMode', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const lines: string[] = [currentTheme.fg('border', '─'.repeat(width))]; + + for (const item of this.messages) { + const singleLine = item.text.replaceAll(/\s+/g, ' ').trim(); + const prefix = ` ${SELECT_POINTER} `; + if (item.mode === 'bash') { + // Shell commands get a `$ ` prompt and the shell-mode hue so they read + // as commands, not as plain text that would be sent to the model. + const prompt = '$ '; + const availableWidth = Math.max(1, width - visibleWidth(prefix) - visibleWidth(prompt)); + const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS); + lines.push(accent(prefix) + shell(prompt + truncated)); + } else { + const availableWidth = Math.max(1, width - visibleWidth(prefix)); + const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS); + lines.push(accent(prefix + truncated)); + } + } + + if (this.hint !== undefined) { + lines.push(dim(truncateToWidth(this.hint, width, ELLIPSIS))); + } + + return lines; + } +} diff --git a/apps/kimi-code/src/tui/components/panes/survey-panel.ts b/apps/kimi-code/src/tui/components/panes/survey-panel.ts new file mode 100644 index 0000000000000000000000000000000000000000..046b72f4d63c69ba571bc43ff4bd65941a3b3c6c --- /dev/null +++ b/apps/kimi-code/src/tui/components/panes/survey-panel.ts @@ -0,0 +1,96 @@ +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@moonshot-ai/pi-tui'; + +import { + SURVEY_MIN_OPTIONS_WIDTH, + SURVEY_OPTION_GAP, + SURVEY_OPTION_LABELS, + SURVEY_QUESTION, +} from '../../constant/survey'; + +import { currentTheme } from '../../theme'; +import type { SurveyResponse } from '../../utils/survey-policy'; + +export type SurveyPanelPhase = 'open' | 'pending' | 'thanks'; + +export interface SurveyPanelView { + phase: SurveyPanelPhase; + response?: Exclude<SurveyResponse, 'dismissed'>; + hoverIndex?: number; +} + +const DOT = '●'; +const DOT_PREFIX_WIDTH = 2; +const OPTION_INDENT = ' '; + +const RESPONSE_LABELS: Record<Exclude<SurveyResponse, 'dismissed'>, string> = { + bad: 'Bad', + fine: 'Fine', + good: 'Good', +}; +const THANKS = 'Thanks for your feedback!'; + +export class SurveyPanelComponent implements Component { + constructor(private readonly view: SurveyPanelView) {} + + invalidate(): void {} + + render(width: number): string[] { + if (width < 1) return ['']; + switch (this.view.phase) { + case 'open': + return this.renderOpen(width); + case 'pending': { + const label = + this.view.response === undefined ? '' : RESPONSE_LABELS[this.view.response]; + return this.renderStatusLine(width, currentTheme.fg('textDim', `Feedback: ${label} · [escape: undo]`)); + } + case 'thanks': + return this.renderStatusLine(width, currentTheme.fg('success', THANKS)); + } + } + + private renderOpen(width: number): string[] { + const title = wrapTextWithAnsi(SURVEY_QUESTION, Math.max(1, width - DOT_PREFIX_WIDTH)).map( + (line, index) => + (index === 0 ? this.dotPrefix() : ' '.repeat(DOT_PREFIX_WIDTH)) + + currentTheme.boldFg('textStrong', line), + ); + const optionsLine = OPTION_INDENT + this.styledOptions(); + if (visibleWidth(optionsLine) <= width) { + return [...title, optionsLine]; + } + if (width >= SURVEY_MIN_OPTIONS_WIDTH) { + return [ + ...title, + ...this.styledOptionsPerLine().map((option) => OPTION_INDENT + option), + ]; + } + return title; + } + + private renderStatusLine(width: number, styledText: string): string[] { + return [truncateToWidth(this.dotPrefix() + styledText, width)]; + } + + private dotPrefix(): string { + return currentTheme.fg('accent', DOT) + ' '; + } + + private styledOptions(): string { + return SURVEY_OPTION_LABELS.map((label, index) => this.styleOption(label, index)).join( + ' '.repeat(SURVEY_OPTION_GAP), + ); + } + + private styledOptionsPerLine(): string[] { + return SURVEY_OPTION_LABELS.map((label, index) => this.styleOption(label, index)); + } + + private styleOption(label: string, index: number): string { + if (this.view.hoverIndex === index) { + return currentTheme.bg('border', currentTheme.boldFg('textStrong', label)); + } + return currentTheme.fg('text', label); + } +} diff --git a/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts new file mode 100644 index 0000000000000000000000000000000000000000..eccb3f3fb4cf8b2969b57f1c9014ae65a4b33d68 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts @@ -0,0 +1,3 @@ +// Timing constants for the clipboard-image hint controller. +export const FOCUS_DEBOUNCE_MS = 1_000; +export const HINT_DISPLAY_MS = 4_000; diff --git a/apps/kimi-code/src/tui/constant/feedback.ts b/apps/kimi-code/src/tui/constant/feedback.ts new file mode 100644 index 0000000000000000000000000000000000000000..f33fc112c7584fe4b4a024de8a150a89c8a19b87 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/feedback.ts @@ -0,0 +1,50 @@ +/** + * Constants for the /feedback command — endpoints, telemetry keys, and + * the status messages shown around the feedback submission flow. + * + * Dialog-internal copy (the box title, subtitle, footer) lives next to + * the dialog component itself, since it is part of that component's + * visual contract. + */ + +import { FEEDBACK_VERSION_PREFIX } from '#/constant/app'; + +export { + FEEDBACK_ISSUE_URL, + FEEDBACK_TELEMETRY_EVENT, + FEEDBACK_VERSION_PREFIX, + kimiCodeSignupUrl, +} from '#/constant/app'; + +export const FEEDBACK_STATUS_SUBMITTING = 'Submitting feedback…'; +export const FEEDBACK_STATUS_UPLOADING = 'Uploading attachments, this could take a few minutes…'; +export const FEEDBACK_STATUS_SUCCESS = 'Feedback submitted, thank you!'; +export const FEEDBACK_STATUS_CANCELLED = 'Feedback cancelled.'; +export const FEEDBACK_STATUS_NETWORK_ERROR = 'Network error, failed to submit feedback.'; +export const FEEDBACK_STATUS_FALLBACK = 'Opening GitHub Issues as fallback…'; +export const FEEDBACK_STATUS_NOT_SIGNED_IN = + "You're not signed in. Sign up or leave feedback on GitHub:"; +export const FEEDBACK_STATUS_UPLOAD_FAILED = + 'Feedback sent; attachment upload failed — see feedback-upload.log.'; + +export function feedbackHttpErrorMessage(status: number): string { + return `Failed to submit feedback (HTTP ${String(status)}).`; +} + +export function feedbackSessionLine(sessionId: string): string { + return `Session: ${sessionId}`; +} + +export function feedbackIdLine(feedbackId: number): string { + return `Feedback ID: ${String(feedbackId)}`; +} + +// Hint shown beneath session-level error messages in the TUI to point users +// at the `/export-debug-zip` workflow so they can share diagnostics with us. +export function errorReportHintLine(): string { + return "If this persists, run `/export-debug-zip` and share the file with us for diagnosis. Please don't share it publicly."; +} + +export function withFeedbackVersionPrefix(version: string): string { + return `${FEEDBACK_VERSION_PREFIX}${version}`; +} diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff249bf486570d6b6ec7f877b26702c8227857e6 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -0,0 +1,30 @@ +import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; + +export { DEFAULT_OAUTH_PROVIDER_NAME, OAUTH_LOGIN_REQUIRED_CODE, PRODUCT_NAME } from '#/constant/app'; + +export const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login'; +export const NO_ACTIVE_SESSION_MESSAGE = 'No active session. Send a message to start one.'; +export const CTRL_D_HINT = 'Press Ctrl+D again to exit'; +export const CTRL_C_HINT = 'Press Ctrl+C again to exit'; +export const MAIN_AGENT_ID = 'main'; +export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.'; +export const SESSIONLESS_STARTUP_NOTICE = + 'No session yet — one will be created on your first message.'; +export const TOWER_STATUS_PROMPT = + 'Report the current tower status: call TowerStatus and give a compact summary.'; +export const TOWER_TEARDOWN_PROMPT = + 'Tear down the tower: call TowerTeardown and report what it did. It refuses to destroy dirty worktrees unless forced.'; +export const EXIT_CONFIRM_WINDOW_MS = 1500; +// Time window for treating two consecutive Esc presses as a double-Esc, which +// opens the undo selector. Kept short (double-click feel) so two deliberate +// presses far apart don't accidentally trigger undo. +export const DOUBLE_ESC_WINDOW_MS = 600; + +/** Session picker page size: one backend keyset page and one picker window. */ +export const SESSION_LIST_PAGE_SIZE = 50; + +export function isManagedUsageProvider( + providerKey: string | undefined, +): providerKey is typeof DEFAULT_OAUTH_PROVIDER_NAME { + return providerKey === DEFAULT_OAUTH_PROVIDER_NAME; +} diff --git a/apps/kimi-code/src/tui/constant/media.ts b/apps/kimi-code/src/tui/constant/media.ts new file mode 100644 index 0000000000000000000000000000000000000000..67618714da5abd23285c052a101a52286082855e --- /dev/null +++ b/apps/kimi-code/src/tui/constant/media.ts @@ -0,0 +1,6 @@ +/** TUI-only daemon staging lifetimes for pasted media. */ + +export const MEDIA_STAGING_TTL_SECONDS = 60 * 60; +export const MEDIA_FILE_REF_MIN_REMAINING_MS = 60_000; +/** How long submit waits for a just-pasted medium's background ingestion before giving up on the daemon-ref form. */ +export const MEDIA_INGESTION_SUBMIT_WAIT_MS = 2_000; diff --git a/apps/kimi-code/src/tui/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts new file mode 100644 index 0000000000000000000000000000000000000000..c941cbe4998763233fef912a2e2e2a91d4e13a6a --- /dev/null +++ b/apps/kimi-code/src/tui/constant/rendering.ts @@ -0,0 +1,82 @@ +// Continuation indent for transcript rows that use a two-cell leading marker. +export const MESSAGE_INDENT = ' '; + +// OSC 133 semantic-zone markers (FinalTerm/shell-integration protocol): +// zero-width escape sequences prefixed onto the first/last rendered line of +// transcript messages. The fullscreen renderer strips them at paint and uses +// the A marker for previous/next-prompt navigation (Ctrl-Shift-Up/Down); in +// regular mode they pass through to native scrollback invisibly. +export const OSC133_ZONE_START = '\u001B]133;A\u0007'; +export const OSC133_ZONE_END = '\u001B]133;B\u0007'; +export const OSC133_ZONE_FINAL = '\u001B]133;C\u0007'; + +// Outer left/right padding applied to the transcript, panels, and the +// statusline so the chrome's left edge lines up with the input box's +// interior (the `>` prompt). The editor itself stays at column 0 — its +// vertical borders are the visual anchor everything else aligns against. +export const CHROME_GUTTER = 1; + +// Shared preview caps used by thinking, tool results, and shell snippets. +export const RESULT_PREVIEW_LINES = 3; +// Collapsed row cap for a finished `!` shell command's output card. +export const SHELL_OUTPUT_PREVIEW_LINES = 10; +export const THINKING_PREVIEW_LINES = 2; +export const COMMAND_PREVIEW_LINES = 10; +export const NOTIFY_PANEL_PAGE_LINES = 8; + +// The ellipsis marking a single-row line (card header, outcome row) that was +// cut to the terminal width or that stands in for hidden output lines. +export const TRUNCATION_ELLIPSIS = '…'; +// ANSI escape sequences (CSI, OSC) — tool output can carry them — that a +// width-aware cut must treat as zero-width atomic units: never counted toward +// the budget, never split in half. +export const ANSI_ESCAPE_PATTERN = /\u001B(?:\[[0-9;?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\))/g; +// Code units a single terminal cell may hold before a tail-preserving cut's +// window can no longer see it: a ZWJ family emoji is about eleven per two +// cells, and combining sequences run longer. +export const TAIL_WINDOW_UNITS_PER_CELL = 16; +// Left indent of a collapsed tool card's outcome rows, aligning them with +// the message-body indent. +export const OUTCOME_ROW_INDENT = ' '; +// Non-empty output lines a collapsed tool card shows in full before it falls +// back to one telling outcome row. +export const OUTCOME_MAX_LINES = 3; +// Path samples a collapsed Grep/Glob card lists in its glance row before +// counting the rest as "+N more". +export const OUTCOME_GLANCE_SAMPLES = 3; + +// Cap on the step-retry detail line under the waiting spinner, so huge +// provider error bodies (occasionally whole HTML error pages) can't flood +// the activity pane. +export const RETRY_DETAIL_MAX_CHARS = 160; +// Left indent (cells) for the detail line under the waiting spinner, aligning +// it with the label text: 1 (the spinner Text's own paddingX) + 2 (moon +// frame) + 1 (space between frame and label). +export const ACTIVITY_DETAIL_INDENT = 4; + +// Retention caps for the subagent activity store (background-agent detail +// view): only the most recent steps are kept, older steps are discarded +// whole, and per-step text / per-call output keep bounded tails. +export const MAX_SUBAGENT_ACTIVITY_STEPS = 20; +export const SUBAGENT_STEP_TEXT_TAIL_CHARS = 4000; +export const SUBAGENT_TOOL_OUTPUT_MAX_CHARS = 8000; +// Cap on individual string argument values kept in a record (Write/Edit +// carry whole-file contents). Only header summaries and the Edit/Write line +// chips read args, so long values are truncated; chips become approximate +// beyond the cap. +export const SUBAGENT_ARG_STRING_MAX_CHARS = 16 * 1024; + +// Retention caps for an agent-swarm member's terminal-state label: terminal +// cells render a single-line label, so only a bounded prefix of the final +// output is worth keeping. Display width alone does not bound memory — ANSI +// sequences and zero-width graphemes add unbounded code units within a +// single column — so the retained label is capped by storage length as well. +export const MAX_FINAL_OUTPUT_LABEL_CHARS = 400; +export const MAX_FINAL_OUTPUT_LABEL_CODE_UNITS = 2_000; + +// Animation frames are shared by the login/update loaders and live thinking. +export const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +export const BRAILLE_SPINNER_INTERVAL_MS = 80; + +export const MOON_SPINNER_FRAMES = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']; +export const MOON_SPINNER_INTERVAL_MS = 120; diff --git a/apps/kimi-code/src/tui/constant/streaming.ts b/apps/kimi-code/src/tui/constant/streaming.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8f88a014db3a50d540b6f02bdd953edddaaaad0 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/streaming.ts @@ -0,0 +1,10 @@ +// Extracts useful string fields from partially streamed JSON tool args. +// This is intentionally a preview parser, not a full JSON parser. +export const STREAMING_ARGS_FIELD_RE = + /"(path|file_path|command|pattern|query|url|description|title|name)"\s*:\s*"((?:\\.|[^"\\])*)"/g; + +// Bounds live tool-argument previews; final tool.call payloads remain complete. +export const STREAMING_ARGS_PREVIEW_MAX_CHARS = 64 * 1024; + +// Coalesces high-frequency model/tool deltas before rebuilding TUI components. +export const STREAMING_UI_FLUSH_MS = 50; diff --git a/apps/kimi-code/src/tui/constant/survey.ts b/apps/kimi-code/src/tui/constant/survey.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd11b3f8638342e6fe0fe7ef641a128276228512 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/survey.ts @@ -0,0 +1,71 @@ +export const SURVEY_IDLE_EVALUATION_DELAY_MS = 2000; + +export const SURVEY_IDLE_STABILITY_MS = 2000; + +export const SURVEY_MOUNT_PROTECTION_MS = 600; + +export const SURVEY_DIGIT_DEBOUNCE_MS = 400; + +export const SURVEY_PENDING_UNDO_WINDOW_MS = 3000; + +export const SURVEY_THANKS_DURATION_MS = 5000; + +export const SURVEY_CONFIG_REFRESH_INTERVAL_MS = 3_600_000; + +export const SURVEY_MIN_OPTIONS_WIDTH = 12; + +export const SURVEY_QUESTION = 'How is Kimi doing this session? (optional)'; + +export const SURVEY_OPTION_LABELS = ['1: Bad', '2: Fine', '3: Good', '0: Dismiss'] as const; + +export const SURVEY_OPTION_GAP = 2; + +const SURVEY_SPACER_ROWS = 1; +const SURVEY_PANEL_HORIZONTAL_CHROME = 2; +const SURVEY_EDITOR_MIN_ROWS = 3; +const SURVEY_TRANSCRIPT_MIN_ROWS = 1; +const SURVEY_FOOTER_MIN_ROWS = 1; + +export function surveyMinTotalHeight(contentWidth: number): number { + const innerWidth = Math.max(1, contentWidth - SURVEY_PANEL_HORIZONTAL_CHROME); + const inlineWidth = + SURVEY_OPTION_LABELS.reduce((total, label) => total + label.length, 0) + + SURVEY_OPTION_GAP * (SURVEY_OPTION_LABELS.length - 1); + const optionsRows = innerWidth >= inlineWidth ? 1 : SURVEY_OPTION_LABELS.length; + return ( + SURVEY_SPACER_ROWS + + surveyQuestionRows(innerWidth) + + optionsRows + + SURVEY_EDITOR_MIN_ROWS + + SURVEY_TRANSCRIPT_MIN_ROWS + + SURVEY_FOOTER_MIN_ROWS + ); +} + +function surveyQuestionRows(width: number): number { + let rows = 1; + let lineLength = 0; + for (const word of SURVEY_QUESTION.split(' ')) { + if (lineLength > 0 && lineLength + 1 + word.length > width) { + rows += 1; + lineLength = word.length; + } else { + lineLength += (lineLength > 0 ? 1 : 0) + word.length; + } + } + return rows; +} + +export const SURVEY_ORDERED_LIST_START = /^[ \t]*\d{1,2}[.)][ \t]/m; + +export const SURVEY_SINGLE_OPTION_DIGIT = /^[0-3]$/; + +export const SURVEY_OPTION_COUNT = 4; + +export const SURVEY_DISMISS_OPTION_INDEX = SURVEY_OPTION_COUNT - 1; + +export const SURVEY_DIGIT_RESPONSES: Record<string, 'bad' | 'fine' | 'good' | undefined> = { + '1': 'bad', + '2': 'fine', + '3': 'good', +}; diff --git a/apps/kimi-code/src/tui/constant/symbols.ts b/apps/kimi-code/src/tui/constant/symbols.ts new file mode 100644 index 0000000000000000000000000000000000000000..bba41d60ab1ce1a02538caaff84bdb0265b460c7 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/symbols.ts @@ -0,0 +1,14 @@ +// Use U+25CF instead of U+23FA to avoid emoji/fallback rendering in terminals. +export const STATUS_BULLET = '● '; + +// Shared transcript markers. Keep widths stable because message wrapping +// assumes the marker occupies the leading cells. +export const USER_MESSAGE_BULLET = '✨ '; +export const SUCCESS_MARK = '✓ '; +export const FAILURE_MARK = '✗ '; + +// Shared selector markers — keep every list picker visually consistent. +// SELECT_POINTER marks the highlighted row; CURRENT_MARK is appended to the +// row that is the currently-active value. See .agents/skills/write-tui/DESIGN.md. +export const SELECT_POINTER = '❯'; +export const CURRENT_MARK = '← current'; diff --git a/apps/kimi-code/src/tui/constant/terminal.ts b/apps/kimi-code/src/tui/constant/terminal.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec87a07803e4003e7617d95a78506e872ecaa393 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/terminal.ts @@ -0,0 +1,40 @@ +import { BEL, ESC, ST } from "#/constant/terminal"; + +export { BEL, ESC, ST } from "#/constant/terminal"; + +// Terminal theme reporting uses private CSI sequences: enable reporting, +// query once, then parse dark/light reports from the input stream. +export const QUERY_TERMINAL_THEME = `${ESC}[?996n`; +export const TERMINAL_THEME_DARK = `${ESC}[?997;1n`; +export const TERMINAL_THEME_LIGHT = `${ESC}[?997;2n`; +export const ENABLE_TERMINAL_THEME_REPORTING = `${ESC}[?2031h`; +export const DISABLE_TERMINAL_THEME_REPORTING = `${ESC}[?2031l`; + +// Xterm-style focus reporting. Input listeners consume these bytes so +// they do not leak into the editor. +export const TERMINAL_FOCUS_IN = `${ESC}[I`; +export const TERMINAL_FOCUS_OUT = `${ESC}[O`; +export const ENABLE_TERMINAL_FOCUS_REPORTING = `${ESC}[?1004h`; +export const DISABLE_TERMINAL_FOCUS_REPORTING = `${ESC}[?1004l`; + +// Standard OSC 11 background-color query. The response regex intentionally +// allows a missing leading ESC because terminals can echo replies alongside +// other raw input, but it requires an OSC terminator so fragmented color +// channels are not parsed as complete replies. +export const OSC11_QUERY = `${ESC}]11;?${BEL}`; +const OSC11_RESPONSE_TERMINATOR_PATTERN = `(?:${BEL}|${ESC}\\\\)`; +export const OSC11_RESPONSE = new RegExp( + String.raw`${ESC}?\]11;rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})${OSC11_RESPONSE_TERMINATOR_PATTERN}`, + "i", +); +export const OSC11_RESPONSE_PREFIX = `${ESC}]11;rgb:`; +export const OSC11_RESPONSE_PREFIX_NO_ESC = "]11;rgb:"; + +// Keep notification/title payloads bounded so terminal tabs and desktop +// notifications stay readable. +export const MAX_TERMINAL_NOTIFICATION_MESSAGE_LENGTH = 240; +export const MAX_TERMINAL_TITLE_LENGTH = 32; + +// OSC 11 probing must be short because unsupported terminals do not reply. +export const TERMINAL_THEME_DETECT_TIMEOUT_MS = 250; +export const TERMINAL_THEME_INPUT_BUFFER_MAX_LENGTH = 512; diff --git a/apps/kimi-code/src/tui/constant/tips.ts b/apps/kimi-code/src/tui/constant/tips.ts new file mode 100644 index 0000000000000000000000000000000000000000..e383e4b458d0198ef522b50f875cac061bde7f39 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/tips.ts @@ -0,0 +1,48 @@ +export interface ToolbarTip { + readonly text: string; + /** + * Long/important tips render on their own. They never pair with a + * neighbour and never appear as the second half of someone else's pair. + */ + readonly solo?: boolean; + /** + * Rotation weight: a higher value makes the tip recur more often. Defaults + * to 1. Used to give newer/important features more airtime. + */ + readonly priority?: number; +} + +/** + * Subset of toolbar tips shown behind the composing spinner. + */ +export const WORKING_TIPS: readonly ToolbarTip[] = [ + { text: 'ctrl-s to add guidance without waiting for the turn to finish', priority: 2, solo: true }, + { text: '/tasks to check progress and status for background tasks', priority: 2 }, + { text: '/init: generate AGENTS.md', priority: 2 }, + { + text: '/plugins: manage plugins — try the "Kimi Datasource" for reliable financial, economic, and academic data', + solo: true, + priority: 3, + }, + { text: 'ask Kimi to schedule tasks, e.g. "remind me at 5pm"', solo: true, priority: 3 }, + { text: '/sessions to browse and resume earlier sessions', solo: true }, + { text: '/goal for multi-step work with a clear finish line', priority: 2, solo: true }, + { text: '/goal next to queue follow-up work while the current goal keeps running', solo: true }, + { text: '/web: use the Web UI for a better experience', solo: true }, + { text: '@: mention files', priority: 2 }, + { text: '! to run a shell command', priority: 2 }, +]; + +export const ALL_TIPS: readonly ToolbarTip[] = [ + ...WORKING_TIPS, + { text: 'shift+enter: newline' }, + { text: 'ctrl+c: cancel' }, + { text: '/theme to switch the terminal UI theme' }, + { text: '/auto when you want Kimi to handle approvals and keep going unattended' }, + { text: '/yolo to skip most approvals for trusted batch work, only use it in repos you trust' }, + { text: '/help: show commands' }, + { text: '/compact compresses context when it gets long', priority: 2 }, + { text: 'ctrl-o to hide or reveal tool output switching between a clean chat view and full execution details', priority: 2 }, + { text: 'shift-tab to Plan mode to review the approach before Kimi edits files.', priority: 2 }, + { text: '/model: switch model', priority: 2 }, +]; diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts new file mode 100644 index 0000000000000000000000000000000000000000..246706f14b85a354c00ad848bbb9420d88ecd6bd --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -0,0 +1,239 @@ +import { + removeProviderFromConfig, + type KimiConfig, + type KimiHarness, + type OAuthRef, + type Session, + type ThinkingEffort, +} from '@moonshot-ai/kimi-code-sdk'; + +import { createKimiCodeUserAgent } from '#/cli/version'; + +import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; +import { + refreshAllProviderModels, + type RefreshProviderHost, + type RefreshProviderScope, + type RefreshResult, +} from '../utils/refresh-providers'; +import { thinkingEffortFromConfig } from '../utils/thinking-config'; +import type { AppState, KimiTUIOptions } from '../types'; + +export interface AuthFlowHost { + session: Session | undefined; + readonly harness: KimiHarness; + readonly options: KimiTUIOptions; + + setAppState(patch: Partial<AppState>): void; + setStartupReady(): void; + resetSessionRuntime(): void; + appendStartupNotice(extra: string): void; + hydrateLazyConfigDefaults(): Promise<void>; +} + +export class AuthFlowController { + constructor(private readonly host: AuthFlowHost) {} + + async refreshAvailableModels(): Promise<void> { + const config = await this.host.harness.getConfig({ reload: true }); + this.host.setAppState({ + availableModels: config.models ?? {}, + availableProviders: config.providers ?? {}, + }); + } + + enterLoginRequiredStartupState(): void { + this.host.resetSessionRuntime(); + this.host.setAppState({ + sessionId: '', + model: '', + thinkingEffort: 'off', + contextTokens: 0, + maxContextTokens: 0, + contextUsage: 0, + sessionTitle: null, + }); + this.host.appendStartupNotice(OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE); + this.host.setStartupReady(); + } + + /** + * Apply a model pick to the runtime. Returns whether the activation made + * the engine emit `model_switch` — it reached an already-live session AND + * changed the bound alias (both engines track the event only on an actual + * alias change). `false` when no live session existed (session creation is + * deferred to the first prompt) or the alias was already bound, so callers + * mirroring the engine's telemetry must stay the producer for exactly + * those paths. Thinking-effort changes are orthogonal: the engine's + * `thinking_toggle` fires from `setThinking` regardless of this flag. + */ + async activateModelAfterLogin(model: string, effort?: string): Promise<boolean> { + const { host } = this; + if (host.session !== undefined) { + const session = host.session; + const modelChanged = (await session.getStatus()).model !== model; + await session.setModel(model); + if (effort !== undefined) { + await session.setThinking(effort); + } + return modelChanged; + } + + // Lazy session creation (v2 engine): configure the model only; the + // session is created on the first message. The effort is carried as the + // first session's thinking override so a session-only choice (Alt+S) + // made before any session exists is applied on creation. + const patch: Partial<AppState> = { model }; + if (effort !== undefined) { + patch.thinkingEffort = effort as ThinkingEffort; + patch.lazySessionThinking = effort as ThinkingEffort; + } + host.setAppState(patch); + return false; + } + + /** + * Re-read config and reactivate the persisted model after login or a + * config-refreshing command. Returns whatever the activation reports (see + * {@link activateModelAfterLogin}); `false` when no activation ran. + */ + async refreshConfigAfterLogin(): Promise<boolean> { + const { host } = this; + const config = await host.harness.getConfig({ reload: true }); + const availableModels = config.models ?? {}; + const availableProviders = config.providers ?? {}; + const defaultModel = host.options.startup.model ?? config.defaultModel; + const selected = defaultModel !== undefined ? availableModels[defaultModel] : undefined; + + if (defaultModel === undefined || selected === undefined) { + if (host.session === undefined) { + // Session-less v2: hydrate permission/plan defaults even without a + // default model. + await host.hydrateLazyConfigDefaults(); + } + host.setAppState({ availableModels, availableProviders }); + return false; + } + + const activated = await this.activateModelAfterLogin( + defaultModel, + thinkingEffortFromConfig(config.thinking), + ); + if (host.session === undefined) { + // Session-less v2: also hydrate permission/plan defaults from the + // refreshed config, same as startup. + await host.hydrateLazyConfigDefaults(); + host.setAppState({ availableModels, availableProviders }); + return activated; + } + const appStatePatch: Partial<AppState> = { + availableModels, + availableProviders, + model: defaultModel, + maxContextTokens: selected.maxContextSize, + }; + host.setAppState(appStatePatch); + return activated; + } + + async refreshConfigAfterLogout(): Promise<void> { + const config = await this.host.harness.getConfig({ reload: true }); + const availableModels = config.models ?? {}; + const availableProviders = config.providers ?? {}; + + if (this.host.session !== undefined) { + this.host.setAppState({ availableModels, availableProviders }); + return; + } + + this.host.setAppState({ + availableModels, + availableProviders, + model: '', + thinkingEffort: 'off', + maxContextTokens: 0, + contextUsage: 0, + contextTokens: 0, + }); + } + + /** + * Re-fetch model lists from every provider whose upstream supports it + * (managed OAuth, open platforms, custom registries) and update local + * config. Runs best-effort: individual provider failures are collected + * and returned instead of thrown. + */ + async refreshProviderModels(): Promise<RefreshResult> { + return this.refreshProviderModelsWithScope('all'); + } + + async refreshOAuthProviderModels(): Promise<RefreshResult> { + return this.refreshProviderModelsWithScope('oauth'); + } + + private async refreshProviderModelsWithScope(scope: RefreshProviderScope): Promise<RefreshResult> { + const result = await refreshAllProviderModels(this.buildRefreshHost(), { scope }); + if (result.changed.length > 0) { + await this.refreshAvailableModels(); + } + return result; + } + + /** + * Build the refresh orchestrator's persistence host. When the harness can + * persist several config sections as ONE atomic write (the v2 engine's + * `replaceSections`), the orchestrator's two-phase contract (removeProvider + * then setConfig) is absorbed the same way the v2 engine's own refresh path + * does it: the removal is staged in memory only, and the following + * setConfig persists the complete records in a single write — so a process + * exit mid-refresh can never leave config.toml in a "provider removed, not + * yet restored" state. The v1 harness keeps the legacy host (two + * whole-document writes, each atomic on its own). + */ + private buildRefreshHost(): RefreshProviderHost { + const { host } = this; + const resolveOAuthToken = async (providerName: string, oauthRef?: OAuthRef): Promise<string> => { + const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); + return tokenProvider.getAccessToken(); + }; + const userAgent = createKimiCodeUserAgent(); + if (!host.harness.supportsAtomicSectionReplace()) { + return { + getConfig: () => host.harness.getConfig({ reload: true }), + removeProvider: (id) => host.harness.removeProvider(id), + setConfig: (patch) => host.harness.setConfig(patch), + resolveOAuthToken, + userAgent, + }; + } + let staged: KimiConfig | undefined; + const requireStaged = (): KimiConfig => { + if (staged === undefined) { + throw new Error('refresh host: getConfig must be called before writes'); + } + return staged; + }; + return { + getConfig: async () => { + staged = await host.harness.getConfig({ reload: true }); + return staged; + }, + removeProvider: (id) => { + staged = removeProviderFromConfig(requireStaged(), id); + return Promise.resolve(staged); + }, + setConfig: async (patch) => { + // The orchestrator always passes complete records (built from a full + // clone), so the Partial-shaped patch is a full KimiConfig overlay. + staged = { ...requireStaged(), ...patch } as KimiConfig; + // Object.entries keeps keys whose value is `undefined`, so a cleared + // section (e.g. a dangling defaultModel) is expressed as a removal in + // the atomic write; sections absent from the patch stay untouched. + await host.harness.replaceConfigSections(Object.fromEntries(Object.entries(patch))); + return staged; + }, + resolveOAuthToken, + userAgent, + }; + } +} diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a46f0e9d7ef6e33354d6040d94ffe82ac1129e9 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -0,0 +1,237 @@ +import { Spacer } from '@moonshot-ai/pi-tui'; +import type { + Event, + KimiHarness, + Session, + TurnEndedEvent, +} from '@moonshot-ai/kimi-code-sdk'; + +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { BtwPanelComponent } from '../components/panes/btw-panel'; +import { formatErrorMessage } from '../utils/event-payload'; +import { formatHookResultPlain } from '../utils/hook-result-format'; +import { createMarkdownTheme } from '../theme/pi-tui-theme'; +import type { InlineSkillActivation } from '../types'; +import type { TUIState } from '../tui-state'; + +const BTW_BUSY_NOTICE = 'Wait for /btw to finish before sending another question.'; + +export interface BtwPanelHost { + state: TUIState; + session: Session | undefined; + readonly harness: KimiHarness; + + showError(msg: string): void; +} + +export class BtwPanelController { + private active: + | { + readonly agentId: string; + readonly panel: BtwPanelComponent; + } + | undefined; + private readonly panelsByAgentId = new Map<string, BtwPanelComponent>(); + + constructor(private readonly host: BtwPanelHost) {} + + open( + agentId: string, + initialPrompt: string, + inlineSkillActivations?: readonly InlineSkillActivation[], + ): void { + let panel: BtwPanelComponent; + panel = new BtwPanelComponent({ + markdownTheme: createMarkdownTheme(), + canUseScrollKeys: () => this.host.state.editor.getText().length === 0, + terminalRows: () => this.host.state.terminal.rows, + onPrompt: (prompt, inlineSkillActivations) => { + this.promptAgent(agentId, prompt, panel, inlineSkillActivations); + }, + }); + this.active = { agentId, panel }; + this.panelsByAgentId.set(agentId, panel); + this.mount(panel); + panel.submit(initialPrompt, inlineSkillActivations); + } + + isActive(): boolean { + return this.active !== undefined; + } + + clear(): void { + const active = this.active; + if (active !== undefined && this.shouldCancelOnUnmount(active.panel)) { + void this.cancelAgent(active.agentId); + } + this.active = undefined; + this.panelsByAgentId.clear(); + this.host.state.btwPanelContainer.clear(); + this.host.state.editor.connectedAbove = false; + } + + closeOrCancel(): boolean { + const active = this.active; + if (active === undefined) return false; + const shouldCancel = this.shouldCancelOnUnmount(active.panel); + this.close(active.panel); + if (shouldCancel) { + void this.cancelAgent(active.agentId); + } + return true; + } + + cancelRunning(): boolean { + const active = this.active; + if (active === undefined || !active.panel.isRunning()) return false; + void this.cancelAgent(active.agentId); + return true; + } + + sendUserInput(text: string, inlineSkillActivations?: readonly InlineSkillActivation[]): boolean { + const active = this.active; + if (active === undefined) return false; + if (active.panel.isRunning()) { + this.showBusyNotice(active, text); + return true; + } + active.panel.submit(text, inlineSkillActivations); + this.host.state.ui.setFocus(this.host.state.editor); + this.host.state.ui.requestRender(); + return true; + } + + scroll(direction: 'up' | 'down'): boolean { + const panel = this.active?.panel; + if (panel === undefined || !panel.scroll(direction)) return false; + this.host.state.ui.requestRender(); + return true; + } + + routeEvent(event: Event): boolean { + const panel = this.panelsByAgentId.get(event.agentId); + if (panel === undefined) return false; + + switch (event.type) { + case 'assistant.delta': + panel.appendAnswer(event.delta); + this.host.state.ui.requestRender(); + return true; + case 'thinking.delta': + panel.appendThinking(event.delta); + this.host.state.ui.requestRender(); + return true; + case 'hook.result': + panel.appendAnswer(formatHookResultPlain(event)); + this.host.state.ui.requestRender(); + return true; + case 'turn.ended': + if (event.reason === 'completed') { + panel.markDone(); + } else { + panel.markFailed(formatBtwTurnEnd(event)); + } + this.host.state.ui.requestRender(); + return true; + default: + return true; + } + } + + private mount(panel: BtwPanelComponent): void { + this.host.state.btwPanelContainer.clear(); + this.host.state.btwPanelContainer.addChild(new Spacer(1)); + this.host.state.btwPanelContainer.addChild(panel); + this.host.state.editor.connectedAbove = true; + this.host.state.ui.setFocus(this.host.state.editor); + this.host.state.ui.requestRender(); + } + + private close(panel: BtwPanelComponent): void { + if (!this.host.state.btwPanelContainer.children.includes(panel)) return; + this.unregister(panel); + this.host.state.btwPanelContainer.clear(); + this.host.state.editor.connectedAbove = false; + this.host.state.ui.setFocus(this.host.state.editor); + this.host.state.ui.requestRender(true); + } + + private unregister(panel: BtwPanelComponent): void { + for (const [agentId, candidate] of this.panelsByAgentId) { + if (candidate === panel) { + this.panelsByAgentId.delete(agentId); + } + } + if (this.active?.panel === panel) this.active = undefined; + } + + private showBusyNotice( + active: { readonly panel: BtwPanelComponent }, + input: string, + ): void { + this.host.state.editor.setText(input); + active.panel.addTransientNotice(BTW_BUSY_NOTICE); + this.host.state.ui.requestRender(); + } + + private promptAgent( + agentId: string, + prompt: string, + panel: BtwPanelComponent, + inlineSkillActivations?: readonly InlineSkillActivation[], + ): void { + const session = this.host.session; + if (session === undefined) { + panel.markFailed(NO_ACTIVE_SESSION_MESSAGE); + this.host.state.ui.requestRender(); + return; + } + const send = + inlineSkillActivations !== undefined && inlineSkillActivations.length > 0 + ? () => + session.promptWithSkills( + prompt, + inlineSkillActivations.map((activation) => ({ + name: activation.skillName, + args: activation.args, + })), + ) + : () => session.prompt(prompt); + void this.withInteractiveAgent(agentId, send).catch((error: unknown) => { + panel.markFailed(`Failed to send /btw prompt: ${formatErrorMessage(error)}`); + this.host.state.ui.requestRender(); + }); + } + + private async cancelAgent(agentId: string): Promise<void> { + const session = this.host.session; + if (session === undefined) return; + await this.withInteractiveAgent(agentId, () => session.cancel()).catch((error: unknown) => { + this.host.showError(`Failed to cancel /btw: ${formatErrorMessage(error)}`); + }); + } + + private shouldCancelOnUnmount(panel: BtwPanelComponent): boolean { + return panel.isRunning() || panel.isEmpty(); + } + + private withInteractiveAgent<T>(agentId: string, fn: () => Promise<T>): Promise<T> { + return this.host.harness.withInteractiveAgent(agentId, fn); + } +} + +function formatBtwTurnEnd(event: TurnEndedEvent): string { + if (event.reason === 'cancelled') { + return 'Interrupted by user'; + } + if (event.error?.code === 'provider.filtered') { + return 'Provider safety policy blocked the response.'; + } + if (event.error !== undefined) { + return `[${event.error.code}] ${event.error.message}`; + } + if (event.reason === 'blocked') { + return 'Prompt hook blocked the request.'; + } + return `BTW turn ended with reason: ${event.reason}`; +} diff --git a/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts b/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..4cf40c93629053bf871c386fa971bf8c7afd85ad --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts @@ -0,0 +1,527 @@ +/** + * CacheHintController — drives the "cache expired" dialog for the two trigger + * scenarios: resuming a long-idle session (fires right after the resume + * finishes loading) and submitting after an in-process idle stretch + * (intercepts the submit). Owns the frequency guards and the in-process + * activity baseline; the pure trigger rule lives in `../utils/cache-hint`. + */ + +import type { Component, Focusable } from '@moonshot-ai/pi-tui'; +import type { KimiHarness, Session, TokenUsage } from '@moonshot-ai/kimi-code-sdk'; + +import { getCacheHintConfig, peekCacheHintConfig } from '#/utils/cache-hint-config'; +import { currentTuiConfig } from '../commands/config'; +import { + CacheHintDialogComponent, + type CacheHintAction, +} from '../components/dialogs/cache-hint-dialog'; +import { saveTuiConfig } from '../config'; +import { MAIN_AGENT_ID } from '../constant/kimi-tui'; +import type { AppState, InlineSkillActivation } from '../types'; +import type { TUIState } from '../tui-state'; +import { evaluateCacheHint } from '../utils/cache-hint'; +import { formatErrorMessage } from '../utils/event-payload'; +import { + makeExtractionResendable, + originalsDirForSession, + type ExtractionResult, +} from '../utils/image-placeholder'; + +/** A swallowed submit: the raw text plus its media extraction (done before + * the dialog so pasted attachments survive a later store clear). */ +interface StashedSubmit { + readonly text: string; + readonly extraction?: ExtractionResult; + /** Session that owned any daemon refs inside {@link extraction}. */ + readonly sessionId: string; + readonly inlineSkillActivations?: readonly InlineSkillActivation[]; +} + +export interface CacheHintHost { + readonly harness: KimiHarness; + readonly session: Session | undefined; + readonly state: TUIState; + track(event: string, props?: Record<string, unknown>): void; + setAppState(patch: Partial<AppState>): void; + mountEditorReplacement(panel: Component & Focusable): void; + restoreEditor(): void; + restoreInputText(text: string): void; + /** + * A stashed submission going back to the editor releases its extraction's + * staged media with queue-recall semantics (consume retains, retire staged + * copies, rebase videos) — without this the retains/copies would leak. + */ + recallStashedMedia(extraction: ExtractionResult | undefined): void; + showError(message: string): void; + createNewSession(): Promise<void>; + sendNormalUserInput(text: string, preExtracted?: ExtractionResult): Promise<void>; + sendInlineSkillUserInput( + text: string, + activations: readonly InlineSkillActivation[], + preExtracted?: ExtractionResult, + ): Promise<void>; +} + +type HintDecision = { readonly idleSeconds: number; readonly totalTokens: number }; + +/** Cache-break detection: a step's cache read dropping under 95% of the + * previous step's by more than this many tokens counts as a break. */ +const CACHE_BREAK_MIN_DROP_TOKENS = 2000; +const CACHE_BREAK_DROP_RATIO = 0.95; + +interface CacheBreakBaseline { + readonly model: string; + readonly effort: string; + readonly usage: TokenUsage; + readonly time: number; +} + +export class CacheHintController { + /** Latest in-process LLM round-trip time (turn begin / turn end). */ + private lastActivityAt: number | undefined; + /** One prompt per idle cycle; reset when a real send starts a turn. */ + private idlePrompted = false; + /** Cold-cache trigger fetches at most once per idle cycle (loop guard for + * the release-and-resend path). */ + private triggerFetchAttempted = false; + /** Swallowed submits waiting on the cold-cache interception chain. */ + private pendingInterceptions = 0; + /** FIFO chain serializing swallowed submits so they keep submit order. */ + private interceptionTail: Promise<void> = Promise.resolve(); + /** Set while a stashed message is being released back into the send path. */ + private releasingStashed = false; + /** Whether the idle dialog's triggering message was restored, not sent. */ + private lastDialogRestored = false; + /** Inputs restored this cycle — chained restores append (newline-joined) + * instead of overwriting the editor. */ + private restoredTexts: string[] = []; + /** Resume scenario fires at most once per session per TUI instance. */ + private readonly resumedSessions = new Set<string>(); + /** Last measured main-loop step usage for cache-break detection. */ + private breakBaseline: CacheBreakBaseline | undefined; + + constructor(private readonly host: CacheHintHost) {} + + /** + * Cache-break detection (client-side, main loop): feed each completed + * step's usage. A step whose cache read drops sharply below the previous + * one is reported as `cache_break_detected` with both usages, both + * model/effort values, the drop ratio, and the interval — a mid-session + * model/effort switch busts the cache key, and that cause is exactly what + * the report should carry. Unmeasured (missing/all-zero) usage is skipped + * without touching the baseline; compaction resets it (the drop there is + * expected). + * + * Also doubles as the cache-activity signal: a completed step is a real + * provider round trip, so the server-side cache was just refreshed — + * unlike a bare turn begin, whose prompt may still fail before any model + * request. + */ + noteStepUsage(usage: TokenUsage | undefined): void { + this.recordActivity(); + if (usage === undefined) return; + if ( + usage.inputOther === 0 && + usage.output === 0 && + usage.inputCacheRead === 0 && + usage.inputCacheCreation === 0 + ) { + return; + } + const model = this.host.state.appState.model; + const effort = this.host.state.appState.thinkingEffort; + const now = Date.now(); + const prev = this.breakBaseline; + this.breakBaseline = { model, effort, usage, time: now }; + if (prev === undefined) return; + const prevRead = prev.usage.inputCacheRead; + const currRead = usage.inputCacheRead; + if (currRead >= prevRead * CACHE_BREAK_DROP_RATIO) return; + if (prevRead - currRead <= CACHE_BREAK_MIN_DROP_TOKENS) return; + this.host.track('cache_break_detected', { + prev_model: prev.model, + curr_model: model, + prev_effort: prev.effort, + curr_effort: effort, + prev_input_cache_read: prevRead, + curr_input_cache_read: currRead, + prev_input_other: prev.usage.inputOther, + curr_input_other: usage.inputOther, + prev_output: prev.usage.output, + curr_output: usage.output, + prev_input_cache_creation: prev.usage.inputCacheCreation, + curr_input_cache_creation: usage.inputCacheCreation, + cache_read_drop_ratio: (prevRead - currRead) / prevRead, + interval_ms: now - prev.time, + }); + } + + /** Compaction legitimately shrinks the cached prefix — reset the baseline. + * Also used when the context is cut by other means (e.g. /undo). */ + resetCacheBreakBaseline(): void { + this.breakBaseline = undefined; + } + + recordActivity(): void { + this.lastActivityAt = Date.now(); + } + + /** + * A real send starts a turn — open a fresh idle cycle. Cache activity is + * deliberately NOT recorded here: the prompt may still fail before any + * model request (rejected call, hook-blocked turn), and only a completed + * provider round trip refreshes the server-side cache. + */ + onTurnBegin(): void { + this.idlePrompted = false; + this.triggerFetchAttempted = false; + this.lastDialogRestored = false; + this.restoredTexts = []; + } + + /** Session switch / create: the new session has no in-process baseline. */ + resetRuntime(): void { + this.lastActivityAt = undefined; + this.idlePrompted = false; + this.triggerFetchAttempted = false; + this.lastDialogRestored = false; + this.restoredTexts = []; + this.breakBaseline = undefined; + } + + /** Background warm-up on session creation; never blocks, never throws. */ + refreshConfigInBackground(): void { + void this.resolveConfig(); + } + + /** Scenario 1: call right after a resume finishes loading. */ + async maybeShowOnResume(): Promise<void> { + const { host } = this; + const session = host.session; + if (session === undefined) return; + if (this.resumedSessions.has(session.id)) return; + const main = session.getResumeState()?.agents[MAIN_AGENT_ID]; + let lastActiveAt = 0; + for (const record of main?.replay ?? []) { + // Only message/compaction records correspond to LLM round-trips; state + // records (permission/plan/config updates, approval results) can be + // appended by slash commands without touching the cache. + if (record.type !== 'message' && record.type !== 'compaction') continue; + if (record.time > lastActiveAt) lastActiveAt = record.time; + } + // `summary.updatedAt` ≈ last user prompt — a coarser but valid fallback. + if (lastActiveAt === 0) lastActiveAt = session.summary?.updatedAt ?? 0; + if (lastActiveAt === 0) return; + const config = await this.resolveConfig(); + // The config fetch above can outlive the user's patience: if they switched + // sessions meanwhile, this dialog (and its actions) would target the wrong + // session — drop it. Likewise, if they already sent the first prompt and + // a turn is now running, don't mount over the active turn. + if (host.session !== session) return; + if (host.state.appState.streamingPhase !== 'idle' || host.state.appState.isCompacting) { + return; + } + // Fold in-process activity into the replay-derived baseline before + // judging: a turn may have completed during the fetch, and a completed + // turn refreshes the server-side cache — the stale replay timestamp would + // warn about an expiration the user just paid to fix. Seeding the + // baseline either way also lets a resume inside the cache window expire + // via the idle-submit path while the user idles in the TUI. + lastActiveAt = Math.max(lastActiveAt, this.lastActivityAt ?? 0); + this.lastActivityAt = lastActiveAt; + const decision = evaluateCacheHint({ + now: Date.now(), + lastActiveAt, + totalTokens: main?.context.tokenCount, + modelId: this.upstreamModelId(), + config, + dismissed: host.state.appState.cacheExpiryHint === false, + }); + if (decision.kind === 'skip') return; + this.resumedSessions.add(session.id); + // The resume dialog also covers this idle cycle: the first submit right + // after it must not be intercepted again. + this.idlePrompted = true; + await this.showDialog('resume', decision, undefined); + } + + /** + * Scenario 2: intercept an idle submit. Returns true when swallowed. + * Synchronous in every non-hint path — the send pipeline must stay + * await-free up to `sendMessage` (tests assert `prompt()` synchronously + * right after `handleUserInput`). When the config cache is cold the submit + * is swallowed while the config is fetched (spec: the trigger must reach + * the interface); the message is then either shown the dialog or released. + */ + maybeInterceptOnSubmit( + text: string, + extraction?: ExtractionResult, + inlineSkillActivations?: readonly InlineSkillActivation[], + ): boolean { + const { host } = this; + if (host.session === undefined) return false; + // A stashed message being released re-enters the send path here — never + // re-intercept it (that would start a second fetch loop). + if (this.releasingStashed) return false; + if (this.idlePrompted || this.lastActivityAt === undefined) return false; + if (host.state.appState.streamingPhase !== 'idle' || host.state.appState.isCompacting) { + return false; + } + if (host.state.appState.cacheExpiryHint === false) return false; + // Providers that can never match a cache rule (apiKey / self-hosted) must + // not pay the cold-fetch stall below — no hint can ever come of it. + if (this.upstreamModelId() === undefined) return false; + // Coarse floor: configured cache durations are 10min+, so anything + // fresher than a minute can never hint. + if (Date.now() - this.lastActivityAt < 60_000) return false; + const stash: StashedSubmit = { text, extraction, sessionId: host.session.id, inlineSkillActivations }; + const cached = peekCacheHintConfig(); + if (cached !== undefined) { + const decision = evaluateCacheHint({ + now: Date.now(), + lastActiveAt: this.lastActivityAt, + totalTokens: host.state.appState.contextTokens, + modelId: this.upstreamModelId(), + config: cached, + dismissed: false, + }); + if (decision.kind === 'skip') return false; + this.idlePrompted = true; + // Mounts synchronously inside; the action resolution runs async. + void this.showDialog('idle', decision, stash); + return true; + } + // Config cache cold: fetch at trigger time. Submits arriving while the + // interception is in flight are swallowed too and replayed through a FIFO + // chain, so a later prompt can never overtake the stashed one. A fetch + // that already failed this cycle falls through to the normal send path. + if (this.triggerFetchAttempted && this.pendingInterceptions === 0) return false; + this.triggerFetchAttempted = true; + const sessionId = host.session.id; + this.pendingInterceptions += 1; + this.interceptionTail = this.interceptionTail + .then(() => this.interceptAfterFetch(stash, sessionId)) + .finally(() => { + this.pendingInterceptions -= 1; + }); + return true; + } + + /** Cold-cache path: fetch the config, then show the dialog or release. */ + private async interceptAfterFetch(stash: StashedSubmit, sessionId: string): Promise<void> { + const { host } = this; + // A dialog already ran for this idle cycle: chained submits follow the + // fate of the message that opened it. If that message was restored + // (dismissed or its action failed), restore these too — sending them now + // would reorder the conversation. + if (this.idlePrompted) { + if (this.lastDialogRestored) { + this.restoreStashedInput(stash); + } else { + await this.releaseStashed(stash); + } + return; + } + const config = await this.resolveConfig(); + // The fetch window is unbounded for the user: if they switched sessions + // meanwhile, never send the stashed text into the wrong session — hand it + // back to the editor instead. + if (host.session?.id !== sessionId) { + this.restoreStashedInput(stash); + return; + } + // If a foreground operation (turn, /compact, …) started meanwhile, don't + // mount over it — release through the normal path, which queues behind + // the running operation. + if (host.state.appState.streamingPhase !== 'idle' || host.state.appState.isCompacting) { + await this.releaseStashed(stash); + return; + } + if (config !== undefined) { + const decision = evaluateCacheHint({ + now: Date.now(), + lastActiveAt: this.lastActivityAt ?? 0, + totalTokens: host.state.appState.contextTokens, + modelId: this.upstreamModelId(), + config, + dismissed: false, + }); + if (decision.kind === 'hint') { + this.idlePrompted = true; + await this.showDialog('idle', decision, stash); + return; + } + } + // No hint (fetch failed or rules don't match): release the message. The + // re-entry skips the fetch (fresh cache or triggerFetchAttempted) and + // flows straight to send. + await this.releaseStashed(stash); + } + + /** Release a stashed message through the normal send path, bypassing the + * interception gate so the re-entry cannot start a second fetch. */ + private async releaseStashed(stash: StashedSubmit): Promise<void> { + this.releasingStashed = true; + try { + await this.releaseToSendPath(stash); + } finally { + this.releasingStashed = false; + } + } + + private async releaseToSendPath(stash: StashedSubmit): Promise<void> { + // A session reset cleared the image store: rebuild the extraction from + // its snapshots, persisting compressed pastes' originals into the NEW + // session's originals dir so the compression caption survives the move. + const extraction = + stash.extraction !== undefined && this.host.state.appState.sessionId !== stash.sessionId + ? makeExtractionResendable(stash.extraction, originalsDirForSession(this.host.session)) + : stash.extraction; + if (stash.inlineSkillActivations !== undefined && stash.inlineSkillActivations.length > 0) { + await this.host.sendInlineSkillUserInput(stash.text, stash.inlineSkillActivations, extraction); + return; + } + await this.host.sendNormalUserInput(stash.text, extraction); + } + + /** Restore a stashed input to the editor, appending to anything already + * restored this cycle so earlier text is not overwritten, and release the + * stash's staged media with recall semantics — the restored draft still + * references its attachments, so retains are consumed (the next submit + * re-retains) and staged copies retire instead of leaking. */ + private restoreStashedInput(stash: StashedSubmit | undefined): void { + if (stash === undefined) return; + this.restoredTexts.push(stash.text); + this.host.restoreInputText(this.restoredTexts.join('\n')); + this.host.recallStashedMedia(stash.extraction); + } + + private upstreamModelId(): string | undefined { + const { model, availableModels, availableProviders } = this.host.state.appState; + const alias = availableModels[model]; + if (alias === undefined) return undefined; + // The cache rules describe the managed service's server-side cache, so + // they only apply to OAuth-managed providers — apiKey or self-hosted + // providers never hint. + if (availableProviders[alias.provider]?.oauth === undefined) return undefined; + return alias.model; + } + + private async resolveConfig() { + let accessToken: string | undefined; + try { + accessToken = await this.host.harness.auth.getCachedAccessToken(); + } catch { + // Facade unavailable (test doubles) — never fetch. + return undefined; + } + // The endpoint is public: apiKey-only users fetch anonymously. + return getCacheHintConfig({ accessToken }); + } + + private async showDialog( + scene: 'resume' | 'idle', + decision: HintDecision, + stashed: StashedSubmit | undefined, + ): Promise<void> { + const { host } = this; + host.track('cache_hint_shown', { + scene, + model: host.state.appState.model, + idle_seconds: decision.idleSeconds, + total_tokens: decision.totalTokens, + }); + const action = await new Promise<CacheHintAction | 'dismiss'>((resolve) => { + host.state.activeDialog = 'cache-hint'; + host.mountEditorReplacement( + new CacheHintDialogComponent({ + idleSeconds: decision.idleSeconds, + totalTokens: decision.totalTokens, + onSelect: (a) => { + resolve(a); + }, + onCancel: () => { + resolve('dismiss'); + }, + }), + ); + }); + host.state.activeDialog = null; + host.restoreEditor(); + host.track('cache_hint_action', { action, scene }); + await this.runAction(action, stashed); + } + + private async runAction( + action: CacheHintAction | 'dismiss', + stashed: StashedSubmit | undefined, + ): Promise<void> { + const { host } = this; + const restoreInput = () => { + this.lastDialogRestored = true; + this.restoreStashedInput(stashed); + }; + switch (action) { + case 'dismiss': + restoreInput(); + return; + case 'never': + host.setAppState({ cacheExpiryHint: false }); + try { + await saveTuiConfig({ ...currentTuiConfig(host), cacheExpiryHint: false }); + } catch { + host.showError('Failed to save the tui.toml preference.'); + } + break; + case 'compact': { + const session = host.session; + if (session !== undefined) { + try { + await session.compact({}); + } catch (error) { + host.showError(`Compact failed: ${formatErrorMessage(error)}`); + restoreInput(); + return; + } + if (stashed !== undefined) { + // compact() is trigger-only — the engine engages asynchronously. + // Wait for the engagement barrier so the resend lands in the + // queue and drains automatically when compaction finishes. + if (!(await this.waitForCompactionStart())) { + host.showError('Compact did not start; message not sent.'); + restoreInput(); + return; + } + } + } + break; + } + case 'new': { + const previousId = host.state.appState.sessionId; + await host.createNewSession(); + if (host.state.appState.sessionId === previousId) { + // Creation failed (error already surfaced); keep the input for retry. + restoreInput(); + return; + } + break; + } + case 'continue': + break; + } + this.lastDialogRestored = false; + if (stashed !== undefined) await this.releaseStashed(stashed); + } + + /** Bounded wait for the engine to flip `isCompacting` after a compact RPC. */ + private async waitForCompactionStart(timeoutMs = 3000): Promise<boolean> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (this.host.state.appState.isCompacting) return true; + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + } + return false; + } +} diff --git a/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts new file mode 100644 index 0000000000000000000000000000000000000000..48dd1f8b83cbc8c37ed749161a1e78c9462e1590 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts @@ -0,0 +1,167 @@ +import type { TUI } from '@moonshot-ai/pi-tui'; + +import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; + +import { FOCUS_DEBOUNCE_MS, HINT_DISPLAY_MS } from '../constant/clipboard-image-hint'; +import { TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT } from '../utils/terminal-focus'; +import type { FooterComponent } from '../components/chrome/footer'; + +export interface ClipboardImageHintHost { + readonly ui: TUI; + readonly footer: FooterComponent; + getModelSupportsImage(): boolean; + requestRender(): void; +} + +function getPasteImageShortcut(): string { + return process.platform === 'win32' ? 'Alt+V' : 'Ctrl+V'; +} + +export class ClipboardImageHintController { + private readonly host: ClipboardImageHintHost; + private disposeInputListener: (() => void) | undefined; + private debounceTimer: ReturnType<typeof setTimeout> | undefined; + private clearHintTimer: ReturnType<typeof setTimeout> | undefined; + private lastHintText: string | undefined; + private checkGeneration = 0; + private focused = true; + // Whether the controller has completed its first clipboard observation since + // start. The first observation only establishes a baseline: an image already + // in the clipboard when the session starts is not "new", so it must not + // trigger a hint during initialization. + private initialized = false; + // Whether a detected clipboard image is allowed to trigger a hint. After + // showing a hint for an image it disarms so the same lingering image does + // not nag on every focus. A focus check that finds the clipboard empty + // re-arms it, so the next genuinely new image notifies again. + private armed = true; + + constructor(host: ClipboardImageHintHost) { + this.host = host; + } + + start(): void { + this.disposeInputListener = this.host.ui.addInputListener((data) => { + this.handleInput(data); + }); + void this.establishInitialBaseline(); + } + + stop(): void { + this.clearDebounceTimer(); + this.clearClearHintTimer(); + this.disposeInputListener?.(); + this.disposeInputListener = undefined; + + this.checkGeneration += 1; + this.clearOwnedHint(); + this.initialized = false; + this.armed = true; + } + + private handleInput(data: string): void { + if (data === TERMINAL_FOCUS_IN) { + this.focused = true; + this.scheduleCheck(); + return; + } + if (data === TERMINAL_FOCUS_OUT) { + this.focused = false; + this.clearDebounceTimer(); + return; + } + } + + private scheduleCheck(): void { + this.clearDebounceTimer(); + this.checkGeneration += 1; + const generation = this.checkGeneration; + this.debounceTimer = setTimeout(() => void this.runCheck(generation), FOCUS_DEBOUNCE_MS); + } + + private clearDebounceTimer(): void { + if (this.debounceTimer !== undefined) { + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; + } + } + + private clearClearHintTimer(): void { + if (this.clearHintTimer !== undefined) { + clearTimeout(this.clearHintTimer); + this.clearHintTimer = undefined; + } + } + + private clearOwnedHint(): void { + if (this.host.footer.getTransientHint() === this.lastHintText) { + this.host.footer.setTransientHint(null); + this.host.requestRender(); + } + this.lastHintText = undefined; + } + + private async establishInitialBaseline(): Promise<void> { + if (!this.host.getModelSupportsImage()) return; + + this.checkGeneration += 1; + const generation = this.checkGeneration; + + let hasImage = false; + try { + hasImage = await clipboardHasImage(); + } catch { + return; + } + + if (generation !== this.checkGeneration) return; + + this.initialized = true; + this.armed = !hasImage; + } + + private async runCheck(generation: number): Promise<void> { + if (!this.focused) return; + if (!this.host.getModelSupportsImage()) return; + + let hasImage = false; + try { + hasImage = await clipboardHasImage(); + } catch { + return; + } + + if (generation !== this.checkGeneration) return; + if (!this.focused) return; + + // First observation after start only establishes the baseline. An image + // already in the clipboard when the session began is not "new", so we + // record the state and stay quiet instead of nagging during initialization. + if (!this.initialized) { + this.initialized = true; + this.armed = !hasImage; + return; + } + + if (!hasImage) { + // Clipboard holds no image, so the next image that appears is a new one + // worth notifying about. Re-arm and bail out. + this.armed = true; + return; + } + + // Same image we already notified about — stay quiet until it changes. + if (!this.armed) return; + + const hintText = `Image in clipboard · ${getPasteImageShortcut()} to paste`; + this.clearClearHintTimer(); + this.lastHintText = hintText; + this.armed = false; + this.host.footer.setTransientHint(hintText); + this.host.requestRender(); + + this.clearHintTimer = setTimeout(() => { + this.clearOwnedHint(); + }, HINT_DISPLAY_MS); + } +} diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a115b4467c17e0ef22f48ce1069009d9c35c831 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -0,0 +1,804 @@ +import { readFile } from 'node:fs/promises'; + +import type { FileMeta, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import { compressImageForModel } from '@moonshot-ai/kimi-code-sdk'; + +import { + ClipboardMediaError, + readClipboardMedia, + type ClipboardVideo, +} from '#/utils/clipboard/clipboard-image'; +import { parseImageMeta } from '#/utils/image/image-mime'; +import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor'; + +import { + CTRL_C_HINT, + CTRL_D_HINT, + DOUBLE_ESC_WINDOW_MS, + EXIT_CONFIRM_WINDOW_MS, + LLM_NOT_SET_MESSAGE, +} from '../constant/kimi-tui'; +import { Key, matchesKey } from '@moonshot-ai/pi-tui'; +import { MEDIA_STAGING_TTL_SECONDS } from '../constant/media'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { + ImageAttachment, + ImageAttachmentStore, + VideoAttachment, +} from '../utils/image-attachment-store'; +import { extractMediaAttachments, imageExtensionForMime } from '../utils/image-placeholder'; +import { extractInlineSkillActivations } from '../utils/inline-skill-tokens'; +import type { PendingExit, QueuedMessage, SteerInputItem } from '../types'; +import type { TUIState } from '../tui-state'; +import type { BtwPanelController } from './btw-panel'; +import type { SurveyController } from './survey-controller'; + +export interface EditorKeyboardHost { + state: TUIState; + session: Session | undefined; + cancelInFlight: (() => void) | undefined; + /** + * The host's harness (KimiTUI always has one). Its `imageLimits` drives + * paste-time image compression; hosts without one fall back to the + * env/built-in default. + */ + harness?: KimiHarness | undefined; + + handleUserInput(text: string): void; + readonly btwPanelController: BtwPanelController; + readonly surveyController: SurveyController; + readonly skillCommandMap: Map<string, string>; + steerMessage(session: Session, input: readonly SteerInputItem[]): void; + steerSkillActivation(session: Session, skillName: string, skillArgs: string): void; + validateMediaCapabilities(extraction: { + hasMedia: boolean; + imageAttachmentIds: readonly number[]; + videoAttachmentIds: readonly number[]; + }): boolean; + releaseStagingMedia(mediaAttachmentIds: readonly number[]): void; + recallLastQueued(): QueuedMessage | undefined; + showError(msg: string): void; + track(event: string, props?: Record<string, unknown>): void; + updateEditorBorderHighlight(text?: string): void; + /** `undefined` means the input cannot be a `/goal` command (clear without measuring). */ + updateGoalLengthWarning(text: string | undefined): void; + updateQueueDisplay(): void; + toggleToolOutputExpansion(): void; + toggleTodoPanelExpansion(): void; + /** Returns true when the Updates panel grabbed or released focus. */ + toggleNotifyPanelFocus(): boolean; + handleNotifyPanelKey(key: 'left' | 'right' | 'up' | 'down' | 'escape'): boolean; + detachCurrentForegroundTask(): void; + cancelRunningShellCommand(): void; + hideSessionPicker(): void; + openUndoSelector(): void; + stop(exitCode?: number): Promise<void>; + ensureSession(): Promise<Session | undefined>; + handlePlanToggle(next: boolean): void; + handleInputModeChange(mode: 'prompt' | 'bash'): void; + clearQueuedMessages(): void; + setExternalEditorRunning(running: boolean): void; + updateActivityPane(): void; +} + +export class EditorKeyboardController { + private pendingExit: PendingExit | null = null; + private pendingUndoEsc: { readonly timer: ReturnType<typeof setTimeout> } | null = null; + + constructor( + private readonly host: EditorKeyboardHost, + private readonly imageStore: ImageAttachmentStore, + ) {} + + install(): void { + const { host } = this; + const editor = host.state.editor; + + editor.onSubmit = (text: string) => { + if (host.surveyController.handleSubmit(text)) return; + host.handleUserInput(text); + }; + + editor.onPreInput = (data: string) => { + if (matchesKey(data, Key.escape)) this.clearPendingExit(); + const consumed = host.surveyController.handlePreInput(data); + if (consumed) this.clearPendingUndoEsc(); + return consumed; + }; + + editor.onChange = (text: string) => { + if (this.pendingExit) this.clearPendingExit(); + host.surveyController.handleEditorChange(text); + host.updateEditorBorderHighlight(text); + // Expanding paste markers costs a full-text pass, and only `/goal` + // input can trip the objective length limit — so skip the expansion + // for ordinary prompts. Submitted text is trimmed before dispatch, so + // gate on the trimmed text too. A paste marker may itself expand into + // part of the command (`[paste #…]` → `/goal …`, or completing a + // partial prefix like `/go[paste #1 …]` → `/goal …`), so any input + // containing a marker that can still become a `/goal` command must + // pass the gate as well. + const trimmed = text.trimStart(); + const mightBeGoal = + trimmed.startsWith('/goal') || + trimmed.startsWith('[paste #') || + (trimmed.startsWith('/') && trimmed.includes('[paste #')); + if (editor.inputMode !== 'bash' && mightBeGoal) { + host.updateGoalLengthWarning(editor.getExpandedText()); + } else { + host.updateGoalLengthWarning(undefined); + } + }; + + // bash mode recalls only shell (`!`-prefixed) history entries; prompt mode + // recalls everything. The filter is locked to the mode captured when the + // user first enters history browsing (see onHistoryDraftSave), so landing on + // a shell entry mid-browse doesn't switch the filter to shell-only. + let browseMode: 'prompt' | 'bash' | null = null; + editor.setHistoryFilter((entry: string) => { + const mode = browseMode ?? editor.inputMode; + return mode === 'bash' ? entry.startsWith('!') : true; + }); + + // Recalling a `!`-prefixed entry strips the marker and returns to bash + // mode; recalling a plain entry returns to prompt mode. The filter above + // guarantees bash mode only ever lands on `!` entries, so this never + // misfires on commands typed in bash mode. + editor.onRecall = (entry: string) => { + if (entry.startsWith('!')) { + editor.setInputMode('bash'); + return entry.slice(1); + } + editor.setInputMode('prompt'); + return undefined; + }; + + // Save/restore the input mode alongside pi-tui's history draft. Without + // this, recalling a shell entry and then pressing Down back to an empty + // draft would leave the editor stuck in bash mode, so the next typed + // message would be submitted as a shell command. Also locks the history + // filter (browseMode) for the duration of the browse session. + editor.onHistoryDraftSave = () => { + browseMode = editor.inputMode; + return editor.inputMode; + }; + editor.onHistoryDraftRestore = (state: unknown) => { + editor.setInputMode(state as 'prompt' | 'bash'); + browseMode = null; + }; + + editor.onNonEscapeInput = () => { + this.clearPendingUndoEsc(); + }; + + editor.onCtrlC = () => { + if (host.cancelInFlight !== undefined) { + const cancel = host.cancelInFlight; + host.cancelInFlight = undefined; + this.clearPendingExit(); + cancel(); + return; + } + + // The btw panel stacks above the transcript, so Ctrl+C cancels/closes it + // before touching an in-flight compaction or stream. + if (host.btwPanelController.cancelRunning()) { + this.clearPendingExit(); + return; + } + if (host.btwPanelController.closeOrCancel()) { + this.clearPendingExit(); + return; + } + + if (host.state.appState.isCompacting) { + this.clearPendingExit(); + + if (this.clearEditorTextIfPresent()) return; + + this.cancelCurrentCompaction(); + return; + } + + if (host.state.appState.streamingPhase !== 'idle') { + this.clearPendingExit(); + + if (this.clearEditorTextIfPresent()) return; + + this.cancelCurrentStream(); + return; + } + + if (this.pendingExit?.kind === 'ctrl-c') { + this.clearPendingExit(); + void host.stop(); + return; + } + + if (editor.getText().length > 0) { + editor.setText(''); + } + this.armPendingExit('ctrl-c', CTRL_C_HINT); + }; + + editor.onCtrlD = () => { + if (this.pendingExit?.kind === 'ctrl-d') { + this.clearPendingExit(); + void host.stop(); + return; + } + this.armPendingExit('ctrl-d', CTRL_D_HINT); + }; + + editor.onEscape = () => { + if (this.pendingExit) this.clearPendingExit(); + if (host.state.activeDialog === 'session-picker') { + host.hideSessionPicker(); + this.clearPendingUndoEsc(); + return; + } + // The btw panel stacks above the transcript, so Esc dismisses it before + // touching an in-flight compaction or stream. + if (host.btwPanelController.closeOrCancel()) { + this.clearPendingUndoEsc(); + return; + } + if (host.state.appState.isCompacting) { + this.cancelCurrentCompaction(); + this.clearPendingUndoEsc(); + return; + } + if (host.state.appState.streamingPhase !== 'idle') { + this.cancelCurrentStream(); + this.clearPendingUndoEsc(); + return; + } + // Idle: a second Esc within the double-tap window opens the undo selector. + if (this.pendingUndoEsc !== null) { + this.clearPendingUndoEsc(); + host.openUndoSelector(); + return; + } + this.armPendingUndoEsc(); + }; + + editor.onShiftTab = () => { + const togglePlan = (): void => { + const next = !host.state.appState.planMode; + host.track('shortcut_plan_toggle', { enabled: next }); + host.track('shortcut_mode_switch', { to_mode: next ? 'plan' : 'agent' }); + host.handlePlanToggle(next); + }; + if (host.session === undefined) { + // v2 session-less: lazy-create the session, then toggle — the same + // path /plan takes. + void host.ensureSession().then((session) => { + if (session !== undefined) togglePlan(); + }); + return; + } + togglePlan(); + }; + + editor.onInputModeChange = (mode) => { + host.handleInputModeChange(mode); + }; + + editor.onOpenExternalEditor = () => { + host.surveyController.closeSilently(); + host.track('shortcut_editor'); + void this.openExternalEditor(); + }; + + editor.onToggleToolExpand = () => { + host.track('shortcut_expand'); + host.toggleToolOutputExpansion(); + }; + + editor.onToggleTodoExpand = (): boolean => { + if (!host.state.todoPanel.hasOverflow()) return false; + // Disarm a pending double-press exit confirmation so expanding the + // todo list in between two Ctrl-C presses does not accidentally exit. + this.clearPendingExit(); + host.track('shortcut_todo_expand'); + host.toggleTodoPanelExpansion(); + return true; + }; + + editor.onPageNotify = (): boolean => { + if (!host.toggleNotifyPanelFocus()) return false; + this.clearPendingExit(); + host.track('shortcut_notify_page'); + return true; + }; + + editor.onNotifyPanelKey = (key) => host.handleNotifyPanelKey(key); + + editor.onCtrlS = () => { + if ( + host.state.appState.streamingPhase === 'idle' || + host.state.appState.streamingPhase === 'shell' || + host.state.appState.isCompacting + ) + return; + const text = editor.getText().trim(); + const editorIsBash = editor.inputMode === 'bash'; + + // Bash commands (`! …`) are not steerable: they stay queued so they run + // after the current task. Grouped inline-skill submissions are not + // steerable either — steer carries no skill activations, so they stay + // queued and submit intact when the session drains; the same applies to + // an editor draft carrying inline skill tokens. Steering stops at the + // first such bundle: items behind it stay queued too, or a later + // message would jump ahead of its bundle and reverse the conversational + // order. Everything else steers in queue order — plain text as a + // steered message, slash-skill items as activations fired into the + // running turn (never as literal text). + const queued = host.state.queuedMessages; + const firstBundle = queued.findIndex((m) => m.inlineSkillActivations !== undefined); + const windowBeforeFirstBundle = firstBundle === -1 ? queued : queued.slice(0, firstBundle); + const steerable = windowBeforeFirstBundle.filter((m) => m.mode !== 'bash'); + const editorHasInlineSkills = + !editorIsBash && + text.length > 0 && + extractInlineSkillActivations(text, host.skillCommandMap).length > 0; + + type SteerRun = + | { readonly kind: 'text'; readonly items: SteerInputItem[] } + | { readonly kind: 'skill'; readonly skillName: string; readonly skillArgs: string }; + const runs: SteerRun[] = []; + let textRun: SteerInputItem[] = []; + const flushTextRun = (): void => { + if (textRun.length > 0) { + runs.push({ kind: 'text', items: textRun }); + textRun = []; + } + }; + for (const m of steerable) { + if (m.mode === 'skill' && m.skillName !== undefined) { + flushTextRun(); + runs.push({ kind: 'skill', skillName: m.skillName, skillArgs: m.skillArgs ?? '' }); + continue; + } + const trimmed = m.text.trim(); + if (trimmed.length > 0) { + // Queued items carry the parts extracted when they were submitted + // (and were already capability-validated then). + textRun.push({ + text: trimmed, + parts: m.parts, + imageAttachmentIds: m.imageAttachmentIds, + videoAttachmentIds: m.videoAttachmentIds, + }); + } + } + let editorExtraction: ReturnType<typeof extractMediaAttachments> | undefined; + if (!editorIsBash && text.length > 0 && !editorHasInlineSkills && firstBundle === -1) { + try { + // Synchronous path: an image still ingesting in the background + // extracts to its inline fallback here (no bounded wait like + // `sendNormalUserInput` — this handler cannot await without + // interleaving queue/draft edits); a video still uploading refuses + // the submission instead (no inline form exists). + editorExtraction = extractMediaAttachments(text, this.imageStore); + } catch (error) { + // Media expansion failed (e.g. the pasted video's upload is still + // in flight) — leave the queue and the editor draft untouched. + host.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } + textRun.push({ + text, + parts: editorExtraction.hasMedia ? editorExtraction.parts : undefined, + imageAttachmentIds: + editorExtraction.imageAttachmentIds.length > 0 + ? editorExtraction.imageAttachmentIds + : undefined, + videoAttachmentIds: + editorExtraction.videoAttachmentIds.length > 0 + ? editorExtraction.videoAttachmentIds + : undefined, + }); + } + flushTextRun(); + + if (runs.length > 0) { + // The editor draft is fresh input: gate it on the model's media + // capabilities before splicing the queue, so a rejection leaves the + // queue and the draft untouched. + if ( + editorExtraction !== undefined && + !host.validateMediaCapabilities(editorExtraction) + ) { + host.releaseStagingMedia([ + ...editorExtraction.imageAttachmentIds, + ...editorExtraction.videoAttachmentIds, + ]); + return; + } + const session = host.session; + if (host.state.appState.model.trim().length === 0 || session === undefined) { + host.releaseStagingMedia([ + ...(editorExtraction?.imageAttachmentIds ?? []), + ...(editorExtraction?.videoAttachmentIds ?? []), + ]); + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + host.state.queuedMessages = queued.filter( + (m, index) => m.mode === 'bash' || (firstBundle !== -1 && index >= firstBundle), + ); + if (!editorIsBash && !editorHasInlineSkills && firstBundle === -1) editor.setText(''); + for (const run of runs) { + if (run.kind === 'text') { + host.steerMessage(session, run.items); + } else { + host.steerSkillActivation(session, run.skillName, run.skillArgs); + } + } + } + host.updateQueueDisplay(); + host.state.ui.requestRender(); + }; + + editor.onCtrlB = (): boolean => { + // Shell command execution is treated as a streaming phase ('shell'), so + // this gate already covers it; only idle + not-compacting falls through. + if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) { + return false; + } + host.track('shortcut_background_task'); + host.detachCurrentForegroundTask(); + return true; + }; + + editor.onUndo = () => { + host.track('undo'); + }; + + editor.onTextPaste = () => { + host.track('shortcut_paste', { kind: 'text' }); + }; + + editor.onUpArrowEmpty = () => { + if (host.btwPanelController.scroll('up')) return true; + if (host.state.appState.streamingPhase === 'idle' && !host.state.appState.isCompacting) return false; + const recalled = host.recallLastQueued(); + if (recalled !== undefined) { + editor.setText(recalled.text); + // Restore the queued item's mode so a recalled `!` command runs as a + // shell command again instead of being submitted as a normal prompt. + // Skill activations recall as prompt mode: their text is the original + // `/name args` slash command, which re-parses on submit. + const mode = recalled.mode === 'bash' ? 'bash' : 'prompt'; + if (editor.inputMode !== mode) { + editor.inputMode = mode; + editor.onInputModeChange?.(mode); + } + host.updateQueueDisplay(); + host.state.ui.requestRender(); + return true; + } + return false; + }; + + editor.onDownArrowEmpty = () => host.btwPanelController.scroll('down'); + + editor.onPasteImage = async () => this.handleClipboardImagePaste(); + } + + clearPendingExit(): void { + if (!this.pendingExit) return; + clearTimeout(this.pendingExit.timer); + this.host.state.footer.setTransientHint(null); + this.pendingExit = null; + } + + dispose(): void { + this.clearPendingExit(); + this.clearPendingUndoEsc(); + } + + private armPendingUndoEsc(): void { + this.clearPendingUndoEsc(); + const timer = setTimeout(() => { + if (this.pendingUndoEsc?.timer === timer) { + this.pendingUndoEsc = null; + } + }, DOUBLE_ESC_WINDOW_MS); + this.pendingUndoEsc = { timer }; + } + + private clearPendingUndoEsc(): void { + if (!this.pendingUndoEsc) return; + clearTimeout(this.pendingUndoEsc.timer); + this.pendingUndoEsc = null; + } + + private armPendingExit(kind: 'ctrl-c' | 'ctrl-d', hint: string): void { + this.clearPendingExit(); + this.host.state.footer.setTransientHint(hint); + + const timer = setTimeout(() => { + if (this.pendingExit?.timer === timer) { + this.clearPendingExit(); + this.host.state.ui.requestRender(); + } + }, EXIT_CONFIRM_WINDOW_MS); + + this.pendingExit = { kind, timer }; + this.host.state.ui.requestRender(); + } + + private clearEditorTextIfPresent(): boolean { + const editor = this.host.state.editor; + if (editor.getText().length === 0) return false; + editor.setText(''); + return true; + } + + private cancelCurrentStream(): void { + // Cancel any running `!` shell command (treated as a streaming phase) in + // addition to the agent turn, so Esc / Ctrl+C interrupts it too. + this.host.cancelRunningShellCommand(); + void this.host.session?.cancel(); + } + + private cancelCurrentCompaction(): void { + const session = this.host.session; + if (session === undefined) return; + void session.cancelCompaction().catch((error: unknown) => { + const message = formatErrorMessage(error); + this.host.showError(`Failed to cancel compaction: ${message}`); + }); + } + + private async handleClipboardImagePaste(): Promise<boolean> { + let media; + try { + media = await readClipboardMedia(); + } catch (error) { + if (error instanceof ClipboardMediaError) { + this.host.showError(error.message); + return true; + } + return false; + } + if (media === null) return false; + + if (media.kind === 'video') { + // Same shape as the image flow below: register the attachment and put + // its placeholder in the editor first, then upload the source file to + // the daemon file store in the background — typing never waits on it, + // and submit gives a pending upload the bounded `pendingMediaIngestions` + // wait. Unlike an image there is no inline fallback form, so a video + // whose upload has not landed (or failed) refuses the submission at + // extraction time. + const attachment = this.imageStore.addVideo(media.mimeType, media.sourcePath, media.filename); + this.host.state.editor.insertTextAtCursor?.(`${attachment.placeholder} `); + this.host.state.ui.requestRender(); + this.host.track('shortcut_paste', { kind: 'video' }); + attachment.pending = this.finishClipboardVideoPaste(attachment, media).catch( + (error: unknown) => { + this.host.showError(`Failed to process pasted video: ${formatErrorMessage(error)}`); + }, + ); + return true; + } + + const meta = parseImageMeta(media.bytes); + if (meta === null) return false; + + // Register the attachment and put its placeholder in the editor before + // any of the asynchronous ingestion work below. CustomEditor only holds + // keystrokes until this handler settles, so the callback returns right + // after the placeholder lands and ingestion continues in the background — + // typing never waits on compression or the daemon upload. Submit gives a + // pending ingestion a bounded wait (`pendingImageIngestions`) and falls + // back to the inline form when it has not finished. + const attachment = this.imageStore.addImage( + media.bytes, + meta.mime, + meta.width, + meta.height, + ); + this.host.state.editor.insertTextAtCursor?.(`${attachment.placeholder} `); + this.host.state.ui.requestRender(); + this.host.track('shortcut_paste', { kind: 'image' }); + + attachment.pending = this.finishClipboardImagePaste( + attachment, + media.bytes, + meta.mime, + meta.width, + meta.height, + ).catch((error: unknown) => { + // The raw attachment and its already-visible placeholder are still a + // valid inline fallback when optional ingestion work fails. + this.host.showError(`Failed to process pasted image: ${formatErrorMessage(error)}`); + }); + return true; + } + + private async finishClipboardImagePaste( + attachment: ImageAttachment, + originalBytes: Uint8Array, + originalMime: string, + originalWidth: number, + originalHeight: number, + ): Promise<void> { + // Compress at ingestion — a pure data step while building the attachment, so + // the stored bytes, the inline thumbnail, the `[image #N (W×H)]` placeholder, + // and the submitted image all agree, and the agent core only ever sees an + // already-compressed image. Best effort: originals pass through on failure. + // When compression changed the bytes, the pre-compression original is kept + // on the attachment in memory: the session whose media-originals dir it + // belongs in may not exist yet at paste time, so dispatch-time caption + // resolution (`resolveOriginalCaptions`) persists it and announces the + // compression, pointing the model at the full-fidelity copy. + // The edge cap comes from the host harness's [image] config (resolved per + // paste so a config reload applies immediately); hosts without a harness + // use the env/built-in default. + const compressed = await compressImageForModel(originalBytes, originalMime, { + maxEdge: this.host.harness?.imageLimits?.maxEdgePx(), + telemetry: { + client: { + track: (event, properties) => + this.host.track(event, properties === undefined ? undefined : { ...properties }), + }, + source: 'tui_paste', + }, + }); + // Dimensions come from the compression result, not parseImageMeta: the + // compressor reports display space (EXIF orientation applied) — the space + // the sent image, the caption, and ReadMediaFile region readback share — + // while parseImageMeta reads the raw pre-rotation header. + const original = compressed.changed + ? { + bytes: originalBytes, + width: compressed.originalWidth, + height: compressed.originalHeight, + byteLength: originalBytes.length, + mime: originalMime, + } + : undefined; + // v2 only: upload the final bytes to the daemon file store so submit-time + // expansion emits a `kimi-file://` reference instead of inline base64. + const uploaded = await this.uploadImageToDaemonFileStore( + compressed.changed ? compressed.data : originalBytes, + compressed.changed ? compressed.mimeType : originalMime, + ); + const completed = this.imageStore.completeImage(attachment, { + bytes: compressed.changed ? compressed.data : originalBytes, + mime: compressed.changed ? compressed.mimeType : originalMime, + width: compressed.width || originalWidth, + height: compressed.height || originalHeight, + original, + fileId: uploaded?.id, + fileExpiresAt: parseExpiry(uploaded), + }); + if (completed === undefined && uploaded !== undefined) { + await this.host.harness?.deleteFile(uploaded.id).catch(() => undefined); + } + this.host.state.ui.requestRender(); + } + + /** + * Paste-time upload of the final image bytes to the engine's daemon file + * store (agent-core-v2 only), run as part of the background ingestion — + * typing never waits on it, and submit only gives it the bounded + * `pendingImageIngestions` wait. Best effort: any failure returns undefined, + * so the attachment keeps no `fileId` and submit-time expansion falls back + * to the inline base64 form. + */ + private async uploadImageToDaemonFileStore( + bytes: Uint8Array, + mime: string, + ): Promise<FileMeta | undefined> { + const harness = this.host.harness; + if (harness === undefined) return undefined; + try { + const meta = await harness.uploadFile(bytes, { + name: `pasted-image.${imageExtensionForMime(mime)}`, + mimeType: mime, + expiresInSec: MEDIA_STAGING_TTL_SECONDS, + }); + return meta; + } catch { + return undefined; + } + } + + /** + * Paste-time upload of the video's source file to the engine's daemon file + * store (agent-core-v2 only), run as background ingestion exactly like the + * image upload above. Best effort: any failure returns undefined, leaving + * the attachment without a `fileId` — submit-time expansion then refuses + * the submission, since a video has no inline fallback form. + */ + private async uploadVideoToDaemonFileStore( + media: ClipboardVideo, + ): Promise<FileMeta | undefined> { + const harness = this.host.harness; + if (harness === undefined) return undefined; + let bytes: Uint8Array; + try { + bytes = await readFile(media.sourcePath); + } catch { + // The source (e.g. a clipboard temp file) vanished before the upload + // could read it — same outcome as a failed upload. + return undefined; + } + try { + return await harness.uploadFile(bytes, { + name: media.filename, + mimeType: media.mimeType, + expiresInSec: MEDIA_STAGING_TTL_SECONDS, + }); + } catch { + return undefined; + } + } + + private async finishClipboardVideoPaste( + attachment: VideoAttachment, + media: ClipboardVideo, + ): Promise<void> { + const uploaded = await this.uploadVideoToDaemonFileStore(media); + const completed = this.imageStore.completeVideo(attachment, { + fileId: uploaded?.id, + fileExpiresAt: parseExpiry(uploaded), + }); + if (completed === undefined && uploaded !== undefined) { + await this.host.harness?.deleteFile(uploaded.id).catch(() => undefined); + } + this.host.state.ui.requestRender(); + } + + private async openExternalEditor(): Promise<void> { + const { state } = this.host; + if (state.externalEditorRunning) return; + const cmd = resolveEditorCommand(state.appState.editorCommand); + if (cmd === undefined) { + this.host.showError('No editor configured. Set $VISUAL / $EDITOR, or run /editor <command>.'); + return; + } + this.host.setExternalEditorRunning(true); + const seed = state.editor.getExpandedText?.() ?? state.editor.getText(); + // Fullscreen: a plain stop() would replay the whole transcript into the + // main screen on exit; the external editor only needs the alternate + // screen released, so preserve the screen instead. + state.ui.stop({ preserveScreen: state.ui.mode === 'fullscreen' ? true : undefined }); + await new Promise<void>((resolve) => { + setImmediate(resolve); + }); + try { + const result = await editInExternalEditor(seed, cmd); + if (result !== undefined) { + state.editor.setText(result.replaceAll('\r\n', '\n').replace(/\n$/, '')); + } + } catch (error) { + const msg = formatErrorMessage(error); + this.host.showError(`External editor failed: ${msg}`); + } finally { + if (typeof process.stdin.pause === 'function') { + process.stdin.pause(); + } + state.ui.start(); + state.ui.setFocus(state.editor); + state.ui.requestRender(true); + // terminal.stop() cleared the OSC 9;4 progress indicator while the + // app-side progressActive flag still reads true; resync so a turn that + // was streaming while the editor was open gets its progress back. + state.terminalState.progressActive = false; + this.host.updateActivityPane(); + this.host.setExternalEditorRunning(false); + } + } +} + +function parseExpiry(meta: FileMeta | undefined): number | undefined { + if (meta?.expires_at === undefined) return undefined; + const value = Date.parse(meta.expires_at); + return Number.isFinite(value) ? value : undefined; +} diff --git a/apps/kimi-code/src/tui/controllers/notify.ts b/apps/kimi-code/src/tui/controllers/notify.ts new file mode 100644 index 0000000000000000000000000000000000000000..698472adbd71d9e8b70bcc31809de1e4e801a9dd --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/notify.ts @@ -0,0 +1,296 @@ +import type { Event, ResumedSessionState } from '@moonshot-ai/kimi-code-sdk'; + +import type { NotifyEntry } from '#/tui/components/chrome/notify-panel'; +import { MAIN_AGENT_ID } from '#/tui/constant/kimi-tui'; +import type { TUIState } from '#/tui/tui-state'; +import { argsRecord } from '#/tui/utils/event-payload'; +import { notifyResultState } from '#/tui/utils/notify-result'; +import { isTerminalBackgroundTask } from '#/tui/utils/message-replay'; + +interface PendingUpdate { + readonly agentId: string; + readonly turnId: number; + readonly step: number; + readonly time: number; + readonly text: string; +} + +/** + * A harness-generated entry marking that work was delegated — shown + * immediately (not result-gated like `NotifyUser` calls), because its whole + * point is to explain the silence while the subagent runs. + */ +function delegationText(name: string, args: Record<string, unknown>): string | undefined { + if (name === 'Agent') { + const description = args['description']; + if (typeof description !== 'string' || description.trim().length === 0) return undefined; + const kind = typeof args['subagent_type'] === 'string' ? args['subagent_type'] : 'subagent'; + return `▸ Delegated to ${kind}: **${description.trim()}**`; + } + const items = args['items']; + const newCount = Array.isArray(items) ? items.length : 0; + const resumeIds = args['resume_agent_ids']; + const resumedCount = + resumeIds !== null && typeof resumeIds === 'object' && !Array.isArray(resumeIds) + ? Object.keys(resumeIds).length + : 0; + const total = newCount + resumedCount; + if (total === 0) return undefined; + return `▸ Delegated to a swarm of ${String(total)} subagents`; +} + +export class NotifyController { + private enabled = false; + private mounted = false; + private mainTurnId: number | undefined; + private readonly running = new Map<string, number | undefined>(); + private readonly steps = new Map<string, number>(); + private readonly pending = new Map<string, PendingUpdate>(); + private readonly settled = new Map<string, string>(); + private readonly endedTurns = new Map<string, number>(); + private readonly agentNames = new Map<string, string>(); + + constructor( + private readonly state: Pick<TUIState, 'notifyPanel' | 'notifyPanelContainer' | 'ui'>, + ) {} + + setEnabled(enabled: boolean): void { + if (this.enabled === enabled) return; + this.reset(); + this.enabled = enabled; + } + + reset(): void { + this.running.clear(); + this.steps.clear(); + this.mainTurnId = undefined; + this.clear(); + this.settled.clear(); + this.endedTurns.clear(); + this.agentNames.clear(); + } + + clear(): void { + for (const [key, update] of this.pending) this.settled.set(key, update.agentId); + this.pending.clear(); + if (!this.enabled && !this.mounted) return; + const { notifyPanel, notifyPanelContainer } = this.state; + if (notifyPanel.isEmpty() && notifyPanelContainer.children.length === 0) return; + notifyPanel.clear(); + notifyPanelContainer.clear(); + this.mounted = false; + this.state.ui.requestRender(); + } + + toggleFocus(): boolean { + if (!this.enabled) return false; + const panel = this.state.notifyPanel; + const changed = panel.isFocused() ? panel.blur() : panel.focus(); + if (!changed) return false; + this.state.ui.requestRender(); + return true; + } + + handlePanelKey(key: 'left' | 'right' | 'up' | 'down' | 'escape'): boolean { + if (!this.enabled || !this.state.notifyPanel.isFocused()) return false; + const panel = this.state.notifyPanel; + if (key === 'escape') panel.blur(); + else if (key === 'left') panel.prevChannel(); + else if (key === 'right') panel.nextChannel(); + else if (key === 'up') panel.prevPage(); + else panel.nextPage(); + this.state.ui.requestRender(); + return true; + } + + handleEvent(event: Event): void { + if (!this.enabled) return; + const agentId = event.agentId; + // oxlint-disable-next-line typescript-eslint/switch-exhaustiveness-check -- Only progress and agent lifecycle events affect this projection. + switch (event.type) { + case 'subagent.spawned': + this.agentNames.set(event.subagentId, event.subagentName); + this.running.set(event.subagentId, undefined); + break; + case 'subagent.started': + this.running.set(event.subagentId, undefined); + break; + case 'subagent.completed': + case 'subagent.failed': + case 'subagent.cancelled': + this.running.delete(event.subagentId); + this.dropPending(event.subagentId); + break; + case 'background.task.started': + case 'background.task.terminated': { + const { info } = event; + if (info.kind !== 'agent' || info.agentId === undefined) return; + if (isTerminalBackgroundTask(info)) { + this.running.delete(info.agentId); + this.dropPending(info.agentId); + } else { + this.running.set(info.agentId, this.running.get(info.agentId)); + } + break; + } + case 'turn.started': + if ( + (this.endedTurns.get(agentId) ?? -1) >= event.turnId || + (this.running.get(agentId) ?? -1) >= event.turnId + ) + return; + if (agentId === MAIN_AGENT_ID && this.mainTurnId !== event.turnId) { + this.clear(); + this.mainTurnId = event.turnId; + } + this.dropPending(agentId); + this.forgetSettled(agentId); + this.running.set(agentId, event.turnId); + this.steps.set(agentId, 0); + break; + case 'turn.ended': + if ( + (this.endedTurns.get(agentId) ?? -1) >= event.turnId || + (this.running.get(agentId) ?? -1) > event.turnId + ) + return; + if ( + agentId === MAIN_AGENT_ID && + this.mainTurnId !== undefined && + this.mainTurnId !== event.turnId + ) + return; + if (this.running.get(agentId) === event.turnId || this.running.get(agentId) === undefined) { + this.running.delete(agentId); + } + this.dropPending(agentId, event.turnId); + this.endedTurns.set(agentId, event.turnId); + this.forgetSettled(agentId); + if (agentId === MAIN_AGENT_ID) this.state.notifyPanel.setEnded(true); + break; + case 'turn.step.started': + this.steps.set(agentId, event.step); + break; + case 'turn.step.interrupted': + case 'turn.step.retrying': + this.dropPending(agentId, event.turnId, event.step); + break; + case 'turn.step.completed': + if (event.finishReason === 'max_tokens') { + this.dropPending(agentId, event.turnId, event.step); + } + break; + case 'tool.call.started': { + if ( + (this.endedTurns.get(agentId) ?? -1) >= event.turnId || + (this.running.get(agentId) ?? -1) > event.turnId + ) + return; + if (agentId === MAIN_AGENT_ID && (event.name === 'Agent' || event.name === 'AgentSwarm')) { + const text = delegationText(event.name, argsRecord(event.args)); + if (text !== undefined) { + this.state.notifyPanel.upsert({ + id: `delegation:${String(event.turnId)}:${event.toolCallId}`, + agentId, + time: Date.now(), + text, + }); + } + break; + } + const key = JSON.stringify([agentId, event.turnId, event.toolCallId]); + if (this.settled.has(key)) return; + if (event.name !== 'NotifyUser') return; + const message = argsRecord(event.args)['message']; + if (typeof message !== 'string' || message.trim().length === 0) return; + this.pending.set(key, { + agentId, + turnId: event.turnId, + step: this.steps.get(agentId) ?? 0, + time: Date.now(), + text: message, + }); + break; + } + case 'tool.result': { + const key = JSON.stringify([agentId, event.turnId, event.toolCallId]); + const update = this.pending.get(key); + if (update === undefined) { + if ( + agentId === MAIN_AGENT_ID && + (event.isError === true || event.synthetic === true) && + this.state.notifyPanel.remove(`delegation:${String(event.turnId)}:${event.toolCallId}`) + ) { + this.render(); + } + return; + } + this.pending.delete(key); + this.settled.set(key, agentId); + if ( + event.isError !== true && + event.synthetic !== true && + notifyResultState(event.output) === 'displayed' + ) { + const entry: NotifyEntry = { + id: key, + agentId, + agentName: this.agentNames.get(agentId), + time: update.time, + text: update.text, + }; + this.state.notifyPanel.upsert(entry); + } + break; + } + default: + return; + } + if (this.running.size === 0) this.state.notifyPanel.setEnded(true); + this.render(); + } + + restore(snapshot: ResumedSessionState | undefined): void { + if (!this.enabled || snapshot === undefined) return; + this.reset(); + for (const agent of Object.values(snapshot.agents)) { + for (const task of agent.background) { + if (task.kind !== 'agent' || task.agentId === undefined) continue; + if (isTerminalBackgroundTask(task)) continue; + this.running.set(task.agentId, undefined); + if (task.subagentType !== undefined) this.agentNames.set(task.agentId, task.subagentType); + } + } + this.render(); + } + + private dropPending(agentId: string, turnId?: number, step?: number): void { + for (const [key, update] of this.pending) { + if ( + update.agentId !== agentId || + (turnId !== undefined && update.turnId !== turnId) || + (step !== undefined && update.step !== step) + ) + continue; + this.pending.delete(key); + this.settled.set(key, update.agentId); + } + } + + private forgetSettled(agentId: string): void { + for (const [key, source] of this.settled) if (source === agentId) this.settled.delete(key); + } + + private render(): void { + const { notifyPanel, notifyPanelContainer, ui } = this.state; + if (notifyPanel.isEmpty()) { + if (notifyPanelContainer.children.length === 0) return; + notifyPanelContainer.clear(); + this.mounted = false; + } else { + if (notifyPanelContainer.children.length === 0) notifyPanelContainer.addChild(notifyPanel); + this.mounted = true; + } + ui.requestRender(); + } +} diff --git a/apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts b/apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts new file mode 100644 index 0000000000000000000000000000000000000000..1983b79807252dd184307aba28be425ffe447030 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts @@ -0,0 +1,208 @@ +import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk'; + +import { kimiCodePluginMarketplaceUrl } from '#/constant/app'; +import { + computeUpdateStatus, + loadPluginMarketplace, + type PluginMarketplace, +} from '#/utils/plugin-marketplace'; +import { + readPluginUpdateNoticeState, + writePluginUpdateNoticeState, +} from '#/utils/plugin-update-notice-state'; +import { isOfficialPluginInstall } from '../utils/plugin-source-label'; + +/** + * The slice of the SDK session the notifier reads. Structurally satisfied by + * the full SDK `Session`, and easy to fake in tests. + */ +export interface PluginUpdateNotifierSession { + listMcpServers(): Promise<readonly { name: string }[]>; + listPlugins(): Promise<readonly PluginSummary[]>; +} + +export interface PluginUpdateNotifierDeps { + readonly getSession: () => PluginUpdateNotifierSession | undefined; + readonly workDir: string; + readonly notify: (message: string) => void; + /** Overridable for tests; defaults to the shared marketplace loader. */ + readonly loadMarketplace?: () => Promise<PluginMarketplace>; + /** Overridable for tests; defaults to the updates dir under the data dir. */ + readonly stateFile?: string; +} + +const MCP_TOOL_NAME_PREFIX = 'mcp__'; +const PLUGIN_MCP_TOOL_NAME_PREFIX = `${MCP_TOOL_NAME_PREFIX}plugin-`; +// Plugin MCP servers run under the runtime name `plugin-<id>:<server>` +// (pluginMcpRuntimeName in packages/agent-core-v2/src/app/plugin/manager.ts). +const PLUGIN_MCP_RUNTIME_NAME = /^plugin-([a-z0-9][a-z0-9_-]{0,63}):/; + +/** Cheap name check for plugin-provided MCP tools (`mcp__plugin-…`). */ +export function isPluginMcpToolName(toolName: string): boolean { + return toolName.startsWith(PLUGIN_MCP_TOOL_NAME_PREFIX); +} + +/** + * Mirror of sanitizeMcpNamePart in packages/agent-core-v2/src/mcpCore/tool-naming.ts. + * MCP tool names on the wire carry the sanitized server name; the collapse + * step guarantees the `__` separator never appears inside a name part. + */ +function sanitizeMcpServerName(name: string): string { + return name.replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_'); +} + +/** + * Find the plugin behind a qualified MCP tool name by longest-prefix match + * against known server names. Prefix matching (rather than splitting on the + * `__` separator) survives core's 64-char truncation, which can cut the + * separator before appending the hash suffix; the boundary check keeps a + * shorter server name from matching another server's name, and longest match + * wins when one server name is a prefix of another. A name truncated inside + * the server part itself cannot be attributed reliably and stays unresolved. + */ +function matchPluginByToolName( + toolName: string, + serverPluginIds: Map<string, string>, +): string | undefined { + let best: string | undefined; + let bestLength = 0; + for (const [serverName, pluginId] of serverPluginIds) { + const prefix = `${MCP_TOOL_NAME_PREFIX}${serverName}`; + if (!toolName.startsWith(prefix)) continue; + const boundary = toolName.charAt(prefix.length); + if (boundary !== '' && boundary !== '_') continue; + if (prefix.length > bestLength) { + best = pluginId; + bestLength = prefix.length; + } + } + return best; +} + +/** + * Shows a one-time "update detected" notice for outdated plugins. Callers + * report completed plugin usage (a plugin MCP tool name, or the plugin id of + * a `/<plugin>:<command>` turn — both reported once the turn's output has + * ended); the notifier checks the marketplace and persists the last notified + * version, so a plugin is re-notified only when the marketplace advertises a + * newer version than the one already shown. + * + * Entry points are fire-and-forget in production (never reject — the notice + * is a background nicety and any failure, e.g. an offline marketplace, is + * swallowed) and return an awaitable promise so tests can settle the queue + * deterministically. + */ +export class PluginUpdateNotifier { + private marketplacePromise: Promise<PluginMarketplace> | undefined; + private mcpServerPluginIds: Map<string, string> | undefined; + private readonly inFlight = new Set<string>(); + private queue: Promise<void> = Promise.resolve(); + + constructor(private readonly deps: PluginUpdateNotifierDeps) {} + + handleMcpToolCompleted(toolName: string): Promise<void> { + // Cheap bail before touching the RPC layer — most tools are not MCP tools, + // let alone plugin ones. + if (!isPluginMcpToolName(toolName)) return Promise.resolve(); + return this.resolvePluginId(toolName) + .then((pluginId) => { + if (pluginId !== undefined) return this.enqueue(pluginId); + return undefined; + }) + .catch(() => {}); + } + + handlePluginCommandCompleted(pluginId: string): Promise<void> { + return this.enqueue(pluginId); + } + + private enqueue(pluginId: string): Promise<void> { + // Serialize the read-modify-write cycle on the notice state file: two + // concurrent checks (e.g. a turn that used two outdated plugins) would + // otherwise read the same snapshot and the last write would drop the + // other plugin's entry. + this.queue = this.queue.then(() => this.checkAndNotify(pluginId)).catch(() => {}); + return this.queue; + } + + private async resolvePluginId(toolName: string): Promise<string | undefined> { + const hit = matchPluginByToolName(toolName, await this.getMcpServerPluginIds()); + if (hit !== undefined) return hit; + // The map is memoized, but this notifier is reused across /reload, /new, + // and session switches, so plugins installed or enabled later in the same + // app run are missing from it. Refresh once on a miss before giving up. + return matchPluginByToolName(toolName, await this.loadMcpServerPluginIds()); + } + + private async getMcpServerPluginIds(): Promise<Map<string, string>> { + if (this.mcpServerPluginIds !== undefined) return this.mcpServerPluginIds; + return this.loadMcpServerPluginIds(); + } + + private async loadMcpServerPluginIds(): Promise<Map<string, string>> { + const map = new Map<string, string>(); + const session = this.deps.getSession(); + // Without a session there is nothing to list; leave the cache unset so + // the next lookup retries instead of pinning an empty map. + if (session === undefined) return map; + const servers = await session.listMcpServers(); + for (const server of servers) { + const match = PLUGIN_MCP_RUNTIME_NAME.exec(server.name); + if (match?.[1] !== undefined) { + map.set(sanitizeMcpServerName(server.name), match[1]); + } + } + this.mcpServerPluginIds = map; + return map; + } + + private async checkAndNotify(pluginId: string): Promise<void> { + if (this.inFlight.has(pluginId)) return; + this.inFlight.add(pluginId); + try { + const session = this.deps.getSession(); + if (session === undefined) return; + const marketplace = await this.loadCatalog(); + // Only the default official catalog can back an "Official Marketplace" + // notice — a custom catalog (KIMI_CODE_PLUGIN_MARKETPLACE_URL) may + // advertise anything under any id. + if (marketplace.source !== kimiCodePluginMarketplaceUrl()) return; + const entry = marketplace.plugins.find((plugin) => plugin.id === pluginId); + if (entry === undefined) return; + const installed = (await session.listPlugins()).find((plugin) => plugin.id === pluginId); + if (installed === undefined) return; + // Only official installs are tracked against the Official Marketplace — + // a local/GitHub fork that happens to share a catalog id is not it. + if (!isOfficialPluginInstall(installed)) return; + const status = computeUpdateStatus(entry.version, installed.version, true); + if (status.kind !== 'update') return; + const state = await readPluginUpdateNoticeState(this.deps.stateFile); + if (state.notified[pluginId] === status.latest) return; + this.deps.notify( + `Update detected: ${installed.displayName} ${status.latest} is available. ` + + 'Run /plugins to install the latest version from the Official Marketplace.', + ); + await writePluginUpdateNoticeState( + { ...state, notified: { ...state.notified, [pluginId]: status.latest } }, + this.deps.stateFile, + ); + } finally { + this.inFlight.delete(pluginId); + } + } + + private loadCatalog(): Promise<PluginMarketplace> { + // Cached for the app run; a failed fetch is retried on the next invocation. + this.marketplacePromise ??= this.loadMarketplace().catch((error: unknown) => { + this.marketplacePromise = undefined; + throw error; + }); + return this.marketplacePromise; + } + + private loadMarketplace(): Promise<PluginMarketplace> { + const load = this.deps.loadMarketplace; + if (load !== undefined) return load(); + return loadPluginMarketplace({ workDir: this.deps.workDir }); + } +} diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..183e24e6021ea0cb27b6fb71b0848646bbdcc499 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -0,0 +1,1301 @@ +import type { Component, Focusable } from '@moonshot-ai/pi-tui'; +import type { + AgentStatusUpdatedEvent, + AssistantDeltaEvent, + BackgroundTaskInfo, + BackgroundTaskStartedEvent, + BackgroundTaskTerminatedEvent, + CompactionCancelledEvent, + CompactionCompletedEvent, + CompactionStartedEvent, + CronFiredEvent, + ErrorEvent, + Event, + GoalChange, + GoalUpdatedEvent, + HookResultEvent, + Session, + SessionMetaUpdatedEvent, + SkillActivatedEvent, + PluginCommandActivatedEvent, + ThinkingDeltaEvent, + ToolCallDeltaEvent, + ToolCallStartedEvent, + ToolProgressEvent, + ToolResultEvent, + TurnEndedEvent, + TurnStartedEvent, + TurnStepCompletedEvent, + TurnStepInterruptedEvent, + TurnStepRetryingEvent, + TurnStepStartedEvent, + TokenUsage, + WarningEvent, +} from '@moonshot-ai/kimi-code-sdk'; + +import { MoonLoader } from '../components/chrome/moon-loader'; +import { buildGoalMarker } from '../components/messages/goal-markers'; +import { StatusMessageComponent } from '../components/messages/status-message'; +import { + SwarmModeMarkerComponent, + type SwarmModeMarkerState, +} from '../components/messages/swarm-markers'; +import { + OAUTH_LOGIN_REQUIRED_CODE, + OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, +} from '../constant/kimi-tui'; +import { buildGoalCompletionMessage } from '../utils/goal-completion'; +import { + argsRecord, + formatErrorPayload, + formatErrorMessage, + isTodoItemShape, + serializeToolResultOutput, + stringValue, +} from '../utils/event-payload'; +import { + readGoalQueue, + removeGoalQueueItem, + restoreGoalQueueItem, + type UpcomingGoal, +} from '../goal-queue-store'; +import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; +import { formatHookResultMarkdown } from '../utils/hook-result-format'; +import { McpOAuthAuthorizationUrlOpener } from '../utils/mcp-oauth'; +import { + formatMcpStartupStatusSummary, + mcpServerStatusKey, + type McpServerStatusSnapshot, + selectMcpStartupStatusRows, +} from '../utils/mcp-server-status'; +import { openUrl } from '#/utils/open-url'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; +import { errorReportHintLine } from '../constant/feedback'; +import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; +import { nextTranscriptId } from '../utils/transcript-id'; +import type { BtwPanelController } from './btw-panel'; +import { isPluginMcpToolName, PluginUpdateNotifier } from './plugin-update-notifier'; +import type { StreamingUIController } from './streaming-ui'; +import type { SurveyController } from './survey-controller'; +import type { TasksBrowserController } from './tasks-browser'; +import { SubAgentEventHandler } from './subagent-event-handler'; +import { NotifyController } from './notify'; +import { + sumTokenUsage, + type AppState, + type LivePaneState, + type QueuedMessage, + type ToolCallBlockData, + type ToolResultBlockData, + type TranscriptEntry, +} from '../types'; +import type { TUIState } from '../tui-state'; +import { createGoal as startGoalCommand } from '../commands/goal'; + +export interface SessionEventHost { + state: TUIState; + session: Session | undefined; + aborted: boolean; + sessionEventUnsubscribe: (() => void) | undefined; + readonly streamingUI: StreamingUIController; + + requireSession(): Session; + setAppState(patch: Partial<AppState>): void; + patchLivePane(patch: Partial<LivePaneState>): void; + resetLivePane(): void; + showError(msg: string): void; + showStatus(msg: string, color?: ColorToken): void; + showNotice(title: string, detail?: string): void; + updateActivityPane(): void; + track(event: string, props?: Record<string, unknown>): void; + recordSessionActivity(): void; + noteStepUsage(usage: TokenUsage | undefined): void; + noteCompactionFinished(): void; + mountEditorReplacement(panel: Component & Focusable): void; + restoreEditor(): void; + restoreInputText(text: string): void; + appendTranscriptEntry(entry: TranscriptEntry): void; + handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void; + handleShellStarted(event: { commandId: string; taskId: string }): void; + sendNormalUserInput(text: string): void; + updateTerminalTitle(): void; + sendQueuedMessage(session: Session, item: QueuedMessage): void; + shiftQueuedMessage(): QueuedMessage | undefined; + handleTurnStarted?(event: TurnStartedEvent): void; + handleTurnEnded?(event: TurnEndedEvent): void; + readonly btwPanelController: BtwPanelController; + readonly tasksBrowserController: TasksBrowserController; + readonly surveyController: SurveyController; +} + +export class SessionEventHandler { + readonly notifications: NotifyController; + readonly subAgentEventHandler: SubAgentEventHandler; + private readonly pluginUpdateNotifier: PluginUpdateNotifier; + + constructor( + private readonly host: SessionEventHost, + pluginUpdateNotifier?: PluginUpdateNotifier, + ) { + this.notifications = new NotifyController(host.state); + this.subAgentEventHandler = new SubAgentEventHandler(host, { + backgroundTasks: this.backgroundTasks, + backgroundTaskTranscriptedTerminal: this.backgroundTaskTranscriptedTerminal, + syncBackgroundAgentBadge: () => { + this.syncBackgroundTaskBadge(); + }, + }); + this.pluginUpdateNotifier = + pluginUpdateNotifier ?? + new PluginUpdateNotifier({ + getSession: () => this.host.session, + workDir: host.state.appState.workDir, + notify: (message) => { + this.host.showStatus(message, 'warning'); + }, + }); + } + + // Runtime state – owned by this handler, reset between sessions. + backgroundTasks: Map<string, BackgroundTaskInfo> = new Map(); + backgroundTaskTranscriptedTerminal: Set<string> = new Set(); + + renderedSkillActivationIds: Set<string> = new Set(); + renderedPluginCommandActivationIds: Set<string> = new Set(); + renderedMcpServerStatusKeys: Map<string, string> = new Map(); + mcpServerStatusSpinners: Map<string, MoonLoader> = new Map(); + mcpServers: Map<string, McpServerStatusSnapshot> = new Map(); + private goalCompletionAwaitingClear = false; + private goalCompletionTurnEnded = false; + private currentTurnHasAssistantText = false; + private pluginCommandTurns: Map<string, string> = new Map(); + private pluginMcpToolsUsedInTurn: Set<string> = new Set(); + private pendingModelBlockedFallback: GoalChange | undefined; + private queuedGoalPromotionPending = false; + private queuedGoalPromotionInFlight = false; + private queuedGoalPromotionTimer: ReturnType<typeof setTimeout> | undefined; + private stepRetryAttemptTimer: ReturnType<typeof setTimeout> | undefined; + + resetRuntimeState(): void { + this.backgroundTasks.clear(); + this.backgroundTaskTranscriptedTerminal.clear(); + this.subAgentEventHandler.resetRuntimeState(); + this.notifications.reset(); + this.renderedSkillActivationIds.clear(); + this.renderedPluginCommandActivationIds.clear(); + this.renderedMcpServerStatusKeys.clear(); + this.mcpServers.clear(); + this.goalCompletionAwaitingClear = false; + this.goalCompletionTurnEnded = false; + this.currentTurnHasAssistantText = false; + this.pluginCommandTurns.clear(); + this.pluginMcpToolsUsedInTurn.clear(); + this.pendingModelBlockedFallback = undefined; + this.queuedGoalPromotionPending = false; + this.queuedGoalPromotionInFlight = false; + this.clearQueuedGoalPromotionTimer(); + this.clearStepRetryAttemptTimer(); + this.stopAllMcpServerStatusSpinners(); + } + + clearAgentSwarmProgress(): void { + this.subAgentEventHandler.clearAgentSwarmProgress(); + } + + hasActiveAgentSwarmToolCall(): boolean { + return this.subAgentEventHandler.hasActiveAgentSwarmToolCall(); + } + + syncAgentSwarmActivitySpinner(spinner: MoonLoader | undefined): void { + this.subAgentEventHandler.syncAgentSwarmActivitySpinner(spinner); + } + + startSubscription(): void { + const { host } = this; + const session = host.requireSession(); + const sendQueued = (item: QueuedMessage): void => { + host.sendQueuedMessage(session, item); + }; + host.sessionEventUnsubscribe?.(); + const mcpOAuthOpener = new McpOAuthAuthorizationUrlOpener(openUrl); + const { sessionId } = host.state.appState; + host.sessionEventUnsubscribe = session.onEvent((event) => { + if (host.aborted) return; + if (event.sessionId !== sessionId) return; + if (event.type === 'tool.progress') { + mcpOAuthOpener.handleToolProgress(event); + } + this.handleEvent(event, sendQueued); + }); + void this.syncMcpServerStatusSnapshot(session); + } + + async syncMcpServerStatusSnapshot(session: Session): Promise<void> { + const { host } = this; + let servers: readonly McpServerStatusSnapshot[]; + try { + servers = await session.listMcpServers(); + } catch (error) { + if (host.session !== session || host.aborted) return; + const message = error instanceof Error ? error.message : String(error); + host.showError(`Failed to sync MCP server status: ${message}`); + return; + } + if (host.session !== session || host.state.appState.sessionId !== session.id) return; + + const visible = selectMcpStartupStatusRows(servers); + const visibleNames = new Set(visible.map((server) => server.name)); + for (const server of visible) { + if (this.renderedMcpServerStatusKeys.has(server.name)) continue; + this.renderMcpServerStatus(server); + } + + this.mcpServers.clear(); + for (const server of servers) { + this.mcpServers.set(server.name, server); + } + const hidden: McpServerStatusSnapshot[] = []; + for (const server of servers) { + if (visibleNames.has(server.name)) continue; + if (this.renderedMcpServerStatusKeys.has(server.name)) continue; + this.renderedMcpServerStatusKeys.set(server.name, mcpServerStatusKey(server)); + hidden.push(server); + } + const summary = formatMcpStartupStatusSummary(servers); + host.setAppState({ mcpServersSummary: summary || null }); + } + + handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void { + this.notifications.handleEvent(event); + if (this.subAgentEventHandler.routeChildAgentEvent(event)) return; + + if ('turnId' in event && event.turnId !== undefined) { + this.host.streamingUI.setTurnId(String(event.turnId)); + } + + switch (event.type) { + case 'turn.started': this.handleTurnBegin(event); break; + case 'turn.ended': this.handleTurnEnd(event, sendQueued); break; + case 'turn.step.started': this.handleStepBegin(event); break; + case 'turn.step.interrupted': this.handleStepInterrupted(event); break; + case 'turn.step.completed': this.handleStepCompleted(event); break; + case 'turn.step.retrying': this.handleStepRetrying(event); break; + case 'tool.progress': this.handleToolProgress(event); break; + case 'shell.output': this.host.handleShellOutput(event); break; + case 'shell.started': this.host.handleShellStarted(event); break; + case 'assistant.delta': this.handleAssistantDelta(event); break; + case 'hook.result': this.handleHookResult(event); break; + case 'thinking.delta': this.handleThinkingDelta(event); break; + case 'tool.call.started': this.handleToolCall(event); break; + case 'tool.call.delta': this.handleToolCallDelta(event); break; + case 'tool.result': this.handleToolResult(event); break; + case 'agent.status.updated': this.handleStatusUpdate(event); break; + case 'session.meta.updated': this.handleSessionMetaChanged(event); break; + case 'goal.updated': this.handleGoalUpdated(event); break; + case 'skill.activated': this.handleSkillActivated(event); break; + case 'plugin_command.activated': this.handlePluginCommandActivated(event); break; + case 'error': this.handleSessionError(event); break; + case 'warning': this.handleSessionWarning(event); break; + case 'compaction.started': this.handleCompactionBegin(event); break; + case 'compaction.completed': this.handleCompactionEnd(event, sendQueued); break; + case 'compaction.blocked': break; + case 'compaction.cancelled': this.handleCompactionCancel(event, sendQueued); break; + case 'subagent.spawned': + case 'subagent.started': + case 'subagent.suspended': + case 'subagent.completed': + case 'subagent.failed': + case 'subagent.cancelled': + this.subAgentEventHandler.handleLifecycleEvent(event); break; + case 'background.task.started': + case 'background.task.terminated': + this.handleBackgroundTaskEvent(event); break; + case 'cron.fired': this.handleCronFired(event); break; + case 'mcp.server.status': this.renderMcpServerStatus(event.server); break; + case 'tool.list.updated': break; + default: break; + } + } + + stopAllMcpServerStatusSpinners(): void { + for (const spinner of this.mcpServerStatusSpinners.values()) { + spinner.stop(); + } + this.mcpServerStatusSpinners.clear(); + } + + // --------------------------------------------------------------------------- + // Private handlers + // --------------------------------------------------------------------------- + + private handleTurnBegin(event: TurnStartedEvent): void { + this.host.handleTurnStarted?.(event); + this.currentTurnHasAssistantText = false; + if (event.origin?.kind === 'plugin_command') { + this.pluginCommandTurns.set(String(event.turnId), event.origin.pluginId); + } + this.clearAgentSwarmProgress(); + this.host.streamingUI.resetToolUi(); + this.host.streamingUI.setStep(0); + this.host.patchLivePane({ + mode: 'waiting', + pendingApproval: null, + pendingQuestion: null, + }); + this.host.setAppState({ + streamingPhase: 'waiting', + streamingStartTime: Date.now(), + }); + } + + private handleCronFired(event: CronFiredEvent): void { + this.host.streamingUI.flushNow(); + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'cron', + turnId: this.host.streamingUI.getTurnContext().turnId, + renderMode: 'plain', + content: event.prompt, + cronData: { + jobId: event.origin.jobId, + cron: event.origin.cron, + recurring: event.origin.recurring, + coalescedCount: event.origin.coalescedCount, + stale: event.origin.stale, + }, + }); + } + + private handleTurnEnd(event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { + this.host.handleTurnEnded?.(event); + this.host.streamingUI.flushNow(); + this.clearStepRetry(); + if (event.reason === 'cancelled') { + this.markActiveAgentSwarmsCancelled(); + } + // Aborted foreground subagents emit no completed/failed lifecycle event + // (v2 suppresses it for aborts), so their activity records would linger + // until the session reset — prune them when the owning turn ends. + this.subAgentEventHandler.dropForegroundOnlyActivityRecords(); + if (event.reason === 'failed' && event.error?.code === 'provider.filtered') { + this.host.showStatus('Turn stopped: provider safety policy blocked the response.', 'error'); + } + if (event.reason === 'blocked') { + this.host.showStatus('Turn stopped: prompt hook blocked the request.', 'error'); + } + const todos = this.host.state.todoPanel.getTodos(); + if (todos.length > 0 && todos.every((t) => t.status === 'done')) { + this.host.streamingUI.setTodoList([]); + } + this.host.streamingUI.resetToolUi(); + this.host.streamingUI.finalizeTurn(sendQueued); + this.host.recordSessionActivity(); + this.renderPendingModelBlockedFallback(); + this.currentTurnHasAssistantText = false; + this.goalCompletionTurnEnded = true; + // Plugin usage is reported once the whole turn's output has ended — but a + // cancelled turn cut the output short, so skip the notice there. + const reportPluginUsage = event.reason !== 'cancelled'; + const pluginCommandPluginId = this.pluginCommandTurns.get(String(event.turnId)); + if (pluginCommandPluginId !== undefined) { + this.pluginCommandTurns.delete(String(event.turnId)); + if (reportPluginUsage) { + void this.pluginUpdateNotifier.handlePluginCommandCompleted(pluginCommandPluginId); + } + } + if (reportPluginUsage) { + for (const toolName of this.pluginMcpToolsUsedInTurn) { + void this.pluginUpdateNotifier.handleMcpToolCompleted(toolName); + } + } + this.pluginMcpToolsUsedInTurn.clear(); + this.scheduleQueuedGoalPromotion(); + } + + private handleStepBegin(event: TurnStepStartedEvent): void { + this.host.streamingUI.flushNow(); + this.host.streamingUI.setStep(event.step); + this.host.streamingUI.resetToolUi(); + this.host.streamingUI.finalizeLiveTextBuffers('waiting'); + this.host.patchLivePane({ + mode: 'waiting', + pendingApproval: null, + pendingQuestion: null, + }); + this.host.setAppState({ + streamingPhase: 'waiting', + streamingStartTime: Date.now(), + }); + } + + private handleStepCompleted(event: TurnStepCompletedEvent): void { + this.host.streamingUI.flushNow(); + this.clearStepRetry(); + this.host.noteStepUsage(event.usage); + this.maybeShowDebugTiming(event); + + if (event.providerFinishReason === 'filtered') { + this.host.showNotice( + 'Provider safety policy blocked the response.', + `The model output was filtered (${event.rawFinishReason ?? 'content_filter'}).`, + ); + return; + } + + if (event.finishReason !== 'max_tokens') return; + + const truncatedCount = this.host.streamingUI.markStepTruncated( + String(event.turnId), + event.step, + ); + + const title = + truncatedCount > 0 + ? 'Model hit max_tokens — tool call was truncated before it could run.' + : 'Model hit max_tokens — no tool call was emitted.'; + const detail = this.isAnthropicSessionActive() + ? 'If this limit is wrong for your model, set `max_output_size` on the model alias in your kimi-code config.' + : undefined; + this.host.showNotice(title, detail); + } + + private handleStepRetrying(event: TurnStepRetryingEvent): void { + // The failure may arrive mid-stream, after thinking/assistant deltas have + // parked the pane in `thinking`/`composing` — drive it back to waiting so + // the retry label and detail actually render during the backoff. + this.host.patchLivePane({ mode: 'waiting' }); + this.host.setAppState({ + streamingPhase: 'waiting', + stepRetry: { + nextAttempt: event.nextAttempt, + maxAttempts: event.maxAttempts, + delayMs: event.delayMs, + errorName: event.errorName, + errorMessage: event.errorMessage, + statusCode: event.statusCode, + phase: 'backoff', + }, + }); + // Both engines sleep for `delayMs` before the next attempt runs, but only + // v2 re-emits `turn.step.started` for it — flip the phase on a timer so the + // stale countdown drops on the legacy engine too. + this.clearStepRetryAttemptTimer(); + this.stepRetryAttemptTimer = setTimeout(() => { + this.stepRetryAttemptTimer = undefined; + const retry = this.host.state.appState.stepRetry; + if (retry === null) return; + this.host.setAppState({ stepRetry: { ...retry, phase: 'attempt' } }); + }, event.delayMs); + } + + private clearStepRetry(): void { + this.clearStepRetryAttemptTimer(); + if (this.host.state.appState.stepRetry === null) return; + this.host.setAppState({ stepRetry: null }); + } + + clearStepRetryAttemptTimer(): void { + if (this.stepRetryAttemptTimer !== undefined) { + clearTimeout(this.stepRetryAttemptTimer); + this.stepRetryAttemptTimer = undefined; + } + } + + private maybeShowDebugTiming(event: TurnStepCompletedEvent): void { + if (process.env['KIMI_CODE_DEBUG'] !== '1') return; + const text = formatStepDebugTiming(event); + if (text === undefined) return; + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'status', + turnId: String(event.turnId), + renderMode: 'plain', + content: text, + }); + } + + private markActiveAgentSwarmsCancelled(): void { + this.subAgentEventHandler.markActiveAgentSwarmsCancelled(); + } + + private isAnthropicSessionActive(): boolean { + const { state } = this.host; + const model = state.appState.availableModels[state.appState.model]; + if (model === undefined) return false; + if (model.protocol === 'anthropic') return true; + return state.appState.availableProviders[model.provider]?.type === 'anthropic'; + } + + private handleStepInterrupted(event: TurnStepInterruptedEvent): void { + this.host.streamingUI.flushNow(); + this.clearStepRetry(); + this.host.streamingUI.resetToolUi(); + this.host.streamingUI.finalizeLiveTextBuffers('idle'); + const reason = event.reason; + if (reason === 'error') return; + if (reason === 'aborted' || reason === undefined || reason === '') { + this.markActiveAgentSwarmsCancelled(); + if (event.message === undefined || event.message === '') { + this.host.showStatus('Interrupted by user', 'error'); + } else { + this.host.showError(event.message); + } + return; + } + this.host.showError( + reason === 'max_steps' + ? 'reached per-turn step limit (max_steps)' + : `step interrupted (${reason})`, + ); + } + + private handleThinkingDelta(event: ThinkingDeltaEvent): void { + const { state, streamingUI } = this.host; + // Encrypted / redacted reasoning (e.g. Kimi over the Anthropic-compatible + // protocol) streams thinking deltas whose visible text is empty — only an + // opaque signature rides along. Models also occasionally stream whitespace- + // only thinking (e.g. a single space). Such deltas carry nothing to render, + // so switching into the `thinking` pane mode here would stop the "waiting" + // moon spinner while no ThinkingComponent is ever created (it needs visible + // text), leaving a blank, spinner-less gap until the first real text/tool + // token arrives. Keep the moon up until actual thinking text shows up. + if (event.delta.trim().length === 0 && !streamingUI.hasThinkingDraft()) return; + streamingUI.appendThinkingDelta(event.delta); + this.host.patchLivePane({ mode: 'idle' }); + if (state.appState.streamingPhase !== 'thinking') { + this.host.setAppState({ streamingPhase: 'thinking', streamingStartTime: Date.now() }); + } + streamingUI.scheduleFlush(); + } + + private handleAssistantDelta(event: AssistantDeltaEvent): void { + const { state, streamingUI } = this.host; + if (streamingUI.hasThinkingDraft()) { + streamingUI.flushThinkingToTranscript('idle'); + } + + if (event.delta.trim().length > 0) { + this.currentTurnHasAssistantText = true; + this.pendingModelBlockedFallback = undefined; + } + streamingUI.appendAssistantDelta(event.delta); + + this.host.patchLivePane({ + mode: 'idle', + pendingApproval: null, + pendingQuestion: null, + }); + if (state.appState.streamingPhase !== 'composing') { + this.host.setAppState({ streamingPhase: 'composing', streamingStartTime: Date.now() }); + } + streamingUI.scheduleFlush(); + } + + private handleHookResult(event: HookResultEvent): void { + this.host.streamingUI.flushNow(); + if (this.host.streamingUI.hasThinkingDraft()) { + this.host.streamingUI.flushThinkingToTranscript('idle'); + } + this.host.streamingUI.finalizeAssistantStream(); + if (event.content.trim().length > 0) { + this.currentTurnHasAssistantText = true; + this.pendingModelBlockedFallback = undefined; + } + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'assistant', + turnId: String(event.turnId), + renderMode: 'markdown', + content: formatHookResultMarkdown(event), + hookResult: true, + }); + this.host.patchLivePane({ + mode: 'idle', + pendingApproval: null, + pendingQuestion: null, + }); + } + + private handleToolCall(event: ToolCallStartedEvent): void { + const { streamingUI } = this.host; + this.host.surveyController.notifyToolCallStarted(); + streamingUI.flushNow(); + const { turnId, step } = streamingUI.getTurnContext(); + const toolCall: ToolCallBlockData = { + id: event.toolCallId, + name: event.name, + args: argsRecord(event.args), + description: event.description, + display: event.display, + step, + turnId, + }; + streamingUI.registerToolCall(toolCall); + if (event.name === 'AgentSwarm') { + this.subAgentEventHandler.handleAgentSwarmToolCallStarted(event.toolCallId, toolCall.args); + } + this.host.patchLivePane({ + mode: 'tool', + pendingApproval: null, + pendingQuestion: null, + }); + } + + private handleToolCallDelta(event: ToolCallDeltaEvent): void { + if (event.toolCallId.length === 0) return; + const { state, streamingUI } = this.host; + streamingUI.accumulateToolCallDelta(event.toolCallId, event.name, event.argumentsPart); + const preview = streamingUI.getStreamingToolCallPreview(event.toolCallId); + if ( + preview !== undefined && + (preview.name === 'AgentSwarm' || this.subAgentEventHandler.hasAgentSwarmProgress(event.toolCallId)) + ) { + this.subAgentEventHandler.handleAgentSwarmToolCallDelta(event.toolCallId, preview.args, { + streamingArguments: preview.argumentsText, + }); + } + + this.host.patchLivePane({ + mode: 'tool', + pendingApproval: null, + pendingQuestion: null, + }); + if (state.appState.streamingPhase !== 'composing') { + this.host.setAppState({ streamingPhase: 'composing', streamingStartTime: Date.now() }); + } + streamingUI.scheduleFlush(); + } + + private handleToolProgress(event: ToolProgressEvent): void { + const text = event.update.text; + if (text === undefined || text.length === 0) return; + const tc = this.host.streamingUI.getToolComponent(event.toolCallId); + if (tc === undefined) return; + if (event.update.kind === 'status') { + tc.appendProgress(text, { replace: event.update.replace === true }); + return; + } + if (event.update.kind === 'stdout' || event.update.kind === 'stderr') { + tc.appendLiveOutput(text); + } + } + + private handleToolResult(event: ToolResultEvent): void { + const { streamingUI } = this.host; + streamingUI.flushNow(); + this.clearStepRetry(); + const resultData: ToolResultBlockData = { + tool_call_id: event.toolCallId, + output: serializeToolResultOutput(event.output), + is_error: event.isError, + synthetic: event.synthetic, + }; + const matchedCall = streamingUI.completeToolResult(event.toolCallId, resultData); + if (matchedCall !== undefined && isPluginMcpToolName(matchedCall.name)) { + // Buffer plugin MCP usage for the turn; the update notice fires once the + // whole turn's output has ended (see handleTurnEnd). + this.pluginMcpToolsUsedInTurn.add(matchedCall.name); + } + this.subAgentEventHandler.handleAgentSwarmToolResult( + event.toolCallId, + resultData, + event.isError === true, + ); + if (matchedCall !== undefined && matchedCall.name === 'TodoList' && !event.isError) { + const rawTodos = (matchedCall.args as { todos?: unknown }).todos; + if (Array.isArray(rawTodos)) { + const sanitized = rawTodos + .filter((todo): todo is { title: string; status: 'pending' | 'in_progress' | 'done' } => + isTodoItemShape(todo), + ) + .map((t) => ({ title: t.title, status: t.status })); + streamingUI.setTodoList(sanitized); + } + } + this.host.patchLivePane({ mode: 'waiting' }); + } + + private handleStatusUpdate(event: AgentStatusUpdatedEvent): void { + const shouldRenderSwarmEnded = + event.swarmMode === false && + this.host.state.appState.swarmMode && + this.host.state.swarmModeEntry === 'task'; + const patch: Partial<AppState> = {}; + if (event.contextTokens !== undefined) patch.contextTokens = event.contextTokens; + if (event.maxContextTokens !== undefined) patch.maxContextTokens = event.maxContextTokens; + if (event.contextUsage !== undefined) { + patch.contextUsage = event.contextUsage; + } else if (event.contextTokens !== undefined || event.maxContextTokens !== undefined) { + // v2 status events carry contextTokens/maxContextTokens but never + // contextUsage. Recompute the ratio from the post-patch token counts so + // it cannot go stale and drift from them — the footer and the /usage + // panel bar render this ratio while their texts recompute from the + // counts, so a stale ratio shows as a bar/percentage mismatch. + const tokens = patch.contextTokens ?? this.host.state.appState.contextTokens; + const max = patch.maxContextTokens ?? this.host.state.appState.maxContextTokens; + patch.contextUsage = max > 0 ? tokens / max : 0; + } + if (event.planMode !== undefined) patch.planMode = event.planMode; + if (event.swarmMode !== undefined) patch.swarmMode = event.swarmMode; + if (event.towerMode !== undefined) patch.towerMode = event.towerMode; + if (event.permission !== undefined) { + patch.permissionMode = event.permission; + } + if (event.model !== undefined) patch.model = event.model; + if (event.thinkingEffort !== undefined) patch.thinkingEffort = event.thinkingEffort; + if (event.usage?.total !== undefined) { + patch.cumulativeTokens = sumTokenUsage(event.usage.total); + } + if (Object.keys(patch).length > 0) this.host.setAppState(patch); + if (event.swarmMode === false) { + this.host.state.swarmModeEntry = undefined; + if (shouldRenderSwarmEnded) { + this.renderSwarmModeMarker('ended'); + } + } + } + + private renderSwarmModeMarker(state: SwarmModeMarkerState): void { + this.host.state.transcriptContainer.addChild( + new SwarmModeMarkerComponent(state), + ); + this.host.state.ui.requestRender(); + } + + private handleGoalUpdated(event: GoalUpdatedEvent): void { + this.host.setAppState({ goal: event.snapshot }); + if (event.snapshot === null && this.goalCompletionAwaitingClear) { + this.goalCompletionAwaitingClear = false; + this.queuedGoalPromotionPending = true; + this.scheduleQueuedGoalPromotion(); + } + if (event.snapshot === null) { + this.pendingModelBlockedFallback = undefined; + } + const change = event.change; + if (change === undefined) return; + const { state } = this.host; + + // Completion -> the box disappears (snapshot cleared on the follow-up null + // update) and a deterministic completion message lands in the transcript. + // Resume renders the same text from the durable goal completion replay + // record, so live and replayed completion cards stay identical. + if (change.kind === 'completion' && event.snapshot !== null) { + this.pendingModelBlockedFallback = undefined; + this.goalCompletionAwaitingClear = true; + this.goalCompletionTurnEnded = false; + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'assistant', + renderMode: 'markdown', + content: buildGoalCompletionMessage(event.snapshot), + }); + state.ui.requestRender(); + return; + } + + // Lifecycle change (pause / resume / blocked) -> a low-profile, + // ctrl+o-expandable marker. + if (change.kind === 'lifecycle' && change.status === 'blocked') { + void this.notifyQueuedGoalWaitingOnBlocked(); + if (change.actor === 'model' || change.reason === undefined) { + this.pendingModelBlockedFallback = this.currentTurnHasAssistantText + ? undefined + : change; + return; + } + this.pendingModelBlockedFallback = undefined; + } else if (change.kind === 'lifecycle') { + this.pendingModelBlockedFallback = undefined; + } + const marker = buildGoalMarker(change, state.toolOutputExpanded, change.actor); + if (marker !== null) { + state.transcriptContainer.addChild(marker); + state.ui.requestRender(); + } + } + + private renderPendingModelBlockedFallback(): void { + const change = this.pendingModelBlockedFallback; + if (change === undefined) return; + this.pendingModelBlockedFallback = undefined; + const { state } = this.host; + const marker = buildGoalMarker(change, state.toolOutputExpanded, 'model'); + if (marker !== null) { + state.transcriptContainer.addChild(marker); + state.ui.requestRender(); + } + } + + private scheduleQueuedGoalPromotion(): void { + if (!this.queuedGoalPromotionPending || !this.goalCompletionTurnEnded) return; + if (this.queuedGoalPromotionInFlight) return; + if (this.queuedGoalPromotionTimer !== undefined) return; + this.queuedGoalPromotionTimer = setTimeout(() => { + this.queuedGoalPromotionTimer = undefined; + if (!this.queuedGoalPromotionPending || !this.goalCompletionTurnEnded) return; + if (this.queuedGoalPromotionInFlight) return; + if (!this.isReadyForQueuedGoalPromotion()) { + return; + } + this.queuedGoalPromotionInFlight = true; + void this.promoteNextQueuedGoal() + .then((complete) => { + if (complete) { + this.queuedGoalPromotionPending = false; + this.goalCompletionTurnEnded = false; + return; + } + this.goalCompletionTurnEnded = false; + }) + .finally(() => { + this.queuedGoalPromotionInFlight = false; + this.scheduleQueuedGoalPromotion(); + }); + }, 0); + } + + private clearQueuedGoalPromotionTimer(): void { + if (this.queuedGoalPromotionTimer === undefined) return; + clearTimeout(this.queuedGoalPromotionTimer); + this.queuedGoalPromotionTimer = undefined; + } + + requestQueuedGoalPromotion(): void { + this.queuedGoalPromotionPending = true; + this.goalCompletionTurnEnded = true; + this.scheduleQueuedGoalPromotion(); + } + + retryQueuedGoalPromotion(): void { + this.scheduleQueuedGoalPromotion(); + } + + private isReadyForQueuedGoalPromotion(session?: Session): boolean { + return ( + (session === undefined || this.host.session === session) && + !this.host.aborted && + this.host.state.appState.streamingPhase === 'idle' && + this.host.state.queuedMessages.length === 0 && + !this.host.state.queuedMessageDispatchPending + ); + } + + private async promoteNextQueuedGoal(): Promise<boolean> { + const { host } = this; + const session = host.session; + if (session === undefined || host.aborted) return true; + + let queue; + try { + queue = await readGoalQueue(session); + } catch (error) { + host.showError(`Failed to read upcoming goals: ${formatErrorMessage(error)}`); + return false; + } + if (host.session !== session || host.aborted) return true; + + const next = queue.goals[0]; + if (next === undefined) return true; + + if (!this.isReadyForQueuedGoalPromotion(session)) return false; + + const started = await startGoalCommand( + host, + { kind: 'create', objective: next.objective, replace: false }, + next.objective, + { + beforeSend: async () => { + if (!this.isReadyForQueuedGoalPromotion(session)) { + await this.cancelStartedQueuedGoal(session); + return false; + } + try { + await removeGoalQueueItem(session, { goalId: next.id }); + } catch (error) { + host.showError( + `Queued goal started, but could not be removed from the queue: ${formatErrorMessage(error)}`, + ); + await this.cancelStartedQueuedGoal(session); + return false; + } + if (this.isReadyForQueuedGoalPromotion(session)) { + return true; + } + await this.restoreAndCancelStartedQueuedGoal(session, next); + return false; + }, + sendInput: (objective) => { + host.sendQueuedMessage(session, { text: objective }); + }, + }, + ); + return started || host.session !== session || host.aborted; + } + + private async restoreAndCancelStartedQueuedGoal( + session: Session, + goal: UpcomingGoal, + ): Promise<void> { + try { + await restoreGoalQueueItem(session, goal); + } catch (error) { + this.host.showError(`Queued goal could not be restored: ${formatErrorMessage(error)}`); + } + await this.cancelStartedQueuedGoal(session); + } + + private async cancelStartedQueuedGoal(session: Session): Promise<void> { + try { + await session.cancelGoal(); + } catch (error) { + this.host.showError(`Queued goal could not be cancelled: ${formatErrorMessage(error)}`); + } + } + + private async notifyQueuedGoalWaitingOnBlocked(): Promise<void> { + const { host } = this; + const session = host.session; + if (session === undefined || host.aborted) return; + + let hasQueuedGoal = false; + try { + const queue = await readGoalQueue(session); + hasQueuedGoal = queue.goals.length > 0; + } catch { + return; + } + if (!hasQueuedGoal || host.session !== session || host.aborted) return; + + host.showNotice( + 'Goal blocked.', + 'The next queued goal will start only after this goal is complete.', + ); + } + + private handleSessionMetaChanged(event: SessionMetaUpdatedEvent): void { + const title = event.title ?? stringValue(event.patch?.['title']); + if (title !== undefined) { + this.host.setAppState({ sessionTitle: title }); + this.host.updateTerminalTitle(); + } + } + + private handleSessionError(event: ErrorEvent): void { + this.host.streamingUI.flushNow(); + this.host.streamingUI.resetToolUi(); + this.host.streamingUI.finalizeLiveTextBuffers('idle'); + if (event.code === OAUTH_LOGIN_REQUIRED_CODE) { + this.host.showError(OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE); + return; + } + this.host.showError(formatErrorPayload(event)); + const sessionId = this.host.state.appState.sessionId; + if (sessionId.length > 0) { + this.host.showStatus(errorReportHintLine()); + } + } + + private handleSessionWarning(event: WarningEvent): void { + this.host.showStatus(`Warning: ${event.message}`, 'warning'); + } + + private renderMcpServerStatus(server: McpServerStatusSnapshot): void { + const key = mcpServerStatusKey(server); + if (this.renderedMcpServerStatusKeys.get(server.name) === key) return; + this.renderedMcpServerStatusKeys.set(server.name, key); + this.mcpServers.set(server.name, server); + const summary = formatMcpStartupStatusSummary([...this.mcpServers.values()]); + this.host.setAppState({ mcpServersSummary: summary || null }); + + switch (server.status) { + case 'connected': { + const toolStr = `${server.toolCount} tool${server.toolCount === 1 ? '' : 's'}`; + const message = `MCP server "${server.name}" connected · ${toolStr} (${server.transport})`; + this.finalizeMcpServerStatusRow(server.name, message, 'success'); + return; + } + case 'failed': { + const message = `MCP server "${server.name}" failed${server.error !== undefined ? `: ${server.error}` : ''}`; + this.finalizeMcpServerStatusRow(server.name, message, 'error'); + return; + } + case 'needs-auth': { + const message = `MCP server "${server.name}" needs OAuth — run /mcp-config login ${server.name}`; + this.finalizeMcpServerStatusRow(server.name, message, 'warning'); + return; + } + case 'disabled': + this.finalizeMcpServerStatusRow( + server.name, + `MCP server "${server.name}" disabled`, + 'textMuted', + ); + return; + case 'removed': + this.finalizeMcpServerStatusRow( + server.name, + `MCP server "${server.name}" removed`, + 'textMuted', + ); + return; + case 'pending': + this.showMcpServerStatusSpinner(server.name); + return; + } + } + + private showMcpServerStatusSpinner(name: string): void { + const { state } = this.host; + const label = `MCP server "${name}" connecting…`; + const existing = this.mcpServerStatusSpinners.get(name); + if (existing !== undefined) { + existing.setLabel(label); + return; + } + const tint = (s: string): string => currentTheme.fg('textMuted', s); + const spinner = new MoonLoader(state.ui, 'braille', tint, label); + state.transcriptContainer.addChild(spinner); + this.mcpServerStatusSpinners.set(name, spinner); + state.ui.requestRender(); + } + + private finalizeMcpServerStatusRow(name: string, message: string, color: ColorToken): void { + const { state } = this.host; + const spinner = this.mcpServerStatusSpinners.get(name); + if (spinner === undefined) { + this.host.showStatus(message, color); + return; + } + spinner.stop(); + const status = new StatusMessageComponent(message, color); + const children = state.transcriptContainer.children; + const idx = children.indexOf(spinner); + if (idx >= 0) { + // In-place replacement is picked up by the container's ref-checked + // render cache; a tree-wide invalidate is unnecessary (and costly). + children[idx] = status; + } else { + state.transcriptContainer.addChild(status); + } + this.mcpServerStatusSpinners.delete(name); + state.ui.requestRender(); + } + + private handleSkillActivated(event: SkillActivatedEvent): void { + if (this.renderedSkillActivationIds.has(event.activationId)) return; + this.renderedSkillActivationIds.add(event.activationId); + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'skill_activation', + turnId: undefined, + renderMode: 'plain', + content: `Activated skill: ${event.skillName}`, + skillActivationId: event.activationId, + skillName: event.skillName, + skillArgs: event.skillArgs, + skillTrigger: event.trigger, + }); + } + + private handlePluginCommandActivated(event: PluginCommandActivatedEvent): void { + if (this.renderedPluginCommandActivationIds.has(event.activationId)) return; + this.renderedPluginCommandActivationIds.add(event.activationId); + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'plugin_command', + turnId: undefined, + renderMode: 'plain', + content: `/${event.pluginId}:${event.commandName}`, + pluginCommandData: { + activationId: event.activationId, + pluginId: event.pluginId, + commandName: event.commandName, + args: event.commandArgs, + trigger: event.trigger, + }, + }); + } + + private handleCompactionBegin(event: CompactionStartedEvent): void { + this.host.streamingUI.finalizeLiveTextBuffers('waiting'); + this.host.setAppState({ + isCompacting: true, + streamingPhase: 'waiting', + streamingStartTime: Date.now(), + }); + this.host.streamingUI.beginCompaction(event.instruction); + } + + private handleCompactionEnd( + event: CompactionCompletedEvent, + sendQueued: (item: QueuedMessage) => void, + ): void { + this.host.streamingUI.endCompaction( + event.result.tokensBefore, + event.result.tokensAfter, + event.result.summary, + ); + // A completed compaction just refreshed and shrank the cached context — + // count it as activity so the next submit isn't judged against the + // pre-compaction timestamp, and reset the cache-break baseline (the drop + // is expected). Cancellations do neither: the context was not cut. + this.host.recordSessionActivity(); + this.host.noteCompactionFinished(); + this.host.surveyController.notifyCompactionFinished(); + this.finishCompaction(sendQueued); + } + + private handleCompactionCancel( + _event: CompactionCancelledEvent, + sendQueued: (item: QueuedMessage) => void, + ): void { + this.host.streamingUI.cancelCompaction(); + this.finishCompaction(sendQueued); + } + + private finishCompaction(sendQueued: (item: QueuedMessage) => void): void { + const hasActiveTurn = this.host.streamingUI.hasActiveTurn(); + if (!hasActiveTurn) { + const next = this.host.shiftQueuedMessage(); + if (next !== undefined) { + this.host.state.queuedMessageDispatchPending = true; + } + this.host.setAppState({ + isCompacting: false, + streamingPhase: 'idle', + }); + this.host.resetLivePane(); + if (next !== undefined) { + setTimeout(() => { + this.host.state.queuedMessageDispatchPending = false; + sendQueued(next); + }, 0); + } + } else { + this.host.setAppState({ isCompacting: false }); + } + } + + // --------------------------------------------------------------------------- + // Background task lifecycle + // --------------------------------------------------------------------------- + + private handleBackgroundTaskEvent( + event: BackgroundTaskStartedEvent | BackgroundTaskTerminatedEvent, + ): void { + const { state } = this.host; + const { info } = event; + const previous = this.backgroundTasks.get(info.taskId); + this.backgroundTasks.set(info.taskId, info); + + const viewer = state.tasksBrowser?.viewer; + if (viewer !== undefined && viewer.taskId === info.taskId) { + void this.host.tasksBrowserController.refreshOutputViewer({ silent: true }); + } + + const isTerminal = + info.status === 'completed' || + info.status === 'failed' || + info.status === 'timed_out' || + info.status === 'killed' || + info.status === 'lost'; + + if (event.type === 'background.task.started') { + if (info.kind === 'agent') { + // A foreground subagent detached via Ctrl+B: flip its card to + // `◐ backgrounded` so it doesn't look like it completed. + this.host.streamingUI.markSubagentBackgrounded(info.agentId); + this.syncBackgroundTaskBadge(); + this.host.tasksBrowserController.repaint(); + return; + } + this.appendBackgroundTaskEntry(info); + this.syncBackgroundTaskBadge(); + this.host.tasksBrowserController.repaint(); + return; + } + + if (event.type === 'background.task.terminated' && isTerminal) { + if (info.kind === 'agent') { + // The Agent tool's spawn-success ToolResult is not an error, so the + // parent toolCall card would otherwise render `✓ Completed` for any + // terminated bg agent — including `lost` / `failed` / `killed`. + // Push the actual terminal status so the card matches reality. + this.host.streamingUI.applyBackgroundTaskTerminalStatus({ + agentId: info.agentId, + description: info.description, + status: info.status, + }); + // Stopped / timed-out agents terminate without a `subagent.failed` + // event — mark the activity record here so the detail view does not + // stay "running" forever. `subagent.completed` carries the result + // summary and may land after this, so only fill still-running records. + const agentId = info.agentId; + if (agentId !== undefined) { + const record = this.subAgentEventHandler.activityStore.get(agentId); + if (record !== undefined && record.status === 'running') { + if (info.status === 'completed') { + this.subAgentEventHandler.activityStore.markCompleted(agentId); + } else { + this.subAgentEventHandler.activityStore.markFailed(agentId); + } + } + } + } + if (!this.backgroundTaskTranscriptedTerminal.has(info.taskId)) { + if (info.kind === 'process' || info.kind === 'question') { + this.appendBackgroundTaskEntry(info); + } + this.backgroundTaskTranscriptedTerminal.add(info.taskId); + } + this.syncBackgroundTaskBadge(); + this.host.tasksBrowserController.repaint(); + return; + } + + if (previous?.status !== info.status) { + this.syncBackgroundTaskBadge(); + } + this.host.tasksBrowserController.repaint(); + } + + private appendBackgroundTaskEntry(info: BackgroundTaskInfo): void { + const status = formatBackgroundTaskTranscript(info); + const entry: TranscriptEntry = { + id: nextTranscriptId(), + kind: 'status', + turnId: this.host.streamingUI.getTurnContext().turnId, + renderMode: 'plain', + content: status.headline, + detail: status.detail, + backgroundAgentStatus: status, + }; + this.host.appendTranscriptEntry(entry); + } + + private syncBackgroundTaskBadge(): void { + const { state } = this.host; + let bashTasks = 0; + let agentTasks = 0; + for (const info of this.backgroundTasks.values()) { + if ( + info.status === 'completed' || + info.status === 'failed' || + info.status === 'timed_out' || + info.status === 'killed' || + info.status === 'lost' + ) { + continue; + } + if (info.kind === 'agent') { + agentTasks += 1; + } else { + bashTasks += 1; + } + } + state.footer.setBackgroundCounts({ bashTasks, agentTasks }); + state.ui.requestRender(); + } +} diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c8baa96e01b5e46a4b2dc557ca5f33cd70b30c9 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -0,0 +1,899 @@ +import type { + AgentReplayRecord, + ContextMessage, + GoalChange, + PermissionMode, + ResumedAgentState, + Session, + ToolCall, +} from '@moonshot-ai/kimi-code-sdk'; + +import { ToolCallComponent } from '../components/messages/tool-call'; +import { ShellRunComponent } from '../components/messages/shell-run'; +import { ReplayTurnBoundaryComponent } from '../components/messages/user-message'; +import { currentTheme } from '../theme'; +import type { TodoItem } from '../components/chrome/todo-panel'; +import type { + AppState, + BackgroundAgentMetadata, + ToolResultBlockData, + TranscriptEntry, +} from '../types'; +import { formatErrorMessage, isTodoItemShape } from '../utils/event-payload'; +import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; +import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; +import { modelDisplayName } from '../components/dialogs/model-selector'; +import { buildGoalCompletionMessage } from '../utils/goal-completion'; +import { PERMISSION_MODE_DISPLAY_NAMES } from '../utils/permission-mode'; +import { formatBashOutputForDisplay } from '../utils/shell-output'; +import { markTranscriptComponent } from '../utils/transcript-component-metadata'; +import { + appStateFromResumeAgent, + backgroundOrigin, + bundledSkillsFromOrigin, + collectReplayMessageContent, + contentPartsToText, + countActiveBackgroundTasks, + createReplayRenderContext, + formatHookResultMessageForTranscript, + isTerminalBackgroundTask, + limitReplayRecordsByTurn, + REPLAY_TURN_LIMIT, + replayBackgroundProjection, + replayEntry, + skillActivationFromOrigin, + stripBundledSkillParts, + pluginCommandFromOrigin, + toolCallFromReplayMessage, + toolResultOutput, + type BackgroundTaskNotificationOrigin, + type ReplayRenderContext, + type SkillActivationProjection, + type PluginCommandProjection, +} from '../utils/message-replay'; +import type { StreamingUIController } from './streaming-ui'; +import type { SessionEventHandler } from './session-event-handler'; +import type { TUIState } from '../tui-state'; + +type GoalReplayRecord = Extract<AgentReplayRecord, { type: 'goal_updated' }>; +type CompactionReplayRecord = Extract<AgentReplayRecord, { type: 'compaction' }>; +type GoalReplayLifecycleChange = GoalChange & { readonly kind: 'lifecycle' }; + +export interface SessionReplayHost { + state: TUIState; + readonly streamingUI: StreamingUIController; + readonly sessionEventHandler: SessionEventHandler; + setAppState(patch: Partial<AppState>): void; + showError(msg: string): void; + appendTranscriptEntry(entry: TranscriptEntry): void; + mergeAllTurnSteps(): void; +} + +function extractBashTag( + text: string, + tag: 'bash-input' | 'bash-stdout' | 'bash-stderr', +): string | undefined { + const match = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(text); + return match?.[1] === undefined ? undefined : unescapeBashXml(match[1]); +} + +function unescapeBashXml(text: string): string { + return text + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('&', '&'); +} + +/** + * Replay records within the turn limit, but never cut between a bundled + * prompt and the hook results recorded immediately before it: when the + * limiter's first retained record is a bundled prompt, the consecutive + * preceding hook results are pulled back into the window so the oldest + * visible bundle keeps its hook context. + */ +function preserveBundleHookResults( + replay: readonly AgentReplayRecord[], + maxTurns: number, +): readonly AgentReplayRecord[] { + const limited = limitReplayRecordsByTurn(replay, maxTurns); + const first = limited[0]; + if (first?.type !== 'message' || bundledSkillsFromOrigin(first.message.origin).length === 0) { + return limited; + } + const firstIndex = replay.indexOf(first); + if (firstIndex < 0) return limited; + let start = firstIndex; + for (;;) { + const candidate = replay[start - 1]; + if (candidate?.type !== 'message' || candidate.message.origin?.kind !== 'hook_result') break; + start -= 1; + } + return start === firstIndex ? limited : [...replay.slice(start, firstIndex), ...limited]; +} + +export class SessionReplayRenderer { + constructor(private readonly host: SessionReplayHost) {} + + async hydrateFromReplay(session: Session): Promise<boolean> { + this.host.setAppState({ isReplaying: true }); + try { + const main = session.getResumeState()?.agents['main']; + if (main === undefined) { + this.host.showError('Session history is unavailable for this session.'); + return false; + } + + this.hydrateSnapshot(main); + this.renderRecords(main); + this.applyTerminalBackgroundAgentStatuses(main); + this.host.sessionEventHandler.notifications.restore(session.getResumeState()); + this.host.mergeAllTurnSteps(); + return true; + } catch (error) { + const message = formatErrorMessage(error); + this.host.showError(`Failed to replay session history: ${message}`); + return false; + } finally { + this.host.setAppState({ isReplaying: false }); + } + } + + // --------------------------------------------------------------------------- + // Snapshot hydration + // --------------------------------------------------------------------------- + + private hydrateSnapshot(agent: ResumedAgentState): void { + this.host.setAppState(appStateFromResumeAgent(agent)); + this.hydrateTodoPanel(agent); + this.hydrateBackgroundState(agent); + } + + private hydrateTodoPanel(agent: ResumedAgentState): void { + const rawTodos = agent.toolStore?.['todo']; + if (!Array.isArray(rawTodos)) { + this.host.streamingUI.setTodoList([]); + return; + } + + const todos = rawTodos + .filter((todo): todo is TodoItem => isTodoItemShape(todo)) + .map((todo) => ({ title: todo.title, status: todo.status })); + if (todos.length > 0 && todos.every((todo) => todo.status === 'done')) { + this.host.streamingUI.setTodoList([]); + return; + } + + this.host.streamingUI.setTodoList(todos); + } + + /** + * Push real terminal status into each replayed `Agent` card whose + * backing background task is already in a terminal state. Runs AFTER + * `renderRecords` because the tool call components only exist once the + * replay has mounted them — `hydrateBackgroundState` runs too early to + * reach them. Without this, terminated bg agents (including ones that + * reconcile reclassified as `lost`) keep the spawn-success ToolResult's + * default of `✓ Completed`. + */ + private applyTerminalBackgroundAgentStatuses(agent: ResumedAgentState): void { + for (const info of agent.background) { + if (info.kind !== 'agent') continue; + if (!isTerminalBackgroundTask(info)) continue; + const status = info.status; + if ( + status !== 'completed' && + status !== 'failed' && + status !== 'timed_out' && + status !== 'killed' && + status !== 'lost' + ) { + continue; + } + this.host.streamingUI.applyBackgroundTaskTerminalStatus({ + agentId: info.agentId, + description: info.description, + status, + }); + } + } + + private hydrateBackgroundState(agent: ResumedAgentState): void { + const { state, sessionEventHandler } = this.host; + const projection = replayBackgroundProjection(agent.background, state.appState.availableModels); + sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata = new Map( + projection.backgroundAgentMetadata, + ); + sessionEventHandler.backgroundTasks.clear(); + for (const info of agent.background) { + sessionEventHandler.backgroundTasks.set(info.taskId, info); + } + sessionEventHandler.backgroundTaskTranscriptedTerminal.clear(); + for (const info of agent.background) { + if (isTerminalBackgroundTask(info)) { + sessionEventHandler.backgroundTaskTranscriptedTerminal.add(info.taskId); + } + } + state.footer.setBackgroundCounts(countActiveBackgroundTasks(sessionEventHandler.backgroundTasks)); + state.ui.requestRender(); + } + + // --------------------------------------------------------------------------- + // Record rendering + // --------------------------------------------------------------------------- + + private renderRecords(agent: ResumedAgentState): void { + const context = createReplayRenderContext(); + const records = [...preserveBundleHookResults(agent.replay, REPLAY_TURN_LIMIT)]; + for (let i = 0; i < records.length; i++) { + i = this.renderRecordWithBundleLookahead(context, records, i); + } + this.flushAssistant(context); + this.cleanupRuntime(context); + } + + private renderRecordWithBundleLookahead( + context: ReplayRenderContext, + records: readonly AgentReplayRecord[], + index: number, + ): number { + const record = records[index]!; + // Hook results recorded ahead of a bundled prompt are projected inside + // the bundle's window — after its skill cards, before the prompt — + // matching the live event order instead of attaching them to the + // previous turn. + if (record.type === 'message' && record.message.origin?.kind === 'hook_result') { + let end = index; + for (;;) { + const candidate = records[end + 1]; + if (candidate?.type !== 'message' || candidate.message.origin?.kind !== 'hook_result') { + break; + } + end += 1; + } + const next = records[end + 1]; + if (next?.type === 'message' && bundledSkillsFromOrigin(next.message.origin).length > 0) { + const hookResults: ContextMessage[] = []; + for (let j = index; j <= end; j++) { + const hookRecord = records[j]!; + if (hookRecord.type === 'message') hookResults.push(hookRecord.message); + } + this.renderBundledPrompt(context, next.message, hookResults); + return end + 1; + } + } + this.renderRecord(context, record); + return index; + } + + private renderRecord(context: ReplayRenderContext, record: AgentReplayRecord): void { + switch (record.type) { + case 'message': + this.renderMessage(context, record.message); + return; + case 'compaction': + this.renderCompaction(context, record); + return; + case 'goal_updated': + this.renderGoalReplayRecord(context, record); + return; + case 'plan_updated': + this.flushAssistant(context); + if (!record.enabled && context.suppressNextPlanModeOffNotice) { + context.suppressNextPlanModeOffNotice = false; + return; + } + context.suppressNextPlanModeOffNotice = false; + this.host.appendTranscriptEntry( + replayEntry(context, 'status', `Plan mode: ${record.enabled ? 'ON' : 'OFF'}`, 'notice'), + ); + return; + case 'permission_updated': + this.flushAssistant(context); + this.renderPermissionUpdate(context, record.mode); + return; + case 'approval_result': + this.flushAssistant(context); + this.renderApprovalResult(context, record.record); + return; + case 'config_updated': + return; + } + } + + private renderMessage(context: ReplayRenderContext, message: ContextMessage): void { + switch (message.role) { + case 'user': + this.renderUserMessage(context, message); + return; + case 'assistant': + if (message.origin?.kind === 'hook_result') { + this.renderHookResult(context, message); + this.renderToolCalls(context, message.toolCalls); + return; + } + collectReplayMessageContent(context.assistant, message.content); + this.flushAssistant(context); + this.renderToolCalls(context, message.toolCalls); + return; + case 'tool': + this.flushAssistant(context); + this.renderToolResult(context, message); + return; + case 'system': + return; + default: + return; + } + } + + private renderUserMessage(context: ReplayRenderContext, message: ContextMessage): void { + const origin = backgroundOrigin(message); + if (origin !== undefined) { + this.flushAssistant(context); + this.renderBackgroundTaskNotification(context, origin); + return; + } + if (message.origin?.kind === 'hook_result') { + this.renderHookResult(context, message); + return; + } + if (message.origin?.kind === 'injection') { + return; + } + if (message.origin?.kind === 'shell_command') { + // A `!` command, replayed from records. Unwrap the XML tags back into the + // same `$ cmd` + output view the live editor produced. (Must NOT fall into + // the `injection` branch above — that returns without rendering.) + this.flushAssistant(context); + const text = contentPartsToText(message.content); + if (message.origin.phase === 'input') { + const cmd = (extractBashTag(text, 'bash-input') ?? text).trim(); + this.advanceTurn(context); + this.host.appendTranscriptEntry( + replayEntry(context, 'user', currentTheme.fg('shellMode', `$ ${cmd}`), 'plain', { + bullet: '', + }), + ); + } else { + const stdout = (extractBashTag(text, 'bash-stdout') ?? '').trim(); + const stderr = (extractBashTag(text, 'bash-stderr') ?? '').trim(); + // Replayed `!` output is a finished card: mount the same component the + // live view uses, already finished, so the ctrl+o toggle reaches it. + const output = new ShellRunComponent(() => this.host.state.ui.requestRender()); + output.finish(stdout, stderr, message.origin.isError); + // Inherit the current ctrl+o state, same as the live card — the global + // toggle only reaches components that exist when it fires. + if (this.host.state.toolOutputExpanded) output.setExpanded(true); + markTranscriptComponent( + output, + replayEntry( + context, + 'status', + formatBashOutputForDisplay(stdout, stderr, message.origin.isError), + 'plain', + ), + ); + this.host.state.transcriptContainer.addChild(output); + } + return; + } + if (message.origin?.kind === 'cron_job') { + this.renderCronJob(context, message); + return; + } + if (message.origin?.kind === 'cron_missed') { + this.renderCronMissed(context, message); + return; + } + // System-trigger messages (goal continuation prompts, goal outcome + // reminders, stop-hook reasons, …) are model-facing only: the live event + // stream never renders them, so replay must not leak them either. + if (message.origin?.kind === 'system_trigger') { + if (message.origin.name === 'goal_continuation') { + // The goal driver's synthetic "continue" prompt starts a new replay + // turn even though nothing visible is mounted: advance the turn and + // mark an invisible boundary so each goal round groups under its own + // turn and step/assistant folding can find the turn edges. + this.advanceTurn(context); + const boundary = new ReplayTurnBoundaryComponent(); + markTranscriptComponent(boundary, replayEntry(context, 'user', '', 'plain')); + this.host.state.transcriptContainer.addChild(boundary); + } + return; + } + + this.flushAssistant(context); + const skill = skillActivationFromOrigin(message.origin); + if (skill !== undefined) { + this.renderSkillActivation(context, skill); + if (message.origin?.kind === 'skill_activation' && message.origin.trigger === 'user-slash') { + this.advanceTurn(context); + } + return; + } + const pluginCommand = pluginCommandFromOrigin(message.origin); + if (pluginCommand !== undefined) { + this.renderPluginCommand(context, pluginCommand); + if (message.origin?.kind === 'plugin_command' && message.origin.trigger === 'user-slash') { + this.advanceTurn(context); + } + return; + } + + if (bundledSkillsFromOrigin(message.origin).length > 0) { + this.renderBundledPrompt(context, message); + return; + } + this.advanceTurn(context); + this.host.appendTranscriptEntry( + replayEntry(context, 'user', contentPartsToText(message.content), 'plain'), + ); + } + + private renderBundledPrompt( + context: ReplayRenderContext, + message: ContextMessage, + hookResults: readonly ContextMessage[] = [], + ): void { + // The bundle is one message: advance once, rebuild the per-skill cards + // from the prompt origin, then show the caller's own parts (the engine + // prepends one rendered text part per bundled skill to the content). + this.advanceTurn(context); + this.renderBundledSkillCards(context, message); + for (const hookResult of hookResults) { + this.renderHookResult(context, hookResult); + } + this.host.appendTranscriptEntry( + replayEntry(context, 'user', contentPartsToText(stripBundledSkillParts(message)), 'plain'), + ); + } + + private renderBundledSkillCards(context: ReplayRenderContext, message: ContextMessage): void { + for (const skill of bundledSkillsFromOrigin(message.origin)) { + this.renderSkillActivation(context, skill); + } + } + + private renderToolCalls(context: ReplayRenderContext, toolCalls: readonly ToolCall[]): void { + if (toolCalls.length === 0) return; + const { streamingUI } = this.host; + context.stepIndex += 1; + this.applyStepContext(context); + for (const rawToolCall of toolCalls) { + const toolCall = toolCallFromReplayMessage(rawToolCall, context); + if (toolCall === undefined) continue; + context.toolCalls.set(toolCall.id, toolCall); + streamingUI.setActiveToolCall(toolCall.id, toolCall); + streamingUI.onToolCallStart(toolCall); + } + } + + private renderToolResult(context: ReplayRenderContext, message: ContextMessage): void { + const toolCallId = message.toolCallId; + if (toolCallId === undefined) return; + const call = context.toolCalls.get(toolCallId); + if (call === undefined) return; + + const result: ToolResultBlockData = { + tool_call_id: toolCallId, + output: toolResultOutput(message.content), + is_error: message.isError, + }; + call.result = result; + this.applyStepContext(context); + this.host.streamingUI.onToolCallEnd(toolCallId, result); + this.host.streamingUI.removeActiveToolCall(toolCallId); + context.completedToolCallIds.add(toolCallId); + } + + private advanceTurn(context: ReplayRenderContext): void { + context.turnIndex += 1; + context.stepIndex = 0; + context.currentTurnId = `replay:${String(context.turnIndex)}`; + this.applyStepContext(context); + } + + private applyStepContext(context: ReplayRenderContext): void { + this.host.streamingUI.setTurnId(context.currentTurnId); + this.host.streamingUI.setStep(context.stepIndex); + } + + private flushAssistant(context: ReplayRenderContext): void { + const { streamingUI } = this.host; + const thinking = context.assistant.thinking.join(''); + const text = context.assistant.text.join(''); + context.assistant = { thinking: [], text: [] }; + this.applyStepContext(context); + + if (thinking.length > 0) { + streamingUI.onThinkingUpdate(thinking); + streamingUI.onThinkingEnd(); + } + if (text.length > 0) { + streamingUI.onStreamingTextStart(); + streamingUI.onStreamingTextUpdate(text); + streamingUI.onStreamingTextEnd(); + streamingUI.clearAssistantDraft(); + } + } + + private cleanupRuntime(context: ReplayRenderContext): void { + this.flushAssistant(context); + this.host.streamingUI.cleanupAfterReplay(context.completedToolCallIds); + } + + // --------------------------------------------------------------------------- + // Special content renderers + // --------------------------------------------------------------------------- + + private renderSkillActivation( + context: ReplayRenderContext, + skill: SkillActivationProjection, + ): void { + const { sessionEventHandler } = this.host; + if (context.skillActivationIds.has(skill.activationId)) return; + if (sessionEventHandler.renderedSkillActivationIds.has(skill.activationId)) return; + context.skillActivationIds.add(skill.activationId); + sessionEventHandler.renderedSkillActivationIds.add(skill.activationId); + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'skill_activation', `Activated skill: ${skill.skillName}`, 'plain'), + skillActivationId: skill.activationId, + skillName: skill.skillName, + skillArgs: skill.skillArgs, + skillTrigger: skill.trigger, + bundledWithPrompt: skill.bundled === true ? true : undefined, + }); + } + + private renderPluginCommand( + context: ReplayRenderContext, + command: PluginCommandProjection, + ): void { + const { sessionEventHandler } = this.host; + if (context.pluginCommandActivationIds.has(command.activationId)) return; + if (sessionEventHandler.renderedPluginCommandActivationIds.has(command.activationId)) return; + context.pluginCommandActivationIds.add(command.activationId); + sessionEventHandler.renderedPluginCommandActivationIds.add(command.activationId); + this.host.appendTranscriptEntry({ + ...replayEntry( + context, + 'plugin_command', + `/${command.pluginId}:${command.commandName}`, + 'plain', + ), + pluginCommandData: { + activationId: command.activationId, + pluginId: command.pluginId, + commandName: command.commandName, + args: command.commandArgs, + trigger: command.trigger, + }, + }); + } + + private renderCompaction(context: ReplayRenderContext, record: CompactionReplayRecord): void { + this.flushAssistant(context); + if (record.result === undefined) return; + if (record.result === 'cancelled') { + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'status', 'Compaction cancelled', 'plain'), + compactionData: { + result: 'cancelled', + instruction: record.instruction, + }, + }); + return; + } + + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'status', 'Compaction complete', 'plain'), + compactionData: { + summary: record.result.summary, + tokensBefore: record.result.tokensBefore, + tokensAfter: record.result.tokensAfter, + instruction: record.instruction, + }, + }); + } + + private renderGoalReplayRecord(context: ReplayRenderContext, record: GoalReplayRecord): void { + this.flushAssistant(context); + const { change } = record; + switch (change.kind) { + case 'created': + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'goal', 'Goal set', 'plain'), + goalData: { kind: 'created' }, + }); + return; + case 'completion': + this.host.appendTranscriptEntry( + replayEntry(context, 'assistant', buildGoalCompletionMessage(record.snapshot), 'markdown'), + ); + return; + case 'lifecycle': { + const lifecycleChange: GoalReplayLifecycleChange = { ...change, kind: 'lifecycle' }; + if (isResumeNormalizationGoalPause(lifecycleChange)) return; + if (isModelBlockedGoalLifecycle(lifecycleChange)) { + return; + } + this.appendGoalLifecycleReplayEntry(context, lifecycleChange); + return; + } + } + } + + private appendGoalLifecycleReplayEntry( + context: ReplayRenderContext, + change: GoalReplayLifecycleChange, + ): void { + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'goal', goalLifecycleReplayContent(change), 'plain'), + goalData: { kind: 'lifecycle', change }, + }); + } + + private renderHookResult(context: ReplayRenderContext, message: ContextMessage): void { + if (message.origin?.kind !== 'hook_result') return; + this.flushAssistant(context); + this.host.appendTranscriptEntry({ + ...replayEntry( + context, + 'assistant', + formatHookResultMessageForTranscript( + contentPartsToText(message.content), + message.origin.event, + message.origin.blocked === true, + ), + 'markdown', + ), + hookResult: true, + }); + } + + private renderCronJob(context: ReplayRenderContext, message: ContextMessage): void { + if (message.origin?.kind !== 'cron_job') return; + this.flushAssistant(context); + this.host.appendTranscriptEntry({ + ...replayEntry( + context, + 'cron', + extractCronPrompt(contentPartsToText(message.content)), + 'plain', + ), + cronData: { + jobId: message.origin.jobId, + cron: message.origin.cron, + recurring: message.origin.recurring, + coalescedCount: message.origin.coalescedCount, + stale: message.origin.stale, + }, + }); + } + + private renderCronMissed(context: ReplayRenderContext, message: ContextMessage): void { + if (message.origin?.kind !== 'cron_missed') return; + this.flushAssistant(context); + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'cron', stripCronEnvelope(contentPartsToText(message.content)), 'plain'), + cronData: { + missedCount: message.origin.count, + }, + }); + } + + private renderPermissionUpdate(context: ReplayRenderContext, mode: PermissionMode): void { + if (mode === 'yolo') { + this.host.appendTranscriptEntry( + replayEntry(context, 'status', 'Ask When Needed mode: ON', 'notice', { + detail: 'Routine edits and commands run automatically; risky actions, questions, and plans still ask.', + }), + ); + return; + } + this.host.appendTranscriptEntry( + replayEntry( + context, + 'status', + mode === 'manual' + ? 'Ask When Needed mode: OFF' + : `Permission mode: ${PERMISSION_MODE_DISPLAY_NAMES[mode]}`, + 'notice', + ), + ); + } + + private renderApprovalResult( + context: ReplayRenderContext, + record: Extract<AgentReplayRecord, { type: 'approval_result' }>['record'], + ): void { + if (record.toolName === 'ExitPlanMode') { + this.renderPlanReviewResult(context, record); + return; + } + + const { result } = record; + const parts: string[] = []; + switch (result.decision) { + case 'approved': + parts.push(result.scope === 'session' ? 'Approved for session' : 'Approved'); + break; + case 'rejected': + parts.push('Rejected'); + break; + case 'cancelled': + parts.push('Cancelled'); + break; + } + parts.push(`: ${record.action}`); + if (result.feedback !== undefined && result.feedback.length > 0) { + parts.push(` — "${result.feedback}"`); + } + this.host.appendTranscriptEntry(replayEntry(context, 'status', parts.join(''), 'notice')); + } + + private renderPlanReviewResult( + context: ReplayRenderContext, + record: Extract<AgentReplayRecord, { type: 'approval_result' }>['record'], + ): void { + const { result } = record; + if (result.decision === 'approved') { + context.suppressNextPlanModeOffNotice = true; + return; + } + this.removeToolCall(record.toolCallId); + + let content: string; + switch (result.decision) { + case 'rejected': + content = + result.selectedLabel === 'Revise' ? 'Plan sent back for revision' : 'Plan review rejected'; + break; + case 'cancelled': + content = 'Plan review cancelled'; + break; + } + const detail = + result.feedback !== undefined && result.feedback.length > 0 + ? `Feedback: ${result.feedback}` + : undefined; + this.host.appendTranscriptEntry(replayEntry(context, 'status', content, 'notice', { detail })); + } + + private removeToolCall(toolCallId: string): void { + const { state, streamingUI } = this.host; + streamingUI.removeActiveToolCall(toolCallId); + streamingUI.removeToolComponent(toolCallId); + const index = state.transcriptEntries.findIndex( + (entry) => entry.toolCallData?.id === toolCallId, + ); + if (index >= 0) state.transcriptEntries.splice(index, 1); + const children = state.transcriptContainer.children; + const childIndex = children.findIndex( + (child) => child instanceof ToolCallComponent && child.toolCallView.id === toolCallId, + ); + if (childIndex >= 0) { + // Structural removal only: the container's ref-checked render cache + // detects the child-list change; no tree-wide invalidate needed. + children.splice(childIndex, 1); + } + } + + private renderBackgroundTaskNotification( + context: ReplayRenderContext, + origin: BackgroundTaskNotificationOrigin, + ): void { + const { sessionEventHandler } = this.host; + const task = sessionEventHandler.backgroundTasks.get(origin.taskId); + if (task !== undefined && task.kind !== 'agent') { + const status = formatBackgroundTaskTranscript({ ...task, status: origin.status }); + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'status', status.headline, 'plain'), + detail: status.detail, + backgroundAgentStatus: status, + }); + sessionEventHandler.backgroundTaskTranscriptedTerminal.add(origin.taskId); + return; + } + + const meta: BackgroundAgentMetadata = { + agentId: origin.taskId, + parentToolCallId: origin.taskId, + description: task?.description, + model: + task?.model === undefined + ? undefined + : modelDisplayName( + task.model, + this.host.state.appState.availableModels[task.model], + ), + effort: + task?.thinkingEffort === undefined || + task.thinkingEffort === 'off' || + task.thinkingEffort === 'on' + ? undefined + : task.thinkingEffort, + }; + let status = formatBackgroundAgentTranscript( + origin.status === 'completed' ? 'completed' : 'failed', + meta, + ); + if (origin.status === 'lost') { + status = { + ...status, + headline: status.headline.replace(' failed in background', ' lost in background'), + }; + } else if (origin.status === 'killed') { + status = { + ...status, + headline: status.headline.replace(' failed in background', ' stopped'), + }; + } else if (origin.status === 'timed_out') { + status = { + ...status, + headline: status.headline.replace(' failed in background', ' timed out'), + }; + } + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'status', status.headline, 'plain'), + detail: status.detail, + backgroundAgentStatus: status, + }); + sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata.delete(meta.agentId); + } +} + +const RESUME_NORMALIZATION_GOAL_PAUSE_REASONS = new Set([ + 'Paused after agent resume', + 'Paused after session resume', +]); + +function isResumeNormalizationGoalPause(change: GoalReplayLifecycleChange): boolean { + return ( + change.status === 'paused' && + change.reason !== undefined && + RESUME_NORMALIZATION_GOAL_PAUSE_REASONS.has(change.reason) + ); +} + +function goalLifecycleReplayContent(change: GoalReplayLifecycleChange): string { + switch (change.status) { + case 'paused': + return 'Goal paused'; + case 'active': + return 'Goal resumed'; + case 'blocked': + return 'Goal blocked'; + case 'complete': + case undefined: + return 'Goal updated'; + } +} + +function isModelBlockedGoalLifecycle(change: GoalReplayLifecycleChange): boolean { + return change.status === 'blocked' && change.actor === 'model'; +} + +function extractCronPrompt(text: string): string { + const open = '<prompt>\n'; + const close = '\n</prompt>'; + const start = text.indexOf(open); + const end = text.lastIndexOf(close); + if (start >= 0 && end >= start + open.length) { + return text.slice(start + open.length, end); + } + return stripCronEnvelope(text); +} + +function stripCronEnvelope(text: string): string { + const lines = text.split('\n'); + if ( + lines.length >= 2 && + lines[0]?.startsWith('<cron-fire ') && + lines.at(-1) === '</cron-fire>' + ) { + return lines.slice(1, -1).join('\n'); + } + return text; +} diff --git a/apps/kimi-code/src/tui/controllers/staging-leases.ts b/apps/kimi-code/src/tui/controllers/staging-leases.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc9bfcd78b68b9460c3e78843e5aef2f11eb54e1 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/staging-leases.ts @@ -0,0 +1,307 @@ +/** + * `StagingLeaseTracker` — owns the lifecycle of staged prompt media (daemon + * uploads + local cache copies) between submission and the session that + * consumes it. + * + * A paste/upload edge stages media before the prompt exists. The two staged + * forms age differently once the consuming turn ends: + * + * - Daemon uploads become garbage — the engine materialized its own session + * copy at intake — so the turn-end release deletes them. + * - Local cache copies may still be referenced by persisted history: slash / + * plugin command args carry the path as plain text (the model reads it + * with `ReadMediaFile`), and that form is never rewritten to the session + * media dir. Turn-end release therefore retires cache copies to a + * session-lifetime bucket, deleted at session close / shutdown. + * + * Media that never gets consumed (validation/render failure, queue discard, + * a dispatch RPC that failed before any turn claimed the lease) is deleted + * immediately, whatever form it takes. + * + * A submission diverted before dispatch hands its lease back via `defer`: + * the media stays staged under raw (ids, paths) ownership — a queued message + * re-leases at dequeue dispatch, and the cache-hint stash's restore/resend + * exits release it through `releaseRecalled` / a fresh lease. + * + * The tracker holds one lease per submission, binds it to the consuming turn + * (explicitly at dispatch, by exact submission id when the turn echoes the + * client-chosen prompt id, or heuristically when a matching-origin turn + * starts), and releases it when that turn ends. The heuristic claims the + * earliest unclaimed lease of the same origin; that is only sound because the + * TUI serializes same-origin dispatches (one in-flight submission at a time, + * see `beginSessionRequest`) and `turn.started` arrives in dispatch order. + * + * Exact binding: a lease created with a `submissionId` is registered in + * `leasesBySubmissionId`, and the submission sends that id as the prompt id; + * the consuming turn's `turn.started` echoes it as `promptId`, so + * `handleTurnStarted` binds the exact lease instead of guessing. The + * heuristic below remains the fallback for submissions without an id echo. + * + * INVARIANT: at most one unclaimed lease per origin at any moment — with two + * or more, the heuristic cannot tell which submission the turn belongs to. + * `handleTurnStarted` reports a violation through the `warn` effect and still + * claims the earliest (a mis-claim only mis-times deletions, so it is not + * worth failing the turn over). An exact `promptId` hit bypasses the + * heuristic entirely, so it neither trips nor needs the invariant. + * + * Unclaimed leases are released at session close / shutdown, and every + * in-flight cleanup is drainable via {@link drain}. + * + * Self-contained state machine extracted from `KimiTUI`: the two side effects + * (resolving attachment ids to daemon file ids, deleting the staged files) + * are injected, so the tracker is unit-testable without a TUI. + */ + +import type { TurnEndedEvent, TurnStartedEvent } from '@moonshot-ai/kimi-code-sdk'; + +import type { QueuedMessage } from '../types'; + +export type StagingLeaseOrigin = 'user' | 'skill_activation' | 'plugin_command'; + +export interface StagingLease { + readonly mediaAttachmentIds: readonly number[]; + readonly paths: readonly string[]; + readonly origin: StagingLeaseOrigin; + readonly submissionId?: string; + turnId: string | undefined; + released: boolean; +} + +export interface StagingLeaseEffects { + /** Resolve attachment ids to the staged daemon file ids, consuming the mapping. */ + readonly takeFileIds: (mediaAttachmentIds: readonly number[]) => readonly string[]; + /** Consume retains without taking the staged files (queue recall keeps them). */ + readonly releaseRetains: (mediaAttachmentIds: readonly number[]) => void; + /** Delete staged files (daemon uploads + local cache copies); never rejects. */ + readonly deleteFiles: (fileIds: readonly string[], paths: readonly string[]) => Promise<void>; + /** + * Optional sink for invariant violations (see the INVARIANT note above). + * The tracker keeps operating; the warning exists to make a broken + * same-origin ordering assumption visible instead of mis-binding silently. + */ + readonly warn?: (message: string) => void; +} + +export class StagingLeaseTracker { + private readonly cleanups = new Set<Promise<void>>(); + /** Staged media is owned by the turn that consumes it, not by the RPC call. */ + private readonly leases = new Set<StagingLease>(); + private readonly leasesByTurn = new Map<string, Set<StagingLease>>(); + /** Leases carrying a client-chosen submission id, for exact `promptId` binding. */ + private readonly leasesBySubmissionId = new Map<string, StagingLease>(); + /** + * Cache copies whose consuming turn already ended. Persisted history may + * still reference their paths (skill/plugin args carry them as plain + * text), so they survive until the session closes. + */ + private readonly retiredPaths = new Set<string>(); + + constructor(private readonly effects: StagingLeaseEffects) {} + + create( + mediaAttachmentIds: readonly number[], + paths: readonly string[], + origin: StagingLeaseOrigin, + submissionId?: string, + ): StagingLease | undefined { + // `mediaAttachmentIds` multiplicity is the retain count this lease must + // release: each extraction/rewrite retains once per unique id, so callers + // dedupe repeated placeholder occurrences per contribution before handing + // the ids over (one message referencing an image twice contributes it + // once; two batched messages sharing an image contribute it twice). + if (mediaAttachmentIds.length === 0 && paths.length === 0) return undefined; + const lease: StagingLease = { + mediaAttachmentIds: [...mediaAttachmentIds], + paths: [...paths], + origin, + submissionId, + turnId: undefined, + released: false, + }; + this.leases.add(lease); + if (submissionId !== undefined) this.leasesBySubmissionId.set(submissionId, lease); + return lease; + } + + bindToTurn(lease: StagingLease | undefined, turnId: string): void { + if (lease === undefined || lease.released || lease.turnId !== undefined) return; + lease.turnId = turnId; + let leases = this.leasesByTurn.get(turnId); + if (leases === undefined) { + leases = new Set<StagingLease>(); + this.leasesByTurn.set(turnId, leases); + } + leases.add(lease); + } + + handleTurnStarted(event: TurnStartedEvent): void { + const kind = event.origin?.kind; + if (kind !== 'user' && kind !== 'skill_activation' && kind !== 'plugin_command') return; + if (event.promptId !== undefined) { + // Exact binding: the turn echoes the submission's client-chosen prompt + // id — bind that lease directly and skip the origin heuristic (and its + // ambiguity warning) entirely. + const exact = this.leasesBySubmissionId.get(event.promptId); + if (exact !== undefined && exact.turnId === undefined) { + this.bindToTurn(exact, String(event.turnId)); + return; + } + } + const candidates = [...this.leases].filter( + (candidate) => + !candidate.released && candidate.turnId === undefined && candidate.origin === kind, + ); + if (candidates.length > 1) { + // INVARIANT violation: the earliest-unclaimed pick cannot tell + // same-origin leases apart — same-origin dispatch serialization or the + // turn.started ordering assumption may be broken. + this.effects.warn?.( + `staging lease: ${candidates.length} unclaimed '${kind}' leases when turn ` + + `${String(event.turnId)} started; claiming the earliest`, + ); + } + this.bindToTurn(candidates[0], String(event.turnId)); + } + + handleTurnEnded(event: TurnEndedEvent): void { + const turnId = String(event.turnId); + const leases = this.leasesByTurn.get(turnId); + if (leases === undefined) return; + for (const lease of leases) this.releaseConsumed(lease); + this.leasesByTurn.delete(turnId); + } + + /** + * Track a dispatch RPC carrying staged media. When it rejects, run + * `onError` and release the lease — but only while no turn has claimed it: + * a bound lease is owned by the turn and released at turn end, whatever the + * RPC's later outcome. + */ + trackDispatch( + lease: StagingLease | undefined, + request: Promise<unknown>, + onError: (error: unknown) => void, + ): void { + this.track( + request + .catch((error: unknown) => { + onError(error); + if (lease?.turnId === undefined) this.release(lease); + }) + .then(() => undefined), + ); + } + + /** + * Release staged media that will never be consumed (dispatch failed before + * a turn claimed the lease): delete daemon uploads and cache copies now. + */ + release(lease: StagingLease | undefined): void { + if (lease === undefined || lease.released) return; + this.unbind(lease); + this.deleteStaged(this.takeFileIds(lease), lease.paths); + } + + /** Release every unclaimed lease and the retired cache copies (session close / shutdown). */ + releaseAll(): void { + for (const lease of this.leases) this.release(lease); + const retired = [...this.retiredPaths]; + this.retiredPaths.clear(); + this.deleteStaged([], retired); + } + + /** Release staged media that never got a lease (validation/render failures). */ + releaseMedia(mediaAttachmentIds: readonly number[], paths: readonly string[]): void { + const fileIds = this.effects.takeFileIds(mediaAttachmentIds); + this.deleteStaged(fileIds, paths); + } + + releaseQueued(items: readonly QueuedMessage[]): void { + const fileIds = items.flatMap((item) => + this.effects.takeFileIds([ + ...(item.imageAttachmentIds ?? []), + ...(item.videoAttachmentIds ?? []), + ]), + ); + this.deleteStaged(fileIds, []); + } + + /** + * Release a queued item (or a cache-hint stash's extraction) recalled into + * the editor: the restored draft still references its attachments, so this + * is not a discard — daemon uploads stay staged (only the retain is + * consumed; the next submit re-retains them). `retirePaths` carries the + * slash/plugin-args channel's cache copies: the queued rewrite's args + * reference them by path, so they retire to session lifetime instead of + * being deleted. + */ + releaseRecalled( + mediaAttachmentIds: readonly number[], + retirePaths: readonly string[] = [], + ): void { + this.effects.releaseRetains(mediaAttachmentIds); + for (const path of retirePaths) this.retiredPaths.add(path); + } + + /** + * Hand a lease's staged media back to raw (ids, paths) ownership without + * consuming retains or deleting files: the lease is simply unbound. Used + * when a submission is diverted before dispatch — queued behind a running + * turn or swallowed by the cache-hint stash; see the header note. + */ + defer(lease: StagingLease | undefined): void { + if (lease === undefined || lease.released) return; + this.unbind(lease); + } + + /** Track an in-flight staging-related promise so {@link drain} can await it. */ + track(cleanup: Promise<void>): void { + let tracked!: Promise<void>; + tracked = cleanup.catch(() => undefined).finally(() => { + this.cleanups.delete(tracked); + }); + this.cleanups.add(tracked); + } + + async drain(): Promise<void> { + while (this.cleanups.size > 0) { + await Promise.allSettled(this.cleanups); + } + } + + /** Schedule deletion of already-resolved staged files (e.g. a store clear). */ + deleteStaged(fileIds: readonly string[], paths: readonly string[] = []): void { + if (fileIds.length === 0 && paths.length === 0) return; + this.track(this.effects.deleteFiles(fileIds, paths)); + } + + /** + * Turn-end release: the daemon uploads are safe to delete — the engine + * materialized its own session copies at intake — while the cache copies + * retire to session lifetime (see {@link retiredPaths}). + */ + private releaseConsumed(lease: StagingLease): void { + if (lease.released) return; + this.unbind(lease); + for (const path of lease.paths) this.retiredPaths.add(path); + this.deleteStaged(this.takeFileIds(lease)); + } + + private unbind(lease: StagingLease): void { + lease.released = true; + this.leases.delete(lease); + if (lease.submissionId !== undefined) this.leasesBySubmissionId.delete(lease.submissionId); + if (lease.turnId !== undefined) { + const leases = this.leasesByTurn.get(lease.turnId); + leases?.delete(lease); + if (leases?.size === 0) this.leasesByTurn.delete(lease.turnId); + } + } + + private takeFileIds(lease: StagingLease): readonly string[] { + // Multiplicity in the lease's id list is the retain count (creation sites + // dedupe per extraction before contributing ids): consume one retain per + // occurrence. + return lease.mediaAttachmentIds.flatMap((id) => this.effects.takeFileIds([id])); + } +} diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts new file mode 100644 index 0000000000000000000000000000000000000000..96c19c6a39fe4c506a04bb90c35910c53e39e905 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -0,0 +1,910 @@ +import type { Session } from '@moonshot-ai/kimi-code-sdk'; + +import { AgentGroupComponent } from '../components/messages/agent-group'; +import { AssistantMessageComponent } from '../components/messages/assistant-message'; +import { currentWorkingTip } from '../components/chrome/working-tips'; +import { CompactionComponent } from '../components/dialogs/compaction'; +import { ReadGroupComponent } from '../components/messages/read-group'; +import { ThinkingComponent } from '../components/messages/thinking'; +import { ToolCallComponent } from '../components/messages/tool-call'; +import { STREAMING_UI_FLUSH_MS } from '../constant/streaming'; +import { hasDispose } from '../utils/component-capabilities'; +import { appendStreamingArgsPreview, parseStreamingArgs } from '../utils/event-payload'; +import { notifyTerminalOnce } from '../utils/terminal-notification'; +import { nextTranscriptId } from '../utils/transcript-id'; +import type { TodoItem } from '../components/chrome/todo-panel'; +import type { + AppState, + LivePaneState, + QueuedMessage, + ToolCallBlockData, + ToolResultBlockData, + TranscriptEntry, +} from '../types'; +import type { TUIState } from '../tui-state'; + +export interface StreamingUIHost { + state: TUIState; + session: Session | undefined; + setAppState(patch: Partial<AppState>): void; + patchLivePane(patch: Partial<LivePaneState>): void; + resetLivePane(): void; + updateActivityPane(): void; + updateQueueDisplay(): void; + requireSession(): Session; + deferUserMessages: boolean; + shiftQueuedMessage(): QueuedMessage | undefined; + pushTranscriptEntry(entry: TranscriptEntry): void; + mergeCurrentTurnSteps(): void; + mergeCompletedTurnAssistants(): void; +} + +export class StreamingUIController { + private flushTimer: ReturnType<typeof setTimeout> | undefined; + private lastFlushAt: number | undefined; + private pendingAssistantFlush = false; + private pendingThinkingFlush = false; + readonly pendingToolCallFlushIds = new Set<string>(); + + // --------------------------------------------------------------------------- + // Streaming runtime state (private — accessed via semantic methods below) + // --------------------------------------------------------------------------- + + private _currentTurnId: string | undefined = undefined; + private _currentStep = 0; + private _assistantDraft = ''; + private _thinkingDraft = ''; + private _streamingBlock: { component: AssistantMessageComponent; entry: TranscriptEntry } | null = null; + private _activeThinkingComponent: ThinkingComponent | undefined = undefined; + private _activeCompactionBlock: CompactionComponent | undefined = undefined; + private _activeToolCalls = new Map<string, ToolCallBlockData>(); + private _streamingToolCallArguments = new Map< + string, + { name?: string; argumentsText: string; startedAtMs: number } + >(); + private _pendingToolComponents = new Map<string, ToolCallComponent>(); + private _pendingAgentGroup: { + readonly turnId: string | undefined; + readonly step: number; + solo?: ToolCallComponent; + group?: AgentGroupComponent; + } | null = null; + private _pendingReadGroup: { + readonly turnId: string | undefined; + readonly step: number; + solo?: ToolCallComponent; + group?: ReadGroupComponent; + } | null = null; + + constructor(private readonly host: StreamingUIHost) {} + + // --------------------------------------------------------------------------- + // Turn context — read/write accessors + // --------------------------------------------------------------------------- + + getTurnContext(): { turnId: string | undefined; step: number } { + return { turnId: this._currentTurnId, step: this._currentStep }; + } + + setTurnId(turnId: string | undefined): void { + this._currentTurnId = turnId; + } + + setStep(step: number): void { + this._currentStep = step; + } + + hasActiveTurn(): boolean { + return this._currentTurnId !== undefined; + } + + // --------------------------------------------------------------------------- + // Text streaming — semantic write accessors + // --------------------------------------------------------------------------- + + appendThinkingDelta(delta: string): void { + this._thinkingDraft += delta; + this.pendingThinkingFlush = true; + } + + appendAssistantDelta(delta: string): void { + if (this._streamingBlock === null) { + this.onStreamingTextStart(); + } + this._assistantDraft += delta; + this.pendingAssistantFlush = true; + } + + hasThinkingDraft(): boolean { + return this._thinkingDraft.length > 0; + } + + hasActiveThinkingComponent(): boolean { + return this._activeThinkingComponent !== undefined; + } + + hasStreamingBlock(): boolean { + return this._streamingBlock !== null; + } + + getStreamingBlockComponent(): AssistantMessageComponent | undefined { + return this._streamingBlock?.component; + } + + clearAssistantDraft(): void { + this._assistantDraft = ''; + } + + // --------------------------------------------------------------------------- + // Tool call state — semantic accessors + // --------------------------------------------------------------------------- + + getActiveToolCall(id: string): ToolCallBlockData | undefined { + return this._activeToolCalls.get(id); + } + + hasActiveToolCall(id: string): boolean { + return this._activeToolCalls.has(id); + } + + setActiveToolCall(id: string, toolCall: ToolCallBlockData): void { + this._activeToolCalls.set(id, toolCall); + } + + removeActiveToolCall(id: string): void { + this._activeToolCalls.delete(id); + } + + getToolComponent(id: string): ToolCallComponent | undefined { + return this._pendingToolComponents.get(id); + } + + removeToolComponent(id: string): void { + this._pendingToolComponents.delete(id); + } + + hasPendingAgentGroup(): boolean { + return this._pendingAgentGroup !== null; + } + + hasPendingReadGroup(): boolean { + return this._pendingReadGroup !== null; + } + + removeToolComponentIfInactive(toolCallId: string): void { + if (!this._activeToolCalls.has(toolCallId)) { + this._pendingToolComponents.delete(toolCallId); + } + } + + /** + * Push the actual terminal status of a background agent task into the + * matching `Agent` tool call component so its snapshot phase no longer + * trusts the spawn-success ToolResult (which would otherwise label every + * terminated bg agent — including `lost` ones — as `✓ Completed`). + * + * Resolution policy: an `args.agentId` is treated as authoritative — we + * either find a card whose `getSubagentAgentId()` returns the same id + * (in-memory metadata for live foreground, parsed from the spawn-success + * `agent_id: ...` line for live backgrounded and replayed cards) or we + * skip. We deliberately do NOT fall back to description match when + * `agentId` is provided, because: + * - On resume, `applyTerminalBackgroundAgentStatuses` iterates every + * persisted terminal task, including ones whose tool calls fell + * outside the `REPLAY_TURN_LIMIT` window. A description fallback + * would let an old `lost` task stamp its status onto an unrelated + * recent Agent card that happens to share `args.description`. + * - During a live spawn / terminate race, the same card can briefly + * appear in both `_pendingToolComponents` and `transcriptContainer`, + * so a description match could double-visit the same component and + * mark itself ambiguous. agentId match short-circuits on the first + * hit and is immune. + * + * Description fallback is kept as a best-effort path only when + * `agentId` is unknown — that is, on resume of pre-PR sessions whose + * disk records pre-date `agent_id` persistence. + * + * Search scope includes both in-flight components and already-mounted + * cards (some live in `transcriptContainer` standalone, others are + * borrowed by an `AgentGroupComponent` and reachable only via + * `getToolComponents()`). + * + * Returns true iff a component was found and updated. + */ + applyBackgroundTaskTerminalStatus(args: { + agentId?: string | undefined; + description: string; + status: 'completed' | 'failed' | 'timed_out' | 'killed' | 'lost'; + /** + * Real failure message to surface on the card. Pass the `subagent.failed` + * event's `error` for live crashes — it is far more useful than the + * friendly generic the card falls back to. Omit on the resume / terminate + * path where no real error is available. + */ + errorText?: string | undefined; + }): boolean { + const useAgentIdOnly = args.agentId !== undefined; + let agentIdMatch: ToolCallComponent | undefined; + let descMatch: ToolCallComponent | undefined; + let descAmbiguous = false; + const visit = (tc: ToolCallComponent): void => { + if (agentIdMatch !== undefined) return; + if (useAgentIdOnly) { + if (tc.getSubagentAgentId() === args.agentId) agentIdMatch = tc; + return; + } + if (tc.getAgentToolDescription() !== args.description) return; + if (descMatch !== undefined) { + descAmbiguous = true; + return; + } + descMatch = tc; + }; + + for (const tc of this._pendingToolComponents.values()) { + visit(tc); + if (agentIdMatch !== undefined) break; + } + if (agentIdMatch === undefined) { + for (const child of this.host.state.transcriptContainer.children) { + if (child instanceof ToolCallComponent) { + visit(child); + } else if (child instanceof AgentGroupComponent) { + for (const tc of child.getToolComponents()) { + visit(tc); + if (agentIdMatch !== undefined) break; + } + } + if (agentIdMatch !== undefined) break; + } + } + const target = useAgentIdOnly + ? agentIdMatch + : descAmbiguous + ? undefined + : descMatch; + if (target === undefined) return false; + target.setBackgroundTaskTerminalStatus(args.status, { errorText: args.errorText }); + return true; + } + + /** + * Mark a foreground subagent card as detached-to-background (`◐ backgrounded`). + * Routed from a `background.task.started` event whose `info.kind === 'agent'`, + * keyed by `agentId`. Returns true iff a matching component was found. + * + * Gated to cards that are currently foreground-running: `background.task.started` + * also fires for `Agent(run_in_background=true)` launches and for background + * resumes, and those must not mutate older completed rows that happen to share + * the same `agentId` (a resume's new card has no parsed `agent_id` yet, so the + * search can otherwise hit the previous completed card). + */ + markSubagentBackgrounded(agentId: string | undefined): boolean { + if (agentId === undefined) return false; + const visit = (tc: ToolCallComponent): boolean => { + if (tc.getSubagentAgentId() !== agentId) return false; + const phase = tc.getSubagentSnapshot().phase; + if (phase !== 'running' && phase !== 'queued' && phase !== 'spawning') return false; + tc.markBackgrounded(); + return true; + }; + for (const tc of this._pendingToolComponents.values()) { + if (visit(tc)) return true; + } + for (const child of this.host.state.transcriptContainer.children) { + if (child instanceof ToolCallComponent) { + if (visit(child)) return true; + } else if (child instanceof AgentGroupComponent) { + for (const tc of child.getToolComponents()) { + if (visit(tc)) return true; + } + } + } + return false; + } + + /** Registers a tool call that arrived via tool.call.started. + * Clears any pending streaming state for this id, updates or creates the + * component, and returns whether the call was new (no previous entry). */ + registerToolCall(toolCall: ToolCallBlockData): boolean { + const existing = this._activeToolCalls.get(toolCall.id); + this._activeToolCalls.set(toolCall.id, toolCall); + this.pendingToolCallFlushIds.delete(toolCall.id); + this._streamingToolCallArguments.delete(toolCall.id); + const existingComponent = this._pendingToolComponents.get(toolCall.id); + if (existingComponent !== undefined) { + existingComponent.updateToolCall(toolCall); + } else if (existing === undefined) { + this.finalizeLiveTextBuffers('tool'); + if (toolCall.name !== 'Agent' && toolCall.name !== 'AgentSwarm') { + this.onToolCallStart(toolCall); + } + } + return existing === undefined; + } + + /** Accumulates a streaming tool-call argument delta. */ + accumulateToolCallDelta( + id: string, + eventName: string | undefined, + argumentsPart: string | null | undefined, + ): void { + const existing = this._streamingToolCallArguments.get(id); + const argumentsText = appendStreamingArgsPreview(existing?.argumentsText, argumentsPart); + const name = eventName ?? existing?.name ?? this._activeToolCalls.get(id)?.name ?? 'Tool'; + const startedAtMs = existing?.startedAtMs ?? Date.now(); + this._streamingToolCallArguments.set(id, { name, argumentsText, startedAtMs }); + this.pendingToolCallFlushIds.add(id); + } + + getStreamingToolCallPreview( + id: string, + ): { name: string; args: Record<string, unknown>; argumentsText: string; startedAtMs: number } | undefined { + const streaming = this._streamingToolCallArguments.get(id); + if (streaming === undefined) return undefined; + return { + name: streaming.name ?? this._activeToolCalls.get(id)?.name ?? 'Tool', + args: parseStreamingArgs(streaming.argumentsText), + argumentsText: streaming.argumentsText, + startedAtMs: streaming.startedAtMs, + }; + } + + /** Completes a tool call: delivers the result and removes tracking state. + * Returns the matched ToolCallBlockData, or undefined if no call was tracked. */ + completeToolResult(toolCallId: string, result: ToolResultBlockData): ToolCallBlockData | undefined { + const matchedCall = this._activeToolCalls.get(toolCallId); + if (matchedCall !== undefined) { + this.onToolCallEnd(toolCallId, result); + } + this._activeToolCalls.delete(toolCallId); + this._streamingToolCallArguments.delete(toolCallId); + return matchedCall; + } + + /** Marks in-flight tool calls as truncated when a step hits max_tokens. + * Returns the count of tool calls that were truncated. */ + markStepTruncated(turnId: string, step: number): number { + let count = 0; + for (const toolCall of this._activeToolCalls.values()) { + if (toolCall.result !== undefined) continue; + if (toolCall.streamingArguments === undefined) continue; + if (toolCall.turnId !== turnId) continue; + if (toolCall.step !== step) continue; + toolCall.truncated = true; + const component = this._pendingToolComponents.get(toolCall.id); + if (component !== undefined) { + component.updateToolCall(toolCall); + } + count += 1; + } + this._streamingToolCallArguments.clear(); + return count; + } + + /** Tears down replay-specific state after session history has been rendered. */ + cleanupAfterReplay(completedToolCallIds: Set<string>): void { + this._activeToolCalls.clear(); + for (const toolCallId of completedToolCallIds) { + this._pendingToolComponents.delete(toolCallId); + } + this._pendingAgentGroup = null; + this._pendingReadGroup = null; + this._currentTurnId = undefined; + this._currentStep = 0; + this._streamingToolCallArguments.clear(); + this.pendingToolCallFlushIds.clear(); + this.host.state.ui.requestRender(); + } + + // --------------------------------------------------------------------------- + // Dispose helpers (moved from KimiTUI) + // --------------------------------------------------------------------------- + + disposeActiveThinkingComponent(): void { + if (this._activeThinkingComponent !== undefined) { + this._activeThinkingComponent.dispose(); + this._activeThinkingComponent = undefined; + } + } + + disposeAndClearPendingToolComponents(): void { + for (const component of this._pendingToolComponents.values()) { + if (hasDispose(component)) component.dispose(); + } + this._pendingToolComponents.clear(); + } + + disposeActiveCompactionBlock(): void { + if (this._activeCompactionBlock !== undefined) { + this._activeCompactionBlock.dispose(); + this._activeCompactionBlock = undefined; + } + } + + // --------------------------------------------------------------------------- + // Flush control + // --------------------------------------------------------------------------- + + hasPending(): boolean { + return ( + this.pendingAssistantFlush || + this.pendingThinkingFlush || + this.pendingToolCallFlushIds.size > 0 + ); + } + + clearFlushTimer(): void { + if (this.flushTimer === undefined) return; + clearTimeout(this.flushTimer); + this.flushTimer = undefined; + } + + private clearFlushTimerIfIdle(): void { + if (this.hasPending()) return; + this.clearFlushTimer(); + } + + discardPending(): void { + this.clearFlushTimer(); + this.pendingAssistantFlush = false; + this.pendingThinkingFlush = false; + this.pendingToolCallFlushIds.clear(); + } + + scheduleFlush(): void { + if (!this.hasPending()) return; + if (this.flushTimer !== undefined) return; + const delay = + this.lastFlushAt === undefined + ? 0 + : Math.max(0, STREAMING_UI_FLUSH_MS - (Date.now() - this.lastFlushAt)); + this.flushTimer = setTimeout(() => { + this.flushTimer = undefined; + this.flush(); + }, delay); + } + + flushNow(): void { + this.clearFlushTimer(); + this.flush(); + } + + private flush(): void { + if (!this.hasPending()) return; + this.lastFlushAt = Date.now(); + const shouldFlushThinking = this.pendingThinkingFlush; + const shouldFlushAssistant = this.pendingAssistantFlush; + const toolCallIds = [...this.pendingToolCallFlushIds]; + this.pendingThinkingFlush = false; + this.pendingAssistantFlush = false; + this.pendingToolCallFlushIds.clear(); + + if (shouldFlushThinking && this._thinkingDraft.length > 0) { + this.onThinkingUpdate(this._thinkingDraft); + } + if (shouldFlushAssistant) { + this.onStreamingTextUpdate(this._assistantDraft); + } + for (const id of toolCallIds) { + this.flushToolCallPreview(id); + } + } + + markAssistantDirty(): void { + this.pendingAssistantFlush = true; + } + + markThinkingDirty(): void { + this.pendingThinkingFlush = true; + } + + // --------------------------------------------------------------------------- + // Text streaming + // --------------------------------------------------------------------------- + + flushThinkingToTranscript(nextMode: LivePaneState['mode'] = 'idle'): void { + this.flushNow(); + this._thinkingDraft = ''; + this.onThinkingEnd(); + this.host.patchLivePane({ mode: nextMode }); + } + + finalizeAssistantStream(): void { + this.flushNow(); + if (this._streamingBlock !== null) { + this.onStreamingTextEnd(); + } + this._assistantDraft = ''; + this.host.updateActivityPane(); + this.host.state.ui.requestRender(); + } + + resetLiveText(): void { + this.pendingAssistantFlush = false; + this.pendingThinkingFlush = false; + this.clearFlushTimerIfIdle(); + this._assistantDraft = ''; + this._streamingBlock = null; + this._thinkingDraft = ''; + this.disposeActiveThinkingComponent(); + } + + resetToolUi(): void { + this.pendingToolCallFlushIds.clear(); + this.clearFlushTimerIfIdle(); + this._streamingToolCallArguments.clear(); + this.disposeAndClearPendingToolComponents(); + this._pendingAgentGroup = null; + this._pendingReadGroup = null; + this.resetToolCallState(); + } + + resetToolCallState(): void { + this._activeToolCalls.clear(); + } + + finalizeLiveTextBuffers(nextMode: LivePaneState['mode'] = 'idle'): void { + this.flushThinkingToTranscript(nextMode); + this.finalizeAssistantStream(); + } + + finalizeTurn(sendQueued: (item: QueuedMessage) => void): void { + const { state } = this.host; + if (state.appState.streamingPhase === 'idle') return; + this.host.deferUserMessages = false; + const completedTurnKey = + this._currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`; + this.finalizeLiveTextBuffers('idle'); + // The finished turn keeps only its conclusion-bearing tail; intermediate + // chatter folds into the step summary. + this.host.mergeCompletedTurnAssistants(); + this.resetToolCallState(); + this._currentTurnId = undefined; + + const next = this.host.shiftQueuedMessage(); + if (next !== undefined) { + // The message is out of the queue but not yet sent. Mark the dispatch + // pending *before* setAppState — that call synchronously retries + // queued-goal promotion, which would otherwise see an empty queue and an + // idle phase and start a goal ahead of this message. + state.queuedMessageDispatchPending = true; + this.host.setAppState({ streamingPhase: 'idle' }); + this.host.resetLivePane(); + setTimeout(() => { + state.queuedMessageDispatchPending = false; + sendQueued(next); + }, 0); + return; + } + + this.host.setAppState({ streamingPhase: 'idle' }); + this.host.resetLivePane(); + notifyTerminalOnce(state, `turn-complete:${completedTurnKey}`, { + title: 'Kimi Code task complete', + body: state.appState.sessionTitle ?? undefined, + }); + } + + // --------------------------------------------------------------------------- + // Live Render Hooks + // --------------------------------------------------------------------------- + + onStreamingTextStart(): void { + const { state } = this.host; + this._pendingAgentGroup = null; + this._pendingReadGroup = null; + const entry = { + id: nextTranscriptId(), + kind: 'assistant' as const, + turnId: this._currentTurnId, + renderMode: 'markdown' as const, + content: '', + modelText: true, + }; + const component = new AssistantMessageComponent(); + this._streamingBlock = { component, entry }; + this.host.pushTranscriptEntry(entry); + state.transcriptContainer.addChild(component); + state.ui.requestRender(); + } + + onStreamingTextUpdate(fullText: string): void { + const block = this._streamingBlock; + if (block !== null) { + block.entry.content = fullText; + block.component.updateContent(fullText, { transient: true }); + this.host.state.ui.requestRender(); + } + } + + onStreamingTextEnd(): void { + const block = this._streamingBlock; + if (block !== null) { + block.component.updateContent(block.entry.content, { transient: false }); + } + this._streamingBlock = null; + } + + onThinkingUpdate(fullText: string): void { + // Skip thinking that carries nothing visible — empty (e.g. encrypted + // reasoning) or whitespace-only (a model occasionally streams a single + // space as thinking). Session replay funnels through here as well, so a + // stored whitespace-only think part never becomes a bare bullet line. + if (fullText.trim().length === 0 && this._activeThinkingComponent === undefined) return; + const { state } = this.host; + if (this._activeThinkingComponent === undefined) { + this._pendingAgentGroup = null; + this._pendingReadGroup = null; + this._activeThinkingComponent = new ThinkingComponent( + fullText, + true, + 'live', + state.ui, + ); + if (state.toolOutputExpanded) this._activeThinkingComponent.setExpanded(true); + state.transcriptContainer.addChild(this._activeThinkingComponent); + } else { + this._activeThinkingComponent.setText(fullText); + } + state.ui.requestRender(); + } + + onThinkingEnd(): void { + if (this._activeThinkingComponent === undefined) return; + this._activeThinkingComponent.finalize(); + this._activeThinkingComponent = undefined; + this.host.state.ui.requestRender(); + this.host.mergeCurrentTurnSteps(); + } + + onToolCallStart(toolCall: ToolCallBlockData): void { + if (toolCall.name === 'AskUserQuestion') return; + + const { state } = this.host; + const tc = new ToolCallComponent( + toolCall, + undefined, + state.ui, + state.appState.workDir, + ); + if (state.toolOutputExpanded) tc.setExpanded(true); + this._pendingToolComponents.set(toolCall.id, tc); + + if (toolCall.name !== 'Agent') this._pendingAgentGroup = null; + if (toolCall.name !== 'Read') this._pendingReadGroup = null; + + let handled = this.tryAttachAgentToolCall(toolCall, tc); + if (!handled) handled = this.tryAttachReadToolCall(toolCall, tc); + if (!handled) { + state.transcriptContainer.addChild(tc); + state.ui.requestRender(); + } + + if (toolCall.name === 'ExitPlanMode' && typeof toolCall.args['plan'] !== 'string') { + const session = this.host.requireSession(); + void (async () => { + try { + const plan = await session.getPlan(); + tc.setPlanInfo(plan === null ? {} : { plan: plan.content, path: plan.path }); + } catch { + tc.setPlanInfo({}); + } + })(); + } + } + + onToolCallEnd(toolCallId: string, result: ToolResultBlockData): void { + const { state } = this.host; + const matchedCall = this._activeToolCalls.get(toolCallId); + const tc = this._pendingToolComponents.get(toolCallId); + if (tc) { + tc.setResult(result); + this._pendingToolComponents.delete(toolCallId); + state.ui.requestRender(); + this.host.mergeCurrentTurnSteps(); + return; + } + + if (matchedCall?.name === 'AskUserQuestion') { + const completed = new ToolCallComponent( + matchedCall, + result, + state.ui, + state.appState.workDir, + ); + if (state.toolOutputExpanded) completed.setExpanded(true); + state.transcriptContainer.addChild(completed); + state.ui.requestRender(); + } + this.host.mergeCurrentTurnSteps(); + } + + setTodoList(todos: readonly TodoItem[]): void { + const { state } = this.host; + state.todoPanel.setTodos(todos); + state.todoPanelContainer.clear(); + if (!state.todoPanel.isEmpty()) { + state.todoPanelContainer.addChild(state.todoPanel); + } + state.ui.requestRender(); + } + + beginCompaction(instruction?: string): void { + const { state } = this.host; + if (this._activeCompactionBlock !== undefined) { + this._activeCompactionBlock.markDone(); + this._activeCompactionBlock = undefined; + } + const block = new CompactionComponent(state.ui, instruction, currentWorkingTip()?.text); + this._activeCompactionBlock = block; + state.transcriptContainer.addChild(block); + if (state.toolOutputExpanded) { + block.setExpanded(true); + } + state.ui.requestRender(); + } + + endCompaction(tokensBefore?: number, tokensAfter?: number, summary?: string): void { + const block = this._activeCompactionBlock; + if (block === undefined) return; + block.markDone(tokensBefore, tokensAfter, summary); + this._activeCompactionBlock = undefined; + this.host.state.ui.requestRender(); + } + + cancelCompaction(): void { + const block = this._activeCompactionBlock; + if (block === undefined) return; + block.markCanceled(); + this._activeCompactionBlock = undefined; + this.host.state.ui.requestRender(); + } + + // --------------------------------------------------------------------------- + // Tool call grouping + // --------------------------------------------------------------------------- + + private flushToolCallPreview(id: string): void { + const streaming = this._streamingToolCallArguments.get(id); + if (streaming === undefined) return; + const toolCall: ToolCallBlockData = { + id, + name: streaming.name ?? this._activeToolCalls.get(id)?.name ?? 'Tool', + args: parseStreamingArgs(streaming.argumentsText), + streamingArguments: streaming.argumentsText, + streamingStartedAtMs: streaming.startedAtMs, + step: this._currentStep, + turnId: this._currentTurnId, + }; + this._activeToolCalls.set(id, toolCall); + + if (this._thinkingDraft.length > 0 || this._streamingBlock !== null) { + this.finalizeLiveTextBuffers('tool'); + } + + const existingComponent = this._pendingToolComponents.get(id); + if (existingComponent !== undefined) { + existingComponent.updateToolCall(toolCall); + } else if (toolCall.name !== 'Agent' && toolCall.name !== 'AgentSwarm') { + this.onToolCallStart(toolCall); + } + } + + private tryAttachAgentToolCall(toolCall: ToolCallBlockData, tc: ToolCallComponent): boolean { + const { state } = this.host; + if (toolCall.name !== 'Agent') { + this._pendingAgentGroup = null; + return false; + } + + const step = toolCall.step ?? this._currentStep; + const turnId = toolCall.turnId ?? this._currentTurnId; + const pending = this._pendingAgentGroup; + + if (pending !== null && (pending.step !== step || pending.turnId !== turnId)) { + this._pendingAgentGroup = null; + } + + const cur = this._pendingAgentGroup; + if (cur === null) { + this._pendingAgentGroup = { step, turnId, solo: tc }; + state.transcriptContainer.addChild(tc); + state.ui.requestRender(); + return true; + } + + if (cur.group !== undefined) { + cur.group.attach(toolCall.id, tc); + return true; + } + + const solo = cur.solo; + if (solo === undefined) { + this._pendingAgentGroup = { step, turnId, solo: tc }; + state.transcriptContainer.addChild(tc); + state.ui.requestRender(); + return true; + } + const group = this.upgradeSoloAgentToGroup(solo); + group.attach(toolCall.id, tc); + this._pendingAgentGroup = { step, turnId, group }; + state.ui.requestRender(); + return true; + } + + private upgradeSoloAgentToGroup(solo: ToolCallComponent): AgentGroupComponent { + const { state } = this.host; + const group = new AgentGroupComponent(state.ui); + const children = state.transcriptContainer.children; + const idx = children.indexOf(solo); + if (idx >= 0) { + // In-place replacement is picked up by the container's ref-checked + // render cache; a tree-wide invalidate is unnecessary (and costly). + children[idx] = group; + } else { + state.transcriptContainer.addChild(group); + } + group.attach(solo.toolCallView.id, solo); + return group; + } + + private tryAttachReadToolCall(toolCall: ToolCallBlockData, tc: ToolCallComponent): boolean { + const { state } = this.host; + if (toolCall.name !== 'Read') { + this._pendingReadGroup = null; + return false; + } + + const step = toolCall.step ?? this._currentStep; + const turnId = toolCall.turnId ?? this._currentTurnId; + const pending = this._pendingReadGroup; + + if (pending !== null && (pending.step !== step || pending.turnId !== turnId)) { + this._pendingReadGroup = null; + } + + const cur = this._pendingReadGroup; + if (cur === null) { + this._pendingReadGroup = { step, turnId, solo: tc }; + state.transcriptContainer.addChild(tc); + state.ui.requestRender(); + return true; + } + + if (cur.group !== undefined) { + cur.group.attach(toolCall.id, tc); + return true; + } + + const solo = cur.solo; + if (solo === undefined) { + this._pendingReadGroup = { step, turnId, solo: tc }; + state.transcriptContainer.addChild(tc); + state.ui.requestRender(); + return true; + } + const group = this.upgradeSoloReadToGroup(solo); + group.attach(toolCall.id, tc); + this._pendingReadGroup = { step, turnId, group }; + state.ui.requestRender(); + return true; + } + + private upgradeSoloReadToGroup(solo: ToolCallComponent): ReadGroupComponent { + const { state } = this.host; + const group = new ReadGroupComponent(state.ui); + if (state.toolOutputExpanded) group.setExpanded(true); + const children = state.transcriptContainer.children; + const idx = children.indexOf(solo); + if (idx >= 0) { + // In-place replacement is picked up by the container's ref-checked + // render cache; a tree-wide invalidate is unnecessary (and costly). + children[idx] = group; + } else { + state.transcriptContainer.addChild(group); + } + group.attach(solo.toolCallView.id, solo); + return group; + } +} diff --git a/apps/kimi-code/src/tui/controllers/subagent-activity-store.ts b/apps/kimi-code/src/tui/controllers/subagent-activity-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f768513f99eb6e9e55ec2022c666f909a30becf --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/subagent-activity-store.ts @@ -0,0 +1,348 @@ +/** + * SubagentActivityStore — per-agent activity records feeding the background + * agent detail view (AgentActivityViewer). + * + * Child-agent events arrive at `SubAgentEventHandler.routeChildAgentEvent` + * regardless of foreground/background state, but are dropped there when the + * parent tool card is gone (Ctrl+B) or never existed (run_in_background). + * This store tees those events into a bounded per-agent fold so the tasks + * browser can show what a background agent is actually doing. + * + * Retention: only the most recent `MAX_SUBAGENT_ACTIVITY_STEPS` steps are + * kept (older steps are discarded whole — a step is the core loop's natural + * "one model response + tool execution" unit, bounded by the core's own + * `turn.step.started` events). Per-step assistant text keeps a trailing + * window; per-call result output is capped. Everything lives in memory and + * is released on session switch (`clear`). + * + * Pure logic — no TUI state, no components — so it is unit-testable. + */ + +import type { Event } from '@moonshot-ai/kimi-code-sdk'; + +import { + MAX_SUBAGENT_ACTIVITY_STEPS, + SUBAGENT_ARG_STRING_MAX_CHARS, + SUBAGENT_STEP_TEXT_TAIL_CHARS, + SUBAGENT_TOOL_OUTPUT_MAX_CHARS, +} from '#/tui/constant/rendering'; +import type { ToolResultBlockData } from '../types'; +import { + argsRecord, + appendStreamingArgsPreview, + parseStreamingArgs, + serializeToolResultOutput, +} from '../utils/event-payload'; + +/** A single tool call inside a step, shaped so the viewer can feed the + * main-flow renderers (`ToolCallBlockData` / `ToolResultBlockData`). */ +export interface SubToolCallActivity { + readonly id: string; + name: string; + args: Record<string, unknown>; + status: 'running' | 'done' | 'error'; + readonly startedAt: number; + durationMs?: number; + result?: ToolResultBlockData; + /** Last line of stdout/stderr live progress, while the call is running. */ + liveOutputTail?: string; +} + +/** One step = one core loop iteration (`turn.step.started` … next start). */ +export interface SubagentStepActivity { + readonly step: number; + /** Assistant text of this step, trailing window only. */ + textTail: string; + readonly toolCalls: SubToolCallActivity[]; + retrying?: string; +} + +export interface SubagentActivityRecord { + readonly agentId: string; + readonly agentName: string; + readonly description?: string; + readonly parentToolCallId: string; + model?: string; + effort?: string; + readonly steps: SubagentStepActivity[]; + /** Count of real `turn.step.started` events seen (monotonic). */ + totalSteps: number; + status: 'running' | 'completed' | 'failed'; + resultSummary?: string; + error?: string; + /** Bumped on every mutation; the viewer caches its render against this. */ + version: number; +} + +export interface SubagentActivitySpawn { + readonly agentId: string; + readonly agentName: string; + readonly description?: string; + readonly parentToolCallId: string; + readonly model?: string; + readonly effort?: string; +} + +const LIVE_OUTPUT_TAIL_CHARS = 200; + +function tail(text: string, maxChars: number): string { + return text.length <= maxChars ? text : text.slice(text.length - maxChars); +} + +/** Truncate long string argument values before they are retained — Write and + * Edit carry whole-file contents in args, which would otherwise dwarf every + * other retention cap. Only header summaries (`extractKeyArgument`) and the + * Edit/Write line chips read args, so truncation is display-safe; those + * chips simply become approximate beyond the cap. Shallow on purpose: the + * tools that matter have flat argument records. */ +function capArgStrings(args: Record<string, unknown>): Record<string, unknown> { + let capped: Record<string, unknown> | undefined; + for (const [key, value] of Object.entries(args)) { + if (typeof value !== 'string' || value.length <= SUBAGENT_ARG_STRING_MAX_CHARS) continue; + capped ??= { ...args }; + capped[key] = `${value.slice(0, SUBAGENT_ARG_STRING_MAX_CHARS)}…`; + } + return capped ?? args; +} + +export class SubagentActivityStore { + private readonly records = new Map<string, SubagentActivityRecord>(); + /** Raw streaming-arguments buffer per in-flight tool call (from deltas). */ + private readonly streamingArgs = new Map<string, string>(); + + ensureRecord(spawn: SubagentActivitySpawn): SubagentActivityRecord { + const existing = this.records.get(spawn.agentId); + if (existing !== undefined) { + // A resumed subagent re-spawns under the same id: keep the accumulated + // steps and flip the record back to running. + existing.status = 'running'; + existing.resultSummary = undefined; + existing.error = undefined; + return existing; + } + const record: SubagentActivityRecord = { + agentId: spawn.agentId, + agentName: spawn.agentName, + description: spawn.description, + parentToolCallId: spawn.parentToolCallId, + model: spawn.model, + effort: spawn.effort, + steps: [], + totalSteps: 0, + status: 'running', + version: 0, + }; + this.records.set(spawn.agentId, record); + return record; + } + + get(agentId: string): SubagentActivityRecord | undefined { + return this.records.get(agentId); + } + + agentIds(): readonly string[] { + return [...this.records.keys()]; + } + + applyEvent(event: Event): void { + switch (event.type) { + case 'turn.step.started': { + const record = this.recordFor(event.agentId); + record.steps.push({ step: event.step, textTail: '', toolCalls: [] }); + record.totalSteps += 1; + while (record.steps.length > MAX_SUBAGENT_ACTIVITY_STEPS) { + const evicted = record.steps.shift(); + if (evicted === undefined) break; + // A call truncated before started/result only ever produced deltas; + // its arg buffer is keyed by id, so evicting the only step that + // referenced it must drop the buffer entry too. + for (const call of evicted.toolCalls) { + this.streamingArgs.delete(this.streamKey(record.agentId, call.id)); + } + } + this.bump(record); + return; + } + case 'assistant.delta': { + const record = this.recordFor(event.agentId); + const step = this.currentStep(record); + step.textTail = tail(step.textTail + event.delta, SUBAGENT_STEP_TEXT_TAIL_CHARS); + this.bump(record); + return; + } + case 'tool.call.started': { + const record = this.recordFor(event.agentId); + const existing = this.findToolCall(record, event.toolCallId); + const args = capArgStrings(argsRecord(event.args)); + if (existing === undefined) { + this.currentStep(record).toolCalls.push({ + id: event.toolCallId, + name: event.name, + args, + status: 'running', + startedAt: Date.now(), + }); + } else { + // Authoritative full args arrive with the start; replace the + // best-effort record assembled from streaming deltas. + existing.name = event.name; + existing.args = args; + } + this.streamingArgs.delete(this.streamKey(event.agentId, event.toolCallId)); + this.bump(record); + return; + } + case 'tool.call.delta': { + const record = this.recordFor(event.agentId); + const key = this.streamKey(event.agentId, event.toolCallId); + // parseStreamingArgs only reads the preview window, so keep the raw + // buffer capped at the same size — an uncapped buffer would outgrow + // the store's retention caps on large Write/Edit argument streams. + const buffered = appendStreamingArgsPreview( + this.streamingArgs.get(key), + event.argumentsPart, + ); + this.streamingArgs.set(key, buffered); + let call = this.findToolCall(record, event.toolCallId); + if (call === undefined) { + call = { + id: event.toolCallId, + name: event.name ?? '', + args: {}, + status: 'running', + startedAt: Date.now(), + }; + this.currentStep(record).toolCalls.push(call); + } + if (call.name.length === 0 && event.name !== undefined) call.name = event.name; + call.args = capArgStrings(parseStreamingArgs(buffered)); + this.bump(record); + return; + } + case 'tool.progress': { + const kind = event.update.kind; + if (kind !== 'stdout' && kind !== 'stderr' && kind !== 'status') return; + const text = event.update.text; + if (text === undefined || text.trim().length === 0) return; + const record = this.records.get(event.agentId); + const call = record === undefined ? undefined : this.findToolCall(record, event.toolCallId); + if (record === undefined || call === undefined) return; + const lines = text.trimEnd().split('\n'); + call.liveOutputTail = tail(lines.at(-1) ?? '', LIVE_OUTPUT_TAIL_CHARS); + this.bump(record); + return; + } + case 'tool.result': { + const record = this.records.get(event.agentId); + const call = record === undefined ? undefined : this.findToolCall(record, event.toolCallId); + if (record === undefined || call === undefined) return; + let output = serializeToolResultOutput(event.output); + if (output.length > SUBAGENT_TOOL_OUTPUT_MAX_CHARS) { + output = `${output.slice(0, SUBAGENT_TOOL_OUTPUT_MAX_CHARS)}\n… [output truncated to ${String(SUBAGENT_TOOL_OUTPUT_MAX_CHARS)} chars]`; + } + call.result = { + tool_call_id: call.id, + output, + is_error: event.isError, + synthetic: event.synthetic, + }; + call.status = event.isError === true ? 'error' : 'done'; + call.durationMs = Date.now() - call.startedAt; + call.liveOutputTail = undefined; + this.streamingArgs.delete(this.streamKey(event.agentId, event.toolCallId)); + this.bump(record); + return; + } + case 'turn.step.retrying': { + const record = this.recordFor(event.agentId); + const step = this.currentStep(record); + step.retrying = `retrying · attempt ${String(event.nextAttempt)}/${String(event.maxAttempts)} (${event.errorName})`; + this.bump(record); + return; + } + default: + return; + } + } + + markCompleted(agentId: string, resultSummary?: string): void { + const record = this.records.get(agentId); + if (record === undefined) return; + record.status = 'completed'; + record.resultSummary = resultSummary; + this.dropStreamingBuffers(agentId); + this.bump(record); + } + + markFailed(agentId: string, error?: string): void { + const record = this.records.get(agentId); + if (record === undefined) return; + record.status = 'failed'; + record.error = error; + this.dropStreamingBuffers(agentId); + this.bump(record); + } + + clear(): void { + this.records.clear(); + this.streamingArgs.clear(); + } + + /** Drop one agent's record and its in-flight arg buffers. Used when a + * foreground-only subagent (never backgrounded, so it can never appear in + * /tasks) reaches a terminal state — its record would otherwise stay + * resident until the session reset. */ + drop(agentId: string): void { + this.records.delete(agentId); + this.dropStreamingBuffers(agentId); + } + + /** No more deltas arrive once the record is terminal, so any buffer left + * by a call truncated before started/result can be released here. */ + private dropStreamingBuffers(agentId: string): void { + const prefix = `${agentId}:`; + for (const key of this.streamingArgs.keys()) { + if (key.startsWith(prefix)) this.streamingArgs.delete(key); + } + } + + /** Get-or-create: events can arrive for agents this process never saw a + * spawn for (e.g. switching back to a session whose background agents are + * still running) — keep their activity rather than dropping it. */ + private recordFor(agentId: string): SubagentActivityRecord { + return ( + this.records.get(agentId) ?? + this.ensureRecord({ agentId, agentName: agentId, parentToolCallId: '' }) + ); + } + + /** Latest step, creating a synthetic one when content arrives ahead of any + * `turn.step.started` (same mid-flight case as `recordFor`). */ + private currentStep(record: SubagentActivityRecord): SubagentStepActivity { + let step = record.steps.at(-1); + if (step === undefined) { + step = { step: 0, textTail: '', toolCalls: [] }; + record.steps.push(step); + } + return step; + } + + private findToolCall( + record: SubagentActivityRecord, + toolCallId: string, + ): SubToolCallActivity | undefined { + for (let i = record.steps.length - 1; i >= 0; i--) { + const call = record.steps[i]!.toolCalls.find((c) => c.id === toolCallId); + if (call !== undefined) return call; + } + return undefined; + } + + private streamKey(agentId: string, toolCallId: string): string { + return `${agentId}:${toolCallId}`; + } + + private bump(record: SubagentActivityRecord): void { + record.version += 1; + } +} diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..3fb20d6efa27baf0d67d64a5ac129b676226b09f --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -0,0 +1,838 @@ +import type { + BackgroundTaskInfo, + Event, +} from '@moonshot-ai/kimi-code-sdk'; +import type { Component } from '@moonshot-ai/pi-tui'; + +import { + AgentSwarmProgressComponent, + agentSwarmDescriptionFromArgs, + agentSwarmGridHeightForTerminalRows, +} from '../components/messages/agent-swarm-progress'; +import { modelDisplayName } from '../components/dialogs/model-selector'; +import { MAIN_AGENT_ID } from '../constant/kimi-tui'; +import type { + BackgroundAgentMetadata, + BackgroundAgentStatusPhase, + ToolCallBlockData, + ToolResultBlockData, + TranscriptEntry, +} from '../types'; +import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; +import { argsRecord, serializeToolResultOutput } from '../utils/event-payload'; +import { formatHookResultPlain } from '../utils/hook-result-format'; +import { nextTranscriptId } from '../utils/transcript-id'; +import type { SessionEventHost } from './session-event-handler'; +import { SubagentActivityStore } from './subagent-activity-store'; + +export interface SubagentInfo { + readonly parentToolCallId: string; + readonly name: string; + readonly runInBackground: boolean; + readonly swarmIndex?: number; +} + +export type SubagentLifecycleEvent = Event & { type: `subagent.${string}` }; +type SubagentLifecycleEventOf<Type extends SubagentLifecycleEvent['type']> = + SubagentLifecycleEvent & { type: Type }; + +export interface SubAgentEventHandlerDependencies { + readonly backgroundTasks: ReadonlyMap<string, BackgroundTaskInfo>; + readonly backgroundTaskTranscriptedTerminal: Set<string>; + readonly syncBackgroundAgentBadge: () => void; +} + +function renderedRowsAfterChild( + children: readonly Component[], + child: Component, + width: number, +): number { + const childIndex = children.indexOf(child); + if (childIndex < 0) return 0; + return children + .slice(childIndex + 1) + .reduce((sum, component) => sum + component.render(width).length, 0); +} + +export class SubAgentEventHandler { + readonly subagentInfo: Map<string, SubagentInfo> = new Map(); + private readonly agentSwarmProgress: Map<string, AgentSwarmProgressComponent> = new Map(); + backgroundAgentMetadata: Map<string, BackgroundAgentMetadata> = new Map(); + /** Bounded per-agent activity fold feeding the background-agent detail view. */ + readonly activityStore = new SubagentActivityStore(); + + constructor( + private readonly host: SessionEventHost, + private readonly deps: SubAgentEventHandlerDependencies, + ) {} + + resetRuntimeState(): void { + this.subagentInfo.clear(); + this.backgroundAgentMetadata.clear(); + this.activityStore.clear(); + this.clearAgentSwarmProgress(); + } + + routeChildAgentEvent(event: Event): boolean { + if (isSubagentLifecycleEvent(event)) return false; + + const childAgentId = event.agentId; + if (childAgentId === MAIN_AGENT_ID) return false; + if (this.host.btwPanelController.routeEvent(event)) return true; + + // Tee every child-agent event into the activity store before the routing + // below swallows events whose parent card is gone (Ctrl+B) or never + // existed (run_in_background) — that data is the background detail view. + this.activityStore.applyEvent(event); + + const info = this.subagentInfo.get(childAgentId); + if (info === undefined || info.parentToolCallId.length === 0) return true; + + const { parentToolCallId } = info; + const swarmProgress = this.agentSwarmProgress.get(parentToolCallId); + if (swarmProgress !== undefined) { + // No per-event requestRender: the swarm component's own frame timer + // (kept alive while members run) batches these deltas into ~12.5fps + // re-renders instead of rendering the whole tree per delta. + this.applySubagentEventToSwarmProgress(swarmProgress, event, childAgentId); + return true; + } + + const toolCall = this.host.streamingUI.getToolComponent(parentToolCallId); + if (toolCall === undefined) return true; + toolCall.setSubagentMeta(childAgentId, info.name); + + if (event.type === 'hook.result') { + toolCall.appendSubagentText(formatHookResultPlain(event), 'text'); + } else if (event.type === 'assistant.delta') { + toolCall.appendSubagentText(event.delta, 'text'); + } else if (event.type === 'thinking.delta') { + toolCall.appendSubagentText(event.delta, 'thinking'); + } else if (event.type === 'tool.call.started') { + toolCall.appendSubToolCall({ + id: `${childAgentId}:${event.toolCallId}`, + name: event.name, + args: argsRecord(event.args), + }); + } else if (event.type === 'tool.call.delta') { + toolCall.appendSubToolCallDelta({ + id: `${childAgentId}:${event.toolCallId}`, + name: event.name, + argumentsPart: event.argumentsPart ?? null, + }); + } else if ( + event.type === 'tool.progress' && + (event.update.kind === 'stdout' || + event.update.kind === 'stderr' || + event.update.kind === 'status') && + event.update.text !== undefined + ) { + toolCall.appendSubToolLiveOutput( + `${childAgentId}:${event.toolCallId}`, + event.update.text, + { replace: event.update.replace === true }, + ); + } else if (event.type === 'tool.result') { + toolCall.finishSubToolCall({ + tool_call_id: `${childAgentId}:${event.toolCallId}`, + output: serializeToolResultOutput(event.output), + is_error: event.isError, + }); + } else if (event.type === 'agent.status.updated') { + const usageObj = event.usage; + const totalUsage = usageObj?.total ?? usageObj?.currentTurn; + toolCall.updateSubagentMetrics({ + contextTokens: event.contextTokens, + usage: totalUsage, + // The bound model alias rides every child status update (emitted right + // after spawn); surface it on the subagent card. `modelDisplayName` + // falls back to the alias itself when the entry is unknown. + modelDisplay: + event.model === undefined + ? undefined + : modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]), + effortDisplay: this.subagentEffortDisplay(event.thinkingEffort), + }); + } + return true; + } + + handleLifecycleEvent(event: SubagentLifecycleEvent): void { + switch (event.type) { + case 'subagent.spawned': + this.handleSubagentSpawned(event); + return; + case 'subagent.started': + this.handleSubagentStarted(event); + return; + case 'subagent.suspended': + this.handleSubagentSuspended(event); + return; + case 'subagent.completed': + this.handleSubagentCompleted(event); + return; + case 'subagent.failed': + this.handleSubagentFailed(event); + return; + case 'subagent.cancelled': + this.handleSubagentCancelled(event); + return; + } + } + + clearAgentSwarmProgress(): void { + for (const progress of this.agentSwarmProgress.values()) { + progress.dispose(); + } + this.agentSwarmProgress.clear(); + this.host.updateActivityPane(); + } + + hasAgentSwarmProgress(toolCallId: string): boolean { + return this.agentSwarmProgress.has(toolCallId); + } + + hasActiveAgentSwarmToolCall(): boolean { + return Array.from(this.agentSwarmProgress.values()).some((progress) => + progress.isToolCallActive() + ); + } + + syncAgentSwarmActivitySpinner( + spinner: { renderInline(): string } | undefined, + ): void { + for (const progress of this.agentSwarmProgress.values()) { + progress.setActivitySpinnerText( + spinner === undefined ? undefined : () => spinner.renderInline(), + ); + } + } + + handleAgentSwarmToolCallStarted( + toolCallId: string, + args: Record<string, unknown>, + ): void { + const progress = this.ensureAgentSwarmProgress(toolCallId, args); + progress.markInputComplete(); + this.requestRender(); + } + + handleAgentSwarmToolCallDelta( + toolCallId: string, + args: Record<string, unknown>, + options: { readonly streamingArguments?: string | undefined }, + ): void { + this.ensureAgentSwarmProgress(toolCallId, args, options); + this.requestRender(); + } + + handleAgentSwarmToolResult( + toolCallId: string, + resultData: ToolResultBlockData, + isError: boolean, + ): void { + const progress = this.agentSwarmProgress.get(toolCallId); + if (progress === undefined) return; + + if (isError && isUserCancelledSubagentError(resultData.output)) { + if (progress.isRequestStreaming()) { + this.removeAgentSwarmProgress(toolCallId, progress); + } else { + progress.markToolCallEnded(); + progress.markActiveCancelled(); + } + } else if (isError) { + progress.markToolCallEnded(); + if (!progress.applyResult(resultData.output)) { + progress.markSwarmFailed(resultData.output); + } + } else { + progress.markToolCallEnded(); + progress.applyResult(resultData.output); + } + this.host.updateActivityPane(); + this.requestRender(); + } + + markActiveAgentSwarmsCancelled(): void { + let updated = false; + for (const [toolCallId, progress] of this.agentSwarmProgress) { + if (progress.isRequestStreaming()) { + this.removeAgentSwarmProgress(toolCallId, progress); + updated = true; + continue; + } + progress.markActiveCancelled(); + updated = true; + } + if (updated) this.requestRender(); + } + + private handleSubagentSpawned( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): void { + this.rememberSubagent(event); + + if (event.runInBackground) { + const meta = this.buildBackgroundAgentMetadata(event); + this.backgroundAgentMetadata.set(event.subagentId, meta); + this.appendBackgroundAgentEntry('started', meta); + this.deps.syncBackgroundAgentBadge(); + return; + } + + this.handleForegroundSubagentSpawned(event); + } + + private handleSubagentStarted( + event: SubagentLifecycleEventOf<'subagent.started'>, + ): void { + const info = this.subagentInfo.get(event.subagentId); + if (info === undefined) return; + if (!info.runInBackground) this.handleForegroundSubagentStarted(event, info); + } + + private handleSubagentSuspended( + event: SubagentLifecycleEventOf<'subagent.suspended'>, + ): void { + const info = this.subagentInfo.get(event.subagentId); + if (info === undefined) return; + if (!info.runInBackground) this.handleForegroundSubagentSuspended(event, info); + } + + private handleSubagentCompleted( + event: SubagentLifecycleEventOf<'subagent.completed'>, + ): void { + this.activityStore.markCompleted(event.subagentId, event.resultSummary); + this.pruneForegroundOnlyRecord(event.subagentId); + const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); + if (backgroundMeta !== undefined) { + const taskId = this.findAgentTaskId( + event.subagentId, + backgroundMeta, + this.deps.backgroundTasks, + ); + this.backgroundAgentMetadata.delete(event.subagentId); + this.deps.syncBackgroundAgentBadge(); + if (taskId !== undefined && this.deps.backgroundTaskTranscriptedTerminal.has(taskId)) { + return; + } + if (taskId !== undefined) { + this.deps.backgroundTaskTranscriptedTerminal.add(taskId); + } + const extras = + event.resultSummary === undefined ? undefined : { resultSummary: event.resultSummary }; + this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); + return; + } + + const info = this.subagentInfo.get(event.subagentId); + if (info === undefined || info.runInBackground) return; + this.handleForegroundSubagentCompleted(event, info); + } + + private handleSubagentFailed( + event: SubagentLifecycleEventOf<'subagent.failed'>, + ): void { + this.activityStore.markFailed(event.subagentId, event.error); + this.pruneForegroundOnlyRecord(event.subagentId); + const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); + if (backgroundMeta !== undefined) { + const taskId = this.findAgentTaskId( + event.subagentId, + backgroundMeta, + this.deps.backgroundTasks, + ); + const task = taskId === undefined ? undefined : this.deps.backgroundTasks.get(taskId); + this.backgroundAgentMetadata.delete(event.subagentId); + this.deps.syncBackgroundAgentBadge(); + if (task?.kind === 'agent' && task.status === 'timed_out') { + return; + } + this.host.streamingUI.applyBackgroundTaskTerminalStatus({ + agentId: event.subagentId, + description: backgroundMeta.description ?? '', + status: 'failed', + errorText: event.error, + }); + if (taskId !== undefined && this.deps.backgroundTaskTranscriptedTerminal.has(taskId)) { + return; + } + if (taskId !== undefined) { + this.deps.backgroundTaskTranscriptedTerminal.add(taskId); + } + this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); + return; + } + + const info = this.subagentInfo.get(event.subagentId); + if (info === undefined || info.runInBackground) return; + this.handleForegroundSubagentFailed(event, info); + } + + private handleSubagentCancelled( + event: SubagentLifecycleEventOf<'subagent.cancelled'>, + ): void { + this.activityStore.markFailed(event.subagentId); + this.pruneForegroundOnlyRecord(event.subagentId); + const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); + if (backgroundMeta !== undefined) { + const taskId = this.findAgentTaskId( + event.subagentId, + backgroundMeta, + this.deps.backgroundTasks, + ); + this.backgroundAgentMetadata.delete(event.subagentId); + this.deps.syncBackgroundAgentBadge(); + this.host.streamingUI.applyBackgroundTaskTerminalStatus({ + agentId: event.subagentId, + description: backgroundMeta.description ?? '', + status: 'killed', + }); + if (taskId !== undefined && this.deps.backgroundTaskTranscriptedTerminal.has(taskId)) { + return; + } + if (taskId !== undefined) { + this.deps.backgroundTaskTranscriptedTerminal.add(taskId); + } + this.appendBackgroundAgentEntry('killed', backgroundMeta); + return; + } + + const info = this.subagentInfo.get(event.subagentId); + if (info === undefined || info.runInBackground) return; + this.handleForegroundSubagentCancelled(event, info); + } + + private findAgentTaskId( + subagentId: string, + meta: BackgroundAgentMetadata, + backgroundTasks: ReadonlyMap<string, BackgroundTaskInfo>, + ): string | undefined { + for (const info of backgroundTasks.values()) { + if (info.kind !== 'agent') continue; + if (info.agentId === subagentId) return info.taskId; + } + const description = meta.description ?? meta.agentName; + if (description === undefined) return undefined; + let match: string | undefined; + for (const info of backgroundTasks.values()) { + if (info.kind !== 'agent') continue; + if (info.description !== description) continue; + if (match !== undefined) return undefined; + match = info.taskId; + } + return match; + } + + /** A subagent that never became a background task (foreground-only) can + * never appear in /tasks, so its activity record is dropped at terminal + * state — otherwise records would pile up for the rest of the session. */ + private pruneForegroundOnlyRecord(subagentId: string): void { + // A spawn-time background agent keeps its record even when the + // background.task.started sync has not landed yet (short-lived agents). + if (this.backgroundAgentMetadata.has(subagentId)) return; + for (const info of this.deps.backgroundTasks.values()) { + if (info.kind === 'agent' && info.agentId === subagentId) return; + } + this.activityStore.drop(subagentId); + } + + /** Drop every foreground-only record. Called when the main turn ends: any + * foreground subagent of the turn is over at that point, and an aborted + * one emits no `subagent.completed`/`subagent.failed` to prune it. */ + dropForegroundOnlyActivityRecords(): void { + for (const agentId of this.activityStore.agentIds()) { + this.pruneForegroundOnlyRecord(agentId); + } + } + + private buildBackgroundAgentMetadata( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): BackgroundAgentMetadata { + const parent = this.host.streamingUI.getActiveToolCall(event.parentToolCallId); + const description = parent?.args['description'] ?? event.description; + return { + agentId: event.subagentId, + parentToolCallId: event.parentToolCallId, + agentName: event.subagentName, + description: typeof description === 'string' ? description : undefined, + model: this.spawnedModelDisplay(event), + effort: this.subagentEffortDisplay(event.thinkingEffort), + }; + } + + private appendBackgroundAgentEntry( + phase: BackgroundAgentStatusPhase, + meta: BackgroundAgentMetadata, + extras: { resultSummary?: string; error?: string } | undefined = undefined, + ): void { + const status = formatBackgroundAgentTranscript(phase, meta, extras); + const entry: TranscriptEntry = { + id: nextTranscriptId(), + kind: 'status', + turnId: this.host.streamingUI.getTurnContext().turnId, + renderMode: 'plain', + content: status.headline, + detail: status.detail, + backgroundAgentStatus: status, + }; + this.host.appendTranscriptEntry(entry); + } + + private rememberSubagent( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): void { + this.subagentInfo.set(event.subagentId, { + parentToolCallId: event.parentToolCallId, + name: event.subagentName, + runInBackground: event.runInBackground, + swarmIndex: event.swarmIndex, + }); + this.activityStore.ensureRecord({ + agentId: event.subagentId, + agentName: event.subagentName, + description: event.description, + parentToolCallId: event.parentToolCallId, + model: this.spawnedModelDisplay(event), + effort: this.subagentEffortDisplay(event.thinkingEffort), + }); + } + + private handleForegroundSubagentSpawned( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): void { + // The spawned event carries the display-normalized bound alias (newer + // cores) — show it at spawn instead of waiting for the child's first + // status frame. The `agent.status.updated` channel below stays as the + // in-run update/fallback path. + const modelDisplay = this.spawnedModelDisplay(event); + const effortDisplay = this.subagentEffortDisplay(event.thinkingEffort); + if (this.updateAgentSwarmProgress(event.parentToolCallId, (progress) => { + progress.registerSubagent({ + agentId: event.subagentId, + swarmIndex: event.swarmIndex, + }); + if (modelDisplay !== undefined) progress.setModelDisplay(modelDisplay); + if (effortDisplay !== undefined) progress.setEffortDisplay(effortDisplay); + })) { + return; + } + + let tc = this.getOrActivateToolComponent(event.parentToolCallId); + tc ??= this.createStandaloneSubagentToolCall(event); + if (tc === undefined) return; + tc.onSubagentSpawned({ + agentId: event.subagentId, + agentName: event.subagentName, + runInBackground: event.runInBackground, + }); + if (modelDisplay !== undefined || effortDisplay !== undefined) { + tc.updateSubagentMetrics({ modelDisplay, effortDisplay }); + } + } + + /** Map the spawned event's bound alias to a display name via the loaded + * model catalog; falls back to the alias itself for unknown entries. */ + private spawnedModelDisplay( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): string | undefined { + if (event.model === undefined) return undefined; + return modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]); + } + + /** Concrete effort levels are always shown; the boolean states carry no + * level information — 'off' (no thinking) and 'on' (generic thinking) are + * both hidden. */ + private subagentEffortDisplay(effort: string | undefined): string | undefined { + if (effort === undefined || effort === 'off' || effort === 'on') return undefined; + return effort; + } + + private handleForegroundSubagentStarted( + event: SubagentLifecycleEventOf<'subagent.started'>, + info: SubagentInfo, + ): void { + if (this.updateAgentSwarmProgress(info.parentToolCallId, (progress) => { + progress.markStarted(event.subagentId); + })) { + return; + } + + const tc = this.getOrActivateToolComponent(info.parentToolCallId); + if (tc === undefined) return; + tc.onSubagentStarted({ + agentId: event.subagentId, + agentName: info.name, + runInBackground: info.runInBackground, + }); + } + + private handleForegroundSubagentSuspended( + event: SubagentLifecycleEventOf<'subagent.suspended'>, + info: SubagentInfo, + ): void { + this.updateAgentSwarmProgress(info.parentToolCallId, (progress) => { + progress.markSuspended({ + agentId: event.subagentId, + reason: event.reason, + swarmIndex: info.swarmIndex, + }); + }); + } + + private handleForegroundSubagentCompleted( + event: SubagentLifecycleEventOf<'subagent.completed'>, + info: SubagentInfo, + ): void { + const { parentToolCallId } = info; + if (this.updateAgentSwarmProgress(parentToolCallId, (progress) => { + progress.markCompleted(event.subagentId, event.resultSummary); + })) { + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + return; + } + + const tc = this.host.streamingUI.getToolComponent(parentToolCallId); + if (tc === undefined) return; + tc.onSubagentCompleted({ + contextTokens: event.contextTokens, + usage: event.usage, + resultSummary: event.resultSummary, + }); + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + } + + private handleForegroundSubagentFailed( + event: SubagentLifecycleEventOf<'subagent.failed'>, + info: SubagentInfo, + ): void { + const { parentToolCallId } = info; + if (this.updateAgentSwarmProgress(parentToolCallId, (progress) => { + this.markAgentSwarmFailedOrCancelled(progress, event.subagentId, event.error); + })) { + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + return; + } + + const tc = this.host.streamingUI.getToolComponent(parentToolCallId); + if (tc === undefined) return; + tc.onSubagentFailed({ error: event.error }); + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + } + + private handleForegroundSubagentCancelled( + event: SubagentLifecycleEventOf<'subagent.cancelled'>, + info: SubagentInfo, + ): void { + const { parentToolCallId } = info; + if (this.updateAgentSwarmProgress(parentToolCallId, (progress) => { + progress.markCancelled(event.subagentId); + })) { + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + return; + } + + const tc = this.host.streamingUI.getToolComponent(parentToolCallId); + if (tc === undefined) return; + tc.onSubagentFailed({ error: 'Aborted by the user' }); + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + } + + private applySubagentEventToSwarmProgress( + progress: AgentSwarmProgressComponent, + event: Event, + subagentId: string, + ): void { + if (event.type === 'assistant.delta' || event.type === 'thinking.delta') { + progress.appendModelDelta({ agentId: subagentId, delta: event.delta }); + } else if (event.type === 'tool.call.started') { + progress.recordToolCall({ agentId: subagentId, toolCallId: event.toolCallId }); + } else if (event.type === 'agent.status.updated' && event.model !== undefined) { + // The bound model alias rides every child status update (emitted right + // after spawn). Swarm members share one binding, so the panel shows it + // once in the header instead of per cell. `modelDisplayName` falls back + // to the alias itself when the entry is unknown. + progress.setModelDisplay( + modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]), + ); + const effortDisplay = this.subagentEffortDisplay(event.thinkingEffort); + if (effortDisplay !== undefined) progress.setEffortDisplay(effortDisplay); + } + } + + private updateAgentSwarmProgress( + parentToolCallId: string, + update: (progress: AgentSwarmProgressComponent) => void, + ): boolean { + const progress = this.agentSwarmProgress.get(parentToolCallId); + if (progress === undefined) return false; + update(progress); + this.requestRender(); + return true; + } + + private ensureAgentSwarmProgress( + toolCallId: string, + args: Record<string, unknown>, + options: { readonly streamingArguments?: string | undefined } = {}, + ): AgentSwarmProgressComponent { + const existing = this.agentSwarmProgress.get(toolCallId); + if (existing !== undefined) { + existing.updateArgs(args, options); + return existing; + } + + const progress = new AgentSwarmProgressComponent({ + description: agentSwarmDescriptionFromArgs(args), + availableGridHeight: () => this.agentSwarmGridHeight(), + requestRender: () => { + this.requestRender(); + }, + }); + progress.updateArgs(args, options); + this.agentSwarmProgress.set(toolCallId, progress); + this.host.streamingUI.finalizeLiveTextBuffers('tool'); + this.host.state.transcriptContainer.addChild(progress); + this.host.updateActivityPane(); + this.requestRender(); + return progress; + } + + private removeAgentSwarmProgress( + toolCallId: string, + progress: AgentSwarmProgressComponent, + ): void { + this.agentSwarmProgress.delete(toolCallId); + progress.dispose(); + const children = this.host.state.transcriptContainer.children; + const index = children.indexOf(progress); + if (index >= 0) { + // Structural removal only: GutterContainer's ref-checked render cache + // detects the child-list change; no tree-wide invalidate needed. + children.splice(index, 1); + } + this.host.updateActivityPane(); + } + + private agentSwarmGridHeightFrame: + | { readonly columns: number; readonly rows: number; readonly value: number | undefined } + | undefined; + + /** + * The measurement re-renders every dock child, so it is shared by every + * swarm component for the rest of the current synchronous render pass + * (frames are macrotask-separated, hence the microtask reset) instead of + * being recomputed per component per frame. + */ + private agentSwarmGridHeight(): number | undefined { + const { state } = this.host; + const terminalRows = state.ui.terminal.rows; + const terminalColumns = state.ui.terminal.columns; + const frame = this.agentSwarmGridHeightFrame; + if ( + frame !== undefined && + frame.columns === terminalColumns && + frame.rows === terminalRows + ) { + return frame.value; + } + const entry = { + columns: terminalColumns, + rows: terminalRows, + value: this.measureAgentSwarmGridHeight(), + }; + this.agentSwarmGridHeightFrame = entry; + queueMicrotask(() => { + if (this.agentSwarmGridHeightFrame === entry) this.agentSwarmGridHeightFrame = undefined; + }); + return entry.value; + } + + private measureAgentSwarmGridHeight(): number | undefined { + const { state } = this.host; + const terminalRows = state.ui.terminal.rows; + const terminalColumns = state.ui.terminal.columns; + if (!Number.isFinite(terminalColumns) || terminalColumns <= 0) { + return agentSwarmGridHeightForTerminalRows(terminalRows); + } + + const width = Math.floor(terminalColumns); + const dock = state.dockContainer; + // Fullscreen: the root children are empty (layout root holds a ScrollView + + // dock); the chrome below the transcript is the dock's children instead. + const rowsAfterSwarm = renderedRowsAfterChild( + dock !== undefined ? [state.transcriptContainer, ...dock.children] : state.ui.children, + state.transcriptContainer, + width, + ); + return agentSwarmGridHeightForTerminalRows(terminalRows, rowsAfterSwarm); + } + + private markAgentSwarmFailedOrCancelled( + progress: AgentSwarmProgressComponent, + subagentId: string, + error: string, + ): void { + if (isUserCancelledSubagentError(error)) { + progress.markCancelled(subagentId); + } else { + progress.markFailed(subagentId, error); + } + } + + private getOrActivateToolComponent(parentToolCallId: string) { + let component = this.host.streamingUI.getToolComponent(parentToolCallId); + if (component !== undefined) return component; + const toolCall = this.host.streamingUI.getActiveToolCall(parentToolCallId); + if (toolCall === undefined) return undefined; + this.host.streamingUI.onToolCallStart(toolCall); + return this.host.streamingUI.getToolComponent(parentToolCallId); + } + + private createStandaloneSubagentToolCall( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ) { + const description = event.description ?? `Run ${event.subagentName} agent`; + const { turnId, step } = this.host.streamingUI.getTurnContext(); + const toolCall: ToolCallBlockData = { + id: event.parentToolCallId, + name: 'Agent', + args: { + description, + subagent_type: event.subagentName, + }, + description, + step, + turnId, + }; + this.host.streamingUI.onToolCallStart(toolCall); + return this.host.streamingUI.getToolComponent(event.parentToolCallId); + } + + private requestRender(): void { + this.host.state.ui.requestRender(); + } +} + +function isSubagentLifecycleEvent(event: Event): event is SubagentLifecycleEvent { + return ( + event.type === 'subagent.spawned' || + event.type === 'subagent.started' || + event.type === 'subagent.suspended' || + event.type === 'subagent.completed' || + event.type === 'subagent.failed' || + event.type === 'subagent.cancelled' + ); +} + +function isUserCancelledSubagentError(error: string): boolean { + // Structured AgentSwarm results use outcome="aborted" and are parsed separately. + switch (error.trim()) { + case 'Aborted by the user': + case 'The user manually interrupted this subagent batch.': + return true; + default: + return false; + } +} diff --git a/apps/kimi-code/src/tui/controllers/survey-controller.ts b/apps/kimi-code/src/tui/controllers/survey-controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..510ee13d5bd600719aa91a20bcaace216f5c78e0 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/survey-controller.ts @@ -0,0 +1,692 @@ +import { randomUUID } from 'node:crypto'; + +import { isManagedKimiCodeBaseUrl } from '@moonshot-ai/kimi-code-oauth'; +import { isTelemetryDisabledByEnv } from '@moonshot-ai/kimi-telemetry'; +import { Key, matchesKey, Spacer } from '@moonshot-ai/pi-tui'; + +import { + getSurveyPopupConfig, + peekSurveyPopupConfig, + peekSurveyPopupConfigFresh, + type SurveyPopupConfig, +} from '#/utils/survey-popup-config'; +import { readSurveyLastShownTime, writeSurveyLastShownTime } from '#/utils/survey-state-store'; +import { currentKimiRegion } from '#/utils/region'; + +import { SurveyPanelComponent, type SurveyPanelView } from '../components/panes/survey-panel'; +import { CHROME_GUTTER } from '../constant/rendering'; +import { printableChar } from '../utils/printable-key'; +import { + SURVEY_DIGIT_DEBOUNCE_MS, + SURVEY_IDLE_EVALUATION_DELAY_MS, + SURVEY_MOUNT_PROTECTION_MS, + SURVEY_PENDING_UNDO_WINDOW_MS, + SURVEY_ORDERED_LIST_START, + SURVEY_SINGLE_OPTION_DIGIT, + SURVEY_OPTION_COUNT, + SURVEY_DISMISS_OPTION_INDEX, + SURVEY_DIGIT_RESPONSES, + SURVEY_MIN_OPTIONS_WIDTH, + surveyMinTotalHeight, + SURVEY_CONFIG_REFRESH_INTERVAL_MS, + SURVEY_THANKS_DURATION_MS, +} from '../constant/survey'; +import type { TUIState } from '../tui-state'; +import { + buildSurveyEventProperties, + evaluateSurveyGate, + SURVEY_EVENT_NAMES, + SURVEY_MACHINE_CLOSED, + surveyMachineReduce, + type LongContextArmGateInput, + type SessionArmGateInput, + type SharedArmGateInput, + type SurveyAppearance, + type SurveyEventEnvironmentFields, + type SurveyKind, + type SurveyMachineAction, + type SurveyMachineEffect, + type SurveyMachineState, +} from '../utils/survey-policy'; +import type { BtwPanelController } from './btw-panel'; + +export interface SurveyHost { + readonly state: TUIState; + readonly btwPanelController: BtwPanelController; + track(event: string, props?: Record<string, unknown>): void; +} + +function resolveKfcModelId(appState: TUIState['appState']): string | undefined { + const entry = appState.availableModels[appState.model]; + if (entry === undefined) return undefined; + const baseUrl = entry.baseUrl ?? appState.availableProviders[entry.provider]?.baseUrl; + if (!isManagedKimiCodeBaseUrl(baseUrl)) return undefined; + return entry.model; +} + +export interface SurveyControllerDeps { + readonly config?: () => SurveyPopupConfig; + readonly monotonicNow?: () => number; + readonly wallNow?: () => number; + readonly random?: () => number; + readonly appearanceId?: () => string; + readonly setTimer?: (fn: () => void, ms: number) => unknown; + readonly clearTimer?: (handle: unknown) => void; + readonly telemetryDisabled?: () => boolean; + readonly feedbackSurveyDisabled?: () => boolean; + readonly refreshConfig?: () => unknown; + readonly terminalHeight?: () => number; + readonly configFresh?: () => boolean; + readonly terminalWidth?: () => number; + readonly configRegion?: () => string; + readonly accessToken?: () => Promise<string | undefined>; + readonly readGlobalLastShown?: () => Promise<number | undefined>; + readonly writeGlobalLastShown?: (wallTime: number) => void; +} + +const defaultDeps = { + monotonicNow: () => performance.now(), + wallNow: () => Date.now(), + random: () => Math.random(), + appearanceId: () => randomUUID(), + setTimer: (fn: () => void, ms: number) => setTimeout(fn, ms), + clearTimer: (handle: unknown) => { + clearTimeout(handle as Parameters<typeof clearTimeout>[0]); + }, + telemetryDisabled: () => isTelemetryDisabledByEnv(), + config: () => peekSurveyPopupConfig(), + configFresh: () => peekSurveyPopupConfigFresh(), + terminalHeight: () => process.stdout.rows, + configRegion: () => currentKimiRegion(), + terminalWidth: () => process.stdout.columns, + readGlobalLastShown: readSurveyLastShownTime, + writeGlobalLastShown: writeSurveyLastShownTime, +} satisfies Omit< + Required<SurveyControllerDeps>, + 'feedbackSurveyDisabled' | 'refreshConfig' | 'accessToken' +>; + +export class SurveyController { + private machine: SurveyMachineState = SURVEY_MACHINE_CLOSED; + private readonly view: SurveyPanelView = { phase: 'open' }; + private mounted = false; + private mountedAt: number; + private userTurnCount = 0; + private lastShownAt: number | undefined; + private userTurnsAtLastShown: number | undefined; + private appearanceCount = 0; + private globalLastShownAt: number | undefined; + private longContextRollConsumed = false; + private generation = 0; + private idleSince: number | undefined; + private openedAt = 0; + private stickySample: { readonly turnCount: number; readonly value: number } | undefined; + private lastTypedDigit: string | undefined; + private openedEditorText: string | undefined; + private idleTimer: unknown; + private digitTimer: unknown; + private phaseTimer: unknown; + private toolCallCount = 0; + private compactionCount = 0; + private currentTurnUserOrigin: boolean | undefined; + private evaluationPending = false; + private appearanceConfig: SurveyPopupConfig | undefined; + private configReady = false; + private cooldownReady = false; + private configRefreshedAt = 0; + private coldRefreshAttemptedAt = 0; + private configRegion: string | undefined; + + constructor( + private readonly host: SurveyHost, + private readonly deps: SurveyControllerDeps = {}, + ) { + this.mountedAt = this.now(); + this.reset(); + this.refreshConfig(); + this.coldRefreshAttemptedAt = this.now(); + } + + reset(): void { + this.generation += 1; + this.clearIdleTimer(); + this.clearDigitTimer(); + this.clearPhaseTimer(); + this.applyClose(); + this.machine = SURVEY_MACHINE_CLOSED; + this.mountedAt = this.now(); + this.userTurnCount = 0; + this.lastShownAt = undefined; + this.userTurnsAtLastShown = undefined; + this.appearanceCount = 0; + this.longContextRollConsumed = false; + this.idleSince = undefined; + this.stickySample = undefined; + this.toolCallCount = 0; + this.compactionCount = 0; + this.appearanceConfig = undefined; + this.currentTurnUserOrigin = undefined; + this.evaluationPending = false; + const generation = this.generation; + this.cooldownReady = false; + void (this.deps.readGlobalLastShown ?? defaultDeps.readGlobalLastShown)() + .then((lastShown) => { + if (this.generation !== generation) return; + if (lastShown !== undefined) { + this.globalLastShownAt = Math.max(lastShown, this.globalLastShownAt ?? 0); + } + this.cooldownReady = true; + }) + .catch(() => { + if (this.generation === generation) this.cooldownReady = true; + }); + } + + dispose(): void { + this.generation += 1; + this.clearIdleTimer(); + this.clearDigitTimer(); + this.clearPhaseTimer(); + this.applyClose(); + } + + notifyTurnStarted(userOrigin: boolean): void { + this.currentTurnUserOrigin = userOrigin; + this.idleSince = undefined; + this.clearIdleTimer(); + if (this.machine.phase !== 'closed') this.applyAction({ type: 'close-silently' }); + if (userOrigin) { + this.userTurnCount += 1; + this.evaluationPending = false; + } + } + + notifyTurnEnded(): void { + if (this.currentTurnUserOrigin === true) this.evaluationPending = true; + this.currentTurnUserOrigin = undefined; + if (!this.evaluationPending) return; + this.idleSince = this.now(); + this.clearIdleTimer(); + this.idleTimer = this.setT(() => { + this.idleTimer = undefined; + this.evaluationPending = false; + this.evaluate(); + }, SURVEY_IDLE_EVALUATION_DELAY_MS); + } + + notifyToolCallStarted(): void { + this.toolCallCount += 1; + } + + notifyCompactionFinished(): void { + this.compactionCount += 1; + } + + notifyInputModeChanged(mode: 'prompt' | 'bash'): void { + if (mode !== 'bash') return; + if (this.machine.phase === 'closed') return; + if (this.machine.phase === 'open') { + this.applyAction({ type: 'abandon' }); + return; + } + this.applyAction({ type: 'close-silently' }); + } + + closeSilently(): void { + if (this.machine.phase === 'closed') return; + this.applyAction({ type: 'close-silently' }); + } + + handlePreInput(data: string): boolean { + const phase = this.machine.phase; + if (phase === 'closed') return false; + if (this.inMountProtection()) { + const printable = printableChar(data); + if (SURVEY_SINGLE_OPTION_DIGIT.test(printable)) { + this.lastTypedDigit = printable; + } + return false; + } + if (this.host.state.editor.hasAutocompleteActivity()) return false; + if (matchesKey(data, Key.escape)) { + switch (phase) { + case 'open': + if (this.tooNarrow() || this.tooShort()) return false; + this.applyAction({ type: 'dismiss' }); + return true; + case 'pending': + this.applyAction({ type: 'undo' }); + return true; + case 'thanks': + this.applyAction({ type: 'close-silently' }); + return true; + } + } + if (this.tooNarrow()) return false; + if (this.tooShort()) return false; + if (phase !== 'open') return false; + const editor = this.host.state.editor; + const empty = editor.getText().length === 0; + const printable = printableChar(data); + if (SURVEY_SINGLE_OPTION_DIGIT.test(printable)) { + this.lastTypedDigit = printable; + if (!empty) this.clearDigitTimer(); + return false; + } + this.lastTypedDigit = undefined; + if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) { + if (!empty) this.clearDigitTimer(); + return false; + } + if (!empty) { + this.clearDigitTimer(); + return false; + } + if (matchesKey(data, Key.left)) { + this.moveHover(-1); + return true; + } + if (matchesKey(data, Key.right)) { + this.moveHover(1); + return true; + } + return false; + } + + handleEditorChange(text: string): void { + if (this.machine.phase !== 'open') return; + const wasTyped = text === this.lastTypedDigit; + this.lastTypedDigit = undefined; + if (this.inMountProtection()) { + if (text.length === 0 || wasTyped) return; + this.applyAction({ type: 'abandon' }); + return; + } + if (this.openedEditorText !== undefined) { + if (text.length > 0 && text !== this.openedEditorText) { + this.openedEditorText = undefined; + } else if (text === this.openedEditorText && !wasTyped) { + this.clearDigitTimer(); + return; + } + } + const bashMode = this.host.state.editor.inputMode === 'bash'; + if (text.length === 0) { + this.clearDigitTimer(); + return; + } + if (!bashMode && SURVEY_SINGLE_OPTION_DIGIT.test(text)) { + if (!wasTyped) { + this.applyAction({ type: 'abandon' }); + return; + } + this.clearDigitTimer(); + this.digitTimer = this.setT(() => { + this.digitTimer = undefined; + if (this.tooNarrow() || this.tooShort()) return; + this.chooseDigit(text); + }, SURVEY_DIGIT_DEBOUNCE_MS); + return; + } + this.applyAction({ type: 'abandon' }); + } + + handleSubmit(text: string): boolean { + if (this.machine.phase !== 'open') return false; + if (this.inMountProtection()) return false; + if (this.tooNarrow()) return false; + if (this.tooShort()) return false; + if (this.host.state.editor.inputMode === 'bash') { + this.applyAction({ type: 'abandon' }); + return false; + } + if (SURVEY_SINGLE_OPTION_DIGIT.test(text) && text !== this.openedEditorText) { + this.chooseDigit(text); + return true; + } + if (text.trim().length === 0 && this.view.hoverIndex !== undefined) { + this.chooseHovered(); + return true; + } + if (text.trim().length > 0) { + this.applyAction({ type: 'abandon' }); + } + return false; + } + + private evaluate(): void { + if (this.machine.phase !== 'closed') return; + if (!this.configReady) return; + if (!this.cooldownReady) return; + const region = (this.deps.configRegion ?? defaultDeps.configRegion)(); + const cacheCold = !(this.deps.configFresh ?? defaultDeps.configFresh)(); + if ( + cacheCold && + this.now() - this.coldRefreshAttemptedAt >= SURVEY_CONFIG_REFRESH_INTERVAL_MS + ) { + this.coldRefreshAttemptedAt = this.now(); + if (this.refreshConfig()) return; + } else if ( + (region !== this.configRegion || + this.now() - this.configRefreshedAt >= SURVEY_CONFIG_REFRESH_INTERVAL_MS) && + this.refreshConfig() + ) { + return; + } + const config = (this.deps.config ?? defaultDeps.config)(); + const verdict = evaluateSurveyGate({ ...this.gateInputs(), config }); + if (verdict.longContextRollConsumed === true) this.longContextRollConsumed = true; + if (!verdict.show) return; + this.open(verdict.survey, config); + } + + private gateInputs(): { session: SessionArmGateInput; longContext: LongContextArmGateInput } { + const { appState } = this.host.state; + const now = this.now(); + const shared: SharedArmGateInput = { + phase: this.machine.phase, + turnInProgress: appState.streamingPhase !== 'idle' || appState.isCompacting, + idleForMs: this.idleSince === undefined ? 0 : now - this.idleSince, + promptActive: this.promptActive(), + editorBashActive: this.host.state.editor.inputMode === 'bash', + editorAutocompleteActive: this.host.state.editor.hasAutocompleteActivity(), + externalEditorActive: this.host.state.externalEditorRunning, + terminalWidth: (this.deps.terminalWidth ?? defaultDeps.terminalWidth)() - 2 * CHROME_GUTTER, + terminalHeight: (this.deps.terminalHeight ?? defaultDeps.terminalHeight)(), + feedbackSurveyDisabled: + this.deps.feedbackSurveyDisabled?.() ?? + this.host.state.appState.disableFeedbackSurvey === true, + telemetryDisabled: (this.deps.telemetryDisabled ?? defaultDeps.telemetryDisabled)(), + kfcModelId: resolveKfcModelId(appState), + lastUserMessageStartsOrderedList: this.lastUserMessageStartsOrderedList(), + }; + return { + session: { + ...shared, + mountedForMs: now - this.mountedAt, + userTurnsSinceMount: this.userTurnCount, + msSinceLastShown: this.lastShownAt === undefined ? undefined : now - this.lastShownAt, + userTurnsSinceLastShown: + this.userTurnsAtLastShown === undefined + ? undefined + : this.userTurnCount - this.userTurnsAtLastShown, + sample: this.currentSample(), + msSinceGlobalLastShown: + this.globalLastShownAt === undefined ? undefined : this.wallNow() - this.globalLastShownAt, + }, + longContext: { + ...shared, + cumulativeTokens: appState.cumulativeTokens ?? 0, + virtualContextTokens: appState.contextTokens, + mountRollConsumed: this.longContextRollConsumed, + drawMountRoll: () => (this.deps.random ?? defaultDeps.random)(), + }, + }; + } + + private promptActive(): boolean { + const { state } = this.host; + return ( + state.editorReplacementMounted || + state.activeDialog !== null || + state.livePane.pendingApproval !== null || + state.livePane.pendingQuestion !== null || + state.tasksBrowser !== undefined || + this.host.btwPanelController.isActive() + ); + } + + private lastUserMessageStartsOrderedList(): boolean { + const entries = this.host.state.transcriptEntries; + for (let index = entries.length - 1; index >= 0; index--) { + const entry = entries[index]!; + if (entry.kind !== 'user' || entry.bullet === '') continue; + return SURVEY_ORDERED_LIST_START.test(entry.content); + } + return false; + } + + private currentSample(): number { + if (this.stickySample?.turnCount !== this.userTurnCount) { + this.stickySample = { + turnCount: this.userTurnCount, + value: (this.deps.random ?? defaultDeps.random)(), + }; + } + return this.stickySample.value; + } + + private open(survey: SurveyKind, config: SurveyPopupConfig): void { + this.appearanceCount += 1; + const appearance: SurveyAppearance = { + survey, + appearanceId: (this.deps.appearanceId ?? defaultDeps.appearanceId)(), + appearanceIndex: this.appearanceCount, + }; + const shownAt = this.now(); + this.appearanceConfig = config; + this.applyAction({ type: 'open', appearance }); + if (this.machine.phase !== 'open') return; + this.openedAt = shownAt; + this.openedEditorText = this.host.state.editor.getText(); + this.lastShownAt = shownAt; + this.userTurnsAtLastShown = this.userTurnCount; + if (survey !== 'session') return; + this.globalLastShownAt = this.wallNow(); + try { + (this.deps.writeGlobalLastShown ?? defaultDeps.writeGlobalLastShown)( + this.globalLastShownAt, + ); + } catch {} + } + + private applyAction(action: SurveyMachineAction): void { + this.clearDigitTimer(); + this.clearPhaseTimer(); + const transition = surveyMachineReduce(this.machine, action); + if (transition.state === this.machine && transition.effects.length === 0) return; + const appearance = transition.state.appearance ?? this.machine.appearance; + this.machine = transition.state; + for (const effect of transition.effects) { + this.runEffect(effect, appearance); + } + this.syncView(); + } + + private runEffect(effect: SurveyMachineEffect, appearance: SurveyAppearance | undefined): void { + switch (effect.type) { + case 'report': { + if (appearance === undefined) return; + this.host.track( + SURVEY_EVENT_NAMES[appearance.survey], + buildSurveyEventProperties( + { + event_type: effect.eventType, + appearance_id: appearance.appearanceId, + appearance_index: appearance.appearanceIndex, + response: effect.response, + }, + this.environmentFields(), + this.appearanceConfig ?? (this.deps.config ?? defaultDeps.config)(), + ), + ); + return; + } + case 'schedule': { + if (effect.timer === 'pending-settle') { + this.phaseTimer = this.setT(() => { + this.phaseTimer = undefined; + this.applyAction({ type: 'settle' }); + }, SURVEY_PENDING_UNDO_WINDOW_MS); + } else { + this.phaseTimer = this.setT(() => { + this.phaseTimer = undefined; + this.applyAction({ type: 'thanks-elapsed' }); + }, SURVEY_THANKS_DURATION_MS); + } + } + } + } + + private syncView(): void { + if (this.machine.phase === 'closed') { + this.view.hoverIndex = undefined; + this.applyClose(); + return; + } + this.view.phase = this.machine.phase; + this.view.response = + this.machine.response === undefined || this.machine.response === 'dismissed' + ? undefined + : this.machine.response; + if (this.machine.phase !== 'open') this.view.hoverIndex = undefined; + this.mount(); + this.host.state.ui.requestRender(); + } + + private applyClose(): void { + if (!this.mounted) return; + this.mounted = false; + this.lastTypedDigit = undefined; + this.openedEditorText = undefined; + this.host.state.surveyContainer.clear(); + this.host.state.ui.requestRender(); + } + + private mount(): void { + if (this.mounted) return; + this.mounted = true; + const container = this.host.state.surveyContainer; + container.clear(); + container.addChild(new Spacer(1)); + container.addChild(new SurveyPanelComponent(this.view)); + } + + private chooseDigit(digit: string): void { + const response = SURVEY_DIGIT_RESPONSES[digit]; + if (response === undefined) { + this.applyAction({ type: 'dismiss' }); + } else { + this.applyAction({ type: 'select', response }); + } + this.host.state.editor.setText(''); + } + + private chooseHovered(): void { + const hoverIndex = this.view.hoverIndex; + if (hoverIndex === undefined) return; + if (hoverIndex === SURVEY_DISMISS_OPTION_INDEX) { + this.applyAction({ type: 'dismiss' }); + return; + } + const response = SURVEY_DIGIT_RESPONSES[String(hoverIndex + 1)]; + if (response === undefined) return; + this.applyAction({ type: 'select', response }); + } + + private moveHover(delta: number): void { + const current = this.view.hoverIndex; + this.view.hoverIndex = + current === undefined + ? (delta > 0 ? 0 : SURVEY_OPTION_COUNT - 1) + : (current + delta + SURVEY_OPTION_COUNT) % SURVEY_OPTION_COUNT; + this.host.state.ui.requestRender(); + } + + private environmentFields(): SurveyEventEnvironmentFields { + const { appState } = this.host.state; + return { + current_model: appState.model, + kfc_model_id: resolveKfcModelId(appState), + user_turn_count: this.userTurnCount, + cumulative_tokens: appState.cumulativeTokens ?? 0, + virtual_context_tokens: appState.contextTokens, + tool_call_count: this.toolCallCount, + compaction_count: this.compactionCount, + permission_mode: appState.permissionMode, + thinking_effort: appState.thinkingEffort, + }; + } + + private refreshConfig(): boolean { + this.configRefreshedAt = this.now(); + this.configRegion = (this.deps.configRegion ?? defaultDeps.configRegion)(); + const markReady = () => { + this.configReady = true; + }; + if (this.deps.refreshConfig !== undefined) { + this.configReady = false; + void Promise.resolve(this.deps.refreshConfig()) + .catch(() => undefined) + .finally(markReady); + return true; + } + const accessToken = this.deps.accessToken; + if (accessToken === undefined) { + markReady(); + return false; + } + this.configReady = false; + void (async () => { + const token = await accessToken(); + await getSurveyPopupConfig({ accessToken: token }); + })() + .catch(() => undefined) + .finally(markReady); + return true; + } + + private tooNarrow(): boolean { + return ( + (this.deps.terminalWidth ?? defaultDeps.terminalWidth)() - 2 * CHROME_GUTTER < + SURVEY_MIN_OPTIONS_WIDTH + ); + } + + private tooShort(): boolean { + return ( + (this.deps.terminalHeight ?? defaultDeps.terminalHeight)() < + surveyMinTotalHeight( + (this.deps.terminalWidth ?? defaultDeps.terminalWidth)() - 2 * CHROME_GUTTER, + ) + ); + } + + private inMountProtection(): boolean { + return this.now() - this.openedAt < SURVEY_MOUNT_PROTECTION_MS; + } + + private now(): number { + return (this.deps.monotonicNow ?? defaultDeps.monotonicNow)(); + } + + private wallNow(): number { + return (this.deps.wallNow ?? defaultDeps.wallNow)(); + } + + private setT(fn: () => void, ms: number): unknown { + return (this.deps.setTimer ?? defaultDeps.setTimer)(fn, ms); + } + + private clearT(handle: unknown): void { + (this.deps.clearTimer ?? defaultDeps.clearTimer)(handle); + } + + private clearIdleTimer(): void { + if (this.idleTimer === undefined) return; + this.clearT(this.idleTimer); + this.idleTimer = undefined; + } + + private clearDigitTimer(): void { + if (this.digitTimer === undefined) return; + this.clearT(this.digitTimer); + this.digitTimer = undefined; + } + + private clearPhaseTimer(): void { + if (this.phaseTimer === undefined) return; + this.clearT(this.phaseTimer); + this.phaseTimer = undefined; + } +} diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts new file mode 100644 index 0000000000000000000000000000000000000000..e64344b1e73aff0568cdd2e6d2ec83d816528059 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -0,0 +1,545 @@ +import type { BackgroundTaskInfo, Session } from '@moonshot-ai/kimi-code-sdk'; +import type { ProcessTerminal, TUI } from '@moonshot-ai/pi-tui'; + +import { AgentActivityViewer, formatSubagentActivityPreview } from '../components/dialogs/agent-activity-viewer'; +import { TaskOutputViewer } from '../components/dialogs/task-output-viewer'; +import { TasksBrowserApp, type TasksFilter } from '../components/dialogs/tasks-browser'; +import type { Theme } from '#/tui/theme'; +import type { CustomEditor } from '../components/editor/custom-editor'; +import type { AppState } from '../types'; +import { + beginScreenTakeover, + endScreenTakeover, + type ScreenTakeover, +} from '../utils/screen-takeover'; +import type { SessionEventHandler } from './session-event-handler'; +import type { SubagentActivityRecord } from './subagent-activity-store'; + +export interface TasksBrowserHost { + readonly state: { + readonly tasksBrowser: TasksBrowserState | undefined; + readonly theme: Theme; + readonly terminal: ProcessTerminal; + readonly ui: TUI; + readonly editor: CustomEditor; + readonly appState: Pick<AppState, 'availableModels'>; + }; + readonly backgroundTasks: ReadonlyMap<string, BackgroundTaskInfo>; + readonly sessionEventHandler: SessionEventHandler; + readonly session: Session | undefined; + showError(msg: string): void; + setTasksBrowser(value: TasksBrowserState | undefined): void; +} + +export type TasksBrowserState = { + component: TasksBrowserApp; + takeover: ScreenTakeover; + filter: TasksFilter; + selectedTaskId: string | undefined; + tailOutput: string | undefined; + tailLoading: boolean; + tailRequestId: number; + flashMessage: string | undefined; + flashTimer: NodeJS.Timeout | undefined; + pollTimer: NodeJS.Timeout | undefined; + viewer: + | { + component: TaskOutputViewer | AgentActivityViewer; + takeover: ScreenTakeover; + taskId: string; + output: string; + refreshId: number; + pollTimer: NodeJS.Timeout; + } + | undefined; +}; + +export class TasksBrowserController { + constructor(private readonly host: TasksBrowserHost) {} + + async show(): Promise<void> { + const { state } = this.host; + if (state.tasksBrowser !== undefined) return; + + const session = this.host.session; + if (session === undefined) { + this.host.showError('No active session.'); + return; + } + + let tasks: readonly BackgroundTaskInfo[] = []; + try { + tasks = await session.listBackgroundTasks({ activeOnly: false }); + } catch (error) { + this.host.showError( + `Failed to load tasks: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + if (state.tasksBrowser !== undefined) return; + + const filter: TasksFilter = 'all'; + const selectedTaskId = this.pickInitialSelection(tasks, filter); + const component = new TasksBrowserApp( + { + tasks, + filter, + selectedTaskId, + tailOutput: undefined, + tailLoading: false, + flashMessage: undefined, + availableModels: state.appState.availableModels, + ...this.buildCallbacks(), + }, + state.terminal, + ); + + const takeover = beginScreenTakeover(state.ui, component); + state.ui.setFocus(component); + state.ui.requestRender(true); + + const pollTimer = setInterval(() => { + void this.refresh({ silent: true }); + }, 1000); + + this.host.setTasksBrowser({ + component, + takeover, + filter, + selectedTaskId, + tailOutput: undefined, + tailLoading: false, + tailRequestId: 0, + flashMessage: undefined, + flashTimer: undefined, + pollTimer, + viewer: undefined, + }); + + if (selectedTaskId !== undefined) { + this.loadTail(selectedTaskId); + } + } + + close(): void { + const { state } = this.host; + const browser = state.tasksBrowser; + if (browser === undefined) return; + if (browser.viewer !== undefined) this.closeOutputViewer(); + if (browser.pollTimer !== undefined) clearInterval(browser.pollTimer); + if (browser.flashTimer !== undefined) clearTimeout(browser.flashTimer); + + endScreenTakeover(state.ui, browser.takeover); + this.host.setTasksBrowser(undefined); + state.ui.setFocus(state.editor); + state.ui.requestRender(true); + } + + repaint(): void { + const browser = this.host.state.tasksBrowser; + if (browser === undefined) return; + const tasks = [...this.host.backgroundTasks.values()]; + this.pushProps(tasks); + } + + async refreshOutputViewer(opts: { silent?: boolean } = {}): Promise<void> { + const { state } = this.host; + const browser = state.tasksBrowser; + const viewer = browser?.viewer; + if (browser === undefined || viewer === undefined) return; + // The agent activity viewer refreshes from the local store, not the RPC. + if (viewer.component instanceof AgentActivityViewer) return; + + const session = this.host.session; + if (session === undefined) return; + + const myRefreshId = ++viewer.refreshId; + let output: string; + try { + output = await session.getBackgroundTaskOutput(viewer.taskId); + } catch (error) { + if (!opts.silent) { + const message = error instanceof Error ? error.message : String(error); + this.flash(`Output refresh failed: ${message}`); + } + return; + } + const current = state.tasksBrowser?.viewer; + if (current === undefined || current !== viewer || current.refreshId !== myRefreshId) { + return; + } + if (output === viewer.output) return; + viewer.output = output; + const info = this.host.backgroundTasks.get(viewer.taskId); + viewer.component.setProps({ + taskId: viewer.taskId, + info, + output, + onClose: () => { + this.closeOutputViewer(); + }, + }); + state.ui.requestRender(); + } + + // --------------------------------------------------------------------------- + + private pickInitialSelection( + tasks: readonly BackgroundTaskInfo[], + filter: TasksFilter, + ): string | undefined { + const candidates = + filter === 'all' + ? tasks + : tasks.filter( + (t) => + t.status !== 'completed' && + t.status !== 'failed' && + t.status !== 'timed_out' && + t.status !== 'killed' && + t.status !== 'lost', + ); + if (candidates.length === 0) return undefined; + return candidates.find((t) => t.status === 'running')?.taskId ?? candidates[0]!.taskId; + } + + private async refresh(opts: { silent?: boolean } = {}): Promise<void> { + const { state } = this.host; + const browser = state.tasksBrowser; + if (browser === undefined) return; + + const session = this.host.session; + if (session === undefined) return; + + let tasks: readonly BackgroundTaskInfo[]; + try { + tasks = await session.listBackgroundTasks({ activeOnly: false }); + } catch (error) { + if (!opts.silent) { + this.flash( + `Refresh failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return; + } + if (state.tasksBrowser !== browser) return; + this.syncAgentPreview(); + this.pushProps(tasks); + } + + /** Agent tasks capture output only on completion, so while one is selected + * the Preview frame is fed from the in-memory activity store instead. */ + private syncAgentPreview(): void { + const browser = this.host.state.tasksBrowser; + const selectedTaskId = browser?.selectedTaskId; + if (browser === undefined || selectedTaskId === undefined) return; + const info = this.host.backgroundTasks.get(selectedTaskId); + if (info?.kind !== 'agent' || info.agentId === undefined) return; + const record = this.host.sessionEventHandler.subAgentEventHandler.activityStore.get( + info.agentId, + ); + if (record === undefined) return; + browser.tailOutput = formatSubagentActivityPreview(record); + browser.tailLoading = false; + } + + private pushProps(tasks: readonly BackgroundTaskInfo[]): void { + const browser = this.host.state.tasksBrowser; + if (browser === undefined) return; + browser.component.setProps({ + tasks, + filter: browser.filter, + selectedTaskId: browser.selectedTaskId, + tailOutput: browser.tailOutput, + tailLoading: browser.tailLoading, + flashMessage: browser.flashMessage, + availableModels: this.host.state.appState.availableModels, + ...this.buildCallbacks(), + }); + this.host.state.ui.requestRender(); + } + + private buildCallbacks(): { + onSelect: (taskId: string) => void; + onToggleFilter: () => void; + onRefresh: () => void; + onCancel: () => void; + onStopConfirmed: (taskId: string) => void; + onOpenOutput: (taskId: string) => void; + onStopIgnored: (taskId: string, reason: 'terminal') => void; + } { + return { + onSelect: (taskId) => { + this.handleSelect(taskId); + }, + onToggleFilter: () => { + this.handleToggleFilter(); + }, + onRefresh: () => { + this.handleRefresh(); + }, + onCancel: () => { + this.close(); + }, + onStopConfirmed: (taskId) => { + void this.handleStop(taskId); + }, + onOpenOutput: (taskId) => { + void this.handleOpenOutput(taskId); + }, + onStopIgnored: (taskId, reason) => { + if (reason === 'terminal') { + this.flash(`${taskId} is already terminal — nothing to stop.`); + } + }, + }; + } + + private handleSelect(taskId: string): void { + const browser = this.host.state.tasksBrowser; + if (browser === undefined) return; + if (browser.selectedTaskId === taskId) return; + browser.selectedTaskId = taskId; + browser.tailOutput = undefined; + browser.tailLoading = true; + this.repaint(); + this.loadTail(taskId); + } + + private handleToggleFilter(): void { + const browser = this.host.state.tasksBrowser; + if (browser === undefined) return; + browser.filter = browser.filter === 'all' ? 'active' : 'all'; + this.repaint(); + } + + private handleRefresh(): void { + this.flash('Refreshing…', 600); + void this.refresh(); + } + + private async handleStop(taskId: string): Promise<void> { + const browser = this.host.state.tasksBrowser; + if (browser === undefined) return; + + const session = this.host.session; + if (session === undefined) { + this.flash('No active session.'); + return; + } + + this.flash(`Stopping ${taskId}…`, 1500); + try { + await session.stopBackgroundTask(taskId, { reason: 'User initiated stop' }); + await this.refresh({ silent: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.flash(`Stop failed: ${message}`); + } + } + + private async handleOpenOutput(taskId: string): Promise<void> { + const { state } = this.host; + const browser = state.tasksBrowser; + if (browser === undefined) return; + if (browser.viewer !== undefined) return; + + // Agent tasks get the activity detail view when this process holds a + // record for the agent; otherwise (e.g. a `lost` task after resume) fall + // through to the captured-output viewer. + const info = this.host.backgroundTasks.get(taskId); + if (info !== undefined && info.kind === 'agent' && info.agentId !== undefined) { + const record = this.host.sessionEventHandler.subAgentEventHandler.activityStore.get( + info.agentId, + ); + if (record !== undefined) { + this.openAgentActivityViewer(taskId, info, record); + return; + } + } + + const session = this.host.session; + if (session === undefined) { + this.flash('No active session.'); + return; + } + + let output: string; + try { + output = await session.getBackgroundTaskOutput(taskId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.flash(`Cannot open output: ${message}`); + return; + } + const current = state.tasksBrowser; + if (current === undefined || current !== browser) return; + + const viewer = new TaskOutputViewer( + { + taskId, + info, + output, + onClose: () => { + this.closeOutputViewer(); + }, + }, + state.terminal, + ); + + const takeover = beginScreenTakeover(state.ui, viewer); + state.ui.setFocus(viewer); + state.ui.requestRender(true); + + const pollTimer = setInterval(() => { + void this.refreshOutputViewer({ silent: true }); + }, 1000); + + browser.viewer = { + component: viewer, + takeover, + taskId, + output, + refreshId: 0, + pollTimer, + }; + } + + private openAgentActivityViewer( + taskId: string, + info: BackgroundTaskInfo, + record: SubagentActivityRecord, + ): void { + const { state } = this.host; + const browser = state.tasksBrowser; + if (browser === undefined || browser.viewer !== undefined) return; + + const viewer = new AgentActivityViewer( + { + taskId, + info, + record, + onClose: () => { + this.closeOutputViewer(); + }, + }, + state.terminal, + ); + + const takeover = beginScreenTakeover(state.ui, viewer); + state.ui.setFocus(viewer); + state.ui.requestRender(true); + + // The activity store is in-memory — refreshing is a local read, no RPC. + const pollTimer = setInterval(() => { + this.refreshAgentActivityViewer(); + }, 1000); + + browser.viewer = { + component: viewer, + takeover, + taskId, + output: '', + refreshId: 0, + pollTimer, + }; + } + + private refreshAgentActivityViewer(): void { + const { state } = this.host; + const viewer = state.tasksBrowser?.viewer; + if (viewer === undefined || !(viewer.component instanceof AgentActivityViewer)) return; + + const info = this.host.backgroundTasks.get(viewer.taskId); + const agentId = info?.kind === 'agent' ? info.agentId : undefined; + const record = + agentId === undefined + ? undefined + : this.host.sessionEventHandler.subAgentEventHandler.activityStore.get(agentId); + viewer.component.setProps({ + taskId: viewer.taskId, + info, + record, + onClose: () => { + this.closeOutputViewer(); + }, + }); + state.ui.requestRender(); + } + + private loadTail(taskId: string): void { + const { state } = this.host; + const browser = state.tasksBrowser; + if (browser === undefined) return; + + // Agent tasks capture output only on completion — serve the preview from + // the in-memory activity store instead of the RPC when a record exists. + const info = this.host.backgroundTasks.get(taskId); + if (info !== undefined && info.kind === 'agent' && info.agentId !== undefined) { + const record = this.host.sessionEventHandler.subAgentEventHandler.activityStore.get( + info.agentId, + ); + if (record !== undefined) { + browser.tailOutput = formatSubagentActivityPreview(record); + browser.tailLoading = false; + this.repaint(); + return; + } + } + + const session = this.host.session; + if (session === undefined) { + browser.tailLoading = false; + this.repaint(); + return; + } + + const requestId = ++browser.tailRequestId; + void session + .getBackgroundTaskOutput(taskId, { tail: 4000 }) + .then((output) => { + const current = state.tasksBrowser; + if (current === undefined) return; + if (current !== browser || current.tailRequestId !== requestId) return; + if (current.selectedTaskId !== taskId) return; + current.tailOutput = output; + current.tailLoading = false; + this.repaint(); + }) + .catch(() => { + const current = state.tasksBrowser; + if (current === undefined) return; + if (current !== browser || current.tailRequestId !== requestId) return; + if (current.selectedTaskId !== taskId) return; + current.tailOutput = ''; + current.tailLoading = false; + this.repaint(); + }); + } + + private flash(message: string, durationMs = 2500): void { + const browser = this.host.state.tasksBrowser; + if (browser === undefined) return; + if (browser.flashTimer !== undefined) clearTimeout(browser.flashTimer); + browser.flashMessage = message; + browser.flashTimer = setTimeout(() => { + const current = this.host.state.tasksBrowser; + if (current !== browser) return; + current.flashMessage = undefined; + current.flashTimer = undefined; + this.repaint(); + }, durationMs); + this.repaint(); + } + + private closeOutputViewer(): void { + const browser = this.host.state.tasksBrowser; + if (browser === undefined || browser.viewer === undefined) return; + const viewer = browser.viewer; + clearInterval(viewer.pollTimer); + browser.viewer = undefined; + endScreenTakeover(this.host.state.ui, viewer.takeover); + this.host.state.ui.setFocus(browser.component); + this.host.state.ui.requestRender(true); + } +} diff --git a/apps/kimi-code/src/tui/easter-eggs/dance.ts b/apps/kimi-code/src/tui/easter-eggs/dance.ts new file mode 100644 index 0000000000000000000000000000000000000000..15e3608f834808de69081d9a56f87719f43ba3c3 --- /dev/null +++ b/apps/kimi-code/src/tui/easter-eggs/dance.ts @@ -0,0 +1,248 @@ +/** + * `/dance` easter egg — everything it needs lives in this one file: the + * rainbow text coloring, the animation state machine, and the command handler. + * Removing the feature is "delete this file + its import sites". + * + * It is deliberately NOT registered in BUILTIN_SLASH_COMMANDS, so it stays out + * of `/help` and autocomplete; `executeSlashCommand` calls the handler as a + * fallback after builtin/skill resolution, so a real command or a same-named + * skill always wins. + */ + +import chalk from 'chalk'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; + +import type { SlashCommandHost } from '../commands/dispatch'; +import type { ParsedSlashInput } from '../commands/types'; +import { currentTheme } from '../theme'; + +/** Frame interval for the rainbow flow animation. */ +export const DANCE_FRAME_MS = 110; +/** How long the rainbow flows before settling (fading out, or freezing). */ +export const DANCE_FLOW_MS = 3000; + +const DARK_RAINBOW = [ + '#4FA8FF', + '#5BC0BE', + '#4EC87E', + '#E8A838', + '#FFCB6B', + '#C678B8', + '#A274D9', + '#7C8DFF', +] as const; + +const LIGHT_RAINBOW = [ + '#1565C0', + '#00838F', + '#0E7A38', + '#92660A', + '#9A4A00', + '#B91C1C', + '#8A3A75', + '#6B3A9A', + '#354CB5', +] as const; + +function getDanceRainbowPalette(): readonly [string, ...string[]] { + return currentTheme.palette.text === '#1A1A1A' ? LIGHT_RAINBOW : DARK_RAINBOW; +} + +/** Paint a string character-by-character through a palette, skipping spaces. */ +export function rainbowText( + text: string, + colors: readonly [string, ...string[]], + offset = 0, + bold = false, +): string { + let colorIndex = offset; + return Array.from(text) + .map((char) => { + if (char === ' ') return char; + const color = colors[colorIndex % colors.length] ?? colors[0]; + colorIndex++; + const style = chalk.hex(color); + return bold ? style.bold(char) : style(char); + }) + .join(''); +} + +/** Read-only view of the dance state for components that only render it. */ +export interface RainbowDanceView { + /** Whether consumers should paint themselves in rainbow at all. */ + readonly colored: boolean; + /** Palette offset, advancing while the rainbow flows. */ + readonly phase: number; +} + +export interface RainbowDanceController extends RainbowDanceView { + start(opts: { hold: boolean }): void; + stop(): void; + dispose(): void; +} + +let currentDanceController: RainbowDanceController | undefined; +let currentDanceView: RainbowDanceView | undefined; + +export function setRainbowDance(dance: RainbowDanceController | undefined): void { + currentDanceController = dance; + currentDanceView = dance; +} + +export function installRainbowDance(requestRender: () => void): () => void { + currentDanceController?.dispose(); + const dance = new RainbowDance(requestRender); + setRainbowDance(dance); + return () => { + dance.dispose(); + if (currentDanceController === dance) { + setRainbowDance(undefined); + } + }; +} + +export function getRainbowDanceView(): RainbowDanceView | undefined { + return currentDanceView; +} + +export function isRainbowDancing(): boolean { + return currentDanceView?.colored === true; +} + +export function renderDanceWelcomeHeader( + logo: readonly [string, string], + textWidth: number, + rightRow1: string, +): string[] { + const phase = currentDanceView?.phase ?? 0; + const palette = getDanceRainbowPalette(); + const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); + const gap = ' '; + const rightRow0 = truncateToWidth( + rainbowText('Welcome to Kimi Code!', palette, phase + 2, true), + textWidth, + '…', + ); + + return [ + rainbowText(logo[0].padEnd(logoWidth), palette, phase) + gap + rightRow0, + rainbowText(logo[1].padEnd(logoWidth), palette, phase + 3) + gap + rightRow1, + ]; +} + +export function renderDanceFooterModel(modelLabel: string): string { + return rainbowText(modelLabel, getDanceRainbowPalette(), currentDanceView?.phase ?? 0); +} + +/** + * Drives the rainbow: a single timer advances a shared `phase` and asks the UI + * to repaint. Lives independently of any component, so the welcome banner + * scrolling away or being rebuilt never disturbs the animation. Three states: + * off (default), flowing, and a frozen static rainbow. + */ +export class RainbowDance implements RainbowDanceController { + private currentPhase = 0; + private isColored = false; + private frameTimer: ReturnType<typeof setInterval> | null = null; + private flowStopTimer: ReturnType<typeof setTimeout> | null = null; + private readonly requestRender: () => void; + + constructor(requestRender: () => void) { + this.requestRender = requestRender; + } + + get colored(): boolean { + return this.isColored; + } + + get phase(): number { + return this.currentPhase; + } + + /** + * Flow the rainbow for `DANCE_FLOW_MS`, then settle: + * - `hold: false` → fade back to the default (uncolored) banner. + * - `hold: true` → freeze into a static rainbow that stays on. + */ + start(opts: { hold: boolean }): void { + this.clearTimers(); + this.isColored = true; + this.frameTimer = setInterval(() => { + // Phase just increments; rainbowText() takes it modulo the *current* + // palette length, so the dance never needs to know the palette size. + this.currentPhase += 1; + this.requestRender(); + }, DANCE_FRAME_MS); + this.flowStopTimer = setTimeout(() => { + this.settle(opts.hold); + }, DANCE_FLOW_MS); + this.requestRender(); + } + + /** Turn the rainbow off — back to the default colors. */ + stop(): void { + this.clearTimers(); + this.isColored = false; + this.currentPhase = 0; + this.requestRender(); + } + + /** + * Clear timers without repainting — for shutdown, where the UI is going + * away and a final render would be wasted or write to a stopped terminal. + */ + dispose(): void { + this.clearTimers(); + } + + /** End the flow: freeze the rainbow (hold) or fade back to default. */ + private settle(hold: boolean): void { + this.clearTimers(); + if (!hold) { + this.isColored = false; + this.currentPhase = 0; + } + this.requestRender(); + } + + private clearTimers(): void { + if (this.frameTimer !== null) { + clearInterval(this.frameTimer); + this.frameTimer = null; + } + if (this.flowStopTimer !== null) { + clearTimeout(this.flowStopTimer); + this.flowStopTimer = null; + } + } +} + +/** + * Handle `/dance`: + * /dance flow for a few seconds, then fade back to the default colors + * /dance on flow, then freeze into a static rainbow that stays on + * /dance off turn the rainbow off + * + * Returns true when it claimed the input. + */ +export function tryHandleDanceCommand(host: SlashCommandHost, parsed: ParsedSlashInput): boolean { + if (parsed.name !== 'dance') return false; + if (currentDanceController === undefined) return false; + + // The status line dims the whole message, which buried the command in the + // hint. Paint just the command in the brand color (bold) so it reads as a + // command; chalk nesting resumes the dim run right after it. + const cmd = (text: string): string => currentTheme.boldFg('primary', text); + + const sub = parsed.args.trim().toLowerCase(); + if (sub === 'off') { + currentDanceController.stop(); + } else if (sub === 'on') { + currentDanceController.start({ hold: true }); + host.showStatus(`Dancing — use ${cmd('/dance off')} to turn it off.`); + } else { + currentDanceController.start({ hold: false }); + host.showStatus(`Use ${cmd('/dance on')} to keep the rainbow on.`); + } + return true; +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..373690592d2d05c24ba4ab955a3a300f6ba130d7 --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts @@ -0,0 +1,392 @@ +import type { ApprovalRequest, ApprovalResponse, ToolInputDisplay } from '@moonshot-ai/kimi-code-sdk'; + +import type { ApprovalPanelResponse } from '#/tui/components/dialogs/approval-panel'; +import { goalStartOptions } from '#/tui/components/dialogs/goal-start-permission-prompt'; +import type { ApprovalPanelChoice, ApprovalPanelData, DisplayBlock } from '#/tui/reverse-rpc/types'; + +const DEFAULT_APPROVAL_CHOICES: ApprovalPanelChoice[] = [ + { label: 'Approve once', response: 'approved' }, + { label: 'Approve for this session', response: 'approved_for_session' }, + { label: 'Reject', response: 'rejected' }, + { label: 'Reject with feedback', response: 'rejected', requires_feedback: true }, +]; + +const PLAN_REJECT_CHOICES: ApprovalPanelChoice[] = [ + { label: 'Reject', response: 'rejected', selected_label: 'Reject' }, + { label: 'Revise', response: 'rejected', selected_label: 'Revise', requires_feedback: true }, +]; + +export function adaptApprovalRequest(event: ApprovalRequest): ApprovalPanelData { + const resolved = resolveDisplay(event.toolName, event.display, event.action); + return { + id: event.toolCallId, + tool_call_id: event.toolCallId, + tool_name: event.toolName, + action: event.action, + description: resolved.description, + display: resolved.blocks, + choices: adaptChoices(event.toolName, event.display), + }; +} + +interface ResolvedDisplay { + blocks: DisplayBlock[]; + description: string; +} + +function resolveDisplay( + toolName: string, + display: ToolInputDisplay, + action: string, +): ResolvedDisplay { + if (display.kind === 'generic' && isRecord(display.detail)) { + const extracted = extractFromArgs(toolName, display.detail); + if (extracted !== null) return extracted; + } + return { + blocks: adaptDisplay(display), + description: describeApproval(display, action), + }; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringField(detail: Record<string, unknown>, key: string): string | undefined { + const value = detail[key]; + return typeof value === 'string' ? value : undefined; +} + +function extractFromArgs( + toolName: string, + detail: Record<string, unknown>, +): ResolvedDisplay | null { + const command = stringField(detail, 'command'); + if (command !== undefined) { + const cwd = stringField(detail, 'cwd'); + const toolDescription = stringField(detail, 'description'); + const danger = detectDanger(command); + const language = stringField(detail, 'language') ?? 'bash'; + return { + blocks: [ + { + type: 'shell', + language, + command, + cwd, + description: toolDescription, + danger, + }, + ], + description: toolDescription ?? '', + }; + } + + const oldString = stringField(detail, 'old_string'); + const newString = stringField(detail, 'new_string'); + if (oldString !== undefined && newString !== undefined) { + const path = stringField(detail, 'file_path') ?? stringField(detail, 'path') ?? ''; + // Diff block carries its own `+N -M path` header — no separate + // file_op title row needed. + return { + blocks: [{ type: 'diff', path, old_text: oldString, new_text: newString }], + description: '', + }; + } + + const filePath = stringField(detail, 'file_path') ?? stringField(detail, 'path'); + const content = stringField(detail, 'content'); + if (filePath !== undefined && content !== undefined) { + // Write is a brand-new file: render the content as a syntax- + // highlighted code block, not a diff full of `+` markers. + return { + blocks: [{ type: 'file_content', path: filePath, content }], + description: '', + }; + } + + const url = stringField(detail, 'url'); + if (url !== undefined) { + const method = stringField(detail, 'method'); + return { + blocks: [{ type: 'url_fetch', url, method }], + description: '', + }; + } + + const query = stringField(detail, 'query'); + if (query !== undefined) { + return { + blocks: [{ type: 'search', query }], + description: '', + }; + } + + const pattern = stringField(detail, 'pattern'); + if (pattern !== undefined) { + const scope = stringField(detail, 'path'); + return { + blocks: [{ type: 'search', query: pattern, scope }], + description: '', + }; + } + + if (filePath !== undefined) { + const operation = inferFileOp(toolName); + return { + blocks: [{ type: 'file_op', operation, path: filePath }], + description: '', + }; + } + + return null; +} + +function inferFileOp(toolName: string): 'read' | 'write' | 'edit' | 'glob' | 'grep' { + const lower = toolName.toLowerCase(); + if (lower.includes('glob')) return 'glob'; + if (lower.includes('grep')) return 'grep'; + if (lower.includes('edit')) return 'edit'; + if (lower.includes('write')) return 'write'; + return 'read'; +} + +export function adaptPanelResponse(response: ApprovalPanelResponse): ApprovalResponse { + if (response.response === 'approved_for_session') { + return { + decision: 'approved', + scope: 'session', + feedback: response.feedback, + selectedLabel: response.selected_label, + }; + } + return { + decision: + response.response === 'approved' + ? 'approved' + : response.response === 'rejected' + ? 'rejected' + : 'cancelled', + feedback: response.feedback, + selectedLabel: response.selected_label, + }; +} + +function describeApproval(display: ToolInputDisplay, action: string): string { + switch (display.kind) { + case 'plan_review': + return ''; + case 'goal_start': + return 'Start a goal?'; + case 'generic': + if (typeof display.detail === 'string' && display.detail.length > 0) { + return display.detail; + } + return display.summary ?? action; + case 'command': + return display.description ?? display.command ?? action; + case 'diff': + return `edit ${display.path ?? ''}`.trim(); + case 'file_io': + return `${display.operation ?? 'file'} ${display.path ?? ''}`.trim(); + case 'task_stop': + return `stop task: ${display.task_description ?? display.task_id ?? ''}`.trim(); + case 'agent_call': + return `spawn ${display.agent_name ?? 'agent'}`; + case 'skill_call': + return `invoke skill ${display.skill_name ?? ''}`.trim(); + case 'url_fetch': + return `fetch ${display.url ?? ''}`.trim(); + case 'search': + return `search: ${display.query ?? ''}`.trim(); + case 'todo_list': + return `update todo list (${String(display.items?.length ?? 0)} items)`; + case 'task': + return `${display.status ?? 'background'} task ${display.task_id ?? ''}: ${ + display.description ?? '' + }`.trim(); + default: + return action; + } +} + +const DANGER_PATTERNS: Array<{ pattern: RegExp; label: string }> = [ + { pattern: /\brm\s+(-[a-zA-Z]*[rRfF][a-zA-Z]*|--recursive|--force)/i, label: 'recursive delete' }, + { pattern: /\bsudo\b/i, label: 'sudo' }, + { pattern: /\b(curl|wget)\b[^|]*\|\s*(sh|bash|zsh)\b/i, label: 'pipe to shell' }, + { pattern: /\bdd\b[^|]*\bof=/i, label: 'dd write' }, + { pattern: /\bmkfs\b/i, label: 'mkfs' }, + { pattern: />\s*\/dev\/(sd|nvme|disk|hd)/i, label: 'write to raw device' }, + { pattern: /\bchmod\s+-R?\s*777\b/i, label: 'chmod 777' }, + { pattern: /:\(\)\s*\{\s*:\|:&\s*\}/i, label: 'fork bomb' }, +]; + +function detectDanger(command: string): string | undefined { + for (const { pattern, label } of DANGER_PATTERNS) { + if (pattern.test(command)) return label; + } + return undefined; +} + +function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] { + switch (display.kind) { + case 'command': { + const command = display.command ?? ''; + const danger = detectDanger(command); + return [ + { + type: 'shell', + language: display.language ?? 'bash', + command, + cwd: display.cwd, + description: display.description, + danger, + }, + ]; + } + case 'diff': + return [ + { + type: 'diff', + path: display.path ?? '', + old_text: display.before ?? '', + new_text: display.after ?? '', + }, + ]; + case 'file_io': { + const path = display.path ?? ''; + // Write attaches the full file content — render it as a syntax- + // highlighted code block so the approval panel can preview (and + // ctrl+e expand) what is about to land on disk. + if (display.operation === 'write' && typeof display.content === 'string') { + return [{ type: 'file_content', path, content: display.content }]; + } + // Edit attaches the old_string/new_string hunk as before/after — render + // it as a diff block so ctrl+e expansion works on the change. + if ( + display.operation === 'edit' && + typeof display.before === 'string' && + typeof display.after === 'string' + ) { + return [{ type: 'diff', path, old_text: display.before, new_text: display.after }]; + } + return [ + { + type: 'file_op', + operation: display.operation, + path, + detail: display.detail, + }, + ]; + } + case 'url_fetch': + return [ + { + type: 'url_fetch', + url: display.url ?? '', + method: display.method, + }, + ]; + case 'search': + return [ + { + type: 'search', + query: display.query ?? '', + scope: display.scope, + }, + ]; + case 'agent_call': + return [ + { + type: 'invocation', + kind: 'agent', + name: display.agent_name ?? '', + description: display.prompt, + }, + ]; + case 'skill_call': + return [ + { + type: 'invocation', + kind: 'skill', + name: display.skill_name ?? '', + description: display.args, + }, + ]; + case 'task_stop': + return [ + { + type: 'brief', + text: `Stop task ${display.task_id ?? ''}: ${display.task_description ?? ''}`, + }, + ]; + case 'plan_review': + return []; + case 'goal_start': { + const lines = [`Start goal: ${display.objective}`]; + if (typeof display.completionCriterion === 'string' && display.completionCriterion.length > 0) { + lines.push(`Done when: ${display.completionCriterion}`); + } + return [{ type: 'brief', text: lines.join('\n') }]; + } + case 'generic': + return []; + case 'todo_list': + return []; + case 'task': + return []; + default: + return []; + } +} + +function adaptChoices(toolName: string, display: ToolInputDisplay): ApprovalPanelChoice[] { + if (toolName === 'ExitPlanMode' || display.kind === 'plan_review') { + return adaptPlanReviewChoices(display); + } + if (display.kind === 'goal_start') { + return adaptGoalStartChoices(display); + } + + return DEFAULT_APPROVAL_CHOICES.map((choice) => cloneChoice(choice)); +} + +function adaptGoalStartChoices( + display: Extract<ToolInputDisplay, { kind: 'goal_start' }>, +): ApprovalPanelChoice[] { + // Reuse the exact options the /goal start menu shows. Each mode option starts + // the goal under that permission mode (the policy reads selected_label); "Do + // not start" declines so no goal is created. + return goalStartOptions(display.mode).map((option) => + option.value === 'cancel' + ? { + label: option.label, + response: 'cancelled', + selected_label: 'cancel', + description: option.description, + } + : { + label: option.label, + response: 'approved', + selected_label: option.value, + description: option.description, + }, + ); +} + +function adaptPlanReviewChoices(display: ToolInputDisplay): ApprovalPanelChoice[] { + const optionChoices = + display.kind === 'plan_review' && display.options !== undefined && display.options.length >= 2 + ? display.options.map((option) => ({ + label: option.label, + response: 'approved' as const, + selected_label: option.label, + })) + : [{ label: 'Approve', response: 'approved' as const, selected_label: 'Approve' }]; + return [...optionChoices, ...PLAN_REJECT_CHOICES].map((choice) => cloneChoice(choice)); +} + +function cloneChoice(choice: ApprovalPanelChoice): ApprovalPanelChoice { + return { ...choice }; +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/controller.ts b/apps/kimi-code/src/tui/reverse-rpc/approval/controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..5578a5189f21e5116798ac3445a646a42ab556dc --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/approval/controller.ts @@ -0,0 +1,27 @@ +import type { ApprovalResponse } from '@moonshot-ai/kimi-code-sdk'; + +import { ReverseRpcController } from '#/tui/reverse-rpc/base-controller'; +import type { ApprovalPanelData } from '#/tui/reverse-rpc/types'; + +export class ApprovalController extends ReverseRpcController< + ApprovalPanelData, + ApprovalResponse +> { + protected createCancelResponse(reason: string): ApprovalResponse { + return { decision: 'cancelled', feedback: reason }; + } + + protected override autoResolveFor( + resolvedPayload: ApprovalPanelData, + response: ApprovalResponse, + queuedPayload: ApprovalPanelData, + ): ApprovalResponse | undefined { + if (response.decision !== 'approved') return undefined; + if (response.scope !== 'session') return undefined; + if (resolvedPayload.action !== queuedPayload.action) return undefined; + // Inherit the session-scoped approval. Drop `feedback` and + // `selectedLabel` — those described the user's interaction with the + // first request only and would be misleading on auto-resolved ones. + return { decision: 'approved', scope: 'session' }; + } +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/handler.ts b/apps/kimi-code/src/tui/reverse-rpc/approval/handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..8640dd5d970ca5f377225f8368012fd191ccf163 --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/approval/handler.ts @@ -0,0 +1,24 @@ +import type { ApprovalHandler, ApprovalRequest, ApprovalResponse } from '@moonshot-ai/kimi-code-sdk'; + +import { adaptApprovalRequest } from './adapter'; +import type { ApprovalController } from './controller'; + +export function createApprovalRequestHandler( + controller: ApprovalController, + onResponse?: (request: ApprovalRequest, response: ApprovalResponse) => void, +): ApprovalHandler { + return async (event): Promise<ApprovalResponse> => { + try { + const response = await controller.show(adaptApprovalRequest(event)); + onResponse?.(event, response); + return response; + } catch { + const response: ApprovalResponse = { + decision: 'cancelled', + feedback: 'approval handler failed', + }; + onResponse?.(event, response); + return response; + } + }; +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/base-controller.ts b/apps/kimi-code/src/tui/reverse-rpc/base-controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..a32dcb047b91d98bb378a5de9be9e174273f36d0 --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/base-controller.ts @@ -0,0 +1,111 @@ +/** + * Base class for promise-based reverse RPC dialog controllers. + * + * Approval and question flows wait for a UI action before returning a response. + * Subclasses only need to define the default cancellation response. + * + * When concurrent requests arrive (e.g. multiple parallel subagents each + * needing approval), only one panel is shown at a time; additional requests + * are queued in arrival order and advance after the current one resolves. + */ + +export interface ReverseRpcUIHooks<TPayload> { + showPanel(payload: TPayload): void; + hidePanel(): void; +} + +interface Pending<TPayload, TResponse> { + readonly payload: TPayload; + readonly resolve: (data: TResponse) => void; +} + +export abstract class ReverseRpcController<TPayload, TResponse> { + private uiHooks: ReverseRpcUIHooks<TPayload> | null = null; + private current: Pending<TPayload, TResponse> | null = null; + private queue: Array<Pending<TPayload, TResponse>> = []; + + setUIHooks(hooks: ReverseRpcUIHooks<TPayload>): void { + this.uiHooks = hooks; + } + + /** + * Called when a reverse RPC request arrives from core. The returned promise + * resolves after the user responds or `cancelAll` forces cancellation. + */ + show(payload: TPayload): Promise<TResponse> { + return new Promise<TResponse>((resolve) => { + const entry: Pending<TPayload, TResponse> = { payload, resolve }; + if (this.current === null) { + this.current = entry; + this.uiHooks?.showPanel(payload); + } else { + this.queue.push(entry); + } + }); + } + + /** Called by the UI after the user makes a panel choice. */ + respond(data: TResponse): void { + const pending = this.current; + this.current = null; + pending?.resolve(data); + if (pending !== null) { + this.drainAutoResolved(pending.payload, data); + } + this.advanceOrHide(); + } + + /** Cancels all pending requests during shutdown or session switches. */ + cancelAll(reason: string): void { + const all = [...(this.current === null ? [] : [this.current]), ...this.queue]; + this.current = null; + this.queue = []; + this.uiHooks?.hidePanel(); + for (const entry of all) { + entry.resolve(this.createCancelResponse(reason)); + } + } + + hasPending(): boolean { + return this.current !== null || this.queue.length > 0; + } + + private advanceOrHide(): void { + const next = this.queue.shift(); + if (next === undefined) { + this.uiHooks?.hidePanel(); + return; + } + this.current = next; + this.uiHooks?.showPanel(next.payload); + } + + private drainAutoResolved(resolvedPayload: TPayload, response: TResponse): void { + const remaining: Array<Pending<TPayload, TResponse>> = []; + for (const entry of this.queue) { + const auto = this.autoResolveFor(resolvedPayload, response, entry.payload); + if (auto === undefined) { + remaining.push(entry); + } else { + entry.resolve(auto); + } + } + this.queue = remaining; + } + + /** + * Subclasses override to short-circuit queued requests when an answer to the + * just-resolved one (e.g. an approve-for-session) implies the same answer + * for matching queued requests. Return `undefined` to leave the queued + * request waiting for its own panel turn. + */ + protected autoResolveFor( + _resolvedPayload: TPayload, + _response: TResponse, + _queuedPayload: TPayload, + ): TResponse | undefined { + return undefined; + } + + protected abstract createCancelResponse(reason: string): TResponse; +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/index.ts b/apps/kimi-code/src/tui/reverse-rpc/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a00ae88990fe998b9eaaded80d64eb1e48a21d7 --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/index.ts @@ -0,0 +1,44 @@ +import type { ApprovalController } from './approval/controller'; +import type { QuestionController } from './question/controller'; +import { ReverseRpcModalCoordinator } from './modal-coordinator'; +import type { ApprovalPanelData, QuestionPanelData } from './types'; + +export interface ReverseRPCUIHooks { + readonly showApprovalPanel: (payload: ApprovalPanelData) => void; + readonly hideApprovalPanel: () => void; + readonly showQuestionDialog: (payload: QuestionPanelData) => void; + readonly hideQuestionDialog: () => void; +} + +export function registerReverseRPCHandlers( + approvalController: ApprovalController, + questionController: QuestionController, + uiHooks: ReverseRPCUIHooks, +): Array<() => void> { + const modalCoordinator = new ReverseRpcModalCoordinator(uiHooks); + + // Setup UI hooks for controllers + approvalController.setUIHooks({ + showPanel: (payload) => { + modalCoordinator.showApproval(payload); + }, + hidePanel: () => { + modalCoordinator.hide('approval'); + }, + }); + + questionController.setUIHooks({ + showPanel: (payload) => { + modalCoordinator.showQuestion(payload); + }, + hidePanel: () => { + modalCoordinator.hide('question'); + }, + }); + + return [ + () => { + modalCoordinator.clear(); + }, + ]; +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/modal-coordinator.ts b/apps/kimi-code/src/tui/reverse-rpc/modal-coordinator.ts new file mode 100644 index 0000000000000000000000000000000000000000..623ec761a8f9c91a6ab5fc6e5bcb4634907308e3 --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/modal-coordinator.ts @@ -0,0 +1,97 @@ +import type { ApprovalPanelData, QuestionPanelData } from './types'; + +export type ReverseRpcModalOwner = 'approval' | 'question'; + +export interface ReverseRpcModalUIHooks { + readonly showApprovalPanel: (payload: ApprovalPanelData) => void; + readonly hideApprovalPanel: () => void; + readonly showQuestionDialog: (payload: QuestionPanelData) => void; + readonly hideQuestionDialog: () => void; +} + +interface ReverseRpcModalEntry { + readonly owner: ReverseRpcModalOwner; + readonly show: () => void; + readonly hide: () => void; +} + +export class ReverseRpcModalCoordinator { + private active: ReverseRpcModalEntry | null = null; + private readonly queued: ReverseRpcModalEntry[] = []; + + constructor(private readonly hooks: ReverseRpcModalUIHooks) {} + + showApproval(payload: ApprovalPanelData): void { + this.show({ + owner: 'approval', + show: () => { + this.hooks.showApprovalPanel(payload); + }, + hide: () => { + this.hooks.hideApprovalPanel(); + }, + }); + } + + showQuestion(payload: QuestionPanelData): void { + this.show({ + owner: 'question', + show: () => { + this.hooks.showQuestionDialog(payload); + }, + hide: () => { + this.hooks.hideQuestionDialog(); + }, + }); + } + + hide(owner: ReverseRpcModalOwner): void { + if (this.active?.owner === owner) { + const active = this.active; + this.active = null; + active.hide(); + this.showNext(); + return; + } + + const queuedIndex = this.queued.findIndex((entry) => entry.owner === owner); + if (queuedIndex >= 0) this.queued.splice(queuedIndex, 1); + } + + clear(): void { + const active = this.active; + this.active = null; + this.queued.length = 0; + active?.hide(); + } + + private show(entry: ReverseRpcModalEntry): void { + const active = this.active; + if (active === null) { + this.active = entry; + entry.show(); + return; + } + + if (active.owner === entry.owner) { + this.active = entry; + entry.show(); + return; + } + + const queuedIndex = this.queued.findIndex((queued) => queued.owner === entry.owner); + if (queuedIndex >= 0) { + this.queued[queuedIndex] = entry; + return; + } + + this.queued.push(entry); + } + + private showNext(): void { + const next = this.queued.shift(); + if (next === undefined) return; + this.active = next; + next.show(); + } +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/question/controller.ts b/apps/kimi-code/src/tui/reverse-rpc/question/controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..a87c24daf79ed33c8abc944ae127f2122be93bce --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/question/controller.ts @@ -0,0 +1,11 @@ +import { ReverseRpcController } from '#/tui/reverse-rpc/base-controller'; +import type { QuestionPanelData, QuestionPanelResponse } from '#/tui/reverse-rpc/types'; + +export class QuestionController extends ReverseRpcController< + QuestionPanelData, + QuestionPanelResponse +> { + protected createCancelResponse(_reason: string): QuestionPanelResponse { + return { answers: [] }; + } +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/question/handler.ts b/apps/kimi-code/src/tui/reverse-rpc/question/handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bf564b02757a41d22bd216bc3060d3c0837b9a8 --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/question/handler.ts @@ -0,0 +1,57 @@ +import type { QuestionHandler, QuestionRequest, QuestionResult } from '@moonshot-ai/kimi-code-sdk'; + +import type { + QuestionPanelData, + QuestionPanelResponse, +} from '#/tui/reverse-rpc/types'; + +import type { QuestionController } from './controller'; + +export function createQuestionAskHandler(controller: QuestionController): QuestionHandler { + return async (event): Promise<QuestionResult> => { + try { + const answers = await controller.show(adaptQuestionRequest(event)); + return adaptQuestionAnswers(event, answers); + } catch { + return null; + } + }; +} + +export function adaptQuestionRequest(event: QuestionRequest): QuestionPanelData { + const id = + event.toolCallId ?? + (event.turnId === undefined ? 'question' : `question-${String(event.turnId)}`); + return { + id, + tool_call_id: id, + questions: event.questions.map((question) => ({ + question: question.question, + header: question.header, + body: question.body, + multi_select: question.multiSelect ?? false, + other_label: question.otherLabel, + other_description: question.otherDescription, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + })), + }; +} + +export function adaptQuestionAnswers( + event: QuestionRequest, + response: QuestionPanelResponse, +): QuestionResult { + const result: Record<string, string | true> = {}; + for (let i = 0; i < event.questions.length; i++) { + const question = event.questions[i]; + const answer = response.answers[i]; + if (question === undefined || typeof answer !== 'string' || answer.length === 0) continue; + result[question.question] = answer; + } + return Object.keys(result).length > 0 + ? { answers: result, method: response.method } + : null; +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/types.ts b/apps/kimi-code/src/tui/reverse-rpc/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a41f0df20a6e4b63d1aa3cae604e92a0957a0c0 --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/types.ts @@ -0,0 +1,154 @@ +/** + * Reverse RPC view-layer types. + * + * These types are the contract between the UI layer and reverse RPC + * controllers, not SDK event payloads. Approval and question adapters convert + * core payloads into these shapes for panel components. + */ + +import type { QuestionAnswerMethod } from '@moonshot-ai/kimi-code-sdk'; + +// ── Display blocks (approval panel) ────────────────────────────────── + +export interface BriefDisplayBlock { + type: 'brief'; + text: string; +} + +export interface DiffDisplayBlock { + type: 'diff'; + path: string; + old_text: string; + new_text: string; + old_start?: number | undefined; + new_start?: number | undefined; + is_summary?: boolean | undefined; +} + +export interface ShellDisplayBlock { + type: 'shell'; + language: string; + command: string; + cwd?: string | undefined; + description?: string | undefined; + danger?: string | undefined; +} + +export interface FileOpDisplayBlock { + type: 'file_op'; + operation: 'read' | 'write' | 'edit' | 'glob' | 'grep'; + path: string; + detail?: string | undefined; +} + +/** Full file content preview for Write — a code block, not a diff. */ +export interface FileContentDisplayBlock { + type: 'file_content'; + path: string; + content: string; + language?: string | undefined; +} + +export interface UrlFetchDisplayBlock { + type: 'url_fetch'; + url: string; + method?: string | undefined; +} + +export interface SearchDisplayBlock { + type: 'search'; + query: string; + scope?: string | undefined; +} + +export interface InvocationDisplayBlock { + type: 'invocation'; + kind: 'agent' | 'skill'; + name: string; + description?: string | undefined; +} + +export interface TodoDisplayItem { + title: string; + status: 'pending' | 'in_progress' | 'done'; +} + +export interface TodoDisplayBlock { + type: 'todo'; + items: TodoDisplayItem[]; +} + +export interface BackgroundTaskDisplayBlock { + type: 'background_task'; + task_id: string; + kind: string; + status: string; + description: string; +} + +export type DisplayBlock = + | BriefDisplayBlock + | DiffDisplayBlock + | ShellDisplayBlock + | FileOpDisplayBlock + | FileContentDisplayBlock + | UrlFetchDisplayBlock + | SearchDisplayBlock + | InvocationDisplayBlock + | TodoDisplayBlock + | BackgroundTaskDisplayBlock; + +export interface ApprovalPanelChoice { + label: string; + response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled'; + selected_label?: string | undefined; + requires_feedback?: boolean | undefined; + // Optional helper text shown dim beneath the label. Omitted/empty renders + // exactly as a plain label-only choice. + description?: string | undefined; +} + +// ── Approval / Question view payloads ──────────────────────────────── + +export interface ApprovalPanelData { + id: string; + tool_call_id: string; + tool_name: string; + action: string; + description: string; + display: DisplayBlock[]; + choices: ApprovalPanelChoice[]; +} + +export interface QuestionPanelItem { + question: string; + header?: string; + body?: string; + multi_select: boolean; + other_label?: string; + other_description?: string; + options: Array<{ label: string; description?: string }>; +} + +export interface QuestionPanelData { + id: string; + tool_call_id: string; + questions: QuestionPanelItem[]; +} + +export type QuestionSubmissionMethod = QuestionAnswerMethod; + +export interface QuestionPanelResponse { + readonly answers: string[]; + readonly method?: QuestionSubmissionMethod | undefined; +} + +// ── Pending state wrappers ─────────────────────────────────────────── + +export interface PendingApproval { + data: ApprovalPanelData; +} + +export interface PendingQuestion { + data: QuestionPanelData; +} diff --git a/apps/kimi-code/src/tui/theme/colors.ts b/apps/kimi-code/src/tui/theme/colors.ts new file mode 100644 index 0000000000000000000000000000000000000000..66f9819c3e31752bcb0fa41a481c359a33d6c256 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/colors.ts @@ -0,0 +1,141 @@ +/** + * Color palette definitions for dark and light themes. + * + * `darkColors` / `lightColors` are the semantic `ColorPalette` consumed by + * every UI component via the global Theme singleton. Each token holds its hex + * value directly — see the per-token docs on `ColorPalette` for what each one + * controls. + * + * Light palette values are tuned for ≥ 4.5:1 contrast against #FFFFFF + * for text tokens and ≥ 3:1 for chrome (border / large text), matching + * WCAG AA. + */ + +// Each token below documents where it is actually consumed, so theme authors +// know what changing it affects. "Widely" means the token is read across most +// dialogs/messages rather than in one specific place. +export interface ColorPalette { + // ── Brand ── + /** Dominant interactive/brand colour: links & inline code, the selected item + * in nearly every dialog, the focused editor border, plan/"running" badges, + * spinners. The most widely used token. */ + primary: string; + /** Secondary highlight: approval "▶" prefix, device-code box, image + * placeholder, BTW / queue panes, custom-registry import. */ + accent: string; + + // ── Text ── + /** Default body text: dialog bodies, todo titles, footer model label, + * markdown headings, tool/read output, and assistant-side message bullets + * (assistant / tool / agent / read) plus markdown list bullets. */ + text: string; + /** Emphasised / bold text: input dialogs, status messages. */ + textStrong: string; + /** Secondary, dimmed text (the most widely used dim shade): thinking blocks, + * hints, descriptions, completed todos, markdown quotes, and the footer + * status bar (cwd path, git badge). */ + textDim: string; + /** Faintest text: counters, scroll info, descriptions, markdown link URLs, + * code-block borders. */ + textMuted: string; + + // ── Surface ── + /** Borders: pane & editor borders, markdown horizontal rule. */ + border: string; + /** Focus / attention border — currently only the approval panel. */ + borderFocus: string; + + // ── State ── + /** Success: ✓ marks, "enabled", completed states. */ + success: string; + /** Warning: auto/yolo badges, stale markers, plan-mode hint. */ + warning: string; + /** Error: error messages, failed tool output. */ + error: string; + + // ── Diff (all consumed by components/media/diff-preview.ts) ── + /** Added lines. */ + diffAdded: string; + /** Removed lines. */ + diffRemoved: string; + /** Added lines — intra-line changed words (bold). */ + diffAddedStrong: string; + /** Removed lines — intra-line changed words (bold). */ + diffRemovedStrong: string; + /** Line-number gutter (also approval panel/preview). */ + diffGutter: string; + /** Meta / hunk headers. */ + diffMeta: string; + + // ── Roles ── + /** User message: bullet & text, skill-activation name. The one role colour + * with its own hue — assistant/thinking/status bullets reuse text/textDim. */ + roleUser: string; + + // ── Shell mode ── + /** Shell mode (`!`): the `!` prompt symbol, bash-mode editor border, and the + * echoed `$ command` line. Its own hue (violet), distinct from + * plan-mode (primary) and the user role (roleUser). */ + shellMode: string; +} + +export const darkColors: ColorPalette = { + primary: '#4FA8FF', + accent: '#5BC0BE', + + text: '#E0E0E0', + textStrong: '#F5F5F5', + textDim: '#888888', + textMuted: '#6B6B6B', + + border: '#5A5A5A', + borderFocus: '#E8A838', + + success: '#4EC87E', + warning: '#E8A838', + error: '#E85454', + + diffAdded: '#4EC87E', + diffRemoved: '#E85454', + diffAddedStrong: '#7AD99B', + diffRemovedStrong: '#F08585', + diffGutter: '#6B6B6B', + diffMeta: '#888888', + + roleUser: '#FFCB6B', + shellMode: '#BD93F9', +}; + +export const lightColors: ColorPalette = { + primary: '#1565C0', + accent: '#00838F', + + text: '#1A1A1A', + textStrong: '#1A1A1A', + textDim: '#454545', + textMuted: '#5F5F5F', + + border: '#737373', + borderFocus: '#92660A', + + success: '#0E7A38', + warning: '#92660A', + error: '#B91C1C', + + diffAdded: '#0E7A38', + diffRemoved: '#B91C1C', + diffAddedStrong: '#0E7A38', + diffRemovedStrong: '#B91C1C', + diffGutter: '#737373', + diffMeta: '#5F5F5F', + + roleUser: '#9A4A00', + shellMode: '#7C3AED', +}; + +export type ResolvedTheme = 'dark' | 'light'; + +/** Synchronous palette lookup for built-in themes only. */ +export function getBuiltInPalette(resolved: ResolvedTheme): ColorPalette { + return resolved === 'dark' ? darkColors : lightColors; +} diff --git a/apps/kimi-code/src/tui/theme/custom-theme-loader.ts b/apps/kimi-code/src/tui/theme/custom-theme-loader.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc0b07cbf5bf9a61dd73908e309025309941cf74 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/custom-theme-loader.ts @@ -0,0 +1,97 @@ +/** + * Custom theme loader — reads JSON files from `~/.kimi-code/themes/`. + */ + +import { readdirSync } from 'node:fs'; +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { z } from 'zod'; + +import { getDataDir } from '#/utils/paths'; +import type { ColorPalette, ResolvedTheme } from './colors'; +import { getBuiltInPalette } from './colors'; + +export const CustomThemeSchema = z.object({ + name: z.string().min(1), + displayName: z.string().optional(), + /** Built-in palette that unspecified tokens fall back to. Defaults to `dark`. */ + base: z.enum(['dark', 'light']).optional(), + colors: z.record(z.string(), z.string()).optional(), +}); + +export type CustomThemeDefinition = z.infer<typeof CustomThemeSchema>; + +const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; + +/** + * Names reserved for built-in themes. A `dark.json` / `light.json` / + * `auto.json` file would collide with the built-in value, so it can never be + * selected as a custom theme — hide it from listings. + */ +const RESERVED_THEME_NAMES: ReadonlySet<string> = new Set(['dark', 'light', 'auto']); + +export function getCustomThemesDir(): string { + return join(getDataDir(), 'themes'); +} + +interface ParsedCustomTheme { + readonly base: ResolvedTheme; + readonly colors: Partial<ColorPalette>; +} + +async function readCustomTheme(name: string): Promise<ParsedCustomTheme | null> { + try { + const content = await readFile(join(getCustomThemesDir(), `${name}.json`), 'utf-8'); + const parsed = CustomThemeSchema.parse(JSON.parse(content)); + + // Invalid hex values are dropped (the token falls back to the base + // palette). We intentionally do not print here: this loader can run while + // pi-tui owns the terminal, where raw stdout/stderr writes corrupt the + // rendered screen. Authoring-time validation lives in the JSON schema. + const colors = Object.fromEntries( + Object.entries(parsed.colors ?? {}).filter(([, v]) => HEX_COLOR_REGEX.test(v)), + ) as Partial<ColorPalette>; + + return { base: parsed.base ?? 'dark', colors }; + } catch { + return null; + } +} + +export async function loadCustomTheme(name: string): Promise<Partial<ColorPalette> | null> { + return (await readCustomTheme(name))?.colors ?? null; +} + +/** Load a custom theme and merge it onto its base palette (dark unless `base` says otherwise). */ +export async function loadCustomThemeMerged(name: string): Promise<ColorPalette | null> { + const parsed = await readCustomTheme(name); + if (parsed === null) return null; + return { ...getBuiltInPalette(parsed.base), ...parsed.colors }; +} + +function toThemeNames(files: readonly string[]): string[] { + return files + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace(/\.json$/, '')) + .filter((name) => !RESERVED_THEME_NAMES.has(name)); +} + +export async function listCustomThemes(): Promise<string[]> { + try { + const entries = await readdir(getCustomThemesDir(), { withFileTypes: true }); + return toThemeNames(entries.filter((e) => e.isFile()).map((e) => e.name)); + } catch { + return []; + } +} + +/** Synchronous variant for UI paths (e.g. the `/theme` picker) that cannot await. */ +export function listCustomThemesSync(): string[] { + try { + const entries = readdirSync(getCustomThemesDir(), { withFileTypes: true }); + return toThemeNames(entries.filter((e) => e.isFile()).map((e) => e.name)); + } catch { + return []; + } +} diff --git a/apps/kimi-code/src/tui/theme/detect.ts b/apps/kimi-code/src/tui/theme/detect.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f4d40953238608b56ed0a2c88d33266ca5b72c2 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/detect.ts @@ -0,0 +1,121 @@ +/** + * Terminal background detection. + * + * Strategy, in priority order: + * 1. Reject — non-TTY, NO_COLOR, FORCE_COLOR=0, CI → safe `'dark'`. + * 2. OSC 11 — write `ESC ] 11 ; ? BEL`, parse `ESC ] 11 ; rgb:RR/GG/BB BEL`, + * compute relative luminance. Capped at `timeoutMs` so unsupported + * terminals don't hang. + * 3. COLORFGBG — VT100 / xterm fallback exposing `"fg;bg"`. + * 4. Default — `'dark'`. + * + * Must run before pi-tui enters raw mode; once the framework owns stdin + * the OSC reply gets eaten by the input loop. + */ + +import { OSC11_QUERY, TERMINAL_THEME_DETECT_TIMEOUT_MS } from "#/tui/constant/terminal"; + +import type { ResolvedTheme } from "./colors"; +import { parseOsc11BackgroundTheme } from "./terminal-background"; + +export interface DetectOptions { + readonly timeoutMs?: number; +} + +export async function detectTerminalTheme(opts: DetectOptions = {}): Promise<ResolvedTheme> { + if (!isInteractiveTerminal()) return "dark"; + if (isColorOptOut()) return "dark"; + + const fromOsc = await queryOsc11({ + timeoutMs: opts.timeoutMs ?? TERMINAL_THEME_DETECT_TIMEOUT_MS, + }); + if (fromOsc !== null) return fromOsc; + + const fromColorFgBg = parseColorFgBg(process.env["COLORFGBG"]); + if (fromColorFgBg !== null) return fromColorFgBg; + + return "dark"; +} + +function isInteractiveTerminal(): boolean { + return (process.stdin.isTTY ?? false) && (process.stdout.isTTY ?? false); +} + +function isColorOptOut(): boolean { + const env = process.env; + if (env["NO_COLOR"] !== undefined && env["NO_COLOR"] !== "") return true; + if (env["FORCE_COLOR"] === "0") return true; + if (env["CI"] !== undefined && env["CI"] !== "" && env["CI"] !== "0") return true; + return false; +} + +interface RawModeStdin { + isRaw?: boolean; + setRawMode(mode: boolean): NodeJS.ReadStream; + on(event: "data", listener: (data: Buffer) => void): NodeJS.ReadStream; + off(event: "data", listener: (data: Buffer) => void): NodeJS.ReadStream; +} + +async function queryOsc11(opts: { timeoutMs: number }): Promise<ResolvedTheme | null> { + const stdin = process.stdin as unknown as RawModeStdin; + if (typeof stdin.setRawMode !== "function") return null; + // If something else is already listening on stdin (e.g. another raw-mode + // consumer), don't fight for it — punt to COLORFGBG instead. + if (process.stdin.listenerCount("data") > 0) return null; + + const wasRaw = stdin.isRaw === true; + let buffer = ""; + let listener: ((data: Buffer) => void) | null = null; + let timer: NodeJS.Timeout | null = null; + + try { + if (!wasRaw) stdin.setRawMode(true); + + const result = await new Promise<ResolvedTheme | null>((resolve) => { + listener = (chunk: Buffer): void => { + buffer += chunk.toString("utf8"); + const theme = parseOsc11BackgroundTheme(buffer); + if (theme !== null) resolve(theme); + }; + stdin.on("data", listener); + timer = setTimeout(() => { + resolve(null); + }, opts.timeoutMs); + try { + process.stdout.write(OSC11_QUERY); + } catch { + resolve(null); + } + }); + + return result; + } catch { + return null; + } finally { + if (timer !== null) clearTimeout(timer); + if (listener !== null) stdin.off("data", listener); + if (!wasRaw) { + try { + stdin.setRawMode(false); + } catch { + /* ignore — raw mode restoration best-effort */ + } + } + } +} + +/** + * COLORFGBG is `"fg;bg"` (sometimes `"fg;default;bg"`). The last token is + * the background ANSI 16-color index; 0–6 and 8 are dark, the rest light. + */ +export function parseColorFgBg(value: string | undefined): ResolvedTheme | null { + if (value === undefined || value === "") return null; + const parts = value.split(";"); + const bgRaw = parts.at(-1); + if (bgRaw === undefined) return null; + const bg = parseInt(bgRaw, 10); + if (!Number.isInteger(bg)) return null; + // ANSI 0=black, 1=red, 2=green, 3=yellow, 4=blue, 5=magenta, 6=cyan, 8=bright black. + const darkBgs = new Set([0, 1, 2, 3, 4, 5, 6, 8]); + return darkBgs.has(bg) ? "dark" : "light"; +} diff --git a/apps/kimi-code/src/tui/theme/gradient-text.ts b/apps/kimi-code/src/tui/theme/gradient-text.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0d12d9d972cedc76e76b2884f5fea3cde06b7a9 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/gradient-text.ts @@ -0,0 +1,39 @@ +import chalk from 'chalk'; + +interface RgbColor { + readonly red: number; + readonly green: number; + readonly blue: number; +} + +export function gradientText(text: string, fromHex: string, toHex: string, accentBias = 1): string { + const chars = Array.from(text); + const from = parseHexColor(fromHex); + const to = parseHexColor(toHex); + if (chars.length <= 1 || from === undefined || to === undefined) { + return chalk.hex(fromHex).bold(text); + } + const safeAccentBias = Number.isFinite(accentBias) ? Math.max(0, accentBias) : 1; + return chars.map((char, index) => { + const ratio = Math.min(1, (index / (chars.length - 1)) * safeAccentBias); + return chalk.hex(interpolateHexColor(from, to, ratio)).bold(char); + }).join(''); +} + +function parseHexColor(hex: string): RgbColor | undefined { + const match = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex); + if (match === null) return undefined; + return { + red: Number.parseInt(match[1]!, 16), + green: Number.parseInt(match[2]!, 16), + blue: Number.parseInt(match[3]!, 16), + }; +} + +function interpolateHexColor(from: RgbColor, to: RgbColor, ratio: number): string { + const mix = (start: number, end: number): string => + Math.round(start + (end - start) * ratio) + .toString(16) + .padStart(2, '0'); + return `#${mix(from.red, to.red)}${mix(from.green, to.green)}${mix(from.blue, to.blue)}`; +} diff --git a/apps/kimi-code/src/tui/theme/highlight-theme.ts b/apps/kimi-code/src/tui/theme/highlight-theme.ts new file mode 100644 index 0000000000000000000000000000000000000000..0385aa02ebb2e220a32a29e5772f362078f29918 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/highlight-theme.ts @@ -0,0 +1,23 @@ +/** + * Shared cli-highlight theme for code previews (Write/Edit tool calls, + * approval panels) and markdown code blocks. + * + * cli-highlight's DEFAULT_THEME paints `string` and `regexp` tokens red; + * reset exactly those tokens to `plain` so highlighted code contains no red. + * Diff `addition` and `deletion` tokens map to the palette's diff colors + * instead, so diff fences and diff previews follow the active palette and + * match the Edit-tool diff styling. Tokens not listed here fall back to + * DEFAULT_THEME. + */ + +import { plain } from 'cli-highlight'; +import type { Theme } from 'cli-highlight'; + +import { currentTheme } from './theme'; + +export const codeHighlightTheme: Theme = { + string: plain, + regexp: plain, + addition: (code) => currentTheme.fg('diffAdded', code), + deletion: (code) => currentTheme.fg('diffRemoved', code), +}; diff --git a/apps/kimi-code/src/tui/theme/index.ts b/apps/kimi-code/src/tui/theme/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e016def5d93f5b332e48df3d39a47cfb7e480097 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/index.ts @@ -0,0 +1,63 @@ +/** + * Theme system public API. + */ + +import { getBuiltInPalette } from './colors'; +import type { ColorPalette, ResolvedTheme } from './colors'; +import { loadCustomThemeMerged } from './custom-theme-loader'; +import { detectTerminalTheme } from './detect'; + +export { currentTheme, Theme } from './theme'; +export type { ColorToken } from './theme'; +export { darkColors, lightColors, getBuiltInPalette } from './colors'; +export type { ColorPalette, ResolvedTheme } from './colors'; +export { detectTerminalTheme } from './detect'; +export { loadCustomTheme, loadCustomThemeMerged, listCustomThemes } from './custom-theme-loader'; + +/** + * User-facing theme preference. + * `'auto'` defers to terminal background detection at startup. + * `'dark'` / `'light'` are explicit built-in overrides. + * Any other string is treated as a custom theme name looked up in + * `~/.kimi-code/themes/<name>.json`. + */ +export type BuiltInTheme = 'dark' | 'light' | 'auto'; +export type ThemeName = BuiltInTheme | (string & {}); + +export function isBuiltInTheme(value: string): value is BuiltInTheme { + return value === 'dark' || value === 'light' || value === 'auto'; +} + +export function isThemeName(_value: string): _value is ThemeName { + return true; // any string is a valid theme name (custom themes) +} + +/** + * Resolve a user preference to a concrete palette. + * + * - `'auto'` triggers terminal background detection. + * - `'dark'` / `'light'` return the built-in palette. + * - Any other string loads a custom theme from `~/.kimi-code/themes/`; + * missing / invalid files fall back to dark palette. + */ +export async function getColorPalette(theme: ThemeName): Promise<ColorPalette> { + if (theme === 'light') return getBuiltInPalette('light'); + if (theme === 'dark') return getBuiltInPalette('dark'); + if (theme === 'auto') { + const detected = await detectTerminalTheme(); + return getBuiltInPalette(detected); + } + // custom theme + const custom = await loadCustomThemeMerged(theme); + return custom ?? getBuiltInPalette('dark'); +} + +/** + * Synchronous fallback used by paths that cannot wait on terminal probes. + * `'auto'` collapses to `'dark'`; explicit choices pass through. + * Custom themes are not supported here — falls back to dark. + */ +export function getColorPaletteSync(theme: ThemeName): ColorPalette { + if (theme === 'light') return getBuiltInPalette('light'); + return getBuiltInPalette('dark'); +} diff --git a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts new file mode 100644 index 0000000000000000000000000000000000000000..91161a5b4f6a4f06ad8fe78fb1b19770e10ecb19 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts @@ -0,0 +1,75 @@ +/** + * Pi-tui theme adapters — MarkdownTheme and EditorTheme backed by the + * global `currentTheme` singleton. + * + * All colour lookups route through `currentTheme.color(token)` so that + * switching themes is instantaneous: old components hold old + * MarkdownTheme/EditorTheme instances, but every method call on those + * instances reads the *current* palette via the singleton. + */ + +import type { MarkdownTheme, EditorTheme } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; +import { highlight, supportsLanguage } from 'cli-highlight'; + +import { currentTheme } from './theme'; +import { codeHighlightTheme } from './highlight-theme'; + +// pi-tui's renderer emits literal "### " / "#### " / ... markers for h3-h6 +// headings (h1/h2 are rendered without the `#` prefix). The prefix arrives +// here already wrapped in bold SGR codes, so we strip it — after any leading +// ANSI sequences — before re-styling. Without this, h3+ renders as raw +// "### Title" and reads like unparsed markdown. +// eslint-disable-next-line no-control-regex -- intentionally matches the ESC byte that opens ANSI SGR sequences. +const HEADING_HASH_PREFIX = /^((?:\u001B\[[0-9;]*m)*)#{1,6}[ \t]+/; + +export function createMarkdownTheme(options?: { transient?: boolean }): MarkdownTheme { + const transient = options?.transient === true; + const stripHash = (text: string): string => text.replace(HEADING_HASH_PREFIX, '$1'); + + return { + heading: (text) => chalk.bold.hex(currentTheme.color('text'))(stripHash(text)), + link: (text) => chalk.hex(currentTheme.color('primary'))(text), + linkUrl: (text) => chalk.hex(currentTheme.color('textMuted'))(text), + code: (text) => chalk.hex(currentTheme.color('primary'))(text), + codeBlock: (text) => text, + codeBlockBorder: (text) => chalk.hex(currentTheme.color('textMuted'))(text), + quote: (text) => chalk.hex(currentTheme.color('textDim'))(text), + quoteBorder: (text) => chalk.hex(currentTheme.color('textDim'))(text), + hr: (text) => chalk.hex(currentTheme.color('border'))(text), + // Match the assistant-message bullet so list markers read like a reply + // prefix. Ordered lists arrive as "1. " / "2. " and are left + // untouched by the leading-dash anchor. + listBullet: (text) => chalk.hex(currentTheme.color('text'))(text.replace(/^-/, '•')), + bold: (text) => chalk.bold(text), + italic: (text) => chalk.italic(text), + strikethrough: (text) => chalk.strikethrough(text), + underline: (text) => chalk.underline(text), + highlightCode: (code: string, lang?: string) => { + if (transient) return code.split('\n'); + + const normalizedLang = lang?.trim().toLowerCase(); + const language = + normalizedLang !== undefined && supportsLanguage(normalizedLang) ? normalizedLang : 'text'; + try { + const highlighted = highlight(code, { language, ignoreIllegals: true, theme: codeHighlightTheme }); + return highlighted.split('\n'); + } catch { + return code.split('\n'); + } + }, + }; +} + +export function createEditorTheme(): EditorTheme { + return { + borderColor: (s) => chalk.hex(currentTheme.color('border'))(s), + selectList: { + selectedPrefix: (s) => chalk.hex(currentTheme.color('primary'))(s), + selectedText: (s) => chalk.hex(currentTheme.color('primary'))(s), + description: (s) => chalk.hex(currentTheme.color('textMuted'))(s), + scrollInfo: (s) => chalk.hex(currentTheme.color('textMuted'))(s), + noMatch: (s) => chalk.hex(currentTheme.color('textMuted'))(s), + }, + }; +} diff --git a/apps/kimi-code/src/tui/theme/terminal-background.ts b/apps/kimi-code/src/tui/theme/terminal-background.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba9803fdb33828eaa2e007f11fd99d2e9c28d9d9 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/terminal-background.ts @@ -0,0 +1,28 @@ +import { OSC11_RESPONSE } from "#/tui/constant/terminal"; + +import type { ResolvedTheme } from "./colors"; + +export function parseOsc11BackgroundTheme(data: string): ResolvedTheme | null { + const match = OSC11_RESPONSE.exec(data); + if (match === null) return null; + const [, r, g, b] = match; + if (r === undefined || g === undefined || b === undefined) return null; + return themeFromHexChannels(r, g, b); +} + +export function themeFromHexChannels(rHex: string, gHex: string, bHex: string): ResolvedTheme { + const r = normalizeChannel(rHex); + const g = normalizeChannel(gHex); + const b = normalizeChannel(bHex); + // Relative luminance, sRGB-linearised. Threshold 0.5 splits dark/light + // backgrounds reliably for both pure-black (#000) and pure-white (#fff). + const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return luma > 0.5 ? "light" : "dark"; +} + +function normalizeChannel(hex: string): number { + // OSC 11 channels can be 1-4 hex digits. Scale into [0,1] regardless. + const max = (1 << (hex.length * 4)) - 1; + const value = parseInt(hex, 16); + return Number.isFinite(value) ? value / max : 0; +} diff --git a/apps/kimi-code/src/tui/theme/theme-schema.json b/apps/kimi-code/src/tui/theme/theme-schema.json new file mode 100644 index 0000000000000000000000000000000000000000..a411088f9cb6982146ac38f393a510f54911c8a8 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/theme-schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/moonshot-ai/kimi-code/blob/main/apps/kimi-code/src/tui/theme/theme-schema.json", + "title": "Kimi Code Custom Theme", + "description": "Schema for Kimi Code TUI custom theme definitions", + "type": "object", + "required": ["name"], + "properties": { + "$schema": { + "type": "string", + "description": "URL to this JSON Schema" + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Machine-readable theme identifier (kebab-case recommended)" + }, + "displayName": { + "type": "string", + "description": "Human-readable theme name shown in UI" + }, + "base": { + "type": "string", + "enum": ["dark", "light"], + "description": "Built-in palette that unspecified tokens fall back to (default: dark)" + }, + "colors": { + "type": "object", + "description": "Color overrides. Omitted tokens fall back to the dark theme defaults.", + "properties": { + "primary": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Primary brand color" }, + "accent": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Accent / highlight color" }, + "text": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Default text color" }, + "textStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Bold / emphasized text" }, + "textDim": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Secondary / muted text" }, + "textMuted": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Most faded text; for counters, scroll info" }, + "border": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Border color" }, + "borderFocus": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Focused border color" }, + "success": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Success state color" }, + "warning": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Warning state color" }, + "error": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Error state color" }, + "diffAdded": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff added lines" }, + "diffRemoved": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff removed lines" }, + "diffAddedStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff added lines (strong)" }, + "diffRemovedStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff removed lines (strong)" }, + "diffGutter": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff gutter color" }, + "diffMeta": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff meta color" }, + "roleUser": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "User message accent" }, + "shellMode": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Shell mode (`!`) prompt, editor border, and the echoed `$ command` line" } + }, + "additionalProperties": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Any valid ColorPalette token" + } + } + }, + "additionalProperties": false +} diff --git a/apps/kimi-code/src/tui/theme/theme.ts b/apps/kimi-code/src/tui/theme/theme.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b7a377febba028fa76fc6f88882070cac5891d3 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/theme.ts @@ -0,0 +1,93 @@ +/** + * Theme class + global singleton. + * + * Components import `currentTheme` and call methods like + * `currentTheme.fg('primary', text)` at render time. When the user switches + * themes we call `currentTheme.setPalette(newPalette)` — the same singleton + * instance stays alive, so every component (including already-rendered + * transcript entries) sees the new colours on the next render frame. + */ + +import chalk from 'chalk'; + +import type { ColorPalette } from './colors'; +import { darkColors } from './colors'; + +export type ColorToken = keyof ColorPalette; + +export class Theme { + private _palette: ColorPalette; + + constructor(palette: ColorPalette) { + this._palette = palette; + } + + get palette(): ColorPalette { + return this._palette; + } + + setPalette(palette: ColorPalette): void { + this._palette = palette; + } + + color(token: ColorToken): string { + return this._palette[token]; + } + + /* ── Foreground helpers ── */ + + fg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token])(text); + } + + boldFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).bold(text); + } + + dimFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).dim(text); + } + + italicFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).italic(text); + } + + underlineFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).underline(text); + } + + strikethroughFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).strikethrough(text); + } + + /* ── Background helpers ── */ + + bg(token: ColorToken, text: string): string { + return chalk.bgHex(this._palette[token])(text); + } + + /* ── Standalone style helpers ── */ + + bold(text: string): string { + return chalk.bold(text); + } + + dim(text: string): string { + return chalk.dim(text); + } + + italic(text: string): string { + return chalk.italic(text); + } + + underline(text: string): string { + return chalk.underline(text); + } + + strikethrough(text: string): string { + return chalk.strikethrough(text); + } +} + +/** Global singleton. Initialise with dark palette; switch via `setPalette`. */ +export const currentTheme = new Theme(darkColors); diff --git a/apps/kimi-code/src/tui/utils/background-agent-status.ts b/apps/kimi-code/src/tui/utils/background-agent-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1ddb3d52f406c002cca7e594b78aba1560bc78a --- /dev/null +++ b/apps/kimi-code/src/tui/utils/background-agent-status.ts @@ -0,0 +1,45 @@ +import type { + BackgroundAgentMetadata, + BackgroundAgentStatusData, + BackgroundAgentStatusPhase, +} from '#/tui/types'; + +const MAX_BACKGROUND_FIELD_LENGTH = 240; + +function normalizeBackgroundField(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const collapsed = value.trim().replaceAll(/\s+/g, ' '); + if (collapsed.length === 0) return undefined; + if (collapsed.length <= MAX_BACKGROUND_FIELD_LENGTH) return collapsed; + return `${collapsed.slice(0, MAX_BACKGROUND_FIELD_LENGTH - 1)}…`; +} + +export function formatBackgroundAgentTranscript( + phase: BackgroundAgentStatusPhase, + meta: BackgroundAgentMetadata, + extras: { resultSummary?: string; error?: string } | undefined = undefined, +): BackgroundAgentStatusData { + const normalizedAgentName = normalizeBackgroundField(meta.agentName); + const subject = normalizedAgentName !== undefined ? `${normalizedAgentName} agent` : 'agent'; + const headline = + phase === 'started' + ? `${subject} started in background` + : phase === 'completed' + ? `${subject} completed in background` + : phase === 'killed' + ? `${subject} stopped` + : `${subject} failed in background`; + const tail = phase === 'failed' ? normalizeBackgroundField(extras?.error) : undefined; + const detailParts = [ + normalizeBackgroundField(meta.model), + normalizeBackgroundField(meta.effort), + normalizeBackgroundField(meta.description), + tail, + ].filter((part): part is string => part !== undefined); + + return { + phase, + headline, + detail: detailParts.length > 0 ? detailParts.join(' · ') : undefined, + }; +} diff --git a/apps/kimi-code/src/tui/utils/background-task-status.ts b/apps/kimi-code/src/tui/utils/background-task-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9e1764965ce899db3237735b56fc3ab2f94fc10 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/background-task-status.ts @@ -0,0 +1,106 @@ +/** + * Format a `BackgroundTaskInfo` snapshot into the transcript card data + * consumed by `BackgroundAgentStatusComponent`. + * + * Background tasks have several statuses (running / completed / failed / + * timed_out / killed / lost) but the transcript card only renders three + * visual phases (started / completed / failed). The + * mapping packs the extra nuance — exit code, kill reason, lost-reason + * — into the dim detail line so the user still sees it. + */ + +import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; + +import type { BackgroundAgentStatusData, BackgroundAgentStatusPhase } from '@/tui/types'; + +const MAX_DETAIL_LENGTH = 240; + +function truncate(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const collapsed = value.trim().replaceAll(/\s+/g, ' '); + if (collapsed.length === 0) return undefined; + if (collapsed.length <= MAX_DETAIL_LENGTH) return collapsed; + return `${collapsed.slice(0, MAX_DETAIL_LENGTH - 1)}…`; +} + +export type BackgroundTaskTranscriptPhase = 'started' | 'updated' | 'terminal'; + +function phaseFromStatus(status: BackgroundTaskStatus): BackgroundAgentStatusPhase { + switch (status) { + case 'running': + return 'started'; + case 'completed': + return 'completed'; + case 'failed': + case 'timed_out': + case 'killed': + case 'lost': + return 'failed'; + } +} + +function subjectFor(info: BackgroundTaskInfo): string { + if (info.kind === 'agent') return 'agent task'; + if (info.kind === 'question') return 'question task'; + return 'bash task'; +} + +function headlineFor(info: BackgroundTaskInfo): string { + const subject = subjectFor(info); + switch (info.status) { + case 'running': + return `${subject} started in background`; + case 'completed': + return `${subject} completed in background`; + case 'failed': + return `${subject} failed in background`; + case 'timed_out': + return `${subject} timed out`; + case 'killed': + return `${subject} stopped`; + case 'lost': + return `${subject} lost`; + } +} + +function detailFor(info: BackgroundTaskInfo): string | undefined { + const parts: string[] = []; + const description = truncate(info.description); + if (description !== undefined) parts.push(description); + + if (info.status === 'completed' || info.status === 'failed') { + if (info.kind === 'process' && info.exitCode !== null) { + parts.push(`exit ${info.exitCode}`); + } + } + if (info.status === 'killed') { + const reason = truncate(info.stopReason); + parts.push(reason !== undefined ? `stopped — ${reason}` : 'stopped'); + } + if (info.status === 'failed') { + const reason = truncate(info.stopReason); + if (reason !== undefined) parts.push(reason); + } + if (info.status === 'timed_out') parts.push('timed out'); + if (info.status === 'lost') { + parts.push('session restarted before completion'); + } + + return parts.length > 0 ? parts.join(' · ') : undefined; +} + +/** + * Build a transcript card payload for a background task lifecycle + * snapshot. The returned phase drives bullet color in the renderer + * (`BackgroundAgentStatusComponent`); the detail line carries the extra + * status nuance (exit code, kill reason, etc.). + */ +export function formatBackgroundTaskTranscript( + info: BackgroundTaskInfo, +): BackgroundAgentStatusData { + return { + phase: phaseFromStatus(info.status), + headline: headlineFor(info), + detail: detailFor(info), + }; +} diff --git a/apps/kimi-code/src/tui/utils/cache-hint.ts b/apps/kimi-code/src/tui/utils/cache-hint.ts new file mode 100644 index 0000000000000000000000000000000000000000..90a0d3e585a9ca1310cd4c3c0c6f2f68372064c4 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/cache-hint.ts @@ -0,0 +1,52 @@ +import type { CacheHintConfig } from '#/utils/cache-hint-config'; + +export interface CacheHintInput { + /** Current time, epoch ms. */ + readonly now: number; + /** Last session activity, epoch ms. Missing → skip. */ + readonly lastActiveAt?: number; + /** Current context size in tokens. Missing → skip (no local estimation). */ + readonly totalTokens?: number; + /** Upstream model ID used to look up the rule. Missing/unconfigured → skip. */ + readonly modelId?: string; + /** Fetch failure → undefined → skip. */ + readonly config?: CacheHintConfig; + /** User chose "Don't ask me again" (tui.toml). */ + readonly dismissed: boolean; +} + +export type CacheHintDecision = + | { readonly kind: 'skip' } + | { readonly kind: 'hint'; readonly idleSeconds: number; readonly totalTokens: number }; + +/** + * Shared trigger rule for both the resume and the idle scenarios. Every + * missing-data branch skips — false negatives are acceptable, false positives + * are not. + */ +export function evaluateCacheHint(input: CacheHintInput): CacheHintDecision { + if (input.dismissed) return { kind: 'skip' }; + const { config, modelId, lastActiveAt, totalTokens } = input; + if (config === undefined || modelId === undefined) return { kind: 'skip' }; + if (lastActiveAt === undefined || totalTokens === undefined) return { kind: 'skip' }; + const rule = config.config[modelId]; + if (rule === undefined) return { kind: 'skip' }; + const idleMs = input.now - lastActiveAt; + if (idleMs <= rule.cache_duration * 1000) return { kind: 'skip' }; + if (totalTokens < rule.min_tokens_to_hint) return { kind: 'skip' }; + return { kind: 'hint', idleSeconds: Math.floor(idleMs / 1000), totalTokens }; +} + +/** `45m` / `3h 20m` / `26d 22h`. */ +export function formatIdleDuration(idleSeconds: number): string { + const minutes = Math.max(1, Math.floor(idleSeconds / 60)); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) { + const restMinutes = minutes % 60; + return restMinutes === 0 ? `${hours}h` : `${hours}h ${restMinutes}m`; + } + const days = Math.floor(hours / 24); + const restHours = hours % 24; + return restHours === 0 ? `${days}d` : `${days}d ${restHours}h`; +} diff --git a/apps/kimi-code/src/tui/utils/component-capabilities.ts b/apps/kimi-code/src/tui/utils/component-capabilities.ts new file mode 100644 index 0000000000000000000000000000000000000000..810f08cae9d42ce6f269898cc18ce7bafb491891 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/component-capabilities.ts @@ -0,0 +1,54 @@ +export interface Expandable { + setExpanded(expanded: boolean): void; +} + +/** + * An expandable component that can say whether ctrl+o would change what it + * shows — content it keeps out of its collapsed form. Drives the footer's + * `ctrl+o expand` / `ctrl+o collapse` hint. + */ +export interface HidesContent extends Expandable { + hasHiddenContent(): boolean; + isExpanded(): boolean; +} + +export interface Disposable { + dispose(): void; +} + +export function isExpandable(obj: unknown): obj is Expandable { + return ( + typeof obj === 'object' && + obj !== null && + 'setExpanded' in obj && + typeof (obj as Expandable).setExpanded === 'function' + ); +} + +export function hasHiddenContent(obj: unknown): boolean { + return ( + isExpandable(obj) && + 'hasHiddenContent' in obj && + typeof (obj as HidesContent).hasHiddenContent === 'function' && + (obj as HidesContent).hasHiddenContent() + ); +} + +/** Whether an expandable component currently shows its expanded form. */ +export function isExpandedComponent(obj: unknown): boolean { + return ( + isExpandable(obj) && + 'isExpanded' in obj && + typeof (obj as HidesContent).isExpanded === 'function' && + (obj as HidesContent).isExpanded() + ); +} + +export function hasDispose(value: unknown): value is Disposable { + return ( + typeof value === 'object' && + value !== null && + 'dispose' in value && + typeof (value as Disposable).dispose === 'function' + ); +} diff --git a/apps/kimi-code/src/tui/utils/dead-terminal.ts b/apps/kimi-code/src/tui/utils/dead-terminal.ts new file mode 100644 index 0000000000000000000000000000000000000000..e851af4c5d1465e7e2612c726567a76606834690 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/dead-terminal.ts @@ -0,0 +1,18 @@ +/** + * Detects errors that mean the controlling terminal (stdout/stderr pty) is + * effectively gone — for example after the parent shell crashed, the tmux + * server vanished, or an SSH connection dropped without delivering SIGHUP. + * + * Continuing to write to a dead terminal would re-fire the same error on every + * render tick and pin a CPU core. Callers should respond by skipping any + * cleanup that touches stdout/stderr and exiting immediately. + */ +const DEAD_TERMINAL_ERROR_CODES = new Set<string>(['EIO', 'EPIPE', 'ENOTCONN']); + +export function isDeadTerminalError(error: unknown): boolean { + if (error === null || typeof error !== 'object' || !('code' in error)) { + return false; + } + const code = (error as NodeJS.ErrnoException).code; + return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code); +} diff --git a/apps/kimi-code/src/tui/utils/errors.ts b/apps/kimi-code/src/tui/utils/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0fd60a2b431c0cfe61bed902dda6ed82ff0bb70 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/errors.ts @@ -0,0 +1,14 @@ +function isAbortMessage(message: string): boolean { + return message === 'Aborted' || message.endsWith(': Aborted'); +} + +export function isAbortError(error: unknown): boolean { + if (error instanceof Error) { + return error.name === 'AbortError' || isAbortMessage(error.message); + } + if (typeof error === 'object' && error !== null) { + const message = (error as { readonly message?: unknown }).message; + return typeof message === 'string' && isAbortMessage(message); + } + return isAbortMessage(String(error)); +} diff --git a/apps/kimi-code/src/tui/utils/event-payload.ts b/apps/kimi-code/src/tui/utils/event-payload.ts new file mode 100644 index 0000000000000000000000000000000000000000..088409ffdd99ce592e48754ec659353abedf0f9c --- /dev/null +++ b/apps/kimi-code/src/tui/utils/event-payload.ts @@ -0,0 +1,137 @@ +import { isKimiError } from '@moonshot-ai/kimi-code-sdk'; + +import { + STREAMING_ARGS_FIELD_RE, + STREAMING_ARGS_PREVIEW_MAX_CHARS, +} from '#/tui/constant/streaming'; + +export function appendStreamingArgsPreview( + current: string | undefined, + next: string | null | undefined, +): string { + const existing = (current ?? '').slice(0, STREAMING_ARGS_PREVIEW_MAX_CHARS); + if (next === null || next === undefined || next.length === 0) return existing; + const remaining = STREAMING_ARGS_PREVIEW_MAX_CHARS - existing.length; + if (remaining <= 0) return existing; + return `${existing}${next.slice(0, remaining)}`; +} + +function unescapeJsonString(s: string): string { + return s.replaceAll(/\\(["\\/bfnrt])/g, (_, ch: string) => { + switch (ch) { + case 'n': + return '\n'; + case 't': + return '\t'; + case 'r': + return '\r'; + case 'b': + return '\b'; + case 'f': + return '\f'; + case '"': + return '"'; + case '\\': + return '\\'; + case '/': + return '/'; + default: + return ch; + } + }); +} + +export function parseStreamingArgs(argumentsText: string): Record<string, unknown> { + const previewText = argumentsText.slice(0, STREAMING_ARGS_PREVIEW_MAX_CHARS); + if (previewText.trim().length === 0) return {}; + if ( + argumentsText.length <= STREAMING_ARGS_PREVIEW_MAX_CHARS && + previewText.trimEnd().endsWith('}') + ) { + try { + const parsed = JSON.parse(previewText) as unknown; + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record<string, unknown>; + } + } catch { + // fall through to partial scan + } + } + const result: Record<string, unknown> = {}; + for (const match of previewText.matchAll(STREAMING_ARGS_FIELD_RE)) { + const key = match[1]; + const rawValue = match[2]; + if (key === undefined || rawValue === undefined) continue; + if (!(key in result)) { + result[key] = unescapeJsonString(rawValue); + } + } + return result; +} + +export function argsRecord(args: unknown): Record<string, unknown> { + return typeof args === 'object' && args !== null && !Array.isArray(args) + ? (args as Record<string, unknown>) + : {}; +} + +export function serializeToolResultOutput(output: unknown): string { + if (typeof output === 'string') return output; + return JSON.stringify(output, null, 2); +} + +export function isTodoItemShape( + value: unknown, +): value is { title: string; status: 'pending' | 'in_progress' | 'done' } { + if (typeof value !== 'object' || value === null) return false; + const rec = value as { title?: unknown; status?: unknown }; + if (typeof rec.title !== 'string' || rec.title.length === 0) return false; + return rec.status === 'pending' || rec.status === 'in_progress' || rec.status === 'done'; +} + +export function formatErrorMessage(error: unknown): string { + if (isKimiError(error)) { + return formatErrorPayload({ + code: error.code, + message: error.message, + details: error.details, + }); + } + return error instanceof Error ? error.message : String(error); +} + +interface ErrorPayloadLike { + readonly code: string; + readonly message: string; + readonly details?: Record<string, unknown>; +} + +export function formatErrorPayload(error: ErrorPayloadLike): string { + const filteredMessage = formatProviderFilteredMessage(error.details); + if (filteredMessage !== undefined) return `[${error.code}] ${filteredMessage}`; + return `[${error.code}] ${error.message}`; +} + +function formatProviderFilteredMessage( + details: Record<string, unknown> | undefined, +): string | undefined { + const finishReason = stringDetail(details, 'finishReason'); + const rawFinishReason = stringDetail(details, 'rawFinishReason'); + if (finishReason !== 'filtered' && rawFinishReason !== 'content_filter') return undefined; + + const normalizedFinishReason = finishReason ?? 'filtered'; + const raw = rawFinishReason === undefined ? '' : `, rawFinishReason=${rawFinishReason}`; + return `Provider filtered the response before visible output (finishReason=${normalizedFinishReason}${raw}).`; +} + +function stringDetail( + details: Record<string, unknown> | undefined, + key: string, +): string | undefined { + const value = details?.[key]; + return typeof value === 'string' ? value : undefined; +} + +export function stringValue(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} diff --git a/apps/kimi-code/src/tui/utils/export-markdown.ts b/apps/kimi-code/src/tui/utils/export-markdown.ts new file mode 100644 index 0000000000000000000000000000000000000000..35813b711256d43b36ba12fa98057bf374483e13 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/export-markdown.ts @@ -0,0 +1,250 @@ +import type { ContentPart, ContextMessage, PromptOrigin, ToolCall } from '@moonshot-ai/kimi-code-sdk'; + +const HINT_KEYS = ['path', 'file_path', 'command', 'query', 'url', 'name', 'pattern'] as const; + +const MAX_HINT_WIDTH = 60; + +export function extractToolCallHint(argsJson: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(argsJson); + } catch { + return ''; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return ''; + + const args = parsed as Record<string, unknown>; + + for (const key of HINT_KEYS) { + const val = args[key]; + if (typeof val === 'string' && val.trim().length > 0) { + return shorten(val, MAX_HINT_WIDTH); + } + } + + for (const val of Object.values(args)) { + if (typeof val === 'string' && val.length > 0 && val.length <= 80) { + return shorten(val, MAX_HINT_WIDTH); + } + } + + return ''; +} + +function shorten(text: string, width: number): string { + if (text.length <= width) return text; + return `${text.slice(0, width)}…`; +} + +export function formatContentPartMd(part: ContentPart): string { + switch (part.type) { + case 'text': + return part.text; + case 'think': + if (part.hidden === true || !part.think.trim()) return ''; + return `<details><summary>Thinking</summary>\n\n${part.think}\n\n</details>`; + case 'image_url': + return '[image]'; + case 'audio_url': + return '[audio]'; + case 'video_url': + return '[video]'; + default: + return `[${(part as ContentPart).type}]`; + } +} + +export function formatToolCallMd(tc: ToolCall): string { + const argsRaw = tc.arguments ?? '{}'; + const hint = extractToolCallHint(argsRaw); + let title = `#### Tool Call: ${tc.name}`; + if (hint) { + title += ` (\`${hint}\`)`; + } + + let argsFormatted: string; + try { + argsFormatted = JSON.stringify(JSON.parse(argsRaw), null, 2); + } catch { + argsFormatted = argsRaw; + } + + return `${title}\n<!-- call_id: ${tc.id} -->\n\`\`\`json\n${argsFormatted}\n\`\`\``; +} + +function formatToolResultMd(msg: ContextMessage, toolName: string, hint: string): string { + const callId = msg.toolCallId ?? 'unknown'; + const parts: string[] = []; + for (const part of msg.content) { + const text = formatContentPartMd(part); + if (text.trim()) parts.push(text); + } + const resultText = parts.join('\n'); + + let summary = `Tool Result: ${toolName}`; + if (hint) summary += ` (\`${hint}\`)`; + + return ( + `<details><summary>${summary}</summary>\n\n` + + `<!-- call_id: ${callId} -->\n` + + `${resultText}\n\n` + + '</details>' + ); +} + +const INTERNAL_ORIGINS = new Set<PromptOrigin['kind']>([ + 'injection', + 'system_trigger', + 'compaction_summary', + 'hook_result', + // Cron fires are stored as user-role records carrying a `<cron-fire ...>` + // XML envelope meant only for the model. Replay and the TUI projector + // already hide them; the markdown exporter must do the same or the raw + // protocol XML leaks into the user-facing export. + 'cron_job', + 'cron_missed', +]); + +export function isInternalMessage(msg: ContextMessage): boolean { + const origin = msg.origin; + if (origin === undefined) return false; + return INTERNAL_ORIGINS.has(origin.kind); +} + +export function groupIntoTurns(history: readonly ContextMessage[]): ContextMessage[][] { + const turns: ContextMessage[][] = []; + let current: ContextMessage[] = []; + + for (const msg of history) { + if (isInternalMessage(msg)) continue; + if (msg.role === 'user' && current.length > 0) { + turns.push(current); + current = []; + } + current.push(msg); + } + + if (current.length > 0) turns.push(current); + return turns; +} + +function formatTurnMd(messages: readonly ContextMessage[], turnNumber: number): string { + const lines: string[] = [`## Turn ${String(turnNumber)}`, '']; + + const toolCallInfo = new Map<string, { name: string; hint: string }>(); + let assistantHeaderWritten = false; + + for (const msg of messages) { + if (isInternalMessage(msg)) continue; + + if (msg.role === 'user') { + lines.push('### User', ''); + // A daemon-ref media part is self-contained and renders as + // `[image]`/`[video]` below; a standalone `<media path>` tag is user + // text and exports verbatim. + for (const part of msg.content) { + const text = formatContentPartMd(part); + if (text.trim()) { + lines.push(text, ''); + } + } + } else if (msg.role === 'assistant') { + if (!assistantHeaderWritten) { + lines.push('### Assistant', ''); + assistantHeaderWritten = true; + } + + for (const part of msg.content) { + const text = formatContentPartMd(part); + if (text.trim()) { + lines.push(text, ''); + } + } + + for (const tc of msg.toolCalls) { + const hint = extractToolCallHint(tc.arguments ?? '{}'); + toolCallInfo.set(tc.id, { name: tc.name, hint }); + lines.push(formatToolCallMd(tc), ''); + } + } else if (msg.role === 'tool') { + const tcId = msg.toolCallId ?? ''; + const info = toolCallInfo.get(tcId) ?? { name: 'unknown', hint: '' }; + lines.push(formatToolResultMd(msg, info.name, info.hint), ''); + } else if (msg.role === 'system') { + lines.push(`### ${msg.role.charAt(0).toUpperCase()}${msg.role.slice(1)}`, ''); + for (const part of msg.content) { + const text = formatContentPartMd(part); + if (text.trim()) { + lines.push(text, ''); + } + } + } + } + + return lines.join('\n'); +} + +function buildOverview( + history: readonly ContextMessage[], + turns: readonly ContextMessage[][], +): string { + let topic = ''; + for (const msg of history) { + if (msg.role === 'user' && !isInternalMessage(msg)) { + const textParts = msg.content + .filter((p): p is { type: 'text'; text: string } => p.type === 'text') + .map((p) => p.text); + topic = shorten(textParts.join(' '), 80); + break; + } + } + + const toolCallCount = history.reduce( + (sum, msg) => sum + msg.toolCalls.length, + 0, + ); + + return [ + '## Overview', + '', + topic ? `- **Topic**: ${topic}` : '- **Topic**: (empty)', + `- **Conversation**: ${String(turns.length)} turns | ${String(toolCallCount)} tool calls`, + '', + '---', + ].join('\n'); +} + +export interface BuildExportMarkdownInput { + readonly sessionId: string; + readonly workDir: string; + readonly history: readonly ContextMessage[]; + readonly tokenCount: number; + readonly now: Date; +} + +export function buildExportMarkdown(input: BuildExportMarkdownInput): string { + const { sessionId, workDir, history, tokenCount, now } = input; + + const lines: string[] = [ + '---', + `session_id: ${sessionId}`, + `exported_at: ${now.toISOString()}`, + `work_dir: ${workDir}`, + `message_count: ${String(history.length)}`, + `token_count: ${String(tokenCount)}`, + '---', + '', + '# Kimi Session Export', + '', + ]; + + const turns = groupIntoTurns(history); + lines.push(buildOverview(history, turns)); + lines.push(''); + + for (let i = 0; i < turns.length; i++) { + lines.push(formatTurnMd(turns[i]!, i + 1)); + } + + return lines.join('\n'); +} diff --git a/apps/kimi-code/src/tui/utils/foreground-task.ts b/apps/kimi-code/src/tui/utils/foreground-task.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba2ac811b281760e2d19e9b70067d6a7b7fbbc55 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/foreground-task.ts @@ -0,0 +1,32 @@ +import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; + +function isDetachableForegroundTask(t: BackgroundTaskInfo): boolean { + return ( + t.detached === false && + t.status === 'running' && + (t.kind === 'process' || t.kind === 'agent') + ); +} + +/** + * Pick all foreground tasks that `Ctrl+B` should detach: `detached === false`, + * currently-running Bash (`process`) or subagent (`agent`) tasks, most recently + * started first. + */ +export function pickForegroundTasks( + tasks: readonly BackgroundTaskInfo[], +): BackgroundTaskInfo[] { + return tasks + .filter(isDetachableForegroundTask) + .sort((a, b) => b.startedAt - a.startedAt); +} + +/** + * Pick the single most recently started foreground task. Kept for callers that + * only need one; `Ctrl+B` uses {@link pickForegroundTasks} to detach them all. + */ +export function pickForegroundTask( + tasks: readonly BackgroundTaskInfo[], +): BackgroundTaskInfo | undefined { + return pickForegroundTasks(tasks)[0]; +} diff --git a/apps/kimi-code/src/tui/utils/goal-completion.ts b/apps/kimi-code/src/tui/utils/goal-completion.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e59b27f9309de2d4c7dda574ac92596853acbd6 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/goal-completion.ts @@ -0,0 +1,36 @@ +import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; + +import { formatTokenCount } from '#/utils/usage/usage-format'; + +interface GoalCompletionStats { + readonly terminalReason?: string | undefined; + readonly turnsUsed: number; + readonly tokensUsed: number; + readonly wallClockMs: number; +} + +/** + * Deterministic goal-completion text rendered by the TUI when the model marks a + * goal `complete`. It is built from the final snapshot, so the figures + * (turns / tokens / time) are exact and do not depend on model prose. + */ +export function buildGoalCompletionMessage(goal: GoalSnapshot): string { + return buildGoalCompletionMessageFromStats(goal); +} + +export function buildGoalCompletionMessageFromStats(goal: GoalCompletionStats): string { + const head = `✓ Goal complete${goal.terminalReason ? ` — ${goal.terminalReason}` : ''}.`; + const turns = `${goal.turnsUsed} turn${goal.turnsUsed === 1 ? '' : 's'}`; + const stats = `Worked ${turns} over ${formatElapsed(goal.wallClockMs)}, using ${formatTokenCount(goal.tokensUsed)} tokens.`; + return `${head}\n${stats}`; +} + +function formatElapsed(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) return `${minutes}m${seconds.toString().padStart(2, '0')}s`; + const hours = Math.floor(minutes / 60); + return `${hours}h${(minutes % 60).toString().padStart(2, '0')}m`; +} diff --git a/apps/kimi-code/src/tui/utils/hook-result-format.ts b/apps/kimi-code/src/tui/utils/hook-result-format.ts new file mode 100644 index 0000000000000000000000000000000000000000..758e3e9127c1593af7999a7e9e2314add0421ed6 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/hook-result-format.ts @@ -0,0 +1,18 @@ +import type { HookResultEvent } from '@moonshot-ai/kimi-code-sdk'; + +export function formatHookResultMarkdown(event: HookResultEvent): string { + return `*${formatHookResultTitle(event)}*\n\n${formatHookResultBody(event)}`; +} + +export function formatHookResultPlain(event: HookResultEvent): string { + return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`; +} + +function formatHookResultTitle(event: HookResultEvent): string { + return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`; +} + +function formatHookResultBody(event: HookResultEvent): string { + const content = event.content.trim(); + return content.length === 0 ? '(empty)' : content; +} diff --git a/apps/kimi-code/src/tui/utils/image-attachment-store.ts b/apps/kimi-code/src/tui/utils/image-attachment-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..86320f72ab23fe29732645575184f37a4035ac87 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/image-attachment-store.ts @@ -0,0 +1,356 @@ +/** + * Registry for media pasted into the input box. + * + * Each paste produces an `ImageAttachment` with an auto-incrementing id + * or `VideoAttachment` with a human-readable placeholder (`[image #1 + * (640×480)]` / `[video #2 sample.mov]`). The placeholder is what the + * user sees in the input field; on submit, `extractMediaAttachments` + * walks the text and expands image placeholders to image content parts + * (dispatch-time caption resolution then precedes them with a compression + * caption when paste-time compression shrank the bytes — see + * `ImageAttachment.original`) and video placeholders to `kimi-file://` + * daemon references (the paste was uploaded to the daemon file store in + * the background, exactly like an uploaded image). + * + * Scope is per-`KimiTUI` instance. Reloads (`/new`, `/clear`, + * session switch) call `clear()` so ids restart from 1 and stale + * prompt attachments are dropped. We intentionally do NOT persist + * attachments across sessions — coding-agent doesn't either, and + * `--resume` wouldn't know how to materialize the files anyway. + */ + +export interface ImageAttachmentOriginal { + /** + * Pre-compression bytes, kept in memory until dispatch-time caption + * resolution (`resolveOriginalCaptions`) persists them — the session whose + * media-originals dir they belong in may not exist yet at paste time. + * Released once persistence succeeds; the on-disk copy is the original + * from then on. + */ + bytes?: Uint8Array; + readonly width: number; + readonly height: number; + /** Pre-compression size, retained for captions after `bytes` is released. */ + readonly byteLength: number; + readonly mime: string; + /** + * Where the original was persisted for readback (ReadMediaFile + region). + * Undefined until dispatch-time persistence succeeds; failures are retried + * at the next dispatch. + */ + path?: string; +} + +export interface ImageAttachment { + readonly id: number; + readonly kind: 'image'; + readonly bytes: Uint8Array; + readonly mime: string; + readonly width: number; + readonly height: number; + /** + * Pre-compression original, recorded when paste-time compression changed + * the bytes. Drives the compression caption authored on dispatch so the + * model knows it received a downsampled copy. Absent for untouched pastes. + */ + readonly original?: ImageAttachmentOriginal | undefined; + /** + * Daemon file-store id, set when the bytes were uploaded at paste time + * (v2 engine only). Submit-time expansion then emits a `kimi-file://` + * reference plus an `<image path>` tag instead of inline base64; absent + * means the inline form is used. + */ + fileId?: string; + /** Epoch milliseconds when the daemon staging upload expires. */ + fileExpiresAt?: number; + /** + * Background ingestion (compression/daemon upload) still in flight. The + * paste callback settles once the placeholder is in the editor — typing + * never waits on this — but submit holds it briefly + * (`pendingImageIngestions`) so a fast paste-then-Enter still gets the + * compressed/ref form; a slow ingestion submits the inline form instead. + * Cleared when ingestion completes. + */ + pending?: Promise<void>; + /** Rendered placeholder string, e.g. `[image #1 (640×480)]`. */ + readonly placeholder: string; +} + +export interface VideoAttachment { + readonly id: number; + readonly kind: 'video'; + readonly mime: string; + readonly filename: string; + readonly sourcePath: string; + readonly label: string; + /** + * Daemon file-store id, set when the source file was uploaded at paste + * time. Submit-time expansion emits a `kimi-file://` video reference; + * absent means the upload failed or is still in flight (`pending`), and + * expansion refuses the submission — a video has no inline fallback. + */ + fileId?: string; + /** Epoch milliseconds when the daemon staging upload expires. */ + fileExpiresAt?: number; + /** + * Background upload still in flight (see `ImageAttachment.pending` — the + * same bounded submit wait applies, `pendingMediaIngestions`). Cleared + * when the upload completes. + */ + pending?: Promise<void>; + /** Rendered placeholder string, e.g. `[video #1 sample.mov]`. */ + readonly placeholder: string; +} + +export type MediaAttachment = ImageAttachment | VideoAttachment; + +type MutableImageAttachment = { + -readonly [Property in keyof ImageAttachment]: ImageAttachment[Property]; +}; + +export class ImageAttachmentStore { + private nextId = 1; + private readonly byId = new Map<number, MediaAttachment>(); + private readonly stagingUses = new Map<number, number>(); + + addImage( + bytes: Uint8Array, + mime: string, + width: number, + height: number, + original?: ImageAttachmentOriginal, + fileId?: string, + fileExpiresAt?: number, + ): ImageAttachment { + const id = this.nextId; + this.nextId += 1; + const attachment: ImageAttachment = { + id, + kind: 'image', + bytes, + mime, + width, + height, + original, + fileId, + fileExpiresAt, + placeholder: formatPlaceholder(id, width, height), + }; + this.byId.set(id, attachment); + return attachment; + } + + addVideo(mime: string, sourcePath: string, filename?: string | undefined): VideoAttachment { + const id = this.nextId; + this.nextId += 1; + const normalizedFilename = basenameLike( + filename !== undefined && filename !== '' ? filename : sourcePath, + ); + const label = sanitizeVideoLabel(normalizedFilename.length > 0 ? normalizedFilename : mime); + const attachment: VideoAttachment = { + id, + kind: 'video', + mime, + filename: normalizedFilename, + sourcePath, + label, + placeholder: formatVideoPlaceholder(id, label), + }; + this.byId.set(id, attachment); + return attachment; + } + + /** + * Complete an image that was inserted into the editor before its ingestion + * work (compression/upload) finished. Returns undefined when the attachment + * was cleared while that work was in flight. + */ + completeImage( + attachment: ImageAttachment, + input: { + bytes: Uint8Array; + mime: string; + width: number; + height: number; + original?: ImageAttachmentOriginal; + fileId?: string; + fileExpiresAt?: number; + }, + ): ImageAttachment | undefined { + const current = this.byId.get(attachment.id); + if (current !== attachment || attachment.kind !== 'image') return undefined; + const mutable = attachment as MutableImageAttachment; + mutable.bytes = input.bytes; + mutable.mime = input.mime; + mutable.width = input.width; + mutable.height = input.height; + mutable.original = input.original; + mutable.fileId = input.fileId; + mutable.fileExpiresAt = input.fileExpiresAt; + mutable.pending = undefined; + mutable.placeholder = formatPlaceholder(attachment.id, input.width, input.height); + return attachment; + } + + /** + * Complete a video whose background daemon upload finished. Returns + * undefined when the attachment was cleared while the upload was in + * flight — the caller then deletes the orphaned upload. + */ + completeVideo( + attachment: VideoAttachment, + input: { + fileId?: string; + fileExpiresAt?: number; + }, + ): VideoAttachment | undefined { + const current = this.byId.get(attachment.id); + if (current !== attachment || attachment.kind !== 'video') return undefined; + attachment.fileId = input.fileId; + attachment.fileExpiresAt = input.fileExpiresAt; + attachment.pending = undefined; + return attachment; + } + + /** + * Record where an attachment's pre-compression original was persisted and + * release the in-memory buffer — the on-disk copy is the original from + * then on, and the caption only needs the retained metadata. Dispatch-time + * caption resolution calls this after a successful write; failures leave + * the path unset so a later dispatch retries. + */ + setOriginalPath(id: number, path: string): void { + const attachment = this.byId.get(id); + if (attachment?.kind !== 'image' || attachment.original === undefined) return; + attachment.original.path = path; + attachment.original.bytes = undefined; + } + + get(id: number): MediaAttachment | undefined { + return this.byId.get(id); + } + + /** + * Drop every attachment and return the staged daemon file ids to delete. + * Uploads with an outstanding retain are excluded: a stashed/queued draft + * still references them (e.g. a cache-hint resend into the NEXT session), + * so they stay alive for that consumer; if none claims them, the daemon's + * staging TTL reaps them. + */ + clear(): readonly string[] { + const fileIds = this.fileIds((id) => (this.stagingUses.get(id) ?? 0) === 0); + this.byId.clear(); + this.stagingUses.clear(); + this.nextId = 1; + return fileIds; + } + + /** + * Drop a single attachment, releasing its bytes. Used to reclaim image + * memory once the transcript entry that references it is trimmed. + */ + remove(id: number): string | undefined { + const attachment = this.byId.get(id); + const fileId = attachment?.fileId; + this.byId.delete(id); + this.stagingUses.delete(id); + return fileId; + } + + /** Drop many attachments at once. See {@link remove}. */ + removeMany(ids: Iterable<number>): readonly string[] { + const fileIds: string[] = []; + for (const id of ids) { + const fileId = this.remove(id); + if (fileId !== undefined) fileIds.push(fileId); + } + return fileIds; + } + + retainFileIds(ids: Iterable<number>): void { + const retained = new Set<number>(); + for (const id of ids) { + if (retained.has(id)) continue; + retained.add(id); + const attachment = this.byId.get(id); + if (attachment?.fileId === undefined) continue; + this.stagingUses.set(id, (this.stagingUses.get(id) ?? 0) + 1); + } + } + + takeFileIds(ids: Iterable<number>): readonly string[] { + const fileIds: string[] = []; + const taken = new Set<number>(); + for (const id of ids) { + if (taken.has(id)) continue; + taken.add(id); + const attachment = this.byId.get(id); + if (attachment?.fileId === undefined) continue; + const uses = this.stagingUses.get(id) ?? 0; + if (uses > 1) { + this.stagingUses.set(id, uses - 1); + continue; + } + this.stagingUses.delete(id); + fileIds.push(attachment.fileId); + attachment.fileId = undefined; + attachment.fileExpiresAt = undefined; + } + return fileIds; + } + + /** + * Consume the retains a recalled submission held WITHOUT taking the staged + * files: the recalled draft still references the attachments, so their + * daemon uploads stay alive and the next submit re-retains them. Used by + * queue recall; every other release path goes through {@link takeFileIds}. + */ + releaseRetains(ids: Iterable<number>): void { + const released = new Set<number>(); + for (const id of ids) { + if (released.has(id)) continue; + released.add(id); + const uses = this.stagingUses.get(id) ?? 0; + if (uses > 1) this.stagingUses.set(id, uses - 1); + else this.stagingUses.delete(id); + } + } + + private fileIds(include?: (id: number) => boolean): readonly string[] { + return [...this.byId.values()].flatMap((attachment) => + attachment.fileId !== undefined && (include?.(attachment.id) ?? true) + ? [attachment.fileId] + : [], + ); + } + + size(): number { + return this.byId.size; + } +} + +export function formatPlaceholder(id: number, width: number, height: number): string { + return `[image #${String(id)} (${String(width)}×${String(height)})]`; +} + +export function formatVideoPlaceholder(id: number, label: string): string { + return `[video #${String(id)} ${sanitizeVideoLabel(label)}]`; +} + +function sanitizeVideoLabel(raw: string): string { + let label = ''; + for (const char of raw) { + const code = char.codePointAt(0); + label += + code === undefined || code < 0x20 || code === 0x7f || char === '[' || char === ']' + ? '_' + : char; + } + label = label.trim(); + return label.length > 0 ? label : 'video'; +} + +function basenameLike(raw: string): string { + const parts = raw.split(/[\\/]/).filter((part) => part.length > 0); + return parts.at(-1) ?? raw; +} diff --git a/apps/kimi-code/src/tui/utils/image-placeholder.ts b/apps/kimi-code/src/tui/utils/image-placeholder.ts new file mode 100644 index 0000000000000000000000000000000000000000..190ba93c20676997db079de152cc4dca9c01fcdc --- /dev/null +++ b/apps/kimi-code/src/tui/utils/image-placeholder.ts @@ -0,0 +1,720 @@ +/** + * Scan submitted text for media placeholders and produce the prompt content + * we'll send to the SDK prompt endpoint. + * + * `extractMediaAttachments` (sync) is the single expansion path for prompts: + * - image placeholders expand to inline image content parts. When the paste + * was uploaded to the daemon file store (`ImageAttachment.fileId`, v2 + * engine only), the placeholder instead expands to a bare + * `kimi-file://<id>` image part — the engine's prompt intake materializes + * the session copy and rewrites the reference with its `?path=`, making + * the part self-contained (no paired tag is authored); without a `fileId` + * the inline base64 form is emitted unchanged (the only form the v1 + * engine accepts). Compression captions for paste-time-downsampled images + * are NOT authored here: extraction runs before a first session exists, + * so `resolveOriginalCaptions` adds them at dispatch time, persisting the + * in-memory original (`ImageAttachment.original`) into the session's + * media-originals dir first; + * - video placeholders expand to a bare `kimi-file://<id>` video part: + * the paste was uploaded to the daemon file store in the background + * (`VideoAttachment.fileId`), and the engine's prompt intake + * materializes the session copy and rewrites the reference with its + * `?path=`, exactly like an uploaded image. A video without a usable + * upload — still in flight after the bounded submit wait, failed, or + * expired — aborts extraction with an error: video bytes have no + * inline fallback form. + * + * `rewriteMediaPlaceholders` is the separate text channel for slash-command + * args (`/skill`, plugin commands): those are plain text, so media is rendered + * as a `<video|image path="…">` tag / plain-text reference into cache-dir + * copies the model opens with `ReadMediaFile`. + * + * Rules for both: + * - Only placeholders that resolve against `store` get extracted. + * A literal `[image #999 ...]` the user typed themselves stays in + * the text (we can't hallucinate files for it). + * - Order is preserved for text/image/video segments. + * - Adjacent text segments are flattened — empty / whitespace-only + * segments drop out so we never emit `{type:'text', text:' '}` + * noise between two media parts. + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { copyFileSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { PromptPart, Session } from '@moonshot-ai/kimi-code-sdk'; +import { + buildDaemonFileUrl, + buildImageCompressionCaption, + buildMediaPathTag, + sessionMediaOriginalsDir, +} from '@moonshot-ai/kimi-code-sdk'; + +import { getCacheDir } from '#/utils/paths'; + +import { MEDIA_FILE_REF_MIN_REMAINING_MS } from '../constant/media'; +import type { + ImageAttachment, + ImageAttachmentStore, + VideoAttachment, +} from './image-attachment-store'; + +const PLACEHOLDER_REGEX = /\[(image|video) #(\d+) (?:(\(\d+×\d+\))|([^\]]+))\]/g; + +export interface ExtractionResult { + /** Flat list of parts in input order; empty array when no media matched. */ + parts: PromptPart[]; + /** + * Did we find at least one matching attachment? When false, callers + * should keep the prompt on the plain text path. + */ + hasMedia: boolean; + /** Image attachment ids matched, in the order they appeared. */ + imageAttachmentIds: number[]; + /** Video attachment ids matched, in the order they appeared. */ + videoAttachmentIds: number[]; + /** + * Image bytes captured while extracting the prompt. A cache-hint resend can + * outlive the attachment store and daemon file ids, so it uses these + * snapshots to rebuild the image parts as inline data URLs. + */ + imageSnapshots: ImageResendSnapshot[]; +} + +export interface ImageResendSnapshot { + readonly bytes: Uint8Array; + readonly mime: string; + readonly width: number; + readonly height: number; + /** + * Pre-compression original captured at extraction, so a new-session resend + * can still persist it and author the compression caption after the image + * store (and its attachments) was cleared. Absent for untouched pastes and + * for originals already persisted and released. + */ + readonly original?: { + readonly bytes: Uint8Array; + readonly width: number; + readonly height: number; + readonly mime: string; + }; +} + +export function extractMediaAttachments( + text: string, + store: ImageAttachmentStore, +): ExtractionResult { + const parts: PromptPart[] = []; + const imageAttachmentIds: number[] = []; + const videoAttachmentIds: number[] = []; + const imageSnapshots: ImageResendSnapshot[] = []; + let cursor = 0; + let hasMedia = false; + + PLACEHOLDER_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) { + const [literal, kind, idStr] = match; + if (kind !== 'image' && kind !== 'video') continue; + if (idStr === undefined) continue; + const id = Number.parseInt(idStr, 10); + const attachment = store.get(id); + if (attachment === undefined) continue; // stale / user-typed — leave as text + if (attachment.kind !== kind) continue; + const before = text.slice(cursor, match.index); + pushText(parts, before); + if (attachment.kind === 'video') { + // The paste was uploaded to the daemon file store in the background: + // reference it by a bare `kimi-file://` url — the engine's prompt + // intake materializes the session copy, so the edge stages no local + // copy. Throws when the upload is unusable (still in flight, failed, + // expired): a video has no inline fallback. + parts.push(videoPartForAttachment(attachment)); + videoAttachmentIds.push(id); + } else { + const original = attachment.original; + imageSnapshots.push({ + bytes: attachment.bytes, + mime: attachment.mime, + width: attachment.width, + height: attachment.height, + original: + original?.bytes === undefined + ? undefined + : { + bytes: original.bytes, + width: original.width, + height: original.height, + mime: original.mime, + }, + }); + // No compression caption here: `resolveOriginalCaptions` authors it + // at dispatch time, once the session (and its media-originals dir) + // is known. + if (attachment.fileId !== undefined) { + // The bytes were uploaded to the daemon file store at paste time + // (v2): reference them by a bare `kimi-file://` url — the engine's + // prompt intake materializes the session copy and rewrites the + // reference with its `?path=`, so the edge stages no local copy. + parts.push({ + type: 'image_url', + imageUrl: { url: buildDaemonFileUrl(attachment.fileId) }, + }); + } else { + parts.push(imagePartForAttachment(attachment)); + } + imageAttachmentIds.push(id); + } + hasMedia = true; + cursor = match.index + literal.length; + } + const tail = text.slice(cursor); + pushText(parts, tail); + + store.retainFileIds([...imageAttachmentIds, ...videoAttachmentIds]); + const freshParts = refreshExpiringImageFileRefs(parts, imageAttachmentIds, store); + return { + // Text-only submissions drop the synthesised parts array — the + // caller's contract is "parts is meaningful iff hasMedia", and + // emitting a stray TextPart confuses consumers that branch on + // `parts.length > 0`. + parts: hasMedia ? freshParts : [], + hasMedia, + imageAttachmentIds, + videoAttachmentIds, + imageSnapshots, + }; +} + +/** + * Give media referenced by `text` a bounded moment to finish its background + * paste ingestion (image compression/upload, video daemon upload — see + * `ImageAttachment.pending` / `VideoAttachment.pending`) before extraction, + * so a paste-then-immediately-submit still expands to the daemon-ref form. + * The returned promise resolves after `timeoutMs` at the latest; an image + * whose ingestion has not landed by then extracts to the inline fallback + * form, a video refuses the submission (no inline form exists). Returns + * undefined when nothing is pending, so the submit path stays synchronous + * for media-free prompts. + */ +export function pendingMediaIngestions( + text: string, + store: ImageAttachmentStore, + timeoutMs: number, +): Promise<void> | undefined { + const pendings: Promise<void>[] = []; + PLACEHOLDER_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) { + const [, kind, idStr] = match; + if (kind !== 'image' && kind !== 'video') continue; + if (idStr === undefined) continue; + const attachment = store.get(Number.parseInt(idStr, 10)); + if (attachment?.kind === kind && attachment.pending !== undefined) { + pendings.push(attachment.pending); + } + } + if (pendings.length === 0) return undefined; + let timer: ReturnType<typeof setTimeout> | undefined; + return Promise.race([ + Promise.allSettled(pendings).then(() => undefined), + new Promise<void>((resolve) => { + timer = setTimeout(resolve, timeoutMs); + }), + ]).finally(() => { + clearTimeout(timer); + }); +} + +/** + * Replace daemon refs that may expire before validation reaches the server + * with the attachment's retained bytes. Called both at extraction time and + * again when a queued/cache-hint submission is actually dispatched. + */ +export function refreshExpiringImageFileRefs( + parts: readonly PromptPart[], + imageAttachmentIds: readonly number[], + store: ImageAttachmentStore, + now = Date.now(), +): PromptPart[] { + if (imageAttachmentIds.length === 0) return [...parts]; + let imageIndex = 0; + let changed = false; + const next = parts.map((part) => { + if (part.type !== 'image_url') return part; + const attachmentId = imageAttachmentIds[imageIndex++]; + if (attachmentId === undefined || !part.imageUrl.url.startsWith('kimi-file://')) return part; + const attachment = store.get(attachmentId); + if (attachment?.kind !== 'image') return part; + + const fileId = attachment.fileId; + const expiresAt = attachment.fileExpiresAt; + const usable = + fileId !== undefined && + (expiresAt === undefined || expiresAt - now > MEDIA_FILE_REF_MIN_REMAINING_MS); + if (usable) { + const url = buildDaemonFileUrl(fileId); + if (url === part.imageUrl.url) return part; + changed = true; + return { ...part, imageUrl: { ...part.imageUrl, url } }; + } + + attachment.fileId = undefined; + attachment.fileExpiresAt = undefined; + changed = true; + return imagePartForAttachment(attachment); + }); + return changed ? next : [...parts]; +} + +/** + * Make an extraction safe to resend after a session reset. The reset clears + * the image store and deletes unretained daemon file ids, so uploaded image + * refs must be replaced with the bytes captured during the original + * extraction. Video refs pass through unchanged: their uploads were retained + * by the stash, and `ImageAttachmentStore.clear` keeps retained uploads + * alive for exactly this resend (unclaimed survivors fall to the daemon's + * staging TTL). + * + * Snapshots of compressed pastes also carry the pre-compression original: the + * cleared store took the attachment with it, so dispatch-time caption + * resolution can no longer find either. `makeExtractionResendable` persists + * that original into `originalsDir` (the NEW session's media-originals dir; + * temp-dir fallback when undefined) and authors the compression caption + * itself, right before the rebuilt image part. + */ +export function makeExtractionResendable( + extraction: ExtractionResult, + originalsDir?: string, +): ExtractionResult { + if (extraction.imageSnapshots.length === 0) return extraction; + + let imageIndex = 0; + const parts: PromptPart[] = []; + for (const part of extraction.parts) { + if (part.type !== 'image_url') { + parts.push(part); + continue; + } + const snapshot = extraction.imageSnapshots[imageIndex++]; + const original = snapshot?.original; + if (snapshot !== undefined && original !== undefined) { + parts.push({ + type: 'text', + text: buildImageCompressionCaption({ + original: { + width: original.width, + height: original.height, + byteLength: original.bytes.length, + mimeType: original.mime, + }, + final: { + width: snapshot.width, + height: snapshot.height, + byteLength: snapshot.bytes.length, + mimeType: snapshot.mime, + }, + originalPath: persistOriginalImageSync(original.bytes, original.mime, originalsDir), + }), + }); + } + if (snapshot === undefined || !part.imageUrl.url.startsWith('kimi-file://')) { + parts.push(part); + continue; + } + parts.push({ + ...part, + imageUrl: { + ...part.imageUrl, + url: `data:${snapshot.mime};base64,${Buffer.from(snapshot.bytes).toString('base64')}`, + }, + }); + } + + return { + ...extraction, + parts, + // The new session's store no longer contains these ids. The rebuilt parts + // carry their own bytes, so keeping stale ids would break thumbnail and + // later cleanup lookups. + imageAttachmentIds: [], + }; +} + +export interface MediaTagRewriteResult { + /** Input text with resolved placeholders replaced by media references. */ + text: string; + hasMedia: boolean; + imageAttachmentIds: number[]; + videoAttachmentIds: number[]; + stagingPaths: string[]; +} + +/** + * How a resolved placeholder is rendered into command args: + * - `'tag'`: the `<image|video path="…"></…>` convention, for channels + * that pass args through verbatim (plugin commands). + * - `'plain'`: a plain-text file reference with no XML tag/attribute + * boundary characters, for channels that XML-escape args (`/skill` + * args are escaped by both `renderSkillAttributes` and + * `expandSkillParameters`, which would mangle the tag form). + */ +export type MediaReferenceStyle = 'tag' | 'plain'; + +/** + * Rewrite media placeholders in slash-command args (`/skill:foo …`, + * plugin commands) into references pointing at cache-dir copies. Command + * args are a plain-text channel — unlike `extractMediaAttachments`, which + * inlines image parts for the prompt endpoint — so the model reaches the + * media through `ReadMediaFile` instead, the same way it already handles + * pasted videos. + * + * Surrounding text is preserved verbatim (args are user content, not + * LLM parts), and unresolved placeholders stay literal. + */ +export function rewriteMediaPlaceholders( + text: string, + store: ImageAttachmentStore, + style: MediaReferenceStyle = 'tag', +): MediaTagRewriteResult { + const imageAttachmentIds: number[] = []; + const videoAttachmentIds: number[] = []; + const stagingPaths: string[] = []; + let cursor = 0; + let out = ''; + + try { + PLACEHOLDER_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) { + const [literal, kind, idStr] = match; + if (kind !== 'image' && kind !== 'video') continue; + if (idStr === undefined) continue; + const id = Number.parseInt(idStr, 10); + const attachment = store.get(id); + if (attachment === undefined) continue; // stale / user-typed — leave as text + if (attachment.kind !== kind) continue; + out += text.slice(cursor, match.index); + if (attachment.kind === 'video') { + const path = materializeVideoToCache(attachment, style === 'plain'); + stagingPaths.push(path); + out += + style === 'plain' + ? formatMediaReference('video', path) + : buildMediaPathTag('video', path); + videoAttachmentIds.push(id); + } else { + const path = materializeImageToCache(attachment); + stagingPaths.push(path); + out += + style === 'plain' + ? formatMediaReference('image', path) + : buildMediaPathTag('image', path); + imageAttachmentIds.push(id); + } + cursor = match.index + literal.length; + } + + const hasMedia = imageAttachmentIds.length + videoAttachmentIds.length > 0; + store.retainFileIds(imageAttachmentIds); + return { + text: hasMedia ? out + text.slice(cursor) : text, + hasMedia, + imageAttachmentIds, + videoAttachmentIds, + stagingPaths, + }; + } catch (error) { + cleanupStagingPaths(stagingPaths); + throw error; + } +} + +function cleanupStagingPaths(paths: readonly string[]): void { + for (const path of paths) { + try { + unlinkSync(path); + } catch { + // Best effort: a failed copy may not have created the target. + } + } +} + +function pushText(parts: PromptPart[], segment: string): void { + if (segment.length === 0) return; + // Keep whitespace-only segments only when they sit between non-empty + // text elsewhere — the simpler rule "drop everything whitespace-only" + // is fine here because the LLM doesn't care about inter-image spaces. + if (segment.trim().length === 0) return; + const last = parts.at(-1); + if (last?.type === 'text') { + parts[parts.length - 1] = { type: 'text', text: last.text + segment }; + return; + } + parts.push({ type: 'text', text: segment }); +} + +function imagePartForAttachment(att: ImageAttachment): Extract<PromptPart, { type: 'image_url' }> { + const base64 = Buffer.from(att.bytes).toString('base64'); + return { + type: 'image_url', + imageUrl: { url: `data:${att.mime};base64,${base64}` }, + }; +} + +/** + * Is this image part still what the attachment holds? Extraction encodes the + * attachment as of extraction time; a paste whose background ingestion + * (compression/daemon upload) landed afterwards mutated it, leaving the part + * carrying the pre-compression form — which no caption may describe. + */ +function imagePartMatchesAttachment( + part: Extract<PromptPart, { type: 'image_url' }>, + attachment: ImageAttachment, +): boolean { + const url = part.imageUrl.url; + if (url.startsWith('kimi-file://')) { + return attachment.fileId !== undefined && url === buildDaemonFileUrl(attachment.fileId); + } + return url === imagePartForAttachment(attachment).imageUrl.url; +} + +/** + * A `video_url` prompt part referencing the paste's daemon upload by a bare + * `kimi-file://` url — the engine's prompt intake materializes the session + * copy before the part reaches the model or the persisted history. Throws + * when the upload is unusable: video bytes have no inline fallback, so the + * submission is refused with an actionable message instead. + */ +function videoPartForAttachment(att: VideoAttachment): PromptPart { + const fileId = att.fileId; + const expired = + att.fileExpiresAt !== undefined && + att.fileExpiresAt - Date.now() <= MEDIA_FILE_REF_MIN_REMAINING_MS; + if (fileId !== undefined && !expired) { + return { + type: 'video_url', + videoUrl: { url: buildDaemonFileUrl(fileId) }, + }; + } + if (att.pending !== undefined) { + throw new Error(`Video "${att.label}" is still uploading; try again in a moment.`); + } + throw new Error( + expired + ? `Video "${att.label}" expired before it was sent; paste it again.` + : `Video "${att.label}" could not be uploaded; paste it again.`, + ); +} + +/** + * Copy a pasted video into the shared cache for the slash-command args + * channel (`rewriteMediaPlaceholders`): command args are plain text, so the + * model reaches the video through `ReadMediaFile` on the cache copy — the + * prompt-part channel never stages one (see `videoPartForAttachment`). + */ +function materializeVideoToCache(att: VideoAttachment, escapeProofName = false): string { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + // The label permits XML boundary chars (`<>&"`); plain references go + // through skill-arg escaping, where they would no longer match the file + // on disk, so strip them from the cache name in that mode. + const label = escapeProofName ? att.label.replaceAll(/[<>&"]/g, '_') : att.label; + const target = join(cacheDir, `${randomUUID()}-${label}`); + copyFileSync(att.sourcePath, target); + return target; +} + +const IMAGE_MIME_EXTENSION: Readonly<Record<string, string>> = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/bmp': 'bmp', + 'image/tiff': 'tif', +}; + +/** + * File-extension hint for an image MIME (`image/png` → `png`). The real + * format is always sniffed from the bytes, so this only names files (cache + * copies, daemon upload labels). + */ +export function imageExtensionForMime(mime: string): string { + return IMAGE_MIME_EXTENSION[mime.trim().toLowerCase()] ?? 'img'; +} + +function materializeImageToCache(att: ImageAttachment): string { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + // ReadMediaFile sniffs the real format from the bytes, so the extension + // only needs to be a reasonable hint. + const target = join(cacheDir, `${randomUUID()}.${imageExtensionForMime(att.mime)}`); + writeFileSync(target, att.bytes); + return target; +} + +/** Opening every compression caption starts with (see buildImageCompressionCaption). */ +const CAPTION_OPENING = '<system>Image compressed to fit model limits:'; + +/** + * The session-owned originals store for compression captions, when the + * session's dir is known; undefined falls back to the shared temp dir. + */ +export function originalsDirForSession(session: Session | undefined): string | undefined { + const sessionDir = session?.summary?.sessionDir; + return sessionDir === undefined ? undefined : sessionMediaOriginalsDir(sessionDir); +} + +/** + * Author a compression caption before every referenced image whose paste-time + * compression shrank the bytes, persisting not-yet-persisted originals into + * `originalsDir` (the session's media-originals dir; the shared temp-dir + * fallback when undefined) so the caption points at a real readback path. + * + * Extraction deliberately does not do this: it can run before the session + * exists (first submit creates it lazily), and the original belongs with the + * session — owned by it, cleaned up with it, immune to OS temp reaping. The + * dispatch paths call this once the session is known. Synchronous because + * those paths cannot await; the write is a single small file, same as the + * cache copies extraction itself stages. Idempotent: an image already + * preceded by a compression caption gets it refreshed in place, so a + * re-resolved part list never grows a duplicate. + */ +export function resolveOriginalCaptions( + parts: readonly PromptPart[], + imageAttachmentIds: readonly number[], + store: ImageAttachmentStore, + originalsDir: string | undefined, +): PromptPart[] { + let imageIndex = 0; + let changed = false; + const out: PromptPart[] = []; + for (const part of parts) { + if (part.type !== 'image_url') { + out.push(part); + continue; + } + const attachmentId = imageAttachmentIds[imageIndex++]; + const attachment = attachmentId === undefined ? undefined : store.get(attachmentId); + if (attachment?.kind !== 'image' || attachment.original === undefined) { + out.push(part); + continue; + } + // The part was encoded from the attachment at extraction; a paste whose + // background ingestion landed afterwards mutated it (compressed bytes, + // daemon file id), leaving the part carrying the pre-compression form. + // Caption only when the two still agree — otherwise the caption would + // describe an image the model did not receive. + if (!imagePartMatchesAttachment(part, attachment)) { + out.push(part); + continue; + } + const original = attachment.original; + if (original.path === undefined && original.bytes !== undefined) { + // A persistence failure (unwritable dir, full disk) leaves the path + // unset — and the bytes retained — so a later dispatch retries; this + // dispatch captions without a readback path. + const path = persistOriginalImageSync(original.bytes, original.mime, originalsDir); + if (path !== null) store.setOriginalPath(attachment.id, path); + } + const caption = buildImageCompressionCaption({ + original: { + width: original.width, + height: original.height, + byteLength: original.byteLength, + mimeType: original.mime, + }, + final: { + width: attachment.width, + height: attachment.height, + byteLength: attachment.bytes.length, + mimeType: attachment.mime, + }, + originalPath: original.path, + }); + const previous = out.at(-1); + if (previous?.type === 'text' && previous.text.startsWith(CAPTION_OPENING)) { + out[out.length - 1] = { type: 'text', text: caption }; + } else { + out.push({ type: 'text', text: caption }); + } + changed = true; + out.push(part); + } + return changed ? out : [...parts]; +} + +/** + * Synchronous twin of the engine's `persistOriginalImage` — same + * content-addressed naming and the same size-capped eviction: the dispatch + * paths that resolve captions cannot await. Exported for tests; production + * callers go through `resolveOriginalCaptions` / `makeExtractionResendable`. + */ +export function persistOriginalImageSync( + bytes: Uint8Array, + mime: string, + dir: string | undefined, + maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES, +): string | null { + if (bytes.length === 0) return null; + try { + const targetDir = dir ?? originalImageTempDir(); + const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 32); + const target = join(targetDir, `${hash}.${imageExtensionForMime(mime)}`); + mkdirSync(targetDir, { recursive: true }); + const existing = statSync(target, { throwIfNoEntry: false }); + // Content-addressed: an existing entry with the right size IS this image. + if (existing === undefined || existing.size !== bytes.length) { + writeFileSync(target, bytes); + } + sweepCacheSync(targetDir, maxTotalBytes); + // The just-written file may itself have been evicted by the sweep when a + // single original exceeds the cap; report persistence honestly. + return statSync(target, { throwIfNoEntry: false }) === undefined ? null : target; + } catch { + return null; + } +} + +/** Per-store ceiling; mirrors the engine originals store. */ +const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024; // 1 GiB + +/** Evict oldest files (by mtime) until the store fits `maxTotalBytes`. */ +function sweepCacheSync(dir: string, maxTotalBytes: number): void { + const entries: { path: string; size: number; mtimeMs: number }[] = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); + const info = statSync(path, { throwIfNoEntry: false }); + if (info === undefined || !info.isFile()) continue; + entries.push({ path, size: info.size, mtimeMs: info.mtimeMs }); + } + let total = entries.reduce((sum, entry) => sum + entry.size, 0); + if (total <= maxTotalBytes) return; + entries.sort((a, b) => a.mtimeMs - b.mtimeMs); + for (const entry of entries) { + if (total <= maxTotalBytes) break; + try { + unlinkSync(entry.path); + total -= entry.size; + } catch { + // Best effort, mirroring the async twin. + } + } +} + +/** Mirrors agent-core-v2's `originalImageCacheDir` (not re-exported through the SDK). */ +function originalImageTempDir(): string { + return join(tmpdir(), 'kimi-code-original-images'); +} + +/** + * Plain-text media reference for channels that XML-escape args (`/skill`). + * Free of `& < > "` (UUID image names; boundary chars stripped from video + * cache names — see materializeVideoToCache) so it survives + * `escapeXml`/`escapeXmlTags` untouched. + */ +function formatMediaReference(kind: 'image' | 'video', path: string): string { + return `Attached ${kind} file: ${path} (open it with ReadMediaFile)`; +} diff --git a/apps/kimi-code/src/tui/utils/inline-skill-tokens.ts b/apps/kimi-code/src/tui/utils/inline-skill-tokens.ts new file mode 100644 index 0000000000000000000000000000000000000000..5444304a95793ab75254737da5342240c64b1c81 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/inline-skill-tokens.ts @@ -0,0 +1,97 @@ +/** + * Scanner for inline skill `/tokens` inside a prompt. + * + * Dispatch, editor highlighting, and autocomplete share this so all three + * agree on what counts as an inline skill reference: a `/name` token whose `/` + * is preceded by whitespace (space, tab, or newline), with no internal `/`. + * The leading slash-command area at the very start of the input is handled by + * the regular slash-command path and is skipped here by default. + */ + +import type { InlineSkillActivation } from '../types'; + +export interface InlineSkillToken { + readonly commandName: string; + readonly start: number; + readonly end: number; +} + +export interface FindInlineSkillTokensOptions { + /** Decide whether a syntactically valid token names a known skill. */ + readonly isKnownSkill: (commandName: string) => boolean; + /** Include tokens with an empty command name (a bare trailing `/`). */ + readonly allowEmpty?: boolean; + /** Also treat a `/` at the very start of the input as a token. */ + readonly includeLeading?: boolean; +} + +const WHITESPACE = /\s/; + +export function findInlineSkillTokens( + text: string, + options: FindInlineSkillTokensOptions, +): InlineSkillToken[] { + const tokens: InlineSkillToken[] = []; + + let searchStart = 0; + if (text.startsWith('/') && options.includeLeading !== true) { + const firstWhitespace = text.search(WHITESPACE); + searchStart = firstWhitespace === -1 ? text.length : firstWhitespace + 1; + } + + for (let i = searchStart; i < text.length; i++) { + if (text[i] !== '/') continue; + + const isLeadingSlash = i === 0 && options.includeLeading === true; + const charBefore = i > 0 ? text[i - 1] : undefined; + if (!isLeadingSlash && (charBefore === undefined || !WHITESPACE.test(charBefore))) continue; + + let end = i + 1; + while (end < text.length && !WHITESPACE.test(text[end] ?? '')) { + end++; + } + + const commandName = text.slice(i + 1, end); + if (commandName.includes('/')) continue; + if (commandName.length === 0 && options.allowEmpty !== true) continue; + if (!options.isKnownSkill(commandName)) continue; + + tokens.push({ commandName, start: i, end }); + } + + return tokens; +} + +export interface ExtractInlineSkillActivationsOptions { + /** Also treat a `/` at the very start of the input as a skill token. */ + readonly includeLeading?: boolean; +} + +/** + * Resolve the skill tokens of `text` through `skillCommandMap` (command name → + * skill name, with the same `skill:` prefix fallback as the leading-command + * path) and return the deduplicated activations in first-occurrence order. + * Unknown tokens, paths, URLs, and fractions are ignored. + */ +export function extractInlineSkillActivations( + text: string, + skillCommandMap: ReadonlyMap<string, string>, + options?: ExtractInlineSkillActivationsOptions, +): InlineSkillActivation[] { + const tokens = findInlineSkillTokens(text, { + isKnownSkill: (commandName) => + skillCommandMap.has(commandName) || skillCommandMap.has(`skill:${commandName}`), + includeLeading: options?.includeLeading, + }); + + const seen = new Set<string>(); + const activations: InlineSkillActivation[] = []; + for (const token of tokens) { + const skillName = + skillCommandMap.get(token.commandName) ?? skillCommandMap.get(`skill:${token.commandName}`); + if (skillName === undefined || seen.has(skillName)) continue; + seen.add(skillName); + activations.push({ skillName }); + } + return activations; +} diff --git a/apps/kimi-code/src/tui/utils/input-latency.ts b/apps/kimi-code/src/tui/utils/input-latency.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ad69f718b52643da317e6e2b13b907085de24a5 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/input-latency.ts @@ -0,0 +1,105 @@ +// src/tui/utils/input-latency.ts +// +// Debug-only input→render latency probe, enabled with KIMI_TUI_INPUT_LATENCY=1. +// Registers a pi-tui input listener (event timestamps) and mounts a +// non-capturing overlay in the top-right corner whose render() drains the +// queue: each pending input event is stamped against the frame that first +// renders after it, and the overlay shows the live stats (last / p50 / p95 / +// p99 / max, plus >100ms / >300ms / >1s counters and the five worst samples). +// Optional JSONL sink: KIMI_TUI_INPUT_LATENCY_LOG=<path> appends one record +// per event for post-hoc analysis. +// +// The measured latency is "input event → start of the first frame rendered +// after it" — it includes input handling and the 16ms render throttle, and +// underestimates by the frame's own diff/write tail (sub-ms to a few ms), +// which is the right granularity for diagnosing >100ms stalls. + +import { appendFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import type { Component, TUI } from '@moonshot-ai/pi-tui'; + +/** Rolling sample cap for the percentile window. */ +const MAX_SAMPLES = 500; + +export interface LatencySample { + latency: number; + at: string; +} + +/** The pure stats core (exported for tests): feed it input→render latencies + * and it keeps the rolling window, counters, and the five worst samples. */ +export class LatencyStats { + last = 0; + events = 0; + over100 = 0; + over300 = 0; + over1000 = 0; + readonly worst: LatencySample[] = []; + private readonly samples: number[] = []; + + record(latency: number, at: string): void { + this.last = latency; + this.events++; + if (latency > 100) this.over100++; + if (latency > 300) this.over300++; + if (latency > 1000) this.over1000++; + this.samples.push(latency); + if (this.samples.length > MAX_SAMPLES) this.samples.shift(); + const smallestKept = this.worst[this.worst.length - 1]?.latency ?? -1; + if (this.worst.length < 5 || latency >= smallestKept) { + this.worst.push({ latency, at }); + this.worst.sort((a, b) => b.latency - a.latency); + if (this.worst.length > 5) this.worst.length = 5; + } + } + + percentile(p: number): number { + if (this.samples.length === 0) return 0; + const sorted = [...this.samples].sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)]!; + } + + max(): number { + return this.samples.length === 0 ? 0 : Math.max(...this.samples); + } + + formatLines(): string[] { + if (this.events === 0) return [' input→render: (type something) ']; + const head = + ` io ${this.last.toFixed(0)}ms | p50 ${this.percentile(50).toFixed(0)} p95 ${this.percentile(95).toFixed(0)}` + + ` p99 ${this.percentile(99).toFixed(0)} max ${this.max().toFixed(0)}ms | n=${this.events}` + + ` >100:${this.over100} >300:${this.over300} >1s:${this.over1000} `; + const worstLine = ` worst: ${this.worst.map((w) => `${w.latency.toFixed(0)}ms@${w.at}`).join(' ')} `; + return [head, worstLine]; + } +} + +/** Install the probe on a running TUI (call only when the env flag is set). */ +export function installInputLatencyProbe(tui: TUI): void { + const stats = new LatencyStats(); + const pending: number[] = []; + const logPath = process.env['KIMI_TUI_INPUT_LATENCY_LOG']; + if (logPath) mkdirSync(path.dirname(logPath), { recursive: true }); + + tui.addInputListener(() => { + pending.push(performance.now()); + return undefined; + }); + + const overlay: Component = { + invalidate: () => {}, + render: () => { + if (pending.length > 0) { + const now = performance.now(); + const at = new Date().toISOString().slice(11, 23); + for (const t of pending.splice(0)) { + const latency = now - t; + stats.record(latency, at); + if (logPath) appendFileSync(logPath, `${JSON.stringify({ t: new Date().toISOString(), latencyMs: Math.round(latency) })}\n`); + } + } + return stats.formatLines(); + }, + }; + tui.showOverlay(overlay, { nonCapturing: true, anchor: 'top-right', margin: 0 }); +} diff --git a/apps/kimi-code/src/tui/utils/markdown-options.ts b/apps/kimi-code/src/tui/utils/markdown-options.ts new file mode 100644 index 0000000000000000000000000000000000000000..d765e599b177816c4d7b00c5648947f8227ce88f --- /dev/null +++ b/apps/kimi-code/src/tui/utils/markdown-options.ts @@ -0,0 +1,21 @@ +/** + * Shared Markdown behavior options (distinct from the visual theme). + * + * Holds the process-wide LaTeX toggle from tui.toml so transcript components + * don't each need the config threaded through construction. Mirrors the + * render-cache toggle pattern (see utils/render-cache.ts). + */ + +import type { MarkdownOptions } from '@moonshot-ai/pi-tui'; + +// Default on, matching upstream pi-tui; overridden from tui.toml at startup +// and on /reload. +let renderLatex = true; + +export function setMarkdownRenderLatex(value: boolean): void { + renderLatex = value; +} + +export function createMarkdownOptions(): MarkdownOptions { + return { renderLatex }; +} diff --git a/apps/kimi-code/src/tui/utils/mcp-oauth.ts b/apps/kimi-code/src/tui/utils/mcp-oauth.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0c17b043c791a9db2da5cdab6246d6a4a4d8d41 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/mcp-oauth.ts @@ -0,0 +1,51 @@ +import { + MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE, + type McpOAuthAuthorizationUrlUpdateData, + type ToolProgressEvent, + type ToolUpdate, +} from '@moonshot-ai/kimi-code-sdk'; + +export type OpenUrl = (url: string) => void; + +export class McpOAuthAuthorizationUrlOpener { + private readonly openedAuthorizationUrls = new Set<string>(); + + constructor(private readonly openUrl: OpenUrl) {} + + handleToolProgress(event: Pick<ToolProgressEvent, 'toolCallId' | 'update'>): void { + const update = parseMcpOAuthAuthorizationUrlUpdate(event.update); + if (update === undefined) return; + const key = `${event.toolCallId}\0${update.authorizationUrl}`; + if (this.openedAuthorizationUrls.has(key)) return; + this.openedAuthorizationUrls.add(key); + this.openUrl(update.authorizationUrl); + } +} + +export function parseMcpOAuthAuthorizationUrlUpdate( + update: ToolUpdate, +): McpOAuthAuthorizationUrlUpdateData | undefined { + if (update.kind !== 'custom') return undefined; + if (update.customKind !== MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE) return undefined; + const data = update.customData; + if (!isRecord(data)) return undefined; + const serverName = data['serverName']; + const authorizationUrl = data['authorizationUrl']; + if (typeof serverName !== 'string' || serverName.length === 0) return undefined; + if (typeof authorizationUrl !== 'string' || authorizationUrl.length === 0) return undefined; + if (!isHttpUrl(authorizationUrl)) return undefined; + return { serverName, authorizationUrl }; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} diff --git a/apps/kimi-code/src/tui/utils/mcp-server-status.ts b/apps/kimi-code/src/tui/utils/mcp-server-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9ab6a663a7b89ea2a47a19482fc60f48a6e9a8c --- /dev/null +++ b/apps/kimi-code/src/tui/utils/mcp-server-status.ts @@ -0,0 +1,77 @@ +import type { McpServerInfo, McpServerStatusEvent } from '@moonshot-ai/kimi-code-sdk'; + +export type McpServerStatusSnapshot = McpServerInfo | McpServerStatusEvent['server']; + +export const MCP_STARTUP_STATUS_ROW_LIMIT = 4; + +function mcpStartupStatusPriority(status: McpServerStatusSnapshot['status']): number { + switch (status) { + case 'failed': + return 0; + case 'needs-auth': + return 1; + case 'pending': + return 2; + case 'connected': + return 3; + case 'disabled': + return 4; + case 'removed': + return 5; + } +} + +export function selectMcpStartupStatusRows( + servers: readonly McpServerStatusSnapshot[], +): McpServerStatusSnapshot[] { + return [...servers] + .filter((server) => server.status !== 'disabled' && server.status !== 'removed') + .toSorted((a, b) => mcpStartupStatusPriority(a.status) - mcpStartupStatusPriority(b.status)) + .slice(0, MCP_STARTUP_STATUS_ROW_LIMIT); +} + +export function formatMcpStartupStatusSummary( + servers: readonly McpServerStatusSnapshot[], +): string { + let failed = 0; + let needsAuth = 0; + let connecting = 0; + let connected = 0; + let disabled = 0; + let removed = 0; + for (const server of servers) { + switch (server.status) { + case 'failed': + failed++; + break; + case 'needs-auth': + needsAuth++; + break; + case 'pending': + connecting++; + break; + case 'connected': + connected++; + break; + case 'disabled': + disabled++; + break; + case 'removed': + removed++; + break; + } + } + + const parts: string[] = []; + if (failed > 0) parts.push(`${failed} failed`); + if (needsAuth > 0) parts.push(`${needsAuth} need auth`); + if (connecting > 0) parts.push(`${connecting} connecting`); + if (connected > 0) parts.push(`${connected} connected`); + if (disabled > 0) parts.push(`${disabled} disabled`); + if (removed > 0) parts.push(`${removed} removed`); + return parts.join(', '); +} + +export function mcpServerStatusKey(server: McpServerStatusSnapshot): string { + return JSON.stringify([server.status, server.transport, server.toolCount, server.error]); +} diff --git a/apps/kimi-code/src/tui/utils/mcp-tool-name.ts b/apps/kimi-code/src/tui/utils/mcp-tool-name.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd5998a82ae3565a5153e5af988466017c29ccab --- /dev/null +++ b/apps/kimi-code/src/tui/utils/mcp-tool-name.ts @@ -0,0 +1,16 @@ +// Decodes the `mcp__<server>__<tool>` qualified names produced by kimi-core's +// `qualifyMcpToolName`. Returns null for non-MCP tools and for hash-truncated +// qualified names (where the trailing `__<tool>` segment has been collapsed). +export function decodeMcpToolName( + name: string, +): { readonly serverName: string; readonly toolName: string } | null { + const PREFIX = 'mcp__'; + if (!name.startsWith(PREFIX)) return null; + const rest = name.slice(PREFIX.length); + const sep = rest.indexOf('__'); + if (sep <= 0 || sep === rest.length - 2) return null; + return { + serverName: rest.slice(0, sep), + toolName: rest.slice(sep + 2), + }; +} diff --git a/apps/kimi-code/src/tui/utils/media-url.ts b/apps/kimi-code/src/tui/utils/media-url.ts new file mode 100644 index 0000000000000000000000000000000000000000..14bc4eba8e4763656b7ee4c540a8bd2cddfe7e72 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/media-url.ts @@ -0,0 +1,57 @@ +import { isDaemonFileUrl } from '@moonshot-ai/kimi-code-sdk'; + +export type MediaUrlKind = 'audio' | 'image' | 'video'; + +export function mediaUrlPartToText(kind: MediaUrlKind, url: string): string { + const summary = summarizeDataUrl(url); + if (summary !== undefined) { + const size = summary.bytes !== undefined ? `, ${formatByteSize(summary.bytes)}` : ''; + return `[${kind} ${summary.mime}${size}]`; + } + // An internal daemon file reference (`kimi-file://…?path=…`) never renders + // its wire form: the scheme resolves nowhere for the user and the query + // carries the materialization path. Render the bare placeholder instead. + if (isDaemonFileUrl(url)) return `[${kind}]`; + return `<${kind} url="${escapeAttribute(url)}">`; +} + +export function summarizeDataUrl(url: string): { mime: string; bytes?: number } | undefined { + if (!url.startsWith('data:')) return undefined; + const commaIndex = url.indexOf(','); + const header = + commaIndex >= 0 ? url.slice('data:'.length, commaIndex) : url.slice('data:'.length); + const data = commaIndex >= 0 ? url.slice(commaIndex + 1) : ''; + const [rawMime, ...params] = header.split(';'); + const mime = rawMime !== undefined && rawMime.length > 0 ? rawMime : 'application/octet-stream'; + const isBase64 = params.some((param) => param.toLowerCase() === 'base64'); + return { + mime, + bytes: isBase64 ? estimateBase64Bytes(data) : undefined, + }; +} + +function estimateBase64Bytes(data: string): number { + const compact = data.replaceAll(/\s/g, ''); + if (compact.length === 0) return 0; + const padding = compact.endsWith('==') ? 2 : compact.endsWith('=') ? 1 : 0; + return Math.max(0, Math.floor((compact.length * 3) / 4) - padding); +} + +function formatByteSize(bytes: number): string { + if (bytes < 1024) return `${String(bytes)} B`; + const kib = bytes / 1024; + if (kib < 1024) return `${formatOneDecimal(kib)} KB`; + return `${formatOneDecimal(kib / 1024)} MB`; +} + +function formatOneDecimal(value: number): string { + return value >= 10 ? value.toFixed(0) : value.toFixed(1); +} + +function escapeAttribute(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts new file mode 100644 index 0000000000000000000000000000000000000000..08e34b28737b73b919148bb48b50987d3281b0f1 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -0,0 +1,393 @@ +import type { + AgentReplayRecord, + BackgroundTaskInfo, + BackgroundTaskStatus, + ContentPart, + ContextMessage, + PromptOrigin, + ResumedAgentState, + ToolCall, +} from '@moonshot-ai/kimi-code-sdk'; +import { limitAgentReplayByTurns } from '@moonshot-ai/kimi-code-sdk'; + +import type { + AppState, + BackgroundAgentMetadata, + SkillActivationTrigger, + ToolCallBlockData, + TranscriptEntry, +} from '#/tui/types'; + +import { modelDisplayName } from '../components/dialogs/model-selector'; +import { mediaUrlPartToText } from './media-url'; +import { nextTranscriptId } from './transcript-id'; + +export const REPLAY_TURN_LIMIT = 10; + +/** + * Resume fetches one extra turn of records: the SDK trims the replay to the + * requested limit before returning it, and a trim that lands between a + * bundled prompt and the hook results recorded immediately before it would + * make them unrecoverable. The extra margin lets the TUI-side limiter + * (session-replay's preserveBundleHookResults) do the final cut without + * losing them. + */ +export const REPLAY_FETCH_TURN_LIMIT = REPLAY_TURN_LIMIT + 1; + +export interface ReplayRenderContext { + turnIndex: number; + stepIndex: number; + currentTurnId: string | undefined; + assistant: { + thinking: string[]; + text: string[]; + }; + toolCalls: Map<string, ToolCallBlockData>; + completedToolCallIds: Set<string>; + skillActivationIds: Set<string>; + pluginCommandActivationIds: Set<string>; + suppressNextPlanModeOffNotice: boolean; +} + +export interface SkillActivationProjection { + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; + readonly trigger: SkillActivationTrigger; + /** The activation rode a bundled prompt message, not a standalone one. */ + readonly bundled?: boolean; +} + +export interface PluginCommandProjection { + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly commandArgs?: string; + readonly trigger: 'user-slash'; +} + +export interface ReplayBackgroundProjection { + readonly backgroundAgentMetadata: ReadonlyMap<string, BackgroundAgentMetadata>; +} + +export function appStateFromResumeAgent(agent: ResumedAgentState): Partial<AppState> { + const maxContextTokens = agent.config.modelCapabilities?.max_context_tokens ?? 0; + const contextTokens = agent.context.tokenCount; + const contextUsage = maxContextTokens > 0 ? contextTokens / maxContextTokens : 0; + return { + model: agent.config.modelAlias ?? agent.config.provider?.model ?? '', + contextTokens, + maxContextTokens, + contextUsage, + planMode: agent.plan !== null, + swarmMode: agent.swarmMode ?? false, + permissionMode: agent.permission.mode, + }; +} + +export function isTerminalBackgroundTask(info: BackgroundTaskInfo): boolean { + return ( + info.status === 'completed' || + info.status === 'failed' || + info.status === 'timed_out' || + info.status === 'killed' || + info.status === 'lost' + ); +} + +export function countActiveBackgroundTasks(tasks: ReadonlyMap<string, BackgroundTaskInfo>): { + bashTasks: number; + agentTasks: number; +} { + let bashTasks = 0; + let agentTasks = 0; + for (const info of tasks.values()) { + if (isTerminalBackgroundTask(info)) continue; + if (info.kind === 'agent') { + agentTasks += 1; + } else { + bashTasks += 1; + } + } + return { bashTasks, agentTasks }; +} + +export function replayBackgroundProjection( + background: readonly BackgroundTaskInfo[], + availableModels?: AppState['availableModels'], +): ReplayBackgroundProjection { + const backgroundAgentMetadata = new Map<string, BackgroundAgentMetadata>(); + for (const info of background) { + if (info.kind !== 'agent') continue; + if (isTerminalBackgroundTask(info)) continue; + const agentId = info.agentId ?? info.taskId; + backgroundAgentMetadata.set(agentId, { + agentId, + parentToolCallId: info.taskId, + description: info.description, + // The persisted task record carries the spawn-time model/effort (v2); + // keep them across a resume so the terminal transcript entry can show + // them. Model maps through the catalog like the live path; boolean + // effort states carry no level and are dropped. + model: + info.model === undefined + ? undefined + : modelDisplayName(info.model, availableModels?.[info.model]), + effort: + info.thinkingEffort === undefined || + info.thinkingEffort === 'off' || + info.thinkingEffort === 'on' + ? undefined + : info.thinkingEffort, + }); + } + return { backgroundAgentMetadata }; +} + +export function createReplayRenderContext(): ReplayRenderContext { + return { + turnIndex: 0, + stepIndex: 0, + currentTurnId: undefined, + assistant: { thinking: [], text: [] }, + toolCalls: new Map(), + completedToolCallIds: new Set(), + skillActivationIds: new Set(), + pluginCommandActivationIds: new Set(), + suppressNextPlanModeOffNotice: false, + }; +} + +export function limitReplayRecordsByTurn( + records: readonly AgentReplayRecord[], + maxTurns: number, +): readonly AgentReplayRecord[] { + // Defensive slice — the core already trims the replay when the caller passes + // `replayTurnLimit` on resume; the boundary predicate lives in the SDK + // (`limitAgentReplayByTurns`). + return limitAgentReplayByTurns(records, maxTurns); +} + +export function replayEntry( + context: ReplayRenderContext, + kind: TranscriptEntry['kind'], + content: string, + renderMode: TranscriptEntry['renderMode'], + extras: { detail?: string; bullet?: string } = {}, +): TranscriptEntry { + return { + id: nextTranscriptId(), + kind, + turnId: context.currentTurnId, + renderMode, + content, + detail: extras.detail, + bullet: extras.bullet, + }; +} + +export function collectReplayMessageContent( + target: ReplayRenderContext['assistant'], + content: readonly ContentPart[], +): void { + for (const part of content) { + switch (part.type) { + case 'think': + if (part.hidden !== true) { + target.thinking.push(part.think); + } + break; + case 'text': + target.text.push(part.text); + break; + case 'audio_url': + case 'image_url': + case 'video_url': + break; + } + } +} + +export function toolCallFromReplayMessage( + rawToolCall: ToolCall, + context: ReplayRenderContext, +): ToolCallBlockData | undefined { + const id = rawToolCall.id; + const name = rawToolCall.name; + if (id.length === 0 || name.length === 0) return undefined; + return { + id, + name, + args: parseReplayToolArguments(rawToolCall.arguments), + step: context.stepIndex, + turnId: context.currentTurnId, + }; +} + +export function toolResultOutput(content: readonly ContentPart[]): string { + if (content.some((part) => part.type !== 'text')) { + return JSON.stringify(content); + } + return contentPartsToText(content); +} + +export function contentPartsToText(content: readonly ContentPart[]): string { + // A daemon-ref media part is self-contained and renders as a bare + // `[image]`/`[video]` placeholder downstream — neither the materialization + // path nor the internal `kimi-file://` url may surface as user text. A + // standalone `<media path>` tag is user text and stays verbatim. + return content.map(contentPartToText).join(''); +} + +/** + * agent-core-v2's task domain persists the terminal notification under the + * 'task' spelling (v1 used 'background_task'); both reach replay verbatim. + */ +export interface TaskNotificationOrigin { + readonly kind: 'task'; + readonly taskId: string; + readonly status: BackgroundTaskStatus; + readonly notificationId: string; +} + +export type BackgroundTaskNotificationOrigin = + | Extract<PromptOrigin, { kind: 'background_task' }> + | TaskNotificationOrigin; + +export function backgroundOrigin( + message: ContextMessage, +): BackgroundTaskNotificationOrigin | undefined { + const origin = message.origin as BackgroundTaskNotificationOrigin | undefined; + return origin?.kind === 'background_task' || origin?.kind === 'task' ? origin : undefined; +} + +export function skillActivationFromOrigin( + origin: PromptOrigin | undefined, +): SkillActivationProjection | undefined { + if (origin?.kind !== 'skill_activation') return undefined; + return { + activationId: origin.activationId, + skillName: origin.skillName, + skillArgs: origin.skillArgs, + trigger: origin.trigger, + }; +} + +/** + * The v2 engine bundles a prompt's inline skill activations into the prompt + * message itself: the rendered skill blocks precede the caller's parts in + * the content, and this origin field carries every activation's metadata so + * replay can rebuild the per-skill cards from the single message. The SDK's + * origin union is typed from the v1 engine, which never sets the field, so + * read it structurally here instead of widening the deprecated v1 package's + * types. + */ +export function bundledSkillsFromOrigin( + origin: PromptOrigin | undefined, +): readonly SkillActivationProjection[] { + if (origin?.kind !== 'user') return []; + const activations = ( + origin as { + readonly skillActivations?: readonly { + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; + }[]; + } + ).skillActivations; + if (activations === undefined) return []; + return activations.map((activation) => ({ + activationId: activation.activationId, + skillName: activation.skillName, + skillArgs: activation.skillArgs, + trigger: 'user-slash' as const, + bundled: true, + })); +} + +/** + * Content parts the caller actually typed: the engine prepends one rendered + * text part per bundled skill, so the caller's own parts start right after + * them. + */ +export function stripBundledSkillParts(message: ContextMessage): readonly ContentPart[] { + const bundledCount = bundledSkillsFromOrigin(message.origin).length; + return bundledCount === 0 ? message.content : message.content.slice(bundledCount); +} + +export function pluginCommandFromOrigin( + origin: PromptOrigin | undefined, +): PluginCommandProjection | undefined { + if (origin?.kind !== 'plugin_command') return undefined; + return { + activationId: origin.activationId, + pluginId: origin.pluginId, + commandName: origin.commandName, + commandArgs: origin.commandArgs, + trigger: origin.trigger, + }; +} + +export function formatHookResultMessageForTranscript( + text: string, + fallbackEvent: string, + blocked: boolean, +): string { + const results: Array<{ event: string; body: string }> = []; + let lastIndex = 0; + + for (const match of text.matchAll(HOOK_RESULT_RE)) { + if (text.slice(lastIndex, match.index).trim().length > 0) { + return formatHookResultBlock(fallbackEvent, text, blocked); + } + const event = match[1]; + const body = match[2]; + if (event === undefined || body === undefined) { + return formatHookResultBlock(fallbackEvent, text, blocked); + } + results.push({ event, body }); + lastIndex = match.index + match[0].length; + } + + if (results.length === 0 || text.slice(lastIndex).trim().length > 0) { + return formatHookResultBlock(fallbackEvent, text, blocked); + } + + return results.map(({ event, body }) => formatHookResultBlock(event, body, blocked)).join('\n\n'); +} + +function parseReplayToolArguments(value: string | null): Record<string, unknown> { + if (value === null || value.length === 0) return {}; + try { + const parsed = JSON.parse(value); + return isRecord(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function contentPartToText(part: ContentPart): string { + switch (part.type) { + case 'text': + return part.text; + case 'think': + return part.think; + case 'image_url': + return mediaUrlPartToText('image', part.imageUrl.url); + case 'video_url': + return mediaUrlPartToText('video', part.videoUrl.url); + case 'audio_url': + return mediaUrlPartToText('audio', part.audioUrl.url); + } +} + +const HOOK_RESULT_RE = + /<hook_result\s+hook_event="([^"]+)">\n?([\s\S]*?)\n?<\/hook_result>/g; + +function formatHookResultBlock(event: string, body: string, blocked: boolean): string { + return `*${event} hook${blocked ? ' blocked' : ''}*\n\n${body.trim() || '(empty)'}`; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null; +} diff --git a/apps/kimi-code/src/tui/utils/notify-result.ts b/apps/kimi-code/src/tui/utils/notify-result.ts new file mode 100644 index 0000000000000000000000000000000000000000..120af014e3711c99f7bab698c40552ecf80c37d8 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/notify-result.ts @@ -0,0 +1,5 @@ +export function notifyResultState(output: unknown): 'displayed' | 'suppressed' | undefined { + if (output === 'Update shown to the user.') return 'displayed'; + if (output === 'Notifications are disabled; the update was not displayed.') return 'suppressed'; + return undefined; +} diff --git a/apps/kimi-code/src/tui/utils/object-patch.ts b/apps/kimi-code/src/tui/utils/object-patch.ts new file mode 100644 index 0000000000000000000000000000000000000000..a44f6f566174f99e7d224a02fff7b23865cb1aef --- /dev/null +++ b/apps/kimi-code/src/tui/utils/object-patch.ts @@ -0,0 +1,8 @@ +// Returns true when applying `patch` would change at least one own property. +// Used before UI refresh paths so repeated equivalent state patches are cheap. +export function hasPatchChanges<T extends object>(target: T, patch: Partial<T>): boolean { + for (const key of Object.keys(patch) as Array<keyof T>) { + if (!Object.is(target[key], patch[key])) return true; + } + return false; +} diff --git a/apps/kimi-code/src/tui/utils/osc133.ts b/apps/kimi-code/src/tui/utils/osc133.ts new file mode 100644 index 0000000000000000000000000000000000000000..3273fe15aa2eada7aa214be91d1a6297f065bf6c --- /dev/null +++ b/apps/kimi-code/src/tui/utils/osc133.ts @@ -0,0 +1,34 @@ +/** + * OSC 133 zone marking for transcript messages. The fullscreen renderer + * anchors previous/next-prompt navigation on lines whose first bytes are an + * OSC 133;A zone marker (and strips the markers at paint), so the marks must + * survive every container between the message component and the ScrollView. + */ + +import { + OSC133_ZONE_END, + OSC133_ZONE_FINAL, + OSC133_ZONE_START, +} from '#/tui/constant/rendering'; + +// One or more consecutive A/B/C zone markers anchored at the line start. +const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/; + +/** + * Mark a message's rendered lines as a semantic zone: A on the first line, + * B+C on the last. Mutates and returns the given array — call it on freshly + * built lines before handing them to a render cache (cached lines then + * already carry the marks, so they are never marked twice). + */ +export function markOsc133Zone(lines: string[]): string[] { + if (lines.length === 0) return lines; + lines[0] = OSC133_ZONE_START + lines[0]!; + lines[lines.length - 1] = OSC133_ZONE_END + OSC133_ZONE_FINAL + lines[lines.length - 1]!; + return lines; +} + +/** Prefix a rendered line while keeping any leading OSC 133 zone at byte 0. */ +export function prefixPreservingOsc133Zone(line: string, prefix: string): string { + const zone = OSC133_ZONE_PREFIX.exec(line)?.[0]; + return zone === undefined ? prefix + line : zone + prefix + line.slice(zone.length); +} diff --git a/apps/kimi-code/src/tui/utils/paging.ts b/apps/kimi-code/src/tui/utils/paging.ts new file mode 100644 index 0000000000000000000000000000000000000000..797124abf62056e881aa7c715bd5afb843008ce7 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/paging.ts @@ -0,0 +1,28 @@ +/** + * Pure paging math shared by list pickers (ChoicePicker, ModelSelector). + * + * The component owns a single `selectedIndex` into its (already filtered) + * item list; the page is derived from it, so ↑↓ moves the cursor smoothly + * across page boundaries while the view still shows an explicit page number. + */ + +export interface PageView { + /** Zero-based index of the page containing `selectedIndex`. */ + readonly page: number; + /** Total number of pages; always at least 1, even for an empty list. */ + readonly pageCount: number; + /** Inclusive slice start of the current page. */ + readonly start: number; + /** Exclusive slice end of the current page (clamped to `total`). */ + readonly end: number; +} + +export function pageView(total: number, selectedIndex: number, pageSize: number): PageView { + const size = Math.max(1, Math.floor(pageSize)); + const pageCount = Math.max(1, Math.ceil(total / size)); + const safeIndex = total <= 0 ? 0 : Math.min(Math.max(0, selectedIndex), total - 1); + const page = Math.min(Math.floor(safeIndex / size), pageCount - 1); + const start = page * size; + const end = Math.min(start + size, total); + return { page, pageCount, start, end }; +} diff --git a/apps/kimi-code/src/tui/utils/permission-mode.ts b/apps/kimi-code/src/tui/utils/permission-mode.ts new file mode 100644 index 0000000000000000000000000000000000000000..6df9eb1d0948025ef5795e120575d4e98fd40671 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/permission-mode.ts @@ -0,0 +1,13 @@ +import type { PermissionMode } from '@moonshot-ai/kimi-code-sdk'; + +export const PERMISSION_MODE_DISPLAY_NAMES: Readonly<Record<PermissionMode, string>> = { + manual: 'Always Ask', + yolo: 'Ask When Needed', + auto: 'Never Ask', +}; + +export const PERMISSION_MODE_DESCRIPTIONS: Readonly<Record<PermissionMode, string>> = { + manual: 'Auto-read only; everything else needs your approval first.', + yolo: 'Routine edits and commands run automatically; risky actions, questions, and plans still ask.', + auto: 'Never interrupts you; everything runs and is decided automatically.', +}; diff --git a/apps/kimi-code/src/tui/utils/plugin-source-label.ts b/apps/kimi-code/src/tui/utils/plugin-source-label.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5d8ee41ae60071277435ff7ddcb7ccaed2dd2a0 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/plugin-source-label.ts @@ -0,0 +1,110 @@ +import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk'; + +export const OFFICIAL_BADGE = 'official'; +export const CURATED_BADGE = 'curated'; +export const THIRD_PARTY_BADGE = 'third-party'; + +export type PluginTrustLabel = 'official' | 'curated' | 'third-party'; + +// Trusted plugin hosts come in .com / .ai region pairs: code.kimi.* is the +// per-region marketplace CDN (cdnBase), cdn.kimi.* the content CDN. Both +// families are trusted regardless of the current region — a zip served by +// either deployment is still an official build. +const CODE_CDN_HOSTS = new Set(['code.kimi.com', 'code.kimi.ai']); +const CONTENT_CDN_HOSTS = new Set(['cdn.kimi.com', 'cdn.kimi.ai']); + +/** + * Human-readable provenance label for a plugin, suitable for inline display + * in `/plugins` overviews and lists. + * + * - github source → `github <owner>/<repo>@<ref>` + * - zip-url with parseable URL → `via <host[:port]>` + * - everything else → raw source kind (`local-path`, `zip-url`) + */ +export function formatPluginSourceLabel(plugin: PluginSummary): string { + if (plugin.source === 'github' && plugin.github !== undefined) { + return `github ${plugin.github.owner}/${plugin.github.repo}@${plugin.github.ref.value}`; + } + if (plugin.source === 'zip-url' && plugin.originalSource !== undefined) { + const host = hostFromUrl(plugin.originalSource); + if (host !== undefined) return `via ${host}`; + } + return plugin.source; +} + +/** + * Returns one of three trust labels for a plugin. Only Kimi-hosted plugin zip + * paths receive official or curated badges. Everything else is third-party. + */ +export function pluginTrustLabel(plugin: PluginSummary): PluginTrustLabel { + if (plugin.source !== 'zip-url' || plugin.originalSource === undefined) { + return 'third-party'; + } + try { + const url = new URL(plugin.originalSource); + if (isOfficialPluginUrl(url)) { + return 'official'; + } + if ( + url.protocol === 'https:' && + CODE_CDN_HOSTS.has(url.hostname) && + url.pathname.startsWith('/kimi-code/plugins/curated/') + ) { + return 'curated'; + } + return 'third-party'; + } catch { + return 'third-party'; + } +} + +/** + * Returns true only for install sources that are unambiguously Kimi-built + * official plugins — an https URL under the official Kimi CDN plugin path. + * Everything else (local paths, GitHub repos, curated or third-party URLs) + * is treated as unofficial and should be confirmed before install. + */ +export function isOfficialPluginSource(source: string): boolean { + const trimmed = source.trim(); + if (!trimmed.startsWith('https://')) return false; + try { + return isOfficialPluginUrl(new URL(trimmed)); + } catch { + return false; + } +} + +/** + * Returns true when an installed plugin provably came from a trusted official + * source — a zip download under the official CDN plugin path. Local paths, + * GitHub repos, and third-party URLs do not qualify, even when their manifest + * id matches an official plugin. + */ +export function isOfficialPluginInstall(plugin: PluginSummary): boolean { + return ( + plugin.source === 'zip-url' && + plugin.originalSource !== undefined && + isOfficialPluginSource(plugin.originalSource) + ); +} + +function isOfficialPluginUrl(url: URL): boolean { + if (url.protocol !== 'https:') return false; + return ( + (CODE_CDN_HOSTS.has(url.hostname) && + url.pathname.startsWith('/kimi-code/plugins/official/')) || + (CONTENT_CDN_HOSTS.has(url.hostname) && + (url.pathname.startsWith('/kimi-computer-use/') || + url.pathname.startsWith('/kimi-computer-use-windows/'))) + ); +} + +function hostFromUrl(raw: string): string | undefined { + try { + const url = new URL(raw); + if (url.port.length > 0) return `${url.hostname}:${url.port}`; + return url.hostname; + } catch { + return undefined; + } +} diff --git a/apps/kimi-code/src/tui/utils/printable-key.ts b/apps/kimi-code/src/tui/utils/printable-key.ts new file mode 100644 index 0000000000000000000000000000000000000000..d165d52a4710630ee93662aeae0ea005ce52f1d0 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/printable-key.ts @@ -0,0 +1,38 @@ +/** + * Decode raw stdin bytes into a comparable printable character. + * + * When a terminal (e.g. the VSCode integrated terminal) enables the Kitty + * keyboard protocol disambiguate flag, ordinary printable keys are sent as + * CSI-u sequences: pressing `r` arrives as "\x1b[114u", pressing `q` as + * "\x1b[113u". A bare `data === 'q'` comparison inside a Container's + * `handleInput` therefore never matches under Kitty-mode terminals. + * + * Rules: + * - Every bare-literal printable-character comparison (letters, digits, + * space, punctuation) must go through this function first. + * - Functional keys (arrows, Enter, Tab, Esc, ...) continue to use + * `matchesKey(data, Key.*)`; pi-tui's `matchesKey` already handles Kitty. + * - Control characters (codepoint < 32, e.g. ctrl-b, ctrl-f) may still + * compare against the raw `data` — `decodeKittyPrintable` rejects them. + * + * The module's existence is itself the "don't forget to decode" constraint: + * `test/tui/printable-key-guard.test.ts` scans every `handleInput` under + * `tui/components/**` and rejects bare-literal comparisons. + */ + +import { decodeKittyPrintable } from '@moonshot-ai/pi-tui'; + +export function printableChar(data: string): string { + return decodeKittyPrintable(data) ?? data; +} + +/** + * True when a decoded key is a single printable character safe to append to a + * text query (e.g. a search box). Rejects C0 control chars, DEL, and any + * multi-codepoint escape sequence. Space is accepted. + */ +export function isPrintableChar(ch: string): boolean { + if (ch.length !== 1) return false; + const code = ch.codePointAt(0)!; + return code >= 0x20 && code !== 0x7f; +} diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts new file mode 100644 index 0000000000000000000000000000000000000000..0801575f987b0c7eeb9df44ebbe05d3d0f25415b --- /dev/null +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -0,0 +1,46 @@ +import { + refreshProviderModels, + type ProviderChange, + type RefreshProviderOptions, + type RefreshProviderScope, + type RefreshResult, +} from '@moonshot-ai/kimi-code-oauth'; +import type { KimiConfig, KimiConfigPatch, OAuthRef } from '@moonshot-ai/kimi-code-sdk'; + +/** + * CLI-side host for provider-model refresh. Kept on the SDK's full config types + * so existing TUI callers (and tests) don't change; the daemon uses the oauth + * package's `ManagedKimiConfigShape`-typed host directly. + */ +export interface RefreshProviderHost { + getConfig(): Promise<KimiConfig>; + removeProvider(providerId: string): Promise<KimiConfig>; + setConfig(patch: KimiConfigPatch): Promise<KimiConfig>; + resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise<string>; + /** Product User-Agent sent on custom-registry (api.json) fetches. */ + readonly userAgent?: string; +} + +export type { ProviderChange, RefreshProviderOptions, RefreshProviderScope, RefreshResult }; + +/** + * Refresh remote model metadata for the configured providers. Thin adapter over + * the shared `refreshProviderModels` orchestrator in `@moonshot-ai/kimi-code-oauth` + * (which is also what the daemon's scheduled/manual refresh uses). + */ +export async function refreshAllProviderModels( + host: RefreshProviderHost, + options: RefreshProviderOptions = {}, +): Promise<RefreshResult> { + return refreshProviderModels( + { + getConfig: () => host.getConfig(), + removeProvider: (providerId) => host.removeProvider(providerId), + setConfig: (patch) => host.setConfig(patch as unknown as KimiConfigPatch), + resolveOAuthToken: (providerName, oauthRef) => + host.resolveOAuthToken(providerName, oauthRef as unknown as OAuthRef), + userAgent: host.userAgent, + }, + options, + ); +} diff --git a/apps/kimi-code/src/tui/utils/render-cache.ts b/apps/kimi-code/src/tui/utils/render-cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..628d80fae58b1fe717a537a7cf448b476492b671 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/render-cache.ts @@ -0,0 +1,28 @@ +/** + * Render-cache toggle for TUI message components. + * + * The transcript re-renders the entire component tree on every frame, and + * most message components rebuild their `render(width)` output from scratch + * even when their content has not changed. Caching the rendered lines (keyed + * on width + a dirty flag) turns an unchanged message's render into an O(1) + * array reference return, which is the dominant per-frame cost once the + * transcript grows long. + * + * The cache is on by default and can be disabled with + * `KIMI_TUI_NO_RENDER_CACHE=1` as an escape hatch (and to let benchmarks + * compare cached vs. uncached runs in the same process). + */ + +let enabled = process.env['KIMI_TUI_NO_RENDER_CACHE'] !== '1'; + +export function isRenderCacheEnabled(): boolean { + return enabled; +} + +/** + * Override the cache at runtime. Intended for benchmarks / tests only; + * production code should not call this. + */ +export function setRenderCacheEnabled(value: boolean): void { + enabled = value; +} diff --git a/apps/kimi-code/src/tui/utils/screen-takeover.ts b/apps/kimi-code/src/tui/utils/screen-takeover.ts new file mode 100644 index 0000000000000000000000000000000000000000..05107a84e73345ab0c67a70c6e7ddb5d93adff72 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/screen-takeover.ts @@ -0,0 +1,38 @@ +/** + * Mode-aware full-screen viewer takeover. + * + * In regular mode a viewer is mounted by snapshotting the root container's + * children and swapping the viewer in. In fullscreen (alternate screen) the + * root children are not painted at all — the layout root is — so the viewer + * must become the layout root instead. Both shapes restore cleanly and nest + * (a viewer opened from another viewer). + */ + +import type { Component, TUI } from '@moonshot-ai/pi-tui'; +import { TuiAltScreen } from '@moonshot-ai/pi-tui'; + +/** Restore data for a screen takeover; opaque to callers. */ +export type ScreenTakeover = + | { readonly kind: 'children'; readonly children: readonly Component[] } + | { readonly kind: 'root'; readonly root: Component | undefined }; + +export function beginScreenTakeover(ui: TUI, viewer: Component): ScreenTakeover { + if (ui instanceof TuiAltScreen) { + const root = ui.getLayoutRoot(); + ui.setLayoutRoot(viewer); + return { kind: 'root', root }; + } + const children = [...ui.children]; + ui.clear(); + ui.addChild(viewer); + return { kind: 'children', children }; +} + +export function endScreenTakeover(ui: TUI, takeover: ScreenTakeover): void { + if (takeover.kind === 'root') { + if (ui instanceof TuiAltScreen) ui.setLayoutRoot(takeover.root); + return; + } + ui.clear(); + for (const child of takeover.children) ui.addChild(child); +} diff --git a/apps/kimi-code/src/tui/utils/searchable-list.ts b/apps/kimi-code/src/tui/utils/searchable-list.ts new file mode 100644 index 0000000000000000000000000000000000000000..20770338038c48f6e3178eaa147f8f5474fcf3f0 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/searchable-list.ts @@ -0,0 +1,149 @@ +/** + * Cursor + fuzzy-search + paging state machine shared by list pickers + * (ChoicePicker, ModelSelector). Pure logic, no rendering. + * + * The component owns presentation and the keys that carry component-specific + * meaning — Enter (submit), Esc (cancel), and ←/→ (paging in one picker, a + * thinking toggle in another). This unit owns the keys that behave identically + * everywhere: ↑/↓, PgUp/PgDn, and search editing. + */ + +import { fuzzyFilter, Key, matchesKey } from '@moonshot-ai/pi-tui'; + +import { pageView, type PageView } from './paging'; +import { isPrintableChar, printableChar } from './printable-key'; + +const DEFAULT_PAGE_SIZE = 8; + +export interface SearchableListOptions<T> { + readonly items: readonly T[]; + /** Text a list item is fuzzy-matched against. */ + readonly toSearchText: (item: T) => string; + /** Items per page; defaults to 8. */ + readonly pageSize?: number; + /** Initial cursor position (clamped to >= 0). */ + readonly initialIndex?: number; + /** When false, typed characters are ignored. Defaults to false. */ + readonly searchable?: boolean; +} + +export interface SearchableListView<T> { + /** Items after the active query filter. */ + readonly items: readonly T[]; + /** Page math for the current cursor over {@link items}. */ + readonly page: PageView; + /** Cursor clamped into the current {@link items} range. */ + readonly selectedIndex: number; + readonly query: string; +} + +export class SearchableList<T> { + private items: readonly T[]; + private readonly toSearchText: (item: T) => string; + private readonly pageSize: number; + private readonly searchable: boolean; + private query = ''; + private cursor: number; + + constructor(opts: SearchableListOptions<T>) { + this.items = opts.items; + this.toSearchText = opts.toSearchText; + this.pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE; + this.searchable = opts.searchable ?? false; + this.cursor = Math.max(opts.initialIndex ?? 0, 0); + } + + /** + * Replaces the item set (e.g. after another page was appended), keeping the + * active query; the cursor is clamped into the new range. + */ + setItems(items: readonly T[]): void { + this.items = items; + this.cursor = Math.min(this.cursor, Math.max(0, items.length - 1)); + } + + filtered(): readonly T[] { + if (this.query.length === 0) return this.items; + return fuzzyFilter([...this.items], this.query, this.toSearchText); + } + + /** The item under the cursor, clamped into the filtered range. */ + selected(): T | undefined { + const items = this.filtered(); + if (items.length === 0) return undefined; + return items[Math.min(this.cursor, items.length - 1)]; + } + + view(): SearchableListView<T> { + const items = this.filtered(); + return { + items, + page: pageView(items.length, this.cursor, this.pageSize), + selectedIndex: Math.min(this.cursor, Math.max(0, items.length - 1)), + query: this.query, + }; + } + + moveUp(): void { + this.cursor = Math.max(0, this.cursor - 1); + } + + moveDown(): void { + this.cursor = Math.min(Math.max(0, this.filtered().length - 1), this.cursor + 1); + } + + pageUp(): void { + this.cursor = Math.max(0, this.cursor - this.pageSize); + } + + pageDown(): void { + this.cursor = Math.min(Math.max(0, this.filtered().length - 1), this.cursor + this.pageSize); + } + + /** Clears the active query and resets the cursor. Returns whether a query was cleared. */ + clearQuery(): boolean { + if (this.query.length === 0) return false; + this.query = ''; + this.cursor = 0; + return true; + } + + /** + * Handles the keys every picker shares: ↑/↓, PgUp/PgDn, and — when searchable — + * Backspace and printable characters. Returns true when the key was consumed. + * Enter, Esc, and ←/→ are intentionally left to the component. + */ + handleKey(data: string): boolean { + if (matchesKey(data, Key.up)) { + this.moveUp(); + return true; + } + if (matchesKey(data, Key.down)) { + this.moveDown(); + return true; + } + if (matchesKey(data, Key.pageUp)) { + this.pageUp(); + return true; + } + if (matchesKey(data, Key.pageDown)) { + this.pageDown(); + return true; + } + if (!this.searchable) return false; + if (matchesKey(data, Key.backspace)) { + if (this.query.length > 0) { + this.query = this.query.slice(0, -1); + this.cursor = 0; + } + return true; + } + const ch = printableChar(data); + if (isPrintableChar(ch)) { + this.query += ch; + this.cursor = 0; + return true; + } + return false; + } +} diff --git a/apps/kimi-code/src/tui/utils/session-picker-rows.ts b/apps/kimi-code/src/tui/utils/session-picker-rows.ts new file mode 100644 index 0000000000000000000000000000000000000000..55e063246a9af15c8be9bbe03dfb0a632ca90cd7 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/session-picker-rows.ts @@ -0,0 +1,20 @@ +import type { SessionSummary } from '@moonshot-ai/kimi-code-sdk'; + +import type { SessionRow } from '#/tui/components/dialogs/session-picker'; + +export function sessionRowsForPicker( + sessions: readonly SessionSummary[], + currentSessionId: string, + currentSessionHasContent: boolean, +): SessionRow[] { + return sessions + .filter((session) => currentSessionHasContent || session.id !== currentSessionId) + .map((session) => ({ + id: session.id, + title: session.title ?? null, + last_prompt: session.lastPrompt ?? null, + work_dir: session.workDir, + updated_at: session.updatedAt ?? session.createdAt ?? 0, + metadata: session.metadata, + })); +} diff --git a/apps/kimi-code/src/tui/utils/shell-output.ts b/apps/kimi-code/src/tui/utils/shell-output.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a482feb73715c02ed47cab066e7ef8d126efc26 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/shell-output.ts @@ -0,0 +1,71 @@ +import { currentTheme } from '#/tui/theme'; + +// Captured command output can contain terminal control sequences — colours, +// cursor moves, alternate-screen switches, hyperlinks, `\r` spinners, bells, … +// We render through pi-tui, which passes strings straight to the terminal, so +// any sequence left intact is executed by the terminal and fights with pi-tui's +// own cursor control (the "blank screen + leftover characters" symptom). Strip +// everything a terminal would interpret as a command rather than printable text, +// keeping only `\n` and `\t` (which the renderer understands). + +// ESC [ <params> <intermediates> <final> — colours, cursor moves, clear, and +// private modes such as ESC[?1049h (alt screen) / ESC[?25l (hide cursor). +const CSI_PATTERN = /\u001B\[[0-9:;<=>?]*[ -/]*[@-~]/g; +// ESC ] … <BEL> or ESC ] … ESC \ — window titles and OSC 8 hyperlinks. +const OSC_PATTERN = /\u001B\][\s\S]*?(?:\u0007|\u001B\\)/g; +// ESC <char> (and ESC <intermediate> <char>) — charset/keypad selection, +// save/restore cursor (ESC 7 / ESC 8), full reset (ESC c), etc. Runs after the +// CSI/OSC patterns, so it only catches sequences they didn't already consume. +const ESC_SINGLE_PATTERN = /\u001B(?:[ -/][0-~]|[0-~])/g; +// C0 control characters except \n (0x0A) and \t (0x09): NUL, BEL, \b, \r, … +// plus a lone ESC (0x1B) that wasn't part of a sequence recognised above. +const C0_CONTROL_PATTERN = /[\u0000-\u0008\u000B-\u001B\u001C-\u001F]/g; + +/** + * Strip every terminal control sequence from captured command output so it is + * safe to render via pi-tui (which does not sanitize on its own). + * + * Never throws: a bad or pathological input falls back to stripping only the + * C0 control characters, so rendering can never crash the TUI. + */ +export function sanitizeShellOutput(text: string): string { + if (typeof text !== 'string') return ''; + if (text.length === 0) return text; + try { + return text + .replace(OSC_PATTERN, '') + .replace(CSI_PATTERN, '') + .replace(ESC_SINGLE_PATTERN, '') + .replace(C0_CONTROL_PATTERN, ''); + } catch { + return text.replace(C0_CONTROL_PATTERN, ''); + } +} + +/** + * Format captured stdout/stderr for the transcript. Sanitizes both streams and + * dims them; stderr is red only on actual failure. + * + * Never throws: if anything goes wrong (theme lookup, huge input, …) it falls + * back to a best-effort plain view so a render error can never crash the TUI. + */ +export function formatBashOutputForDisplay(stdout: string, stderr: string, isError?: boolean): string { + try { + const dim = (s: string): string => currentTheme.fg('textDim', s); + const parts: string[] = []; + const cleanStdout = sanitizeShellOutput(stdout).trimEnd(); + if (cleanStdout.length > 0) parts.push(dim(cleanStdout)); + const cleanStderr = sanitizeShellOutput(stderr).trimEnd(); + if (cleanStderr.length > 0) { + // Dim grey normally; red only on actual failure (so warnings on a + // successful command are not mistaken for errors). + parts.push(isError ? currentTheme.fg('error', cleanStderr) : dim(cleanStderr)); + } + return parts.length > 0 ? parts.join('\n') : dim('(no output)'); + } catch { + const plain = [sanitizeShellOutput(String(stdout ?? '')), sanitizeShellOutput(String(stderr ?? ''))] + .filter((s) => s.length > 0) + .join('\n'); + return plain.length > 0 ? plain : '(no output)'; + } +} diff --git a/apps/kimi-code/src/tui/utils/startup.ts b/apps/kimi-code/src/tui/utils/startup.ts new file mode 100644 index 0000000000000000000000000000000000000000..e29561e9dd57ada8a680db9f86ae382bacc7885b --- /dev/null +++ b/apps/kimi-code/src/tui/utils/startup.ts @@ -0,0 +1,15 @@ +import { OAUTH_LOGIN_REQUIRED_CODE } from '../constant/kimi-tui'; + +export function combineStartupNotice( + existing: string | undefined, + next: string | undefined, +): string | undefined { + if (existing !== undefined && next !== undefined) { + return `${existing}\n${next}`; + } + return existing ?? next; +} + +export function isOAuthLoginRequiredError(error: unknown): boolean { + return (error as { readonly code?: unknown }).code === OAUTH_LOGIN_REQUIRED_CODE; +} diff --git a/apps/kimi-code/src/tui/utils/status-line-command.ts b/apps/kimi-code/src/tui/utils/status-line-command.ts new file mode 100644 index 0000000000000000000000000000000000000000..6482a4ac7cf25e2dad9b3c0e6af7a0e988680e87 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/status-line-command.ts @@ -0,0 +1,185 @@ +/** + * User-provided status line command (`status_line.command` in tui.toml). + * + * The footer spawns the command with a JSON snapshot on stdin and renders the + * first stdout line. Runs are throttled and time-boxed; any failure (spawn + * error, nonzero exit, timeout) yields null so the caller falls back to the + * built-in layout. Mirrors Claude Code's statusLine contract at the seam: + * JSON in, first line out, 300ms ceiling. + */ + +import { spawn } from 'node:child_process'; + +export const STATUS_LINE_COMMAND_TIMEOUT_MS = 300; +export const STATUS_LINE_RERUN_INTERVAL_MS = 1_000; +export const STATUS_LINE_MAX_CAPTURE_BYTES = 65_536; + +export interface StatusLinePayload { + model: string; + cwd: string; + gitBranch: string | null; + permissionMode: string; + planMode: boolean; + contextUsage: number; + contextTokens: number; + maxContextTokens: number; + sessionId: string; + version: string; +} + +export function runStatusLineCommand( + command: string, + payload: StatusLinePayload, + timeoutMs: number = STATUS_LINE_COMMAND_TIMEOUT_MS, +): Promise<string | null> { + return new Promise((resolve) => { + let settled = false; + const finish = (value: string | null): void => { + if (settled) return; + settled = true; + resolve(value); + }; + + const isWin = process.platform === 'win32'; + let child; + try { + child = spawn(isWin ? (process.env['ComSpec'] ?? 'cmd.exe') : 'sh', isWin ? ['/d', '/s', '/c', command] : ['-c', command], { + stdio: ['pipe', 'pipe', 'ignore'], + env: { ...process.env, KIMI_CODE_STATUS_LINE: '1' }, + // Own process group on POSIX so a timeout can drop the whole tree, + // not just the shell wrapper. + detached: !isWin, + }); + } catch { + finish(null); + return; + } + + const killTree = (): void => { + if (child.pid === undefined) return; + if (isWin) { + try { + spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); + } catch { + // best effort + } + } else { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + child.kill('SIGKILL'); + } + } + }; + + const timer = setTimeout(() => { + killTree(); + finish(null); + }, timeoutMs); + timer.unref?.(); + + let stdout = ''; + child.stdout?.setEncoding('utf-8'); + child.stdout?.on('data', (chunk: string) => { + if (stdout.includes('\n')) return; // first line is complete + stdout += chunk; + // Only the first line is ever used; stop accumulating past it (and cap + // a missing-newline stream) so a chatty command can't grow memory + // unboundedly before the timeout lands. + const cut = stdout.indexOf('\n'); + if (cut >= 0) { + stdout = stdout.slice(0, cut + 1); + } else if (stdout.length > STATUS_LINE_MAX_CAPTURE_BYTES) { + stdout = stdout.slice(0, STATUS_LINE_MAX_CAPTURE_BYTES); + } + }); + child.on('error', () => { + clearTimeout(timer); + finish(null); + }); + child.on('close', (code) => { + clearTimeout(timer); + if (code !== 0) { + finish(null); + return; + } + const firstLine = (stdout.split('\n')[0] ?? '').trimEnd(); + finish(firstLine.length > 0 ? firstLine : null); + }); + + child.stdin?.on('error', () => { + // The command closed stdin early (e.g. `true`); nothing more to send. + }); + child.stdin?.end(JSON.stringify(payload)); + }); +} + +/** + * Throttled cache around `runStatusLineCommand` for a sync render path: + * `current()` returns the last good line, and a refresh is kicked off in the + * background at most once per interval. `onUpdate` fires when a fresh line + * lands so the footer can repaint. + */ +export class StatusLineCommandRunner { + private lastRunAt = 0; + private cached: string | null = null; + private inFlight = false; + private pendingPayload: StatusLinePayload | null = null; + private trailingTimer: ReturnType<typeof setTimeout> | null = null; + + constructor( + readonly command: string, + private readonly onUpdate: () => void, + ) {} + + current(): string | null { + return this.cached; + } + + maybeRefresh(payload: StatusLinePayload): void { + const now = Date.now(); + if (this.inFlight || now - this.lastRunAt < STATUS_LINE_RERUN_INTERVAL_MS) { + // Don't drop the update: land it as soon as the current gap expires, + // so a final state change is never lost to throttling. + this.pendingPayload = payload; + this.scheduleTrailing(now); + return; + } + this.startRun(payload, now); + } + + dispose(): void { + if (this.trailingTimer !== null) { + clearTimeout(this.trailingTimer); + this.trailingTimer = null; + } + this.pendingPayload = null; + } + + private scheduleTrailing(now: number): void { + if (this.trailingTimer !== null) return; + const waitMs = Math.max(0, STATUS_LINE_RERUN_INTERVAL_MS - (now - this.lastRunAt)); + this.trailingTimer = setTimeout(() => { + this.trailingTimer = null; + const pending = this.pendingPayload; + this.pendingPayload = null; + if (pending !== null) this.maybeRefresh(pending); + }, waitMs); + this.trailingTimer.unref?.(); + } + + private startRun(payload: StatusLinePayload, now: number): void { + this.inFlight = true; + this.lastRunAt = now; + void runStatusLineCommand(this.command, payload).then((line) => { + this.inFlight = false; + if (line !== null) { + this.cached = line; + this.onUpdate(); + } + const pending = this.pendingPayload; + this.pendingPayload = null; + if (pending !== null) this.maybeRefresh(pending); + }); + } +} diff --git a/apps/kimi-code/src/tui/utils/steer-input.ts b/apps/kimi-code/src/tui/utils/steer-input.ts new file mode 100644 index 0000000000000000000000000000000000000000..d69c999d8de61b257bc2254e1643e2ad2f4916ac --- /dev/null +++ b/apps/kimi-code/src/tui/utils/steer-input.ts @@ -0,0 +1,56 @@ +/** + * Steer-input composition for `session.steer`: flattens queued items (and the + * editor draft) into one payload — the historical `'\n\n'`-joined string when + * nothing carries media, or a merged part list when any item has extracted + * media parts (queued image messages, or the editor draft after placeholder + * extraction). Media parts are self-contained daemon references; no machine + * `<media path>` tag is authored, so text parts always merge freely. + */ + +import type { PromptPart } from '@moonshot-ai/kimi-code-sdk'; + +import type { SteerInputItem } from '../types'; + +/** + * Flatten steer items into the payload `session.steer` expects. + * + * Items are separated by the historical `'\n\n'`, which merges into the + * adjacent text part. The one exception is two touching media parts: a + * standalone `{type:'text',text:'\n\n'}` between them would be rejected + * by `normalizePromptInput` as an empty text part, so the separator is + * dropped there (media parts are self-delimiting anyway). + */ +export function combineSteerInput(items: readonly SteerInputItem[]): string | PromptPart[] { + const hasMedia = items.some((item) => item.parts !== undefined && item.parts.length > 0); + if (!hasMedia) return items.map((item) => item.text).join('\n\n'); + const parts: PromptPart[] = []; + for (const item of items) { + const first = item.parts?.[0]; + const startsWithMedia = first !== undefined && first.type !== 'text'; + const lastIsMedia = parts.length > 0 && parts.at(-1)?.type !== 'text'; + if (parts.length > 0 && !(lastIsMedia && startsWithMedia)) { + appendSteerText(parts, '\n\n'); + } + if (item.parts !== undefined && item.parts.length > 0) { + for (const part of item.parts) { + if (part.type !== 'text') { + parts.push(part); + continue; + } + appendSteerText(parts, part.text); + } + } else { + appendSteerText(parts, item.text); + } + } + return parts; +} + +function appendSteerText(parts: PromptPart[], text: string): void { + const last = parts.at(-1); + if (last?.type === 'text') { + parts[parts.length - 1] = { type: 'text', text: last.text + text }; + return; + } + parts.push({ type: 'text', text }); +} diff --git a/apps/kimi-code/src/tui/utils/step-retry.ts b/apps/kimi-code/src/tui/utils/step-retry.ts new file mode 100644 index 0000000000000000000000000000000000000000..34a79887863d3cc81ec8e05f7dcdd37254f6bfa2 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/step-retry.ts @@ -0,0 +1,19 @@ +import { RETRY_DETAIL_MAX_CHARS } from '../constant/rendering'; +import type { StepRetryState } from '../types'; + +export function formatStepRetryLabel(retry: StepRetryState): string { + const base = `Retrying (${retry.nextAttempt}/${retry.maxAttempts}) · ${retry.errorName}`; + if (retry.phase === 'attempt') return base; + const delaySeconds = Math.max(1, Math.ceil(retry.delayMs / 1000)); + return `${base} · in ${delaySeconds}s`; +} + +/** Detail line under the spinner: status code + provider message, single-line, capped. */ +export function formatStepRetryDetail(retry: StepRetryState): string { + const message = retry.errorMessage.replaceAll(/\s+/g, ' ').trim(); + const code = retry.statusCode === undefined ? '' : String(retry.statusCode); + const detail = [code, message].filter((part) => part.length > 0).join(' · '); + return detail.length > RETRY_DETAIL_MAX_CHARS + ? `${detail.slice(0, RETRY_DETAIL_MAX_CHARS - 1)}…` + : detail; +} diff --git a/apps/kimi-code/src/tui/utils/survey-policy.ts b/apps/kimi-code/src/tui/utils/survey-policy.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ca6a949797a9adfde69096f79fc83ff5039b459 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/survey-policy.ts @@ -0,0 +1,379 @@ + +import type { SurveyPopupConfig } from '#/utils/survey-popup-config'; + +import { + SURVEY_IDLE_STABILITY_MS, + SURVEY_MIN_OPTIONS_WIDTH, + surveyMinTotalHeight, +} from '../constant/survey'; + +export type SurveyKind = 'session' | 'long_context'; + +export type SurveyGateSkipReason = + | 'mount-roll-consumed' + | 'survey-active' + | 'turn-in-progress' + | 'idle-too-short' + | 'ordered-list-ambiguity' + | 'prompt-active' + | 'editor-bash-active' + | 'editor-autocomplete-active' + | 'external-editor-active' + | 'terminal-too-narrow' + | 'terminal-too-short' + | 'feature-disabled' + | 'telemetry-disabled' + | 'model-gated' + | 'warmup' + | 'pacing' + | 'threshold-invalid' + | 'below-threshold' + | 'sampled-out' + | 'global-cooldown'; + +export interface SharedArmGateInput { + readonly phase: SurveyPhase; + readonly turnInProgress: boolean; + readonly idleForMs: number; + readonly promptActive: boolean; + readonly editorBashActive: boolean; + readonly editorAutocompleteActive: boolean; + readonly externalEditorActive: boolean; + readonly terminalWidth: number; + readonly terminalHeight: number; + readonly feedbackSurveyDisabled: boolean; + readonly telemetryDisabled: boolean; + readonly kfcModelId: string | undefined; + readonly lastUserMessageStartsOrderedList: boolean; +} + +export interface SessionArmGateInput extends SharedArmGateInput { + readonly mountedForMs: number; + readonly userTurnsSinceMount: number; + readonly msSinceLastShown: number | undefined; + readonly userTurnsSinceLastShown: number | undefined; + readonly sample: number; + readonly msSinceGlobalLastShown: number | undefined; +} + +export interface LongContextArmGateInput extends SharedArmGateInput { + readonly cumulativeTokens: number; + readonly virtualContextTokens: number; + readonly mountRollConsumed: boolean; + readonly drawMountRoll: () => number; +} + +export interface SurveyGateInput { + readonly session: SessionArmGateInput; + readonly longContext: LongContextArmGateInput; + readonly config: SurveyPopupConfig; +} + +export type SurveyGateVerdict = + | { + readonly show: true; + readonly survey: SurveyKind; + readonly longContextRollConsumed?: boolean; + } + | { + readonly show: false; + readonly reason: SurveyGateSkipReason; + readonly longContextRollConsumed?: boolean; + }; + +function modelGatePasses(onForModels: readonly string[], kfcModelId: string | undefined): boolean { + if (onForModels.length === 0) return false; + if (onForModels.includes('*')) return true; + return kfcModelId !== undefined && onForModels.includes(kfcModelId); +} + +function evaluateSessionArm(input: SurveyGateInput): SurveyGateVerdict { + const { session, config } = input; + if (session.phase !== 'closed') return { show: false, reason: 'survey-active' }; + if (session.turnInProgress) return { show: false, reason: 'turn-in-progress' }; + if (session.idleForMs < SURVEY_IDLE_STABILITY_MS) { + return { show: false, reason: 'idle-too-short' }; + } + if (session.lastUserMessageStartsOrderedList) { + return { show: false, reason: 'ordered-list-ambiguity' }; + } + if (session.promptActive) return { show: false, reason: 'prompt-active' }; + if (session.editorBashActive) return { show: false, reason: 'editor-bash-active' }; + if (session.editorAutocompleteActive) { + return { show: false, reason: 'editor-autocomplete-active' }; + } + if (session.externalEditorActive) { + return { show: false, reason: 'external-editor-active' }; + } + if (session.terminalWidth < SURVEY_MIN_OPTIONS_WIDTH) { + return { show: false, reason: 'terminal-too-narrow' }; + } + if (session.terminalHeight < surveyMinTotalHeight(session.terminalWidth)) { + return { show: false, reason: 'terminal-too-short' }; + } + if (session.feedbackSurveyDisabled) return { show: false, reason: 'feature-disabled' }; + if (session.telemetryDisabled) return { show: false, reason: 'telemetry-disabled' }; + if (!modelGatePasses(config.on_for_models, session.kfcModelId)) { + return { show: false, reason: 'model-gated' }; + } + if (session.msSinceLastShown === undefined) { + if ( + session.mountedForMs < config.min_time_before_feedback_ms || + session.userTurnsSinceMount < config.min_user_turns_before_feedback + ) { + return { show: false, reason: 'warmup' }; + } + } else if ( + session.msSinceLastShown < config.min_time_between_feedback_ms || + (session.userTurnsSinceLastShown ?? 0) < config.min_user_turns_between_feedback + ) { + return { show: false, reason: 'pacing' }; + } + if (session.sample >= config.probability) return { show: false, reason: 'sampled-out' }; + if ( + session.msSinceGlobalLastShown !== undefined && + session.msSinceGlobalLastShown < config.min_time_between_global_feedback_ms + ) { + return { show: false, reason: 'global-cooldown' }; + } + return { show: true, survey: 'session' }; +} + +export function evaluateLongContextArm(input: SurveyGateInput): SurveyGateVerdict { + const { longContext, config } = input; + if (longContext.mountRollConsumed) return { show: false, reason: 'mount-roll-consumed' }; + if (longContext.phase !== 'closed') return { show: false, reason: 'survey-active' }; + if (longContext.turnInProgress) return { show: false, reason: 'turn-in-progress' }; + if (longContext.idleForMs < SURVEY_IDLE_STABILITY_MS) { + return { show: false, reason: 'idle-too-short' }; + } + if (longContext.lastUserMessageStartsOrderedList) { + return { show: false, reason: 'ordered-list-ambiguity' }; + } + if (longContext.promptActive) return { show: false, reason: 'prompt-active' }; + if (longContext.editorBashActive) return { show: false, reason: 'editor-bash-active' }; + if (longContext.editorAutocompleteActive) { + return { show: false, reason: 'editor-autocomplete-active' }; + } + if (longContext.externalEditorActive) { + return { show: false, reason: 'external-editor-active' }; + } + if (longContext.terminalWidth < SURVEY_MIN_OPTIONS_WIDTH) { + return { show: false, reason: 'terminal-too-narrow' }; + } + if (longContext.terminalHeight < surveyMinTotalHeight(longContext.terminalWidth)) { + return { show: false, reason: 'terminal-too-short' }; + } + if (longContext.feedbackSurveyDisabled) return { show: false, reason: 'feature-disabled' }; + if (longContext.telemetryDisabled) return { show: false, reason: 'telemetry-disabled' }; + if (!modelGatePasses(config.on_for_models, longContext.kfcModelId)) { + return { show: false, reason: 'model-gated' }; + } + if (!(config.long_context_survey_threshold > 0)) { + return { show: false, reason: 'threshold-invalid' }; + } + const counter = + config.long_context_trigger_mode === 'cumulative' + ? longContext.cumulativeTokens + : longContext.virtualContextTokens; + if (counter < config.long_context_survey_threshold) { + return { show: false, reason: 'below-threshold' }; + } + if (longContext.drawMountRoll() >= config.long_context_probability) { + return { show: false, reason: 'sampled-out', longContextRollConsumed: true }; + } + return { show: true, survey: 'long_context', longContextRollConsumed: true }; +} + +export function evaluateSurveyGate(input: SurveyGateInput): SurveyGateVerdict { + const longContext = evaluateLongContextArm(input); + if (longContext.show) return longContext; + const session = evaluateSessionArm(input); + if (longContext.longContextRollConsumed === true) { + return { ...session, longContextRollConsumed: true }; + } + return session; +} + +export type SurveyPhase = 'closed' | 'open' | 'pending' | 'thanks'; + +export type SurveyResponse = 'bad' | 'fine' | 'good' | 'dismissed'; + +export type SurveyEventType = 'appeared' | 'responded' | 'abandoned'; + +export interface SurveyAppearance { + readonly survey: SurveyKind; + readonly appearanceId: string; + readonly appearanceIndex: number; +} + +export interface SurveyMachineState { + readonly phase: SurveyPhase; + readonly appearance: SurveyAppearance | undefined; + readonly response: SurveyResponse | undefined; +} + +export const SURVEY_MACHINE_CLOSED: SurveyMachineState = { + phase: 'closed', + appearance: undefined, + response: undefined, +}; + +const SURVEY_PRIORITY: Record<SurveyKind, number> = { session: 0, long_context: 1 }; + +export type SurveyMachineAction = + | { readonly type: 'open'; readonly appearance: SurveyAppearance } + | { readonly type: 'select'; readonly response: 'bad' | 'fine' | 'good' } + | { readonly type: 'dismiss' } + | { readonly type: 'undo' } + | { readonly type: 'settle' } + | { readonly type: 'thanks-elapsed' } + | { readonly type: 'abandon' } + | { readonly type: 'close-silently' }; + +export type SurveyMachineEffect = + | { + readonly type: 'report'; + readonly eventType: SurveyEventType; + readonly response?: SurveyResponse; + } + | { readonly type: 'schedule'; readonly timer: 'pending-settle' | 'thanks-close' }; + +export interface SurveyMachineTransition { + readonly state: SurveyMachineState; + readonly effects: readonly SurveyMachineEffect[]; +} + +const NO_EFFECTS: readonly SurveyMachineEffect[] = []; + +function noTransition(state: SurveyMachineState): SurveyMachineTransition { + return { state, effects: NO_EFFECTS }; +} + +export function surveyMachineReduce( + state: SurveyMachineState, + action: SurveyMachineAction, +): SurveyMachineTransition { + switch (action.type) { + case 'open': { + if (state.phase === 'closed') { + return { + state: { phase: 'open', appearance: action.appearance, response: undefined }, + effects: [{ type: 'report', eventType: 'appeared' }], + }; + } + if (state.phase !== 'open' || state.appearance === undefined) { + return noTransition(state); + } + if (SURVEY_PRIORITY[action.appearance.survey] <= SURVEY_PRIORITY[state.appearance.survey]) { + return noTransition(state); + } + return { + state: { phase: 'open', appearance: action.appearance, response: undefined }, + effects: [{ type: 'report', eventType: 'appeared' }], + }; + } + case 'select': { + if (state.phase !== 'open') return noTransition(state); + return { + state: { ...state, phase: 'pending', response: action.response }, + effects: [{ type: 'schedule', timer: 'pending-settle' }], + }; + } + case 'dismiss': { + if (state.phase !== 'open') return noTransition(state); + return { + state: SURVEY_MACHINE_CLOSED, + effects: [{ type: 'report', eventType: 'responded', response: 'dismissed' }], + }; + } + case 'undo': { + if (state.phase !== 'pending') return noTransition(state); + return { + state: { ...state, phase: 'open', response: undefined }, + effects: NO_EFFECTS, + }; + } + case 'settle': { + if (state.phase !== 'pending' || state.response === undefined) { + return noTransition(state); + } + return { + state: { ...state, phase: 'thanks' }, + effects: [ + { type: 'report', eventType: 'responded', response: state.response }, + { type: 'schedule', timer: 'thanks-close' }, + ], + }; + } + case 'thanks-elapsed': { + if (state.phase !== 'thanks') return noTransition(state); + return { state: SURVEY_MACHINE_CLOSED, effects: NO_EFFECTS }; + } + case 'abandon': { + if (state.phase !== 'open') return noTransition(state); + return { + state: SURVEY_MACHINE_CLOSED, + effects: [{ type: 'report', eventType: 'abandoned' }], + }; + } + case 'close-silently': { + if (state.phase === 'closed') return noTransition(state); + if (state.phase === 'pending' && state.response !== undefined) { + return { + state: SURVEY_MACHINE_CLOSED, + effects: [{ type: 'report', eventType: 'responded', response: state.response }], + }; + } + return { state: SURVEY_MACHINE_CLOSED, effects: NO_EFFECTS }; + } + } +} + +export const SURVEY_EVENT_NAMES: Record<SurveyKind, string> = { + session: 'feedback_survey', + long_context: 'long_context_survey', +}; + +export interface SurveyEventCoreFields { + readonly event_type: SurveyEventType; + readonly appearance_id: string; + readonly appearance_index: number; + readonly response?: SurveyResponse; +} + +export interface SurveyEventEnvironmentFields { + readonly current_model: string; + readonly kfc_model_id?: string; + readonly user_turn_count: number; + readonly cumulative_tokens: number; + readonly virtual_context_tokens: number; + readonly tool_call_count: number; + readonly compaction_count: number; + readonly permission_mode: string; + readonly thinking_effort: string; +} + +export function buildSurveyEventProperties( + core: SurveyEventCoreFields, + environment: SurveyEventEnvironmentFields, + config: SurveyPopupConfig, +): Record<string, string | number | undefined> { + return { + event_type: core.event_type, + appearance_id: core.appearance_id, + appearance_index: core.appearance_index, + response: core.response, + ...environment, + config_probability: config.probability, + config_on_for_models: config.on_for_models.join(','), + config_min_time_before_feedback_ms: config.min_time_before_feedback_ms, + config_min_user_turns_before_feedback: config.min_user_turns_before_feedback, + config_min_time_between_feedback_ms: config.min_time_between_feedback_ms, + config_min_user_turns_between_feedback: config.min_user_turns_between_feedback, + config_min_time_between_global_feedback_ms: config.min_time_between_global_feedback_ms, + config_long_context_survey_threshold: config.long_context_survey_threshold, + config_long_context_probability: config.long_context_probability, + config_long_context_trigger_mode: config.long_context_trigger_mode, + }; +} diff --git a/apps/kimi-code/src/tui/utils/tab-strip.ts b/apps/kimi-code/src/tui/utils/tab-strip.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4b5099a761b31a7af1f7f2c0dab90e902af48fd --- /dev/null +++ b/apps/kimi-code/src/tui/utils/tab-strip.ts @@ -0,0 +1,94 @@ +/** + * Shared tab strip renderer for tabbed dialogs (model selector, plugin + * marketplace, …). The active tab is filled with the brand background, inactive + * tabs are muted — matching the AskUserQuestion dialog. See + * .agents/skills/write-tui/DESIGN.md §5. + * + * When the strip is wider than the terminal, it scrolls to keep the active tab + * visible, framed by `<`/`>` markers. + */ + +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export interface RenderTabStripOptions { + readonly labels: readonly string[]; + readonly activeIndex: number; + readonly width: number; + readonly colors: ColorPalette; +} + +/** Style one tab cell. Active and inactive cells have the same visible width so + * switching never shifts the layout. */ +function styleTab(label: string, isActive: boolean, colors: ColorPalette): string { + const cell = ` ${label} `; + return isActive + ? chalk.bgHex(colors.primary).hex(colors.text).bold(cell) + : chalk.hex(colors.textMuted)(cell); +} + +export function renderTabStrip(opts: RenderTabStripOptions): string { + const { labels, activeIndex, width, colors } = opts; + const segments = labels.map((label, i) => styleTab(label, i === activeIndex, colors)); + + // If everything fits with a leading space, show the whole strip. Account for + // the single spaces `segments.join(' ')` inserts between tabs — otherwise the + // strip is declared to fit at widths where the joined line is actually wider + // and gets truncated instead of showing the `<`/`>` scroll markers. + const totalSegmentWidth = segments.reduce((sum, s) => sum + visibleWidth(s), 0); + const fullSeparatorWidth = Math.max(0, segments.length - 1); + if (1 + totalSegmentWidth + fullSeparatorWidth <= width) { + return ' ' + segments.join(' '); + } + + // Scrolling needed. Find the widest window that contains activeIndex. + const segmentWidths = segments.map((s) => visibleWidth(s)); + let start = activeIndex; + let end = activeIndex + 1; + let contentWidth = segmentWidths[activeIndex] ?? 0; + + const fits = (s: number, e: number, cw: number): boolean => { + const needLeft = s > 0; + const needRight = e < segments.length; + const frameWidth = (needLeft ? 2 : 1) + (needRight ? 2 : 0); + const separators = Math.max(0, e - s - 1); + return cw + separators + frameWidth <= width; + }; + + while (true) { + const leftW = start > 0 ? segmentWidths[start - 1]! : Infinity; + const rightW = end < segments.length ? segmentWidths[end]! : Infinity; + if (leftW === Infinity && rightW === Infinity) break; + + if (leftW <= rightW) { + if (fits(start - 1, end, contentWidth + leftW)) { + contentWidth += leftW; + start--; + } else if (fits(start, end + 1, contentWidth + rightW)) { + contentWidth += rightW; + end++; + } else { + break; + } + } else if (fits(start, end + 1, contentWidth + rightW)) { + contentWidth += rightW; + end++; + } else if (fits(start - 1, end, contentWidth + leftW)) { + contentWidth += leftW; + start--; + } else { + break; + } + } + + const hasLeft = start > 0; + const hasRight = end < segments.length; + let strip = hasLeft ? chalk.hex(colors.textMuted)('< ') : ' '; + strip += segments.slice(start, end).join(' '); + if (hasRight) { + strip += chalk.hex(colors.textMuted)(' >'); + } + return strip; +} diff --git a/apps/kimi-code/src/tui/utils/terminal-focus.ts b/apps/kimi-code/src/tui/utils/terminal-focus.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc07e92d34041fa598e9660081ab02bdcb2cc0cc --- /dev/null +++ b/apps/kimi-code/src/tui/utils/terminal-focus.ts @@ -0,0 +1,44 @@ +import { + DISABLE_TERMINAL_FOCUS_REPORTING, + ENABLE_TERMINAL_FOCUS_REPORTING, + TERMINAL_FOCUS_IN, + TERMINAL_FOCUS_OUT, +} from '#/tui/constant/terminal'; +import type { TUIState } from '#/tui/tui-state'; +import type { TerminalState } from '#/tui/utils/terminal-state'; + +export { + DISABLE_TERMINAL_FOCUS_REPORTING, + ENABLE_TERMINAL_FOCUS_REPORTING, + TERMINAL_FOCUS_IN, + TERMINAL_FOCUS_OUT, +} from '#/tui/constant/terminal'; + +export function installTerminalFocusTracking(state: TUIState): () => void { + state.terminalState.focused = true; + const disposeInputListener = state.ui.addInputListener((data) => + handleTerminalFocusInput(state.terminalState, data), + ); + state.terminal.write(ENABLE_TERMINAL_FOCUS_REPORTING); + + return () => { + disposeInputListener(); + state.terminal.write(DISABLE_TERMINAL_FOCUS_REPORTING); + state.terminalState.focused = true; + }; +} + +export function handleTerminalFocusInput( + state: Pick<TerminalState, 'focused'>, + data: string, +): { consume: true } | undefined { + if (data === TERMINAL_FOCUS_IN) { + state.focused = true; + return { consume: true }; + } + if (data === TERMINAL_FOCUS_OUT) { + state.focused = false; + return { consume: true }; + } + return undefined; +} diff --git a/apps/kimi-code/src/tui/utils/terminal-notification.ts b/apps/kimi-code/src/tui/utils/terminal-notification.ts new file mode 100644 index 0000000000000000000000000000000000000000..c71f41df9f4800cbfd69dd9e8ecf3b16aa2a323b --- /dev/null +++ b/apps/kimi-code/src/tui/utils/terminal-notification.ts @@ -0,0 +1,148 @@ +import type { Terminal } from '@moonshot-ai/pi-tui'; + +import { BEL, ESC, MAX_TERMINAL_NOTIFICATION_MESSAGE_LENGTH, ST } from '#/tui/constant/terminal'; +import type { TUIState } from '#/tui/tui-state'; + +export interface TerminalNotification { + readonly title: string; + readonly body?: string | undefined; +} + +export interface EmitOptions { + readonly supportsOsc9?: boolean; + readonly insideTmux?: boolean; +} + +export interface BuildOptions { + readonly supportsOsc9: boolean; + readonly insideTmux: boolean; +} + +export function notifyTerminalOnce( + state: TUIState, + key: string, + notification: TerminalNotification, +): void { + const { enabled, condition } = state.appState.notifications; + if (!enabled) return; + if (state.terminalState.notificationKeys.has(key)) return; + state.terminalState.notificationKeys.add(key); + if (condition === 'unfocused' && state.terminalState.focused) return; + emitTerminalNotification(state.terminal, notification, { + supportsOsc9: state.terminalState.supportsOsc9, + insideTmux: state.terminalState.insideTmux, + }); +} + +export function emitTerminalNotification( + terminal: Pick<Terminal, 'write'>, + notification: TerminalNotification, + options: EmitOptions = {}, +): void { + const sequences = buildTerminalNotificationSequences(notification, { + supportsOsc9: options.supportsOsc9 ?? supportsOsc9Notification(), + insideTmux: options.insideTmux ?? isInsideTmux(), + }); + for (const sequence of sequences) { + terminal.write(sequence); + } +} + +export function formatNotification(notification: TerminalNotification): string { + const title = sanitizeNotificationText(notification.title); + const body = sanitizeNotificationText(notification.body ?? ''); + const message = + title.length > 0 && body.length > 0 ? `${title}: ${body}` : title.length > 0 ? title : body; + return message.slice(0, MAX_TERMINAL_NOTIFICATION_MESSAGE_LENGTH); +} + +/** + * Build the OSC/BEL bytes for a terminal notification. + * + * - `supportsOsc9 === true`: emit a single OSC 9 sequence — the modern + * desktop-notification path used by iTerm2, WezTerm, Kitty, Ghostty + * and Warp. + * - `supportsOsc9 === false`: fall back to a bare BEL so the user still + * gets the system bell on terminals that don't recognize OSC 9. + * + * When `insideTmux === true` and we're emitting OSC 9, wrap the sequence + * in a tmux DCS passthrough (`ESC P tmux ; <payload> ESC \`) and double + * any `ESC` bytes inside the payload — otherwise tmux swallows the OSC. + * BEL is single-byte and passes through tmux unchanged, so no wrap is + * needed in the fallback path. + */ +export function buildTerminalNotificationSequences( + notification: TerminalNotification, + options: BuildOptions, +): string[] { + const message = formatNotification(notification); + if (message.length === 0) return []; + if (!options.supportsOsc9) { + return [BEL]; + } + const osc9 = `${ESC}]9;${message}${BEL}`; + if (options.insideTmux) { + const escaped = osc9.replaceAll(ESC, `${ESC}${ESC}`); + return [`${ESC}Ptmux;${escaped}${ESC}${ST}`]; + } + return [osc9]; +} + +/** + * Best-effort detection of OSC 9 desktop-notification support, driven + * entirely off well-known environment variables. The allow-list is + * intentionally short and conservative because BEL is safe everywhere, + * while shipping OSC 9 to a terminal that doesn't grok it would print + * escape garbage on screen. + */ +export function supportsOsc9Notification(env: NodeJS.ProcessEnv = process.env): boolean { + const termProgram = env['TERM_PROGRAM'] ?? ''; + if ( + termProgram === 'iTerm.app' || + termProgram === 'WezTerm' || + termProgram === 'ghostty' || + termProgram === 'WarpTerminal' + ) { + return true; + } + const term = env['TERM'] ?? ''; + if (term === 'xterm-kitty' || term === 'xterm-ghostty') return true; + return false; +} + +/** + * Best-effort detection of ConEmu-style OSC 9;4 progress support, driven + * off well-known environment variables like `supportsOsc9Notification`. + * The two allow-lists must stay separate: iTerm2 posts a desktop + * notification for ANY `OSC 9;<payload>` it receives, so sending the 9;4 + * progress sequence there pops a "4;3" notification every keepalive tick. + * Terminals outside this list simply get no progress reporting, which is + * always safe. + */ +export function supportsTerminalProgress(env: NodeJS.ProcessEnv = process.env): boolean { + if ((env['WT_SESSION'] ?? '').length > 0) return true; + if (env['ConEmuANSI'] === 'ON') return true; + const termProgram = env['TERM_PROGRAM'] ?? ''; + if (termProgram === 'ghostty' || termProgram === 'WezTerm') return true; + const term = env['TERM'] ?? ''; + if (term === 'xterm-ghostty') return true; + return false; +} + +export function isInsideTmux(env: NodeJS.ProcessEnv = process.env): boolean { + const tmux = env['TMUX'] ?? ''; + return tmux.length > 0; +} + +function sanitizeNotificationText(value: string): string { + return Array.from(value) + .map((ch) => (isControlCharacter(ch) ? ' ' : ch)) + .join('') + .replaceAll(/\s+/g, ' ') + .trim(); +} + +function isControlCharacter(ch: string): boolean { + const code = ch.codePointAt(0) ?? 0; + return (code >= 0x00 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f); +} diff --git a/apps/kimi-code/src/tui/utils/terminal-state.ts b/apps/kimi-code/src/tui/utils/terminal-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..29127ea02eb8b5a7b3dadcac3f21848544b49ad2 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/terminal-state.ts @@ -0,0 +1,25 @@ +import { + isInsideTmux, + supportsOsc9Notification, + supportsTerminalProgress, +} from './terminal-notification'; + +export interface TerminalState { + notificationKeys: Set<string>; + focused: boolean; + supportsOsc9: boolean; + supportsProgress: boolean; + insideTmux: boolean; + progressActive: boolean; +} + +export function createTerminalState(): TerminalState { + return { + notificationKeys: new Set<string>(), + focused: true, + supportsOsc9: supportsOsc9Notification(), + supportsProgress: supportsTerminalProgress(), + insideTmux: isInsideTmux(), + progressActive: false, + }; +} diff --git a/apps/kimi-code/src/tui/utils/terminal-theme.ts b/apps/kimi-code/src/tui/utils/terminal-theme.ts new file mode 100644 index 0000000000000000000000000000000000000000..e66896a7340d03ab972942c415f89501013ae442 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/terminal-theme.ts @@ -0,0 +1,153 @@ +import { + DISABLE_TERMINAL_THEME_REPORTING, + ENABLE_TERMINAL_THEME_REPORTING, + OSC11_QUERY, + OSC11_RESPONSE, + OSC11_RESPONSE_PREFIX, + OSC11_RESPONSE_PREFIX_NO_ESC, + QUERY_TERMINAL_THEME, + TERMINAL_THEME_INPUT_BUFFER_MAX_LENGTH, + TERMINAL_THEME_DARK, + TERMINAL_THEME_LIGHT, +} from "#/tui/constant/terminal"; +import type { TUIState } from "#/tui/tui-state"; +import type { ResolvedTheme } from "#/tui/theme/colors"; +import { parseOsc11BackgroundTheme } from "#/tui/theme/terminal-background"; + +export { + DISABLE_TERMINAL_THEME_REPORTING, + ENABLE_TERMINAL_THEME_REPORTING, + OSC11_QUERY, + QUERY_TERMINAL_THEME, + TERMINAL_THEME_DARK, + TERMINAL_THEME_LIGHT, +} from "#/tui/constant/terminal"; + +export function hasTerminalThemeReport(data: string): boolean { + return data.includes(TERMINAL_THEME_DARK) || data.includes(TERMINAL_THEME_LIGHT); +} + +export interface TerminalThemeInputState { + osc11Buffer: string; +} + +export type TerminalThemeInputResult = + | { + consume?: boolean; + data?: string; + } + | undefined; + +export function createTerminalThemeInputState(): TerminalThemeInputState { + return { osc11Buffer: "" }; +} + +export function handleTerminalThemeInput( + data: string, + terminal: Pick<TUIState["terminal"], "write">, + onTheme: (theme: ResolvedTheme) => void, + inputState: TerminalThemeInputState = createTerminalThemeInputState(), +): TerminalThemeInputResult { + let remaining = data; + + if (inputState.osc11Buffer !== "") { + const candidate = `${inputState.osc11Buffer}${data}`; + const stripped = stripOsc11Reports(candidate, onTheme); + if (stripped !== candidate) { + inputState.osc11Buffer = ""; + return resultFromRemaining(stripped); + } + + inputState.osc11Buffer = + candidate.length > TERMINAL_THEME_INPUT_BUFFER_MAX_LENGTH ? "" : candidate; + return { consume: true }; + } + + remaining = stripOsc11Reports(remaining, onTheme); + remaining = stripTerminalThemeReports(remaining, terminal); + + const partialOsc11Start = findPartialOsc11Start(remaining); + if (partialOsc11Start !== -1) { + inputState.osc11Buffer = remaining.slice(partialOsc11Start); + return resultFromRemaining(remaining.slice(0, partialOsc11Start)); + } + + if (remaining !== data) return resultFromRemaining(remaining); + + return undefined; +} + +function stripOsc11Reports(data: string, onTheme: (theme: ResolvedTheme) => void): string { + let remaining = data; + + for (;;) { + const match = OSC11_RESPONSE.exec(remaining); + if (match === null) return remaining; + + const theme = parseOsc11BackgroundTheme(match[0]); + if (theme !== null) onTheme(theme); + + remaining = `${remaining.slice(0, match.index)}${remaining.slice(match.index + match[0].length)}`; + } +} + +function stripTerminalThemeReports( + data: string, + terminal: Pick<TUIState["terminal"], "write">, +): string { + let remaining = data; + let strippedReport = false; + + for (const report of [TERMINAL_THEME_DARK, TERMINAL_THEME_LIGHT]) { + if (!remaining.includes(report)) continue; + remaining = remaining.split(report).join(""); + strippedReport = true; + } + + if (strippedReport) { + terminal.write(OSC11_QUERY); + } + + return remaining; +} + +function findPartialOsc11Start(data: string): number { + const fullPrefixIndex = data.indexOf(OSC11_RESPONSE_PREFIX); + if (fullPrefixIndex !== -1) return fullPrefixIndex; + + const noEscPrefixIndex = data.indexOf(OSC11_RESPONSE_PREFIX_NO_ESC); + if (noEscPrefixIndex !== -1) return noEscPrefixIndex; + + for (let i = 0; i < data.length; i++) { + const suffix = data.slice(i); + if (OSC11_RESPONSE_PREFIX.startsWith(suffix) && suffix.length > 1) return i; + if (OSC11_RESPONSE_PREFIX_NO_ESC.startsWith(suffix) && suffix.startsWith("]11;")) { + return i; + } + } + + return -1; +} + +function resultFromRemaining(data: string): TerminalThemeInputResult { + if (data.length === 0) return { consume: true }; + return { data }; +} + +export function installTerminalThemeTracking( + state: Pick<TUIState, "terminal" | "ui">, + onTheme: (theme: ResolvedTheme) => void, +): () => void { + const inputState = createTerminalThemeInputState(); + const disposeInputListener = state.ui.addInputListener((data) => + handleTerminalThemeInput(data, state.terminal, onTheme, inputState), + ); + state.terminal.write(ENABLE_TERMINAL_THEME_REPORTING); + state.terminal.write(OSC11_QUERY); + state.terminal.write(QUERY_TERMINAL_THEME); + + return () => { + disposeInputListener(); + state.terminal.write(DISABLE_TERMINAL_THEME_REPORTING); + }; +} diff --git a/apps/kimi-code/src/tui/utils/thinking-config.ts b/apps/kimi-code/src/tui/utils/thinking-config.ts new file mode 100644 index 0000000000000000000000000000000000000000..79dff432398d8822c14a1dfcaf24eadc302260ed --- /dev/null +++ b/apps/kimi-code/src/tui/utils/thinking-config.ts @@ -0,0 +1,60 @@ +import type { ModelAlias, ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; + +/** Whether a thinking effort represents "thinking enabled" (anything but 'off'). */ +export function isThinkingOn(effort: ThinkingEffort): boolean { + return effort !== 'off'; +} + +/** + * Project a thinking effort to the `[thinking]` config patch persisted to + * config.toml. `'off'` disables thinking; `'on'` is the boolean-model + * on-signal rather than a declared effort, so it only persists `enabled` — + * boolean models resolve back to `'on'` at runtime via + * `defaultThinkingEffortFor`. A concrete effort persists as the global + * default, EXCEPT when it ranks above the model's effective default + * effort: `support_efforts` is ordered by strength (the same assumption + * the `middleOf` default-effort resolution makes), and a pick more + * expensive than the default stays session-only and records just + * `enabled`, so it never becomes the global default for every new + * session. The default here is the effective model's, however it arose — + * declared via the catalog or `[models.*.overrides]`, or synthesized by + * the protocol-profile inference (`withAnthropicProfile` resolves Claude + * models to 'high', so an 'xhigh' pick stays session-only there). When + * the effective model carries no default effort at all, its highest + * declared level stays session-only (the historical rule). Undeclared + * values persist as-is — the configured provider validates them. + */ +export function thinkingEffortToConfig( + effort: ThinkingEffort, + model?: Pick<ModelAlias, 'supportEfforts' | 'defaultEffort'>, +): { + enabled: boolean; + effort?: string; +} { + if (effort === 'off') return { enabled: false }; + if (effort === 'on') return { enabled: true }; + const efforts = model?.supportEfforts; + if (efforts !== undefined && efforts.includes(effort)) { + const declared = model?.defaultEffort; + const ceiling = + declared !== undefined && efforts.includes(declared) + ? efforts.indexOf(declared) + : efforts.length - 2; + if (efforts.indexOf(effort) > ceiling) return { enabled: true }; + } + return { enabled: true, effort }; +} + +/** + * Inverse of {@link thinkingEffortToConfig}: derive the runtime thinking effort + * to activate a model with from the persisted `[thinking]` config. Returns + * `'off'` when thinking is disabled, the configured concrete effort when set, + * and `undefined` when thinking is enabled without a concrete effort so the + * model's own default applies. + */ +export function thinkingEffortFromConfig( + config: { enabled?: boolean; effort?: string } | undefined, +): ThinkingEffort | undefined { + if (config?.enabled === false) return 'off'; + return config?.effort; +} diff --git a/apps/kimi-code/src/tui/utils/tmux-keyboard.ts b/apps/kimi-code/src/tui/utils/tmux-keyboard.ts new file mode 100644 index 0000000000000000000000000000000000000000..88897fa741c65f649c85951ee9312ad09e11b022 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/tmux-keyboard.ts @@ -0,0 +1,68 @@ +import { spawn } from 'node:child_process'; + +const TMUX_QUERY_TIMEOUT_MS = 2000; + +export const TMUX_EXTENDED_KEYS_OFF_WARNING = + 'tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux.'; + +export const TMUX_EXTENDED_KEYS_FORMAT_XTERM_WARNING = + 'tmux extended-keys-format is xterm. Kimi Code works best with csi-u. Add `set -g extended-keys-format csi-u` to ~/.tmux.conf and restart tmux.'; + +export type TmuxOptionReader = (option: string) => Promise<string | undefined>; + +export async function detectTmuxKeyboardWarning( + env: NodeJS.ProcessEnv = process.env, + readTmuxOption: TmuxOptionReader = readTmuxOptionFromProcess, +): Promise<string | undefined> { + if ((env['TMUX'] ?? '').length === 0) return undefined; + + const [extendedKeys, extendedKeysFormat] = await Promise.all([ + readTmuxOption('extended-keys'), + readTmuxOption('extended-keys-format'), + ]); + + if (extendedKeys === undefined) return undefined; + + if (extendedKeys !== 'on' && extendedKeys !== 'always') { + return TMUX_EXTENDED_KEYS_OFF_WARNING; + } + + if (extendedKeysFormat === 'xterm') { + return TMUX_EXTENDED_KEYS_FORMAT_XTERM_WARNING; + } + + return undefined; +} + +function readTmuxOptionFromProcess(option: string): Promise<string | undefined> { + return new Promise((resolve) => { + const proc = spawn('tmux', ['show', '-gv', option], { + stdio: ['ignore', 'pipe', 'ignore'], + }); + let stdout = ''; + let settled = false; + let timer: NodeJS.Timeout; + + const finish = (value: string | undefined) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + + timer = setTimeout(() => { + proc.kill(); + finish(undefined); + }, TMUX_QUERY_TIMEOUT_MS); + + proc.stdout?.on('data', (data: Buffer) => { + stdout += data.toString('utf8'); + }); + proc.on('error', () => { + finish(undefined); + }); + proc.on('close', (code) => { + finish(code === 0 ? stdout.trim() : undefined); + }); + }); +} diff --git a/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts b/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts new file mode 100644 index 0000000000000000000000000000000000000000..94cb45693deeac538bbb229206130b1915f1336e --- /dev/null +++ b/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts @@ -0,0 +1,15 @@ +import type { Component } from '@moonshot-ai/pi-tui'; + +import type { TranscriptEntry } from '../types'; + +const componentEntries = new WeakMap<Component, TranscriptEntry>(); + +export function markTranscriptComponent(component: Component, entry: TranscriptEntry): void { + componentEntries.set(component, entry); +} + +export function getTranscriptComponentEntry( + component: Component, +): TranscriptEntry | undefined { + return componentEntries.get(component); +} diff --git a/apps/kimi-code/src/tui/utils/transcript-id.ts b/apps/kimi-code/src/tui/utils/transcript-id.ts new file mode 100644 index 0000000000000000000000000000000000000000..149378a9bc6ecbcfad9e787960311dd4c7a9c02e --- /dev/null +++ b/apps/kimi-code/src/tui/utils/transcript-id.ts @@ -0,0 +1,6 @@ +let transcriptIdCounter = 0; + +export function nextTranscriptId(): string { + transcriptIdCounter += 1; + return `entry-${String(transcriptIdCounter)}`; +} diff --git a/apps/kimi-code/src/tui/utils/transcript-window.ts b/apps/kimi-code/src/tui/utils/transcript-window.ts new file mode 100644 index 0000000000000000000000000000000000000000..d94f228b2741515b9b7c634c1e112b0768749821 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/transcript-window.ts @@ -0,0 +1,140 @@ +/** + * Sliding window for the TUI transcript. + * + * The transcript grows unbounded as the conversation goes on. To keep the TUI + * responsive and bounded, we only keep the most recent N *turns* (a turn = a + * user prompt plus everything the assistant does in response, identified by a + * shared `turnId`), and destroy older turns wholesale (component + entry). + * Within a kept turn, older steps — and assistant messages beyond a cap — are + * folded into a collapsed summary line so a single long turn cannot grow the + * mounted component tree without bound. + * + * All threshold logic here is pure so it can be unit-tested in isolation; the + * constants are the production defaults passed in by the TUI. + */ + +import type { TranscriptEntry } from '../types'; + +/** + * Read a non-negative integer env var, falling back to `fallback` when it is + * unset, empty, negative, or not an integer. `0` is a valid value (call sites + * treat it as "feature disabled"). + */ +export function readEnvInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === '') return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) return fallback; + return value; +} + +/** Master switch for the sliding window. */ +export const TRANSCRIPT_WINDOW_ENABLED = true; + +/** Keep the most recent N turns. `0` disables trimming. */ +export const TRANSCRIPT_MAX_TURNS = readEnvInt('KIMI_CODE_TUI_MAX_TURNS', 15); + +/** Only the most recent E turns are allowed to expand (Ctrl+O). `0` disables expanding. */ +export const TRANSCRIPT_EXPAND_TURNS = readEnvInt('KIMI_CODE_TUI_EXPAND_TURNS', 3); + +/** Only trim once the window exceeds maxTurns by this much (avoids churn). */ +export const TRANSCRIPT_HYSTERESIS = readEnvInt('KIMI_CODE_TUI_HYSTERESIS', 5); + +/** Keep this many recent steps untouched inside a turn; older steps are merged into a summary. `0` disables merging. */ +export const TRANSCRIPT_KEEP_RECENT_STEPS = readEnvInt('KIMI_CODE_TUI_KEEP_RECENT_STEPS', 30); + +/** Keep this many recent assistant messages mounted inside the active turn; older ones fold into the step summary. `0` disables folding. */ +export const TRANSCRIPT_KEEP_RECENT_ASSISTANT = readEnvInt('KIMI_CODE_TUI_KEEP_RECENT_ASSISTANT', 20); + +/** + * Once a turn ends, fold all but its last few assistant messages into the + * step summary — intermediate chatter is rarely re-read, while the tail + * usually holds the conclusion. `0` disables folding. + */ +export const TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED = readEnvInt( + 'KIMI_CODE_TUI_KEEP_RECENT_ASSISTANT_COMPLETED', + 2, +); + +export interface TranscriptTurn { + readonly turnId: string | undefined; + readonly entries: TranscriptEntry[]; +} + +/** + * Group consecutive entries into turns by `turnId`. Entries with the same + * non-undefined `turnId` that are adjacent belong to the same turn. + * + * Entries with an undefined `turnId` are buffered and attached to the *next* + * defined turn. This matters because a user message is appended (with + * `turnId: undefined`) before its turn actually starts, so without this + * buffering every user message would become its own single-entry turn at the + * front and get trimmed first. Any undefined entries left at the tail (no + * following turn) become their own turn. + */ +export function groupTurns(entries: readonly TranscriptEntry[]): TranscriptTurn[] { + const turns: TranscriptTurn[] = []; + let current: TranscriptTurn | undefined; + let pendingUndefined: TranscriptEntry[] = []; + + for (const entry of entries) { + const turnId = entry.turnId; + if (turnId === undefined) { + pendingUndefined.push(entry); + continue; + } + if (current !== undefined && current.turnId === turnId) { + current.entries.push(entry); + } else { + current = { turnId, entries: [...pendingUndefined, entry] }; + pendingUndefined = []; + turns.push(current); + } + } + + if (pendingUndefined.length > 0) { + turns.push({ turnId: undefined, entries: pendingUndefined }); + } + + return turns; +} + +/** + * Decide which entries to destroy so the remaining turns fit within + * `maxTurns`. Returns an empty set when the turn count is within + * `maxTurns + hysteresis`. Oldest turns are removed first; the most recent + * turn is never removed (it is the active / just-finished turn). + */ +export function turnsToTrim( + turns: readonly TranscriptTurn[], + maxTurns: number, + hysteresis: number, +): Set<TranscriptEntry> { + const toRemove = new Set<TranscriptEntry>(); + + if (turns.length <= maxTurns + hysteresis) return toRemove; + + let remaining = turns.length; + // `turns.length - 1` keeps the most recent turn off-limits. + for (let i = 0; i < turns.length - 1 && remaining > maxTurns; i++) { + const turn = turns[i]!; + for (const entry of turn.entries) toRemove.add(entry); + remaining--; + } + return toRemove; +} + +/** + * Index of the first transcript child ctrl+o may expand: the start of the + * (turns - expandTurns)-th turn, given the child indexes of the turn + * boundaries. `expandTurns <= 0` disables expanding (the cutoff is past the + * last child); fewer boundaries than `expandTurns` means everything expands. + */ +export function expandCutoffIndex( + childCount: number, + boundaries: readonly number[], + expandTurns: number, +): number { + if (expandTurns <= 0) return childCount; + return boundaries.length > expandTurns ? boundaries[boundaries.length - expandTurns]! : 0; +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-common.ts b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts new file mode 100644 index 0000000000000000000000000000000000000000..dc0f60886e9d7537a6f88110de0d46b23e473ad7 --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts @@ -0,0 +1,158 @@ +import { readFileSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; + +import type { ClipboardModule } from './clipboard-native'; + +export type RunCommandOptions = { timeoutMs?: number; env?: NodeJS.ProcessEnv }; +export type RunCommand = ( + command: string, + args: string[], + options?: RunCommandOptions, +) => { stdout: Buffer; ok: boolean }; +export type RunCommandAsync = ( + command: string, + args: string[], + options?: RunCommandOptions, +) => Promise<{ stdout: Buffer; ok: boolean }>; + +export const SUPPORTED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; + +export const DEFAULT_LIST_TIMEOUT_MS = 1000; +export const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024; + +export function baseMimeType(raw: string): string { + return raw.split(';')[0]?.trim().toLowerCase() ?? raw.toLowerCase(); +} + +export function isSupportedImageMimeType(mime: string): boolean { + const base = baseMimeType(mime); + return (SUPPORTED_IMAGE_MIME_TYPES as readonly string[]).includes(base); +} + +export function parseTargetList(output: Buffer): string[] { + return output + .toString('utf-8') + .split(/\r?\n/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + +export function runCommand( + command: string, + args: string[], + options?: RunCommandOptions, +): { stdout: Buffer; ok: boolean } { + const result = spawnSync(command, args, { + timeout: options?.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS, + maxBuffer: DEFAULT_MAX_BUFFER_BYTES, + env: options?.env, + }); + if (result.error !== undefined || result.status !== 0) { + return { ok: false, stdout: Buffer.alloc(0) }; + } + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? ''); + return { ok: true, stdout }; +} + +/** + * Non-blocking counterpart of `runCommand`. Used by the clipboard image probe + * on the startup path so a slow or wedged helper (notably `powershell.exe` on + * WSL, or a stuck `wl-paste`/`xclip`) cannot freeze the event loop. The child + * is killed and the promise resolves with `ok: false` once `timeoutMs` elapses + * or the captured stdout exceeds `DEFAULT_MAX_BUFFER_BYTES`. + */ +export function runCommandAsync( + command: string, + args: string[], + options?: RunCommandOptions, +): Promise<{ stdout: Buffer; ok: boolean }> { + const timeoutMs = options?.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS; + return new Promise((resolve) => { + let child; + try { + child = spawn(command, args, { + env: options?.env, + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + + const chunks: Buffer[] = []; + let totalBytes = 0; + let settled = false; + let timer: ReturnType<typeof setTimeout>; + + // Marks the promise as settled and clears the timeout. Returns true only for + // the first caller, so each event handler below resolves at most once. + const claim = (): boolean => { + if (settled) return false; + settled = true; + clearTimeout(timer); + return true; + }; + + timer = setTimeout(() => { + child.kill(); + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + }, timeoutMs); + + child.stdout?.on('data', (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > DEFAULT_MAX_BUFFER_BYTES) { + child.kill(); + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + chunks.push(chunk); + }); + + child.on('error', () => { + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + }); + + child.on('close', (code) => { + if (code !== 0) { + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + if (claim()) resolve({ ok: true, stdout: Buffer.concat(chunks) }); + }); + }); +} + +export function isWaylandSession(env: NodeJS.ProcessEnv): boolean { + return Boolean(env['WAYLAND_DISPLAY']) || env['XDG_SESSION_TYPE'] === 'wayland'; +} + +export function isWSL(env: NodeJS.ProcessEnv): boolean { + if (env['WSL_DISTRO_NAME'] !== undefined || env['WSLENV'] !== undefined) return true; + try { + return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf-8')); + } catch { + return false; + } +} + +export function isFileLikeNativeFormat(format: string): boolean { + const f = format.toLowerCase(); + const base = baseMimeType(format); + return ( + f.includes('file-url') || + f.includes('file url') || + f.includes('nsfilenames') || + f.includes('com.apple.finder') || + base === 'text/uri-list' || + base === 'public.url' + ); +} + +export function safeAvailableFormats(clip: ClipboardModule | null): string[] { + if (clip?.availableFormats === undefined) return []; + try { + return clip.availableFormats(); + } catch { + return []; + } +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c344d02ea1950e37cf1bf76d20519f5903790b8 --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts @@ -0,0 +1,44 @@ +import { isFileLikeNativeFormat, safeAvailableFormats } from './clipboard-common'; +import { clipboard, type ClipboardModule } from './clipboard-native'; + +async function hasImageViaNative(clip: ClipboardModule | null): Promise<boolean> { + if (clip === null) return false; + + // Finder exposes file icons/thumbnails as image data when a non-image file + // is copied. Treat file-like clipboard contents as "not a pasteable image" + // to match the read path in clipboard-image.ts. + const formats = safeAvailableFormats(clip); + if (formats.some(isFileLikeNativeFormat)) return false; + + try { + return clip.hasImage(); + } catch { + return false; + } +} + +export async function clipboardHasImage(options?: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + clipboard?: ClipboardModule | null; +}): Promise<boolean> { + const env = options?.env ?? process.env; + const platform = options?.platform ?? process.platform; + const clip = options?.clipboard ?? clipboard; + + if (env['TERMUX_VERSION'] !== undefined) return false; + + // The focus-driven clipboard-image hint does not probe on Linux. The probe + // would spawn wl-paste / xclip, which on Wayland perturbs seat focus and + // re-triggers the terminal's focus event, creating a focus feedback loop + // (window repeatedly gains/loses focus, IME candidate window cannot stay + // focused — see issue #1090). macOS and Windows are fine: both use the + // in-process native module, which neither spawns a subprocess nor perturbs + // focus. + // + // Image *paste* is unaffected on all platforms: it reads the clipboard + // through readClipboardMedia() on the explicit paste path, not here. + if (platform !== 'darwin' && platform !== 'win32') return false; + + return hasImageViaNative(clip); +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts new file mode 100644 index 0000000000000000000000000000000000000000..23b7a1337eff0eb3cb8acf974e81527115e65527 --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts @@ -0,0 +1,458 @@ +/** + * Read media from the system clipboard with graceful platform fallbacks. + * + * Every model provider accepts PNG/JPEG/GIF/WebP (the engine widens the set + * per provider, e.g. BMP/HEIC/HEIF for Kimi), and the clipboard sources we + * query already emit those baseline formats on supported platforms — so we + * deliberately do not include a BMP→PNG converter. + * + * Lookup order: + * macOS file clipboard -> osascript/AppKit file URLs + * macOS / Windows -> native `@mariozechner/clipboard` + * Linux Wayland -> wl-paste + * Linux X11 -> xclip + * WSL (image not on Linux cb) -> PowerShell fallback via wslpath + * + * Returns `null` when no supported media is available, the format isn't + * supported, or every fallback fails. + */ + +import { randomUUID } from 'node:crypto'; +import { readFileSync, statSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, isAbsolute, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { parseImageMeta } from '#/utils/image/image-mime'; + +import { + DEFAULT_LIST_TIMEOUT_MS, + SUPPORTED_IMAGE_MIME_TYPES, + baseMimeType, + isFileLikeNativeFormat, + isSupportedImageMimeType, + isWaylandSession, + isWSL, + parseTargetList, + runCommand as runCommandBase, + safeAvailableFormats, + type RunCommand, + type RunCommandOptions, +} from './clipboard-common'; +import { clipboard, type ClipboardModule } from './clipboard-native'; + +export interface ClipboardImage { + kind: 'image'; + bytes: Uint8Array; + mimeType: string; +} + +export interface ClipboardVideo { + kind: 'video'; + mimeType: string; + filename: string; + sourcePath: string; +} + +export type ClipboardMedia = ClipboardImage | ClipboardVideo; + +export class ClipboardMediaError extends Error { + constructor(message: string) { + super(message); + this.name = 'ClipboardMediaError'; + } +} + +const MAX_VIDEO_BYTES = 100 * 1024 * 1024; + +const VIDEO_MIME_BY_SUFFIX: Readonly<Record<string, string>> = Object.freeze({ + '.mp4': 'video/mp4', + '.mpg': 'video/mpeg', + '.mpeg': 'video/mpeg', + '.mkv': 'video/x-matroska', + '.avi': 'video/x-msvideo', + '.mov': 'video/quicktime', + '.ogv': 'video/ogg', + '.wmv': 'video/x-ms-wmv', + '.webm': 'video/webm', + '.m4v': 'video/x-m4v', + '.flv': 'video/x-flv', + '.3gp': 'video/3gpp', + '.3g2': 'video/3gpp2', +}); + +const DEFAULT_READ_TIMEOUT_MS = 3000; +const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000; + +const MACOS_FILE_PATH_SCRIPT = String.raw` +ObjC.import('AppKit'); +ObjC.import('Foundation'); + +const out = []; +const pb = $.NSPasteboard.generalPasteboard; +if (String(pb) !== '[id nil]') { + try { + const options = $.NSMutableDictionary.dictionary; + options.setObjectForKey($.NSNumber.numberWithBool(true), $.NSPasteboardURLReadingFileURLsOnlyKey); + const urls = pb.readObjectsForClassesOptions([$.NSURL], options); + const count = urls ? urls.count : 0; + for (let i = 0; i < count; i++) { + const value = urls.objectAtIndex(i).path; + const path = value ? ObjC.unwrap(value) : ''; + if (path) out.push(path); + } + } catch (error) {} + + if (out.length === 0) { + try { + const files = ObjC.deepUnwrap(pb.propertyListForType('NSFilenamesPboardType')); + if (Array.isArray(files)) { + for (const path of files) { + if (path) out.push(String(path)); + } + } else if (files) { + out.push(String(files)); + } + } catch (error) {} + } +} +out.join('\n'); +`.trim(); + +function selectPreferredImageMimeType(candidates: string[]): string | null { + const normalized = candidates + .map((t) => t.trim()) + .filter((t) => t.length > 0) + .map((raw) => ({ raw, base: baseMimeType(raw) })); + + for (const preferred of SUPPORTED_IMAGE_MIME_TYPES) { + const match = normalized.find((t) => t.base === preferred); + if (match !== undefined) return match.raw; + } + const anyImage = normalized.find((t) => t.base.startsWith('image/')); + return anyImage?.raw ?? null; +} + +function videoMimeFromPath(path: string): string | null { + const dot = path.lastIndexOf('.'); + if (dot < 0) return null; + const suffix = path.slice(dot).toLowerCase(); + return VIDEO_MIME_BY_SUFFIX[suffix] ?? null; +} + +function parseClipboardPaths(text: string): string[] { + return splitClipboardPathLines(text) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map((line) => { + if (line.startsWith('file://')) { + try { + return fileURLToPath(line); + } catch { + return ''; + } + } + return line; + }) + .filter((line) => line.length > 0 && isAbsolute(line)); +} + +function splitClipboardPathLines(text: string): string[] { + const lines: string[] = []; + let start = 0; + for (let i = 0; i < text.length; i += 1) { + const char = text[i]; + if (char === '\r' || char === '\n' || text.codePointAt(i) === 0) { + lines.push(text.slice(start, i)); + start = i + 1; + } + } + lines.push(text.slice(start)); + return lines; +} + +function readImagePath(path: string): ClipboardImage | null { + let stat: ReturnType<typeof statSync>; + try { + stat = statSync(path); + } catch { + return null; + } + if (!stat.isFile()) return null; + + let bytes: Buffer; + try { + bytes = readFileSync(path); + } catch { + return null; + } + if (bytes.length === 0) return null; + + const meta = parseImageMeta(bytes); + if (meta === null) return null; + return { kind: 'image', bytes: new Uint8Array(bytes), mimeType: meta.mime }; +} + +function readVideoPath(path: string): ClipboardVideo | null { + const mimeType = videoMimeFromPath(path); + if (mimeType === null) return null; + let stat: ReturnType<typeof statSync>; + try { + stat = statSync(path); + } catch { + return null; + } + if (!stat.isFile()) return null; + if (stat.size > MAX_VIDEO_BYTES) { + throw new ClipboardMediaError( + `Video is ${(stat.size / 1024 / 1024).toFixed(1)} MB; maximum supported size is 100 MB.`, + ); + } + return { + kind: 'video', + mimeType, + filename: basename(path), + sourcePath: path, + }; +} + +function readMediaPath(path: string): ClipboardMedia | null { + // Video files are never opened as images. + const video = readVideoPath(path); + if (video !== null) return video; + return readImagePath(path); +} + +function readMediaFromPaths(paths: readonly string[]): ClipboardMedia | null { + for (const path of paths) { + const media = readMediaPath(path); + if (media !== null) return media; + } + return null; +} + +function readMediaFromText(text: string): ClipboardMedia | null { + return readMediaFromPaths(parseClipboardPaths(text)); +} + +function runCommand(command: string, args: string[], options?: RunCommandOptions): { stdout: Buffer; ok: boolean } { + return runCommandBase(command, args, { + timeoutMs: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS, + env: options?.env, + }); +} + +function readClipboardFileMediaViaWlPaste(): ClipboardMedia | null { + const list = runCommand('wl-paste', ['--list-types'], { + timeoutMs: DEFAULT_LIST_TIMEOUT_MS, + }); + if (!list.ok) return null; + + const types = parseTargetList(list.stdout); + const uriType = types.find((t) => baseMimeType(t) === 'text/uri-list'); + if (uriType === undefined) return null; + + const uris = runCommand('wl-paste', ['--type', uriType, '--no-newline']); + return uris.ok ? readMediaFromText(uris.stdout.toString('utf-8')) : null; +} + +function readClipboardImageViaWlPaste(): ClipboardImage | null { + const list = runCommand('wl-paste', ['--list-types'], { + timeoutMs: DEFAULT_LIST_TIMEOUT_MS, + }); + if (!list.ok) return null; + + const selected = selectPreferredImageMimeType(parseTargetList(list.stdout)); + if (selected === null) return null; + + const data = runCommand('wl-paste', ['--type', selected, '--no-newline']); + if (!data.ok || data.stdout.length === 0) return null; + return { kind: 'image', bytes: data.stdout, mimeType: baseMimeType(selected) }; +} + +function readClipboardFileMediaViaXclip(): ClipboardMedia | null { + const targets = runCommand('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { + timeoutMs: DEFAULT_LIST_TIMEOUT_MS, + }); + if (!targets.ok) return null; + + const candidates = parseTargetList(targets.stdout); + const uriType = candidates.find((t) => baseMimeType(t) === 'text/uri-list'); + if (uriType === undefined) return null; + + const uris = runCommand('xclip', ['-selection', 'clipboard', '-t', uriType, '-o']); + return uris.ok ? readMediaFromText(uris.stdout.toString('utf-8')) : null; +} + +function readClipboardImageViaXclip(): ClipboardImage | null { + const targets = runCommand('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { + timeoutMs: DEFAULT_LIST_TIMEOUT_MS, + }); + + const candidates = targets.ok ? parseTargetList(targets.stdout) : []; + const preferred = candidates.length > 0 ? selectPreferredImageMimeType(candidates) : null; + const tryTypes = + preferred !== null + ? [preferred, ...SUPPORTED_IMAGE_MIME_TYPES] + : [...SUPPORTED_IMAGE_MIME_TYPES]; + + for (const mime of tryTypes) { + const data = runCommand('xclip', ['-selection', 'clipboard', '-t', mime, '-o']); + if (data.ok && data.stdout.length > 0) { + return { kind: 'image', bytes: data.stdout, mimeType: baseMimeType(mime) }; + } + } + return null; +} + +/** + * Windows clipboard images (Win+Shift+S) don't bridge into the WSL + * Linux clipboard. PowerShell reaches the Windows clipboard directly; + * we round-trip via a temp PNG because binary stdout is unreliable + * across the WSL interop boundary. + */ +function readClipboardImageViaPowerShell(): ClipboardImage | null { + const tmpFile = join(tmpdir(), `kimi-wsl-clip-${randomUUID()}.png`); + try { + const winPathResult = runCommand('wslpath', ['-w', tmpFile], { + timeoutMs: DEFAULT_LIST_TIMEOUT_MS, + }); + if (!winPathResult.ok) return null; + const winPath = winPathResult.stdout.toString('utf-8').trim(); + if (winPath.length === 0) return null; + + const psScript = [ + 'Add-Type -AssemblyName System.Windows.Forms', + 'Add-Type -AssemblyName System.Drawing', + '$path = $env:KIMI_WSL_CLIPBOARD_IMAGE_PATH', + '$img = [System.Windows.Forms.Clipboard]::GetImage()', + "if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }", + ].join('; '); + + const result = runCommand('powershell.exe', ['-NoProfile', '-Command', psScript], { + timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS, + env: { ...process.env, KIMI_WSL_CLIPBOARD_IMAGE_PATH: winPath }, + }); + if (!result.ok) return null; + if (result.stdout.toString('utf-8').trim() !== 'ok') return null; + + const bytes = readFileSync(tmpFile); + if (bytes.length === 0) return null; + return { kind: 'image', bytes: new Uint8Array(bytes), mimeType: 'image/png' }; + } catch { + return null; + } finally { + try { + unlinkSync(tmpFile); + } catch { + // ignore cleanup errors + } + } +} + +function readClipboardFilePathsViaMacOs(run: RunCommand): string[] { + const result = run('osascript', ['-l', 'JavaScript', '-e', MACOS_FILE_PATH_SCRIPT], { + timeoutMs: DEFAULT_LIST_TIMEOUT_MS, + }); + if (!result.ok || result.stdout.length === 0) return []; + return parseClipboardPaths(result.stdout.toString('utf-8')); +} + +async function readClipboardFileMediaViaNativeText( + clip: ClipboardModule | null, +): Promise<{ media: ClipboardMedia | null; lookedFileLike: boolean }> { + if (clip === null) return { media: null, lookedFileLike: false }; + + const formats = safeAvailableFormats(clip); + const lookedFileLike = formats.some(isFileLikeNativeFormat); + if (!lookedFileLike || clip.getText === undefined) { + return { media: null, lookedFileLike }; + } + + try { + return { media: readMediaFromText(await clip.getText()), lookedFileLike }; + } catch (error) { + if (error instanceof ClipboardMediaError) throw error; + return { media: null, lookedFileLike }; + } +} + +async function readClipboardImageViaNative( + clip: ClipboardModule | null = clipboard, +): Promise<ClipboardImage | null> { + if (clip === null) return null; + + let hasImage = false; + try { + hasImage = clip.hasImage(); + } catch { + return null; + } + if (!hasImage) return null; + + try { + const data = await clip.getImageBinary(); + if (data.length === 0) return null; + return { kind: 'image', bytes: Uint8Array.from(data), mimeType: 'image/png' }; + } catch { + return null; + } +} + +export async function readClipboardMedia(options?: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + clipboard?: ClipboardModule | null; + runCommand?: RunCommand; +}): Promise<ClipboardMedia | null> { + const env = options?.env ?? process.env; + const platform = options?.platform ?? process.platform; + const clip = options?.clipboard ?? clipboard; + const run = options?.runCommand ?? runCommand; + + // Termux on Android has no desktop clipboard; skip early rather than + // churn through every fallback. + if (env['TERMUX_VERSION'] !== undefined) return null; + + let image: ClipboardImage | null = null; + if (platform === 'linux') { + const wayland = isWaylandSession(env); + const wsl = isWSL(env); + + if (wayland || wsl) { + const fileMedia = readClipboardFileMediaViaWlPaste() ?? readClipboardFileMediaViaXclip(); + if (fileMedia !== null) return fileMedia; + image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip(); + } + if (image === null && wsl) { + image = readClipboardImageViaPowerShell(); + } + if (image === null && !wayland) { + const nativeFileMedia = await readClipboardFileMediaViaNativeText(clip); + if (nativeFileMedia.media !== null) return nativeFileMedia.media; + if (nativeFileMedia.lookedFileLike) return null; + image = await readClipboardImageViaNative(clip); + } + } else { + if (platform === 'darwin') { + const fileMedia = readMediaFromPaths(readClipboardFilePathsViaMacOs(run)); + if (fileMedia !== null) return fileMedia; + } + + const nativeFileMedia = await readClipboardFileMediaViaNativeText(clip); + if (nativeFileMedia.media !== null) return nativeFileMedia.media; + + // Finder exposes file icons/thumbnails as image data. If the clipboard + // looks file-like but we could not read a real file path, do not consume + // that icon as an image attachment. + if (platform === 'darwin' && nativeFileMedia.lookedFileLike) { + return null; + } + + image = await readClipboardImageViaNative(clip); + } + + if (image === null) return null; + if (!isSupportedImageMimeType(image.mimeType)) return null; + return image; +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-native.ts b/apps/kimi-code/src/utils/clipboard/clipboard-native.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd136b4033f7a2d2add61fc8ac31ac305860576d --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-native.ts @@ -0,0 +1,52 @@ +/** + * Optional native clipboard binding. + * + * `@mariozechner/clipboard` is a native Node binding that can read image + * binaries from the system clipboard on macOS and Windows. It's an + * optional dependency — if the native module fails to load (e.g. on a + * platform where no prebuilt is available) we degrade to shell-based + * fallbacks (wl-paste / xclip / PowerShell) in `clipboard-image.ts`. + */ + +import { createRequire } from 'node:module'; + +import { loadNativePackage } from '#/native/native-require'; + +declare const __KIMI_CODE_NATIVE_BUNDLE__: boolean | undefined; + +export interface ClipboardModule { + availableFormats?(): string[]; + hasText?(): boolean; + getText?(): Promise<string>; + setText?(text: string): Promise<void>; + hasImage(): boolean; + getImageBinary(): Promise<Array<number>>; +} + +const nodeRequire = createRequire(import.meta.url); +const isNativeBundle = + typeof __KIMI_CODE_NATIVE_BUNDLE__ === 'boolean' && __KIMI_CODE_NATIVE_BUNDLE__; + +// The native module uses X11/Wayland on Linux; if no display is +// available, skip the load attempt so headless environments don't pay +// the binding cost just to fail later. +const hasDisplay = + process.platform !== 'linux' || Boolean(process.env['DISPLAY'] ?? process.env['WAYLAND_DISPLAY']); + +const clipboard: ClipboardModule | null = (() => { + if (process.env['TERMUX_VERSION'] !== undefined || !hasDisplay) return null; + try { + const bundledClipboard = loadNativePackage<ClipboardModule>('@mariozechner/clipboard'); + if (bundledClipboard !== null) return bundledClipboard; + } catch { + return null; + } + if (isNativeBundle) return null; + try { + return nodeRequire('@mariozechner/clipboard') as ClipboardModule; + } catch { + return null; + } +})(); + +export { clipboard }; diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-osc52.ts b/apps/kimi-code/src/utils/clipboard/clipboard-osc52.ts new file mode 100644 index 0000000000000000000000000000000000000000..f69976849a6374b40441276c03d6dc28419451b0 --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-osc52.ts @@ -0,0 +1,40 @@ +const ESC = '\u001B'; +const BEL = '\u0007'; +const ST = '\\'; + +function isInsideTmux(): boolean { + return (process.env['TMUX'] ?? '').length > 0; +} + +/** + * Build an OSC 52 sequence that asks the terminal emulator to put `text` on + * the system clipboard. The sequence reaches the *local* clipboard through + * stdout alone, so it keeps working over SSH and inside containers where no + * native clipboard tool exists. Terminals without OSC 52 support silently + * ignore it. + * + * tmux swallows bare OSC sequences, so inside tmux the sequence is wrapped in + * a DCS passthrough with doubled ESC bytes (same convention as + * `buildTerminalNotificationSequences`). + */ +export function buildClipboardOSC52(text: string, insideTmux = isInsideTmux()): string { + const payload = Buffer.from(text, 'utf8').toString('base64'); + const sequence = `${ESC}]52;c;${payload}${BEL}`; + if (!insideTmux) return sequence; + const escaped = sequence.replaceAll(ESC, `${ESC}${ESC}`); + return `${ESC}Ptmux;${escaped}${ESC}${ST}`; +} + +/** + * Write the OSC 52 sequence to stdout. Returns false when stdout is not a + * terminal (the sequence would pollute piped output) or the write failed. + */ +export function writeClipboardOSC52(text: string): boolean { + if (!process.stdout.isTTY) return false; + try { + process.stdout.write(buildClipboardOSC52(text)); + return true; + } catch { + return false; + } +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-text.ts b/apps/kimi-code/src/utils/clipboard/clipboard-text.ts new file mode 100644 index 0000000000000000000000000000000000000000..8738c9ede400bc0889cd774cb728ff02139082e3 --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-text.ts @@ -0,0 +1,74 @@ +import { spawnSync } from 'node:child_process'; + +import { clipboard } from './clipboard-native'; +import { writeClipboardOSC52 } from './clipboard-osc52'; + +function runClipboardCommand(command: string, args: readonly string[], input: string): void { + const result = spawnSync(command, args, { encoding: 'utf8', input }); + if (result.error) throw result.error; + if (result.status === 0) return; + + const detail = result.stderr.trim(); + throw new Error( + detail.length > 0 + ? `${command} exited with code ${String(result.status)}: ${detail}` + : `${command} exited with code ${String(result.status)}`, + ); +} + +async function copyWithPlatformCommand(text: string): Promise<void> { + const commands = + process.platform === 'darwin' + ? [{ command: 'pbcopy', args: [] as string[] }] + : process.platform === 'win32' + ? [{ command: 'clip.exe', args: [] as string[] }] + : [ + { command: 'wl-copy', args: [] as string[] }, + { command: 'xclip', args: ['-selection', 'clipboard'] }, + ]; + + let lastError: unknown; + for (const candidate of commands) { + try { + runClipboardCommand(candidate.command, candidate.args, text); + return; + } catch (error) { + lastError = error; + } + } + + if (lastError instanceof Error) throw lastError; + throw new Error('No clipboard command is available.'); +} + +/** How the text was delivered: a verified local clipboard tool, or an + * unverified OSC 52 escape emitted to the terminal as a last resort. */ +export type ClipboardCopyMethod = 'native' | 'osc52'; + +export async function copyTextToClipboard(text: string): Promise<ClipboardCopyMethod> { + // OSC 52 travels over stdout to the local terminal emulator, so it reaches + // the clipboard even over SSH or in containers with no native clipboard + // tool. Emit it up front; every failure path below can fall back on it. + const osc52Emitted = writeClipboardOSC52(text); + + const clipboardModule = clipboard; + if (clipboardModule?.setText !== undefined) { + try { + await clipboardModule.setText(text); + return 'native'; + } catch { + // Fall back to platform clipboard commands below. + } + } + + try { + await copyWithPlatformCommand(text); + return 'native'; + } catch (error) { + // The native clipboard is unreachable (headless server, SSH session, + // missing wl-copy/xclip …) but the terminal may still have delivered the + // text via OSC 52; without a terminal there is nothing left to try. + if (osc52Emitted) return 'osc52'; + throw error; + } +} diff --git a/apps/kimi-code/src/utils/git/git-status.ts b/apps/kimi-code/src/utils/git/git-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..56b7f0af69f6d67136717484b430cbfeba89ed2f --- /dev/null +++ b/apps/kimi-code/src/utils/git/git-status.ts @@ -0,0 +1,371 @@ +/** + * Cached git branch + working-tree status for the footer/statusline. + * + * Branch name refreshes every 5s, porcelain status every 15s. Branch + * and status reads stay synchronous with short timeouts. Pull request + * lookup uses an async cache so a slow `gh pr view` never blocks + * footer rendering. + */ + +import { execFile, spawnSync } from 'node:child_process'; + +import { resolveCommandPath } from '#/utils/process/resolve-command'; + +const BRANCH_TTL_MS = 5_000; +const STATUS_TTL_MS = 15_000; +const PULL_REQUEST_TTL_MS = 60_000; +const SPAWN_TIMEOUT_MS = 500; +const PR_SPAWN_TIMEOUT_MS = 5_000; + +export interface GitStatus { + readonly branch: string; + readonly dirty: boolean; + readonly ahead: number; + readonly behind: number; + readonly diffAdded: number; + readonly diffDeleted: number; + readonly pullRequest: PullRequestInfo | null; +} + +export interface PullRequestInfo { + readonly number: number; + readonly url: string; +} + +export interface GitStatusCache { + /** Returns current status, or `null` when workDir is not a git repo. */ + getStatus(): GitStatus | null; +} + +export interface GitStatusCacheOptions { + readonly onChange?: () => void; +} + +interface BranchState { + value: string | null; + fetchedAt: number; +} + +interface StatusState { + dirty: boolean; + ahead: number; + behind: number; + diffAdded: number; + diffDeleted: number; + fetchedAt: number; +} + +interface PullRequestState { + value: PullRequestInfo | null; + branch: string | null; + fetchedAt: number; + pendingBranch: string | null; + requestId: number; +} + +const AHEAD_BEHIND_RE = /\[(?:ahead (\d+))?(?:, )?(?:behind (\d+))?\]/; + +export function createGitStatusCache( + workDir: string, + options: GitStatusCacheOptions = {}, +): GitStatusCache { + // This cache is constructed before the workspace trust gate, so the git + // binary must be resolved through PATH to an absolute path — a bare name + // would let cmd.exe pick up a `git.exe` planted in the workspace. + const git = resolveCommandPath('git', workDir); + const isRepo = git !== undefined && detectGitRepo(git, workDir); + let branch: BranchState = { value: null, fetchedAt: 0 }; + let status: StatusState = { + dirty: false, + ahead: 0, + behind: 0, + diffAdded: 0, + diffDeleted: 0, + fetchedAt: 0, + }; + let pullRequest: PullRequestState = { + value: null, + branch: null, + fetchedAt: 0, + pendingBranch: null, + requestId: 0, + }; + + return { + getStatus: () => { + if (!isRepo || git === undefined) return null; + + const now = Date.now(); + if (now - branch.fetchedAt >= BRANCH_TTL_MS) { + branch = { value: readBranch(git, workDir), fetchedAt: now }; + } + if (branch.value === null) return null; + + if (now - status.fetchedAt >= STATUS_TTL_MS) { + status = { ...readStatus(git, workDir), fetchedAt: now }; + } + refreshPullRequestIfNeeded(branch.value, now); + + return { + branch: branch.value, + dirty: status.dirty, + ahead: status.ahead, + behind: status.behind, + diffAdded: status.diffAdded, + diffDeleted: status.diffDeleted, + pullRequest: pullRequest.branch === branch.value ? pullRequest.value : null, + }; + }, + }; + + function refreshPullRequestIfNeeded(branchName: string, now: number): void { + if (pullRequest.pendingBranch === branchName) return; + const fetchedAt = pullRequest.branch === branchName ? pullRequest.fetchedAt : 0; + if (now - fetchedAt < PULL_REQUEST_TTL_MS) return; + + const requestId = pullRequest.requestId + 1; + pullRequest = { + value: pullRequest.branch === branchName ? pullRequest.value : null, + branch: branchName, + fetchedAt, + pendingBranch: branchName, + requestId, + }; + + void readPullRequest(workDir).then((value) => { + if (pullRequest.requestId !== requestId) return; + + const previous = pullRequest.branch === branchName ? pullRequest.value : null; + const changed = !samePullRequest(previous, value); + pullRequest = { + value, + branch: branchName, + fetchedAt: Date.now(), + pendingBranch: null, + requestId, + }; + if (changed) options.onChange?.(); + }); + } +} + +function detectGitRepo(git: string, workDir: string): boolean { + try { + const result = spawnSync(git, ['-C', workDir, 'rev-parse', '--is-inside-work-tree'], { + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + }); + return result.status === 0 && result.stdout.trim() === 'true'; + } catch { + return false; + } +} + +function readBranch(git: string, workDir: string): string | null { + try { + const result = spawnSync(git, ['-C', workDir, 'branch', '--show-current'], { + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + }); + if (result.status !== 0) return null; + const name = result.stdout.trim(); + return name.length > 0 ? name : null; + } catch { + return null; + } +} + +function readStatus( + git: string, + workDir: string, +): { + dirty: boolean; + ahead: number; + behind: number; + diffAdded: number; + diffDeleted: number; +} { + try { + const result = spawnSync(git, ['-C', workDir, 'status', '--porcelain', '-b'], { + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + maxBuffer: 4 * 1024 * 1024, + }); + if (result.status !== 0) { + return { dirty: false, ahead: 0, behind: 0, diffAdded: 0, diffDeleted: 0 }; + } + + let dirty = false; + let ahead = 0; + let behind = 0; + for (const line of result.stdout.split('\n')) { + if (line.startsWith('## ')) { + const m = AHEAD_BEHIND_RE.exec(line); + if (m) { + ahead = Number.parseInt(m[1] ?? '0', 10) || 0; + behind = Number.parseInt(m[2] ?? '0', 10) || 0; + } + } else if (line.trim().length > 0) { + dirty = true; + } + } + const diff = dirty ? readDiffStats(git, workDir) : { added: 0, deleted: 0 }; + return { + dirty, + ahead, + behind, + diffAdded: diff.added, + diffDeleted: diff.deleted, + }; + } catch { + return { dirty: false, ahead: 0, behind: 0, diffAdded: 0, diffDeleted: 0 }; + } +} + +function readDiffStats(git: string, workDir: string): { added: number; deleted: number } { + try { + const result = spawnSync(git, ['-C', workDir, 'diff', '--numstat', 'HEAD', '--'], { + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + maxBuffer: 4 * 1024 * 1024, + }); + if (result.status !== 0) return { added: 0, deleted: 0 }; + + let added = 0; + let deleted = 0; + for (const line of result.stdout.split('\n')) { + if (!line) continue; + const [addedText, deletedText] = line.split('\t'); + added += parseDiffNumstatCount(addedText); + deleted += parseDiffNumstatCount(deletedText); + } + return { added, deleted }; + } catch { + return { added: 0, deleted: 0 }; + } +} + +function parseDiffNumstatCount(value: string | undefined): number { + if (value === undefined || value === '-') return 0; + const n = Number.parseInt(value, 10); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +function readPullRequest(workDir: string): Promise<PullRequestInfo | null> { + return new Promise((resolve) => { + // Resolve gh through PATH as well — this runs with cwd = workDir, where a + // planted `gh.exe` would otherwise be picked up by cmd.exe on Windows. + const gh = resolveCommandPath('gh', workDir); + if (gh === undefined) { + resolve(null); + return; + } + try { + execFile( + gh, + ['pr', 'view', '--json', 'number,url'], + { + cwd: workDir, + encoding: 'utf8', + env: { + ...process.env, + GH_NO_UPDATE_NOTIFIER: '1', + GH_PROMPT_DISABLED: '1', + }, + timeout: PR_SPAWN_TIMEOUT_MS, + maxBuffer: 256 * 1024, + }, + (error, stdout) => { + if (error !== null) { + resolve(null); + return; + } + resolve(parsePullRequest(stdout)); + }, + ); + } catch { + resolve(null); + } + }); +} + +function samePullRequest(a: PullRequestInfo | null, b: PullRequestInfo | null): boolean { + if (a === null || b === null) return a === b; + return a.number === b.number && a.url === b.url; +} + +function parsePullRequest(stdout: string): PullRequestInfo | null { + try { + const raw = JSON.parse(stdout) as unknown; + if (typeof raw !== 'object' || raw === null) return null; + const record = raw as Record<string, unknown>; + const number = record['number']; + const url = record['url']; + if (typeof number !== 'number' || !Number.isInteger(number) || number <= 0) return null; + if (typeof url !== 'string' || !isSafeHttpUrl(url)) return null; + return { number, url }; + } catch { + return null; + } +} + +function isSafeHttpUrl(value: string): boolean { + if (hasControlChars(value)) return false; + try { + const url = new URL(value); + return url.protocol === 'https:' || url.protocol === 'http:'; + } catch { + return false; + } +} + +function hasControlChars(value: string): boolean { + for (const char of value) { + const code = char.codePointAt(0) ?? 0; + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +export interface FormatGitBadgeOptions { + readonly linkPullRequest?: boolean; +} + +export function formatGitBadgeBase(status: GitStatus): string { + const parts: string[] = []; + const diff = formatDiffStats(status); + if (diff) parts.push(diff); + let sync = ''; + if (status.ahead > 0) sync += `↑${status.ahead}`; + if (status.behind > 0) sync += `↓${status.behind}`; + if (sync) parts.push(sync); + return parts.length === 0 ? status.branch : `${status.branch} [${parts.join(' ')}]`; +} + +export function formatPullRequestBadge( + pullRequest: PullRequestInfo, + options: FormatGitBadgeOptions = {}, +): string { + const prText = `[PR#${String(pullRequest.number)}]`; + return options.linkPullRequest ? toTerminalHyperlink(prText, pullRequest.url) : prText; +} + +export function formatGitBadge(status: GitStatus, options: FormatGitBadgeOptions = {}): string { + const base = formatGitBadgeBase(status); + if (status.pullRequest === null) return base; + + return `${base} ${formatPullRequestBadge(status.pullRequest, options)}`; +} + +function formatDiffStats(status: GitStatus): string | null { + const parts: string[] = []; + if (status.diffAdded > 0) parts.push(`+${String(status.diffAdded)}`); + if (status.diffDeleted > 0) parts.push(`-${String(status.diffDeleted)}`); + if (parts.length > 0) return parts.join(' '); + return status.dirty ? '±' : null; +} + +function toTerminalHyperlink(text: string, url: string): string { + if (!isSafeHttpUrl(url)) return text; + return `\u001B]8;;${url}\u0007${text}\u001B]8;;\u0007`; +} diff --git a/apps/kimi-code/src/utils/history/input-history.ts b/apps/kimi-code/src/utils/history/input-history.ts new file mode 100644 index 0000000000000000000000000000000000000000..cade00bec2003c8fed4592353ead759431ba2435 --- /dev/null +++ b/apps/kimi-code/src/utils/history/input-history.ts @@ -0,0 +1,46 @@ +/** + * User input history persistence — JSONL file with `{"content": "..."}` per line. + * + * Semantics: + * - One JSON object per line (`InputHistoryEntry { content }`) + * - `content` is the raw input. Shell commands are stored with a leading `!` + * (e.g. `!ls -la`) so ↑ recall can distinguish them from prompts and restore + * bash mode; the `!` is stripped again when the entry is recalled. Plain + * prompts (and legacy entries without a leading `!`) are normal prompts. + * - Append-only writes + * - Skip empty entries + * - Skip when same as last entry (consecutive deduplication) + * - Tolerate corrupt lines: log + skip, do not abort load + */ + +import { z } from 'zod'; + +import { appendJsonlLine, readJsonlFile } from '#/utils/persistence'; + +export interface InputHistoryEntry { + content: string; +} + +const InputHistoryEntrySchema: z.ZodType<InputHistoryEntry> = z.object({ + content: z.string(), +}); + +export async function loadInputHistory(file: string): Promise<InputHistoryEntry[]> { + return readJsonlFile(file, InputHistoryEntrySchema); +} + +/** + * Append an entry to the history file. Returns true if written, false if + * skipped (empty or equal to `lastContent`). + */ +export async function appendInputHistory( + file: string, + text: string, + lastContent?: string, +): Promise<boolean> { + const content = text.trim(); + if (content.length === 0) return false; + if (content === lastContent) return false; + await appendJsonlLine(file, InputHistoryEntrySchema, { content }); + return true; +} diff --git a/apps/kimi-code/src/utils/image/image-mime.ts b/apps/kimi-code/src/utils/image/image-mime.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7ceb97de18a46a770663e50104b27ec0c332e46 --- /dev/null +++ b/apps/kimi-code/src/utils/image/image-mime.ts @@ -0,0 +1,189 @@ +/** + * Detect image MIME type + dimensions from raw bytes. + * + * Uses magic-byte sniffing for MIME and minimal format-specific parsing + * for dimensions. Only formats that the kimi-core multimodal pipeline + * accepts are supported: PNG / JPEG / GIF / WebP. + * + * Unsupported or truncated inputs return `null` so the caller can + * decline the paste cleanly. + */ + +export type SupportedImageMime = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'; + +export interface ImageMeta { + mime: SupportedImageMime; + width: number; + height: number; +} + +export function parseImageMeta(bytes: Uint8Array): ImageMeta | null { + if (isPng(bytes)) return parsePng(bytes); + if (isJpeg(bytes)) return parseJpeg(bytes); + if (isGif(bytes)) return parseGif(bytes); + if (isWebp(bytes)) return parseWebp(bytes); + return null; +} + +// ── PNG ───────────────────────────────────────────────────────────── + +function isPng(b: Uint8Array): boolean { + return ( + b.length >= 8 && + b[0] === 0x89 && + b[1] === 0x50 && + b[2] === 0x4e && + b[3] === 0x47 && + b[4] === 0x0d && + b[5] === 0x0a && + b[6] === 0x1a && + b[7] === 0x0a + ); +} + +function parsePng(b: Uint8Array): ImageMeta | null { + // IHDR chunk immediately follows the 8-byte signature: length (4) + + // "IHDR" (4) + width (4 BE) + height (4 BE) + ... + if (b.length < 24) return null; + const width = readUInt32BE(b, 16); + const height = readUInt32BE(b, 20); + if (width <= 0 || height <= 0) return null; + return { mime: 'image/png', width, height }; +} + +// ── JPEG ──────────────────────────────────────────────────────────── + +function isJpeg(b: Uint8Array): boolean { + return b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff; +} + +function parseJpeg(b: Uint8Array): ImageMeta | null { + // Scan for a Start-Of-Frame marker (SOF0..SOF3, SOF5..SOF7, SOF9..SOF11, + // SOF13..SOF15). After the marker + 2-byte segment length come: + // precision (1), height (2 BE), width (2 BE), components (1). + let i = 2; + while (i < b.length) { + if (b[i] !== 0xff) { + i += 1; + continue; + } + // Skip fill bytes (0xFF padding before a marker). + while (i < b.length && b[i] === 0xff) i += 1; + if (i >= b.length) return null; + const marker = b[i]!; + i += 1; + if (marker === 0xd8 || marker === 0xd9) continue; // SOI / EOI — no length + if (i + 1 >= b.length) return null; + const segLen = readUInt16BE(b, i); + if (isSofMarker(marker)) { + if (i + 7 >= b.length) return null; + const height = readUInt16BE(b, i + 3); + const width = readUInt16BE(b, i + 5); + if (width <= 0 || height <= 0) return null; + return { mime: 'image/jpeg', width, height }; + } + i += segLen; + } + return null; +} + +function isSofMarker(marker: number): boolean { + if (marker < 0xc0 || marker > 0xcf) return false; + // Exclude DHT (0xC4), JPG (0xC8), DAC (0xCC) — these reuse the SOF + // number range but are not frame headers. + return marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; +} + +// ── GIF ───────────────────────────────────────────────────────────── + +function isGif(b: Uint8Array): boolean { + return ( + b.length >= 6 && + b[0] === 0x47 && // G + b[1] === 0x49 && // I + b[2] === 0x46 && // F + b[3] === 0x38 && // 8 + (b[4] === 0x37 || b[4] === 0x39) && // 7 or 9 + b[5] === 0x61 // a + ); +} + +function parseGif(b: Uint8Array): ImageMeta | null { + // Logical screen width/height are at offsets 6-9, little-endian. + if (b.length < 10) return null; + const width = readUInt16LE(b, 6); + const height = readUInt16LE(b, 8); + if (width <= 0 || height <= 0) return null; + return { mime: 'image/gif', width, height }; +} + +// ── WebP ──────────────────────────────────────────────────────────── + +function isWebp(b: Uint8Array): boolean { + return ( + b.length >= 12 && + b[0] === 0x52 && // R + b[1] === 0x49 && // I + b[2] === 0x46 && // F + b[3] === 0x46 && // F + b[8] === 0x57 && // W + b[9] === 0x45 && // E + b[10] === 0x42 && // B + b[11] === 0x50 // P + ); +} + +function parseWebp(b: Uint8Array): ImageMeta | null { + // WebP has three sub-formats: VP8 (simple), VP8L (lossless), VP8X + // (extended). Offset 12 is the 4-byte chunk identifier. + if (b.length < 30) return null; + const chunk = String.fromCodePoint(b[12]!, b[13]!, b[14]!, b[15]!); + if (chunk === 'VP8 ') { + // Frame data starts at offset 20 + 3-byte start code; dimensions at + // offsets 26-29 (little-endian, 14-bit values with 2 top bits masked). + const widthRaw = readUInt16LE(b, 26); + const heightRaw = readUInt16LE(b, 28); + const width = widthRaw & 0x3fff; + const height = heightRaw & 0x3fff; + if (width <= 0 || height <= 0) return null; + return { mime: 'image/webp', width, height }; + } + if (chunk === 'VP8L') { + // VP8L packs width-1 (14 bits) and height-1 (14 bits) across bytes 21-24 + // starting from the signature byte at offset 20 (0x2F). + if (b[20] !== 0x2f) return null; + const b1 = b[21]!; + const b2 = b[22]!; + const b3 = b[23]!; + const b4 = b[24]!; + const width = 1 + (((b2 & 0x3f) << 8) | b1); + const height = 1 + (((b4 & 0x0f) << 10) | (b3 << 2) | ((b2 & 0xc0) >> 6)); + if (width <= 0 || height <= 0) return null; + return { mime: 'image/webp', width, height }; + } + if (chunk === 'VP8X') { + // Canvas width-1 at offsets 24-26, height-1 at 27-29 (24-bit LE). + const width = 1 + readUInt24LE(b, 24); + const height = 1 + readUInt24LE(b, 27); + if (width <= 0 || height <= 0) return null; + return { mime: 'image/webp', width, height }; + } + return null; +} + +// ── byte helpers ──────────────────────────────────────────────────── + +function readUInt16BE(b: Uint8Array, off: number): number { + return (b[off]! << 8) | b[off + 1]!; +} +function readUInt16LE(b: Uint8Array, off: number): number { + return b[off]! | (b[off + 1]! << 8); +} +function readUInt24LE(b: Uint8Array, off: number): number { + return b[off]! | (b[off + 1]! << 8) | (b[off + 2]! << 16); +} +function readUInt32BE(b: Uint8Array, off: number): number { + return Math.trunc( + b[off]! * 0x100_0000 + (b[off + 1]! << 16) + (b[off + 2]! << 8) + b[off + 3]!, + ); +} diff --git a/apps/kimi-code/src/utils/process/external-editor.ts b/apps/kimi-code/src/utils/process/external-editor.ts new file mode 100644 index 0000000000000000000000000000000000000000..7881238b61a07c9667169b7ce2a34e693305ce1c --- /dev/null +++ b/apps/kimi-code/src/utils/process/external-editor.ts @@ -0,0 +1,61 @@ +/** + * External-editor helper — spawn $VISUAL / $EDITOR (or a configured + * command) on a temp file seeded with the current editor buffer, then + * read the edited contents back. + * + * Resolution priority: + * configured (from Core/SDK defaults or `/editor`) > + * $VISUAL > $EDITOR > undefined (caller handles "no editor" toast). + */ + +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { quoteShellArg } from '#/utils/shell-quote'; + +export function resolveEditorCommand(configured?: string | null): string | undefined { + const candidates = [configured, process.env['VISUAL'], process.env['EDITOR']]; + for (const c of candidates) { + if (typeof c === 'string' && c.trim().length > 0) { + return c.trim(); + } + } + return undefined; +} + +/** + * Launch `command` (tokenised via a shell) against a temp file seeded + * with `initialText`. Returns the edited contents on success, or + * `undefined` if the editor exited non-zero / the file disappeared. + * + * The command is passed to the system shell (`shell: true`) so users can + * supply argv-style strings like `code --wait` or `nvim +"set ft=markdown"`. + */ +export async function editInExternalEditor( + initialText: string, + command: string, +): Promise<string | undefined> { + const dir = await mkdtemp(join(tmpdir(), 'kimi-edit-')); + const file = join(dir, 'prompt.md'); + await writeFile(file, initialText, 'utf-8'); + try { + const shellCmd = `${command} ${quoteShellArg(file)}`; + const code = await new Promise<number>((resolve, reject) => { + const child = spawn(shellCmd, { + stdio: 'inherit', + shell: true, + }); + child.on('exit', (c) => { resolve(c ?? 0); }); + child.on('error', reject); + }); + if (code !== 0) return undefined; + return await readFile(file, 'utf-8'); + } finally { + await rm(dir, { recursive: true, force: true }).catch(() => { + // best-effort cleanup + }); + } +} + diff --git a/apps/kimi-code/src/utils/process/fd-detect.ts b/apps/kimi-code/src/utils/process/fd-detect.ts new file mode 100644 index 0000000000000000000000000000000000000000..48d5e147c2a059f668528f416aa69e97e618af10 --- /dev/null +++ b/apps/kimi-code/src/utils/process/fd-detect.ts @@ -0,0 +1,208 @@ +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, +} from 'node:fs'; +import { arch, platform } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import { kimiCodeCdnBase } from '#/constant/app'; +import { getBinDir } from '#/utils/paths'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; + +const CANDIDATES = ['fd', 'fdfind']; +const DOWNLOAD_TIMEOUT_MS = 120_000; + +const FD_ARCHIVE_SHA256: Record<string, string> = { + 'fd-v10.4.2-aarch64-apple-darwin.tar.gz': + '623dc0afc81b92e4d4606b380d7bc91916ba7b97814263e554d50923a39e480a', + 'fd-v10.3.0-x86_64-apple-darwin.tar.gz': + '50d30f13fe3d5914b14c4fff5abcbd4d0cdab4b855970a6956f4f006c17117a3', + 'fd-v10.4.2-aarch64-unknown-linux-gnu.tar.gz': + '6c51f7c5446b3338b1e401ff15dc194c590bb2fa64fd43ff3278300f073adec5', + 'fd-v10.4.2-x86_64-unknown-linux-musl.tar.gz': + 'e3257d48e29a6be965187dbd24ce9af564e0fe67b3e73c9bdcd180f4ec11bdde', + 'fd-v10.4.2-aarch64-pc-windows-msvc.zip': + '4f9110c2d5b33a7f760bfa5510f4c113d828109f7277d421b1053a9943c0fc92', + 'fd-v10.4.2-x86_64-pc-windows-msvc.zip': + 'b2816e506390a89941c63c9187d58a3cc10e9a55f2ef0685f9ea0eccaf7c98c8', +}; + +export function detectFdPath(): string | null { + const managed = getManagedFdPath(); + if (managed !== null) return managed; + return detectSystemFdPath(); +} + +export async function ensureFdPath(): Promise<string | null> { + const existing = detectFdPath(); + if (existing !== null) return existing; + + try { + return await downloadFd(); + } catch { + return null; + } +} + +function detectSystemFdPath(): string | null { + for (const name of CANDIDATES) { + const commandPath = resolveCommandPath(name); + if (commandPath === undefined) continue; + try { + const result = spawnSync(commandPath, ['--version'], { stdio: 'ignore' }); + if (result.status === 0) return commandPath; + } catch { + // ENOENT, EACCES, etc. — try next candidate. + } + } + return null; +} + +function getManagedFdPath(): string | null { + const binaryPath = getManagedFdBinaryPath(); + if (!existsSync(binaryPath)) return null; + try { + const result = spawnSync(binaryPath, ['--version'], { stdio: 'ignore' }); + return result.status === 0 ? binaryPath : null; + } catch { + return null; + } +} + +function getManagedFdBinaryPath(): string { + return join(getBinDir(), platform() === 'win32' ? 'fd.exe' : 'fd'); +} + +export function getFdAssetName(plat = platform(), architecture = arch()): string | null { + if (plat === 'darwin') { + if (architecture === 'arm64') return 'fd-v10.4.2-aarch64-apple-darwin.tar.gz'; + if (architecture === 'x64') return 'fd-v10.3.0-x86_64-apple-darwin.tar.gz'; + return null; + } + if (plat === 'linux') { + if (architecture === 'arm64') return 'fd-v10.4.2-aarch64-unknown-linux-gnu.tar.gz'; + if (architecture === 'x64') return 'fd-v10.4.2-x86_64-unknown-linux-musl.tar.gz'; + return null; + } + if (plat === 'win32') { + if (architecture === 'arm64') return 'fd-v10.4.2-aarch64-pc-windows-msvc.zip'; + if (architecture === 'x64') return 'fd-v10.4.2-x86_64-pc-windows-msvc.zip'; + return null; + } + return null; +} + +async function downloadFd(): Promise<string | null> { + const assetName = getFdAssetName(); + if (assetName === null) return null; + const expectedSha256 = FD_ARCHIVE_SHA256[assetName]; + if (expectedSha256 === undefined) return null; + + const binDir = getBinDir(); + mkdirSync(binDir, { recursive: true }); + + const binaryPath = getManagedFdBinaryPath(); + const extractDir = join( + binDir, + `fd_extract_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, + ); + mkdirSync(extractDir, { recursive: true }); + const archivePath = join(extractDir, assetName); + + try { + const downloadUrl = `${kimiCodeCdnBase()}/fd/${assetName}`; + await downloadFile(downloadUrl, archivePath); + verifyArchive(archivePath, expectedSha256); + extractArchive(archivePath, extractDir, assetName); + + const binaryName = platform() === 'win32' ? 'fd.exe' : 'fd'; + const extractedBinary = findBinaryRecursively(extractDir, binaryName); + if (extractedBinary === null) return null; + + rmSync(binaryPath, { force: true }); + renameSync(extractedBinary, binaryPath); + if (platform() !== 'win32') { + chmodSync(binaryPath, 0o755); + } + return binaryPath; + } finally { + rmSync(extractDir, { recursive: true, force: true }); + } +} + +function verifyArchive(path: string, expectedSha256: string): void { + const actualSha256 = createHash('sha256').update(readFileSync(path)).digest('hex'); + if (actualSha256 !== expectedSha256) { + throw new Error(`fd archive checksum mismatch: ${actualSha256} !== ${expectedSha256}`); + } +} + +async function downloadFile(url: string, dest: string): Promise<void> { + const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); + if (!response.ok) { + throw new Error(`Failed to download fd: ${response.status}`); + } + if (response.body === null) { + throw new Error('Failed to download fd: empty response body'); + } + await pipeline(Readable.fromWeb(response.body), createWriteStream(dest)); +} + +function extractArchive(archivePath: string, extractDir: string, assetName: string): void { + if (assetName.endsWith('.tar.gz')) { + runExtractionCommand('tar', ['xzf', archivePath, '-C', extractDir]); + return; + } + if (assetName.endsWith('.zip')) { + if (platform() === 'win32') { + runExtractionCommand(getWindowsTarCommand(), ['xf', archivePath, '-C', extractDir]); + return; + } + runExtractionCommand('unzip', ['-q', archivePath, '-d', extractDir]); + return; + } + throw new Error(`Unsupported fd archive format: ${assetName}`); +} + +function runExtractionCommand(command: string, args: readonly string[]): void { + const result = spawnSync(command, [...args], { stdio: 'pipe' }); + if (!result.error && result.status === 0) return; + const stderr = result.stderr.toString().trim(); + const detail = + result.error?.message ?? (stderr.length > 0 ? stderr : `exit status ${String(result.status)}`); + throw new Error(`Failed to extract fd with ${command}: ${detail}`); +} + +function getWindowsTarCommand(): string { + const systemRoot = process.env['SystemRoot'] ?? process.env['WINDIR']; + if (systemRoot !== undefined) { + const systemTar = join(systemRoot, 'System32', 'tar.exe'); + if (existsSync(systemTar)) return systemTar; + } + return 'tar.exe'; +} + +function findBinaryRecursively(rootDir: string, binaryName: string): string | null { + const stack = [rootDir]; + while (stack.length > 0) { + const currentDir = stack.pop(); + if (currentDir === undefined) continue; + const entries = readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(currentDir, entry.name); + if (entry.isFile() && entry.name === binaryName) return fullPath; + if (entry.isDirectory()) stack.push(fullPath); + } + } + return null; +} diff --git a/apps/kimi-code/src/utils/process/resolve-command.ts b/apps/kimi-code/src/utils/process/resolve-command.ts new file mode 100644 index 0000000000000000000000000000000000000000..721342e601afd2d76ff9f4bef8f8ad563779c0f0 --- /dev/null +++ b/apps/kimi-code/src/utils/process/resolve-command.ts @@ -0,0 +1,79 @@ +import { accessSync, constants, statSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; + +// cmd.exe / CreateProcess search the current directory before PATH, so on +// Windows a bare command name can execute a binary planted in the workspace +// the user just opened (binary planting). Resolving through PATH ourselves — +// and refusing any hit inside the cwd — keeps that from happening before the +// workspace trust gate has run. + +const DEFAULT_WIN32_PATHEXT = ['.COM', '.EXE', '.BAT', '.CMD']; + +function pathExtensions(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): readonly string[] { + if (platform !== 'win32') return ['']; + const raw = env['PATHEXT']; + if (raw === undefined || raw.trim().length === 0) return DEFAULT_WIN32_PATHEXT; + return raw + .split(';') + .map((ext) => ext.trim()) + .filter((ext) => ext.length > 0); +} + +function candidateNames(command: string, extensions: readonly string[]): readonly string[] { + if (extensions.length === 1 && extensions[0] === '') return [command]; + const lower = command.toLowerCase(); + // An explicitly suffixed name (npm.cmd) is tried as-is first, like cmd.exe. + if (extensions.some((ext) => lower.endsWith(ext.toLowerCase()))) { + return [command, ...extensions.map((ext) => command + ext)]; + } + return extensions.map((ext) => command + ext); +} + +function isExecutableFile(candidate: string, platform: NodeJS.Platform): boolean { + try { + if (!statSync(candidate).isFile()) return false; + // Windows has no executable bit; file existence is enough there. + if (platform !== 'win32') accessSync(candidate, constants.X_OK); + return true; + } catch { + return false; + } +} + +function isInsideCwd(candidate: string, cwd: string, platform: NodeJS.Platform): boolean { + let resolvedCandidate = resolve(candidate); + let resolvedCwd = resolve(cwd); + if (platform === 'win32') { + resolvedCandidate = resolvedCandidate.toLowerCase(); + resolvedCwd = resolvedCwd.toLowerCase(); + } + const rel = relative(resolvedCwd, resolvedCandidate); + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); +} + +/** + * Resolve a bare command name to an absolute executable path by searching + * PATH (PATHEXT-aware on Windows). Returns undefined when the command is not + * found — or when the only hit lives inside `cwd`, since executing that would + * run whatever a malicious workspace planted there. + */ +export function resolveCommandPath(command: string, cwd: string = process.cwd()): string | undefined { + const platform = process.platform; + const env = process.env; + const extensions = pathExtensions(platform, env); + const names = candidateNames(command, extensions); + const pathValue = env['PATH'] ?? ''; + const separator = platform === 'win32' ? ';' : ':'; + for (const dir of pathValue.split(separator)) { + // An empty PATH entry means the current directory on POSIX — anything it + // could produce would be rejected by the cwd check anyway, so skip it. + if (dir === '') continue; + for (const name of names) { + const candidate = join(dir, name); + if (!isExecutableFile(candidate, platform)) continue; + if (isInsideCwd(candidate, cwd, platform)) return undefined; + return resolve(candidate); + } + } + return undefined; +} diff --git a/apps/kimi-code/src/utils/process/shell-env.ts b/apps/kimi-code/src/utils/process/shell-env.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e63ac5607b088f38b4719b4d31d338c16db0ab9 --- /dev/null +++ b/apps/kimi-code/src/utils/process/shell-env.ts @@ -0,0 +1,18 @@ +import type { ShellEnvironment } from '@moonshot-ai/kimi-code-sdk'; + +function detectMultiplexer(): string | undefined { + if (process.env['TMUX']) return 'tmux'; + if (process.env['STY']) return 'screen'; + if (process.env['ZELLIJ']) return 'zellij'; + return undefined; +} + +export function detectShellEnvironment(): ShellEnvironment { + return { + term: process.env['TERM'] || undefined, + termProgram: process.env['TERM_PROGRAM'] || undefined, + termProgramVersion: process.env['TERM_PROGRAM_VERSION'] || undefined, + multiplexer: detectMultiplexer(), + shell: process.env['SHELL'] || undefined, + }; +} diff --git a/apps/kimi-code/src/utils/process/stdin.ts b/apps/kimi-code/src/utils/process/stdin.ts new file mode 100644 index 0000000000000000000000000000000000000000..c59792f2a769169c82b74ec24d9a45c5f34002a7 --- /dev/null +++ b/apps/kimi-code/src/utils/process/stdin.ts @@ -0,0 +1,18 @@ +import { createInterface } from 'node:readline'; + +export function readStdinText(): Promise<string> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk)); + process.stdin.on('end', () =>{ resolve(Buffer.concat(chunks).toString('utf-8').trim()); }); + process.stdin.on('error', reject); + process.stdin.resume(); + }); +} + +export async function* createStdinLineReader(): AsyncIterable<string> { + const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }); + for await (const line of rl) { + yield line; + } +} diff --git a/apps/kimi-code/src/utils/usage/debug-timing.ts b/apps/kimi-code/src/utils/usage/debug-timing.ts new file mode 100644 index 0000000000000000000000000000000000000000..15de825703c2c33624f262c9fa1d82b892e05e64 --- /dev/null +++ b/apps/kimi-code/src/utils/usage/debug-timing.ts @@ -0,0 +1,111 @@ +import { formatTokenCount } from './usage-format'; + +interface DebugTokenUsage { + readonly inputOther?: number; + readonly inputCacheRead?: number; + readonly inputCacheCreation?: number; + readonly output?: number; +} + +export interface StepTimingInput { + readonly llmFirstTokenLatencyMs?: number; + readonly llmStreamDurationMs?: number; + /** + * Split of `llmFirstTokenLatencyMs` into the client-side request-build + * portion (`llmRequestBuildMs`) and the network + API-server portion + * (`llmServerFirstTokenMs`). Both present together or not at all. + */ + readonly llmRequestBuildMs?: number; + readonly llmServerFirstTokenMs?: number; + /** + * Split of `llmStreamDurationMs` (the decode window) into server time spent + * awaiting parts (`llmServerDecodeMs`) and client time spent processing parts + * (`llmClientConsumeMs`). Both present together or not at all. + */ + readonly llmServerDecodeMs?: number; + readonly llmClientConsumeMs?: number; + readonly llmClientBlockedMs?: number; + readonly usage?: DebugTokenUsage; +} + +// Decode TPS is only meaningful when the output actually streamed over a +// measurable window. Below this threshold the duration is dominated by +// `Date.now()`'s ~1ms quantization (short / single-chunk tool-call turns can +// drain in 1ms), so dividing output tokens by it would report inflated rates +// like tens of thousands of tok/s. In that case we report the raw counts +// instead of a meaningless ratio. +const MIN_STREAM_MS_FOR_TPS = 50; + +export function formatStepDebugTiming(input: StepTimingInput): string | undefined { + const latency = input.llmFirstTokenLatencyMs; + const streamMs = input.llmStreamDurationMs; + if (latency === undefined || streamMs === undefined) return undefined; + + const parts: string[] = [`TTFT: ${formatTtft(input)}`]; + const outputTokens = input.usage?.output; + if (outputTokens !== undefined && outputTokens > 0) { + if (streamMs >= MIN_STREAM_MS_FOR_TPS) { + const tps = (outputTokens / (streamMs / 1000)).toFixed(1); + parts.push( + `TPS: ${tps} tok/s (${outputTokens} tokens in ${formatDuration(streamMs)}${formatDecodeSplit(input)})`, + ); + } else { + parts.push( + `${outputTokens} tokens in ${formatDuration(streamMs)} (stream too short for TPS)`, + ); + } + } + + const inputTokens = usageInputTotal(input.usage); + const hasInputUsage = + input.usage !== undefined && + (input.usage.inputOther !== undefined || + input.usage.inputCacheRead !== undefined || + input.usage.inputCacheCreation !== undefined); + if (hasInputUsage && (inputTokens > 0 || (outputTokens ?? 0) > 0)) { + const cacheReadTokens = input.usage.inputCacheRead ?? 0; + const cacheCreationTokens = input.usage.inputCacheCreation ?? 0; + const cacheHitRate = inputTokens > 0 ? Math.round((cacheReadTokens / inputTokens) * 100) : 0; + const cacheParts = [`cache read ${formatTokenCount(cacheReadTokens)} (${cacheHitRate}%)`]; + if (cacheCreationTokens > 0) { + cacheParts.push(`write ${formatTokenCount(cacheCreationTokens)}`); + } + parts.push(`tokens in ${formatTokenCount(inputTokens)}`); + parts.push(cacheParts.join(' / ')); + } + + return `[Debug] ${parts.join(' | ')}`; +} + +function usageInputTotal(usage: DebugTokenUsage | undefined): number { + if (usage === undefined) return 0; + return (usage.inputOther ?? 0) + (usage.inputCacheRead ?? 0) + (usage.inputCacheCreation ?? 0); +} + +// Render TTFT, splitting the latency into the network + API-server portion and +// the in-process request-build portion when the provider reported the +// boundary. Falls back to the bare total otherwise. +function formatTtft(input: StepTimingInput): string { + const total = formatDuration(input.llmFirstTokenLatencyMs ?? 0); + const build = input.llmRequestBuildMs; + const server = input.llmServerFirstTokenMs; + if (build === undefined || server === undefined) return total; + return `${total} (api ${formatDuration(server)} + client ${formatDuration(build)})`; +} + +// Render the decode-window split as a trailing clause, e.g. +// `; server 4.6s + client 0.4s`. A large client share means the host's per-part +// processing is throttling decode. Empty when the provider did not report it. +function formatDecodeSplit(input: StepTimingInput): string { + const server = input.llmServerDecodeMs; + const client = input.llmClientConsumeMs; + if (server === undefined || client === undefined) return ''; + const blocked = input.llmClientBlockedMs; + const blockedPart = blocked === undefined ? '' : ` (busy ${formatDuration(blocked)})`; + return `; server ${formatDuration(server)}${blockedPart} + client ${formatDuration(client)}`; +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} diff --git a/apps/kimi-code/src/utils/usage/usage-format.ts b/apps/kimi-code/src/utils/usage/usage-format.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d332b302b0ebfa05836710de2aa091f1b559903 --- /dev/null +++ b/apps/kimi-code/src/utils/usage/usage-format.ts @@ -0,0 +1,115 @@ +/** + * Formatting helpers for the `/usage` slash command. + * + * Kept pure + ANSI-free so they're trivial to unit-test; the slash + * command itself chalks the colour afterwards. + */ + +import { type ManagedQuota, type ManagedQuotaEntry } from '@moonshot-ai/kimi-code-oauth'; + +/** + * Format a token count in 1024-based units: context sizes are powers of + * two, so 262144 reads as "256k", not "262.1k". k values at or above + * 100 are rounded to whole numbers ("977k"). + */ +export function formatTokenCount(n: number): string { + if (!Number.isFinite(n) || n < 0) return '0'; + if (n >= 1024 * 1024) return `${trimDecimal(n / (1024 * 1024))}M`; + if (n >= 1024) { + const k = n / 1024; + return `${k >= 100 ? Math.round(k) : trimDecimal(k)}k`; + } + return String(n); +} + +/** One decimal place, dropping a redundant ".0" ("1.0" → "1", "1.5" stays). */ +function trimDecimal(v: number): string { + const s = v.toFixed(1); + return s.endsWith('.0') ? s.slice(0, -2) : s; +} + +/** + * Usage as a whole-number percentage of `max`, ceiled so any non-zero + * usage shows at least 1%, clamped to [0, 100]. A non-positive or + * non-finite `max` reports 0. + */ +export function usagePercent(used: number, max: number): number { + if (!Number.isFinite(max) || max <= 0) return 0; + return Math.min(100, Math.max(0, Math.ceil((used / max) * 100))); +} + +/** `usagePercent` for callers that only know the ratio (NaN-safe). */ +export function usagePercentFromRatio(ratio: number): number { + return Math.min(100, Math.max(0, Math.ceil(safeUsageRatio(ratio) * 100))); +} + +/** + * Build a `[███░░░░░░░]` style bar. Returns a plain-ASCII string with + * `filled`/`empty` glyphs — colouring is the caller's responsibility. + */ +export function renderProgressBar(ratio: number, width = 20, filled = '█', empty = '░'): string { + const clamped = safeUsageRatio(ratio); + const filledCount = Math.round(clamped * width); + return filled.repeat(filledCount) + empty.repeat(Math.max(0, width - filledCount)); +} + +export function safeUsageRatio(ratio: number): number { + return Number.isFinite(ratio) ? Math.max(0, Math.min(ratio, 1)) : 0; +} + +/** + * Map a usage ratio to a semantic colour token — the `/usage` renderer + * translates these into palette hex values. + */ +export function ratioSeverity(ratio: number): 'ok' | 'warn' | 'danger' { + if (ratio >= 0.85) return 'danger'; + if (ratio >= 0.5) return 'warn'; + return 'ok'; +} + +/** + * The kimi/code split of the new plan's monthly quota: `codeRatio` is the + * code-typed share of the monthly total as served, `kimiRatio` the + * remainder, clamped against float noise. + */ +export interface MonthlyUsageBreakdown { + readonly kimiRatio: number; + readonly codeRatio: number; +} + +export interface QuotaUsageRow { + readonly name: string; + readonly usedRatio: number; + readonly resetAt?: string; + readonly breakdown?: MonthlyUsageBreakdown; +} + +/** + * Assemble the plan-usage rows for the `/usage` report from the managed + * quota: one row per quota window the backend served — 5h, weekly, + * monthly (with its kimi/code breakdown) — in payload order. Entries the + * backend omitted are skipped. + */ +export function quotaUsageRows(quota: ManagedQuota): QuotaUsageRow[] { + const rows: QuotaUsageRow[] = []; + const push = ( + name: string, + entry: ManagedQuotaEntry | undefined, + breakdown?: MonthlyUsageBreakdown, + ): void => { + if (entry === undefined) return; + rows.push({ name, usedRatio: entry.usedRatio, resetAt: entry.resetAt, breakdown }); + }; + push('5h limit', quota.usages.limit5h); + push('Weekly limit', quota.usages.limit7d); + push('Monthly limit', quota.usages.monthTotal, monthlyBreakdown(quota)); + return rows; +} + +function monthlyBreakdown(quota: ManagedQuota): MonthlyUsageBreakdown | undefined { + const total = quota.usages.monthTotal; + if (total === undefined) return undefined; + const codeRatio = safeUsageRatio(quota.usages.monthCode?.usedRatio ?? 0); + const kimiRatio = safeUsageRatio(Math.round((total.usedRatio - codeRatio) * 1e6) / 1e6); + return { kimiRatio, codeRatio }; +}