SaylorTwift HF Staff commited on
Commit
ce605ef
·
verified ·
1 Parent(s): 7c5ed63

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. apps/kimi-code/dist-web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  2. apps/kimi-code/dist-web/assets/arc-Bh_RIraT.js +1 -0
  3. apps/kimi-code/dist-web/assets/chunk-32BRIVSS-Dvm33eUZ.js +1 -0
  4. apps/kimi-code/dist-web/assets/clojure-P80f7IUj.js +1 -0
  5. apps/kimi-code/dist-web/assets/dagre-VKFMJZFB-DkhizOKY.js +4 -0
  6. apps/kimi-code/dist-web/assets/mindmap-definition-LN4V7U3C-BYqiMfi6.js +96 -0
  7. apps/kimi-code/dist-web/assets/pkl-u5AG7uiY.js +1 -0
  8. apps/kimi-code/dist-web/assets/timeline-definition-FHXFAJF6-CHrctaTh.js +120 -0
  9. apps/kimi-code/src/cli/sub/fork.ts +197 -0
  10. apps/kimi-code/src/cli/sub/login-flow.ts +83 -0
  11. apps/kimi-code/src/cli/sub/provider.ts +602 -0
  12. apps/kimi-code/src/cli/sub/session.ts +154 -0
  13. apps/kimi-code/src/cli/sub/upgrade.ts +232 -0
  14. apps/kimi-code/src/cli/sub/web/access-urls.ts +97 -0
  15. apps/kimi-code/src/cli/sub/web/deprecated-server.ts +38 -0
  16. apps/kimi-code/src/cli/sub/web/index.ts +34 -0
  17. apps/kimi-code/src/cli/sub/web/legacy-kill.ts +263 -0
  18. apps/kimi-code/src/cli/sub/web/networks.ts +84 -0
  19. apps/kimi-code/src/cli/sub/web/remote-control.ts +92 -0
  20. apps/kimi-code/src/cli/sub/web/rotate-token.ts +56 -0
  21. apps/kimi-code/src/cli/sub/web/run.ts +528 -0
  22. apps/kimi-code/src/cli/sub/web/shared.ts +164 -0
  23. apps/kimi-code/src/tui/banner/banner-config.ts +24 -0
  24. apps/kimi-code/src/tui/banner/banner-provider.ts +356 -0
  25. apps/kimi-code/src/tui/banner/state.ts +69 -0
  26. apps/kimi-code/src/tui/commands/add-dir.ts +107 -0
  27. apps/kimi-code/src/tui/commands/auth.ts +262 -0
  28. apps/kimi-code/src/tui/commands/btw.ts +28 -0
  29. apps/kimi-code/src/tui/commands/complete-args.ts +41 -0
  30. apps/kimi-code/src/tui/commands/config.ts +912 -0
  31. apps/kimi-code/src/tui/commands/copy.ts +41 -0
  32. apps/kimi-code/src/tui/commands/dispatch.ts +607 -0
  33. apps/kimi-code/src/tui/commands/experimental-flags.ts +19 -0
  34. apps/kimi-code/src/tui/commands/goal.ts +576 -0
  35. apps/kimi-code/src/tui/commands/index.ts +43 -0
  36. apps/kimi-code/src/tui/commands/info.ts +255 -0
  37. apps/kimi-code/src/tui/commands/parse.ts +14 -0
  38. apps/kimi-code/src/tui/commands/plugin-commands.ts +27 -0
  39. apps/kimi-code/src/tui/commands/plugins.ts +901 -0
  40. apps/kimi-code/src/tui/commands/prompts.ts +251 -0
  41. apps/kimi-code/src/tui/commands/provider.ts +432 -0
  42. apps/kimi-code/src/tui/commands/registry.ts +476 -0
  43. apps/kimi-code/src/tui/commands/reload.ts +86 -0
  44. apps/kimi-code/src/tui/commands/resolve.ts +157 -0
  45. apps/kimi-code/src/tui/commands/session.ts +219 -0
  46. apps/kimi-code/src/tui/commands/skills.ts +48 -0
  47. apps/kimi-code/src/tui/commands/swarm.ts +159 -0
  48. apps/kimi-code/src/tui/commands/tower.ts +90 -0
  49. apps/kimi-code/src/tui/commands/types.ts +30 -0
  50. apps/kimi-code/src/tui/commands/undo.ts +558 -0
apps/kimi-code/dist-web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 ADDED
Binary file (9.64 kB). View file
 
apps/kimi-code/dist-web/assets/arc-Bh_RIraT.js ADDED
@@ -0,0 +1 @@
 
 
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*u<y))return u=(n*(h-A)-d*(l-v))/u,[l+u*O,h+u*i]}function U(l,h,I,D,v,A,z){var a=l-I,O=h-D,i=(z?A:-A)/J(a*a+O*O),n=i*O,d=-i*a,u=l+n,s=h+d,f=I+n,c=D+d,B=(u+f)/2,t=(s+c)/2,m=f-u,g=c-s,R=m*m+g*g,T=v-A,P=u*c-f*s,S=(g<0?-1:1)*J(fn(0,T*T*R-P*P)),Y=(P*g-m*S)/R,$=(-P*m-g*S)/R,w=(P*g+m*S)/R,p=(-P*m+g*S)/R,x=Y-B,e=$-t,r=w-B,C=p-t;return x*x+e*e>r*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()),s<u&&(d=s,s=u,u=d),!(s>y))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(B<rn)if(E=pn(G,H,V,W,N,Q,L,M)){var X=G-E[0],Z=H-E[1],b=N-E[0],k=Q-E[1],nn=1/q(on((X*b+Z*k)/(J(X*X+Z*Z)*J(b*b+k*k)))/2),en=J(E[0]*E[0]+E[1]*E[1]);p=_(w,(u-en)/(nn-1)),x=_(w,(s-en)/(nn+1))}else p=x=0}S>y?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),x<w?a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,s,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),!t),a.arc(r.cx,r.cy,x,o(r.y11,r.x11),o(r.y01,r.x01),!t))):(a.moveTo(G,H),a.arc(0,0,s,m,g,!t)):a.moveTo(G,H),!(u>y)||!(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<w?a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,u,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),t),a.arc(r.cx,r.cy,p,o(r.y11,r.x11),o(r.y01,r.x01),!t))):a.arc(0,0,u,T,R,t)}if(a.closePath(),n)return a=null,n+""||null}return i.centroid=function(){var n=(+l.apply(this,arguments)+ +h.apply(this,arguments))/2,d=(+v.apply(this,arguments)+ +A.apply(this,arguments))/2-rn/2;return[F(d)*n,q(d)*n]},i.innerRadius=function(n){return arguments.length?(l=typeof n=="function"?n:j(+n),i):l},i.outerRadius=function(n){return arguments.length?(h=typeof n=="function"?n:j(+n),i):h},i.cornerRadius=function(n){return arguments.length?(I=typeof n=="function"?n:j(+n),i):I},i.padRadius=function(n){return arguments.length?(D=n==null?null:typeof n=="function"?n:j(+n),i):D},i.startAngle=function(n){return arguments.length?(v=typeof n=="function"?n:j(+n),i):v},i.endAngle=function(n){return arguments.length?(A=typeof n=="function"?n:j(+n),i):A},i.padAngle=function(n){return arguments.length?(z=typeof n=="function"?n:j(+n),i):z},i.context=function(n){return arguments.length?(a=n??null,i):a},i}export{hn as d};
apps/kimi-code/dist-web/assets/chunk-32BRIVSS-Dvm33eUZ.js ADDED
@@ -0,0 +1 @@
 
 
1
+ import{_ as i,d as l,S as d,j as o}from"./mermaidParser.worker-BFSlSHEW.js";var x=i((r,t)=>{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};
apps/kimi-code/dist-web/assets/clojure-P80f7IUj.js ADDED
@@ -0,0 +1 @@
 
 
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":"(?<!\\\\\\\\);","beginCaptures":{"0":{"name":"punctuation.definition.comment.clojure"}},"end":"$","name":"comment.line.semicolon.clojure"},"constants":{"patterns":[{"match":"(nil)(?=([])}\\\\s]))","name":"constant.language.nil.clojure"},{"match":"(true|false)","name":"constant.language.boolean.clojure"},{"match":"(##(?:Inf|-Inf|NaN))","name":"constant.numeric.symbol.clojure"},{"match":"([-+]?\\\\d+/\\\\d+)","name":"constant.numeric.ratio.clojure"},{"match":"([-+]?(?:3[0-6]|[12]\\\\d|[2-9])[Rr][0-9A-Za-z]+N?)","name":"constant.numeric.arbitrary-radix.clojure"},{"match":"([-+]?0[Xx]\\\\h+N?)","name":"constant.numeric.hexadecimal.clojure"},{"match":"([-+]?0[0-7]+N?)","name":"constant.numeric.octal.clojure"},{"match":"([-+]?[0-9]+(\\\\.|(?=[EMe]))[0-9]*([Ee][-+]?[0-9]+)?M?)","name":"constant.numeric.double.clojure"},{"match":"([-+]?\\\\d+N?)","name":"constant.numeric.long.clojure"},{"include":"#keyword"}]},"dynamic-variables":{"match":"\\\\*[-!+.:<-?_\\\\w\\\\d]+\\\\*","name":"meta.symbol.dynamic.clojure"},"keyfn":{"patterns":[{"match":"(?<=([(\\\\[{\\\\s]))(if(-[-?\\\\p{Ll}]*)?|when(-[-\\\\p{Ll}]*)?|for(-[-\\\\p{Ll}]*)?|cond|do|let(-[-?\\\\p{Ll}]*)?|binding|loop|recur|fn|throw[-\\\\p{Ll}]*|try|catch|finally|(\\\\p{Ll}*case))(?=([])}\\\\s]))","name":"storage.control.clojure"},{"match":"(?<=([(\\\\[{\\\\s]))(declare-?|(in-)?ns|import|use|require|load|compile|(def[-\\\\p{Ll}]*))(?=([])}\\\\s]))","name":"keyword.control.clojure"}]},"keyword":{"match":"(?<=([(\\\\[{\\\\s])):[-!#*+./:<-?_\\\\w]+(?=([]),}\\\\s]))","name":"constant.keyword.clojure"},"map":{"begin":"(\\\\{)","beginCaptures":{"1":{"name":"punctuation.section.map.begin.clojure"}},"end":"(}(?=[])}\\\\s]*(?:;|$)))|(})","endCaptures":{"1":{"name":"punctuation.section.map.end.trailing.clojure"},"2":{"name":"punctuation.section.map.end.clojure"}},"name":"meta.map.clojure","patterns":[{"include":"$self"}]},"metadata":{"patterns":[{"begin":"(\\\\^\\\\{)","beginCaptures":{"1":{"name":"punctuation.section.metadata.map.begin.clojure"}},"end":"(}(?=[])}\\\\s]*(?:;|$)))|(})","endCaptures":{"1":{"name":"punctuation.section.metadata.map.end.trailing.clojure"},"2":{"name":"punctuation.section.metadata.map.end.clojure"}},"name":"meta.metadata.map.clojure","patterns":[{"include":"$self"}]},{"begin":"(\\\\^)","end":"(\\\\s)","name":"meta.metadata.simple.clojure","patterns":[{"include":"#keyword"},{"include":"$self"}]}]},"namespace-symbol":{"patterns":[{"captures":{"1":{"name":"meta.symbol.namespace.clojure"}},"match":"([-!*+.<-?_\\\\p{L}][-!*+.:<-?_\\\\w\\\\d]*)/"}]},"quoted-sexp":{"begin":"(['\`]\\\\()","beginCaptures":{"1":{"name":"punctuation.section.expression.begin.clojure"}},"end":"(\\\\))$|(\\\\)(?=[])}\\\\s]*(?:;|$)))|(\\\\))","endCaptures":{"1":{"name":"punctuation.section.expression.end.trailing.clojure"},"2":{"name":"punctuation.section.expression.end.trailing.clojure"},"3":{"name":"punctuation.section.expression.end.clojure"}},"name":"meta.quoted-expression.clojure","patterns":[{"include":"$self"}]},"regexp":{"begin":"#\\"","beginCaptures":{"0":{"name":"punctuation.definition.regexp.begin.clojure"}},"end":"\\"","endCaptures":{"0":{"name":"punctuation.definition.regexp.end.clojure"}},"name":"string.regexp.clojure","patterns":[{"include":"#regexp_escaped_char"}]},"regexp_escaped_char":{"match":"\\\\\\\\.","name":"constant.character.escape.clojure"},"set":{"begin":"(#\\\\{)","beginCaptures":{"1":{"name":"punctuation.section.set.begin.clojure"}},"end":"(}(?=[])}\\\\s]*(?:;|$)))|(})","endCaptures":{"1":{"name":"punctuation.section.set.end.trailing.clojure"},"2":{"name":"punctuation.section.set.end.clojure"}},"name":"meta.set.clojure","patterns":[{"include":"$self"}]},"sexp":{"begin":"(\\\\()","beginCaptures":{"1":{"name":"punctuation.section.expression.begin.clojure"}},"end":"(\\\\))$|(\\\\)(?=[])}\\\\s]*(?:;|$)))|(\\\\))","endCaptures":{"1":{"name":"punctuation.section.expression.end.trailing.clojure"},"2":{"name":"punctuation.section.expression.end.trailing.clojure"},"3":{"name":"punctuation.section.expression.end.clojure"}},"name":"meta.expression.clojure","patterns":[{"begin":"(?<=\\\\()(ns|declare|def[-!*+.:<-?_\\\\w\\\\d]*|[-!*+.:<-?_\\\\w][-!*+.:<-?_\\\\w\\\\d]*/def[-!*+.:<-?_\\\\w\\\\d]*)\\\\s+","beginCaptures":{"1":{"name":"keyword.control.clojure"}},"end":"(?=\\\\))","name":"meta.definition.global.clojure","patterns":[{"include":"#metadata"},{"include":"#dynamic-variables"},{"match":"([-!*+.<-?_\\\\p{L}][-!*+.:<-?_\\\\w\\\\d]*)","name":"entity.global.clojure"},{"include":"$self"}]},{"include":"#keyfn"},{"include":"#constants"},{"include":"#vector"},{"include":"#map"},{"include":"#set"},{"include":"#sexp"},{"captures":{"1":{"name":"entity.name.function.clojure"}},"match":"(?<=\\\\()(.+?)(?=[)\\\\s])","patterns":[{"include":"$self"}]},{"include":"$self"}]},"shebang-comment":{"begin":"^(#!)","beginCaptures":{"1":{"name":"punctuation.definition.comment.shebang.clojure"}},"end":"$","name":"comment.line.shebang.clojure"},"string":{"begin":"(?<!\\\\\\\\)(\\")","beginCaptures":{"1":{"name":"punctuation.definition.string.begin.clojure"}},"end":"(\\")","endCaptures":{"1":{"name":"punctuation.definition.string.end.clojure"}},"name":"string.quoted.double.clojure","patterns":[{"match":"\\\\\\\\.","name":"constant.character.escape.clojure"}]},"symbol":{"patterns":[{"match":"([-!*+.<-?_\\\\p{L}][-!*+.:<-?_\\\\w\\\\d]*)","name":"meta.symbol.clojure"}]},"var":{"match":"(?<=([(\\\\[{\\\\s])#)'[-!*+./:<-?_\\\\w]+(?=([])}\\\\s]))","name":"meta.var.clojure"},"vector":{"begin":"(\\\\[)","beginCaptures":{"1":{"name":"punctuation.section.vector.begin.clojure"}},"end":"(](?=[])}\\\\s]*(?:;|$)))|(])","endCaptures":{"1":{"name":"punctuation.section.vector.end.trailing.clojure"},"2":{"name":"punctuation.section.vector.end.clojure"}},"name":"meta.vector.clojure","patterns":[{"include":"$self"}]}},"scopeName":"source.clojure","aliases":["clj"]}`)),n=[e];export{n as default};
apps/kimi-code/dist-web/assets/dagre-VKFMJZFB-DkhizOKY.js ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import{c as O,w as I,a as J,f as P,b as E,s as A}from"./chunk-RYQCIY6F-BQRhK4dT.js";import{_ as w,am as v,an as D,ao as H,ap as Y,l,c as _,aq as W,ar as $,ae as j,as as q,af as R,ad as F,at as z,au as K,av as G}from"./mermaidParser.worker-BFSlSHEW.js";import{G as Q}from"./graph-CAUpWmoK.js";import{l as U}from"./layout-CvP6o9AK.js";import"./map-CWKZqZjr.js";var C=w((s,t,g)=>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
2
+ Node.id = `,d,`
3
+ data=`,u.height,`
4
+ 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};
apps/kimi-code/dist-web/assets/mindmap-definition-LN4V7U3C-BYqiMfi6.js ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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)+`:
2
+ `+y.showPosition()+`
3
+ 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).
4
+ `+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()+`
5
+ `+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;r<p.length;r++)if(o=this._input.match(this.rules[p[r]]),o&&(!i||o[0].length>i[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.
6
+ `+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].level<e)return this.nodes[n];return null}getMindmap(){return this.nodes.length>0?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
7
+ `+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<e.THEME_COLOR_LIMIT;t++)e["lineColor"+t]=e["lineColor"+t]||e["cScaleInv"+t],ge(e["lineColor"+t])?e["lineColor"+t]=ue(e["lineColor"+t],20):e["lineColor"+t]=pe(e["lineColor"+t],20);for(let t=0;t<e.THEME_COLOR_LIMIT;t++){const h=""+(g==="neo"?Math.max(10-(t-1)*2,2):17-3*t);a+=`
8
+ .section-${t-1} rect, .section-${t-1} path, .section-${t-1} circle, .section-${t-1} polygon, .section-${t-1} path {
9
+ fill: ${e["cScale"+t]};
10
+ }
11
+ .section-${t-1} text {
12
+ fill: ${e["cScaleLabel"+t]};
13
+ }
14
+ .section-${t-1} span {
15
+ color: ${e["cScaleLabel"+t]};
16
+ }
17
+ .node-icon-${t-1} {
18
+ font-size: 40px;
19
+ color: ${e["cScaleLabel"+t]};
20
+ }
21
+ .section-edge-${t-1}{
22
+ stroke: ${e["cScale"+t]};
23
+ }
24
+ .edge-depth-${t-1}{
25
+ stroke-width: ${h};
26
+ }
27
+ .section-${t-1} line {
28
+ stroke: ${e["cScaleInv"+t]} ;
29
+ stroke-width: 3;
30
+ }
31
+
32
+ .disabled, .disabled circle, .disabled text {
33
+ fill: lightgray;
34
+ }
35
+ .disabled text {
36
+ fill: #efefef;
37
+ }
38
+ [data-look="neo"].mindmap-node.section-${t-1} rect, [data-look="neo"].mindmap-node.section-${t-1} path, [data-look="neo"].mindmap-node.section-${t-1} circle, [data-look="neo"].mindmap-node.section-${t-1} polygon {
39
+ fill: ${n==="redux"||n==="redux-dark"||n==="neutral"?e.mainBkg:e["cScale"+t]};
40
+ stroke: ${n==="redux"||n==="redux-dark"?e.nodeBorder:e["cScale"+t]};
41
+ stroke-width: ${e.strokeWidth??2}px;
42
+ }
43
+ [data-look="neo"].section-edge-${t-1}{
44
+ stroke: ${n?.includes("redux")||n==="neo-dark"?e.nodeBorder:e["cScale"+t]};
45
+ }
46
+ [data-look="neo"].mindmap-node.section-${t-1} text {
47
+ fill: ${n==="redux"||n==="redux-dark"?e.nodeBorder:e["cScaleLabel"+(n==="neutral"?1:t)]};
48
+ }
49
+ `}return a},"genSections"),Le=l((e,n,g)=>{let a="";for(let t=0;t<e;t++)a+=`
50
+ [data-look="neo"].mindmap-node.section-${t-1} rect, [data-look="neo"].mindmap-node.section-${t-1} path, [data-look="neo"].mindmap-node.section-${t-1} circle, [data-look="neo"].mindmap-node.section-${t-1} polygon {
51
+ stroke: url(${n}-gradient);
52
+ fill: ${g};
53
+ }
54
+ .section-${t-1} line {
55
+ stroke-width: 0;
56
+ }`;return a},"genGradient"),ve=l(e=>{const{theme:n}=e,g=e.svgId,a=e.dropShadow?e.dropShadow.replace("url(#drop-shadow)",`url(${g}-drop-shadow)`):"none";return`
57
+ .edge {
58
+ stroke-width: 3;
59
+ }
60
+ ${De(e)}
61
+ .section-root rect, .section-root path, .section-root circle, .section-root polygon {
62
+ fill: ${e.git0};
63
+ }
64
+ .section-root text {
65
+ fill: ${e.gitBranchLabel0};
66
+ }
67
+ .section-root span {
68
+ color: ${n?.includes("redux")?e.nodeBorder:e.gitBranchLabel0};
69
+ }
70
+ .icon-container {
71
+ height:100%;
72
+ display: flex;
73
+ justify-content: center;
74
+ align-items: center;
75
+ }
76
+ .edge {
77
+ fill: none;
78
+ }
79
+ .mindmap-node-label {
80
+ dy: 1em;
81
+ alignment-baseline: middle;
82
+ text-anchor: middle;
83
+ dominant-baseline: middle;
84
+ text-align: center;
85
+ }
86
+ [data-look="neo"].mindmap-node {
87
+ filter: ${a};
88
+ }
89
+ [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 {
90
+ fill: ${n?.includes("redux")?e.mainBkg:e.git0};
91
+ }
92
+ [data-look="neo"].mindmap-node.section-root .text-inner-tspan {
93
+ fill: ${n?.includes("redux")?e.nodeBorder:e["cScaleLabel"+(n==="neutral"?1:0)]};
94
+ }
95
+ ${e.useGradient&&g&&e.mainBkg?Le(e.THEME_COLOR_LIMIT,g,e.mainBkg):""}
96
+ `},"getStyles"),Te=ve,Ae={get db(){return new Se},renderer:Ne,parser:be,styles:Te};export{Ae as diagram};
apps/kimi-code/dist-web/assets/pkl-u5AG7uiY.js ADDED
@@ -0,0 +1 @@
 
 
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};
apps/kimi-code/dist-web/assets/timeline-definition-FHXFAJF6-CHrctaTh.js ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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)+`:
2
+ `+w.showPosition()+`
3
+ 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).
4
+ `+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()+`
5
+ `+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;u<x.length;u++)if(l=this._input.match(this.rules[x[u]]),l&&(!d||l[0].length>d[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.
6
+ `+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&&n<t;)e=ct(),n++;return q.push(...K),q},"getTasks"),Et=o(function(e,t,n){const i={id:mt++,section:D,type:D,task:e,score:t||0,events:n?[n]:[]};K.push(i)},"addTask"),Tt=o(function(e){K.find(n=>n.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(/<br\s*\/?>/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(/<br\s*\/?>/gi);for(let p=0;p<l.length;p++){const x=p*s-s*(l.length-1)/2,u=h.append("text").attr("x",c+f/2).attr("y",a).attr("fill",k).style("text-anchor","middle").style("font-size",s).style("font-family",d);u.append("tspan").attr("x",c+f/2).attr("dy",x).text(l[p]),u.attr("y",a+g/2).attr("dominant-baseline","central").attr("alignment-baseline","central"),i(u,m)}}o(t,"byTspan");function n(r,h,c,a,f,g,m,y){const k=h.append("switch"),d=k.append("foreignObject").attr("x",c).attr("y",a).attr("width",f).attr("height",g).attr("position","fixed").append("xhtml:div").style("display","table").style("height","100%").style("width","100%");d.append("div").attr("class","label").style("display","table-cell").style("text-align","center").style("vertical-align","middle").text(r),t(r,k,c,a,f,g,m,y),i(d,m)}o(n,"byFo");function i(r,h){for(const c in h)c in h&&r.attr(c,h[c])}return o(i,"_setTextAttrs"),function(r){return r.textPlacement==="fo"?n:r.textPlacement==="old"?e:t}})(),Jt=o(function(e,t){Nt=0,et=-1,e.append("defs").append("marker").attr("id",t+"-arrowhead").attr("refX",5).attr("refY",2).attr("markerWidth",6).attr("markerHeight",4).attr("orient","auto").append("path").attr("d","M 0,0 V 4 L6,2 Z")},"initGraphics");function it(e,t){e.each(function(){var n=X(this),i=n.text().split(/(\s+|<br>)/).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;m<i.length;m++)r=i[i.length-1-m],h.push(r),g.text(h.join(" ").trim()),(g.node().getComputedTextLength()>t||r==="<br>")&&(h.pop(),g.text(h.join(" ").trim()),r==="<br>"?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&&lt(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<e.THEME_COLOR_LIMIT;a++){const f=`${17-3*a}`,g=i?e.borderColorArray[a]:e.mainBkg,m=i?e.borderColorArray[a]:e.nodeBorder;c+=`
7
+ .section-${a-1} rect,
8
+ .section-${a-1} path,
9
+ .section-${a-1} circle {
10
+ fill: ${n&&i?e.mainBkg:g};
11
+ stroke: ${m};
12
+ stroke-width: ${e.strokeWidth};
13
+ filter: ${h};
14
+ }
15
+
16
+ .section-${a-1} text {
17
+ fill: ${e.nodeBorder};
18
+ font-weight: ${e.fontWeight}
19
+ }
20
+
21
+ .node-icon-${a-1} {
22
+ font-size: 40px;
23
+ color: ${e["cScaleLabel"+a]};
24
+ }
25
+
26
+ .section-edge-${a-1} {
27
+ stroke: ${e["cScale"+a]};
28
+ }
29
+
30
+ .edge-depth-${a-1} {
31
+ stroke-width: ${f};
32
+ }
33
+
34
+ .section-${a-1} line {
35
+ stroke: ${e["cScaleInv"+a]};
36
+ stroke-width: 3;
37
+ }
38
+
39
+ .lineWrapper line {
40
+ stroke: ${e.nodeBorder};
41
+ stroke-width:${e.strokeWidth}
42
+ }
43
+
44
+ .disabled,
45
+ .disabled circle,
46
+ .disabled text {
47
+ fill: ${e.tertiaryColor??"lightgray"};
48
+ }
49
+
50
+ .disabled text {
51
+ fill: ${e.clusterBorder??"#efefef"};
52
+ }
53
+ `}return c},"genReduxSections"),de=o(e=>{let t="";for(let n=0;n<e.THEME_COLOR_LIMIT;n++)e["lineColor"+n]=e["lineColor"+n]||e["cScaleInv"+n],Rt(e["lineColor"+n])?e["lineColor"+n]=Ct(e["lineColor"+n],20):e["lineColor"+n]=Wt(e["lineColor"+n],20);for(let n=0;n<e.THEME_COLOR_LIMIT;n++){const i=""+(17-3*n);t+=`
54
+ .section-${n-1} rect, .section-${n-1} path, .section-${n-1} circle, .section-${n-1} path {
55
+ fill: ${e["cScale"+n]};
56
+ }
57
+ .section-${n-1} text {
58
+ fill: ${e["cScaleLabel"+n]};
59
+ }
60
+ .node-icon-${n-1} {
61
+ font-size: 40px;
62
+ color: ${e["cScaleLabel"+n]};
63
+ }
64
+ .section-edge-${n-1}{
65
+ stroke: ${e["cScale"+n]};
66
+ }
67
+ .edge-depth-${n-1}{
68
+ stroke-width: ${i};
69
+ }
70
+ .section-${n-1} line {
71
+ stroke: ${e["cScaleInv"+n]} ;
72
+ stroke-width: 3;
73
+ }
74
+
75
+ .lineWrapper line{
76
+ stroke: ${e["cScaleLabel"+n]} ;
77
+ }
78
+
79
+ .disabled, .disabled circle, .disabled text {
80
+ fill: ${e.tertiaryColor??"lightgray"};
81
+ }
82
+ .disabled text {
83
+ fill: ${e.clusterBorder??"#efefef"};
84
+ }
85
+ `}return t},"genSections"),ue=o(e=>{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<e.THEME_COLOR_LIMIT;c++)h+=`
86
+ .section-${c-1}[data-look="neo"] rect,
87
+ .section-${c-1}[data-look="neo"] path,
88
+ .section-${c-1}[data-look="neo"] circle {
89
+ fill: ${e.mainBkg};
90
+ stroke: url(#${r}-gradient);
91
+ stroke-width: 2;
92
+ }
93
+ .section-${c-1}[data-look="neo"] line {
94
+ stroke: url(#${r}-gradient);
95
+ stroke-width: 2;
96
+ }`;return`
97
+ .edge {
98
+ stroke-width: 3;
99
+ }
100
+ ${n?he(e):de(e)}
101
+ ${h}
102
+ .section-root rect, .section-root path, .section-root circle {
103
+ fill: ${e.git0};
104
+ }
105
+ .section-root text {
106
+ fill: ${e.gitBranchLabel0};
107
+ }
108
+ .icon-container {
109
+ height:100%;
110
+ display: flex;
111
+ justify-content: center;
112
+ align-items: center;
113
+ }
114
+ .edge {
115
+ fill: none;
116
+ }
117
+ .eventWrapper {
118
+ filter: brightness(120%);
119
+ }
120
+ `},"getStyles"),pe=ue,ge={setConf:o(()=>{},"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};
apps/kimi-code/src/cli/sub/fork.ts ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createInterface } from 'node:readline/promises';
2
+
3
+ import {
4
+ setTelemetryContext,
5
+ shutdownTelemetry,
6
+ track,
7
+ withTelemetryContext,
8
+ } from '@moonshot-ai/kimi-telemetry';
9
+ import {
10
+ createKimiHarness,
11
+ type KimiHarness,
12
+ type SessionSummary,
13
+ type TelemetryClient,
14
+ } from '@moonshot-ai/kimi-code-sdk';
15
+ import type { Command } from 'commander';
16
+
17
+ import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app';
18
+ import { createCliTelemetryBootstrap, initializeCliTelemetry } from '#/cli/telemetry';
19
+ import { createKimiCodeHostIdentity } from '#/cli/version';
20
+
21
+ interface WritableLike {
22
+ write(chunk: string): boolean;
23
+ }
24
+
25
+ export interface ForkedSessionResult {
26
+ readonly id: string;
27
+ readonly title?: string | undefined;
28
+ }
29
+
30
+ export interface ForkDeps {
31
+ readonly listSessions: (workDir: string) => Promise<readonly SessionSummary[]>;
32
+ readonly forkSession: (sessionId: string) => Promise<ForkedSessionResult>;
33
+ readonly confirmPreviousSession: (summary: SessionSummary) => Promise<boolean>;
34
+ readonly cwd: () => string;
35
+ readonly stdout: WritableLike;
36
+ readonly stderr: WritableLike;
37
+ readonly exit: (code: number) => never;
38
+ }
39
+
40
+ export interface ForkOptions {
41
+ readonly yes: boolean;
42
+ readonly cwd?: string | undefined;
43
+ }
44
+
45
+ export async function handleFork(
46
+ deps: ForkDeps,
47
+ sessionId: string | undefined,
48
+ opts: ForkOptions,
49
+ ): Promise<void> {
50
+ let resolvedId = normalizeOptionalSessionId(sessionId);
51
+ if (resolvedId === undefined) {
52
+ const sessions = await deps.listSessions(opts.cwd ?? deps.cwd());
53
+ const latest = sessions[0];
54
+ if (latest === undefined) {
55
+ deps.stderr.write('No previous session found to fork.\n');
56
+ deps.exit(1);
57
+ }
58
+ if (!opts.yes) {
59
+ const confirmed = await deps.confirmPreviousSession(latest);
60
+ if (!confirmed) {
61
+ deps.stdout.write('Fork cancelled.\n');
62
+ return;
63
+ }
64
+ }
65
+ resolvedId = latest.id;
66
+ }
67
+
68
+ const startedAt = Date.now();
69
+ try {
70
+ const forked = await deps.forkSession(resolvedId);
71
+ const elapsedMs = Date.now() - startedAt;
72
+ const title = forked.title === undefined ? '' : ` ("${forked.title}")`;
73
+ deps.stdout.write(`Forked to ${forked.id}${title} in ${elapsedMs}ms\n`);
74
+ } catch (error) {
75
+ deps.stderr.write(`${errorMessage(error)}\n`);
76
+ deps.exit(1);
77
+ }
78
+ }
79
+
80
+ export function registerForkCommand(parent: Command, deps?: Partial<ForkDeps>): void {
81
+ parent
82
+ .command('fork')
83
+ .description('Fork a session into a new session.')
84
+ .option(
85
+ '--cwd <path>',
86
+ 'Working directory used to find the most recent session to fork. Defaults to the current directory.',
87
+ )
88
+ .option('-y, --yes', 'Skip previous-session confirmation.')
89
+ .argument('[sessionId]', 'Session id to fork. Defaults to the most recent session.')
90
+ .action(async (sessionId: string | undefined, options: { cwd?: string; yes?: boolean }) => {
91
+ const resolved = createDefaultForkDeps(deps);
92
+ try {
93
+ await handleFork(resolved, sessionId, {
94
+ yes: options.yes === true,
95
+ cwd: options.cwd,
96
+ });
97
+ } finally {
98
+ await resolved.close();
99
+ }
100
+ });
101
+ }
102
+
103
+ function createDefaultForkDeps(overrides: Partial<ForkDeps> = {}): ForkDeps & {
104
+ readonly close: () => Promise<void>;
105
+ } {
106
+ let harness: KimiHarness | undefined;
107
+ let telemetryBootstrap: ReturnType<typeof createCliTelemetryBootstrap> | undefined;
108
+ let telemetryInitialized = false;
109
+ let telemetryShutdown = false;
110
+ const identity = createKimiCodeHostIdentity();
111
+ const telemetryClient: TelemetryClient = {
112
+ track,
113
+ withContext: withTelemetryContext,
114
+ setContext: setTelemetryContext,
115
+ };
116
+ const getTelemetryBootstrap = (): ReturnType<typeof createCliTelemetryBootstrap> => {
117
+ telemetryBootstrap ??= createCliTelemetryBootstrap();
118
+ return telemetryBootstrap;
119
+ };
120
+ const getHarness = (): KimiHarness => {
121
+ const currentTelemetryBootstrap = getTelemetryBootstrap();
122
+ harness ??= createKimiHarness({
123
+ homeDir: currentTelemetryBootstrap.homeDir,
124
+ identity,
125
+ telemetry: telemetryClient,
126
+ });
127
+ return harness;
128
+ };
129
+ const initializeDefaultTelemetry = async (): Promise<void> => {
130
+ if (telemetryInitialized) return;
131
+ const currentTelemetryBootstrap = getTelemetryBootstrap();
132
+ const currentHarness = getHarness();
133
+ await currentHarness.ensureConfigFile();
134
+ const config = await currentHarness.getConfig();
135
+ initializeCliTelemetry({
136
+ harness: currentHarness,
137
+ bootstrap: currentTelemetryBootstrap,
138
+ config,
139
+ version: identity.version,
140
+ uiMode: CLI_UI_MODE,
141
+ });
142
+ telemetryInitialized = true;
143
+ };
144
+ const shutdownDefaultTelemetry = async (): Promise<void> => {
145
+ if (!telemetryInitialized || telemetryShutdown) return;
146
+ telemetryShutdown = true;
147
+ await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
148
+ };
149
+ return {
150
+ listSessions:
151
+ overrides.listSessions ??
152
+ ((workDir: string) =>
153
+ getHarness().listSessions({
154
+ workDir,
155
+ })),
156
+ forkSession:
157
+ overrides.forkSession ??
158
+ (async (sessionId: string) => {
159
+ await initializeDefaultTelemetry();
160
+ try {
161
+ const forked = await getHarness().forkSession({ id: sessionId });
162
+ return { id: forked.id, title: forked.summary?.title };
163
+ } finally {
164
+ await shutdownDefaultTelemetry();
165
+ }
166
+ }),
167
+ confirmPreviousSession: overrides.confirmPreviousSession ?? confirmPreviousSession,
168
+ cwd: overrides.cwd ?? (() => process.cwd()),
169
+ stdout: overrides.stdout ?? process.stdout,
170
+ stderr: overrides.stderr ?? process.stderr,
171
+ exit: overrides.exit ?? ((code: number) => process.exit(code)),
172
+ close: async () => {
173
+ await harness?.close();
174
+ },
175
+ };
176
+ }
177
+
178
+ function normalizeOptionalSessionId(sessionId: string | undefined): string | undefined {
179
+ const trimmed = sessionId?.trim();
180
+ return trimmed === undefined || trimmed === '' ? undefined : trimmed;
181
+ }
182
+
183
+ async function confirmPreviousSession(summary: SessionSummary): Promise<boolean> {
184
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
185
+ try {
186
+ const title = summary.title === undefined ? summary.id : `${summary.title} (${summary.id})`;
187
+ const answer = await rl.question(`Fork previous session "${title}"? [Y/n] `);
188
+ const trimmed = answer.trim().toLowerCase();
189
+ return trimmed === '' || trimmed === 'y' || trimmed === 'yes';
190
+ } finally {
191
+ rl.close();
192
+ }
193
+ }
194
+
195
+ function errorMessage(error: unknown): string {
196
+ return error instanceof Error ? error.message : String(error);
197
+ }
apps/kimi-code/src/cli/sub/login-flow.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared device-code login flow used by both `kimi login` (top-level
3
+ * subcommand) and `kimi acp --login` (the first-class ACP terminal-auth
4
+ * entry point). Exiting the process is part of the contract — callers
5
+ * MUST treat the returned promise as `Promise<never>`.
6
+ */
7
+
8
+ import { createKimiHarness } from '@moonshot-ai/kimi-code-sdk';
9
+ import { OAuthAccessDeniedError, type KimiRegion } from '@moonshot-ai/kimi-code-oauth';
10
+
11
+ import { createKimiCodeHostIdentity } from '#/cli/version';
12
+ import { openUrl } from '#/utils/open-url';
13
+ import { persistedKimiOAuthRef, regionForBareLogin } from '#/utils/region';
14
+
15
+ /** Parse a `--region` CLI flag; exits with an actionable message on bad input. */
16
+ export function parseRegionFlag(value: string): KimiRegion {
17
+ if (value !== 'mainland-cn' && value !== 'global') {
18
+ process.stderr.write(`Invalid --region "${value}" (expected "mainland-cn" or "global").\n`);
19
+ process.exit(1);
20
+ }
21
+ return value;
22
+ }
23
+
24
+ export async function runLoginFlow(options: { region?: KimiRegion } = {}): Promise<never> {
25
+ // No flag: a fresh install follows the resolved region (env/marker/
26
+ // default); an existing login keeps its own environment (see
27
+ // regionForBareLogin — the default slot re-pins mainland-cn, a scoped slot
28
+ // keeps its configured hosts).
29
+ const region = options.region ?? regionForBareLogin(persistedKimiOAuthRef());
30
+ const identity = createKimiCodeHostIdentity();
31
+ const harness = createKimiHarness({
32
+ identity,
33
+ uiMode: 'cli',
34
+ });
35
+ const controller = new AbortController();
36
+ process.once('SIGINT', () => {
37
+ controller.abort();
38
+ });
39
+ try {
40
+ const result = await harness.auth.login(undefined, {
41
+ signal: controller.signal,
42
+ region,
43
+ onDeviceCode: (data) => {
44
+ const url = data.verificationUriComplete || data.verificationUri;
45
+ // Print the manual fallback before attempting to open the user's
46
+ // browser so headless/browser-opener failures never hide the URL
47
+ // and code needed to complete login.
48
+ process.stderr.write(
49
+ [
50
+ '',
51
+ `Opening browser for Kimi device login: ${url}`,
52
+ `If the browser did not open, paste the URL above and enter code: ${data.userCode}`,
53
+ data.expiresIn !== null && data.expiresIn !== undefined
54
+ ? `Code expires in ${data.expiresIn}s.`
55
+ : undefined,
56
+ 'Waiting for authorization to complete…',
57
+ '',
58
+ ]
59
+ .filter((line): line is string => line !== undefined)
60
+ .join('\n'),
61
+ );
62
+ try {
63
+ openUrl(url);
64
+ } catch {
65
+ // Best effort only: the manual fallback has already been printed.
66
+ }
67
+ },
68
+ });
69
+ process.stderr.write(`Logged in to ${result.providerName}.\n`);
70
+ process.exit(0);
71
+ } catch (error) {
72
+ if (controller.signal.aborted) {
73
+ process.stderr.write('Login cancelled.\n');
74
+ } else if (error instanceof OAuthAccessDeniedError) {
75
+ const message = error instanceof Error ? error.message : String(error);
76
+ process.stderr.write(`Login cancelled: ${message}\n`);
77
+ } else {
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ process.stderr.write(`Login failed: ${message}\n`);
80
+ }
81
+ process.exit(1);
82
+ }
83
+ }
apps/kimi-code/src/cli/sub/provider.ts ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * `kimi provider` sub-command — non-interactive provider management.
3
+ *
4
+ * Mirrors the TUI `/provider` flow (apps/kimi-code/src/tui/commands/provider.ts)
5
+ * for the custom-registry path so users can import an api.json document, drop
6
+ * a provider, or inspect what is configured without launching the TUI.
7
+ *
8
+ * `add` writes the same `source = { kind: 'apiJson', url, apiKey }` blob the
9
+ * TUI does; the next launch's `refreshAllProviderModels`
10
+ * (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by URL, retries
11
+ * available API-key candidates, and re-fetches the model list, so periodic
12
+ * refresh is automatic.
13
+ */
14
+
15
+ import {
16
+ applyCustomRegistryProvider,
17
+ CustomRegistryApiError,
18
+ fetchCustomRegistry,
19
+ type CustomRegistrySource,
20
+ type ManagedKimiConfigShape,
21
+ } from '@moonshot-ai/kimi-code-oauth';
22
+ import {
23
+ applyCatalogProvider,
24
+ catalogProviderModels,
25
+ CatalogFetchError,
26
+ createKimiHarness,
27
+ DEFAULT_CATALOG_URL,
28
+ resolveCatalogImport,
29
+ type Catalog,
30
+ type CatalogProviderEntry,
31
+ type KimiConfig,
32
+ type KimiHarness,
33
+ } from '@moonshot-ai/kimi-code-sdk';
34
+ import type { Command } from 'commander';
35
+
36
+ import { createKimiCodeHostIdentity, createKimiCodeUserAgent } from '#/cli/version';
37
+ import { fetchCatalogOrBuiltIn } from '#/utils/catalog-fetch';
38
+
39
+ interface WritableLike {
40
+ write(chunk: string): boolean;
41
+ }
42
+
43
+ export interface ProviderDeps {
44
+ readonly getHarness: () => KimiHarness;
45
+ readonly stdout: WritableLike;
46
+ readonly stderr: WritableLike;
47
+ readonly env: NodeJS.ProcessEnv;
48
+ readonly exit: (code: number) => never;
49
+ }
50
+
51
+ interface AddOptions {
52
+ readonly apiKey?: string;
53
+ }
54
+
55
+ interface ListOptions {
56
+ readonly json: boolean;
57
+ }
58
+
59
+ interface CatalogListOptions {
60
+ readonly json: boolean;
61
+ readonly filter?: string;
62
+ readonly url?: string;
63
+ }
64
+
65
+ interface CatalogAddOptions {
66
+ readonly apiKey?: string;
67
+ readonly defaultModel?: string;
68
+ readonly url?: string;
69
+ readonly baseUrl?: string;
70
+ }
71
+
72
+ export async function handleProviderAdd(
73
+ deps: ProviderDeps,
74
+ url: string,
75
+ opts: AddOptions,
76
+ ): Promise<void> {
77
+ const apiKey = resolveApiKey(opts.apiKey, deps.env);
78
+ if (apiKey === undefined) {
79
+ deps.stderr.write(
80
+ 'Missing API key. Pass --api-key <key> or set KIMI_REGISTRY_API_KEY.\n',
81
+ );
82
+ deps.exit(1);
83
+ }
84
+
85
+ const trimmedUrl = url.trim();
86
+ if (trimmedUrl.length === 0) {
87
+ deps.stderr.write('Registry URL is required.\n');
88
+ deps.exit(1);
89
+ }
90
+
91
+ const source: CustomRegistrySource = {
92
+ kind: 'apiJson',
93
+ url: trimmedUrl,
94
+ apiKey,
95
+ };
96
+
97
+ const harness = deps.getHarness();
98
+ await harness.ensureConfigFile();
99
+
100
+ let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>;
101
+ try {
102
+ entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() });
103
+ } catch (error) {
104
+ const suffix = error instanceof CustomRegistryApiError ? ` (HTTP ${String(error.status)})` : '';
105
+ deps.stderr.write(`Failed to fetch registry${suffix}: ${errorMessage(error)}\n`);
106
+ deps.exit(1);
107
+ }
108
+
109
+ const entryList = Object.values(entries);
110
+ if (entryList.length === 0) {
111
+ deps.stderr.write(`Registry at ${trimmedUrl} contained no usable providers.\n`);
112
+ deps.exit(1);
113
+ }
114
+
115
+ // `harness.removeProvider` reloads the config from disk on each call (see
116
+ // `core-impl.ts removeKimiProvider`), so calling it inside the apply loop
117
+ // would discard providers we already applied in memory but have not yet
118
+ // persisted. Drop every stale id up front in a single batch instead, then
119
+ // apply against the resulting fresh config.
120
+ let config = await harness.getConfig();
121
+ const staleIds = entryList
122
+ .filter((entry) => config.providers[entry.id] !== undefined)
123
+ .map((entry) => entry.id);
124
+ for (const id of staleIds) {
125
+ config = await harness.removeProvider(id);
126
+ }
127
+
128
+ const addedProviderIds: string[] = [];
129
+ let modelCount = 0;
130
+ for (const entry of entryList) {
131
+ applyCustomRegistryProvider(asManaged(config), entry, source);
132
+ addedProviderIds.push(entry.id);
133
+ modelCount += Object.keys(entry.models).length;
134
+ }
135
+
136
+ await harness.setConfig({
137
+ providers: config.providers,
138
+ models: config.models,
139
+ });
140
+
141
+ deps.stdout.write(
142
+ `Imported ${String(addedProviderIds.length)} provider${addedProviderIds.length === 1 ? '' : 's'} ` +
143
+ `(${String(modelCount)} model${modelCount === 1 ? '' : 's'}) from ${trimmedUrl}:\n`,
144
+ );
145
+ for (const id of addedProviderIds) {
146
+ deps.stdout.write(` - ${id}\n`);
147
+ }
148
+ }
149
+
150
+ export async function handleProviderRemove(
151
+ deps: ProviderDeps,
152
+ providerId: string,
153
+ ): Promise<void> {
154
+ const harness = deps.getHarness();
155
+ await harness.ensureConfigFile();
156
+ const config = await harness.getConfig();
157
+ if (config.providers[providerId] === undefined) {
158
+ deps.stderr.write(`Provider "${providerId}" not found.\n`);
159
+ deps.exit(1);
160
+ }
161
+ await harness.removeProvider(providerId);
162
+ deps.stdout.write(`Removed provider "${providerId}".\n`);
163
+ }
164
+
165
+ export async function handleProviderList(
166
+ deps: ProviderDeps,
167
+ opts: ListOptions,
168
+ ): Promise<void> {
169
+ const harness = deps.getHarness();
170
+ await harness.ensureConfigFile();
171
+ const config = await harness.getConfig();
172
+
173
+ if (opts.json) {
174
+ deps.stdout.write(
175
+ `${JSON.stringify({ providers: config.providers, models: config.models ?? {} }, null, 2)}\n`,
176
+ );
177
+ return;
178
+ }
179
+
180
+ const modelsByProvider = new Map<string, string[]>();
181
+ for (const [alias, model] of Object.entries(config.models ?? {})) {
182
+ const list = modelsByProvider.get(model.provider) ?? [];
183
+ list.push(alias);
184
+ modelsByProvider.set(model.provider, list);
185
+ }
186
+
187
+ const providerIds = Object.keys(config.providers).toSorted();
188
+ if (providerIds.length === 0) {
189
+ deps.stdout.write('No providers configured.\n');
190
+ return;
191
+ }
192
+
193
+ for (const id of providerIds) {
194
+ const provider = config.providers[id]!;
195
+ const aliases = modelsByProvider.get(id) ?? [];
196
+ const sourceLabel = providerSourceLabel(provider);
197
+ deps.stdout.write(
198
+ `${id} type=${provider.type} models=${String(aliases.length)} source=${sourceLabel}\n`,
199
+ );
200
+ }
201
+ if (config.defaultModel !== undefined) {
202
+ deps.stdout.write(`\nDefault model: ${config.defaultModel}\n`);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Fetches the models.dev-style public catalog and lists providers, or — when
208
+ * `providerId` is given — drills into one provider and lists its models. This
209
+ * mirrors the discovery half of the TUI "Known third-party provider" flow.
210
+ */
211
+ export async function handleCatalogList(
212
+ deps: ProviderDeps,
213
+ providerId: string | undefined,
214
+ opts: CatalogListOptions,
215
+ ): Promise<void> {
216
+ const url = opts.url ?? DEFAULT_CATALOG_URL;
217
+ const catalog = await loadCatalogOrExit(deps, url);
218
+
219
+ if (providerId !== undefined) {
220
+ const entry = catalog[providerId];
221
+ if (entry === undefined) {
222
+ deps.stderr.write(`Provider "${providerId}" not found in catalog at ${url}.\n`);
223
+ deps.exit(1);
224
+ }
225
+ const models = catalogProviderModels(entry);
226
+ if (opts.json) {
227
+ deps.stdout.write(
228
+ `${JSON.stringify({ providerId, name: entry.name ?? providerId, models }, null, 2)}\n`,
229
+ );
230
+ return;
231
+ }
232
+ if (models.length === 0) {
233
+ deps.stdout.write(`Provider "${providerId}" lists no usable models in this catalog.\n`);
234
+ return;
235
+ }
236
+ deps.stdout.write(`${entry.name ?? providerId} (${providerId})\n`);
237
+ for (const model of models) {
238
+ const cap: string[] = [];
239
+ if (model.capability.tool_use) cap.push('tool_use');
240
+ if (model.capability.thinking) cap.push('thinking');
241
+ if (model.capability.image_in) cap.push('image_in');
242
+ const ctx =
243
+ typeof model.capability.max_context_tokens === 'number'
244
+ ? String(model.capability.max_context_tokens)
245
+ : '?';
246
+ const capLabel = cap.length > 0 ? ` [${cap.join(',')}]` : '';
247
+ deps.stdout.write(` ${model.id} ctx=${ctx}${capLabel}\n`);
248
+ }
249
+ return;
250
+ }
251
+
252
+ const filter = opts.filter?.toLowerCase();
253
+ const entries = Object.entries(catalog)
254
+ .filter(([id, entry]) => {
255
+ if (filter === undefined) return true;
256
+ const haystack = `${id} ${entry.name ?? ''}`.toLowerCase();
257
+ return haystack.includes(filter);
258
+ })
259
+ .toSorted(([a], [b]) => a.localeCompare(b));
260
+
261
+ if (opts.json) {
262
+ const out: Record<string, CatalogProviderEntry> = {};
263
+ for (const [id, entry] of entries) out[id] = entry;
264
+ deps.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
265
+ return;
266
+ }
267
+
268
+ if (entries.length === 0) {
269
+ if (filter !== undefined) {
270
+ deps.stdout.write(`No providers in catalog match "${filter}".\n`);
271
+ } else {
272
+ deps.stdout.write('Catalog is empty.\n');
273
+ }
274
+ return;
275
+ }
276
+
277
+ for (const [id, entry] of entries) {
278
+ const modelCount = entry.models === undefined ? 0 : Object.keys(entry.models).length;
279
+ const resolution = resolveCatalogImport(entry);
280
+ const wireLabel =
281
+ resolution.kind === 'invalid'
282
+ ? '?'
283
+ : resolution.guessed
284
+ ? `${resolution.wire} (guessed)`
285
+ : resolution.wire;
286
+ deps.stdout.write(
287
+ `${id} wire=${wireLabel} models=${String(modelCount)} ${entry.name ?? ''}\n`,
288
+ );
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Imports a known provider from the models.dev catalog by id. Unlike
294
+ * `provider add` (which expects a custom api.json), this command relies on
295
+ * the catalog's normalized metadata to fill in context limits and capabilities.
296
+ */
297
+ export async function handleCatalogAdd(
298
+ deps: ProviderDeps,
299
+ providerId: string,
300
+ opts: CatalogAddOptions,
301
+ ): Promise<void> {
302
+ const apiKey = resolveApiKey(opts.apiKey, deps.env);
303
+ if (apiKey === undefined) {
304
+ deps.stderr.write(
305
+ 'Missing API key. Pass --api-key <key> or set KIMI_REGISTRY_API_KEY.\n',
306
+ );
307
+ deps.exit(1);
308
+ }
309
+
310
+ const url = opts.url ?? DEFAULT_CATALOG_URL;
311
+ const catalog = await loadCatalogOrExit(deps, url);
312
+
313
+ const entry = catalog[providerId];
314
+ if (entry === undefined) {
315
+ deps.stderr.write(`Provider "${providerId}" not found in catalog at ${url}.\n`);
316
+ deps.exit(1);
317
+ }
318
+
319
+ const resolution = resolveCatalogImport(entry, opts.baseUrl);
320
+ if (resolution.kind === 'invalid') {
321
+ switch (resolution.reason) {
322
+ case 'unknown-explicit-type':
323
+ deps.stderr.write(
324
+ `Provider "${providerId}" declares protocol "${entry.type}" in the catalog, which this client version does not support.\n`,
325
+ );
326
+ break;
327
+ case 'proprietary-sdk':
328
+ deps.stderr.write(
329
+ `Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock or Cohere); it cannot be imported from the catalog.\n`,
330
+ );
331
+ break;
332
+ case 'empty-base-url':
333
+ deps.stderr.write('--base-url cannot be empty.\n');
334
+ break;
335
+ case 'placeholder-base-url':
336
+ deps.stderr.write(
337
+ `Base URL "${opts.baseUrl}" contains an env placeholder. Pass --base-url with the resolved value.\n`,
338
+ );
339
+ break;
340
+ }
341
+ deps.exit(1);
342
+ }
343
+ if (resolution.kind === 'needs-base-url') {
344
+ deps.stderr.write(
345
+ `The catalog does not declare an endpoint for "${providerId}". Pass --base-url <url> (e.g. the vendor's OpenAI-compatible base URL).\n`,
346
+ );
347
+ deps.exit(1);
348
+ }
349
+ const { wire, baseUrl } = resolution;
350
+
351
+ const models = catalogProviderModels(entry);
352
+ if (models.length === 0) {
353
+ deps.stderr.write(`Provider "${providerId}" lists no usable models in this catalog.\n`);
354
+ deps.exit(1);
355
+ }
356
+
357
+ if (opts.defaultModel !== undefined && !models.some((m) => m.id === opts.defaultModel)) {
358
+ deps.stderr.write(
359
+ `Model "${opts.defaultModel}" is not in provider "${providerId}". Run "kimi provider catalog list ${providerId}" to see available ids.\n`,
360
+ );
361
+ deps.exit(1);
362
+ }
363
+
364
+ const harness = deps.getHarness();
365
+ await harness.ensureConfigFile();
366
+
367
+ let config = await harness.getConfig();
368
+
369
+ // Capture defaults BEFORE `removeProvider`, because that call clears
370
+ // `defaultModel` when it points at one of this provider's aliases (see
371
+ // `core-impl.ts removeKimiProvider`). Without this, re-importing an
372
+ // already-configured provider would lose the user's previously-set default
373
+ // even when `--default-model` is not supplied.
374
+ const previousDefaultModel = config.defaultModel;
375
+ const previousThinking = config.thinking;
376
+
377
+ if (config.providers[providerId] !== undefined) {
378
+ config = await harness.removeProvider(providerId);
379
+ }
380
+
381
+ // `applyCatalogProvider` always overwrites both `defaultModel` and
382
+ // `[thinking]`. The values we pass here are temporary; we restore
383
+ // a consistent state in the post-apply block below.
384
+ applyCatalogProvider(config, {
385
+ providerId,
386
+ wire,
387
+ ...(baseUrl === undefined ? {} : { baseUrl }),
388
+ apiKey,
389
+ models,
390
+ selectedModelId: opts.defaultModel ?? '',
391
+ thinking: false,
392
+ });
393
+
394
+ // Resolve the final `defaultModel`:
395
+ // - If the caller asked for one, `applyCatalogProvider` already set it.
396
+ // - Else, restore the previous default ONLY when its alias still resolves
397
+ // after the catalog refresh; the catalog may have dropped the old
398
+ // model, in which case restoring would point default_model at a
399
+ // non-existent alias and break the next session.
400
+ if (opts.defaultModel === undefined) {
401
+ const stillResolves =
402
+ previousDefaultModel !== undefined &&
403
+ config.models?.[previousDefaultModel] !== undefined;
404
+ config.defaultModel = stillResolves ? previousDefaultModel : undefined;
405
+ }
406
+
407
+ // Always restore `[thinking]` from what was there before — including
408
+ // `undefined`. Persisting `enabled: false` when the user never set it would
409
+ // make `resolveThinkingEffort` (agent-core-v2/src/kosong/model/thinking.ts) treat
410
+ // it as an explicit "off" request and silently disable thinking, even for
411
+ // thinking-capable models.
412
+ config.thinking = previousThinking;
413
+
414
+ await harness.setConfig({
415
+ providers: config.providers,
416
+ models: config.models,
417
+ defaultModel: config.defaultModel,
418
+ thinking: config.thinking,
419
+ });
420
+
421
+ const displayName = entry.name ?? providerId;
422
+ deps.stdout.write(
423
+ `Imported ${displayName} (${providerId}) with ${String(models.length)} model${models.length === 1 ? '' : 's'} from ${url}.\n`,
424
+ );
425
+ if (resolution.guessed) {
426
+ deps.stdout.write(
427
+ `Note: the catalog does not declare a protocol for "${providerId}"; guessed "openai". Edit "type" in config.toml if requests fail.\n`,
428
+ );
429
+ }
430
+ if (opts.defaultModel !== undefined) {
431
+ deps.stdout.write(`Default model set to ${providerId}/${opts.defaultModel}.\n`);
432
+ }
433
+ }
434
+
435
+ async function loadCatalogOrExit(deps: ProviderDeps, url: string): Promise<Catalog> {
436
+ try {
437
+ const loaded = await fetchCatalogOrBuiltIn(url, { userAgent: createKimiCodeUserAgent() });
438
+ if (loaded.fromBuiltIn) {
439
+ deps.stderr.write(
440
+ `Warning: failed to reach ${url}; using the built-in models.dev catalog snapshot.\n`,
441
+ );
442
+ }
443
+ return loaded.catalog;
444
+ } catch (error) {
445
+ const suffix = error instanceof CatalogFetchError ? ` (HTTP ${String(error.status)})` : '';
446
+ deps.stderr.write(`Failed to fetch catalog from ${url}${suffix}: ${errorMessage(error)}\n`);
447
+ deps.exit(1);
448
+ }
449
+ }
450
+
451
+ export function registerProviderCommand(parent: Command, deps?: Partial<ProviderDeps>): void {
452
+ const provider = parent
453
+ .command('provider')
454
+ .description('Manage LLM providers non-interactively.');
455
+
456
+ // Last-resort boundary: handlers report expected failures themselves, but
457
+ // anything that escapes (e.g. a config write rejected because config.toml
458
+ // is invalid) must end as a one-line error + exit 1, not an unhandled
459
+ // rejection dumping a stack trace.
460
+ const runAction = async (
461
+ resolved: ResolvedProviderDeps,
462
+ run: () => Promise<void>,
463
+ ): Promise<void> => {
464
+ try {
465
+ await run();
466
+ } catch (error) {
467
+ resolved.stderr.write(`${errorMessage(error)}\n`);
468
+ resolved.exit(1);
469
+ } finally {
470
+ await resolved.close();
471
+ }
472
+ };
473
+
474
+ provider
475
+ .command('add <url>')
476
+ .description('Import every provider listed in a custom registry (api.json).')
477
+ .option('--api-key <key>', 'Registry API key. Falls back to KIMI_REGISTRY_API_KEY.')
478
+ .action(async (url: string, options: { apiKey?: string }) => {
479
+ const resolved = resolveDeps(deps);
480
+ await runAction(resolved, () => handleProviderAdd(resolved, url, { apiKey: options.apiKey }));
481
+ });
482
+
483
+ provider
484
+ .command('remove <providerId>')
485
+ .description('Remove a provider and every model alias that referenced it.')
486
+ .action(async (providerId: string) => {
487
+ const resolved = resolveDeps(deps);
488
+ await runAction(resolved, () => handleProviderRemove(resolved, providerId));
489
+ });
490
+
491
+ provider
492
+ .command('list')
493
+ .description('Show configured providers and their model counts.')
494
+ .option('--json', 'Emit the raw providers/models config as JSON.', false)
495
+ .action(async (options: { json?: boolean }) => {
496
+ const resolved = resolveDeps(deps);
497
+ await runAction(resolved, () => handleProviderList(resolved, { json: options.json === true }));
498
+ });
499
+
500
+ const catalog = provider
501
+ .command('catalog')
502
+ .description('Discover and import providers from the public models.dev catalog.');
503
+
504
+ catalog
505
+ .command('list [providerId]')
506
+ .description('List providers in the catalog, or models when a providerId is given.')
507
+ .option('--filter <substring>', 'Case-insensitive id/name substring filter.')
508
+ .option('--url <url>', `Override catalog URL. Defaults to ${DEFAULT_CATALOG_URL}.`)
509
+ .option('--json', 'Emit the matching catalog slice as JSON.', false)
510
+ .action(
511
+ async (
512
+ providerId: string | undefined,
513
+ options: { filter?: string; url?: string; json?: boolean },
514
+ ) => {
515
+ const resolved = resolveDeps(deps);
516
+ await runAction(resolved, () =>
517
+ handleCatalogList(resolved, providerId, {
518
+ json: options.json === true,
519
+ ...(options.filter === undefined ? {} : { filter: options.filter }),
520
+ ...(options.url === undefined ? {} : { url: options.url }),
521
+ }),
522
+ );
523
+ },
524
+ );
525
+
526
+ catalog
527
+ .command('add <providerId>')
528
+ .description('Import a known provider from the catalog by id.')
529
+ .option('--api-key <key>', 'API key for the provider. Falls back to KIMI_REGISTRY_API_KEY.')
530
+ .option('--default-model <modelId>', 'Mark the imported model as default_model after import.')
531
+ .option(
532
+ '--base-url <url>',
533
+ 'Override the catalog endpoint. Required when the catalog declares none (or an env placeholder).',
534
+ )
535
+ .option('--url <url>', `Override catalog URL. Defaults to ${DEFAULT_CATALOG_URL}.`)
536
+ .action(
537
+ async (
538
+ providerId: string,
539
+ options: { apiKey?: string; defaultModel?: string; url?: string; baseUrl?: string },
540
+ ) => {
541
+ const resolved = resolveDeps(deps);
542
+ await runAction(resolved, () =>
543
+ handleCatalogAdd(resolved, providerId, {
544
+ ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
545
+ ...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }),
546
+ ...(options.url === undefined ? {} : { url: options.url }),
547
+ ...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
548
+ }),
549
+ );
550
+ },
551
+ );
552
+ }
553
+
554
+ type ResolvedProviderDeps = ProviderDeps & { readonly close: () => Promise<void> };
555
+
556
+ function resolveDeps(overrides: Partial<ProviderDeps> = {}): ResolvedProviderDeps {
557
+ let harness: KimiHarness | undefined;
558
+ const identity = createKimiCodeHostIdentity();
559
+ return {
560
+ getHarness:
561
+ overrides.getHarness ??
562
+ (() => {
563
+ harness ??= createKimiHarness({ identity });
564
+ return harness;
565
+ }),
566
+ stdout: overrides.stdout ?? process.stdout,
567
+ stderr: overrides.stderr ?? process.stderr,
568
+ env: overrides.env ?? process.env,
569
+ exit: overrides.exit ?? ((code: number) => process.exit(code)),
570
+ // The v2 harness boots an engine whose watchers hold the event loop open;
571
+ // close it so a one-shot command can exit. No-op for injected harnesses.
572
+ close: async () => {
573
+ await harness?.close();
574
+ },
575
+ };
576
+ }
577
+
578
+ function resolveApiKey(flag: string | undefined, env: NodeJS.ProcessEnv): string | undefined {
579
+ if (typeof flag === 'string' && flag.length > 0) return flag;
580
+ const fromEnv = env['KIMI_REGISTRY_API_KEY'];
581
+ if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
582
+ return undefined;
583
+ }
584
+
585
+ function asManaged(config: KimiConfig): ManagedKimiConfigShape {
586
+ return config as unknown as ManagedKimiConfigShape;
587
+ }
588
+
589
+ function providerSourceLabel(provider: KimiConfig['providers'][string]): string {
590
+ const source = provider.source;
591
+ if (source !== undefined) {
592
+ if (source['kind'] === 'apiJson' && typeof source['url'] === 'string') {
593
+ return `apiJson(${source['url']})`;
594
+ }
595
+ }
596
+ if (provider.oauth !== undefined) return 'oauth';
597
+ return 'inline';
598
+ }
599
+
600
+ function errorMessage(error: unknown): string {
601
+ return error instanceof Error ? error.message : String(error);
602
+ }
apps/kimi-code/src/cli/sub/session.ts ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * `kimi session` sub-command group.
3
+ *
4
+ * CLI glue only: listing semantics (workspace scoping, recency order,
5
+ * archived filtering) are owned by the SDK/engine; this file parses options
6
+ * and formats rows.
7
+ */
8
+
9
+ import { setTelemetryContext, track, withTelemetryContext } from '@moonshot-ai/kimi-telemetry';
10
+ import {
11
+ createKimiHarness,
12
+ type KimiHarness,
13
+ type ListSessionsOptions,
14
+ type SessionSummary,
15
+ type TelemetryClient,
16
+ } from '@moonshot-ai/kimi-code-sdk';
17
+ import type { Command } from 'commander';
18
+
19
+ import { createCliTelemetryBootstrap } from '#/cli/telemetry';
20
+ import { createKimiCodeHostIdentity } from '#/cli/version';
21
+
22
+ interface WritableLike {
23
+ write(chunk: string): boolean;
24
+ }
25
+
26
+ export interface SessionListDeps {
27
+ readonly listSessions: (options: ListSessionsOptions) => Promise<readonly SessionSummary[]>;
28
+ readonly cwd: () => string;
29
+ readonly stdout: WritableLike;
30
+ readonly stderr: WritableLike;
31
+ readonly exit: (code: number) => never;
32
+ }
33
+
34
+ export interface SessionListOptions {
35
+ readonly all: boolean;
36
+ readonly archived: boolean;
37
+ readonly cwd?: string;
38
+ readonly limit?: number;
39
+ readonly json: boolean;
40
+ }
41
+
42
+ export async function handleSessionList(
43
+ deps: SessionListDeps,
44
+ opts: SessionListOptions,
45
+ ): Promise<void> {
46
+ const workDir = opts.all ? undefined : (opts.cwd ?? deps.cwd());
47
+ const sessions = await deps.listSessions({
48
+ workDir,
49
+ includeArchived: opts.archived ? true : undefined,
50
+ });
51
+ const limited = opts.limit === undefined ? sessions : sessions.slice(0, opts.limit);
52
+ if (opts.json) {
53
+ deps.stdout.write(`${JSON.stringify(limited, null, 2)}\n`);
54
+ return;
55
+ }
56
+ if (limited.length === 0) {
57
+ deps.stdout.write('No sessions found.\n');
58
+ return;
59
+ }
60
+ for (const summary of limited) {
61
+ deps.stdout.write(`${formatRow(summary, opts.all)}\n`);
62
+ }
63
+ }
64
+
65
+ export function registerSessionCommand(parent: Command, deps?: Partial<SessionListDeps>): void {
66
+ const session = parent.command('session').description('Manage sessions non-interactively.');
67
+
68
+ session
69
+ .command('list')
70
+ .description('List sessions, most recently updated first.')
71
+ .option('--cwd <path>', 'List sessions of this working directory. Defaults to the current directory.')
72
+ .option('--all', 'List sessions across every workspace.', false)
73
+ .option('--archived', 'Include archived sessions.', false)
74
+ .option('--limit <n>', 'Print at most n sessions.', parseLimitOption)
75
+ .option('--json', 'Emit the session summaries as JSON.', false)
76
+ .action(async (options: { cwd?: string; all?: boolean; archived?: boolean; limit?: number; json?: boolean }) => {
77
+ const resolved = createDefaultSessionListDeps(deps);
78
+ try {
79
+ await handleSessionList(resolved, {
80
+ all: options.all === true,
81
+ archived: options.archived === true,
82
+ cwd: options.cwd,
83
+ limit: options.limit,
84
+ json: options.json === true,
85
+ });
86
+ } catch (error) {
87
+ resolved.stderr.write(`${errorMessage(error)}\n`);
88
+ resolved.exit(1);
89
+ } finally {
90
+ await resolved.close();
91
+ }
92
+ });
93
+ }
94
+
95
+ function createDefaultSessionListDeps(
96
+ overrides: Partial<SessionListDeps> = {},
97
+ ): SessionListDeps & { readonly close: () => Promise<void> } {
98
+ let harness: KimiHarness | undefined;
99
+ const identity = createKimiCodeHostIdentity();
100
+ const telemetryClient: TelemetryClient = {
101
+ track,
102
+ withContext: withTelemetryContext,
103
+ setContext: setTelemetryContext,
104
+ };
105
+ const getHarness = (): KimiHarness => {
106
+ harness ??= createKimiHarness({
107
+ homeDir: createCliTelemetryBootstrap().homeDir,
108
+ identity,
109
+ telemetry: telemetryClient,
110
+ });
111
+ return harness;
112
+ };
113
+ return {
114
+ listSessions:
115
+ overrides.listSessions ??
116
+ ((options: ListSessionsOptions) => getHarness().listSessions(options)),
117
+ cwd: overrides.cwd ?? (() => process.cwd()),
118
+ stdout: overrides.stdout ?? process.stdout,
119
+ stderr: overrides.stderr ?? process.stderr,
120
+ exit: overrides.exit ?? ((code: number) => process.exit(code)),
121
+ close: async () => {
122
+ await harness?.close();
123
+ },
124
+ };
125
+ }
126
+
127
+ function formatRow(summary: SessionSummary, showWorkDir: boolean): string {
128
+ const archived = summary.archived === true ? ' [archived]' : '';
129
+ const title = sanitizeField(summary.title ?? summary.lastPrompt ?? '');
130
+ const base = `${formatTimestamp(summary.updatedAt)} ${summary.id} ${title}${archived}`;
131
+ return showWorkDir ? `${base} ${sanitizeField(summary.workDir)}` : base;
132
+ }
133
+
134
+ function sanitizeField(value: string): string {
135
+ return value.replaceAll(/[\x00-\x1f\x7f]+/g, ' ').trim();
136
+ }
137
+
138
+ function formatTimestamp(epochMs: number): string {
139
+ const date = new Date(epochMs);
140
+ const pad = (value: number): string => String(value).padStart(2, '0');
141
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
142
+ }
143
+
144
+ function parseLimitOption(value: string): number {
145
+ const parsed = Number.parseInt(value, 10);
146
+ if (!Number.isFinite(parsed) || parsed <= 0) {
147
+ throw new Error(`--limit must be a positive integer, got "${value}"`);
148
+ }
149
+ return parsed;
150
+ }
151
+
152
+ function errorMessage(error: unknown): string {
153
+ return error instanceof Error ? error.message : String(error);
154
+ }
apps/kimi-code/src/cli/sub/upgrade.ts ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { log, type Logger } from '@moonshot-ai/kimi-code-sdk';
2
+ import { track as trackTelemetry, type TelemetryProperties } from '@moonshot-ai/kimi-telemetry';
3
+
4
+ import { INTERACTIVE_UPDATE_CHECK_TIMEOUT_MS } from '#/constant/app';
5
+
6
+ import { refreshUpdateCache } from '#/cli/update/refresh';
7
+ import { selectUpdateTarget } from '#/cli/update/select';
8
+ import { detectInstallSource } from '#/cli/update/source';
9
+ import {
10
+ canAutoInstall,
11
+ installCommandFor,
12
+ installUpdate as installUpdateForeground,
13
+ renderInstallSuccessMessage,
14
+ renderManualUpdateMessage,
15
+ } from '#/cli/update/preflight';
16
+ import {
17
+ promptForInstallChoice,
18
+ type InstallPromptChoiceValue,
19
+ type InstallPromptOptions,
20
+ } from '#/cli/update/prompt';
21
+ import {
22
+ NPM_PACKAGE_NAME,
23
+ type InstallSource,
24
+ type UpdateCache,
25
+ } from '#/cli/update/types';
26
+
27
+ interface WritableLike {
28
+ write(chunk: string): boolean;
29
+ }
30
+
31
+ type UpgradeTrack = (event: string, properties?: TelemetryProperties) => void;
32
+ type UpgradeLogger = Pick<Logger, 'info' | 'warn'>;
33
+
34
+ export interface UpgradeDeps {
35
+ readonly refreshUpdateCache: () => Promise<UpdateCache>;
36
+ readonly detectInstallSource: () => Promise<InstallSource>;
37
+ readonly installUpdate: (
38
+ source: InstallSource,
39
+ version: string,
40
+ platform: NodeJS.Platform,
41
+ ) => Promise<void>;
42
+ readonly promptForInstallChoice: (
43
+ options: InstallPromptOptions,
44
+ ) => Promise<InstallPromptChoiceValue>;
45
+ readonly platform: NodeJS.Platform;
46
+ readonly stdout: WritableLike;
47
+ readonly stderr: WritableLike;
48
+ readonly isInteractive: boolean;
49
+ readonly yes: boolean;
50
+ readonly track: UpgradeTrack;
51
+ readonly logger: UpgradeLogger;
52
+ }
53
+
54
+ export async function handleUpgrade(
55
+ currentVersion: string,
56
+ overrides: Partial<UpgradeDeps> = {},
57
+ ): Promise<number> {
58
+ const deps = createDefaultUpgradeDeps(overrides);
59
+
60
+ let cache: UpdateCache;
61
+ try {
62
+ cache = await deps.refreshUpdateCache();
63
+ } catch (error) {
64
+ const reason = formatErrorMessage(error);
65
+ trackUpgradeEvent(deps.track, 'upgrade_command_failed', {
66
+ current_version: currentVersion,
67
+ stage: 'refresh',
68
+ reason,
69
+ });
70
+ logUpgradeWarn(deps.logger, 'manual upgrade check failed', {
71
+ currentVersion,
72
+ error,
73
+ });
74
+ deps.stderr.write(`error: failed to check for updates: ${reason}\n`);
75
+ return 1;
76
+ }
77
+
78
+ const target = selectUpdateTarget(currentVersion, cache.latest);
79
+ if (target === null) {
80
+ trackUpgradeEvent(deps.track, 'upgrade_command_no_update', {
81
+ current_version: currentVersion,
82
+ });
83
+ logUpgradeInfo(deps.logger, 'manual upgrade no update', {
84
+ currentVersion,
85
+ });
86
+ deps.stdout.write(`Kimi Code is already up to date (${formatDisplayVersion(currentVersion)}).\n`);
87
+ return 0;
88
+ }
89
+
90
+ const source = await deps.detectInstallSource().catch(() => 'unsupported' as const);
91
+ const installCommand = installCommandFor(source, target.version, deps.platform);
92
+ if (!canAutoInstall(source, deps.platform) || (!deps.yes && !deps.isInteractive)) {
93
+ trackUpgradeEvent(deps.track, 'upgrade_command_manual_command', {
94
+ current_version: currentVersion,
95
+ target_version: target.version,
96
+ source,
97
+ });
98
+ logUpgradeInfo(deps.logger, 'manual upgrade command shown', {
99
+ currentVersion,
100
+ targetVersion: target.version,
101
+ source,
102
+ });
103
+ deps.stdout.write(renderManualUpdateMessage(currentVersion, target, source, installCommand));
104
+ return 0;
105
+ }
106
+
107
+ if (!deps.yes) {
108
+ trackUpgradeEvent(deps.track, 'upgrade_command_prompted', {
109
+ current_version: currentVersion,
110
+ target_version: target.version,
111
+ source,
112
+ });
113
+ logUpgradeInfo(deps.logger, 'manual upgrade prompted', {
114
+ currentVersion,
115
+ targetVersion: target.version,
116
+ source,
117
+ });
118
+ const choice = await deps.promptForInstallChoice({
119
+ currentVersion,
120
+ target,
121
+ installCommand,
122
+ installSource: source,
123
+ });
124
+ if (choice === 'skip') {
125
+ trackUpgradeEvent(deps.track, 'upgrade_command_skipped', {
126
+ current_version: currentVersion,
127
+ target_version: target.version,
128
+ source,
129
+ });
130
+ logUpgradeInfo(deps.logger, 'manual upgrade skipped', {
131
+ currentVersion,
132
+ targetVersion: target.version,
133
+ source,
134
+ });
135
+ return 0;
136
+ }
137
+ }
138
+
139
+ try {
140
+ trackUpgradeEvent(deps.track, 'upgrade_command_install_selected', {
141
+ current_version: currentVersion,
142
+ target_version: target.version,
143
+ source,
144
+ });
145
+ await deps.installUpdate(source, target.version, deps.platform);
146
+ trackUpgradeEvent(deps.track, 'upgrade_command_succeeded', {
147
+ current_version: currentVersion,
148
+ target_version: target.version,
149
+ source,
150
+ });
151
+ logUpgradeInfo(deps.logger, 'manual upgrade install succeeded', {
152
+ currentVersion,
153
+ targetVersion: target.version,
154
+ source,
155
+ });
156
+ deps.stdout.write(renderInstallSuccessMessage(target));
157
+ return 0;
158
+ } catch (error) {
159
+ trackUpgradeEvent(deps.track, 'upgrade_command_failed', {
160
+ current_version: currentVersion,
161
+ target_version: target.version,
162
+ source,
163
+ stage: 'install',
164
+ reason: formatErrorMessage(error),
165
+ });
166
+ logUpgradeWarn(deps.logger, 'manual upgrade install failed', {
167
+ currentVersion,
168
+ targetVersion: target.version,
169
+ source,
170
+ error,
171
+ });
172
+ deps.stderr.write(
173
+ `warning: failed to install ${NPM_PACKAGE_NAME}@${target.version}: ` +
174
+ `${formatErrorMessage(error)}\n`,
175
+ );
176
+ return 1;
177
+ }
178
+ }
179
+
180
+ function createDefaultUpgradeDeps(overrides: Partial<UpgradeDeps>): UpgradeDeps {
181
+ return {
182
+ refreshUpdateCache:
183
+ overrides.refreshUpdateCache ??
184
+ (() => refreshUpdateCache({ timeoutMs: INTERACTIVE_UPDATE_CHECK_TIMEOUT_MS })),
185
+ detectInstallSource: overrides.detectInstallSource ?? (() => detectInstallSource()),
186
+ installUpdate: overrides.installUpdate ?? installUpdateForeground,
187
+ promptForInstallChoice: overrides.promptForInstallChoice ?? promptForInstallChoice,
188
+ platform: overrides.platform ?? process.platform,
189
+ stdout: overrides.stdout ?? process.stdout,
190
+ stderr: overrides.stderr ?? process.stderr,
191
+ isInteractive: overrides.isInteractive ?? (process.stdin.isTTY && process.stdout.isTTY),
192
+ yes: overrides.yes ?? false,
193
+ track: overrides.track ?? trackTelemetry,
194
+ logger: overrides.logger ?? log,
195
+ };
196
+ }
197
+
198
+ function formatDisplayVersion(version: string): string {
199
+ return version.startsWith('v') ? version : `v${version}`;
200
+ }
201
+
202
+ function formatErrorMessage(error: unknown): string {
203
+ return error instanceof Error ? error.message : String(error);
204
+ }
205
+
206
+ function trackUpgradeEvent(
207
+ track: UpgradeTrack,
208
+ event: string,
209
+ properties: TelemetryProperties,
210
+ ): void {
211
+ try {
212
+ track(event, properties);
213
+ } catch {
214
+ // Telemetry must never affect upgrade flow.
215
+ }
216
+ }
217
+
218
+ function logUpgradeInfo(logger: UpgradeLogger, message: string, payload: Record<string, unknown>): void {
219
+ try {
220
+ logger.info(message, payload);
221
+ } catch {
222
+ // Diagnostic logging must never affect upgrade flow.
223
+ }
224
+ }
225
+
226
+ function logUpgradeWarn(logger: UpgradeLogger, message: string, payload: Record<string, unknown>): void {
227
+ try {
228
+ logger.warn(message, payload);
229
+ } catch {
230
+ // Diagnostic logging must never affect upgrade flow.
231
+ }
232
+ }
apps/kimi-code/src/cli/sub/web/access-urls.ts ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Build the clickable/copyable access URLs for the running server.
3
+ *
4
+ * Shared by the `kimi web` ready banner and `kimi web rotate-token` so both
5
+ * show the same Local/Network links. When a token is known it rides in the
6
+ * `#token=` fragment (never sent to the server, so never logged), letting a
7
+ * user open the link on another device and be authenticated automatically.
8
+ */
9
+
10
+ import { formatHostForUrl, listNetworkAddresses, type NetworkAddress } from './networks';
11
+
12
+ /**
13
+ * Build a directly-openable server URL. When the token is known it is appended
14
+ * as `#token=<token>`; otherwise the bare origin (with a trailing slash) is
15
+ * returned.
16
+ */
17
+ export function buildOpenableUrl(bareOrigin: string, token: string | undefined): string {
18
+ const base = bareOrigin.endsWith('/') ? bareOrigin.slice(0, -1) : bareOrigin;
19
+ return token === undefined ? `${base}/` : `${base}/#token=${token}`;
20
+ }
21
+
22
+ /**
23
+ * Split a full URL into the part before `#token=` and the `#token=…` fragment
24
+ * itself, so callers can render the fragment in a de-emphasized color. Returns
25
+ * `[fullUrl, '']` when there is no token fragment.
26
+ */
27
+ export function splitTokenFragment(fullUrl: string): [string, string] {
28
+ const marker = '#token=';
29
+ const idx = fullUrl.indexOf(marker);
30
+ return idx < 0 ? [fullUrl, ''] : [fullUrl.slice(0, idx), fullUrl.slice(idx)];
31
+ }
32
+
33
+ export interface AccessUrlLine {
34
+ /** Fixed-width label including trailing padding, e.g. `"Local: "`. */
35
+ label: string;
36
+ /** Full URL, carrying `#token=` when a token is known. */
37
+ url: string;
38
+ }
39
+
40
+ function isWildcard(host: string): boolean {
41
+ return host === '' || host === '0.0.0.0' || host === '::';
42
+ }
43
+
44
+ /**
45
+ * Rewrite a bound origin for browser auto-open. A wildcard bind host
46
+ * (`0.0.0.0` / `::` / empty) is not navigable, so open localhost on the same
47
+ * port instead — the same address the ready banner's `Local:` line shows.
48
+ */
49
+ export function browserOpenOrigin(origin: string): string {
50
+ const separator = origin.lastIndexOf(':');
51
+ const host = origin.slice(origin.indexOf('://') + 3, separator);
52
+ if (!isWildcard(host)) return origin;
53
+ return `http://localhost${origin.slice(separator)}`;
54
+ }
55
+
56
+ /** True when `host` is a loopback address (this host only). */
57
+ export function isLoopbackHost(host: string): boolean {
58
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
59
+ }
60
+
61
+ function hostOrigin(host: string, port: number): string {
62
+ const family = host.includes(':') ? 'IPv6' : 'IPv4';
63
+ return `http://${formatHostForUrl(host, family)}:${port}`;
64
+ }
65
+
66
+ /**
67
+ * Compute the access-URL lines for a bind host/port.
68
+ *
69
+ * - wildcard (`0.0.0.0` / `::` / empty): a `Local:` line (localhost) plus one
70
+ * `Network:` line per non-loopback interface.
71
+ * - loopback: a single `Local:` line.
72
+ * - specific host: a single `URL:` line.
73
+ */
74
+ export function accessUrlLines(
75
+ host: string,
76
+ port: number,
77
+ token: string | undefined,
78
+ networkAddresses?: NetworkAddress[],
79
+ ): AccessUrlLine[] {
80
+ if (isWildcard(host)) {
81
+ const lines: AccessUrlLine[] = [
82
+ { label: 'Local: ', url: buildOpenableUrl(`http://localhost:${port}`, token) },
83
+ ];
84
+ const addrs = networkAddresses ?? listNetworkAddresses();
85
+ for (const addr of addrs) {
86
+ lines.push({
87
+ label: 'Network: ',
88
+ url: buildOpenableUrl(`http://${formatHostForUrl(addr.address, addr.family)}:${port}`, token),
89
+ });
90
+ }
91
+ return lines;
92
+ }
93
+ if (isLoopbackHost(host)) {
94
+ return [{ label: 'Local: ', url: buildOpenableUrl(hostOrigin(host, port), token) }];
95
+ }
96
+ return [{ label: 'URL: ', url: buildOpenableUrl(hostOrigin(host, port), token) }];
97
+ }
apps/kimi-code/src/cli/sub/web/deprecated-server.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Deprecated `kimi server` shim.
3
+ *
4
+ * The `kimi server` command tree was replaced by `kimi web` (a foreground
5
+ * server opened in the browser). Any `kimi server …` invocation — bare or
6
+ * with any legacy subcommand/flags — lands here, prints the deprecation
7
+ * notice, and exits 1. The shim itself is scheduled for removal in the next
8
+ * major version of Kimi Code.
9
+ *
10
+ * One subcommand stays functional: `kimi server kill`, the cleanup path for
11
+ * background servers started by pre-0.28.0 builds (recorded in the legacy
12
+ * single-instance lock, which the instance registry never sees).
13
+ */
14
+
15
+ import type { Command } from 'commander';
16
+
17
+ import { registerLegacyKillCommand } from './legacy-kill';
18
+
19
+ export const DEPRECATED_SERVER_NOTICE =
20
+ '`kimi server` has been deprecated and no longer works.\n' +
21
+ 'Use `kimi web` instead — it runs the local server in the foreground and opens the web UI (`--no-open` to skip).\n' +
22
+ 'To stop a server started by a version before 0.28.0, use `kimi server kill`.\n' +
23
+ 'This notice will be removed in the next major version of Kimi Code.\n';
24
+
25
+ export function registerDeprecatedServerCommand(program: Command): void {
26
+ const server = program
27
+ .command('server')
28
+ .description('Deprecated — use `kimi web` instead.')
29
+ // Swallow every legacy subcommand/flag (`run`, `kill`, `--port`, …) so
30
+ // they all land in the same notice instead of a commander parse error.
31
+ .allowUnknownOption(true)
32
+ .allowExcessArguments(true)
33
+ .action(() => {
34
+ process.stderr.write(DEPRECATED_SERVER_NOTICE);
35
+ process.exit(1);
36
+ });
37
+ registerLegacyKillCommand(server);
38
+ }
apps/kimi-code/src/cli/sub/web/index.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * `kimi web` — run the local Kimi server (REST + WebSocket + web UI) in the
3
+ * foreground and open the web UI in the default browser.
4
+ *
5
+ * The command itself is the runner (`kimi web` = start the server + open the
6
+ * browser; `--no-open` to skip). The server stays attached to the terminal
7
+ * and stops with Ctrl+C, so there is no kill/ps subcommand; the only
8
+ * management subcommand is `web rotate-token` (rotate the home-wide bearer
9
+ * token). Servers left behind by pre-0.28.0 builds are cleaned up with
10
+ * `kimi server kill`.
11
+ */
12
+
13
+ import type { Command } from 'commander';
14
+
15
+ import { registerDeprecatedServerCommand } from './deprecated-server';
16
+ import { registerRotateTokenCommand } from './rotate-token';
17
+ import { buildWebCommand } from './run';
18
+
19
+ export function registerWebCommand(program: Command): void {
20
+ const web = buildWebCommand(
21
+ program
22
+ .command('web')
23
+ .description('Run the local Kimi server and open the web UI.'),
24
+ );
25
+ registerRotateTokenCommand(web);
26
+ registerDeprecatedServerCommand(program);
27
+ buildWebCommand(
28
+ program
29
+ .command('rc')
30
+ .alias('remote')
31
+ .description('Run the local Kimi server and open the web UI through Remote Control.'),
32
+ { forceRemoteControl: true },
33
+ );
34
+ }
apps/kimi-code/src/cli/sub/web/legacy-kill.ts ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * `kimi server kill` — deprecated; only stops a server started by an old
3
+ * (pre-`kimi web`, i.e. before 0.28.0) build.
4
+ *
5
+ * Servers started by current builds run in the foreground attached to a
6
+ * terminal (Ctrl+C stops them), so they need no kill command. Builds before
7
+ * the `kimi web` command tree could leave a background daemon behind; those
8
+ * recorded themselves in the legacy single-instance lock at
9
+ * `<KIMI_CODE_HOME>/server/lock`, which the instance registry never sees.
10
+ * This command is the cleanup path for exactly those servers.
11
+ *
12
+ * The kill combines two independent mechanisms so the server dies even if one
13
+ * path fails:
14
+ *
15
+ * 1. API path — `POST /api/v1/shutdown` for a graceful, in-process shutdown
16
+ * (best-effort; old builds may not have the route, or may not
17
+ * answer at all).
18
+ * 2. PID path — signal the pid recorded in the lock (SIGTERM → wait →
19
+ * SIGKILL). SIGKILL is the hard guarantee: it cannot be
20
+ * caught or ignored.
21
+ *
22
+ * The lock file is removed once the recorded pid is confirmed dead (or was
23
+ * dead already), so the cleanup is complete after one run.
24
+ */
25
+
26
+ import { readFile, unlink } from 'node:fs/promises';
27
+ import { join } from 'node:path';
28
+
29
+ import type { Command } from 'commander';
30
+
31
+ import { getDataDir } from '#/utils/paths';
32
+
33
+ import { authHeaders, serverOrigin, tryResolveServerToken } from './shared';
34
+
35
+ /** How long to wait for the graceful API shutdown request. */
36
+ const API_TIMEOUT_MS = 2000;
37
+ /** Grace period after SIGTERM before escalating to SIGKILL. */
38
+ const TERM_GRACE_MS = 3000;
39
+ /** Grace period after SIGKILL before giving up. */
40
+ const KILL_GRACE_MS = 2000;
41
+ /** Poll cadence while waiting for the pid to exit. */
42
+ const POLL_INTERVAL_MS = 100;
43
+
44
+ /**
45
+ * The first release whose servers run in the foreground (`kimi web`) and
46
+ * register under `server/instances/`. Servers from older builds are the only
47
+ * ones this command can — and should — kill.
48
+ */
49
+ export const LEGACY_SERVER_MAX_VERSION = '0.28.0';
50
+
51
+ /** Deprecation notice printed on every `kimi server kill` run. */
52
+ export const DEPRECATED_KILL_NOTICE =
53
+ '`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';
54
+
55
+ /**
56
+ * The fields of the legacy `<home>/server/lock` this command needs. The full
57
+ * on-disk shape also carried `started_at` / `host_version` / `entry`, which
58
+ * are irrelevant to killing the process.
59
+ */
60
+ export interface LegacyServerLock {
61
+ pid: number;
62
+ host?: string;
63
+ port?: number;
64
+ }
65
+
66
+ export interface LegacyKillDeps {
67
+ /** Read and parse the legacy lock; undefined when missing or unparseable. */
68
+ readLock(): Promise<LegacyServerLock | undefined>;
69
+ /** Delete the lock file. Best-effort semantics live with the caller. */
70
+ removeLock(): Promise<void>;
71
+ requestShutdown(origin: string, token: string | undefined): Promise<void>;
72
+ /** Best-effort read of the persistent bearer token; undefined on miss. */
73
+ resolveToken(): string | undefined;
74
+ signalPid(pid: number, signal: NodeJS.Signals): boolean;
75
+ pidAlive(pid: number): boolean;
76
+ sleep(ms: number): Promise<void>;
77
+ stdout: Pick<NodeJS.WriteStream, 'write'>;
78
+ stderr: Pick<NodeJS.WriteStream, 'write'>;
79
+ now(): number;
80
+ }
81
+
82
+ export function registerLegacyKillCommand(server: Command): void {
83
+ server
84
+ .command('kill')
85
+ .description(
86
+ '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.',
87
+ )
88
+ // Swallow legacy argument shapes (`kimi server kill <serverId>`, flags):
89
+ // the legacy lock records a single server, so they carry no meaning here.
90
+ .allowUnknownOption(true)
91
+ .allowExcessArguments(true)
92
+ .action(async () => {
93
+ try {
94
+ await handleLegacyKillCommand(DEFAULT_LEGACY_KILL_DEPS);
95
+ } catch (error) {
96
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
97
+ process.exit(1);
98
+ }
99
+ });
100
+ }
101
+
102
+ export async function handleLegacyKillCommand(deps: LegacyKillDeps): Promise<void> {
103
+ deps.stderr.write(DEPRECATED_KILL_NOTICE);
104
+
105
+ const lock = await deps.readLock();
106
+ if (lock === undefined) {
107
+ deps.stdout.write('No running legacy Kimi server.\n');
108
+ return;
109
+ }
110
+
111
+ if (!deps.pidAlive(lock.pid)) {
112
+ // Stale lock from a server that died without releasing it; sweep it so the
113
+ // cleanup is done in one run.
114
+ await deps.removeLock().catch(() => {});
115
+ deps.stdout.write('No running legacy Kimi server.\n');
116
+ return;
117
+ }
118
+
119
+ const outcome = await killLegacyServer(lock, deps);
120
+ await deps.removeLock().catch(() => {});
121
+ deps.stdout.write(`Legacy Kimi server (pid ${String(lock.pid)}) ${outcome}.\n`);
122
+ }
123
+
124
+ /**
125
+ * Kill the locked server via the API path (best-effort graceful shutdown)
126
+ * followed by the PID path (SIGTERM �� wait → SIGKILL). Resolves with how the
127
+ * process went down; throws when the pid survives SIGKILL.
128
+ */
129
+ async function killLegacyServer(
130
+ lock: LegacyServerLock,
131
+ deps: LegacyKillDeps,
132
+ ): Promise<'stopped' | 'killed'> {
133
+ const { pid } = lock;
134
+
135
+ // 1. API path — best-effort graceful shutdown. Ignore every outcome: an old
136
+ // build may not have the route, may be wedged, or may drop the connection
137
+ // as it exits. The bearer token is best-effort too: if it can't be read
138
+ // the API call 401s and the PID path below still guarantees the kill.
139
+ if (lock.port !== undefined) {
140
+ const origin = serverOrigin(lock.host ?? '127.0.0.1', lock.port);
141
+ await deps.requestShutdown(origin, deps.resolveToken()).catch(() => {});
142
+ }
143
+
144
+ // 2. PID path — SIGTERM, wait, then SIGKILL.
145
+ deps.signalPid(pid, 'SIGTERM');
146
+
147
+ if (await waitForExit(pid, TERM_GRACE_MS, deps)) {
148
+ return 'stopped';
149
+ }
150
+
151
+ deps.signalPid(pid, 'SIGKILL');
152
+
153
+ if (await waitForExit(pid, KILL_GRACE_MS, deps)) {
154
+ return 'killed';
155
+ }
156
+
157
+ throw new Error(
158
+ `Failed to stop legacy Kimi server (pid ${String(pid)}); insufficient permissions?`,
159
+ );
160
+ }
161
+
162
+ async function waitForExit(
163
+ pid: number,
164
+ timeoutMs: number,
165
+ deps: Pick<LegacyKillDeps, 'pidAlive' | 'sleep' | 'now'>,
166
+ ): Promise<boolean> {
167
+ const deadline = deps.now() + timeoutMs;
168
+ do {
169
+ if (!deps.pidAlive(pid)) return true;
170
+ await deps.sleep(POLL_INTERVAL_MS);
171
+ } while (deps.now() < deadline);
172
+ return !deps.pidAlive(pid);
173
+ }
174
+
175
+ /** `process.kill(pid, 0)` probe — true if the pid exists, false on ESRCH. */
176
+ export function pidAlive(pid: number): boolean {
177
+ try {
178
+ process.kill(pid, 0);
179
+ return true;
180
+ } catch (error) {
181
+ const code = (error as NodeJS.ErrnoException).code;
182
+ if (code === 'ESRCH') return false;
183
+ // EPERM = process exists but we can't signal it. Treat as alive.
184
+ return true;
185
+ }
186
+ }
187
+
188
+ /** Send `signal` to `pid`. Returns false if the signal could not be sent. */
189
+ export function signalPid(pid: number, signal: NodeJS.Signals): boolean {
190
+ try {
191
+ process.kill(pid, signal);
192
+ return true;
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ /** POST the shutdown endpoint; resolves once the request completes or times out. */
199
+ export async function requestShutdownViaApi(
200
+ origin: string,
201
+ token: string | undefined,
202
+ ): Promise<void> {
203
+ const controller = new AbortController();
204
+ const timeout = setTimeout(() => {
205
+ controller.abort();
206
+ }, API_TIMEOUT_MS);
207
+ try {
208
+ await fetch(`${origin}/api/v1/shutdown`, {
209
+ method: 'POST',
210
+ headers: token !== undefined ? authHeaders(token) : undefined,
211
+ signal: controller.signal,
212
+ });
213
+ } finally {
214
+ clearTimeout(timeout);
215
+ }
216
+ }
217
+
218
+ /** Path of the legacy single-instance lock under the CLI's data dir. */
219
+ export function legacyLockPath(homeDir: string): string {
220
+ return join(homeDir, 'server', 'lock');
221
+ }
222
+
223
+ /** Read + decode the legacy lock; undefined on missing/unparseable input. */
224
+ export async function readLegacyLock(lockPath: string): Promise<LegacyServerLock | undefined> {
225
+ let raw: string;
226
+ try {
227
+ raw = await readFile(lockPath, 'utf8');
228
+ } catch {
229
+ return undefined;
230
+ }
231
+ try {
232
+ const parsed = JSON.parse(raw) as Partial<{ pid: unknown; host: unknown; port: unknown }>;
233
+ // Only accept a positive safe-integer pid: on POSIX, 0 and negative pids
234
+ // have process-GROUP semantics, so signaling a corrupt lock's pid could
235
+ // hit this CLI's own group or an unrelated one.
236
+ if (typeof parsed.pid !== 'number' || !Number.isSafeInteger(parsed.pid) || parsed.pid <= 0) {
237
+ return undefined;
238
+ }
239
+ return {
240
+ pid: parsed.pid,
241
+ host: typeof parsed.host === 'string' ? parsed.host : undefined,
242
+ port: typeof parsed.port === 'number' ? parsed.port : undefined,
243
+ };
244
+ } catch {
245
+ return undefined;
246
+ }
247
+ }
248
+
249
+ const DEFAULT_LEGACY_KILL_DEPS: LegacyKillDeps = {
250
+ readLock: () => readLegacyLock(legacyLockPath(getDataDir())),
251
+ removeLock: () => unlink(legacyLockPath(getDataDir())),
252
+ requestShutdown: requestShutdownViaApi,
253
+ resolveToken: () => tryResolveServerToken(getDataDir()),
254
+ signalPid,
255
+ pidAlive,
256
+ sleep: (ms) =>
257
+ new Promise((resolve) => {
258
+ setTimeout(resolve, ms);
259
+ }),
260
+ stdout: process.stdout,
261
+ stderr: process.stderr,
262
+ now: () => Date.now(),
263
+ };
apps/kimi-code/src/cli/sub/web/networks.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Enumerate this machine's non-loopback network interface addresses, used to
3
+ * print `Network: http://<addr>:<port>/` hints (à la Vite) when the server
4
+ * binds a wildcard host (`0.0.0.0` / `::`).
5
+ */
6
+
7
+ import { networkInterfaces } from 'node:os';
8
+
9
+ export interface NetworkAddress {
10
+ /** Raw IP address (IPv4 or IPv6); IPv6 is NOT bracket-wrapped here. */
11
+ address: string;
12
+ family: 'IPv4' | 'IPv6';
13
+ }
14
+
15
+ /**
16
+ * List non-internal interface addresses, IPv4 first then IPv6, preserving
17
+ * interface order within each family.
18
+ *
19
+ * Like Vite, this lists the machine's own interface addresses — LAN
20
+ * (192.168/10/172.16) plus any directly-assigned public address. It does not
21
+ * (and cannot, without an external service) discover a NAT-translated WAN IP,
22
+ * and we deliberately avoid any network call for a startup hint.
23
+ */
24
+ export function listNetworkAddresses(): NetworkAddress[] {
25
+ const raw: NetworkAddress[] = [];
26
+ for (const entries of Object.values(networkInterfaces())) {
27
+ for (const info of entries ?? []) {
28
+ if (info.internal) {
29
+ continue;
30
+ }
31
+ if (info.family === 'IPv4') {
32
+ raw.push({ address: info.address, family: 'IPv4' });
33
+ } else if (info.family === 'IPv6') {
34
+ raw.push({ address: info.address, family: 'IPv6' });
35
+ }
36
+ }
37
+ }
38
+ return filterDisplayAddresses(raw);
39
+ }
40
+
41
+ /**
42
+ * Drop addresses that are not useful as a connect target and de-duplicate.
43
+ *
44
+ * IPv6 link-local (`fe80::/10`) is filtered out: it is only reachable with a
45
+ * zone id (e.g. `fe80::1%en0`), which our bare URL cannot carry, so showing it
46
+ * is pure noise — and it is the bulk of what `os.networkInterfaces()` reports
47
+ * on a typical machine. Duplicates (the same address reported on more than one
48
+ * interface) are collapsed. The result is IPv4 first, then IPv6, preserving
49
+ * order within each family.
50
+ */
51
+ export function filterDisplayAddresses(
52
+ addrs: readonly NetworkAddress[],
53
+ ): NetworkAddress[] {
54
+ const seen = new Set<string>();
55
+ const kept: NetworkAddress[] = [];
56
+ for (const addr of addrs) {
57
+ if (addr.family === 'IPv6' && isLinkLocalV6(addr.address)) {
58
+ continue;
59
+ }
60
+ if (seen.has(addr.address)) {
61
+ continue;
62
+ }
63
+ seen.add(addr.address);
64
+ kept.push(addr);
65
+ }
66
+ return [
67
+ ...kept.filter((a) => a.family === 'IPv4'),
68
+ ...kept.filter((a) => a.family === 'IPv6'),
69
+ ];
70
+ }
71
+
72
+ /** True for IPv6 link-local addresses (`fe80::/10`, i.e. `fe80::`–`febf::`). */
73
+ function isLinkLocalV6(address: string): boolean {
74
+ const first = Number.parseInt(address.split(':')[0] ?? '', 16);
75
+ return first >= 0xfe80 && first <= 0xfebf;
76
+ }
77
+
78
+ /**
79
+ * Format an address for use as a URL host: bracket-wrap IPv6 per RFC 3986,
80
+ * return IPv4 as-is.
81
+ */
82
+ export function formatHostForUrl(address: string, family: NetworkAddress['family']): string {
83
+ return family === 'IPv6' ? `[${address}]` : address;
84
+ }
apps/kimi-code/src/cli/sub/web/remote-control.ts ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import chalk from 'chalk';
2
+
3
+ import { getVersion } from '../../version';
4
+ import { darkColors } from '../../../tui/theme/colors';
5
+ import { supportsHyperlinks, toTerminalHyperlink } from '../../../utils/terminal-hyperlink';
6
+ import type { RemoteControlStatus } from '@moonshot-ai/remote-control';
7
+ import { buildOpenableUrl, splitTokenFragment } from './access-urls';
8
+
9
+ export {
10
+ acquireRemoteControlLock,
11
+ buildRemoteControlUrl,
12
+ filterForwardRequestHeaders,
13
+ formatRemoteControlAlreadyRunning,
14
+ inspectRemoteControlLock,
15
+ parseRawHttpRequest,
16
+ remoteControlLockPath,
17
+ RemoteControlAlreadyRunningError,
18
+ REMOTE_CONTROL_RELAY_ORIGIN,
19
+ REMOTE_CONTROL_RELAY_URL_ENV,
20
+ resolveRemoteControlRelayOrigin,
21
+ rewriteRemoteControlResponse,
22
+ startRemoteControl,
23
+ } from '@moonshot-ai/remote-control';
24
+ export type {
25
+ ParsedRawHttpRequest,
26
+ RemoteControlHandle,
27
+ RemoteControlLock,
28
+ RemoteControlLockInfo,
29
+ RemoteControlOptions,
30
+ RemoteControlStatus,
31
+ } from '@moonshot-ai/remote-control';
32
+
33
+ export interface RemoteControlOutputOptions {
34
+ readonly url: string;
35
+ readonly localOrigin: string;
36
+ readonly localServerToken: string;
37
+ readonly deviceName: string;
38
+ readonly qrCode: string;
39
+ readonly pngPath: string;
40
+ }
41
+
42
+ export function formatRemoteControlOutput(options: RemoteControlOutputOptions): string {
43
+ const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text);
44
+ const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text);
45
+ const accent = (text: string): string => chalk.hex(darkColors.accent)(text);
46
+ const dim = (text: string): string => chalk.hex(darkColors.textDim)(text);
47
+ const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text);
48
+ const status = (text: string): string => chalk.hex(darkColors.success)(text);
49
+ const link = (url: string): string =>
50
+ supportsHyperlinks() ? toTerminalHyperlink(accent(url), url) : accent(url);
51
+ const docs = toTerminalHyperlink('docs', 'https://kimi.com/code/docs/remote-control');
52
+ const feedback = toTerminalHyperlink('feedback', 'https://kimi.com/code/feedback');
53
+ const [localBase, localFrag] = splitTokenFragment(
54
+ buildOpenableUrl(options.localOrigin, options.localServerToken),
55
+ );
56
+ return [
57
+ '',
58
+ ` ${title('Kimi Remote Control ready')} ${muted(getVersion())}`,
59
+ ` ${muted('Use Kimi Code on this machine from your phone or another computer.')}`,
60
+ '',
61
+ ` ${label('1.')} Scan the QR code, or open ${link(options.url)}`,
62
+ ` ${label('2.')} Log in with your Kimi account`,
63
+ ` ${label('3.')} Start chatting — sessions run on this machine`,
64
+ '',
65
+ ` ${status('✓')} ${muted(`Connected to ${new URL(options.url).host}, waiting for remote devices…`)}`,
66
+ ` ${label('This device: ')}${muted(options.deviceName)}`,
67
+ ` ${status('⚠')} ${muted('This link grants control of this machine. Do not share it.')}`,
68
+ '',
69
+ options.qrCode.trimEnd().replaceAll(/^/gm, ' '),
70
+ ` ${label('QR code PNG: ')}${options.pngPath} ${muted('(open this if the QR above does not scan)')}`,
71
+ ` ${label('Local UI: ')}${accent(localBase)}${dim(localFrag)} ${muted('(LAN: --host)')}`,
72
+ '',
73
+ ` ${docs} ${muted('·')} ${feedback}`,
74
+ ` ${label('Logs: ')}${muted('off (--log-level info)')} ${muted('·')} ${label('Stop: ')}${muted('Ctrl+C')}`,
75
+ '',
76
+ ].join('\n');
77
+ }
78
+
79
+ export function formatRemoteControlStatus(status: RemoteControlStatus): string {
80
+ const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text);
81
+ const value = (text: string): string => chalk.hex(darkColors.success)(text);
82
+ switch (status) {
83
+ case 'relay_connected':
84
+ return ` ${value('✓')} ${label('Connected to relay, waiting for remote devices…')}\n`;
85
+ case 'relay_disconnected':
86
+ return ` ${value('!')} ${label('Relay disconnected; reconnecting…')}\n`;
87
+ case 'device_connected':
88
+ return ` ${value('✓')} ${label('Remote device connected (1 active session)')}\n`;
89
+ case 'device_disconnected':
90
+ return ` ${value('→')} ${label('Remote device disconnected')}\n`;
91
+ }
92
+ }
apps/kimi-code/src/cli/sub/web/rotate-token.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * `kimi web rotate-token` — generate a new persistent server token.
3
+ *
4
+ * Rewrites `<KIMI_CODE_HOME>/server.token` (0600, atomic). The previous token
5
+ * stops working immediately: a running server re-reads the file on its next
6
+ * auth check, so rotation takes effect without a restart.
7
+ */
8
+
9
+ import { getLiveServerInstance, rotateServerToken } from '@moonshot-ai/kap-server';
10
+ import chalk from 'chalk';
11
+ import type { Command } from 'commander';
12
+
13
+ import { darkColors } from '#/tui/theme/colors';
14
+ import { getDataDir } from '#/utils/paths';
15
+
16
+ import { accessUrlLines, splitTokenFragment } from './access-urls';
17
+
18
+ export function registerRotateTokenCommand(server: Command): void {
19
+ server
20
+ .command('rotate-token')
21
+ .description(
22
+ 'Generate a new persistent server token; the previous token stops working immediately.',
23
+ )
24
+ .action(async () => {
25
+ try {
26
+ const token = await rotateServerToken(getDataDir());
27
+ process.stdout.write(
28
+ 'The previous token is now invalid. A running server picks up the new token automatically.\n',
29
+ );
30
+
31
+ // Token in the middle: indented and set off by blank lines (no color
32
+ // highlight), so it is easy to spot without dominating the output.
33
+ process.stdout.write(`\n ${chalk.bold('New server token:')} ${token}\n\n`);
34
+
35
+ // Re-print the access links with the new token so the user can
36
+ // reconnect immediately. When a server is running its bind host/port
37
+ // come from the instance registry; otherwise there is nothing to
38
+ // connect to yet.
39
+ const instance = await getLiveServerInstance();
40
+ if (instance !== undefined) {
41
+ for (const { label, url: href } of accessUrlLines(instance.host, instance.port, token)) {
42
+ // De-emphasize the `#token=…` fragment so the host/port stands out.
43
+ const [base, frag] = splitTokenFragment(href);
44
+ const rendered =
45
+ frag === ''
46
+ ? chalk.hex(darkColors.accent)(base)
47
+ : chalk.hex(darkColors.accent)(base) + chalk.hex(darkColors.textDim)(frag);
48
+ process.stdout.write(` ${chalk.dim(label)}${rendered}\n`);
49
+ }
50
+ }
51
+ } catch (error) {
52
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
53
+ process.exit(1);
54
+ }
55
+ });
56
+ }
apps/kimi-code/src/cli/sub/web/run.ts ADDED
@@ -0,0 +1,528 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * `kimi web` — run the local server in the foreground and open the web UI.
3
+ *
4
+ * The server always runs in the current process, attached to the terminal,
5
+ * and shuts down cleanly on SIGINT/SIGTERM. `--no-open` skips the browser.
6
+ * Multiple instances can share the home directory: each registers itself in
7
+ * the instance registry and takes the next free port (see kap-server's
8
+ * `startServer`).
9
+ */
10
+
11
+ import { existsSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ import { createServerLogger, startServer, type ServerLogger } from '@moonshot-ai/kap-server';
15
+ import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry';
16
+ import chalk from 'chalk';
17
+ import { type Command, Option } from 'commander';
18
+
19
+ import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_USER_AGENT_SUFFIX } from '#/constant/app';
20
+ import { getNativeWebAssetsDir } from '#/native/web-assets';
21
+ import { darkColors } from '#/tui/theme/colors';
22
+ import { openUrl as defaultOpenUrl } from '#/utils/open-url';
23
+ import { getDataDir } from '#/utils/paths';
24
+ import { generateRemoteControlQr } from '#/utils/remote-control-qr';
25
+
26
+ import { initializeServerTelemetry } from '../../telemetry';
27
+ import {
28
+ createKimiCodeHostIdentity,
29
+ getHostPackageRoot,
30
+ getVersion,
31
+ } from '../../version';
32
+ import {
33
+ accessUrlLines,
34
+ browserOpenOrigin,
35
+ buildOpenableUrl,
36
+ isLoopbackHost,
37
+ splitTokenFragment,
38
+ } from './access-urls';
39
+ import { type NetworkAddress } from './networks';
40
+ import {
41
+ formatRemoteControlOutput,
42
+ formatRemoteControlStatus,
43
+ startRemoteControl,
44
+ type RemoteControlHandle,
45
+ type RemoteControlOptions,
46
+ type RemoteControlStatus,
47
+ } from './remote-control';
48
+ import {
49
+ DEFAULT_FOREGROUND_LOG_LEVEL,
50
+ DEFAULT_LAN_HOST,
51
+ DEFAULT_SERVER_HOST,
52
+ DEFAULT_SERVER_PORT,
53
+ parseServerOptions,
54
+ tryResolveServerToken,
55
+ VALID_LOG_LEVELS,
56
+ type ParsedServerOptions,
57
+ type ServerCliOptions,
58
+ } from './shared';
59
+
60
+ const WEB_ASSETS_DIR = 'dist-web';
61
+
62
+ /**
63
+ * Minimal surface `runServerInProcess` needs from the server. kap-server's
64
+ * `RunningServer` is adapted to it (it returns `{ host, port, close }`
65
+ * instead of `{ address, logger, close }`).
66
+ */
67
+ interface RoutedServer {
68
+ readonly address: string;
69
+ readonly logger: ServerLogger;
70
+ close(): Promise<void>;
71
+ }
72
+
73
+ export interface WebCliOptions extends ServerCliOptions {
74
+ open?: boolean;
75
+ remoteControl?: boolean;
76
+ }
77
+
78
+ export interface StartForegroundHooks {
79
+ /** Fires once the server is listening, before the foreground runner blocks. */
80
+ onReady?: (origin: string) => void | Promise<void>;
81
+ onShutdown?: (reason: string) => void | Promise<void>;
82
+ }
83
+
84
+ export interface WebCommandDeps {
85
+ /** Foreground runner; defaults to the real in-process runner when omitted. */
86
+ startServerForeground?: (
87
+ options: ParsedServerOptions,
88
+ hooks?: StartForegroundHooks,
89
+ ) => Promise<never>;
90
+ startRemoteControl?: (options: RemoteControlOptions) => Promise<RemoteControlHandle>;
91
+ openUrl(url: string): void;
92
+ /**
93
+ * Best-effort read of the server's persistent bearer token. When it returns
94
+ * a token, the ready banner prints it and the opened Web UI URL carries it in
95
+ * the `#token=` fragment (M5.5). Optional so callers/tests that don't supply
96
+ * it simply print/open the plain origin.
97
+ */
98
+ resolveToken?: () => string | undefined;
99
+ /**
100
+ * Non-loopback interface addresses to display for a wildcard bind. Defaults
101
+ * to the machine's own interfaces (`listNetworkAddresses()`); inject a fixed
102
+ * list in tests for deterministic output.
103
+ */
104
+ networkAddresses?: NetworkAddress[];
105
+ stdout: Pick<NodeJS.WriteStream, 'write'>;
106
+ stderr: Pick<NodeJS.WriteStream, 'write'>;
107
+ }
108
+
109
+ /**
110
+ * Build the Web UI URL, carrying the bearer token in the URL fragment.
111
+ *
112
+ * The token rides in `#token=<token>` — a client-side fragment that is never
113
+ * sent to the server (so it never appears in server access logs) and is not
114
+ * logged by proxies. The Web UI reads it from `location.hash` after load.
115
+ */
116
+ export function buildWebUrl(origin: string, token: string): string {
117
+ return buildOpenableUrl(origin, token);
118
+ }
119
+
120
+ /** Build the `web` command, mounting the runner action on `cmd` itself. */
121
+ export function buildWebCommand(
122
+ cmd: Command,
123
+ opts: { forceRemoteControl?: boolean } = {},
124
+ ): Command {
125
+ const forceRemoteControl = opts.forceRemoteControl === true;
126
+ const withServerOptions = cmd
127
+ .option(
128
+ '--port <port>',
129
+ `Bind port (default ${DEFAULT_SERVER_PORT})`,
130
+ String(DEFAULT_SERVER_PORT),
131
+ )
132
+ .option(
133
+ '--host [host]',
134
+ `Bind host. Omit to bind ${DEFAULT_SERVER_HOST} (this machine only); pass --host to bind ${DEFAULT_LAN_HOST} (all interfaces), or --host <host> for a specific host. The bearer token is printed at startup.`,
135
+ )
136
+ .option(
137
+ '--allowed-host <host...>',
138
+ '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).',
139
+ )
140
+ .option(
141
+ '--insecure-no-tls',
142
+ 'Allow a non-loopback bind without a TLS-terminating reverse proxy. Defaults to true; only relevant for non-loopback binds.',
143
+ true,
144
+ )
145
+ .option(
146
+ '--allow-remote-shutdown',
147
+ 'On a non-loopback bind, keep POST /api/v1/shutdown enabled (default: route is disabled → 404).',
148
+ false,
149
+ )
150
+ .option(
151
+ '--dangerous-bypass-auth',
152
+ '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.',
153
+ false,
154
+ )
155
+ .option(
156
+ '--log-level <level>',
157
+ `Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`,
158
+ )
159
+ .option(
160
+ '--debug-endpoints',
161
+ 'Mount /api/v1/debug/* routes for test introspection. OFF by default; production callers leave this unset.',
162
+ false,
163
+ )
164
+ .option(
165
+ '--web-title <title>',
166
+ 'Set a custom browser tab title for this web UI instance (default: "<workspace dir> | Kimi Code").',
167
+ );
168
+ if (!forceRemoteControl) {
169
+ withServerOptions.addOption(
170
+ new Option(
171
+ '--rc, --remote-control',
172
+ 'Expose the web UI through Kimi Remote Control.',
173
+ ).default(false),
174
+ );
175
+ }
176
+ return withServerOptions
177
+ .option('--no-open', 'Do not open the web UI in the default browser.', true)
178
+ .action(async (opts: WebCliOptions) => {
179
+ try {
180
+ await handleWebCommand(
181
+ forceRemoteControl ? { ...opts, remoteControl: true } : opts,
182
+ );
183
+ } catch (error) {
184
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
185
+ process.exit(1);
186
+ }
187
+ });
188
+ }
189
+
190
+ export async function handleWebCommand(
191
+ opts: WebCliOptions,
192
+ deps: WebCommandDeps = DEFAULT_WEB_COMMAND_DEPS,
193
+ ): Promise<void> {
194
+ const parsed = parseServerOptions(opts);
195
+ if (opts.remoteControl === true && parsed.dangerousBypassAuth) {
196
+ throw new Error('--remote-control cannot be combined with --dangerous-bypass-auth.');
197
+ }
198
+ if (opts.remoteControl === true && !isLoopbackHost(parsed.host)) {
199
+ throw new Error('--remote-control requires a loopback host.');
200
+ }
201
+ const run = deps.startServerForeground ?? startServerForeground;
202
+ let remoteControl: RemoteControlHandle | undefined;
203
+ await run(parsed, {
204
+ onReady: async (origin) => {
205
+ // Resolve the persistent token only once the server is up: a fresh
206
+ // server writes `server.token` on first boot, so reading it beforehand
207
+ // would miss first-time starts and the browser would hit the auth gate.
208
+ // It is printed in the ready banner and rides in the opened Web UI
209
+ // URL's `#token=` fragment (M5.5); falls back to the plain origin / no
210
+ // token line when unavailable. When auth is bypassed, the token is
211
+ // meaningless and is intentionally NOT shown or carried in the URL.
212
+ const token = parsed.dangerousBypassAuth ? undefined : deps.resolveToken?.();
213
+ if (opts.remoteControl === true) {
214
+ if (token === undefined) throw new Error('Unable to read the local server token.');
215
+ const dataDir = getDataDir();
216
+ let outputReady = false;
217
+ const pendingStatuses: string[] = [];
218
+ const onStatus = (status: RemoteControlStatus): void => {
219
+ const line = formatRemoteControlStatus(status);
220
+ if (outputReady) deps.stdout.write(line);
221
+ else pendingStatuses.push(line);
222
+ };
223
+ remoteControl = await (deps.startRemoteControl ?? startRemoteControl)({
224
+ homeDir: dataDir,
225
+ localOrigin: origin,
226
+ localServerToken: token,
227
+ clientVersion: `kimi-code/${getVersion()}`,
228
+ stderr: deps.stderr,
229
+ onStatus,
230
+ });
231
+ const qrCode = await generateRemoteControlQr(remoteControl.url, dataDir);
232
+ deps.stdout.write(
233
+ formatRemoteControlOutput({
234
+ url: remoteControl.url,
235
+ localOrigin: origin,
236
+ localServerToken: token,
237
+ deviceName: remoteControl.deviceName,
238
+ qrCode: qrCode.terminal,
239
+ pngPath: qrCode.pngPath,
240
+ }),
241
+ );
242
+ outputReady = true;
243
+ for (const line of pendingStatuses) deps.stdout.write(line);
244
+ if (opts.open === true) deps.openUrl(remoteControl.url);
245
+ return;
246
+ }
247
+ deps.stdout.write(
248
+ parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL
249
+ ? formatReadyBanner(origin, parsed.host, {
250
+ token,
251
+ networkAddresses: deps.networkAddresses,
252
+ dangerousBypassAuth: parsed.dangerousBypassAuth,
253
+ })
254
+ : formatReadyLine(origin, token, parsed.dangerousBypassAuth),
255
+ );
256
+ if (opts.open === true) {
257
+ const openOrigin = browserOpenOrigin(origin);
258
+ deps.openUrl(token !== undefined ? buildWebUrl(openOrigin, token) : openOrigin);
259
+ }
260
+ },
261
+ onShutdown: async () => {
262
+ await remoteControl?.close();
263
+ },
264
+ });
265
+ }
266
+
267
+ function formatReadyLine(
268
+ origin: string,
269
+ token: string | undefined,
270
+ dangerousBypassAuth = false,
271
+ ): string {
272
+ const notice = dangerousBypassAuth
273
+ ? `${formatDangerNoticeLines().join('\n')}\n`
274
+ : '';
275
+ return `${notice}Kimi server: ${buildOpenableUrl(origin, token)}\n`;
276
+ }
277
+
278
+ /**
279
+ * Red, impossible-to-miss notice emitted when `--dangerous-bypass-auth`
280
+ * disables the bearer-token gate. Shared by the full ready banner and the
281
+ * compact one-line output so the warning always shows regardless of log level.
282
+ */
283
+ function formatDangerNoticeLines(): string[] {
284
+ const danger = (text: string): string => chalk.hex(darkColors.error)(text);
285
+ const dangerBold = (text: string): string => chalk.bold.hex(darkColors.error)(text);
286
+ return [
287
+ ` ${dangerBold('⚠ DANGER: authentication is DISABLED (--dangerous-bypass-auth).')}`,
288
+ ` ${danger('Anyone who can reach this port gets full access. Only continue if you understand the risk.')}`,
289
+ ` ${danger('If you are unsure, stop this process now with ')}${dangerBold('Ctrl+C')}${danger('.')}`,
290
+ ];
291
+ }
292
+
293
+ /**
294
+ * `kimi web` — runs the local server in-process, attached to the current
295
+ * terminal. Resolves only via `process.exit` (SIGINT/SIGTERM).
296
+ */
297
+ export async function startServerForeground(
298
+ options: ParsedServerOptions,
299
+ hooks: StartForegroundHooks = {},
300
+ ): Promise<never> {
301
+ return runServerInProcess(options, hooks);
302
+ }
303
+
304
+ /**
305
+ * Start the server in the current process and block until shutdown.
306
+ * `onReady` fires once the server is listening.
307
+ */
308
+ async function runServerInProcess(
309
+ options: ParsedServerOptions,
310
+ hooks: StartForegroundHooks,
311
+ ): Promise<never> {
312
+ const version = getVersion();
313
+ // Registers the telemetry provider for `track` / `shutdownTelemetry`; the
314
+ // client itself is not passed into kap-server.
315
+ initializeServerTelemetry({ version });
316
+
317
+ let running: RoutedServer | undefined;
318
+ let stopping = false;
319
+
320
+ async function shutdown(reason: string): Promise<void> {
321
+ if (stopping) return;
322
+ stopping = true;
323
+ running?.logger.info({ reason }, 'server shutting down');
324
+ try {
325
+ await hooks.onShutdown?.(reason);
326
+ } catch (error) {
327
+ running?.logger.error(
328
+ { err: error instanceof Error ? error : new Error(String(error)) },
329
+ 'foreground shutdown hook error',
330
+ );
331
+ }
332
+ try {
333
+ await running?.close();
334
+ await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
335
+ } catch (error) {
336
+ running?.logger.error(
337
+ { err: error instanceof Error ? error : new Error(String(error)) },
338
+ 'server shutdown error',
339
+ );
340
+ }
341
+ process.exit(0);
342
+ }
343
+
344
+ // kap-server (the DI × Scope engine server) is the only server flavor. Its
345
+ // `startServer` returns `{ host, port, close }` rather than `{ address,
346
+ // logger, close }`, so adapt it to the `RoutedServer` surface the rest of
347
+ // this runner consumes.
348
+ const logger = createServerLogger({ level: options.logLevel });
349
+ const webAssetsDir = serverWebAssetsDir();
350
+ if (webAssetsDir === undefined) {
351
+ logger.info(
352
+ 'dev mode: web assets not built; starting the API server without the web UI',
353
+ );
354
+ }
355
+ const v2 = await startServer({
356
+ host: options.host,
357
+ port: options.port,
358
+ // Report the CLI's product version as `server_version` (/meta, web UI)
359
+ // rather than kap-server's private package version.
360
+ serverVersion: version,
361
+ // The CLI's host identity: feeds the engine's bootstrap client identity
362
+ // and the derived outbound headers (User-Agent + X-Msh-*), so web-UI
363
+ // OAuth flows and model / WebSearch requests carry the CLI identity. The
364
+ // `web` User-Agent suffix distinguishes web-UI traffic from direct CLI
365
+ // runs upstream (same product token, same platform).
366
+ hostIdentity: {
367
+ ...createKimiCodeHostIdentity(version),
368
+ userAgentSuffix: WEB_USER_AGENT_SUFFIX,
369
+ },
370
+ logLevel: options.logLevel,
371
+ logger,
372
+ debugEndpoints: options.debugEndpoints,
373
+ insecureNoTls: options.insecureNoTls,
374
+ allowRemoteShutdown: options.allowRemoteShutdown,
375
+ allowedHosts: options.allowedHosts,
376
+ disableAuth: options.dangerousBypassAuth,
377
+ webTitle: options.webTitle,
378
+ // Attach the engine's cloud telemetry appender (still gated by the config
379
+ // `telemetry` toggle). Complements the v1 client registered above, which
380
+ // only covers host-level events.
381
+ telemetry: true,
382
+ webAssetsDir,
383
+ });
384
+ logger.info('serving the REST/WS API and the bundled web UI');
385
+ running = {
386
+ address: `http://${v2.host}:${v2.port}`,
387
+ logger,
388
+ close: () => v2.close(),
389
+ };
390
+
391
+ track('server_started', { daemon: false });
392
+
393
+ process.once('SIGINT', () => {
394
+ void shutdown('SIGINT');
395
+ });
396
+ process.once('SIGTERM', () => {
397
+ void shutdown('SIGTERM');
398
+ });
399
+
400
+ running.logger.info({ address: running.address }, 'server ready');
401
+
402
+ try {
403
+ await hooks.onReady?.(running.address);
404
+ } catch (error) {
405
+ try {
406
+ await hooks.onShutdown?.('startup_failed');
407
+ } finally {
408
+ await running.close();
409
+ await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
410
+ }
411
+ throw error;
412
+ }
413
+
414
+ return new Promise<never>(() => {
415
+ // Keeps the event loop alive; the process ends via shutdown()/process.exit.
416
+ });
417
+ }
418
+
419
+ /**
420
+ * Resolve the web assets directory passed to kap-server. In dev mode
421
+ * (`KIMI_CODE_DEV_SERVER=1`, set by the repo's `dev:server` / `dev:kap-server*`
422
+ * scripts) a missing `dist-web` build is tolerated: the server starts API-only
423
+ * and the web UI is expected to come from a Vite dev server (the web UI source lives in the code-app repo).
424
+ * Outside dev mode the directory is always returned and kap-server keeps
425
+ * failing fast when the assets are missing.
426
+ */
427
+ export function serverWebAssetsDir(
428
+ env: NodeJS.ProcessEnv = process.env,
429
+ nativeWebAssetsDir: string | null = getNativeWebAssetsDir(),
430
+ ): string | undefined {
431
+ const dir = resolveServerWebAssetsDir(nativeWebAssetsDir);
432
+ if (env['KIMI_CODE_DEV_SERVER'] === '1' && !existsSync(join(dir, 'index.html'))) {
433
+ return undefined;
434
+ }
435
+ return dir;
436
+ }
437
+
438
+ export function resolveServerWebAssetsDir(
439
+ nativeWebAssetsDir: string | null = getNativeWebAssetsDir(),
440
+ ): string {
441
+ return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR);
442
+ }
443
+
444
+ interface FormatReadyBannerOptions {
445
+ /** Persistent bearer token to print; omitted when unresolvable. */
446
+ token?: string;
447
+ /** Non-loopback interface addresses to list for a wildcard bind. */
448
+ networkAddresses?: NetworkAddress[];
449
+ /** When true, render a red danger notice (auth is disabled). */
450
+ dangerousBypassAuth?: boolean;
451
+ }
452
+
453
+ export function formatReadyBanner(
454
+ origin: string,
455
+ host: string,
456
+ opts: FormatReadyBannerOptions = {},
457
+ ): string {
458
+ const primary = (text: string): string => chalk.hex(darkColors.primary)(text);
459
+ const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text);
460
+ const dim = (text: string): string => chalk.hex(darkColors.textDim)(text);
461
+ const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text);
462
+ const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text);
463
+ const url = (text: string): string => chalk.hex(darkColors.accent)(text);
464
+ // Render the `#token=…` fragment in a de-emphasized gray so the host/port
465
+ // stands out while the full URL stays selectable for copying.
466
+ const urlWithDimToken = (href: string): string => {
467
+ const [base, frag] = splitTokenFragment(href);
468
+ return frag === '' ? url(base) : url(base) + dim(frag);
469
+ };
470
+
471
+ const port = Number(origin.slice(origin.lastIndexOf(':') + 1));
472
+ // Borderless header: the Kimi sprite (the little mascot with eyes) sits next
473
+ // to the title, keeping the brand without the enclosing box.
474
+ const logo = ['▐█▛█▛█▌', '▐█████▌'] as const;
475
+ const lines: string[] = [
476
+ '',
477
+ ` ${primary(logo[0])} ${title('Kimi server ready')} ${dim(getVersion())}`,
478
+ ` ${primary(logo[1])} ${dim('Local web UI is available from this machine.')}`,
479
+ '',
480
+ ];
481
+
482
+ if (opts.dangerousBypassAuth === true) {
483
+ // Red, impossible-to-miss notice: the bearer-token gate is off, so anyone
484
+ // who can reach this port gets full session / filesystem / shell access.
485
+ lines.push(...formatDangerNoticeLines(), '');
486
+ }
487
+
488
+ // Access links.
489
+ for (const { label: text, url: href } of accessUrlLines(
490
+ host,
491
+ port,
492
+ opts.token,
493
+ opts.networkAddresses,
494
+ )) {
495
+ lines.push(` ${label(text)}${urlWithDimToken(href)}`);
496
+ }
497
+ // On a loopback bind there is no network URL; show how to enable one.
498
+ if (isLoopbackHost(host)) {
499
+ lines.push(` ${label('Network: ')}${muted('off')}${dim(' use --host to enable')}`);
500
+ }
501
+ if (opts.token !== undefined) {
502
+ // Set the token off with surrounding whitespace rather than color, so it is
503
+ // easy to spot without being highlighted.
504
+ lines.push('');
505
+ lines.push(` ${label('Token: ')}${opts.token}`);
506
+ lines.push('');
507
+ }
508
+
509
+ // Auxiliary controls last.
510
+ lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`);
511
+ // The server always runs in the foreground attached to this terminal.
512
+ lines.push(` ${label('Stop: ')}${muted('Ctrl+C')}`);
513
+ lines.push('');
514
+ return lines.join('\n');
515
+ }
516
+
517
+ const DEFAULT_WEB_COMMAND_DEPS: WebCommandDeps = {
518
+ startServerForeground,
519
+ openUrl: defaultOpenUrl,
520
+ resolveToken: () => {
521
+ // Read the persistent `<homeDir>/server.token` written on first boot
522
+ // (M5.1). Best-effort: a missing/older server yields undefined and the
523
+ // caller opens the plain origin.
524
+ return tryResolveServerToken(getDataDir());
525
+ },
526
+ stdout: process.stdout,
527
+ stderr: process.stderr,
528
+ };
apps/kimi-code/src/cli/sub/web/shared.ts ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared helpers for `kimi web` and its subcommands.
3
+ *
4
+ * Owns the default host/port, option parsers, and health/readiness probes.
5
+ */
6
+
7
+ import { readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ import type { ServerLogLevel } from '@moonshot-ai/kap-server';
11
+
12
+ export const LOCAL_SERVER_HOST = '127.0.0.1';
13
+ export const DEFAULT_LAN_HOST = '0.0.0.0';
14
+ export const DEFAULT_SERVER_HOST = LOCAL_SERVER_HOST;
15
+ export const DEFAULT_SERVER_PORT = 58627;
16
+ export const DEFAULT_SERVER_ORIGIN = serverOrigin(DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT);
17
+
18
+ /** Filename (under KIMI_CODE_HOME) of the persistent server bearer token. */
19
+ export const SERVER_TOKEN_FILE = 'server.token';
20
+
21
+ export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info';
22
+ export const DEFAULT_FOREGROUND_LOG_LEVEL: ServerLogLevel = 'silent';
23
+
24
+ export const VALID_LOG_LEVELS: readonly ServerLogLevel[] = [
25
+ 'fatal',
26
+ 'error',
27
+ 'warn',
28
+ 'info',
29
+ 'debug',
30
+ 'trace',
31
+ 'silent',
32
+ ];
33
+
34
+ export interface ParsedServerOptions {
35
+ host: string;
36
+ port: number;
37
+ logLevel: ServerLogLevel;
38
+ debugEndpoints: boolean;
39
+ /** Allow a non-loopback bind without a TLS-terminating reverse proxy. */
40
+ insecureNoTls: boolean;
41
+ /** Allow `POST /api/v1/shutdown` on a non-loopback bind. */
42
+ allowRemoteShutdown: boolean;
43
+ /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */
44
+ dangerousBypassAuth: boolean;
45
+ /** Extra `Host` header values to allow through the DNS-rebinding check. */
46
+ allowedHosts: readonly string[];
47
+ /** Custom browser tab title for this web UI instance (`--web-title`). */
48
+ webTitle?: string;
49
+ }
50
+
51
+ export interface ServerCliOptions {
52
+ host?: string | boolean;
53
+ port?: string;
54
+ logLevel?: string;
55
+ debugEndpoints?: boolean;
56
+ /** Allow a non-loopback bind without TLS (`--insecure-no-tls`). */
57
+ insecureNoTls?: boolean;
58
+ /** Allow remote shutdown on a non-loopback bind (`--allow-remote-shutdown`). */
59
+ allowRemoteShutdown?: boolean;
60
+ /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */
61
+ dangerousBypassAuth?: boolean;
62
+ /** Extra `Host` header values to allow (`--allowed-host`). */
63
+ allowedHost?: string[];
64
+ /** Custom browser tab title for this web UI instance (`--web-title`). */
65
+ webTitle?: string;
66
+ }
67
+
68
+ export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions {
69
+ return {
70
+ host: parseHost(opts.host),
71
+ port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT),
72
+ logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL),
73
+ debugEndpoints: opts.debugEndpoints === true,
74
+ insecureNoTls: opts.insecureNoTls !== false,
75
+ allowRemoteShutdown: opts.allowRemoteShutdown === true,
76
+ dangerousBypassAuth: opts.dangerousBypassAuth === true,
77
+ allowedHosts: parseAllowedHostArgs(opts.allowedHost),
78
+ webTitle: opts.webTitle,
79
+ };
80
+ }
81
+
82
+ export function parseAllowedHostArgs(raw: readonly string[] | undefined): string[] {
83
+ if (raw === undefined) return [];
84
+ return raw
85
+ .flatMap((entry) => entry.split(','))
86
+ .map((entry) => entry.trim())
87
+ .filter((entry) => entry.length > 0);
88
+ }
89
+
90
+ function parseHost(raw: string | boolean | undefined): string {
91
+ if (raw === undefined || raw === false) return DEFAULT_SERVER_HOST;
92
+ if (raw === true || raw === '') return DEFAULT_LAN_HOST;
93
+ return raw;
94
+ }
95
+
96
+ export function parsePort(raw: string | undefined, label: string, fallback: number): number {
97
+ if (raw === undefined) return fallback;
98
+ const n = Number.parseInt(raw, 10);
99
+ if (!Number.isFinite(n) || n < 0 || n > 65535) {
100
+ throw new Error(`error: invalid ${label} value: ${raw}`);
101
+ }
102
+ return n;
103
+ }
104
+
105
+ export function parseLogLevel(raw: string | undefined): ServerLogLevel {
106
+ if (raw === undefined) return DEFAULT_LOG_LEVEL;
107
+ if ((VALID_LOG_LEVELS as readonly string[]).includes(raw)) {
108
+ return raw as ServerLogLevel;
109
+ }
110
+ throw new Error(
111
+ `error: invalid --log-level value: ${raw} (allowed: ${VALID_LOG_LEVELS.join(', ')})`,
112
+ );
113
+ }
114
+
115
+ export function serverOrigin(host: string, port: number): string {
116
+ return `http://${host}:${port}`;
117
+ }
118
+
119
+ /** Strip `/api/v1` and trailing slashes so user-supplied origins are uniform. */
120
+ export function normalizeServerOrigin(value: string): string {
121
+ const url = new URL(value);
122
+ url.pathname = url.pathname.replace(/\/api\/v1\/?$/, '').replace(/\/$/, '');
123
+ url.search = '';
124
+ url.hash = '';
125
+ return url.toString().replace(/\/$/, '');
126
+ }
127
+
128
+ /**
129
+ * Read the persistent bearer token for the server.
130
+ *
131
+ * The server writes `<homeDir>/server.token` (0600) on first boot and reuses
132
+ * it across restarts (ROADMAP M5.1); CLI commands that hit a gated REST route
133
+ * read it back here and send it as `Authorization: Bearer <token>`. `homeDir`
134
+ * is the CLI's own KIMI_CODE_HOME resolution (`getDataDir()`).
135
+ *
136
+ * Throws a clear error when the file is missing/unreadable — the usual cause
137
+ * is a server that has never been started (no token file yet), or an older
138
+ * build that predates token auth.
139
+ */
140
+ export function resolveServerToken(homeDir: string): string {
141
+ const tokenPath = join(homeDir, SERVER_TOKEN_FILE);
142
+ try {
143
+ return readFileSync(tokenPath, 'utf8').trim();
144
+ } catch (error) {
145
+ throw new Error(
146
+ `unable to read server token at ${tokenPath}; has the server been started at least once?`,
147
+ { cause: error },
148
+ );
149
+ }
150
+ }
151
+
152
+ /** Best-effort token read: returns `undefined` instead of throwing. */
153
+ export function tryResolveServerToken(homeDir: string): string | undefined {
154
+ try {
155
+ return resolveServerToken(homeDir);
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ }
160
+
161
+ /** An `Authorization: Bearer <token>` header bag for `fetch`. */
162
+ export function authHeaders(token: string): { Authorization: string } {
163
+ return { Authorization: `Bearer ${token}` };
164
+ }
apps/kimi-code/src/tui/banner/banner-config.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from 'zod';
2
+
3
+ import { fetchClientConfig, type ClientConfigFetchOptions } from '#/utils/client-configs';
4
+
5
+ /** The tips/banner payload is one named config on the client-configs endpoint. */
6
+ const CONFIG_NAME = 'client_banner';
7
+
8
+ /** The payload keeps the legacy tips.json shape, which banner-provider parses
9
+ defensively; the schema only guarantees an object. */
10
+ const bannerConfigSchema = z.looseObject({});
11
+
12
+ export type BannerConfig = z.infer<typeof bannerConfigSchema>;
13
+ export type BannerConfigFetchOptions = ClientConfigFetchOptions;
14
+
15
+ /**
16
+ * Fetches the banner config straight from the endpoint — banners are
17
+ * time-sensitive announcements, so no caching layer is used. Any failure
18
+ * resolves to `undefined` — callers treat that as "no banner".
19
+ */
20
+ export async function getBannerConfig(
21
+ options: BannerConfigFetchOptions = {},
22
+ ): Promise<BannerConfig | undefined> {
23
+ return fetchClientConfig(CONFIG_NAME, bannerConfigSchema, options);
24
+ }
apps/kimi-code/src/tui/banner/banner-provider.ts ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { eq, gte, lt, valid } from 'semver';
4
+
5
+ import type { BannerDisplay, BannerState } from '#/tui/types';
6
+
7
+ import { getBannerConfig } from './banner-config';
8
+ import type { BannerDisplayState } from './state';
9
+
10
+ interface BannerVersionFields {
11
+ banner_min_version?: string | null;
12
+ banner_max_version?: string | null;
13
+ banner_version?: string | null;
14
+ }
15
+
16
+ interface TipsBannerFallbackItem extends BannerVersionFields {
17
+ banner_id?: string | null;
18
+ enabled?: boolean;
19
+ banner_title?: string | null;
20
+ banner_maintext?: string;
21
+ banner_subtext?: string | null;
22
+ banner_start_time?: string | null;
23
+ banner_end_time?: string | null;
24
+ banner_display?: unknown;
25
+ banner_display_ttl_hours?: unknown;
26
+ banner_platform?: string | null;
27
+ }
28
+
29
+ interface TipsBannerJson extends BannerVersionFields {
30
+ banner_id?: string | null;
31
+ banner_enabled?: boolean;
32
+ banner_title?: string | null;
33
+ banner_maintext?: string;
34
+ banner_subtext?: string | null;
35
+ banner_start_time?: string | null;
36
+ banner_end_time?: string | null;
37
+ banner_display?: unknown;
38
+ banner_display_ttl_hours?: unknown;
39
+ banner_fallback_enabled?: boolean;
40
+ banner_fallback_list?: unknown[];
41
+ banner_platform?: string | null;
42
+ }
43
+
44
+ interface BannerHashInput {
45
+ tag: string | null;
46
+ mainText: string;
47
+ subText: string | null;
48
+ startTime: string | null;
49
+ endTime: string | null;
50
+ display: BannerDisplay;
51
+ ttlHours?: number;
52
+ }
53
+
54
+ interface BannerCandidateInput {
55
+ id: unknown;
56
+ tag: unknown;
57
+ mainText: string;
58
+ subText: unknown;
59
+ display: BannerDisplay;
60
+ ttlHours?: number;
61
+ startTime?: unknown;
62
+ endTime?: unknown;
63
+ }
64
+
65
+ export interface SelectDisplayableBannerArgs {
66
+ json: unknown;
67
+ clientVersion: string;
68
+ now: Date;
69
+ random: () => number;
70
+ state: BannerDisplayState;
71
+ }
72
+
73
+ interface BannerProviderLoadOptions {
74
+ state?: BannerDisplayState;
75
+ now?: Date;
76
+ random?: () => number;
77
+ }
78
+
79
+ const HOUR_MS = 60 * 60 * 1000;
80
+ export const DEFAULT_COOLDOWN_TTL_HOURS = 24;
81
+
82
+ function normalizeTag(value: unknown): string | null {
83
+ if (typeof value !== 'string') return null;
84
+ const trimmed = value.trim();
85
+ return trimmed.length > 0 ? trimmed : null;
86
+ }
87
+
88
+ function normalizeText(value: unknown): string | null {
89
+ if (typeof value !== 'string') return null;
90
+ const trimmed = value.trim();
91
+ return trimmed.length > 0 ? trimmed : null;
92
+ }
93
+
94
+ function normalizeUtcDate(value: string): string {
95
+ if (value.endsWith('Z')) return value;
96
+ if (/[+-]\d{2}:\d{2}$/.test(value)) return value;
97
+ return `${value}Z`;
98
+ }
99
+
100
+ function parseDate(value: unknown): Date | null {
101
+ if (typeof value !== 'string' || value.length === 0) return null;
102
+ const normalized = normalizeUtcDate(value);
103
+ const date = new Date(normalized);
104
+ return Number.isNaN(date.getTime()) ? null : date;
105
+ }
106
+
107
+ function isWithinWindow(start: Date | null, end: Date | null, now: Date): boolean {
108
+ if (start !== null && now < start) return false;
109
+ if (end !== null && now > end) return false;
110
+ return true;
111
+ }
112
+
113
+ type VersionConstraintCompare = (current: string, target: string) => boolean;
114
+
115
+ function meetsVersionConstraint(
116
+ constraint: unknown,
117
+ clientVersion: string,
118
+ compare: VersionConstraintCompare,
119
+ ): boolean {
120
+ if (constraint === undefined || constraint === null) return true;
121
+ if (typeof constraint !== 'string' || constraint.length === 0) return true;
122
+ const target = valid(constraint);
123
+ const current = valid(clientVersion);
124
+ if (target === null || current === null) return false;
125
+ return compare(current, target);
126
+ }
127
+
128
+ function meetsVersion(banner: BannerVersionFields, clientVersion: string): boolean {
129
+ return (
130
+ meetsVersionConstraint(banner.banner_min_version, clientVersion, gte) &&
131
+ meetsVersionConstraint(banner.banner_max_version, clientVersion, lt) &&
132
+ meetsVersionConstraint(banner.banner_version, clientVersion, eq)
133
+ );
134
+ }
135
+
136
+ /** The CLI shows banners targeting every platform (missing / empty / 'all')
137
+ or the CLI itself; any other platform value ('desktop', 'web', …) hides
138
+ the banner here. */
139
+ function meetsPlatform(value: unknown): boolean {
140
+ if (typeof value !== 'string') return true;
141
+ const platform = value.trim().toLowerCase();
142
+ return platform === '' || platform === 'all' || platform === 'cli';
143
+ }
144
+
145
+ function parseBannerDisplay(value: unknown): BannerDisplay {
146
+ if (value === 'once') return 'once';
147
+ if (value === 'cooldown') return 'cooldown';
148
+ return 'always';
149
+ }
150
+
151
+ function parseBannerDisplayTtlHours(value: unknown): number {
152
+ return typeof value === 'number' && Number.isFinite(value) && value > 0
153
+ ? value
154
+ : DEFAULT_COOLDOWN_TTL_HOURS;
155
+ }
156
+
157
+ function normalizeBannerId(value: unknown): string | null {
158
+ if (typeof value !== 'string') return null;
159
+ const trimmed = value.trim();
160
+ return trimmed.length > 0 ? trimmed : null;
161
+ }
162
+
163
+ function hashBannerIdentity(input: BannerHashInput): string {
164
+ const raw = JSON.stringify([
165
+ input.tag ?? '',
166
+ input.mainText,
167
+ input.subText ?? '',
168
+ input.startTime ?? '',
169
+ input.endTime ?? '',
170
+ input.display,
171
+ input.ttlHours ?? '',
172
+ ]);
173
+ return createHash('sha256').update(raw).digest('hex').slice(0, 32);
174
+ }
175
+
176
+ function getBannerKey(rawBannerId: unknown, input: BannerHashInput): string {
177
+ return normalizeBannerId(rawBannerId) ?? hashBannerIdentity(input);
178
+ }
179
+
180
+ function toBannerState(input: BannerCandidateInput): BannerState {
181
+ const tag = normalizeTag(input.tag);
182
+ const subText = normalizeText(input.subText);
183
+ const display = input.display;
184
+ const ttlHours = display === 'cooldown' ? parseBannerDisplayTtlHours(input.ttlHours) : undefined;
185
+ const startTime = normalizeText(input.startTime);
186
+ const endTime = normalizeText(input.endTime);
187
+ const key = getBannerKey(input.id, {
188
+ tag,
189
+ mainText: input.mainText,
190
+ subText,
191
+ startTime,
192
+ endTime,
193
+ display,
194
+ ttlHours,
195
+ });
196
+
197
+ return {
198
+ key,
199
+ tag,
200
+ mainText: input.mainText,
201
+ subText,
202
+ display,
203
+ ttlHours,
204
+ };
205
+ }
206
+
207
+ function pickActiveBanner(
208
+ json: TipsBannerJson,
209
+ clientVersion: string,
210
+ now: Date,
211
+ ): BannerState | null {
212
+ if (json.banner_enabled !== true) return null;
213
+ if (!meetsVersion(json, clientVersion)) return null;
214
+ if (!meetsPlatform(json.banner_platform)) return null;
215
+ const start = parseDate(json.banner_start_time);
216
+ const end = parseDate(json.banner_end_time);
217
+ if (!isWithinWindow(start, end, now)) return null;
218
+ const mainText = normalizeText(json.banner_maintext);
219
+ if (mainText === null) return null;
220
+ const display = parseBannerDisplay(json.banner_display);
221
+ return toBannerState({
222
+ id: json.banner_id,
223
+ tag: json.banner_title,
224
+ mainText,
225
+ subText: json.banner_subtext,
226
+ display,
227
+ ttlHours: display === 'cooldown' ? parseBannerDisplayTtlHours(json.banner_display_ttl_hours) : undefined,
228
+ startTime: json.banner_start_time,
229
+ endTime: json.banner_end_time,
230
+ });
231
+ }
232
+
233
+ function pickFallbackCandidates(
234
+ json: TipsBannerJson,
235
+ clientVersion: string,
236
+ now: Date,
237
+ ): BannerState[] {
238
+ if (json.banner_fallback_enabled !== true) return [];
239
+ const list = Array.isArray(json.banner_fallback_list) ? json.banner_fallback_list : [];
240
+ const candidates: BannerState[] = [];
241
+ for (const raw of list) {
242
+ if (typeof raw !== 'object' || raw === null) continue;
243
+ const item = raw as TipsBannerFallbackItem;
244
+ if (item.enabled !== true) continue;
245
+ if (!meetsVersion(item, clientVersion)) continue;
246
+ if (!meetsPlatform(item.banner_platform)) continue;
247
+ const start = parseDate(item.banner_start_time);
248
+ const end = parseDate(item.banner_end_time);
249
+ if (!isWithinWindow(start, end, now)) continue;
250
+ const mainText = normalizeText(item.banner_maintext);
251
+ if (mainText === null) continue;
252
+ const display = parseBannerDisplay(item.banner_display);
253
+ candidates.push(
254
+ toBannerState({
255
+ id: item.banner_id,
256
+ tag: item.banner_title,
257
+ mainText,
258
+ subText: item.banner_subtext,
259
+ display,
260
+ ttlHours: display === 'cooldown' ? parseBannerDisplayTtlHours(item.banner_display_ttl_hours) : undefined,
261
+ startTime: item.banner_start_time,
262
+ endTime: item.banner_end_time,
263
+ }),
264
+ );
265
+ }
266
+ return candidates;
267
+ }
268
+
269
+ function pickRandomCandidate(candidates: BannerState[], random: () => number): BannerState | null {
270
+ if (candidates.length === 0) return null;
271
+ const index = Math.floor(random() * candidates.length);
272
+ return candidates[index]!;
273
+ }
274
+
275
+ function pickFallbackBanner(
276
+ json: TipsBannerJson,
277
+ clientVersion: string,
278
+ now: Date,
279
+ random: () => number,
280
+ ): BannerState | null {
281
+ return pickRandomCandidate(pickFallbackCandidates(json, clientVersion, now), random);
282
+ }
283
+
284
+ function parseShownAt(value: string | undefined): Date | null {
285
+ if (value === undefined) return null;
286
+ const date = new Date(value);
287
+ return Number.isNaN(date.getTime()) ? null : date;
288
+ }
289
+
290
+ function getCooldownTtlHours(banner: BannerState): number {
291
+ return typeof banner.ttlHours === 'number' && Number.isFinite(banner.ttlHours) && banner.ttlHours > 0
292
+ ? banner.ttlHours
293
+ : DEFAULT_COOLDOWN_TTL_HOURS;
294
+ }
295
+
296
+ export function shouldDisplayBanner(
297
+ banner: BannerState,
298
+ state: BannerDisplayState,
299
+ now: Date,
300
+ ): boolean {
301
+ if (banner.display === 'always') return true;
302
+ const lastShownAt = parseShownAt(state.shown[banner.key]?.lastShownAt);
303
+ if (lastShownAt === null) return true;
304
+ if (banner.display === 'once') return false;
305
+ return now.getTime() - lastShownAt.getTime() >= getCooldownTtlHours(banner) * HOUR_MS;
306
+ }
307
+
308
+ export function selectBannerState(
309
+ json: unknown,
310
+ clientVersion: string,
311
+ now: Date,
312
+ random: () => number,
313
+ ): BannerState | null {
314
+ const typed = typeof json === 'object' && json !== null ? (json as TipsBannerJson) : {};
315
+ return (
316
+ pickActiveBanner(typed, clientVersion, now) ??
317
+ pickFallbackBanner(typed, clientVersion, now, random)
318
+ );
319
+ }
320
+
321
+ export function selectDisplayableBanner({
322
+ json,
323
+ clientVersion,
324
+ now,
325
+ random,
326
+ state,
327
+ }: SelectDisplayableBannerArgs): BannerState | null {
328
+ const typed = typeof json === 'object' && json !== null ? (json as TipsBannerJson) : {};
329
+ const active = pickActiveBanner(typed, clientVersion, now);
330
+ if (active !== null && shouldDisplayBanner(active, state, now)) return active;
331
+ const candidates = pickFallbackCandidates(typed, clientVersion, now).filter((candidate) =>
332
+ shouldDisplayBanner(candidate, state, now),
333
+ );
334
+ return pickRandomCandidate(candidates, random);
335
+ }
336
+
337
+ export class BannerProvider {
338
+ constructor(private readonly clientVersion: string) {}
339
+
340
+ async load(options: BannerProviderLoadOptions = {}): Promise<BannerState | null> {
341
+ // getBannerConfig never throws; undefined means "config unavailable".
342
+ const json = await getBannerConfig();
343
+ if (json === undefined) return null;
344
+ const now = options.now ?? new Date();
345
+ const random = options.random ?? Math.random;
346
+ return options.state === undefined
347
+ ? selectBannerState(json, this.clientVersion, now, random)
348
+ : selectDisplayableBanner({
349
+ json,
350
+ clientVersion: this.clientVersion,
351
+ now,
352
+ random,
353
+ state: options.state,
354
+ });
355
+ }
356
+ }
apps/kimi-code/src/tui/banner/state.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from 'zod';
2
+
3
+ import { getBannerStateFile } from '#/utils/paths';
4
+ import { readJsonFile, writeJsonFile } from '#/utils/persistence';
5
+
6
+ export type BannerDisplayRecord = {
7
+ lastShownAt: string;
8
+ };
9
+
10
+ export type BannerDisplayState = {
11
+ version: 1;
12
+ shown: Record<string, BannerDisplayRecord>;
13
+ };
14
+
15
+ const BannerDisplayRecordSchema = z
16
+ .object({
17
+ lastShownAt: z.string().min(1),
18
+ })
19
+ .strict();
20
+
21
+ const BannerDisplayStateSchema = z.preprocess(
22
+ (value) => {
23
+ if (typeof value !== 'object' || value === null) return value;
24
+ const shown = (value as { shown?: unknown }).shown;
25
+ if (typeof shown !== 'object' || shown === null) {
26
+ return { ...(value as Record<string, unknown>), shown: {} };
27
+ }
28
+
29
+ const normalizedShown: Record<string, BannerDisplayRecord> = {};
30
+ for (const [key, record] of Object.entries(shown)) {
31
+ if (key.length === 0 || typeof record !== 'object' || record === null) continue;
32
+ const lastShownAt = (record as { lastShownAt?: unknown }).lastShownAt;
33
+ if (typeof lastShownAt !== 'string' || Number.isNaN(Date.parse(lastShownAt))) continue;
34
+ normalizedShown[key] = { lastShownAt };
35
+ }
36
+
37
+ return { ...(value as Record<string, unknown>), shown: normalizedShown };
38
+ },
39
+ z
40
+ .object({
41
+ version: z.literal(1),
42
+ shown: z.record(z.string().min(1), BannerDisplayRecordSchema),
43
+ })
44
+ .strict(),
45
+ );
46
+
47
+ export function emptyBannerDisplayState(): BannerDisplayState {
48
+ return {
49
+ version: 1,
50
+ shown: {},
51
+ };
52
+ }
53
+
54
+ export async function readBannerDisplayState(
55
+ filePath: string = getBannerStateFile(),
56
+ ): Promise<BannerDisplayState> {
57
+ try {
58
+ return await readJsonFile(filePath, BannerDisplayStateSchema, emptyBannerDisplayState());
59
+ } catch {
60
+ return emptyBannerDisplayState();
61
+ }
62
+ }
63
+
64
+ export async function writeBannerDisplayState(
65
+ value: BannerDisplayState,
66
+ filePath: string = getBannerStateFile(),
67
+ ): Promise<void> {
68
+ await writeJsonFile(filePath, BannerDisplayStateSchema, value);
69
+ }
apps/kimi-code/src/tui/commands/add-dir.ts ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
2
+ import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
3
+ import type { SlashCommandHost } from './dispatch';
4
+ import { slashBusyMessage, slashCommandBusyReason } from './resolve';
5
+
6
+ type AddDirChoice = 'session' | 'remember' | 'cancel';
7
+
8
+ export async function handleAddDirCommand(host: SlashCommandHost, args: string): Promise<void> {
9
+ const input = args.trim();
10
+ let session = host.session;
11
+
12
+ if (input.length === 0 || input.toLowerCase() === 'list') {
13
+ // With no session yet (v2 session-less startup) the pending startup
14
+ // directories live in appState and will be passed to the lazy-created
15
+ // session; reflect them instead of reporting an empty list.
16
+ const additionalDirs = session?.summary?.additionalDirs ?? host.state.appState.additionalDirs;
17
+ if (additionalDirs.length === 0) {
18
+ host.showStatus('No additional directories configured.');
19
+ return;
20
+ }
21
+ host.showStatus(formatAdditionalDirsStatus(additionalDirs));
22
+ return;
23
+ }
24
+
25
+ if (session === undefined) {
26
+ // The path-adding form needs a live session; lazy-create it on first use
27
+ // (the read-only `list`/bare forms above tolerate a missing session).
28
+ session = await host.ensureSession();
29
+ if (session === undefined) return;
30
+ // A first prompt may have started a turn during the await; /add-dir is
31
+ // idle-only, so re-check the busy gate resolved before it.
32
+ const busyReason = slashCommandBusyReason({
33
+ isStreaming: host.state.appState.streamingPhase !== 'idle',
34
+ isCompacting: host.state.appState.isCompacting,
35
+ });
36
+ if (busyReason !== undefined) {
37
+ host.showError(slashBusyMessage('add-dir', busyReason));
38
+ return;
39
+ }
40
+ }
41
+
42
+ host.mountEditorReplacement(
43
+ new ChoicePickerComponent({
44
+ title: `Add directory to workspace: ${input}`,
45
+ hint: '↑↓ navigate · Enter confirm · Esc cancel',
46
+ options: [
47
+ {
48
+ value: 'session',
49
+ label: 'Yes, for this session',
50
+ },
51
+ {
52
+ value: 'remember',
53
+ label: 'Yes, and remember this directory',
54
+ },
55
+ {
56
+ value: 'cancel',
57
+ label: 'No',
58
+ },
59
+ ],
60
+ onSelect: (value) => {
61
+ void handleAddDirChoice(host, session.id, input, value as AddDirChoice);
62
+ },
63
+ onCancel: () => {
64
+ host.restoreEditor();
65
+ host.showStatus(`Did not add ${input} as a working directory.`);
66
+ },
67
+ }),
68
+ );
69
+ }
70
+
71
+ function formatAdditionalDirsStatus(additionalDirs: readonly string[]): string {
72
+ return ['Additional directories:', ...additionalDirs.map((dir) => ` ${dir}`)].join('\n');
73
+ }
74
+
75
+ async function handleAddDirChoice(
76
+ host: SlashCommandHost,
77
+ sessionId: string,
78
+ path: string,
79
+ choice: AddDirChoice,
80
+ ): Promise<void> {
81
+ host.restoreEditor();
82
+
83
+ if (choice === 'cancel') {
84
+ host.showStatus(`Did not add ${path} as a working directory.`);
85
+ return;
86
+ }
87
+
88
+ const session = host.session;
89
+ if (session === undefined || session.id !== sessionId) {
90
+ host.showError(NO_ACTIVE_SESSION_MESSAGE);
91
+ return;
92
+ }
93
+
94
+ try {
95
+ const result = await session.addAdditionalDir(path, { persist: choice === 'remember' });
96
+ host.setAppState({ additionalDirs: result.additionalDirs });
97
+ host.refreshSlashCommandAutocomplete();
98
+ host.showStatus(
99
+ choice === 'remember'
100
+ ? `Added workspace directory:\n ${path}\n Saved to:\n ${result.configPath}`
101
+ : `Added workspace directory:\n ${path}\n For this session only`,
102
+ 'success',
103
+ );
104
+ } catch (error) {
105
+ host.showError(error instanceof Error ? error.message : String(error));
106
+ }
107
+ }
apps/kimi-code/src/tui/commands/auth.ts ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ applyOpenPlatformConfig,
3
+ fetchOpenPlatformModels,
4
+ filterModelsByPrefix,
5
+ getOpenPlatformById,
6
+ OAuthAccessDeniedError,
7
+ OpenPlatformApiError,
8
+ type KimiRegion,
9
+ type ManagedKimiCodeModelInfo,
10
+ type ManagedKimiConfigShape,
11
+ type OpenPlatformDefinition,
12
+ } from '@moonshot-ai/kimi-code-oauth';
13
+ import { log } from '@moonshot-ai/kimi-code-sdk';
14
+
15
+ import type { ChoiceOption } from '../components/dialogs/choice-picker';
16
+ import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '../constant/kimi-tui';
17
+ import { formatErrorMessage } from '../utils/event-payload';
18
+ import {
19
+ KIMI_CODE_GLOBAL_PLATFORM_VALUE,
20
+ refreshKimiRegion,
21
+ } from '#/utils/region';
22
+ import type { LoginProgressSpinnerHandle } from '../types';
23
+ import {
24
+ promptApiKey,
25
+ promptLogoutProviderSelection,
26
+ promptModelSelectionForOpenPlatform,
27
+ promptPlatformSelection,
28
+ } from './prompts';
29
+ import type { SlashCommandHost } from './dispatch';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Auth: login / logout
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export async function handleLoginCommand(host: SlashCommandHost): Promise<void> {
36
+ const platformId = await promptPlatformSelection(host);
37
+ if (platformId === undefined) return;
38
+
39
+ if (platformId === 'kimi-code' || platformId === KIMI_CODE_GLOBAL_PLATFORM_VALUE) {
40
+ const region: KimiRegion = platformId === KIMI_CODE_GLOBAL_PLATFORM_VALUE ? 'global' : 'mainland-cn';
41
+ await handleKimiCodeOAuthLogin(host, region);
42
+ return;
43
+ }
44
+
45
+ const platform = getOpenPlatformById(platformId);
46
+ if (platform === undefined) return;
47
+ await handleOpenPlatformLogin(host, platform);
48
+ }
49
+
50
+ async function handleKimiCodeOAuthLogin(
51
+ host: SlashCommandHost,
52
+ region: KimiRegion,
53
+ ): Promise<void> {
54
+ const status = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME);
55
+ const alreadyLoggedIn = status.providers.some(
56
+ (provider) => provider.providerName === DEFAULT_OAUTH_PROVIDER_NAME && provider.hasToken,
57
+ );
58
+
59
+ let spinner: LoginProgressSpinnerHandle | undefined;
60
+ const controller = new AbortController();
61
+ const cancelLogin = (): void => {
62
+ controller.abort();
63
+ };
64
+ host.cancelInFlight = cancelLogin;
65
+ try {
66
+ // The facade maps region → profile hosts (env overrides keep priority);
67
+ // 'mainland-cn' is passed explicitly too so switching back overrides a
68
+ // persisted global login.
69
+ await host.harness.auth.login(DEFAULT_OAUTH_PROVIDER_NAME, {
70
+ signal: controller.signal,
71
+ region,
72
+ onDeviceCode: (data) => {
73
+ spinner = host.showLoginAuthorizationPrompt(data);
74
+ },
75
+ });
76
+ refreshKimiRegion();
77
+ spinner?.stop({ ok: true, label: 'Logged in.' });
78
+ spinner = undefined;
79
+ try {
80
+ await host.authFlow.refreshConfigAfterLogin();
81
+ } catch (refreshError) {
82
+ const message = formatErrorMessage(refreshError);
83
+ host.showError(`Authentication successful, but failed to refresh config: ${message}`);
84
+ return;
85
+ }
86
+ host.track('login', {
87
+ provider: DEFAULT_OAUTH_PROVIDER_NAME,
88
+ method: 'oauth',
89
+ already_logged_in: alreadyLoggedIn,
90
+ });
91
+ if (alreadyLoggedIn) {
92
+ host.showStatus('Already logged in. Model configuration refreshed.', 'success');
93
+ }
94
+ } catch (error) {
95
+ const cancelled = controller.signal.aborted;
96
+ const denied = error instanceof OAuthAccessDeniedError;
97
+ spinner?.stop({
98
+ ok: false,
99
+ label: cancelled || denied ? 'Login cancelled.' : 'Login failed.',
100
+ });
101
+ spinner = undefined;
102
+ if (cancelled) return;
103
+ const message = formatErrorMessage(error);
104
+ if (denied) {
105
+ host.showError(`Login cancelled: ${message}`);
106
+ return;
107
+ }
108
+ log.warn('login failed', {
109
+ providerName: DEFAULT_OAUTH_PROVIDER_NAME,
110
+ alreadyLoggedIn,
111
+ sessionId: host.session?.id,
112
+ error,
113
+ });
114
+ host.showError(`Login failed: ${message}`);
115
+ } finally {
116
+ if (host.cancelInFlight === cancelLogin) {
117
+ host.cancelInFlight = undefined;
118
+ }
119
+ }
120
+ }
121
+
122
+ async function handleOpenPlatformLogin(
123
+ host: SlashCommandHost,
124
+ platform: OpenPlatformDefinition,
125
+ ): Promise<void> {
126
+ const consoleHost = platform.consoleUrl?.replace(/^https?:\/\//, '') ?? '';
127
+ const platformName = consoleHost.length > 0 ? `Kimi Platform (${consoleHost})` : 'Kimi Platform';
128
+ const subtitleLines = [
129
+ `${'base_url'.padEnd(12)}${platform.baseUrl}`,
130
+ `${'saved to'.padEnd(12)}~/.kimi-code/config.toml`,
131
+ ];
132
+ const apiKey = await promptApiKey(host, platformName, subtitleLines);
133
+ if (apiKey === undefined) return;
134
+
135
+ const controller = new AbortController();
136
+ const cancelLogin = (): void => {
137
+ controller.abort();
138
+ };
139
+ host.cancelInFlight = cancelLogin;
140
+
141
+ let models: ManagedKimiCodeModelInfo[];
142
+ try {
143
+ models = await fetchOpenPlatformModels(platform, apiKey, fetch, controller.signal);
144
+ models = filterModelsByPrefix(models, platform);
145
+ } catch (error) {
146
+ if (controller.signal.aborted) return;
147
+ const msg = formatErrorMessage(error);
148
+ host.showError(`Failed to verify API key: ${msg}`);
149
+ if (
150
+ error instanceof OpenPlatformApiError &&
151
+ error.status === 401
152
+ ) {
153
+ host.showStatus(
154
+ 'Hint: If your API key was obtained from Kimi Code, please select "Kimi Code" instead.',
155
+ );
156
+ }
157
+ return;
158
+ } finally {
159
+ if (host.cancelInFlight === cancelLogin) {
160
+ host.cancelInFlight = undefined;
161
+ }
162
+ }
163
+
164
+ if (models.length === 0) {
165
+ host.showError('No models available for this platform.');
166
+ return;
167
+ }
168
+
169
+ const selection = await promptModelSelectionForOpenPlatform(host, models, platform);
170
+ if (selection === undefined) return;
171
+
172
+ const existingConfig = await host.harness.getConfig();
173
+ if (existingConfig.providers[platform.id] !== undefined) {
174
+ await host.harness.removeProvider(platform.id);
175
+ }
176
+
177
+ const config = await host.harness.getConfig();
178
+ applyOpenPlatformConfig(config as ManagedKimiConfigShape, {
179
+ platform,
180
+ models,
181
+ selectedModel: selection.model,
182
+ thinking: selection.thinking !== 'off',
183
+ effort:
184
+ selection.thinking !== 'off' && selection.thinking !== 'on'
185
+ ? selection.thinking
186
+ : undefined,
187
+ apiKey,
188
+ });
189
+
190
+ await host.harness.setConfig({
191
+ providers: config.providers,
192
+ models: config.models,
193
+ defaultModel: config.defaultModel,
194
+ thinking: config.thinking,
195
+ });
196
+
197
+ await host.authFlow.refreshConfigAfterLogin();
198
+ host.track('login', { provider: platform.id, method: 'api_key' });
199
+ host.showStatus(`Setup complete: ${platform.name} · ${selection.model.id}`);
200
+ }
201
+
202
+ export async function handleLogoutCommand(host: SlashCommandHost): Promise<void> {
203
+ const oauthStatus = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME);
204
+ const hasOAuthToken = oauthStatus.providers.some(
205
+ (p) => p.providerName === DEFAULT_OAUTH_PROVIDER_NAME && p.hasToken,
206
+ );
207
+ const config = await host.harness.getConfig();
208
+ const hasManagedRemnant =
209
+ hasOAuthToken || config.providers[DEFAULT_OAUTH_PROVIDER_NAME] !== undefined;
210
+ const apiKeyProviderIds = Object.keys(config.providers ?? {})
211
+ .filter((id) => id !== DEFAULT_OAUTH_PROVIDER_NAME)
212
+ .toSorted();
213
+
214
+ const options: ChoiceOption[] = [];
215
+ if (hasManagedRemnant) {
216
+ options.push({
217
+ value: DEFAULT_OAUTH_PROVIDER_NAME,
218
+ label: PRODUCT_NAME,
219
+ description: 'OAuth login',
220
+ });
221
+ }
222
+ for (const id of apiKeyProviderIds) {
223
+ const baseUrl = config.providers[id]?.baseUrl;
224
+ options.push({
225
+ value: id,
226
+ label: id,
227
+ description: typeof baseUrl === 'string' && baseUrl.length > 0 ? baseUrl : undefined,
228
+ });
229
+ }
230
+
231
+ if (options.length === 0) {
232
+ host.showStatus('Nothing to logout.');
233
+ return;
234
+ }
235
+
236
+ const currentModel = host.state.appState.model.trim();
237
+ const currentProvider = host.state.appState.availableModels[currentModel]?.provider;
238
+
239
+ const target = await promptLogoutProviderSelection(host, options, currentProvider);
240
+ if (target === undefined) return;
241
+
242
+ if (target === DEFAULT_OAUTH_PROVIDER_NAME) {
243
+ await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME);
244
+ } else {
245
+ await host.harness.removeProvider(target);
246
+ }
247
+
248
+ if (target === currentProvider) {
249
+ await host.authFlow.refreshConfigAfterLogout();
250
+ } else {
251
+ const updated = await host.harness.getConfig({ reload: true });
252
+ host.setAppState({
253
+ availableModels: updated.models ?? {},
254
+ availableProviders: updated.providers ?? {},
255
+ });
256
+ }
257
+ refreshKimiRegion();
258
+
259
+ host.track('logout', { provider: target });
260
+ const label = target === DEFAULT_OAUTH_PROVIDER_NAME ? PRODUCT_NAME : target;
261
+ host.showStatus(`Logged out from ${label}.`);
262
+ }
apps/kimi-code/src/tui/commands/btw.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui';
2
+ import { formatErrorMessage } from '../utils/event-payload';
3
+ import { extractInlineSkillActivations } from '../utils/inline-skill-tokens';
4
+ import type { SlashCommandHost } from './dispatch';
5
+
6
+ export async function handleBtwCommand(host: SlashCommandHost, args: string): Promise<void> {
7
+ const prompt = args.trim();
8
+ const session = host.session;
9
+ if (host.state.appState.model.trim().length === 0 || session === undefined) {
10
+ host.showError(LLM_NOT_SET_MESSAGE);
11
+ return;
12
+ }
13
+ host.btwPanelController.closeOrCancel();
14
+
15
+ try {
16
+ const agentId = await session.startBtw();
17
+ const activations = extractInlineSkillActivations(prompt, host.skillCommandMap, {
18
+ includeLeading: true,
19
+ });
20
+ host.btwPanelController.open(
21
+ agentId,
22
+ prompt,
23
+ activations.length > 0 ? activations : undefined,
24
+ );
25
+ } catch (error) {
26
+ host.showError(`Failed to start /btw: ${formatErrorMessage(error)}`);
27
+ }
28
+ }
apps/kimi-code/src/tui/commands/complete-args.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AutocompleteItem } from '@moonshot-ai/pi-tui';
2
+
3
+ /**
4
+ * A completable token (subcommand or flag) for a slash command's argument
5
+ * position. Generic across commands — any `KimiSlashCommand` can build a
6
+ * `getArgumentCompletions` from a list of these via {@link completeLeadingArg}.
7
+ */
8
+ export interface ArgCompletionSpec {
9
+ /** The token inserted on completion, e.g. `pause` or `resume`. */
10
+ readonly value: string;
11
+ /** Short description shown in the autocomplete menu. */
12
+ readonly description: string;
13
+ }
14
+
15
+ /**
16
+ * Generic leading-token completer for slash-command arguments.
17
+ *
18
+ * pi-tui passes `argumentPrefix` = everything typed after `/<command> `. We only
19
+ * complete the *first* token: once the user has typed a space after it (moved on
20
+ * to an objective, a flag value, etc.) we return `null` so completion never
21
+ * clobbers free text. Matching is case-insensitive prefix match on `value`.
22
+ */
23
+ export function completeLeadingArg(
24
+ specs: readonly ArgCompletionSpec[],
25
+ argumentPrefix: string,
26
+ ): AutocompleteItem[] | null {
27
+ if (argumentPrefix.includes(' ')) return null;
28
+ const lower = argumentPrefix.toLowerCase();
29
+ const items = specs
30
+ .filter((spec) => spec.value.toLowerCase().startsWith(lower))
31
+ .map((spec) => ({ value: spec.value, label: spec.value, description: spec.description }));
32
+ // Nothing left to complete: the user has finished typing a token that is the
33
+ // sole remaining match (e.g. `status`). Keeping the menu open here would make
34
+ // Enter confirm the no-op completion instead of submitting the command, so we
35
+ // suppress it. (A space after the token already returns null above.)
36
+ const [only] = items;
37
+ if (items.length === 1 && only !== undefined && only.value.toLowerCase() === lower) {
38
+ return null;
39
+ }
40
+ return items.length > 0 ? items : null;
41
+ }
apps/kimi-code/src/tui/commands/config.ts ADDED
@@ -0,0 +1,912 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ effectiveModelAlias,
3
+ PRIMARY_SUBAGENT_MODEL_CHOICE,
4
+ SECONDARY_DERIVED_MODEL_ALIAS,
5
+ type ExperimentalFeatureState,
6
+ type ModelAlias,
7
+ type PermissionMode,
8
+ type Session,
9
+ type ThinkingEffort,
10
+ } from '@moonshot-ai/kimi-code-sdk';
11
+
12
+ import { EditorSelectorComponent } from '../components/dialogs/editor-selector';
13
+ import { EffortSelectorComponent } from '../components/dialogs/effort-selector';
14
+ import {
15
+ ExperimentsSelectorComponent,
16
+ type ExperimentalFeatureDraftChange,
17
+ } from '../components/dialogs/experiments-selector';
18
+ import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selector';
19
+ import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector';
20
+ import { PermissionSelectorComponent } from '../components/dialogs/permission-selector';
21
+ import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector';
22
+ import { SurveyPreferenceSelectorComponent } from '../components/dialogs/survey-preference-selector';
23
+ import { ThemeSelectorComponent } from '../components/dialogs/theme-selector';
24
+ import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector';
25
+ import { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig } from '../config';
26
+ import type { ThemeName } from '#/tui/theme';
27
+ import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme';
28
+ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
29
+ import { formatErrorMessage } from '../utils/event-payload';
30
+ import { PERMISSION_MODE_DESCRIPTIONS, PERMISSION_MODE_DISPLAY_NAMES } from '../utils/permission-mode';
31
+ import { thinkingEffortToConfig } from '../utils/thinking-config';
32
+ import { showUsage } from './info';
33
+ import { setExperimentalFeatures } from './experimental-flags';
34
+ import type { SlashCommandHost } from './dispatch';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Plan / Config commands
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const MODEL_PICKER_REFRESH_TIMEOUT_MS = 2_000;
41
+
42
+ const MODEL_SWITCH_CACHE_WARNING =
43
+ 'Note: Switching models invalidates the existing prompt cache. Use /new to avoid extra token costs.';
44
+ const EFFORT_SWITCH_CACHE_WARNING =
45
+ 'Note: Switching effort invalidates the existing prompt cache. Use /new to avoid extra token costs.';
46
+
47
+ /** True once the conversation has at least one user message: a switch from
48
+ * then on resends the accumulated context, losing the cache. Shell-command
49
+ * echoes are also 'user' transcript entries but carry an empty `bullet`, so
50
+ * they're excluded. */
51
+ function hasConversationHistory(host: SlashCommandHost): boolean {
52
+ return host.state.transcriptEntries.some(
53
+ (entry) => entry.kind === 'user' && entry.bullet !== '',
54
+ );
55
+ }
56
+
57
+ export function currentTuiConfig(host: Pick<SlashCommandHost, 'state'>): TuiConfig {
58
+ return {
59
+ theme: host.state.appState.theme,
60
+ editorCommand: host.state.appState.editorCommand,
61
+ disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst,
62
+ renderLatex: host.state.appState.renderLatex ?? DEFAULT_TUI_CONFIG.renderLatex ?? true,
63
+ cacheExpiryHint: host.state.appState.cacheExpiryHint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint,
64
+ disableFeedbackSurvey:
65
+ host.state.appState.disableFeedbackSurvey ?? DEFAULT_TUI_CONFIG.disableFeedbackSurvey,
66
+ notifications: host.state.appState.notifications,
67
+ upgrade: host.state.appState.upgrade,
68
+ statusLine: host.state.appState.statusLine ?? DEFAULT_TUI_CONFIG.statusLine,
69
+ };
70
+ }
71
+
72
+ export function effectiveModelForHost(host: SlashCommandHost, model: ModelAlias): ModelAlias {
73
+ const providerType = host.state.appState.availableProviders[model.provider]?.type;
74
+ // Flat models (no named provider, e.g. inline base_url served by a v2
75
+ // backend) have no provider entry to look up; their own protocol declaration
76
+ // plays the provider-identity role, mirroring the resolver.
77
+ return effectiveModelAlias(model, providerType ?? model.protocol);
78
+ }
79
+
80
+ export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise<void> {
81
+ const session = host.session;
82
+ if (session === undefined) {
83
+ host.showError(NO_ACTIVE_SESSION_MESSAGE);
84
+ return;
85
+ }
86
+
87
+ const subcmd = args.trim().toLowerCase();
88
+ if (subcmd === 'clear') {
89
+ await session.clearPlan();
90
+ host.showNotice('Plan cleared');
91
+ return;
92
+ }
93
+
94
+ let enabled: boolean;
95
+ if (subcmd.length === 0) enabled = !host.state.appState.planMode;
96
+ else if (subcmd === 'on') enabled = true;
97
+ else if (subcmd === 'off') enabled = false;
98
+ else {
99
+ host.showError(`Unknown plan subcommand: ${subcmd}`);
100
+ return;
101
+ }
102
+
103
+ // The session may already be in the requested mode (e.g. it was created
104
+ // with config.defaultPlanMode applied), and re-entering plan mode throws.
105
+ if (host.state.appState.planMode === enabled) {
106
+ host.showNotice(`Plan mode is already ${enabled ? 'on' : 'off'}`);
107
+ return;
108
+ }
109
+
110
+ await applyPlanMode(host, session, enabled);
111
+ }
112
+
113
+ async function applyPlanMode(host: SlashCommandHost, session: Session, enabled: boolean): Promise<void> {
114
+ try {
115
+ await session.setPlanMode(enabled);
116
+ host.setAppState({ planMode: enabled });
117
+ if (enabled) {
118
+ const plan = await session.getPlan().catch(() => null);
119
+ host.showNotice(
120
+ 'Plan mode: ON',
121
+ plan?.path !== undefined ? `Plan will be created here: ${plan.path}` : undefined,
122
+ );
123
+ return;
124
+ }
125
+ host.showNotice('Plan mode: OFF');
126
+ } catch (error) {
127
+ const msg = formatErrorMessage(error);
128
+ host.showError(`Failed to set plan mode: ${msg}`);
129
+ }
130
+ }
131
+
132
+ export async function handleCompactCommand(host: SlashCommandHost, args: string): Promise<void> {
133
+ const session = host.session;
134
+ if (session === undefined) {
135
+ host.showError(NO_ACTIVE_SESSION_MESSAGE);
136
+ return;
137
+ }
138
+ const customInstruction = args.trim() || undefined;
139
+ await session.compact({ instruction: customInstruction });
140
+ }
141
+
142
+ export async function handleEditorCommand(host: SlashCommandHost, args: string): Promise<void> {
143
+ const command = args.trim();
144
+ if (command.length === 0) {
145
+ showEditorPicker(host);
146
+ return;
147
+ }
148
+ await applyEditorChoice(host, command);
149
+ }
150
+
151
+ export async function handleThemeCommand(host: SlashCommandHost, args: string): Promise<void> {
152
+ const theme = args.trim();
153
+ if (theme.length === 0) {
154
+ showThemePicker(host);
155
+ return;
156
+ }
157
+ if (!isBuiltInTheme(theme)) {
158
+ const custom = await loadCustomThemeMerged(theme);
159
+ if (custom === null) {
160
+ host.showError(`Unknown theme: ${theme}`);
161
+ return;
162
+ }
163
+ }
164
+ await applyThemeChoice(host, theme);
165
+ }
166
+
167
+ export async function handleModelCommand(host: SlashCommandHost, args: string): Promise<void> {
168
+ const alias = args.trim();
169
+ await refreshModelsForPicker(host);
170
+ if (alias.length === 0) {
171
+ showModelPicker(host);
172
+ return;
173
+ }
174
+ if (host.state.appState.availableModels[alias] === undefined) {
175
+ host.showError(`Unknown model alias: ${alias}`);
176
+ return;
177
+ }
178
+ showModelPicker(host, alias);
179
+ }
180
+
181
+ export async function handleSecondaryModelCommand(host: SlashCommandHost, args: string): Promise<void> {
182
+ const alias = args.trim();
183
+ await refreshModelsForPicker(host);
184
+ const models = pickerModelsForHost(host);
185
+ // The pool reserves `primary` as the symbolic "caller's own model" choice —
186
+ // a user alias with that name can never be the subagent default.
187
+ delete models[PRIMARY_SUBAGENT_MODEL_CHOICE];
188
+ if (alias === PRIMARY_SUBAGENT_MODEL_CHOICE) {
189
+ host.showError(
190
+ `"${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.`,
191
+ );
192
+ return;
193
+ }
194
+ if (Object.keys(models).length === 0) {
195
+ host.showNotice(
196
+ 'No models configured',
197
+ 'Run /login to sign in to Kimi, or /provider to add another provider from a model catalog.',
198
+ );
199
+ return;
200
+ }
201
+ if (alias.length > 0 && models[alias] === undefined) {
202
+ host.showError(`Unknown model alias: ${alias}`);
203
+ return;
204
+ }
205
+ const secondary = (await host.harness.getConfig()).secondaryModel;
206
+ // The v2 engine honors a lone legacy `model` key as the fallback pool
207
+ // default — reflect it as the picker's current value.
208
+ const current = secondary?.defaultModel ?? secondary?.model ?? '';
209
+ showSecondaryModelPicker(host, models, current, alias.length > 0 ? alias : undefined);
210
+ }
211
+
212
+ export async function handleEffortCommand(host: SlashCommandHost, args: string): Promise<void> {
213
+ const alias = host.state.appState.model;
214
+ const model = host.state.appState.availableModels[alias];
215
+ if (model === undefined) {
216
+ host.showError('No model selected. Run /model to select one first.');
217
+ return;
218
+ }
219
+ const effective = effectiveModelForHost(host, model);
220
+ const segments = segmentsFor(effective);
221
+ const arg = args.trim().toLowerCase();
222
+ if (arg.length === 0) {
223
+ showEffortPicker(host, effective, segments);
224
+ return;
225
+ }
226
+ if (!segments.includes(arg)) {
227
+ const providerType = host.state.appState.availableProviders[effective.provider]?.type;
228
+ const protocol = effective.protocol ?? providerType;
229
+ if (protocol !== 'anthropic') {
230
+ host.showError(
231
+ `Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`,
232
+ );
233
+ return;
234
+ }
235
+ const knownEfforts = effective.supportEfforts?.join(', ') ?? 'none declared';
236
+ host.showStatus(
237
+ `Thinking effort "${arg}" is not listed for ${alias} (known: ${knownEfforts}). Sending "${arg}" unchanged; the configured provider will validate it.`,
238
+ 'warning',
239
+ );
240
+ }
241
+ await performModelSwitch(host, alias, arg, true);
242
+ }
243
+
244
+ function showEffortPicker(
245
+ host: SlashCommandHost,
246
+ model: ModelAlias,
247
+ segments: readonly string[],
248
+ ): void {
249
+ const liveEffort = host.state.appState.thinkingEffort;
250
+ const currentValue = segments.includes(liveEffort) ? liveEffort : (segments[0] ?? 'off');
251
+ const alias = host.state.appState.model;
252
+ host.mountEditorReplacement(
253
+ new EffortSelectorComponent({
254
+ efforts: segments,
255
+ currentValue,
256
+ warning: hasConversationHistory(host) ? EFFORT_SWITCH_CACHE_WARNING : undefined,
257
+ onSelect: (effort) => {
258
+ host.restoreEditor();
259
+ void performModelSwitch(host, alias, effort, true);
260
+ },
261
+ onSessionOnlySelect: (effort) => {
262
+ host.restoreEditor();
263
+ void performModelSwitch(host, alias, effort, false);
264
+ },
265
+ onCancel: () => {
266
+ host.restoreEditor();
267
+ },
268
+ }),
269
+ );
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Pickers & config apply
274
+ // ---------------------------------------------------------------------------
275
+
276
+ function showEditorPicker(host: SlashCommandHost): void {
277
+ const currentValue = host.state.appState.editorCommand ?? '';
278
+ host.mountEditorReplacement(
279
+ new EditorSelectorComponent({
280
+ currentValue,
281
+ onSelect: (value) => {
282
+ host.restoreEditor();
283
+ void applyEditorChoice(host, value);
284
+ },
285
+ onCancel: () => {
286
+ host.restoreEditor();
287
+ },
288
+ }),
289
+ );
290
+ }
291
+
292
+ async function refreshModelsForPicker(host: SlashCommandHost): Promise<void> {
293
+ try {
294
+ const result = await withTimeout(
295
+ host.authFlow.refreshOAuthProviderModels(),
296
+ MODEL_PICKER_REFRESH_TIMEOUT_MS,
297
+ );
298
+ if (result === undefined) return;
299
+ for (const f of result.failed) {
300
+ host.showStatus(`Skipped refreshing ${f.provider}: ${f.reason}`, 'warning');
301
+ }
302
+ } catch (error) {
303
+ host.showStatus(`Skipped refreshing models: ${formatErrorMessage(error)}`, 'warning');
304
+ }
305
+ }
306
+
307
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined> {
308
+ let timeout: ReturnType<typeof setTimeout> | undefined;
309
+ try {
310
+ return await Promise.race([
311
+ promise,
312
+ new Promise<undefined>((resolve) => {
313
+ timeout = setTimeout(() => {
314
+ resolve(undefined);
315
+ }, timeoutMs);
316
+ }),
317
+ ]);
318
+ } finally {
319
+ if (timeout !== undefined) clearTimeout(timeout);
320
+ }
321
+ }
322
+
323
+ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise<void> {
324
+ const previous = host.state.appState.editorCommand ?? '';
325
+ if (value === previous && value.length > 0) {
326
+ host.showStatus(`Editor unchanged: ${value.length > 0 ? value : 'auto-detect'}`);
327
+ return;
328
+ }
329
+
330
+ const editorCommand = value.length > 0 ? value : null;
331
+ try {
332
+ await saveTuiConfig({
333
+ ...currentTuiConfig(host),
334
+ editorCommand,
335
+ });
336
+ } catch (error) {
337
+ host.showStatus(
338
+ `Failed to save editor: ${formatErrorMessage(error)}`,
339
+ 'error',
340
+ );
341
+ return;
342
+ }
343
+
344
+ host.setAppState({ editorCommand });
345
+ host.showStatus(
346
+ value.length > 0
347
+ ? `Editor set to "${value}".`
348
+ : 'Editor set to auto-detect ($VISUAL / $EDITOR).',
349
+ );
350
+ }
351
+
352
+ /**
353
+ * The models a picker may offer: the user's configured aliases with
354
+ * host-effective provider resolution applied, minus the synthesized
355
+ * `__secondary__` derived entry — a runtime artifact of the v1 engine's
356
+ * `[secondary_model]` recipe that must never be selectable as a model.
357
+ */
358
+ function pickerModelsForHost(host: SlashCommandHost): Record<string, ModelAlias> {
359
+ return Object.fromEntries(
360
+ Object.entries(host.state.appState.availableModels)
361
+ .filter(([alias]) => alias !== SECONDARY_DERIVED_MODEL_ALIAS)
362
+ .map(([alias, model]) => [alias, effectiveModelForHost(host, model)]),
363
+ );
364
+ }
365
+
366
+ export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void {
367
+ const models = pickerModelsForHost(host);
368
+ const entries = Object.entries(models);
369
+ if (entries.length === 0) {
370
+ host.showNotice(
371
+ 'No models configured',
372
+ 'Run /login to sign in to Kimi, or /provider to add another provider from a model catalog.',
373
+ );
374
+ return;
375
+ }
376
+ host.mountEditorReplacement(
377
+ new TabbedModelSelectorComponent({
378
+ models,
379
+ currentValue: host.state.appState.model,
380
+ selectedValue,
381
+ currentThinkingEffort: host.state.appState.thinkingEffort,
382
+ warning: hasConversationHistory(host) ? MODEL_SWITCH_CACHE_WARNING : undefined,
383
+ onSelect: ({ alias, thinking }) => {
384
+ host.restoreEditor();
385
+ void performModelSwitch(host, alias, thinking, true);
386
+ },
387
+ onSessionOnlySelect: ({ alias, thinking }) => {
388
+ host.restoreEditor();
389
+ void performModelSwitch(host, alias, thinking, false);
390
+ },
391
+ onCancel: () => {
392
+ host.restoreEditor();
393
+ },
394
+ }),
395
+ );
396
+ }
397
+
398
+ async function performModelSwitch(
399
+ host: SlashCommandHost,
400
+ alias: string,
401
+ effort: ThinkingEffort,
402
+ persist: boolean,
403
+ ): Promise<void> {
404
+ let session = host.session;
405
+ if (session === undefined) {
406
+ // A first prompt may still be inside lazy creation: wait it out so the
407
+ // switch lands on the new session instead of being overwritten by its
408
+ // assembly.
409
+ await host.waitForLazyCreation();
410
+ session = host.session;
411
+ }
412
+ if (host.state.appState.streamingPhase !== 'idle') {
413
+ host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.');
414
+ return;
415
+ }
416
+
417
+ const prevModel = host.state.appState.model;
418
+ const prevEffort = host.state.appState.thinkingEffort;
419
+ const modelChanged = alias !== prevModel;
420
+ const effortChanged = effort !== prevEffort;
421
+ const runtimeChanged = modelChanged || effortChanged;
422
+ let effectiveAlias = alias;
423
+ let effectiveEffort = effort;
424
+
425
+ try {
426
+ if (session === undefined && runtimeChanged) {
427
+ await host.authFlow.activateModelAfterLogin(alias, effort);
428
+ } else if (session !== undefined) {
429
+ if (alias !== prevModel) {
430
+ await session.setModel(alias);
431
+ }
432
+ if (effort !== prevEffort) {
433
+ await session.setThinking(effort);
434
+ }
435
+ const status = await session.getStatus();
436
+ effectiveAlias = status.model ?? alias;
437
+ effectiveEffort = status.thinkingEffort;
438
+ }
439
+ } catch (error) {
440
+ const msg = formatErrorMessage(error);
441
+ host.showError(`Failed to switch model: ${msg}`);
442
+ return;
443
+ }
444
+
445
+ if (session === undefined) {
446
+ effectiveAlias = host.state.appState.model;
447
+ effectiveEffort = host.state.appState.thinkingEffort;
448
+ }
449
+ const effectiveModelChanged = effectiveAlias !== prevModel;
450
+ const effectiveEffortChanged = effectiveEffort !== prevEffort;
451
+ const displayName = modelDisplayName(
452
+ effectiveAlias,
453
+ host.state.appState.availableModels[effectiveAlias],
454
+ );
455
+ host.setAppState({ model: effectiveAlias, thinkingEffort: effectiveEffort });
456
+ if (session === undefined && runtimeChanged) {
457
+ if (effectiveModelChanged) {
458
+ host.track('model_switch', { model: effectiveAlias });
459
+ }
460
+ if (effectiveEffortChanged) {
461
+ host.track('thinking_toggle', {
462
+ enabled: effectiveEffort !== 'off',
463
+ effort: effectiveEffort,
464
+ from: prevEffort,
465
+ });
466
+ }
467
+ }
468
+
469
+ let persisted = false;
470
+ if (persist) {
471
+ try {
472
+ persisted = await persistModelSelection(
473
+ host,
474
+ effectiveAlias,
475
+ effectiveEffort,
476
+ effectiveEffortChanged,
477
+ );
478
+ } catch (error) {
479
+ const msg = formatErrorMessage(error);
480
+ host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`);
481
+ return;
482
+ }
483
+ }
484
+
485
+ let status: string;
486
+ if (effectiveModelChanged) {
487
+ status = persist
488
+ ? `Switched to ${displayName} with thinking ${effectiveEffort}.`
489
+ : `Switched to ${displayName} with thinking ${effectiveEffort} for this session only.`;
490
+ } else if (effectiveEffortChanged) {
491
+ status = persist
492
+ ? `Thinking set to ${effectiveEffort}.`
493
+ : `Thinking set to ${effectiveEffort} for this session only.`;
494
+ } else if (persist && persisted) {
495
+ status = `Saved ${displayName} with thinking ${effectiveEffort} as default.`;
496
+ } else {
497
+ status = `Already using ${displayName} with thinking ${effectiveEffort}.`;
498
+ }
499
+ host.showStatus(status, 'success');
500
+ }
501
+
502
+ async function persistModelSelection(
503
+ host: SlashCommandHost,
504
+ alias: string,
505
+ effort: ThinkingEffort,
506
+ effortChanged: boolean,
507
+ ): Promise<boolean> {
508
+ const config = await host.harness.getConfig({ reload: true });
509
+ const model = host.state.appState.availableModels[alias];
510
+ const full = thinkingEffortToConfig(
511
+ effort,
512
+ model === undefined ? undefined : effectiveModelForHost(host, model),
513
+ );
514
+ // Re-confirming the effort shown when the picker opened is not an explicit
515
+ // choice — persist the model but leave the stored effort preference alone.
516
+ const patch = effortChanged ? full : { enabled: full.enabled };
517
+ if (
518
+ config.defaultModel === alias &&
519
+ config.thinking?.enabled === patch.enabled &&
520
+ (!effortChanged || config.thinking?.effort === patch.effort)
521
+ ) {
522
+ return false;
523
+ }
524
+ await host.harness.setConfig({
525
+ defaultModel: alias,
526
+ thinking: patch,
527
+ });
528
+ return true;
529
+ }
530
+
531
+ // ---------------------------------------------------------------------------
532
+ // Secondary model (`/secondary-model`) — persists `[secondary_model] default_model`
533
+ // ---------------------------------------------------------------------------
534
+
535
+ function showSecondaryModelPicker(
536
+ host: SlashCommandHost,
537
+ models: Record<string, ModelAlias>,
538
+ currentValue: string,
539
+ selectedValue?: string,
540
+ ): void {
541
+ host.mountEditorReplacement(
542
+ new TabbedModelSelectorComponent({
543
+ models,
544
+ currentValue,
545
+ selectedValue,
546
+ currentThinkingEffort: 'off',
547
+ // Subagent pool bindings carry no explicit thinking level, so the picker
548
+ // hides the Thinking footer instead of offering a no-op choice.
549
+ thinkingControl: false,
550
+ title: ' Select a secondary model (subagents)',
551
+ onSelect: ({ alias }) => {
552
+ host.restoreEditor();
553
+ void performSecondaryModelSave(host, alias);
554
+ },
555
+ onCancel: () => {
556
+ host.restoreEditor();
557
+ },
558
+ }),
559
+ );
560
+ }
561
+
562
+ /**
563
+ * Persists `[secondary_model] default_model`. When a
564
+ * `[secondary_model.models]` pool exists and does not list the alias yet, the
565
+ * alias is added with an empty description — the engine requires the default
566
+ * to be a pool key. Without a pool the default alone forms an implicit
567
+ * single-entry pool, so nothing else is written. No live-apply step: the
568
+ * engine resolves the pool per spawn, so the next subagent dispatch picks the
569
+ * new value up on its own.
570
+ */
571
+ async function performSecondaryModelSave(host: SlashCommandHost, alias: string): Promise<void> {
572
+ const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]);
573
+ try {
574
+ const config = await host.harness.getConfig({ reload: true });
575
+ const existing = config.secondaryModel?.models;
576
+ const patch: { defaultModel: string; models?: Record<string, string> } = {
577
+ defaultModel: alias,
578
+ };
579
+ if (existing !== undefined) {
580
+ patch.models = { ...existing, [alias]: existing[alias] ?? '' };
581
+ }
582
+ await host.harness.setConfig({ secondaryModel: patch });
583
+ } catch (error) {
584
+ host.showError(`Failed to save secondary model: ${formatErrorMessage(error)}`);
585
+ return;
586
+ }
587
+ host.showStatus(
588
+ `Secondary model set to ${displayName}. Newly spawned subagents will use it by default.`,
589
+ 'success',
590
+ );
591
+ }
592
+
593
+ function showThemePicker(host: SlashCommandHost): void {
594
+ host.mountEditorReplacement(
595
+ new ThemeSelectorComponent({
596
+ currentValue: host.state.appState.theme,
597
+ onSelect: (value) => {
598
+ host.restoreEditor();
599
+ void applyThemeChoice(host, value);
600
+ },
601
+ onCancel: () => {
602
+ host.restoreEditor();
603
+ },
604
+ }),
605
+ );
606
+ }
607
+
608
+ async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promise<void> {
609
+ if (theme === host.state.appState.theme) {
610
+ if (theme === 'auto') host.refreshTerminalThemeTracking();
611
+ host.showStatus(`Theme unchanged: "${theme}".`);
612
+ return;
613
+ }
614
+
615
+ // Validate custom themes up front so a missing / malformed file reports an
616
+ // error instead of silently persisting a name that resolves to the dark
617
+ // fallback.
618
+ if (!isBuiltInTheme(theme)) {
619
+ const palette = await loadCustomThemeMerged(theme);
620
+ if (palette === null) {
621
+ host.showStatus(`Theme "${theme}" could not be loaded.`, 'error');
622
+ return;
623
+ }
624
+ }
625
+
626
+ try {
627
+ await saveTuiConfig({
628
+ ...currentTuiConfig(host),
629
+ theme,
630
+ });
631
+ } catch (error) {
632
+ host.showStatus(
633
+ `Failed to save theme: ${formatErrorMessage(error)}`,
634
+ 'error',
635
+ );
636
+ return;
637
+ }
638
+
639
+ const resolved = theme === 'auto'
640
+ ? (currentTheme.palette === lightColors ? 'light' : 'dark')
641
+ : undefined;
642
+ await host.applyTheme(theme, resolved);
643
+ host.refreshTerminalThemeTracking();
644
+ host.track('theme_switch', { theme });
645
+ const detail = theme === 'auto' ? ` (tracking terminal; current: ${resolved})` : '';
646
+ host.showStatus(`Theme set to "${theme}"${detail}.`);
647
+ }
648
+
649
+ export function showPermissionPicker(host: SlashCommandHost, initialMode?: PermissionMode): void {
650
+ host.mountEditorReplacement(
651
+ new PermissionSelectorComponent({
652
+ currentValue: host.state.appState.permissionMode,
653
+ initialValue: initialMode,
654
+ onSelect: (value) => {
655
+ host.restoreEditor();
656
+ void applyPermissionChoice(host, value);
657
+ },
658
+ onCancel: () => {
659
+ host.restoreEditor();
660
+ },
661
+ }),
662
+ );
663
+ }
664
+
665
+ export function showUpdatePreferencePicker(host: SlashCommandHost): void {
666
+ host.mountEditorReplacement(
667
+ new UpdatePreferenceSelectorComponent({
668
+ currentValue: host.state.appState.upgrade.autoInstall,
669
+ onSelect: (value) => {
670
+ host.restoreEditor();
671
+ void applyUpdatePreferenceChoice(host, value);
672
+ },
673
+ onCancel: () => {
674
+ host.restoreEditor();
675
+ },
676
+ }),
677
+ );
678
+ }
679
+
680
+ export async function showExperimentsPanel(host: SlashCommandHost): Promise<void> {
681
+ let features: readonly ExperimentalFeatureState[];
682
+ try {
683
+ features = await host.harness.getExperimentalFeatures();
684
+ } catch (error) {
685
+ host.showError(`Failed to load experimental features: ${formatErrorMessage(error)}`);
686
+ return;
687
+ }
688
+ mountExperimentsPanel(host, features);
689
+ }
690
+
691
+ export async function applyExperimentalFeatureChanges(
692
+ host: SlashCommandHost,
693
+ changes: readonly ExperimentalFeatureDraftChange[],
694
+ ): Promise<void> {
695
+ if (changes.length === 0) {
696
+ host.showStatus(
697
+ 'No experimental feature changes to apply.',
698
+ 'textMuted',
699
+ );
700
+ return;
701
+ }
702
+
703
+ const experimental: Record<string, boolean> = {};
704
+ for (const change of changes) {
705
+ experimental[change.id] = change.enabled;
706
+ }
707
+
708
+ try {
709
+ await host.harness.setConfig({ experimental });
710
+ const features = await host.harness.getExperimentalFeatures();
711
+ setExperimentalFeatures(features);
712
+ host.refreshSlashCommandAutocomplete();
713
+ host.restoreEditor();
714
+ if (host.session !== undefined && changes.some((change) => change.id !== 'notify_user')) {
715
+ const reloadedSession = await host.harness.reloadSession({ id: host.session.id });
716
+ await host.reloadCurrentSessionView(
717
+ reloadedSession,
718
+ 'Experimental features updated. Session reloaded.',
719
+ );
720
+ } else {
721
+ host.showStatus('Experimental features updated.', 'success');
722
+ }
723
+ if (
724
+ host.session !== undefined &&
725
+ changes.some((change) => change.id === 'notify_user' && change.enabled)
726
+ ) {
727
+ host.showNotice(
728
+ 'Start a new session to use Updates if this session was created with the feature disabled.',
729
+ );
730
+ }
731
+ if (changes.some((change) => change.id === 'tower')) {
732
+ // TowerFeature assembles its tool/profile contributions once at App
733
+ // scope construction, so a live flag flip cannot install or retract
734
+ // them; only the mode machinery (enter/injection/guards) reacts live.
735
+ host.showNotice('Tower mode takes effect after restarting Kimi Code.');
736
+ }
737
+ host.track('experimental_features_apply', {
738
+ changed: changes.length,
739
+ flags: features
740
+ .filter((feature) => feature.enabled)
741
+ .map((feature) => feature.id)
742
+ .toSorted()
743
+ .join(','),
744
+ });
745
+ } catch (error) {
746
+ host.showError(`Failed to update experimental features: ${formatErrorMessage(error)}`);
747
+ }
748
+ }
749
+
750
+ function mountExperimentsPanel(
751
+ host: SlashCommandHost,
752
+ features: readonly ExperimentalFeatureState[],
753
+ ): void {
754
+ host.mountEditorReplacement(
755
+ new ExperimentsSelectorComponent({
756
+ features,
757
+ onApply: (changes) => {
758
+ void applyExperimentalFeatureChanges(host, changes);
759
+ },
760
+ onCancel: () => {
761
+ host.restoreEditor();
762
+ },
763
+ }),
764
+ );
765
+ }
766
+
767
+ type UpdatePreferenceHost = {
768
+ readonly state: {
769
+ readonly appState: Pick<
770
+ SlashCommandHost['state']['appState'],
771
+ 'theme' | 'editorCommand' | 'notifications' | 'upgrade'
772
+ >;
773
+ };
774
+ setAppState(patch: Pick<SlashCommandHost['state']['appState'], 'upgrade'>): void;
775
+ showStatus(msg: string, color?: string): void;
776
+ track: SlashCommandHost['track'];
777
+ };
778
+
779
+ export async function applyUpdatePreferenceChoice(
780
+ host: UpdatePreferenceHost,
781
+ autoInstall: boolean,
782
+ ): Promise<void> {
783
+ if (autoInstall === host.state.appState.upgrade.autoInstall) {
784
+ host.showStatus(`Automatic updates already ${autoInstall ? 'enabled' : 'disabled'}.`);
785
+ return;
786
+ }
787
+
788
+ const upgrade = { autoInstall };
789
+ try {
790
+ await saveTuiConfig({
791
+ ...currentTuiConfig(host as unknown as SlashCommandHost),
792
+ upgrade,
793
+ });
794
+ } catch (error) {
795
+ host.showStatus(
796
+ `Failed to save automatic update setting: ${formatErrorMessage(error)}`,
797
+ 'error',
798
+ );
799
+ return;
800
+ }
801
+
802
+ host.setAppState({ upgrade });
803
+ host.track('upgrade_preference_changed', { auto_install: autoInstall });
804
+ host.showStatus(`Automatic updates ${autoInstall ? 'enabled' : 'disabled'}.`);
805
+ }
806
+
807
+ async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMode): Promise<void> {
808
+ if (mode === host.state.appState.permissionMode) {
809
+ host.showStatus(`Permission mode unchanged: ${PERMISSION_MODE_DISPLAY_NAMES[mode]}.`);
810
+ return;
811
+ }
812
+
813
+ try {
814
+ if (host.session !== undefined) {
815
+ await host.session.setPermission(mode);
816
+ }
817
+ // v2 session-less: the chosen mode is recorded in appState and passed to
818
+ // the lazy-created session.
819
+ } catch (error) {
820
+ const msg = formatErrorMessage(error);
821
+ host.showError(`Failed to set permission mode: ${msg}`);
822
+ return;
823
+ }
824
+
825
+ host.setAppState({ permissionMode: mode });
826
+ host.showNotice(`Permission mode: ${PERMISSION_MODE_DISPLAY_NAMES[mode]}`);
827
+ if (mode !== 'manual') {
828
+ host.showStatus(PERMISSION_MODE_DESCRIPTIONS[mode], 'warning');
829
+ }
830
+ }
831
+
832
+ export function showSurveyPreferencePicker(host: SlashCommandHost): void {
833
+ host.mountEditorReplacement(
834
+ new SurveyPreferenceSelectorComponent({
835
+ currentValue: host.state.appState.disableFeedbackSurvey !== true,
836
+ onSelect: (value) => {
837
+ host.restoreEditor();
838
+ void applySurveyPreferenceChoice(host, value);
839
+ },
840
+ onCancel: () => {
841
+ host.restoreEditor();
842
+ },
843
+ }),
844
+ );
845
+ }
846
+
847
+ type SurveyPreferenceHost = {
848
+ readonly state: {
849
+ readonly appState: Pick<
850
+ SlashCommandHost['state']['appState'],
851
+ 'theme' | 'editorCommand' | 'notifications' | 'upgrade' | 'disableFeedbackSurvey'
852
+ >;
853
+ };
854
+ setAppState(
855
+ patch: Pick<SlashCommandHost['state']['appState'], 'disableFeedbackSurvey'>,
856
+ ): void;
857
+ showStatus(msg: string, color?: string): void;
858
+ };
859
+
860
+ export async function applySurveyPreferenceChoice(
861
+ host: SurveyPreferenceHost,
862
+ enabled: boolean,
863
+ ): Promise<void> {
864
+ const disableFeedbackSurvey = !enabled;
865
+ if (disableFeedbackSurvey === (host.state.appState.disableFeedbackSurvey === true)) {
866
+ host.showStatus(`Feedback survey already ${enabled ? 'enabled' : 'disabled'}.`);
867
+ return;
868
+ }
869
+
870
+ try {
871
+ await saveTuiConfig({
872
+ ...currentTuiConfig(host as unknown as SlashCommandHost),
873
+ disableFeedbackSurvey,
874
+ });
875
+ } catch (error) {
876
+ host.showStatus(
877
+ `Failed to save session rating setting: ${formatErrorMessage(error)}`,
878
+ 'error',
879
+ );
880
+ return;
881
+ }
882
+
883
+ host.setAppState({ disableFeedbackSurvey });
884
+ host.showStatus(`Feedback survey ${enabled ? 'enabled' : 'disabled'}.`);
885
+ }
886
+
887
+ export function showSettingsSelector(host: SlashCommandHost): void {
888
+ host.mountEditorReplacement(
889
+ new SettingsSelectorComponent({
890
+ onSelect: (value) => {
891
+ handleSettingsSelection(host, value);
892
+ },
893
+ onCancel: () => {
894
+ host.restoreEditor();
895
+ },
896
+ }),
897
+ );
898
+ }
899
+
900
+ function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelection): void {
901
+ host.restoreEditor();
902
+ switch (value) {
903
+ case 'model': showModelPicker(host); return;
904
+ case 'permission': showPermissionPicker(host); return;
905
+ case 'theme': showThemePicker(host); return;
906
+ case 'editor': showEditorPicker(host); return;
907
+ case 'survey': showSurveyPreferencePicker(host); return;
908
+ case 'experiments': void showExperimentsPanel(host); return;
909
+ case 'upgrade': showUpdatePreferencePicker(host); return;
910
+ case 'usage': void showUsage(host); return;
911
+ }
912
+ }
apps/kimi-code/src/tui/commands/copy.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text';
2
+ import type { TranscriptEntry } from '../types';
3
+ import { formatErrorMessage } from '../utils/event-payload';
4
+ import type { SlashCommandHost } from './dispatch';
5
+
6
+ /**
7
+ * Visible text of the last assistant transcript entry, newest first; empty
8
+ * string when none. Sourced from the rendered transcript rather than the
9
+ * model context so it survives compaction and session resume: after
10
+ * `/compact` the context keeps user messages plus a user-role summary only,
11
+ * while the last reply is still on screen. Only entries tagged `modelText`
12
+ * count — hook-result and goal-completion cards share kind 'assistant' but
13
+ * are not replies.
14
+ */
15
+ export function findLastAssistantText(entries: readonly TranscriptEntry[]): string {
16
+ for (let i = entries.length - 1; i >= 0; i--) {
17
+ const entry = entries[i];
18
+ if (entry === undefined || entry.kind !== 'assistant' || entry.modelText !== true) continue;
19
+ if (entry.content.trim().length > 0) return entry.content;
20
+ }
21
+ return '';
22
+ }
23
+
24
+ export async function handleCopyCommand(host: SlashCommandHost): Promise<void> {
25
+ const text = findLastAssistantText(host.state.transcriptEntries);
26
+ if (text.length === 0) {
27
+ host.showStatus('No assistant message to copy.', 'warning');
28
+ return;
29
+ }
30
+
31
+ try {
32
+ const method = await copyTextToClipboard(text);
33
+ host.showStatus(
34
+ method === 'native'
35
+ ? `Copied to clipboard (${String(text.length)} characters).`
36
+ : `Copied via terminal escape sequence (unverified, ${String(text.length)} characters).`,
37
+ );
38
+ } catch (error) {
39
+ host.showError(`Failed to copy to clipboard: ${formatErrorMessage(error)}`);
40
+ }
41
+ }
apps/kimi-code/src/tui/commands/dispatch.ts ADDED
@@ -0,0 +1,607 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Component, Focusable } from '@moonshot-ai/pi-tui';
2
+ import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth';
3
+ import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
4
+
5
+ import type { ColorToken, ThemeName } from '#/tui/theme';
6
+
7
+ import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui';
8
+ import type { AuthFlowController } from '../controllers/auth-flow';
9
+ import type { BtwPanelController } from '../controllers/btw-panel';
10
+ import type { StreamingUIController } from '../controllers/streaming-ui';
11
+ import type { TasksBrowserController } from '../controllers/tasks-browser';
12
+ import { tryHandleDanceCommand } from '../easter-eggs/dance';
13
+ import type { ResolvedTheme } from '../theme/colors';
14
+ import type { TUIState } from '../tui-state';
15
+ import type {
16
+ AppState,
17
+ InlineSkillActivation,
18
+ LoginProgressSpinnerHandle,
19
+ QueuedMessage,
20
+ TranscriptEntry,
21
+ } from '../types';
22
+ import { formatErrorMessage } from '../utils/event-payload';
23
+ import {
24
+ extractInlineSkillActivations,
25
+ findInlineSkillTokens,
26
+ } from '../utils/inline-skill-tokens';
27
+ import { handleLoginCommand, handleLogoutCommand } from './auth';
28
+ import { handleBtwCommand } from './btw';
29
+ import { handleCopyCommand } from './copy';
30
+ import {
31
+ handleCompactCommand,
32
+ handleEditorCommand,
33
+ handleEffortCommand,
34
+ handleModelCommand,
35
+ handlePlanCommand,
36
+ handleSecondaryModelCommand,
37
+ handleThemeCommand,
38
+ showExperimentsPanel,
39
+ showModelPicker,
40
+ showPermissionPicker,
41
+ showSettingsSelector,
42
+ } from './config';
43
+ import { handleGoalCommand } from './goal';
44
+ import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
45
+ import { handleAddDirCommand } from './add-dir';
46
+ import { parseSlashInput } from './parse';
47
+ import { handlePluginsCommand } from './plugins';
48
+ import { handleProviderCommand } from './provider';
49
+ import {
50
+ findBuiltInSlashCommand,
51
+ resolveSlashCommandAvailability,
52
+ type BuiltinSlashCommandName,
53
+ } from './registry';
54
+ import { handleReloadCommand, handleReloadTuiCommand } from './reload';
55
+ import type { SkillListSession } from './skills';
56
+ import {
57
+ canRestoreSubmittedInput,
58
+ resolveSlashCommandInput,
59
+ slashBusyMessage,
60
+ slashCommandBusyReason,
61
+ } from './resolve';
62
+ import {
63
+ handleExportDebugZipCommand,
64
+ handleExportMdCommand,
65
+ handleForkCommand,
66
+ handleInitCommand,
67
+ handleTitleCommand,
68
+ } from './session';
69
+ import { handleSwarmCommand } from './swarm';
70
+ import { handleTowerCommand } from './tower';
71
+ import { handleUndoCommand } from './undo';
72
+ import { handleRemoteControlCommand, handleWebCommand } from './web';
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Re-exports — keep existing consumers working
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export { handleLoginCommand, handleLogoutCommand } from './auth';
79
+ export { handleBtwCommand } from './btw';
80
+ export { handleCopyCommand } from './copy';
81
+ export { handleAddDirCommand } from './add-dir';
82
+ export {
83
+ handleCompactCommand,
84
+ handleEditorCommand,
85
+ handleEffortCommand,
86
+ handleModelCommand,
87
+ handlePlanCommand,
88
+ handleSecondaryModelCommand,
89
+ handleThemeCommand,
90
+ showModelPicker,
91
+ showExperimentsPanel,
92
+ showPermissionPicker,
93
+ showSettingsSelector,
94
+ } from './config';
95
+ export { handleSwarmCommand } from './swarm';
96
+ export { handleTowerCommand } from './tower';
97
+ export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
98
+ export { handlePluginsCommand } from './plugins';
99
+ export { handleReloadCommand, handleReloadTuiCommand } from './reload';
100
+ export { handleGoalCommand } from './goal';
101
+ export {
102
+ handleExportDebugZipCommand,
103
+ handleExportMdCommand,
104
+ handleForkCommand,
105
+ handleInitCommand,
106
+ handleTitleCommand,
107
+ } from './session';
108
+ export { handleUndoCommand } from './undo';
109
+ export { handleRemoteControlCommand, handleWebCommand } from './web';
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Host interface
113
+ // ---------------------------------------------------------------------------
114
+
115
+ export interface SlashCommandHost {
116
+ state: TUIState;
117
+ session: Session | undefined;
118
+ readonly harness: KimiHarness;
119
+ cancelInFlight: (() => void) | undefined;
120
+ deferUserMessages: boolean;
121
+
122
+ setAppState(patch: Partial<AppState>): void;
123
+ resetLivePane(): void;
124
+ showError(msg: string): void;
125
+ showStatus(msg: string, color?: ColorToken): void;
126
+ showNotice(title: string, detail?: string): void;
127
+ appendTranscriptEntry(entry: TranscriptEntry): void;
128
+ track(event: string, props?: Record<string, unknown>): void;
129
+ mountEditorReplacement(panel: Component & Focusable): void;
130
+ restoreEditor(): void;
131
+ restoreInputText(text: string): void;
132
+ refreshSlashCommandAutocomplete(): void;
133
+ /**
134
+ * Rebuild the plugin slash-command list. With no session (v2 session-less
135
+ * startup) this reads the app-global plugin commands instead, so `/plugins`
136
+ * mutations apply before the first session exists.
137
+ */
138
+ refreshPluginCommands(session?: Session): Promise<void>;
139
+ /**
140
+ * Rebuild the skill slash-command list. With no session (v2 session-less
141
+ * startup) this reads the workspace skills instead.
142
+ */
143
+ refreshSkillCommands(session?: SkillListSession): Promise<void>;
144
+ /**
145
+ * Seed appState with the config defaults the v2 engine would apply at
146
+ * createSession time (model, permission, plan mode, thinking effort,
147
+ * context cap). No-op semantics on a live session path: only /reload calls
148
+ * it while still session-less.
149
+ */
150
+ hydrateLazyConfigDefaults(): Promise<void>;
151
+
152
+ // Session
153
+ requireSession(): Session;
154
+ /**
155
+ * Lazy-create the session on first use (v2 engine). Returns the existing
156
+ * session, or undefined (with the error already surfaced) when creation
157
+ * fails.
158
+ */
159
+ ensureSession(): Promise<Session | undefined>;
160
+ /** Await the in-flight lazy session creation, if any (v2); no-op otherwise. */
161
+ waitForLazyCreation(): Promise<void>;
162
+ switchToSession(session: Session, message: string): Promise<void>;
163
+ reloadCurrentSessionView(session: Session, message: string): Promise<void>;
164
+ beginSessionRequest(): void;
165
+ failSessionRequest(message: string): void;
166
+ sendQueuedMessage(session: Session, item: QueuedMessage): void;
167
+ requestQueuedGoalPromotion?(): void;
168
+ /** Reset the client-side cache-break baseline after the context was cut
169
+ * (/undo): the next step's cache-read drop is expected, not a break. */
170
+ noteContextCut?(): void;
171
+
172
+ // UI
173
+ showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle;
174
+ showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle;
175
+ showProgressSpinner(label: string): LoginProgressSpinnerHandle;
176
+
177
+ // Theme
178
+ applyTheme(theme: ThemeName, resolved?: ResolvedTheme): Promise<void>;
179
+ refreshTerminalThemeTracking(): void;
180
+
181
+ // Dispatch
182
+ stop(exitCode?: number): Promise<void>;
183
+ setExitOpenUrl(url: string): void;
184
+ /**
185
+ * Register a task that takes over the process after the TUI has shut down
186
+ * (instead of exiting): the runner awaits it and only exits when it returns.
187
+ * Used by `/web` to keep a freshly started server attached to this terminal
188
+ * until Ctrl+C.
189
+ */
190
+ setExitForegroundTask(task: (exitCode: number) => Promise<void>): void;
191
+ showHelpPanel(): void;
192
+ createNewSession(): Promise<void>;
193
+ showSessionPicker(): Promise<void>;
194
+ sendNormalUserInput(text: string): void;
195
+ /**
196
+ * Submit a prompt that explicitly activates one or more skills inline
197
+ * (v2 engine only): all activations ride the same submission as the prompt
198
+ * and launch as a single turn.
199
+ */
200
+ sendInlineSkillUserInput(text: string, activations: readonly InlineSkillActivation[]): Promise<void>;
201
+ sendSkillActivation(session: Session, skillName: string, skillArgs: string): void;
202
+ activatePluginCommand(
203
+ session: Session,
204
+ pluginId: string,
205
+ commandName: string,
206
+ args: string,
207
+ ): void;
208
+ readonly skillCommandMap: Map<string, string>;
209
+ readonly pluginCommandMap: Map<string, string>;
210
+
211
+ // Controller refs
212
+ readonly streamingUI: StreamingUIController;
213
+ readonly btwPanelController: BtwPanelController;
214
+ readonly tasksBrowserController: TasksBrowserController;
215
+ readonly authFlow: AuthFlowController;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Dispatch — entry point from handleUserInput
220
+ // ---------------------------------------------------------------------------
221
+
222
+ export function dispatchInput(host: SlashCommandHost, text: string): void {
223
+ if (parseSlashInput(text) !== null) {
224
+ // A leading skill command combined with further inline skill tokens
225
+ // (`/skill:a args /skill:b`) is one grouped submission on the v2 engine.
226
+ if (dispatchInlineSkillCombo(host, text)) {
227
+ return;
228
+ }
229
+ void executeSlashCommand(host, text);
230
+ return;
231
+ }
232
+ // Inline skill tokens anywhere in a plain prompt activate the skills.
233
+ const activations = extractInlineSkillActivations(text, host.skillCommandMap);
234
+ if (activations.length > 0) {
235
+ void host.sendInlineSkillUserInput(text, activations);
236
+ return;
237
+ }
238
+ host.sendNormalUserInput(text);
239
+ }
240
+
241
+ /**
242
+ * Handle a leading-slash input that may be a bundled submission. Returns true
243
+ * when the input was claimed, false when it should fall through to the
244
+ * regular single-skill slash path.
245
+ *
246
+ * Bundle rule: two or more known skill tokens with the first one leading the
247
+ * input make the whole input one bundled prompt in which every token
248
+ * activates with NO args — the mention is the whole interface, and args stay
249
+ * a standalone-activation concept (`/skill:a some args` with no other tokens
250
+ * keeps its single-skill path). Tokenization is whitespace-generic, so
251
+ * space- and newline-separated bundles behave identically. A recognized
252
+ * builtin or plugin command always keeps its own path, no matter how many
253
+ * skill tokens its arguments mention.
254
+ */
255
+ function dispatchInlineSkillCombo(host: SlashCommandHost, text: string): boolean {
256
+ // The intent is parsed without the busy flags on purpose: submissions
257
+ // through sendInlineSkillUserInput queue while busy — only genuine
258
+ // single-skill commands reject.
259
+ const intent = resolveSlashCommandInput({
260
+ input: text,
261
+ skillCommandMap: host.skillCommandMap,
262
+ pluginCommandMap: host.pluginCommandMap,
263
+ isStreaming: false,
264
+ isCompacting: false,
265
+ });
266
+ if (intent.kind !== 'skill' && intent.kind !== 'message') return false;
267
+
268
+ const tokens = findInlineSkillTokens(text, {
269
+ isKnownSkill: (commandName) =>
270
+ host.skillCommandMap.has(commandName) || host.skillCommandMap.has(`skill:${commandName}`),
271
+ includeLeading: true,
272
+ });
273
+ // The 'message' kind joins the bundle rule because parseSlashInput only
274
+ // splits on a literal space: a newline after a leading skill resolves to
275
+ // 'message' instead of 'skill', and must not silently drop the leading
276
+ // activation.
277
+ if (tokens.length >= 2 && tokens[0]!.start === 0) {
278
+ const activations = extractInlineSkillActivations(text, host.skillCommandMap, {
279
+ includeLeading: true,
280
+ });
281
+ void host.sendInlineSkillUserInput(text, activations);
282
+ return true;
283
+ }
284
+
285
+ // An unrecognized leading slash token makes the whole input a plain
286
+ // message; scan it for inline skills like any other plain prompt.
287
+ if (intent.kind !== 'message') return false;
288
+ const activations = extractInlineSkillActivations(text, host.skillCommandMap);
289
+ if (activations.length === 0) return false;
290
+ void host.sendInlineSkillUserInput(text, activations);
291
+ return true;
292
+ }
293
+
294
+ async function executeSlashCommand(host: SlashCommandHost, input: string): Promise<void> {
295
+ const parsedCommand = parseSlashInput(input);
296
+ const intent = resolveSlashCommandInput({
297
+ input,
298
+ skillCommandMap: host.skillCommandMap,
299
+ pluginCommandMap: host.pluginCommandMap,
300
+ isStreaming: host.state.appState.streamingPhase !== 'idle',
301
+ isCompacting: host.state.appState.isCompacting,
302
+ });
303
+
304
+ switch (intent.kind) {
305
+ case 'not-command':
306
+ return;
307
+ case 'blocked':
308
+ host.track('input_command_invalid', { reason: 'blocked', command: intent.commandName });
309
+ host.showError(slashBusyMessage(intent.commandName, intent.reason));
310
+ // The editor buffer was already cleared on submit; give the rejected
311
+ // command line back so hand-typed input is not lost.
312
+ host.restoreInputText(input);
313
+ return;
314
+ case 'invalid':
315
+ host.track('input_command_invalid', {
316
+ reason: intent.reason,
317
+ command: intent.commandName,
318
+ });
319
+ host.showError(`Invalid slash command: /${intent.commandName}`);
320
+ return;
321
+ case 'skill': {
322
+ if (host.state.appState.model.trim().length === 0) {
323
+ host.showError(LLM_NOT_SET_MESSAGE);
324
+ return;
325
+ }
326
+ let session = host.session;
327
+ if (session === undefined) {
328
+ session = await ensureSessionForCommand(host);
329
+ if (session === undefined) return;
330
+ // A first prompt may have started a turn while the session was being
331
+ // created; skill commands are always busy-gated, so re-check the gate
332
+ // resolved before the await.
333
+ const busyReason = slashCommandBusyReason({
334
+ isStreaming: host.state.appState.streamingPhase !== 'idle',
335
+ isCompacting: host.state.appState.isCompacting,
336
+ });
337
+ if (busyReason !== undefined) {
338
+ host.showError(slashBusyMessage(intent.commandName, busyReason));
339
+ return;
340
+ }
341
+ }
342
+ host.track('input_command', {
343
+ command: intent.commandName,
344
+ skill_name: intent.skillName,
345
+ });
346
+ host.sendSkillActivation(session, intent.skillName, intent.args);
347
+ return;
348
+ }
349
+ case 'plugin-command': {
350
+ if (host.state.appState.model.trim().length === 0) {
351
+ host.showError(LLM_NOT_SET_MESSAGE);
352
+ return;
353
+ }
354
+ let session = host.session;
355
+ if (session === undefined) {
356
+ session = await ensureSessionForCommand(host);
357
+ if (session === undefined) return;
358
+ // Same busy re-check as the skill path: plugin commands are always
359
+ // busy-gated too.
360
+ const busyReason = slashCommandBusyReason({
361
+ isStreaming: host.state.appState.streamingPhase !== 'idle',
362
+ isCompacting: host.state.appState.isCompacting,
363
+ });
364
+ if (busyReason !== undefined) {
365
+ host.showError(slashBusyMessage(intent.commandName, busyReason));
366
+ return;
367
+ }
368
+ }
369
+ host.track('input_command', { command: `${intent.pluginId}:${intent.commandName}` });
370
+ host.activatePluginCommand(session, intent.pluginId, intent.commandName, intent.args);
371
+ return;
372
+ }
373
+ case 'message':
374
+ // Unknown slash command: let /dance claim it before it falls through to
375
+ // the model as a normal message. This runs *after* builtin and skill
376
+ // resolution, so a real command or a same-named skill always wins.
377
+ if (parsedCommand !== null && tryHandleDanceCommand(host, parsedCommand)) {
378
+ return;
379
+ }
380
+ host.sendNormalUserInput(intent.input);
381
+ return;
382
+ case 'builtin':
383
+ host.track('input_command', { command: intent.name });
384
+ if (intent.name === 'new' && parsedCommand?.name === 'clear') {
385
+ host.track('clear');
386
+ }
387
+ try {
388
+ await handleBuiltInSlashCommand(host, intent.name, intent.args, input);
389
+ } catch (error) {
390
+ host.showError(formatErrorMessage(error));
391
+ }
392
+ return;
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Lazy-create the session for a slash command that needs one (v2 engine). A
398
+ * missing session means the TUI started session-less, so commands create it
399
+ * on first use. Returns undefined (error already shown) when creation fails.
400
+ */
401
+ async function ensureSessionForCommand(host: SlashCommandHost): Promise<Session | undefined> {
402
+ return host.ensureSession();
403
+ }
404
+
405
+ /** Builtin commands that need an active session; lazy-created on the v2 engine. */
406
+ const SESSION_REQUIRING_COMMANDS: ReadonlySet<BuiltinSlashCommandName> = new Set([
407
+ 'btw',
408
+ 'compact',
409
+ 'export-debug-zip',
410
+ 'export-md',
411
+ 'fork',
412
+ 'goal',
413
+ 'init',
414
+ 'plan',
415
+ 'swarm',
416
+ 'undo',
417
+ 'web',
418
+ ]);
419
+
420
+ async function handleBuiltInSlashCommand(
421
+ host: SlashCommandHost,
422
+ name: BuiltinSlashCommandName,
423
+ args: string,
424
+ input: string,
425
+ ): Promise<void> {
426
+ if (host.session === undefined && SESSION_REQUIRING_COMMANDS.has(name)) {
427
+ const session = await ensureSessionForCommand(host);
428
+ if (session === undefined) {
429
+ // Creation failed after submit cleared the buffer; give the input
430
+ // back unless the user moved on — a newer draft or an opened panel.
431
+ if (canRestoreSubmittedInput(host)) host.restoreInputText(input);
432
+ return;
433
+ }
434
+ // A first prompt may have started a turn while the session was being
435
+ // created; re-check the availability gate that was resolved before the
436
+ // await (idle-only commands are blocked while a turn is active).
437
+ const command = findBuiltInSlashCommand(name);
438
+ const busyReason = slashCommandBusyReason({
439
+ isStreaming: host.state.appState.streamingPhase !== 'idle',
440
+ isCompacting: host.state.appState.isCompacting,
441
+ });
442
+ if (
443
+ busyReason !== undefined &&
444
+ command !== undefined &&
445
+ resolveSlashCommandAvailability(command, args) === 'idle-only'
446
+ ) {
447
+ host.showError(slashBusyMessage(name, busyReason));
448
+ // Same as the dispatch blocked branch: give the cleared input back,
449
+ // guarded the same way — session creation awaited above.
450
+ if (canRestoreSubmittedInput(host)) host.restoreInputText(input);
451
+ return;
452
+ }
453
+ }
454
+ switch (name) {
455
+ case 'exit':
456
+ void host.stop();
457
+ return;
458
+ case 'help':
459
+ host.showHelpPanel();
460
+ return;
461
+ case 'version':
462
+ host.showStatus(`Kimi Code v${host.state.appState.version}`);
463
+ return;
464
+ case 'new': {
465
+ // A first-use lazy creation may still be in flight: wait it out so /new
466
+ // never races a second createSession against the pending prompt.
467
+ await host.waitForLazyCreation();
468
+ // The waited-out prompt may have started a turn meanwhile; /new is
469
+ // idle-only, so re-run the busy gate resolved before the await.
470
+ const busyReason = slashCommandBusyReason({
471
+ isStreaming: host.state.appState.streamingPhase !== 'idle',
472
+ isCompacting: host.state.appState.isCompacting,
473
+ });
474
+ if (busyReason !== undefined) {
475
+ host.showError(slashBusyMessage(name, busyReason));
476
+ return;
477
+ }
478
+ await host.createNewSession();
479
+ host.state.ui.requestRender();
480
+ return;
481
+ }
482
+ case 'sessions':
483
+ void host.showSessionPicker();
484
+ return;
485
+ case 'tasks':
486
+ void host.tasksBrowserController.show();
487
+ return;
488
+ case 'mcp':
489
+ void showMcpServers(host);
490
+ return;
491
+ case 'plugins':
492
+ // `handlePluginsCommand` throws when no session is active (its own
493
+ // requireSession), so catch here instead of letting the `void` call
494
+ // reject unhandled.
495
+ try {
496
+ await handlePluginsCommand(host, args);
497
+ } catch (error) {
498
+ host.showError(formatErrorMessage(error));
499
+ }
500
+ return;
501
+ case 'add-dir':
502
+ await handleAddDirCommand(host, args);
503
+ return;
504
+ case 'experiments':
505
+ await showExperimentsPanel(host);
506
+ return;
507
+ case 'reload':
508
+ await handleReloadCommand(host);
509
+ return;
510
+ case 'reload-tui':
511
+ await handleReloadTuiCommand(host);
512
+ return;
513
+ case 'editor':
514
+ await handleEditorCommand(host, args);
515
+ return;
516
+ case 'theme':
517
+ await handleThemeCommand(host, args);
518
+ return;
519
+ case 'model':
520
+ await handleModelCommand(host, args);
521
+ return;
522
+ case 'secondary-model':
523
+ await handleSecondaryModelCommand(host, args);
524
+ return;
525
+ case 'effort':
526
+ await handleEffortCommand(host, args);
527
+ return;
528
+ case 'provider':
529
+ await handleProviderCommand(host);
530
+ return;
531
+ case 'permission':
532
+ showPermissionPicker(host);
533
+ return;
534
+ case 'settings':
535
+ showSettingsSelector(host);
536
+ return;
537
+ case 'usage':
538
+ void showUsage(host);
539
+ return;
540
+ case 'status':
541
+ void showStatusReport(host);
542
+ return;
543
+ case 'feedback':
544
+ await handleFeedbackCommand(host);
545
+ return;
546
+ case 'btw':
547
+ await handleBtwCommand(host, args);
548
+ return;
549
+ case 'title':
550
+ await handleTitleCommand(host, args);
551
+ return;
552
+ case 'yolo':
553
+ showPermissionPicker(host, 'yolo');
554
+ return;
555
+ case 'auto':
556
+ showPermissionPicker(host, 'auto');
557
+ return;
558
+ case 'plan':
559
+ await handlePlanCommand(host, args);
560
+ return;
561
+ case 'swarm':
562
+ await handleSwarmCommand(host, args);
563
+ return;
564
+ case 'tower':
565
+ await handleTowerCommand(host, args);
566
+ return;
567
+ case 'compact':
568
+ await handleCompactCommand(host, args);
569
+ return;
570
+ case 'goal':
571
+ await handleGoalCommand(host, args);
572
+ return;
573
+ case 'init':
574
+ await handleInitCommand(host);
575
+ return;
576
+ case 'fork':
577
+ await handleForkCommand(host, args);
578
+ return;
579
+ case 'export-md':
580
+ await handleExportMdCommand(host, args);
581
+ return;
582
+ case 'export-debug-zip':
583
+ await handleExportDebugZipCommand(host);
584
+ return;
585
+ case 'copy':
586
+ await handleCopyCommand(host);
587
+ return;
588
+ case 'login':
589
+ await handleLoginCommand(host);
590
+ return;
591
+ case 'logout':
592
+ await handleLogoutCommand(host);
593
+ return;
594
+ case 'undo':
595
+ await handleUndoCommand(host, args);
596
+ return;
597
+ case 'web':
598
+ await handleWebCommand(host);
599
+ return;
600
+ case 'remote-control':
601
+ await handleRemoteControlCommand(host);
602
+ return;
603
+ default:
604
+ host.showError(`Unknown slash command: /${String(name)}`);
605
+ return;
606
+ }
607
+ }
apps/kimi-code/src/tui/commands/experimental-flags.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ExperimentalFeatureState, ExperimentalFlagMap } from '@moonshot-ai/kimi-code-sdk';
2
+
3
+ import { experimentalFeatureMap } from '#/utils/experimental-features';
4
+
5
+ // Resolved experimental features, fetched once from the core over RPC at startup and then read
6
+ // synchronously by the command palette and dispatch. App-local cache, not a source of truth.
7
+ let snapshot: ExperimentalFlagMap = {};
8
+
9
+ /** Replace the cached flag snapshot. Call after fetching via `harness.getExperimentalFeatures()`. */
10
+ export function setExperimentalFeatures(
11
+ features: readonly Pick<ExperimentalFeatureState, 'id' | 'enabled'>[],
12
+ ): void {
13
+ snapshot = experimentalFeatureMap(features);
14
+ }
15
+
16
+ /** An `undefined` flag means "not gated" → always enabled, so callers can pass an optional flag id. */
17
+ export function isExperimentalFlagEnabled(flag: string | undefined): boolean {
18
+ return flag === undefined || snapshot[flag] === true;
19
+ }
apps/kimi-code/src/tui/commands/goal.ts ADDED
@@ -0,0 +1,576 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ErrorCodes, isKimiError, type PermissionMode } from '@moonshot-ai/kimi-code-sdk';
2
+
3
+ import {
4
+ GoalStartPermissionPromptComponent,
5
+ type GoalStartPermissionChoice,
6
+ } from '../components/dialogs/goal-start-permission-prompt';
7
+ import {
8
+ GoalQueueEditDialogComponent,
9
+ GoalQueueManagerComponent,
10
+ type GoalQueueEditResult,
11
+ type GoalQueueManagerAction,
12
+ } from '../components/dialogs/goal-queue-manager';
13
+ import {
14
+ GoalSetMessageComponent,
15
+ GoalStatusMessageComponent,
16
+ UpcomingGoalAddedMessageComponent,
17
+ } from '../components/messages/goal-panel';
18
+ import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui';
19
+ import {
20
+ appendGoalQueueItem,
21
+ moveGoalQueueItem,
22
+ readGoalQueue,
23
+ removeGoalQueueItem,
24
+ updateGoalQueueItem,
25
+ type GoalQueueSnapshot,
26
+ } from '../goal-queue-store';
27
+ import { formatErrorMessage } from '../utils/event-payload';
28
+ import { PERMISSION_MODE_DESCRIPTIONS, PERMISSION_MODE_DISPLAY_NAMES } from '../utils/permission-mode';
29
+ import { canRestoreSubmittedInput } from './resolve';
30
+ import type { SlashCommandHost } from './dispatch';
31
+
32
+ const MAX_GOAL_OBJECTIVE_LENGTH = 4000;
33
+ const RESUME_GOAL_INPUT = 'Resume the active goal.';
34
+ const START_NEXT_GOAL_NOW_MESSAGE = 'No active goal. Starting this goal now.';
35
+
36
+ type GoalCommandHost = Pick<
37
+ SlashCommandHost,
38
+ | 'state'
39
+ | 'session'
40
+ | 'requireSession'
41
+ | 'setAppState'
42
+ | 'showError'
43
+ | 'showNotice'
44
+ | 'showStatus'
45
+ | 'track'
46
+ | 'mountEditorReplacement'
47
+ | 'restoreEditor'
48
+ | 'restoreInputText'
49
+ | 'sendNormalUserInput'
50
+ >;
51
+
52
+ export interface GoalStartOptions {
53
+ readonly beforeSend?: () => boolean | Promise<boolean>;
54
+ readonly sendInput?: (objective: string) => void;
55
+ }
56
+
57
+ export type ParsedGoalCommand =
58
+ | { readonly kind: 'status' }
59
+ | { readonly kind: 'pause' }
60
+ | { readonly kind: 'resume' }
61
+ | { readonly kind: 'cancel' }
62
+ | {
63
+ readonly kind: 'create';
64
+ readonly objective: string;
65
+ readonly replace: boolean;
66
+ }
67
+ | { readonly kind: 'next-add'; readonly objective: string }
68
+ | { readonly kind: 'next-manage' }
69
+ | {
70
+ readonly kind: 'error';
71
+ readonly message: string;
72
+ readonly severity?: 'error' | 'hint';
73
+ /** Restore the typed `/goal ...` line into the editor so the input is not lost. */
74
+ readonly restoreInput?: boolean;
75
+ };
76
+
77
+ const CONTROL_SUBCOMMANDS = new Set(['pause', 'resume', 'cancel']);
78
+
79
+ /**
80
+ * Parses the deterministic `/goal` command grammar. Reserved subcommands
81
+ * (`pause`/`resume`/`cancel`/`status`/`replace`) are only honored as the first
82
+ * token; use `/goal -- <objective>` to start a goal whose text begins with one
83
+ * of those words. (`cancel` is the single discard action — it removes the
84
+ * current goal.) Stop conditions are expressed in the objective in natural
85
+ * language (e.g. "…or stop after 20 turns"); the model honors them when it
86
+ * self-audits each turn and reports `complete`/`blocked` via UpdateGoal.
87
+ */
88
+ export function parseGoalCommand(rawArgs: string): ParsedGoalCommand {
89
+ const args = rawArgs.trim();
90
+ if (args.length === 0 || args === 'status') return { kind: 'status' };
91
+
92
+ const tokens = args.split(/\s+/);
93
+ const first = tokens[0];
94
+ if (first === 'next') {
95
+ return parseNextGoalCommand(tokens);
96
+ }
97
+ if (first !== undefined && CONTROL_SUBCOMMANDS.has(first) && tokens.length === 1) {
98
+ return { kind: first as 'pause' | 'resume' | 'cancel' };
99
+ }
100
+
101
+ let index = 0;
102
+ let replace = false;
103
+ if (tokens[index] === 'replace') {
104
+ replace = true;
105
+ index += 1;
106
+ }
107
+ // `--` ends subcommand parsing so an objective can begin with a reserved word
108
+ // (e.g. `/goal -- pause the rollout`).
109
+ if (tokens[index] === '--') {
110
+ index += 1;
111
+ }
112
+
113
+ const objective = tokens.slice(index).join(' ').trim();
114
+ if (objective.length === 0) {
115
+ // A usage hint, not a failure — shown in the same calm style as the other
116
+ // "nothing to act on" messages (no goal to pause/resume/cancel).
117
+ return {
118
+ kind: 'error',
119
+ severity: 'hint',
120
+ message: 'Provide a goal objective, e.g. `/goal Ship feature X`.',
121
+ };
122
+ }
123
+ if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) {
124
+ return {
125
+ kind: 'error',
126
+ restoreInput: true,
127
+ message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Put long content in a file and reference the file path.`,
128
+ };
129
+ }
130
+ return { kind: 'create', objective, replace };
131
+ }
132
+
133
+ export async function handleGoalCommand(host: SlashCommandHost, args: string): Promise<void> {
134
+ const parsed = parseGoalCommand(args);
135
+ switch (parsed.kind) {
136
+ case 'error':
137
+ if (parsed.severity === 'hint') host.showStatus(parsed.message);
138
+ else host.showError(parsed.message);
139
+ // Give rejected input back so a long hand-typed objective is not
140
+ // lost — unless the user already moved on (a newer draft or an
141
+ // opened panel), which is possible after the async lazy-session
142
+ // creation on the v2 engine.
143
+ if (parsed.restoreInput === true && canRestoreSubmittedInput(host))
144
+ host.restoreInputText(`/goal ${args}`);
145
+ return;
146
+ case 'status':
147
+ await showGoalStatus(host);
148
+ return;
149
+ case 'pause':
150
+ await pauseGoal(host);
151
+ return;
152
+ case 'resume':
153
+ await resumeGoal(host);
154
+ return;
155
+ case 'cancel':
156
+ await cancelGoal(host);
157
+ return;
158
+ case 'next-add':
159
+ await queueNextGoal(host, parsed);
160
+ return;
161
+ case 'next-manage':
162
+ await showGoalQueueManager(host);
163
+ return;
164
+ case 'create':
165
+ await createGoal(host, parsed, args);
166
+ return;
167
+ }
168
+ }
169
+
170
+ function parseNextGoalCommand(tokens: readonly string[]): ParsedGoalCommand {
171
+ if (tokens.length === 2 && tokens[1] === 'manage') return { kind: 'next-manage' };
172
+ let index = 1;
173
+ if (tokens[index] === '--') index += 1;
174
+ const objective = tokens.slice(index).join(' ').trim();
175
+ if (objective.length === 0) {
176
+ return {
177
+ kind: 'error',
178
+ severity: 'hint',
179
+ message:
180
+ 'Provide an upcoming goal objective, e.g. `/goal next Ship feature X`, or use `/goal next manage`.',
181
+ };
182
+ }
183
+ if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) {
184
+ return {
185
+ kind: 'error',
186
+ restoreInput: true,
187
+ message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Put long content in a file and reference the file path.`,
188
+ };
189
+ }
190
+ return { kind: 'next-add', objective };
191
+ }
192
+
193
+ /**
194
+ * Live pre-send check for the main editor: when the typed text is a `/goal`
195
+ * create/next command whose objective already exceeds the length limit,
196
+ * returns a warning to show while typing — before anything is submitted or
197
+ * sent to the server. Returns undefined for non-goal input and for control
198
+ * forms (`status`/`pause`/`resume`/`cancel`/`next manage`).
199
+ */
200
+ export function goalObjectiveLengthWarning(text: string): string | undefined {
201
+ // Submitted text is trimmed before dispatch, so match leading whitespace.
202
+ const trimmed = text.trimStart();
203
+ if (!trimmed.startsWith('/goal')) return undefined;
204
+ const args = trimmed.slice('/goal'.length);
205
+ // parseSlashInput splits the command name at a literal space only, so a
206
+ // newline/tab boundary (`/goal⏎…`, `/goalfoo`) is not the goal command.
207
+ if (args.length > 0 && args.charAt(0) !== ' ') return undefined;
208
+ const objective = extractGoalObjective(args);
209
+ if (objective === undefined || objective.length <= MAX_GOAL_OBJECTIVE_LENGTH) return undefined;
210
+ return `Goal objective is too long (${objective.length}/${MAX_GOAL_OBJECTIVE_LENGTH} characters); put long content in a file and reference the file path.`;
211
+ }
212
+
213
+ /**
214
+ * Mirrors the parse grammar above: strips `next` / `replace` / `--` and
215
+ * returns the objective text, or undefined when the args form a control
216
+ * command that carries no objective.
217
+ */
218
+ function extractGoalObjective(rawArgs: string): string | undefined {
219
+ const args = rawArgs.trim();
220
+ if (args.length === 0 || args === 'status') return undefined;
221
+ const tokens = args.split(/\s+/);
222
+ const first = tokens[0];
223
+ let index = 0;
224
+ if (first === 'next') {
225
+ if (tokens.length === 2 && tokens[1] === 'manage') return undefined;
226
+ index = 1;
227
+ } else {
228
+ if (first !== undefined && CONTROL_SUBCOMMANDS.has(first) && tokens.length === 1) {
229
+ return undefined;
230
+ }
231
+ if (tokens[index] === 'replace') index += 1;
232
+ }
233
+ if (tokens[index] === '--') index += 1;
234
+ return tokens.slice(index).join(' ').trim();
235
+ }
236
+
237
+ async function queueNextGoal(
238
+ host: SlashCommandHost,
239
+ parsed: Extract<ParsedGoalCommand, { kind: 'next-add' }>,
240
+ ): Promise<void> {
241
+ const session = host.requireSession();
242
+ let hasCurrentGoal: boolean;
243
+ try {
244
+ const { goal } = await session.getGoal();
245
+ hasCurrentGoal = goal !== null;
246
+ } catch (error) {
247
+ host.showError(`Failed to inspect current goal: ${formatErrorMessage(error)}`);
248
+ return;
249
+ }
250
+
251
+ if (!hasCurrentGoal && !isBusy(host)) {
252
+ host.showStatus(START_NEXT_GOAL_NOW_MESSAGE);
253
+ await createGoal(
254
+ host,
255
+ { kind: 'create', objective: parsed.objective, replace: false },
256
+ `next ${parsed.objective}`,
257
+ );
258
+ return;
259
+ }
260
+
261
+ try {
262
+ await appendGoalQueueItem(session, { objective: parsed.objective });
263
+ } catch (error) {
264
+ host.showError(formatErrorMessage(error));
265
+ return;
266
+ }
267
+ host.track('goal_queue_append');
268
+ if (!hasCurrentGoal) host.requestQueuedGoalPromotion?.();
269
+ host.state.transcriptContainer.addChild(
270
+ new UpcomingGoalAddedMessageComponent(),
271
+ );
272
+ host.state.ui.requestRender();
273
+ }
274
+
275
+ async function showGoalQueueManager(
276
+ host: SlashCommandHost,
277
+ selectedGoalId?: string,
278
+ ): Promise<void> {
279
+ let snapshot: GoalQueueSnapshot;
280
+ try {
281
+ snapshot = await readGoalQueue(host.requireSession());
282
+ } catch (error) {
283
+ host.showError(`Failed to load upcoming goals: ${formatErrorMessage(error)}`);
284
+ return;
285
+ }
286
+
287
+ host.track('goal_queue_manage');
288
+ host.mountEditorReplacement(
289
+ new GoalQueueManagerComponent({
290
+ goals: snapshot.goals,
291
+ selectedGoalId,
292
+ onAction: async (action) => {
293
+ try {
294
+ return await handleGoalQueueManagerAction(host, action);
295
+ } catch (error) {
296
+ host.showError(`Failed to update upcoming goals: ${formatErrorMessage(error)}`);
297
+ return undefined;
298
+ }
299
+ },
300
+ onCancel: () => {
301
+ host.restoreEditor();
302
+ },
303
+ }),
304
+ );
305
+ }
306
+
307
+ async function handleGoalQueueManagerAction(
308
+ host: SlashCommandHost,
309
+ action: GoalQueueManagerAction,
310
+ ): Promise<GoalQueueSnapshot | void> {
311
+ const session = host.requireSession();
312
+ switch (action.kind) {
313
+ case 'move': {
314
+ const snapshot = await moveGoalQueueItem(session, {
315
+ goalId: action.goalId,
316
+ direction: action.direction,
317
+ });
318
+ host.track('goal_queue_move', { direction: action.direction });
319
+ return snapshot;
320
+ }
321
+ case 'delete': {
322
+ const snapshot = await removeGoalQueueItem(session, { goalId: action.goalId });
323
+ host.track('goal_queue_remove');
324
+ return snapshot;
325
+ }
326
+ case 'edit':
327
+ await showGoalQueueEditDialog(host, action.goalId);
328
+ return;
329
+ }
330
+ }
331
+
332
+ async function showGoalQueueEditDialog(
333
+ host: SlashCommandHost,
334
+ goalId: string,
335
+ ): Promise<void> {
336
+ let snapshot: GoalQueueSnapshot;
337
+ try {
338
+ snapshot = await readGoalQueue(host.requireSession());
339
+ } catch (error) {
340
+ host.showError(`Failed to load upcoming goals: ${formatErrorMessage(error)}`);
341
+ return;
342
+ }
343
+
344
+ const goal = snapshot.goals.find((item) => item.id === goalId);
345
+ if (goal === undefined) {
346
+ host.showStatus('Queued goal no longer exists.');
347
+ await showGoalQueueManager(host);
348
+ return;
349
+ }
350
+
351
+ host.mountEditorReplacement(
352
+ new GoalQueueEditDialogComponent({
353
+ goal,
354
+ onDone: (result) => {
355
+ void handleGoalQueueEditResult(host, result).catch((error: unknown) => {
356
+ host.showError(`Failed to update upcoming goal: ${formatErrorMessage(error)}`);
357
+ });
358
+ },
359
+ }),
360
+ );
361
+ }
362
+
363
+ async function handleGoalQueueEditResult(
364
+ host: SlashCommandHost,
365
+ result: GoalQueueEditResult,
366
+ ): Promise<void> {
367
+ if (result.kind === 'cancel') {
368
+ await showGoalQueueManager(host, result.goalId);
369
+ return;
370
+ }
371
+
372
+ await updateGoalQueueItem(host.requireSession(), {
373
+ goalId: result.goalId,
374
+ objective: result.objective,
375
+ });
376
+ host.track('goal_queue_update');
377
+ await showGoalQueueManager(host, result.goalId);
378
+ }
379
+
380
+ export async function createGoal(
381
+ host: GoalCommandHost,
382
+ parsed: Extract<ParsedGoalCommand, { kind: 'create' }>,
383
+ rawArgs?: string,
384
+ options: GoalStartOptions = {},
385
+ ): Promise<boolean> {
386
+ // A goal must be able to start a model turn; refuse to create one otherwise.
387
+ if (host.state.appState.model.trim().length === 0 || host.session === undefined) {
388
+ host.showError(LLM_NOT_SET_MESSAGE);
389
+ return false;
390
+ }
391
+
392
+ if (
393
+ host.state.appState.permissionMode === 'manual' ||
394
+ host.state.appState.permissionMode === 'yolo'
395
+ ) {
396
+ showGoalStartPermissionPrompt(host, parsed, rawArgs ?? parsed.objective, options);
397
+ return false;
398
+ }
399
+
400
+ return startGoal(host, parsed, options);
401
+ }
402
+
403
+ function showGoalStartPermissionPrompt(
404
+ host: GoalCommandHost,
405
+ parsed: Extract<ParsedGoalCommand, { kind: 'create' }>,
406
+ rawArgs: string,
407
+ options: GoalStartOptions,
408
+ ): void {
409
+ const commandText = `/goal ${rawArgs.trim()}`;
410
+ const cancelStart = (): void => {
411
+ host.restoreInputText(commandText);
412
+ host.showStatus('Goal not started.');
413
+ };
414
+ host.mountEditorReplacement(
415
+ new GoalStartPermissionPromptComponent({
416
+ mode: host.state.appState.permissionMode === 'yolo' ? 'yolo' : 'manual',
417
+ onSelect: (choice) => {
418
+ if (choice === 'cancel') {
419
+ cancelStart();
420
+ return;
421
+ }
422
+ host.restoreEditor();
423
+ void startGoalWithPermission(host, parsed, choice, options);
424
+ },
425
+ onCancel: cancelStart,
426
+ }),
427
+ );
428
+ }
429
+
430
+ async function startGoalWithPermission(
431
+ host: GoalCommandHost,
432
+ parsed: Extract<ParsedGoalCommand, { kind: 'create' }>,
433
+ choice: GoalStartPermissionChoice,
434
+ options: GoalStartOptions,
435
+ ): Promise<void> {
436
+ const previousMode = host.state.appState.permissionMode;
437
+ const switched =
438
+ choice !== previousMode && (choice === 'auto' || choice === 'yolo');
439
+ if (switched) {
440
+ if (!(await setPermissionForGoal(host, choice))) return;
441
+ }
442
+ const started = await startGoal(host, parsed, options);
443
+ // The permission switch only exists to run this goal. If creation fails
444
+ // (e.g. a goal already exists and `replace` was not given), restore the
445
+ // previous mode so the session is not left more permissive than before.
446
+ if (!started && switched) {
447
+ await setPermissionForGoal(host, previousMode);
448
+ return;
449
+ }
450
+ // Announce the switch only once the goal actually starts: shown earlier, a
451
+ // failed creation would leave a stale permissive-mode notice in the
452
+ // transcript even though the rollback above restored the previous mode.
453
+ if (switched) {
454
+ host.showNotice(`Permission mode: ${PERMISSION_MODE_DISPLAY_NAMES[choice]}`);
455
+ host.showStatus(PERMISSION_MODE_DESCRIPTIONS[choice], 'warning');
456
+ }
457
+ }
458
+
459
+ async function setPermissionForGoal(host: GoalCommandHost, mode: PermissionMode): Promise<boolean> {
460
+ try {
461
+ await host.requireSession().setPermission(mode);
462
+ } catch (error) {
463
+ host.showError(`Failed to set permission mode: ${formatErrorMessage(error)}`);
464
+ return false;
465
+ }
466
+ host.setAppState({ permissionMode: mode });
467
+ return true;
468
+ }
469
+
470
+ async function startGoal(
471
+ host: GoalCommandHost,
472
+ parsed: Extract<ParsedGoalCommand, { kind: 'create' }>,
473
+ options: GoalStartOptions,
474
+ ): Promise<boolean> {
475
+ try {
476
+ await host.requireSession().createGoal({
477
+ objective: parsed.objective,
478
+ replace: parsed.replace,
479
+ });
480
+ } catch (error) {
481
+ if (isKimiError(error) && error.code === ErrorCodes.GOAL_ALREADY_EXISTS) {
482
+ host.showError(
483
+ 'A goal is already active. Use `/goal replace <objective>` to replace it, or `/goal status` to inspect it.',
484
+ );
485
+ return false;
486
+ }
487
+ host.showError(formatErrorMessage(error));
488
+ return false;
489
+ }
490
+ if (options.beforeSend !== undefined && !(await options.beforeSend())) {
491
+ return false;
492
+ }
493
+ host.state.transcriptContainer.addChild(new GoalSetMessageComponent());
494
+ host.state.ui.requestRender();
495
+ if (options.sendInput !== undefined) {
496
+ options.sendInput(parsed.objective);
497
+ } else {
498
+ host.sendNormalUserInput(parsed.objective);
499
+ }
500
+ return true;
501
+ }
502
+
503
+ async function pauseGoal(host: SlashCommandHost): Promise<void> {
504
+ const session = host.requireSession();
505
+ try {
506
+ await session.pauseGoal();
507
+ if (isStreaming(host)) await session.cancel();
508
+ } catch (error) {
509
+ if (isKimiError(error) && error.code === ErrorCodes.GOAL_NOT_FOUND) {
510
+ host.showStatus('No goal to pause.');
511
+ return;
512
+ }
513
+ host.showError(formatErrorMessage(error));
514
+ return;
515
+ }
516
+ host.track('goal_pause');
517
+ host.showStatus('Goal paused. Use `/goal resume` to continue.');
518
+ }
519
+
520
+ async function resumeGoal(host: SlashCommandHost): Promise<void> {
521
+ if (host.state.appState.model.trim().length === 0 || host.session === undefined) {
522
+ host.showError(LLM_NOT_SET_MESSAGE);
523
+ return;
524
+ }
525
+
526
+ try {
527
+ await host.requireSession().resumeGoal();
528
+ } catch (error) {
529
+ if (isKimiError(error) && error.code === ErrorCodes.GOAL_NOT_FOUND) {
530
+ host.showStatus('No goal to resume.');
531
+ return;
532
+ }
533
+ host.showError(formatErrorMessage(error));
534
+ return;
535
+ }
536
+ host.track('goal_resume');
537
+ host.sendNormalUserInput(RESUME_GOAL_INPUT);
538
+ }
539
+
540
+ async function cancelGoal(host: SlashCommandHost): Promise<void> {
541
+ const session = host.requireSession();
542
+ try {
543
+ await session.cancelGoal();
544
+ if (isStreaming(host)) await session.cancel();
545
+ } catch (error) {
546
+ if (isKimiError(error) && error.code === ErrorCodes.GOAL_NOT_FOUND) {
547
+ host.showStatus('No goal to cancel.');
548
+ return;
549
+ }
550
+ host.showError(formatErrorMessage(error));
551
+ return;
552
+ }
553
+ host.track('goal_cancel');
554
+ host.showNotice('Goal cancelled.');
555
+ }
556
+
557
+ async function showGoalStatus(host: SlashCommandHost): Promise<void> {
558
+ const { goal } = await host.requireSession().getGoal();
559
+ host.track('goal_status', { status: goal?.status ?? 'none' });
560
+ if (goal === null) {
561
+ host.showStatus('No goal set. Start one with `/goal <objective>`.');
562
+ return;
563
+ }
564
+ host.state.transcriptContainer.addChild(
565
+ new GoalStatusMessageComponent(goal),
566
+ );
567
+ host.state.ui.requestRender();
568
+ }
569
+
570
+ function isStreaming(host: SlashCommandHost): boolean {
571
+ return host.state.appState.streamingPhase !== 'idle';
572
+ }
573
+
574
+ function isBusy(host: SlashCommandHost): boolean {
575
+ return isStreaming(host) || host.state.appState.isCompacting;
576
+ }
apps/kimi-code/src/tui/commands/index.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export * from './experimental-flags';
2
+ export * from './parse';
3
+ export * from './registry';
4
+ export * from './resolve';
5
+ export * from './skills';
6
+ export * from './plugin-commands';
7
+ export * from './types';
8
+
9
+ export { dispatchInput, type SlashCommandHost } from './dispatch';
10
+ export { handleLoginCommand, handleLogoutCommand } from './auth';
11
+ export { handleBtwCommand } from './btw';
12
+ export { handleCopyCommand } from './copy';
13
+ export {
14
+ handleCompactCommand,
15
+ handleEditorCommand,
16
+ handleModelCommand,
17
+ handlePlanCommand,
18
+ handleThemeCommand,
19
+ showExperimentsPanel,
20
+ showModelPicker,
21
+ showPermissionPicker,
22
+ showSettingsSelector,
23
+ } from './config';
24
+ export { handleSwarmCommand } from './swarm';
25
+ export { handleTowerCommand } from './tower';
26
+ export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
27
+ export { handlePluginsCommand } from './plugins';
28
+ export { handleReloadCommand, handleReloadTuiCommand } from './reload';
29
+ export { handleGoalCommand, parseGoalCommand, goalObjectiveLengthWarning } from './goal';
30
+ export { goalArgumentCompletions } from './registry';
31
+ export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session';
32
+ export { handleUndoCommand } from './undo';
33
+ export { handleRemoteControlCommand, handleWebCommand } from './web';
34
+ export {
35
+ promptApiKey,
36
+ promptCatalogProviderSelection,
37
+ promptFeedbackInput,
38
+ promptLogoutProviderSelection,
39
+ promptModelSelectionForCatalog,
40
+ promptModelSelectionForOpenPlatform,
41
+ promptPlatformSelection,
42
+ runModelSelector,
43
+ } from './prompts';
apps/kimi-code/src/tui/commands/info.ts ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { release as osRelease, type as osType } from 'node:os';
2
+
3
+ import type { McpServerInfo, SessionStatus, SessionUsage } from '@moonshot-ai/kimi-code-sdk';
4
+
5
+ import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel';
6
+ import { buildStatusReportLines } from '../components/messages/status-panel';
7
+ import { buildUsageReportLines, UsagePanelComponent, type ManagedUsageReport } from '../components/messages/usage-panel';
8
+ import { isExperimentalFlagEnabled } from './experimental-flags';
9
+ import { quotaUsageRows } from '#/utils/usage/usage-format';
10
+ import {
11
+ FEEDBACK_ISSUE_URL,
12
+ FEEDBACK_STATUS_CANCELLED,
13
+ FEEDBACK_STATUS_FALLBACK,
14
+ FEEDBACK_STATUS_NETWORK_ERROR,
15
+ FEEDBACK_STATUS_NOT_SIGNED_IN,
16
+ FEEDBACK_STATUS_SUBMITTING,
17
+ FEEDBACK_STATUS_SUCCESS,
18
+ FEEDBACK_STATUS_UPLOAD_FAILED,
19
+ FEEDBACK_TELEMETRY_EVENT,
20
+ feedbackIdLine,
21
+ feedbackSessionLine,
22
+ kimiCodeSignupUrl,
23
+ withFeedbackVersionPrefix,
24
+ } from '../constant/feedback';
25
+ import { DEFAULT_OAUTH_PROVIDER_NAME, isManagedUsageProvider } from '../constant/kimi-tui';
26
+ import { submitFeedbackWithAttachments } from '../../feedback/feedback-attachments';
27
+ import { formatErrorMessage } from '../utils/event-payload';
28
+ import { openUrl } from '#/utils/open-url';
29
+ import { promptFeedbackAttachment, promptFeedbackInput } from './prompts';
30
+ import type { SlashCommandHost } from './dispatch';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Feedback
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export async function handleFeedbackCommand(host: SlashCommandHost): Promise<void> {
37
+ const fallback = (reason: string): void => {
38
+ host.showStatus(reason);
39
+ host.showStatus(FEEDBACK_ISSUE_URL);
40
+ openUrl(FEEDBACK_ISSUE_URL);
41
+ };
42
+
43
+ // Gate on the OAuth token rather than the active model's provider: a
44
+ // signed-in user running an API-key model can still submit feedback
45
+ // through the authenticated channel.
46
+ let signedIn = false;
47
+ try {
48
+ const status = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME);
49
+ signedIn = status.providers.some(
50
+ (provider) => provider.providerName === DEFAULT_OAUTH_PROVIDER_NAME && provider.hasToken,
51
+ );
52
+ } catch {
53
+ // The sign-in state is unreadable — keep the feedback entry usable by
54
+ // falling back to GitHub Issues instead of failing the command.
55
+ fallback(FEEDBACK_STATUS_FALLBACK);
56
+ return;
57
+ }
58
+ if (!signedIn) {
59
+ host.showStatus(FEEDBACK_STATUS_NOT_SIGNED_IN);
60
+ host.showStatus(kimiCodeSignupUrl());
61
+ host.showStatus(FEEDBACK_ISSUE_URL);
62
+ return;
63
+ }
64
+
65
+ // Stage 1: collect the free-form feedback text.
66
+ const input = await promptFeedbackInput(host);
67
+ if (input === undefined) {
68
+ host.showStatus(FEEDBACK_STATUS_CANCELLED);
69
+ return;
70
+ }
71
+
72
+ // Stage 2: ask whether to attach diagnostics (logs / codebase).
73
+ const level = await promptFeedbackAttachment(host);
74
+ if (level === undefined) {
75
+ host.showStatus(FEEDBACK_STATUS_CANCELLED);
76
+ return;
77
+ }
78
+
79
+ const version = withFeedbackVersionPrefix(host.state.appState.version);
80
+ const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING);
81
+ // Guarantee the spinner's underlying setInterval is always cleared, even when
82
+ // submitFeedback throws — otherwise the interval (and its per-frame
83
+ // requestRender) leaks for the rest of the session.
84
+ let stopped = false;
85
+ const stopSpinner = (opts: { ok: boolean; label: string }): void => {
86
+ if (stopped) return;
87
+ stopped = true;
88
+ spinner.stop(opts);
89
+ };
90
+ try {
91
+ const res = await host.harness.auth.submitFeedback({
92
+ content: input.value,
93
+ sessionId: host.state.appState.sessionId,
94
+ version,
95
+ os: `${osType()} ${osRelease()}`,
96
+ model: host.state.appState.model.length > 0 ? host.state.appState.model : null,
97
+ });
98
+
99
+ if (res.kind !== 'ok') {
100
+ stopSpinner({ ok: false, label: res.message });
101
+ fallback(FEEDBACK_STATUS_FALLBACK);
102
+ return;
103
+ }
104
+
105
+ // Stage 3: prepare and upload each requested attachment independently.
106
+ // Attachment failures are non-fatal partial failures — the text feedback
107
+ // already exists server-side — so a throw here degrades to the
108
+ // partial-failure status, never to the GitHub fallback in the outer catch.
109
+ let attachmentFailed = false;
110
+ try {
111
+ attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level);
112
+ } catch {
113
+ attachmentFailed = true;
114
+ }
115
+
116
+ stopSpinner({ ok: true, label: FEEDBACK_STATUS_SUCCESS });
117
+ host.showStatus(feedbackSessionLine(host.state.appState.sessionId));
118
+ host.showStatus(feedbackIdLine(res.feedbackId));
119
+ host.track(FEEDBACK_TELEMETRY_EVENT);
120
+ if (attachmentFailed) {
121
+ host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED);
122
+ }
123
+ } catch (error) {
124
+ stopSpinner({ ok: false, label: FEEDBACK_STATUS_NETWORK_ERROR });
125
+ fallback(FEEDBACK_STATUS_FALLBACK);
126
+ throw error;
127
+ }
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Info commands
132
+ // ---------------------------------------------------------------------------
133
+
134
+ interface SessionUsageResult {
135
+ readonly usage?: SessionUsage;
136
+ readonly error?: string;
137
+ }
138
+
139
+ interface RuntimeStatusResult {
140
+ readonly status?: SessionStatus;
141
+ readonly error?: string;
142
+ }
143
+
144
+ interface ManagedUsageResult {
145
+ readonly usage?: ManagedUsageReport;
146
+ readonly error?: string;
147
+ }
148
+
149
+ export async function showUsage(host: SlashCommandHost): Promise<void> {
150
+ const sessionUsage = await loadSessionUsageReport(host);
151
+ const managedUsage = await loadManagedUsageReport(host);
152
+ const reportArgs = {
153
+ sessionUsage: sessionUsage.usage,
154
+ sessionUsageError: sessionUsage.error,
155
+ contextUsage: host.state.appState.contextUsage,
156
+ contextTokens: host.state.appState.contextTokens,
157
+ maxContextTokens: host.state.appState.maxContextTokens,
158
+ managedUsage: managedUsage?.usage,
159
+ managedUsageError: managedUsage?.error,
160
+ };
161
+ const panel = new UsagePanelComponent(() => buildUsageReportLines(reportArgs), 'primary');
162
+ host.state.transcriptContainer.addChild(panel);
163
+ host.state.ui.requestRender();
164
+ }
165
+
166
+ export async function showStatusReport(host: SlashCommandHost): Promise<void> {
167
+ const [runtimeStatus, managedUsage] = await Promise.all([
168
+ loadRuntimeStatusReport(host),
169
+ loadManagedUsageReport(host),
170
+ ]);
171
+ const appState = host.state.appState;
172
+ const reportArgs = {
173
+ version: appState.version,
174
+ model: appState.model,
175
+ workDir: appState.workDir,
176
+ sessionId: appState.sessionId,
177
+ sessionTitle: appState.sessionTitle,
178
+ thinkingEffort: appState.thinkingEffort,
179
+ permissionMode: appState.permissionMode,
180
+ planMode: appState.planMode,
181
+ towerMode: appState.towerMode,
182
+ towerAvailable: isExperimentalFlagEnabled('tower'),
183
+ contextUsage: appState.contextUsage,
184
+ contextTokens: appState.contextTokens,
185
+ maxContextTokens: appState.maxContextTokens,
186
+ availableModels: appState.availableModels,
187
+ status: runtimeStatus.status,
188
+ statusError: runtimeStatus.error,
189
+ managedUsage: managedUsage?.usage,
190
+ managedUsageError: managedUsage?.error,
191
+ };
192
+ const panel = new UsagePanelComponent(() => buildStatusReportLines(reportArgs), 'primary', ' Status ');
193
+ host.state.transcriptContainer.addChild(panel);
194
+ host.state.ui.requestRender();
195
+ }
196
+
197
+ export async function showMcpServers(host: SlashCommandHost): Promise<void> {
198
+ let servers: readonly McpServerInfo[];
199
+ try {
200
+ if (host.session !== undefined) {
201
+ servers = await host.session.listMcpServers();
202
+ } else {
203
+ // v2 session-less: the MCP connection set is workspace-scoped, so it is
204
+ // inspectable before the first session exists.
205
+ servers = await host.harness.listWorkspaceMcpServers(host.state.appState.workDir);
206
+ }
207
+ } catch (error) {
208
+ host.showError(`Failed to load MCP servers: ${formatErrorMessage(error)}`);
209
+ return;
210
+ }
211
+
212
+ const title = servers.length > 0 ? ` MCP (${servers.length}) ` : ' MCP ';
213
+ const panel = new UsagePanelComponent(
214
+ () => buildMcpStatusReportLines({ servers }),
215
+ 'primary',
216
+ title,
217
+ );
218
+ host.state.transcriptContainer.addChild(panel);
219
+ host.state.ui.requestRender();
220
+ }
221
+
222
+ async function loadSessionUsageReport(host: SlashCommandHost): Promise<SessionUsageResult> {
223
+ if (host.session === undefined) return {};
224
+ try {
225
+ return { usage: await host.session.getUsage() };
226
+ } catch (error) {
227
+ return { error: formatErrorMessage(error) };
228
+ }
229
+ }
230
+
231
+ async function loadRuntimeStatusReport(host: SlashCommandHost): Promise<RuntimeStatusResult> {
232
+ if (host.session === undefined) return {};
233
+ try {
234
+ return { status: await host.session.getStatus() };
235
+ } catch (error) {
236
+ return { error: error instanceof Error ? error.message : String(error) };
237
+ }
238
+ }
239
+
240
+ async function loadManagedUsageReport(host: SlashCommandHost): Promise<ManagedUsageResult | undefined> {
241
+ const alias = host.state.appState.model;
242
+ const providerKey = host.state.appState.availableModels[alias]?.provider;
243
+ if (!isManagedUsageProvider(providerKey)) return undefined;
244
+
245
+ let res;
246
+ try {
247
+ res = await host.harness.auth.getManagedUsage(providerKey);
248
+ } catch (error) {
249
+ return { error: formatErrorMessage(error) };
250
+ }
251
+ if (res.kind === 'error') {
252
+ return { error: res.message };
253
+ }
254
+ return { usage: { rows: quotaUsageRows(res.quota), extraUsage: res.quota.extraUsage } };
255
+ }
apps/kimi-code/src/tui/commands/parse.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ParsedSlashInput } from './types';
2
+
3
+ export function parseSlashInput(input: string): ParsedSlashInput | null {
4
+ if (!input.startsWith('/')) return null;
5
+ const trimmed = input.slice(1).trim();
6
+ if (trimmed.length === 0) return null;
7
+ const spaceIdx = trimmed.indexOf(' ');
8
+ const name = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
9
+ const args = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim();
10
+ // Reject file paths (e.g. `/usr/local/bin`), but allow namespaced plugin
11
+ // commands whose name itself contains `/` (e.g. `plugin:frontend/component`).
12
+ if (name.includes('/') && !name.includes(':')) return null;
13
+ return { name, args };
14
+ }
apps/kimi-code/src/tui/commands/plugin-commands.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { PluginCommandDef } from '@moonshot-ai/kimi-code-sdk';
2
+
3
+ import type { KimiSlashCommand } from './types';
4
+
5
+ export interface PluginSlashCommands {
6
+ readonly commands: readonly KimiSlashCommand[];
7
+ /** Maps a namespaced command name (`plugin:command`) to its markdown body. */
8
+ readonly commandMap: ReadonlyMap<string, string>;
9
+ }
10
+
11
+ export function pluginCommandName(pluginId: string, name: string): string {
12
+ return `${pluginId}:${name}`;
13
+ }
14
+
15
+ export function buildPluginSlashCommands(defs: readonly PluginCommandDef[]): PluginSlashCommands {
16
+ const commandMap = new Map<string, string>();
17
+ const commands = defs.map((def) => {
18
+ const commandName = pluginCommandName(def.pluginId, def.name);
19
+ commandMap.set(commandName, def.body);
20
+ return {
21
+ name: commandName,
22
+ aliases: [],
23
+ description: def.description,
24
+ } satisfies KimiSlashCommand;
25
+ });
26
+ return { commands, commandMap };
27
+ }
apps/kimi-code/src/tui/commands/plugins.ts ADDED
@@ -0,0 +1,901 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { homedir as osHomedir } from 'node:os';
2
+ import { isAbsolute, join, resolve } from 'node:path';
3
+
4
+ import {
5
+ log,
6
+ type CapabilityStatus,
7
+ type PluginInfo,
8
+ type PluginSummary,
9
+ type Session,
10
+ } from '@moonshot-ai/kimi-code-sdk';
11
+ import { Markdown, Spacer } from '@moonshot-ai/pi-tui';
12
+
13
+ import {
14
+ PluginInstallTrustConfirmComponent,
15
+ PluginMcpSelectorComponent,
16
+ PluginRemoveConfirmComponent,
17
+ PluginsPanelComponent,
18
+ type PluginInstallTrustConfirmResult,
19
+ type PluginMcpSelection,
20
+ type PluginRemoveConfirmResult,
21
+ type PluginsPanelSelection,
22
+ type PluginsPanelTabId,
23
+ } from '../components/dialogs/plugins-selector';
24
+ import {
25
+ buildPluginsInfoLines,
26
+ buildPluginsListLines,
27
+ } from '../components/messages/plugins-status-panel';
28
+ import { UsagePanelComponent } from '../components/messages/usage-panel';
29
+ import { createMarkdownTheme } from '../theme/pi-tui-theme';
30
+ import { formatErrorMessage } from '../utils/event-payload';
31
+ import { createMarkdownOptions } from '../utils/markdown-options';
32
+ import {
33
+ formatPluginSourceLabel,
34
+ isOfficialPluginInstall,
35
+ isOfficialPluginSource,
36
+ } from '../utils/plugin-source-label';
37
+ import {
38
+ KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV,
39
+ QUOTA_CONSUMING_PLUGIN_IDS,
40
+ } from '#/constant/app';
41
+ import {
42
+ loadPluginMarketplace,
43
+ withBuiltInEntries,
44
+ withMarketplaceLatestVersions,
45
+ type PluginMarketplace,
46
+ type PluginMarketplaceEntry,
47
+ } from '#/utils/plugin-marketplace';
48
+ import { openUrl } from '#/utils/open-url';
49
+ import type { SlashCommandHost } from './dispatch';
50
+
51
+ interface ShowPluginsPickerOptions {
52
+ readonly selectedId?: string;
53
+ readonly pluginHint?: {
54
+ readonly id: string;
55
+ readonly text: string;
56
+ };
57
+ readonly initialTab?: PluginsPanelTabId;
58
+ readonly marketplaceSource?: string;
59
+ }
60
+
61
+ interface PluginMcpServerHint {
62
+ readonly server: string;
63
+ readonly text: string;
64
+ }
65
+
66
+ interface ShowPluginMcpPickerOptions {
67
+ readonly selectedServer?: string;
68
+ readonly serverHint?: PluginMcpServerHint;
69
+ }
70
+
71
+ /** The plugin-management surface `/plugins` operates on. */
72
+ type PluginApi = Pick<
73
+ Session,
74
+ | 'listPlugins'
75
+ | 'installPlugin'
76
+ | 'setPluginEnabled'
77
+ | 'setPluginMcpServerEnabled'
78
+ | 'removePlugin'
79
+ | 'reloadPlugins'
80
+ | 'getPluginInfo'
81
+ >;
82
+
83
+ /**
84
+ * Resolve the plugin-management API. Plugin state is app-global, so a
85
+ * session-less startup still gets a working `/plugins` through the harness's
86
+ * global facade; once a session exists the session's own API is used.
87
+ */
88
+ async function resolvePluginApi(host: SlashCommandHost): Promise<PluginApi> {
89
+ if (host.session !== undefined) return host.session;
90
+ return {
91
+ listPlugins: () => host.harness.listPlugins(),
92
+ installPlugin: (source) => host.harness.installPlugin(source),
93
+ setPluginEnabled: (id, enabled) => host.harness.setPluginEnabled(id, enabled),
94
+ setPluginMcpServerEnabled: (id, server, enabled) =>
95
+ host.harness.setPluginMcpServerEnabled(id, server, enabled),
96
+ removePlugin: (id) => host.harness.removePlugin(id),
97
+ reloadPlugins: () => host.harness.reloadPlugins(),
98
+ getPluginInfo: (id) => host.harness.getPluginInfo(id),
99
+ };
100
+ }
101
+
102
+ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: string): Promise<void> {
103
+ const args = rawArgs.trim().split(/\s+/).filter((part) => part.length > 0);
104
+ const sub = args[0];
105
+ const rest = args.slice(1);
106
+ const session = await resolvePluginApi(host);
107
+
108
+ try {
109
+ if (sub === undefined) {
110
+ await showPluginsPicker(host);
111
+ return;
112
+ }
113
+ if (sub === 'list') {
114
+ await renderPluginsList(host);
115
+ return;
116
+ }
117
+ if (sub === 'install') {
118
+ const source = rest.join(' ').trim();
119
+ if (source.length === 0) {
120
+ host.showError('Usage: /plugins install <local-path-or-zip-url>');
121
+ return;
122
+ }
123
+ if (!(await confirmInstallTrust(host, source, isOfficialPluginSource(source)))) {
124
+ host.showStatus('Install cancelled.');
125
+ return;
126
+ }
127
+ const spinner = host.showProgressSpinner(`Installing plugin from ${truncateForStatus(source)}…`);
128
+ try {
129
+ await installPluginFromSource(host, source);
130
+ spinner.stop({ ok: true, label: `Install finished — see details below.` });
131
+ } catch (error) {
132
+ spinner.stop({ ok: false, label: `Install failed: ${formatErrorMessage(error)}` });
133
+ throw error;
134
+ }
135
+ return;
136
+ }
137
+ if (sub === 'marketplace') {
138
+ const marketplaceSource = rest.join(' ').trim() || undefined;
139
+ await showPluginsPicker(host, {
140
+ // Custom marketplaces often omit `tier`, so their entries land on the
141
+ // Curated tab (entry.tier !== 'official'). Open there when a custom
142
+ // source is supplied; otherwise the default catalog's official entries
143
+ // make Official the right landing tab.
144
+ initialTab: marketplaceSource === undefined ? 'official' : 'third-party',
145
+ marketplaceSource,
146
+ });
147
+ return;
148
+ }
149
+ if (sub === 'info') {
150
+ const id = rest[0];
151
+ if (id === undefined) {
152
+ await showPluginsPicker(host);
153
+ return;
154
+ }
155
+ await renderPluginInfo(host, id);
156
+ return;
157
+ }
158
+ if (sub === 'mcp') {
159
+ const action = rest[0];
160
+ const id = rest[1];
161
+ const server = rest[2];
162
+ if ((action !== 'enable' && action !== 'disable') || id === undefined || server === undefined) {
163
+ host.showError('Usage: /plugins mcp enable|disable <id> <server>');
164
+ return;
165
+ }
166
+ await session.setPluginMcpServerEnabled(id, server, action === 'enable');
167
+ host.showStatus(
168
+ `${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /reload or /new to apply.`,
169
+ );
170
+ return;
171
+ }
172
+ if (sub === 'enable' || sub === 'disable') {
173
+ const id = rest[0];
174
+ if (id === undefined) {
175
+ await showPluginsPicker(host);
176
+ return;
177
+ }
178
+ await applyPluginEnabled(host, id, sub === 'enable');
179
+ return;
180
+ }
181
+ if (sub === 'remove') {
182
+ const id = rest[0];
183
+ if (id === undefined) {
184
+ host.showError('Usage: /plugins remove <id>');
185
+ return;
186
+ }
187
+ if (!(await confirmRemovePlugin(host, id))) {
188
+ host.showStatus(`Remove cancelled: ${id}.`);
189
+ return;
190
+ }
191
+ await removePlugin(host, id);
192
+ return;
193
+ }
194
+ if (sub === 'reload') {
195
+ await reloadPlugins(host);
196
+ return;
197
+ }
198
+ const plugins = await session.listPlugins();
199
+ if (plugins.some((plugin) => plugin.id === sub)) {
200
+ await renderPluginInfo(host, sub);
201
+ return;
202
+ }
203
+ host.showError(`Unknown /plugins action: ${sub}. Run /plugins to choose interactively.`);
204
+ } catch (error) {
205
+ host.showError(`/plugins ${sub ?? ''} failed: ${formatErrorMessage(error)}`);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Resolve the capability API. Like plugin state, capability state is
211
+ * app-global on the v2 engine, so a session-less startup still gets
212
+ * readiness and installs through the harness's global facade; with a live
213
+ * session the session's own API is used.
214
+ */
215
+ type CapabilityApi = Pick<Session, 'listCapabilities' | 'getCapability' | 'installCapability'>;
216
+
217
+ async function resolveCapabilityApi(host: SlashCommandHost): Promise<CapabilityApi> {
218
+ if (host.session !== undefined) return host.session;
219
+ return host.harness;
220
+ }
221
+
222
+ function logCapabilityStatus(capability: CapabilityStatus, installed?: boolean): void {
223
+ const payload = {
224
+ capabilityId: capability.id,
225
+ pluginId: capability.pluginId,
226
+ installed,
227
+ supported: capability.supported,
228
+ state: capability.state,
229
+ version: capability.version,
230
+ install: capability.install,
231
+ steps: capability.steps,
232
+ };
233
+ const hasStepIssues = capability.steps.some((step) => step.state !== 'ok');
234
+ if (
235
+ capability.install.error !== undefined ||
236
+ (installed !== false && hasStepIssues)
237
+ ) {
238
+ log.warn('capability needs attention', payload);
239
+ } else {
240
+ log.info('capability status', payload);
241
+ }
242
+ }
243
+
244
+ async function showPluginsPicker(
245
+ host: SlashCommandHost,
246
+ options?: ShowPluginsPickerOptions,
247
+ ): Promise<void> {
248
+ let plugins: readonly PluginSummary[];
249
+ try {
250
+ plugins = await (await resolvePluginApi(host)).listPlugins();
251
+ } catch (error) {
252
+ host.showError(`Failed to load plugins: ${formatErrorMessage(error)}`);
253
+ return;
254
+ }
255
+
256
+ let capabilities: readonly CapabilityStatus[] = [];
257
+ try {
258
+ capabilities = await (await resolveCapabilityApi(host)).listCapabilities();
259
+ } catch (error) {
260
+ log.warn('capability status unavailable', { error });
261
+ }
262
+
263
+ const installedIds = new Set(plugins.map((plugin) => plugin.id));
264
+ for (const capability of capabilities) {
265
+ logCapabilityStatus(capability, installedIds.has(capability.pluginId ?? capability.id));
266
+ }
267
+
268
+ const panel = new PluginsPanelComponent({
269
+ installed: plugins,
270
+ installedIds,
271
+ capabilities,
272
+ catalogIsDefault: isDefaultMarketplaceCatalog(options?.marketplaceSource),
273
+ initialTab: options?.initialTab,
274
+ selectedId: options?.selectedId,
275
+ pluginHint: options?.pluginHint,
276
+ onSelect: (selection) => {
277
+ // Each branch of the handler either mounts the next view or restores the
278
+ // editor itself, so do not pre-restore here — that would flash the editor
279
+ // for in-place actions like toggling a plugin.
280
+ void handlePluginsPanelSelection(host, panel, selection).catch((error: unknown) => {
281
+ host.showError(`/plugins failed: ${formatErrorMessage(error)}`);
282
+ });
283
+ },
284
+ onCancel: () => {
285
+ host.restoreEditor();
286
+ },
287
+ // Every tab except Custom needs the catalog: Official/Curated list it,
288
+ // and Installed uses it to show update badges. The Installed/Custom tabs
289
+ // keep working even when the marketplace is unreachable (badges simply stay
290
+ // hidden until data arrives).
291
+ onRequestMarketplace: () => {
292
+ void loadMarketplaceCatalog(host, panel, options?.marketplaceSource, capabilities);
293
+ },
294
+ });
295
+ host.mountEditorReplacement(panel);
296
+ // Kick off the catalog fetch for any tab that needs it: Installed uses it for
297
+ // update badges, Official/Curated list it. Custom never reads the catalog,
298
+ // so skip the fetch there. Done here (after `panel` is initialized) rather
299
+ // than inside the component constructor, because the callback above closes
300
+ // over `panel`.
301
+ if (options?.initialTab !== 'custom') {
302
+ panel.setMarketplaceLoading();
303
+ void loadMarketplaceCatalog(host, panel, options?.marketplaceSource, capabilities);
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Adapt a capability from the engine's registry into a catalog row. The
309
+ * engine is the single source of truth for what the built-in capabilities
310
+ * are — the CLI only renders them. The `capability:<id>` source marker
311
+ * routes installs through the capability flow (never a plain plugin
312
+ * install), so the row needs no real URL.
313
+ */
314
+ function capabilityMarketplaceEntry(capability: CapabilityStatus): PluginMarketplaceEntry {
315
+ return {
316
+ id: capability.id,
317
+ displayName: capability.displayName,
318
+ description: capability.description,
319
+ tier: 'official',
320
+ source: `capability:${capability.id}`,
321
+ builtIn: true,
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Injection is part of the DEFAULT catalog experience only: any explicit
327
+ * replacement (the slash-command source or a user-set env override) opts out
328
+ * wholesale. The dev marketplace server started by scripts/dev.mjs serves
329
+ * this repo's own catalog and marks itself, so it still counts as default.
330
+ */
331
+ function isDefaultMarketplaceCatalog(
332
+ source: string | undefined,
333
+ env: NodeJS.ProcessEnv = process.env,
334
+ ): boolean {
335
+ if (source !== undefined) return false;
336
+ if (env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] === undefined) return true;
337
+ return env['KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER'] === '1';
338
+ }
339
+
340
+ async function loadMarketplaceCatalog(
341
+ host: SlashCommandHost,
342
+ panel: PluginsPanelComponent,
343
+ source: string | undefined,
344
+ capabilities: readonly CapabilityStatus[],
345
+ ): Promise<void> {
346
+ const builtInEntries = isDefaultMarketplaceCatalog(source)
347
+ ? capabilities.map(capabilityMarketplaceEntry)
348
+ : undefined;
349
+ let marketplace: PluginMarketplace;
350
+ let catalog: PluginMarketplace;
351
+ try {
352
+ // Phase 1: render the catalog as soon as it arrives. Version lookups
353
+ // (GitHub releases/latest round trips) must not gate the first paint.
354
+ // Keep the raw parsed catalog for phase 2: injecting built-ins first
355
+ // would mask the matching catalog entries' GitHub sources behind
356
+ // `capability:<id>` rows, making their versions unresolvable.
357
+ catalog = await loadPluginMarketplace({
358
+ workDir: host.state.appState.workDir,
359
+ source,
360
+ skipLatestVersions: true,
361
+ });
362
+ marketplace =
363
+ builtInEntries !== undefined ? withBuiltInEntries(catalog, builtInEntries) : catalog;
364
+ panel.setMarketplace(marketplace.plugins, marketplace.source);
365
+ host.state.ui.requestRender();
366
+ } catch (error) {
367
+ // Any phase-1 failure (unreachable OR malformed catalog) surfaces as an
368
+ // error: the panel keeps built-in capability rows installable in the
369
+ // Official tab while the error is shown, and a broken catalog must not
370
+ // be masked as a successfully loaded, built-ins-only marketplace.
371
+ panel.setMarketplaceError(formatErrorMessage(error));
372
+ host.state.ui.requestRender();
373
+ return;
374
+ }
375
+ try {
376
+ // Phase 2: resolve latest versions in the background (against the raw
377
+ // catalog), re-apply the built-in injection so resolved versions flow
378
+ // onto capability rows, then refresh so update badges appear. Failures
379
+ // degrade to badge-less rows and never clobber the rendered list.
380
+ const enrichedCatalog = await withMarketplaceLatestVersions(catalog);
381
+ const enriched =
382
+ builtInEntries !== undefined
383
+ ? withBuiltInEntries(enrichedCatalog, builtInEntries)
384
+ : enrichedCatalog;
385
+ panel.setMarketplace(enriched.plugins, enriched.source);
386
+ } catch (error) {
387
+ log.warn('marketplace version lookup failed', { error });
388
+ }
389
+ host.state.ui.requestRender();
390
+ }
391
+
392
+ async function showPluginMcpPicker(
393
+ host: SlashCommandHost,
394
+ id: string,
395
+ options?: ShowPluginMcpPickerOptions,
396
+ ): Promise<void> {
397
+ let info: PluginInfo;
398
+ try {
399
+ info = await (await resolvePluginApi(host)).getPluginInfo(id);
400
+ } catch (error) {
401
+ host.showError(`Failed to load plugin MCP servers: ${formatErrorMessage(error)}`);
402
+ return;
403
+ }
404
+
405
+ host.mountEditorReplacement(
406
+ new PluginMcpSelectorComponent({
407
+ info,
408
+ selectedServer: options?.selectedServer,
409
+ serverHint: options?.serverHint,
410
+ onSelect: (selection) => {
411
+ // Every MCP action re-mounts a picker, so let the handler do the
412
+ // mounting — pre-restoring the editor here would flash on toggle.
413
+ void handlePluginMcpSelection(host, selection).catch((error: unknown) => {
414
+ host.showError(`/plugins mcp failed: ${formatErrorMessage(error)}`);
415
+ });
416
+ },
417
+ onCancel: () => {
418
+ host.restoreEditor();
419
+ void showPluginsPicker(host, { selectedId: id });
420
+ },
421
+ }),
422
+ );
423
+ }
424
+
425
+ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise<boolean> {
426
+ let displayName = id;
427
+ try {
428
+ displayName = (await (await resolvePluginApi(host)).getPluginInfo(id)).displayName;
429
+ } catch {
430
+ // Keep the confirmation available even when plugin details cannot be loaded.
431
+ }
432
+
433
+ return new Promise((resolveConfirmed) => {
434
+ host.mountEditorReplacement(
435
+ new PluginRemoveConfirmComponent({
436
+ id,
437
+ displayName,
438
+ onDone: (result: PluginRemoveConfirmResult) => {
439
+ host.restoreEditor();
440
+ resolveConfirmed(result.kind === 'confirm');
441
+ },
442
+ }),
443
+ );
444
+ });
445
+ }
446
+
447
+ async function confirmInstallTrust(
448
+ host: SlashCommandHost,
449
+ label: string,
450
+ official: boolean,
451
+ ): Promise<boolean> {
452
+ // Kimi-built official plugins are trusted implicitly; anything else requires
453
+ // the user to explicitly opt in via the trust prompt.
454
+ if (official) return true;
455
+ return new Promise((resolveConfirmed) => {
456
+ host.mountEditorReplacement(
457
+ new PluginInstallTrustConfirmComponent({
458
+ label,
459
+ onDone: (result: PluginInstallTrustConfirmResult) => {
460
+ host.restoreEditor();
461
+ resolveConfirmed(result.kind === 'confirm');
462
+ },
463
+ }),
464
+ );
465
+ });
466
+ }
467
+
468
+ const CAPABILITY_POLL_INTERVAL_MS = 700;
469
+ const CAPABILITY_POLL_ATTEMPTS = 260; // ~3 minutes of runtime setup budget
470
+
471
+ /** Client-injected v2 entries install their runtime and plugin together.
472
+ * Trust keys on the parser-proof `builtIn` flag — the `capability:<id>`
473
+ * source string stays purely diagnostic. */
474
+ function isCapabilityEntry(entry: PluginMarketplaceEntry): boolean {
475
+ return entry.builtIn === true;
476
+ }
477
+
478
+ /**
479
+ * Closed-set plugin id check for the post-remove note. What must not happen
480
+ * is answering membership by running `listCapabilities()`, which fires every
481
+ * entry's detector (seconds of probes) just to print one hint line.
482
+ */
483
+ function isCapabilityPluginId(id: string): boolean {
484
+ return id === 'kimi-cu' || id === 'kimi-cu-win' || id === 'kimi-webbridge';
485
+ }
486
+
487
+ /** Poll a background capability install until it settles (or we run out of budget). */
488
+ async function pollCapabilityInstall(
489
+ host: SlashCommandHost,
490
+ id: string,
491
+ ): Promise<CapabilityStatus | undefined> {
492
+ const api = await resolveCapabilityApi(host);
493
+ let previousProgress = '';
494
+ for (let attempt = 0; attempt < CAPABILITY_POLL_ATTEMPTS; attempt += 1) {
495
+ await new Promise((resolve) => {
496
+ setTimeout(resolve, CAPABILITY_POLL_INTERVAL_MS);
497
+ });
498
+ const status = await api.getCapability(id);
499
+ if (!status.install.running) return status;
500
+ const progress = `${status.install.step ?? ''}:${status.install.percent ?? ''}`;
501
+ if (progress !== previousProgress) {
502
+ previousProgress = progress;
503
+ log.info('capability install progress', {
504
+ capabilityId: id,
505
+ step: status.install.step,
506
+ percent: status.install.percent,
507
+ });
508
+ }
509
+ }
510
+ return undefined;
511
+ }
512
+
513
+ export const __pluginsCommandInternals = {
514
+ isCapabilityEntry,
515
+ installCapabilityFromPanel,
516
+ isDefaultMarketplaceCatalog,
517
+ pollCapabilityInstall,
518
+ removePlugin,
519
+ };
520
+
521
+ async function installCapabilityFromPanel(
522
+ host: SlashCommandHost,
523
+ panel: PluginsPanelComponent,
524
+ entry: PluginMarketplaceEntry,
525
+ ): Promise<void> {
526
+ const label = entry.displayName;
527
+ // Capability entries are official by construction; the trust prompt is
528
+ // reserved for unreviewed third-party plugins.
529
+ panel.setInstalling(truncateForStatus(label));
530
+ host.state.ui.requestRender();
531
+ const api = await resolveCapabilityApi(host);
532
+ log.info('capability install requested', { capabilityId: entry.id });
533
+ try {
534
+ // An install already running (started from another panel or client) is
535
+ // followed, not restarted — the service rejects duplicate starts even
536
+ // though the original is healthy.
537
+ const alreadyRunning = await api
538
+ .getCapability(entry.id)
539
+ .then((status) => status.install.running, () => false);
540
+ if (!alreadyRunning) {
541
+ await api.installCapability(entry.id);
542
+ } else {
543
+ log.info('following running capability install', { capabilityId: entry.id });
544
+ }
545
+ } catch (error) {
546
+ log.warn('capability install failed to start', { capabilityId: entry.id, error });
547
+ panel.clearInstalling();
548
+ host.state.ui.requestRender();
549
+ host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`);
550
+ host.restoreEditor();
551
+ return;
552
+ }
553
+ let result: CapabilityStatus | undefined;
554
+ try {
555
+ result = await pollCapabilityInstall(host, entry.id);
556
+ } catch (error) {
557
+ log.warn('capability install polling failed', { capabilityId: entry.id, error });
558
+ result = undefined;
559
+ }
560
+ panel.clearInstalling();
561
+ // Close the panel so the result lines land in the transcript, matching the
562
+ // plain plugin install flow.
563
+ host.restoreEditor();
564
+ if (result === undefined) {
565
+ host.showStatus(`${label} installation is still running in the background.`);
566
+ return;
567
+ }
568
+ logCapabilityStatus(result);
569
+ if (result.install.error !== undefined) {
570
+ host.showError(`${label} installation failed: ${result.install.error}`);
571
+ host.showStatus('Fix the reported error, then install again from /plugins.', 'warning');
572
+ return;
573
+ }
574
+ if (result.state !== 'ready') {
575
+ const permissionsRequired =
576
+ entry.id === 'kimi-cu' &&
577
+ result.steps.some((step) => step.id === 'permissions' && step.state !== 'ok');
578
+ if (permissionsRequired) {
579
+ host.showStatus(
580
+ 'Grant Accessibility and Screen Recording in System Settings → Privacy & Security.',
581
+ 'warning',
582
+ );
583
+ } else {
584
+ host.showError(
585
+ `${label} installation did not complete. Check the logs and install again from /plugins.`,
586
+ );
587
+ }
588
+ host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
589
+ return;
590
+ }
591
+ if (entry.id === 'kimi-webbridge') {
592
+ host.showNotice(`${label} is installed.`);
593
+ host.state.transcriptContainer.addChild(new Spacer(1));
594
+ host.state.transcriptContainer.addChild(
595
+ new Markdown(WEBBRIDGE_POST_INSTALL_MARKDOWN, 2, 0, createMarkdownTheme(), undefined, createMarkdownOptions()),
596
+ );
597
+ host.state.ui.requestRender();
598
+ return;
599
+ }
600
+ host.showStatus(`${label} is installed.`);
601
+ host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
602
+ }
603
+
604
+ async function installFromPanel(
605
+ host: SlashCommandHost,
606
+ panel: PluginsPanelComponent,
607
+ source: string,
608
+ label: string,
609
+ official: boolean,
610
+ ): Promise<void> {
611
+ if (!(await confirmInstallTrust(host, label, official))) {
612
+ host.showStatus(`Install cancelled: ${label}.`);
613
+ host.restoreEditor();
614
+ return;
615
+ }
616
+ // Official installs keep the panel mounted and show the inline installing
617
+ // state; third-party installs pass through a trust prompt that replaces the
618
+ // panel, so fall back to a transcript status for those.
619
+ if (official) {
620
+ panel.setInstalling(truncateForStatus(label));
621
+ } else {
622
+ host.showStatus(`Installing or updating ${label} from marketplace…`);
623
+ }
624
+ host.state.ui.requestRender();
625
+ try {
626
+ await installPluginFromSource(host, source);
627
+ } catch (error) {
628
+ if (official) {
629
+ panel.clearInstalling();
630
+ host.state.ui.requestRender();
631
+ } else {
632
+ // The trust prompt replaced the panel; re-mount it so the user can retry
633
+ // instead of being dropped back at the editor.
634
+ host.mountEditorReplacement(panel);
635
+ }
636
+ host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`);
637
+ return;
638
+ }
639
+ // Close the panel after installing so the result status and the
640
+ // "/reload or /new" tip are visible in the transcript.
641
+ host.restoreEditor();
642
+ }
643
+
644
+ async function applyPluginEnabled(
645
+ host: SlashCommandHost,
646
+ id: string,
647
+ enabled: boolean,
648
+ showStatus = true,
649
+ ): Promise<string> {
650
+ const session = await resolvePluginApi(host);
651
+ await session.setPluginEnabled(id, enabled);
652
+ let info: PluginInfo | undefined;
653
+ try {
654
+ info = await session.getPluginInfo(id);
655
+ } catch {
656
+ info = undefined;
657
+ }
658
+ const mcpHint =
659
+ enabled && info !== undefined && info.mcpServerCount > info.enabledMcpServerCount
660
+ ? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} <server>.`
661
+ : '';
662
+ if (showStatus) {
663
+ host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /reload or /new to apply.${mcpHint}`);
664
+ }
665
+ const inlineMcpHint = mcpHint.length > 0 ? ' · MCP servers disabled' : '';
666
+ return `${pluginInlineChangeHint()}${inlineMcpHint}`;
667
+ }
668
+
669
+ async function handlePluginsPanelSelection(
670
+ host: SlashCommandHost,
671
+ panel: PluginsPanelComponent,
672
+ selection: PluginsPanelSelection,
673
+ ): Promise<void> {
674
+ switch (selection.kind) {
675
+ case 'toggle': {
676
+ const hint = await applyPluginEnabled(host, selection.id, selection.enabled, false);
677
+ await showPluginsPicker(host, {
678
+ initialTab: 'installed',
679
+ selectedId: selection.id,
680
+ pluginHint: { id: selection.id, text: hint },
681
+ });
682
+ return;
683
+ }
684
+ case 'remove':
685
+ if (!(await confirmRemovePlugin(host, selection.id))) {
686
+ host.showStatus(`Remove cancelled: ${selection.id}.`);
687
+ await showPluginsPicker(host, { initialTab: 'installed', selectedId: selection.id });
688
+ return;
689
+ }
690
+ await removePlugin(host, selection.id);
691
+ await showPluginsPicker(host, { initialTab: 'installed' });
692
+ return;
693
+ case 'mcp':
694
+ await showPluginMcpPicker(host, selection.id);
695
+ return;
696
+ case 'details':
697
+ host.restoreEditor();
698
+ await renderPluginInfo(host, selection.id);
699
+ return;
700
+ case 'reload':
701
+ await reloadPlugins(host);
702
+ await showPluginsPicker(host, { initialTab: 'installed' });
703
+ return;
704
+ case 'install':
705
+ if (isCapabilityEntry(selection.entry)) {
706
+ await installCapabilityFromPanel(host, panel, selection.entry);
707
+ return;
708
+ }
709
+ await installFromPanel(
710
+ host,
711
+ panel,
712
+ selection.entry.source,
713
+ selection.entry.displayName,
714
+ isOfficialPluginSource(selection.entry.source),
715
+ );
716
+ return;
717
+ case 'install-source':
718
+ await installFromPanel(
719
+ host,
720
+ panel,
721
+ selection.source,
722
+ selection.source,
723
+ isOfficialPluginSource(selection.source),
724
+ );
725
+ return;
726
+ case 'open-url':
727
+ host.restoreEditor();
728
+ openUrl(selection.url);
729
+ host.showStatus(`Opening the ${selection.label} page in your browser…`, 'success');
730
+ host.showStatus(`If it did not open, visit ${selection.url}`);
731
+ return;
732
+ }
733
+ }
734
+
735
+ async function handlePluginMcpSelection(
736
+ host: SlashCommandHost,
737
+ selection: PluginMcpSelection,
738
+ ): Promise<void> {
739
+ switch (selection.kind) {
740
+ case 'toggle':
741
+ await (
742
+ await resolvePluginApi(host)
743
+ ).setPluginMcpServerEnabled(selection.pluginId, selection.server, selection.enabled);
744
+ await showPluginMcpPicker(host, selection.pluginId, {
745
+ selectedServer: selection.server,
746
+ serverHint: {
747
+ server: selection.server,
748
+ text: pluginInlineChangeHint(),
749
+ },
750
+ });
751
+ return;
752
+ case 'back':
753
+ await showPluginsPicker(host, { selectedId: selection.pluginId });
754
+ return;
755
+ }
756
+ }
757
+
758
+ async function removePlugin(host: SlashCommandHost, id: string): Promise<void> {
759
+ await (await resolvePluginApi(host)).removePlugin(id);
760
+ host.showStatus(`Removed ${id}.`);
761
+ if (isCapabilityPluginId(id)) {
762
+ host.showStatus(
763
+ '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.',
764
+ );
765
+ return;
766
+ }
767
+ host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
768
+ }
769
+
770
+ async function renderPluginsList(
771
+ host: SlashCommandHost,
772
+ plugins?: readonly PluginSummary[],
773
+ ): Promise<void> {
774
+ const currentPlugins = plugins ?? (await (await resolvePluginApi(host)).listPlugins());
775
+ const title = ` Plugins (${currentPlugins.length}) `;
776
+ const panel = new UsagePanelComponent(
777
+ () => buildPluginsListLines({ plugins: currentPlugins }),
778
+ 'primary',
779
+ title,
780
+ );
781
+ host.state.transcriptContainer.addChild(panel);
782
+ host.state.ui.requestRender();
783
+ }
784
+
785
+ async function renderPluginInfo(host: SlashCommandHost, id: string): Promise<void> {
786
+ const info = await (await resolvePluginApi(host)).getPluginInfo(id);
787
+ const panel = new UsagePanelComponent(
788
+ () => buildPluginsInfoLines({ info }),
789
+ 'primary',
790
+ ` ${info.id} `,
791
+ );
792
+ host.state.transcriptContainer.addChild(panel);
793
+ host.state.ui.requestRender();
794
+ }
795
+
796
+ async function installPluginFromSource(
797
+ host: SlashCommandHost,
798
+ source: string,
799
+ ): Promise<void> {
800
+ const session = await resolvePluginApi(host);
801
+ const beforeList = await session.listPlugins();
802
+ const summary = await session.installPlugin(
803
+ resolvePluginInstallSource(source, host.state.appState.workDir),
804
+ );
805
+ showPluginInstallResult(host, beforeList, summary);
806
+ }
807
+
808
+ const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.';
809
+
810
+ const WEBBRIDGE_POST_INSTALL_MARKDOWN = [
811
+ '*Two steps left to use Kimi Browser Extension:*',
812
+ '1. Install the browser extension',
813
+ '',
814
+ ' - [Chrome Web Store](https://chromewebstore.google.com/detail/kimi-webbridge/fldmhceldgbpfpkbgopacenieobmligc)',
815
+ ' - [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/kimi-webbridge/bnlffdbcfnanfbknnlaflhlhkocccckg)',
816
+ ' - [Manual installation guide](https://www.kimi.com/code/docs/kimi-code-cli/customization/plugins.html#install-the-browser-extension)',
817
+ '',
818
+ '2. Run `/reload` or `/new` to apply it.',
819
+ ].join('\n');
820
+
821
+ const PLUGIN_QUOTA_NOTE = 'Note: This plugin consumes your quota.';
822
+
823
+ function showPluginInstallResult(
824
+ host: SlashCommandHost,
825
+ beforeList: readonly PluginSummary[],
826
+ summary: PluginSummary,
827
+ ): void {
828
+ const previous = beforeList.find((entry) => entry.id === summary.id);
829
+ const serverWord = summary.mcpServerCount === 1 ? 'server' : 'servers';
830
+ const mcpHint =
831
+ summary.mcpServerCount > 0
832
+ ? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.`
833
+ : '';
834
+ const action = describeInstallAction(previous, summary);
835
+ host.showStatus(`${action} (${summary.id}).${mcpHint}`);
836
+ host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
837
+ // Gate on provenance, not just the id: a local/GitHub fork whose manifest
838
+ // reuses a billed plugin's id is not the official quota-consuming build.
839
+ if (QUOTA_CONSUMING_PLUGIN_IDS.includes(summary.id) && isOfficialPluginInstall(summary)) {
840
+ host.showStatus(PLUGIN_QUOTA_NOTE, 'warning');
841
+ }
842
+ }
843
+
844
+ function describeInstallAction(
845
+ previous: PluginSummary | undefined,
846
+ next: PluginSummary,
847
+ ): string {
848
+ const sourceLabel = formatPluginSourceLabel(next);
849
+ const versionFromTo = (prev?: string, cur?: string): string => {
850
+ if (prev === undefined || prev === cur) return cur === undefined ? '' : ` ${cur}`;
851
+ return ` ${prev} → ${cur ?? '-'}`;
852
+ };
853
+ if (previous === undefined) {
854
+ return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} ${sourcePhrase(sourceLabel)}`;
855
+ }
856
+ if (sourceIdentity(previous) !== sourceIdentity(next)) {
857
+ const prevSourceLabel = formatPluginSourceLabel(previous);
858
+ return `Migrated ${next.displayName}: ${prevSourceLabel} → ${sourceLabel}${versionFromTo(previous.version, next.version)}`;
859
+ }
860
+ return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} ${sourcePhrase(sourceLabel)}`;
861
+ }
862
+
863
+ // formatPluginSourceLabel already prefixes zip-url hosts with "via", so adding
864
+ // "from" would read as "from via <host>". Only prepend "from" otherwise.
865
+ function sourcePhrase(sourceLabel: string): string {
866
+ return sourceLabel.startsWith('via ') ? sourceLabel : `from ${sourceLabel}`;
867
+ }
868
+
869
+ function sourceIdentity(plugin: PluginSummary): string {
870
+ if (plugin.source === 'github' && plugin.github !== undefined) {
871
+ return `github:${plugin.github.owner}/${plugin.github.repo}`;
872
+ }
873
+ return plugin.source;
874
+ }
875
+
876
+ function truncateForStatus(input: string): string {
877
+ const max = 80;
878
+ return input.length > max ? `${input.slice(0, max - 1)}…` : input;
879
+ }
880
+
881
+ async function reloadPlugins(host: SlashCommandHost): Promise<void> {
882
+ const summary = await (await resolvePluginApi(host)).reloadPlugins();
883
+ const line = `Reload: +${summary.added.length} -${summary.removed.length}` +
884
+ (summary.errors.length > 0 ? ` (${summary.errors.length} errors)` : '');
885
+ host.showStatus(line);
886
+ // Rebuild the TUI's plugin slash-command list from the reloaded service so
887
+ // newly added/enabled commands resolve in this session-less UI right away.
888
+ await host.refreshPluginCommands(host.session);
889
+ }
890
+
891
+ function resolvePluginInstallSource(source: string, workDir: string): string {
892
+ const trimmed = source.trim();
893
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed;
894
+ if (trimmed === '~') return osHomedir();
895
+ if (trimmed.startsWith('~/')) return join(osHomedir(), trimmed.slice(2));
896
+ return isAbsolute(trimmed) ? trimmed : resolve(workDir, trimmed);
897
+ }
898
+
899
+ function pluginInlineChangeHint(): string {
900
+ return 'run /reload or /new to apply';
901
+ }
apps/kimi-code/src/tui/commands/prompts.ts ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ catalogModelToAlias,
3
+ resolveCatalogImport,
4
+ type Catalog,
5
+ type CatalogModel,
6
+ type ModelAlias,
7
+ type ThinkingEffort,
8
+ } from '@moonshot-ai/kimi-code-sdk';
9
+ import { capabilitiesForModel } from '@moonshot-ai/kimi-code-oauth';
10
+ import type {
11
+ ManagedKimiCodeModelInfo,
12
+ OpenPlatformDefinition,
13
+ } from '@moonshot-ai/kimi-code-oauth';
14
+
15
+ import { ApiKeyInputDialogComponent, type ApiKeyInputResult } from '../components/dialogs/api-key-input-dialog';
16
+ import { ChoicePickerComponent, type ChoiceOption } from '../components/dialogs/choice-picker';
17
+ import { FeedbackInputDialogComponent, type FeedbackInputDialogResult } from '../components/dialogs/feedback-input-dialog';
18
+ import { ModelSelectorComponent } from '../components/dialogs/model-selector';
19
+ import { PlatformSelectorComponent } from '../components/dialogs/platform-selector';
20
+ import type { SlashCommandHost } from './dispatch';
21
+
22
+ export function promptPlatformSelection(host: SlashCommandHost): Promise<string | undefined> {
23
+ return new Promise((resolve) => {
24
+ const selector = new PlatformSelectorComponent({
25
+ onSelect: (platformId) => {
26
+ host.restoreEditor();
27
+ resolve(platformId);
28
+ },
29
+ onCancel: () => {
30
+ host.restoreEditor();
31
+ resolve(undefined);
32
+ },
33
+ });
34
+ host.mountEditorReplacement(selector);
35
+ });
36
+ }
37
+
38
+ export function promptLogoutProviderSelection(
39
+ host: SlashCommandHost,
40
+ options: readonly ChoiceOption[],
41
+ currentValue: string | undefined,
42
+ ): Promise<string | undefined> {
43
+ return new Promise((resolve) => {
44
+ const picker = new ChoicePickerComponent({
45
+ title: 'Select a provider to log out',
46
+ options,
47
+ currentValue,
48
+ onSelect: (value) => {
49
+ host.restoreEditor();
50
+ resolve(value);
51
+ },
52
+ onCancel: () => {
53
+ host.restoreEditor();
54
+ resolve(undefined);
55
+ },
56
+ });
57
+ host.mountEditorReplacement(picker);
58
+ });
59
+ }
60
+
61
+ export interface FeedbackPromptResult {
62
+ readonly value: string;
63
+ }
64
+
65
+ export function promptFeedbackInput(host: SlashCommandHost): Promise<FeedbackPromptResult | undefined> {
66
+ return new Promise((resolve) => {
67
+ const dialog = new FeedbackInputDialogComponent((result: FeedbackInputDialogResult) => {
68
+ host.restoreEditor();
69
+ resolve(result.kind === 'ok' ? { value: result.value } : undefined);
70
+ });
71
+ host.mountEditorReplacement(dialog);
72
+ });
73
+ }
74
+
75
+ export type FeedbackAttachmentLevel = 'none' | 'logs' | 'logs+codebase';
76
+
77
+ const FEEDBACK_ATTACHMENT_OPTIONS: readonly ChoiceOption[] = [
78
+ { value: 'none', label: 'No attachment', description: 'Text feedback only' },
79
+ {
80
+ value: 'logs',
81
+ label: 'Logs only',
82
+ description: 'Upload wire events and diagnostic logs from this session',
83
+ },
84
+ {
85
+ value: 'logs+codebase',
86
+ label: 'Logs + codebase',
87
+ description:
88
+ '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.',
89
+ descriptionTone: 'warning',
90
+ },
91
+ ];
92
+
93
+ export function promptFeedbackAttachment(
94
+ host: SlashCommandHost,
95
+ ): Promise<FeedbackAttachmentLevel | undefined> {
96
+ return new Promise((resolve) => {
97
+ const picker = new ChoicePickerComponent({
98
+ title: 'Share diagnostic info to help us investigate?',
99
+ options: FEEDBACK_ATTACHMENT_OPTIONS,
100
+ onSelect: (value) => {
101
+ host.restoreEditor();
102
+ resolve(value as FeedbackAttachmentLevel);
103
+ },
104
+ onCancel: () => {
105
+ host.restoreEditor();
106
+ resolve(undefined);
107
+ },
108
+ });
109
+ host.mountEditorReplacement(picker);
110
+ });
111
+ }
112
+
113
+ export function promptApiKey(
114
+ host: SlashCommandHost,
115
+ platformName: string,
116
+ subtitleLines: readonly string[] = ['Your key will be saved to ~/.kimi-code/config.toml'],
117
+ ): Promise<string | undefined> {
118
+ return new Promise((resolve) => {
119
+ const dialog = new ApiKeyInputDialogComponent(
120
+ platformName,
121
+ subtitleLines,
122
+ (result: ApiKeyInputResult) => {
123
+ host.restoreEditor();
124
+ resolve(result.kind === 'ok' ? result.value : undefined);
125
+ },
126
+ );
127
+ host.mountEditorReplacement(dialog);
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Asks for the provider endpoint the catalog did not declare (or declared
133
+ * only as an env placeholder) — required for catalog imports whose protocol
134
+ * was guessed, where the built-in default endpoint would point at the wrong
135
+ * host. Esc cancels the import.
136
+ */
137
+ export function promptBaseUrl(host: SlashCommandHost, platformName: string): Promise<string | undefined> {
138
+ return new Promise((resolve) => {
139
+ const dialog = new ApiKeyInputDialogComponent(
140
+ platformName,
141
+ ['The catalog declares no endpoint for this provider — enter its base URL.'],
142
+ (result: ApiKeyInputResult) => {
143
+ host.restoreEditor();
144
+ resolve(result.kind === 'ok' ? result.value : undefined);
145
+ },
146
+ {
147
+ title: `Enter base URL for ${platformName}`,
148
+ mask: false,
149
+ emptyHint: 'Base URL cannot be empty.',
150
+ },
151
+ );
152
+ host.mountEditorReplacement(dialog);
153
+ });
154
+ }
155
+
156
+ export function promptCatalogProviderSelection(host: SlashCommandHost, catalog: Catalog): Promise<string | undefined> {
157
+ return new Promise((resolve) => {
158
+ const options: ChoiceOption[] = Object.entries(catalog)
159
+ .filter(([, entry]) => resolveCatalogImport(entry).kind !== 'invalid')
160
+ .map(([id, entry]) => ({
161
+ value: id,
162
+ label: entry.name ?? id,
163
+ description:
164
+ typeof entry.api === 'string' && entry.api.length > 0 ? entry.api : undefined,
165
+ }))
166
+ .toSorted((a, b) => a.label.localeCompare(b.label));
167
+
168
+ if (options.length === 0) {
169
+ host.showError('Catalog has no providers with supported wire types.');
170
+ resolve(undefined);
171
+ return;
172
+ }
173
+
174
+ const picker = new ChoicePickerComponent({
175
+ title: 'Select a provider',
176
+ options,
177
+ searchable: true,
178
+ onSelect: (value) => {
179
+ host.restoreEditor();
180
+ resolve(value);
181
+ },
182
+ onCancel: () => {
183
+ host.restoreEditor();
184
+ resolve(undefined);
185
+ },
186
+ });
187
+ host.mountEditorReplacement(picker);
188
+ });
189
+ }
190
+
191
+ export async function promptModelSelectionForOpenPlatform(
192
+ host: SlashCommandHost,
193
+ models: ManagedKimiCodeModelInfo[],
194
+ platform: OpenPlatformDefinition,
195
+ ): Promise<{ model: ManagedKimiCodeModelInfo; thinking: ThinkingEffort } | undefined> {
196
+ const modelDict: Record<string, ModelAlias> = {};
197
+ for (const m of models) {
198
+ modelDict[`${platform.id}/${m.id}`] = {
199
+ provider: platform.id,
200
+ model: m.id,
201
+ maxContextSize: m.contextLength,
202
+ capabilities: capabilitiesForModel(m),
203
+ displayName: m.displayName,
204
+ };
205
+ }
206
+ const selection = await runModelSelector(host, modelDict);
207
+ if (selection === undefined) return undefined;
208
+ const model = models.find((m) => `${platform.id}/${m.id}` === selection.alias);
209
+ return model ? { model, thinking: selection.thinking } : undefined;
210
+ }
211
+
212
+ export async function promptModelSelectionForCatalog(
213
+ host: SlashCommandHost,
214
+ providerId: string,
215
+ models: CatalogModel[],
216
+ ): Promise<{ model: CatalogModel; thinking: ThinkingEffort } | undefined> {
217
+ const modelDict: Record<string, ModelAlias> = {};
218
+ for (const m of models) {
219
+ modelDict[`${providerId}/${m.id}`] = catalogModelToAlias(providerId, m);
220
+ }
221
+ const selection = await runModelSelector(host, modelDict);
222
+ if (selection === undefined) return undefined;
223
+ const model = models.find((m) => `${providerId}/${m.id}` === selection.alias);
224
+ return model ? { model, thinking: selection.thinking } : undefined;
225
+ }
226
+
227
+ export function runModelSelector(
228
+ host: SlashCommandHost,
229
+ modelDict: Record<string, ModelAlias>,
230
+ ): Promise<{ alias: string; thinking: ThinkingEffort } | undefined> {
231
+ return new Promise((resolve) => {
232
+ const firstAlias = Object.keys(modelDict)[0] ?? '';
233
+ const caps = modelDict[firstAlias]?.capabilities ?? [];
234
+ const initialThinking = caps.includes('always_thinking') || caps.includes('thinking');
235
+ const selector = new ModelSelectorComponent({
236
+ models: modelDict,
237
+ currentValue: firstAlias,
238
+ currentThinkingEffort: initialThinking ? 'on' : 'off',
239
+ searchable: true,
240
+ onSelect: ({ alias, thinking }) => {
241
+ host.restoreEditor();
242
+ resolve({ alias, thinking });
243
+ },
244
+ onCancel: () => {
245
+ host.restoreEditor();
246
+ resolve(undefined);
247
+ },
248
+ });
249
+ host.mountEditorReplacement(selector);
250
+ });
251
+ }
apps/kimi-code/src/tui/commands/provider.ts ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ applyCustomRegistryEntries,
3
+ fetchCustomRegistry,
4
+ type CustomRegistrySource,
5
+ type ManagedKimiConfigShape,
6
+ } from '@moonshot-ai/kimi-code-oauth';
7
+ import {
8
+ applyCatalogProvider,
9
+ catalogProviderModels,
10
+ CatalogFetchError,
11
+ DEFAULT_CATALOG_URL,
12
+ resolveCatalogImport,
13
+ SECONDARY_DERIVED_MODEL_ALIAS,
14
+ type Catalog,
15
+ type ThinkingEffort,
16
+ } from '@moonshot-ai/kimi-code-sdk';
17
+
18
+ import { createKimiCodeUserAgent } from '#/cli/version';
19
+ import { fetchCatalogOrBuiltIn } from '#/utils/catalog-fetch';
20
+ import { refreshKimiRegion } from '#/utils/region';
21
+ import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
22
+ import {
23
+ CustomRegistryImportDialogComponent,
24
+ type CustomRegistryImportResult,
25
+ } from '../components/dialogs/custom-registry-import';
26
+ import {
27
+ ProviderManagerComponent,
28
+ type ProviderManagerOptions,
29
+ } from '../components/dialogs/provider-manager';
30
+ import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector';
31
+ import { DEFAULT_OAUTH_PROVIDER_NAME } from '../constant/kimi-tui';
32
+ import { formatErrorMessage } from '../utils/event-payload';
33
+ import { thinkingEffortToConfig } from '../utils/thinking-config';
34
+ import { effectiveModelForHost } from './config';
35
+ import {
36
+ promptApiKey,
37
+ promptBaseUrl,
38
+ promptCatalogProviderSelection,
39
+ } from './prompts';
40
+ import type { SlashCommandHost } from './dispatch';
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // /provider command
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export async function handleProviderCommand(host: SlashCommandHost): Promise<void> {
47
+ const options = buildProviderManagerOptions(host);
48
+ const component = new ProviderManagerComponent(options);
49
+ host.mountEditorReplacement(component);
50
+ }
51
+
52
+ function buildProviderManagerOptions(host: SlashCommandHost): ProviderManagerOptions {
53
+ const activeProviderId =
54
+ host.state.appState.availableModels[host.state.appState.model]?.provider;
55
+ return {
56
+ providers: host.state.appState.availableProviders,
57
+ activeProviderId,
58
+ onAdd: () => {
59
+ void handleProviderAdd(host).catch((error: unknown) => {
60
+ host.showError(`Add provider failed: ${formatErrorMessage(error)}`);
61
+ });
62
+ },
63
+ onDeleteSource: (providerIds) => {
64
+ void handleProviderManagerDeleteSource(host, providerIds).catch((error: unknown) => {
65
+ host.showError(`Remove provider failed: ${formatErrorMessage(error)}`);
66
+ });
67
+ },
68
+ onClose: () => {
69
+ host.restoreEditor();
70
+ },
71
+ };
72
+ }
73
+
74
+ async function handleProviderManagerDeleteSource(
75
+ host: SlashCommandHost,
76
+ providerIds: readonly string[],
77
+ ): Promise<void> {
78
+ for (const providerId of providerIds) {
79
+ try {
80
+ await handleProviderDelete(host, providerId);
81
+ } catch (error) {
82
+ const msg = formatErrorMessage(error);
83
+ host.showError(`Failed to delete provider ${providerId}: ${msg}`);
84
+ }
85
+ }
86
+ reopenProviderManager(host);
87
+ }
88
+
89
+ async function handleProviderDelete(host: SlashCommandHost, providerId: string): Promise<void> {
90
+ if (providerId === DEFAULT_OAUTH_PROVIDER_NAME) {
91
+ await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME);
92
+ // Drop the process-wide region cache with the credential: derived
93
+ // endpoints (updates, marketplace, site links, telemetry) must fall back
94
+ // to the marker/default profile, not the logged-out region.
95
+ refreshKimiRegion();
96
+ await host.authFlow.refreshConfigAfterLogout();
97
+ return;
98
+ }
99
+
100
+ const activeProvider =
101
+ host.state.appState.availableModels[host.state.appState.model]?.provider;
102
+ const config = await host.harness.removeProvider(providerId);
103
+ if (activeProvider === providerId) {
104
+ await host.authFlow.refreshConfigAfterLogout();
105
+ } else {
106
+ host.setAppState({
107
+ availableProviders: config.providers ?? {},
108
+ availableModels: config.models ?? {},
109
+ });
110
+ }
111
+ }
112
+
113
+ async function handleProviderAdd(host: SlashCommandHost): Promise<void> {
114
+ const source = await promptProviderAddSource(host);
115
+ if (source === undefined) {
116
+ reopenProviderManager(host);
117
+ return;
118
+ }
119
+
120
+ if (source === 'known') {
121
+ await handleCatalogProviderAdd(host);
122
+ return;
123
+ }
124
+ const handled = await handleCustomRegistryAddViaDialog(host);
125
+ if (!handled) {
126
+ reopenProviderManager(host);
127
+ }
128
+ }
129
+
130
+ function reopenProviderManager(host: SlashCommandHost): void {
131
+ const options = buildProviderManagerOptions(host);
132
+ const component = new ProviderManagerComponent(options);
133
+ host.mountEditorReplacement(component);
134
+ }
135
+
136
+ function promptProviderAddSource(
137
+ host: SlashCommandHost,
138
+ ): Promise<'known' | 'custom' | undefined> {
139
+ return new Promise((resolve) => {
140
+ const picker = new ChoicePickerComponent({
141
+ title: 'Add provider',
142
+ options: [
143
+ { value: 'known', label: 'Known third-party provider' },
144
+ { value: 'custom', label: 'Custom registry (api.json)' },
145
+ ],
146
+ onSelect: (value) => {
147
+ host.restoreEditor();
148
+ resolve(value === 'known' || value === 'custom' ? value : undefined);
149
+ },
150
+ onCancel: () => {
151
+ host.restoreEditor();
152
+ resolve(undefined);
153
+ },
154
+ });
155
+ host.mountEditorReplacement(picker);
156
+ });
157
+ }
158
+
159
+ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
160
+ const controller = new AbortController();
161
+ const cancel = (): void => {
162
+ controller.abort();
163
+ };
164
+ host.cancelInFlight = cancel;
165
+
166
+ const spinner = host.showLoginProgressSpinner(`Fetching catalog from ${DEFAULT_CATALOG_URL}`);
167
+ let catalog: Catalog | undefined;
168
+ try {
169
+ const loaded = await fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, {
170
+ signal: controller.signal,
171
+ userAgent: createKimiCodeUserAgent(),
172
+ });
173
+ catalog = loaded.catalog;
174
+ spinner.stop({
175
+ ok: true,
176
+ label: loaded.fromBuiltIn
177
+ ? 'Catalog loaded from built-in snapshot (models.dev unreachable).'
178
+ : 'Catalog loaded.',
179
+ });
180
+ } catch (error) {
181
+ if (controller.signal.aborted) {
182
+ spinner.stop({ ok: false, label: 'Aborted.' });
183
+ } else {
184
+ const hint = error instanceof CatalogFetchError ? ` (HTTP ${error.status})` : '';
185
+ spinner.stop({ ok: false, label: 'Failed to load catalog.' });
186
+ host.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`);
187
+ }
188
+ } finally {
189
+ if (host.cancelInFlight === cancel) host.cancelInFlight = undefined;
190
+ }
191
+
192
+ if (catalog === undefined) return;
193
+
194
+ const providerId = await promptCatalogProviderSelection(host, catalog);
195
+ if (providerId === undefined) return;
196
+ const entry = catalog[providerId];
197
+ if (entry === undefined) return;
198
+
199
+ const models = catalogProviderModels(entry);
200
+ if (models.length === 0) {
201
+ host.showError(`Provider "${providerId}" has no usable models in this catalog.`);
202
+ return;
203
+ }
204
+
205
+ let resolution = resolveCatalogImport(entry);
206
+ if (resolution.kind === 'needs-base-url') {
207
+ const entered = await promptBaseUrl(host, entry.name ?? providerId);
208
+ if (entered === undefined) return;
209
+ resolution = resolveCatalogImport(entry, entered);
210
+ }
211
+ if (resolution.kind !== 'ok') {
212
+ if (resolution.kind === 'invalid') {
213
+ if (resolution.reason === 'unknown-explicit-type') {
214
+ host.showError(
215
+ `Provider "${providerId}" declares protocol "${entry.type}" in the catalog, which this client version does not support.`,
216
+ );
217
+ } else if (resolution.reason === 'proprietary-sdk') {
218
+ host.showError(
219
+ `Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock or Cohere); it cannot be imported from the catalog.`,
220
+ );
221
+ } else {
222
+ host.showError(
223
+ `Base URL contains an env placeholder or is empty. Enter the resolved URL instead.`,
224
+ );
225
+ }
226
+ }
227
+ return;
228
+ }
229
+ const { wire, baseUrl } = resolution;
230
+
231
+ const apiKey = await promptApiKey(host, entry.name ?? providerId);
232
+ if (apiKey === undefined) return;
233
+
234
+ // Persist the provider and all its models immediately after the api key is
235
+ // entered. The model selector that follows is just a convenience to pick the
236
+ // default model; ESC leaves the provider in place without a default selection.
237
+ const existingConfig = await host.harness.getConfig();
238
+ if (existingConfig.providers[providerId] !== undefined) {
239
+ await host.harness.removeProvider(providerId);
240
+ }
241
+
242
+ const config = await host.harness.getConfig();
243
+ applyCatalogProvider(config, {
244
+ providerId,
245
+ wire,
246
+ baseUrl,
247
+ apiKey,
248
+ models,
249
+ selectedModelId: '', // no default yet; user picks in the model selector
250
+ thinking: false, // will be resolved by the model selector
251
+ });
252
+
253
+ await host.harness.setConfig({
254
+ providers: config.providers,
255
+ models: config.models,
256
+ });
257
+
258
+ await host.authFlow.refreshConfigAfterLogin();
259
+ host.track('connect', { provider: providerId, method: 'catalog' });
260
+ host.showStatus(`Provider added: ${entry.name ?? providerId}`);
261
+ if (resolution.guessed) {
262
+ host.showStatus(
263
+ `Protocol guessed as "openai" for ${providerId} — edit "type" in config.toml if requests fail.`,
264
+ );
265
+ }
266
+
267
+ // Build a merged model dictionary that includes existing models plus the
268
+ // newly-persisted provider's models, so the tabbed selector shows every
269
+ // provider's tab (the new provider's tab starts active via initialTabId).
270
+ // The v1 runtime may carry the synthesized `__secondary__` derived entry —
271
+ // never selectable in a picker.
272
+ const stateModels = await host.harness.getConfig().then((c) => c.models ?? {});
273
+ const mergedModels = { ...stateModels };
274
+ delete mergedModels[SECONDARY_DERIVED_MODEL_ALIAS];
275
+
276
+ const selector = new TabbedModelSelectorComponent({
277
+ models: mergedModels,
278
+ currentValue: host.state.appState.model,
279
+ selectedValue: Object.keys(mergedModels).find((a) => a.startsWith(`${providerId}/`)),
280
+ currentThinkingEffort: host.state.appState.thinkingEffort,
281
+ initialTabId: providerId,
282
+ onSelect: ({ alias, thinking }) => {
283
+ host.restoreEditor();
284
+ void setDefaultModel(host, alias, thinking).catch((error: unknown) => {
285
+ host.showError(`Set default model failed: ${formatErrorMessage(error)}`);
286
+ });
287
+ },
288
+ onCancel: () => {
289
+ host.restoreEditor();
290
+ },
291
+ });
292
+ host.mountEditorReplacement(selector);
293
+ }
294
+
295
+ export async function setDefaultModel(
296
+ host: SlashCommandHost,
297
+ alias: string,
298
+ effort: ThinkingEffort,
299
+ ): Promise<void> {
300
+ // Resolve efforts the same way the /model path does (effectiveModelForHost
301
+ // applies overrides and the protocol-profile inference): catalog entries for
302
+ // e.g. Anthropic models declare no support_efforts on the alias, and without
303
+ // the inference an above-default pick would slip through as a persisted effort.
304
+ const model = host.state.appState.availableModels[alias];
305
+ const thinking = thinkingEffortToConfig(
306
+ effort,
307
+ model === undefined ? undefined : effectiveModelForHost(host, model),
308
+ );
309
+ if (host.session === undefined) {
310
+ // A first prompt may still be inside lazy creation: wait it out so the
311
+ // pick lands on the new session instead of racing its assembly (same
312
+ // coordination as the /model path).
313
+ await host.waitForLazyCreation();
314
+ }
315
+ await host.harness.setConfig({
316
+ defaultModel: alias,
317
+ thinking,
318
+ });
319
+ // Whether activation made the engine emit model_switch (it reached a live
320
+ // session AND changed the bound alias — both engines track only an actual
321
+ // change). Recorded at activation time rather than snapshotted at entry: a
322
+ // lazy session can come live while the config writes above are pending; a
323
+ // session created BY activation (v1) or a same-alias rebind does not count
324
+ // — both bind the model without an engine event.
325
+ let engineTrackedSwitch = await host.authFlow.refreshConfigAfterLogin();
326
+ // refreshConfigAfterLogin reactivates from the persisted config, so a pick
327
+ // the gate keeps session-only never reaches the runtime — apply it after
328
+ // the refresh, or the persisted value would clobber it.
329
+ if (thinking.effort === undefined && effort !== 'off' && effort !== 'on') {
330
+ engineTrackedSwitch =
331
+ (await host.authFlow.activateModelAfterLogin(alias, effort)) || engineTrackedSwitch;
332
+ }
333
+ // When the engine never emitted (no live session, or the alias was already
334
+ // bound), the TUI stays the sole producer for the pick.
335
+ if (!engineTrackedSwitch) {
336
+ host.track('model_switch', { model: alias });
337
+ }
338
+ host.showStatus(`Default model set to ${alias} with thinking ${effort}.`);
339
+ }
340
+
341
+ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise<boolean> {
342
+ const value = await promptCustomRegistryImport(host);
343
+ if (value === undefined) return false;
344
+
345
+ const source: CustomRegistrySource = {
346
+ kind: 'apiJson',
347
+ url: value.url,
348
+ apiKey: value.apiKey,
349
+ };
350
+
351
+ let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>;
352
+ try {
353
+ entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() });
354
+ } catch (error) {
355
+ host.showError(`Failed to import registry: ${formatErrorMessage(error)}`);
356
+ return false;
357
+ }
358
+
359
+ const addedProviderIds = Object.values(entries).map((entry) => entry.id);
360
+ try {
361
+ const config = await host.harness.getConfig();
362
+ applyCustomRegistryEntries(
363
+ config as unknown as ManagedKimiConfigShape,
364
+ entries,
365
+ source,
366
+ );
367
+ await host.harness.setConfig({
368
+ providers: config.providers,
369
+ models: config.models,
370
+ });
371
+ await host.authFlow.refreshConfigAfterLogin();
372
+ } catch (error) {
373
+ host.showError(`Failed to apply registry: ${formatErrorMessage(error)}`);
374
+ return false;
375
+ }
376
+
377
+ const count = addedProviderIds.length;
378
+ if (count === 0) {
379
+ host.showStatus('Registry contained no providers.');
380
+ return false;
381
+ }
382
+ host.showStatus(
383
+ count === 1
384
+ ? 'Imported 1 provider from registry.'
385
+ : `Imported ${String(count)} providers from registry.`,
386
+ 'success',
387
+ );
388
+
389
+ // Offer the model selector so the user can pick a default, just like the
390
+ // catalog (known-provider) flow. Copy without the v1-synthesized
391
+ // `__secondary__` derived entry — never selectable in a picker.
392
+ const stateModels = { ...(await host.harness.getConfig().then((c) => c.models ?? {})) };
393
+ delete stateModels[SECONDARY_DERIVED_MODEL_ALIAS];
394
+ const firstNewAlias = Object.keys(stateModels).find((a) =>
395
+ addedProviderIds.some((pid) => a.startsWith(`${pid}/`)),
396
+ );
397
+ const firstNewProvider = firstNewAlias
398
+ ? stateModels[firstNewAlias]?.provider
399
+ : addedProviderIds[0];
400
+ const selector = new TabbedModelSelectorComponent({
401
+ models: stateModels,
402
+ currentValue: host.state.appState.model,
403
+ selectedValue: firstNewAlias,
404
+ currentThinkingEffort: host.state.appState.thinkingEffort,
405
+ initialTabId: firstNewProvider,
406
+ onSelect: ({ alias, thinking }) => {
407
+ host.restoreEditor();
408
+ void setDefaultModel(host, alias, thinking).catch((error: unknown) => {
409
+ host.showError(`Set default model failed: ${formatErrorMessage(error)}`);
410
+ });
411
+ },
412
+ onCancel: () => {
413
+ host.restoreEditor();
414
+ },
415
+ });
416
+ host.mountEditorReplacement(selector);
417
+ return true;
418
+ }
419
+
420
+ function promptCustomRegistryImport(
421
+ host: SlashCommandHost,
422
+ ): Promise<{ readonly url: string; readonly apiKey: string } | undefined> {
423
+ return new Promise((resolve) => {
424
+ const dialog = new CustomRegistryImportDialogComponent(
425
+ (result: CustomRegistryImportResult) => {
426
+ host.restoreEditor();
427
+ resolve(result.kind === 'ok' ? result.value : undefined);
428
+ },
429
+ );
430
+ host.mountEditorReplacement(dialog);
431
+ });
432
+ }
apps/kimi-code/src/tui/commands/registry.ts ADDED
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, join, relative, resolve } from 'pathe';
4
+
5
+ import type { AutocompleteItem } from '@moonshot-ai/pi-tui';
6
+
7
+ import { completeLeadingArg, type ArgCompletionSpec } from './complete-args';
8
+ import type { KimiSlashCommand, SlashCommandAvailability } from './types';
9
+
10
+ /** Subcommands offered when autocompleting `/goal <…>`. */
11
+ const GOAL_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
12
+ { value: 'status', description: 'Show the current goal' },
13
+ { value: 'pause', description: 'Pause the active goal' },
14
+ { value: 'resume', description: 'Resume a paused goal' },
15
+ { value: 'cancel', description: 'Cancel and remove the current goal' },
16
+ { value: 'replace', description: 'Replace the current goal with a new objective' },
17
+ { value: 'next', description: 'Queue an upcoming goal' },
18
+ ];
19
+
20
+ const GOAL_NEXT_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
21
+ { value: 'manage', description: 'Manage upcoming goals' },
22
+ ];
23
+
24
+ const SWARM_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
25
+ { value: 'on', description: 'Turn swarm mode on' },
26
+ { value: 'off', description: 'Turn swarm mode off' },
27
+ ];
28
+
29
+ const TOWER_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
30
+ { value: 'status', description: 'Report tower status' },
31
+ { value: 'teardown', description: 'Tear down the tower' },
32
+ { value: 'on', description: 'Turn tower mode on' },
33
+ { value: 'off', description: 'Turn tower mode off' },
34
+ ];
35
+
36
+ const ADD_DIR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
37
+ { value: 'list', description: 'Show configured additional workspace directories' },
38
+ ];
39
+
40
+ /** Argument autocompletion for the `/goal` command (subcommands). */
41
+ export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
42
+ const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i);
43
+ if (nextMatch !== null) {
44
+ return (
45
+ completeLeadingArg(GOAL_NEXT_ARG_COMPLETIONS, nextMatch[1] ?? '')?.map((item) => ({
46
+ ...item,
47
+ value: `next ${item.value}`,
48
+ })) ?? null
49
+ );
50
+ }
51
+ return completeLeadingArg(GOAL_ARG_COMPLETIONS, argumentPrefix);
52
+ }
53
+
54
+ /** Argument autocompletion for the `/swarm` command (subcommands). */
55
+ export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
56
+ return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix);
57
+ }
58
+
59
+ /** Argument autocompletion for the `/tower` command (subcommands). */
60
+ export function towerArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
61
+ return completeLeadingArg(TOWER_ARG_COMPLETIONS, argumentPrefix);
62
+ }
63
+
64
+ /** Argument autocompletion for the `/add-dir` command. */
65
+ export function addDirArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
66
+ if (isPathLikeAddDirArgument(argumentPrefix)) {
67
+ return completeAddDirPath(argumentPrefix);
68
+ }
69
+ return completeLeadingArg(ADD_DIR_ARG_COMPLETIONS, argumentPrefix);
70
+ }
71
+
72
+ function isPathLikeAddDirArgument(argumentPrefix: string): boolean {
73
+ return argumentPrefix === '.' || argumentPrefix === '..' || argumentPrefix.startsWith('./') || argumentPrefix.startsWith('../') || argumentPrefix.startsWith('/') || argumentPrefix.startsWith('~');
74
+ }
75
+
76
+ function completeAddDirPath(argumentPrefix: string): AutocompleteItem[] | null {
77
+ const normalizedPrefix = argumentPrefix === '~' ? '~/' : argumentPrefix;
78
+ const expandedPrefix = expandHomePrefix(normalizedPrefix);
79
+ const parentInput = getDirectoryCompletionParentInput(normalizedPrefix, expandedPrefix);
80
+ const partialName = normalizedPrefix.endsWith('/') ? '' : basename(expandedPrefix);
81
+ const parentDir = resolveDirectoryCompletionParent(parentInput);
82
+ let entries;
83
+ try {
84
+ entries = readdirSync(parentDir, { withFileTypes: true });
85
+ } catch {
86
+ return null;
87
+ }
88
+
89
+ const items: AutocompleteItem[] = [];
90
+ for (const entry of entries) {
91
+ if (entry.name === '.' || entry.name === '..' || entry.name.startsWith('.')) continue;
92
+ if (partialName.length > 0 && !entry.name.toLowerCase().startsWith(partialName.toLowerCase())) continue;
93
+ const absolutePath = join(parentDir, entry.name);
94
+ if (!isDirectoryPath(absolutePath, entry.isDirectory(), entry.isSymbolicLink())) continue;
95
+ const value = formatDirectoryCompletionValue(normalizedPrefix, parentInput, entry.name);
96
+ items.push({
97
+ value,
98
+ label: `${entry.name}/`,
99
+ description: absolutePath,
100
+ });
101
+ }
102
+
103
+ return items.length > 0 ? items : null;
104
+ }
105
+
106
+ function expandHomePrefix(argumentPrefix: string): string {
107
+ if (argumentPrefix === '~') return homedir();
108
+ if (argumentPrefix.startsWith('~/')) return join(homedir(), argumentPrefix.slice(2));
109
+ return argumentPrefix;
110
+ }
111
+
112
+ function getDirectoryCompletionParentInput(argumentPrefix: string, expandedPrefix: string): string {
113
+ if (argumentPrefix === '/') return '/';
114
+ if (argumentPrefix === '~/') return homedir();
115
+ if (argumentPrefix.endsWith('/')) return expandedPrefix.slice(0, -1);
116
+ return dirname(expandedPrefix);
117
+ }
118
+
119
+ function resolveDirectoryCompletionParent(parentInput: string): string {
120
+ if (parentInput === '~') return homedir();
121
+ if (parentInput.startsWith('~/')) return join(homedir(), parentInput.slice(2));
122
+ return resolve(parentInput);
123
+ }
124
+
125
+ function isDirectoryPath(path: string, isDirectory: boolean, isSymlink: boolean): boolean {
126
+ if (isDirectory) return true;
127
+ if (!isSymlink) return false;
128
+ try {
129
+ return statSync(path).isDirectory();
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ function formatDirectoryCompletionValue(argumentPrefix: string, parentInput: string, entryName: string): string {
136
+ if (argumentPrefix.startsWith('~/')) {
137
+ const home = homedir();
138
+ const homeRelative = relative(home, parentInput);
139
+ return `~${homeRelative.length > 0 ? `/${homeRelative}` : ''}/${entryName}/`;
140
+ }
141
+ if (argumentPrefix.startsWith('/')) {
142
+ return `${join(parentInput, entryName)}/`;
143
+ }
144
+ return `${join(parentInput, entryName)}/`;
145
+ }
146
+
147
+ export const BUILTIN_SLASH_COMMANDS = [
148
+ {
149
+ name: 'yolo',
150
+ aliases: ['yes'],
151
+ description: 'Ask When Needed mode: routine edits and commands run automatically; risky actions, questions, and plans still ask.',
152
+ priority: 101,
153
+ availability: 'always',
154
+ },
155
+ {
156
+ name: 'auto',
157
+ aliases: [],
158
+ description: 'Never Ask mode: never interrupts you; everything runs and is decided automatically.',
159
+ priority: 99,
160
+ availability: 'always',
161
+ },
162
+ {
163
+ name: 'permission',
164
+ aliases: [],
165
+ description: 'Select permission mode',
166
+ priority: 100,
167
+ availability: 'always',
168
+ },
169
+ {
170
+ name: 'settings',
171
+ aliases: ['config'],
172
+ description: 'Open TUI settings',
173
+ priority: 100,
174
+ availability: 'always',
175
+ },
176
+ {
177
+ name: 'plan',
178
+ aliases: [],
179
+ description: 'Toggle plan mode',
180
+ priority: 100,
181
+ availability: (args) => (args.trim().toLowerCase() === 'clear' ? 'idle-only' : 'always'),
182
+ },
183
+ {
184
+ name: 'swarm',
185
+ aliases: [],
186
+ description: 'Toggle swarm mode or run one task in swarm mode',
187
+ priority: 100,
188
+ argumentHint: '[on|off] | <task>',
189
+ completeArgs: swarmArgumentCompletions,
190
+ availability: 'idle-only',
191
+ },
192
+ {
193
+ name: 'tower',
194
+ aliases: [],
195
+ description: 'Report tower status, toggle tower mode, or turn it on with a base branch',
196
+ priority: 100,
197
+ argumentHint: '[status|teardown|on|off] | <base-branch>',
198
+ completeArgs: towerArgumentCompletions,
199
+ // Every form stays available while busy: base selections apply to the next
200
+ // TowerInit of the running coordinator turn, so /tower commands never wait
201
+ // for the previous one to finish.
202
+ availability: 'always',
203
+ experimentalFlag: 'tower',
204
+ },
205
+ {
206
+ name: 'model',
207
+ aliases: [],
208
+ description: 'Switch LLM model',
209
+ priority: 100,
210
+ availability: 'always',
211
+ },
212
+ {
213
+ name: 'secondary-model',
214
+ aliases: ['subagent-model'],
215
+ description: 'Configure the secondary model for subagents',
216
+ priority: 90,
217
+ availability: 'always',
218
+ },
219
+ {
220
+ name: 'effort',
221
+ aliases: ['thinking'],
222
+ description: 'Switch thinking effort',
223
+ priority: 95,
224
+ availability: 'always',
225
+ },
226
+ {
227
+ name: 'provider',
228
+ aliases: ['providers'],
229
+ description: 'Manage AI providers (add / delete / refresh)',
230
+ priority: 95,
231
+ availability: 'always',
232
+ },
233
+ {
234
+ name: 'btw',
235
+ aliases: [],
236
+ description: 'Ask a forked side agent a question',
237
+ priority: 90,
238
+ availability: 'always',
239
+ },
240
+ {
241
+ name: 'help',
242
+ aliases: ['h', '?'],
243
+ description: 'Show available commands and shortcuts',
244
+ priority: 80,
245
+ availability: 'always',
246
+ },
247
+ {
248
+ name: 'new',
249
+ aliases: ['clear'],
250
+ description: 'Start a fresh session in the current workspace',
251
+ priority: 80,
252
+ },
253
+ {
254
+ name: 'sessions',
255
+ aliases: ['resume'],
256
+ description: 'Browse and resume sessions',
257
+ priority: 80,
258
+ },
259
+ {
260
+ name: 'tasks',
261
+ aliases: ['task'],
262
+ description: 'Browse background tasks',
263
+ priority: 80,
264
+ availability: 'always',
265
+ },
266
+ {
267
+ name: 'mcp',
268
+ aliases: [],
269
+ description: 'Show MCP server status',
270
+ priority: 60,
271
+ availability: 'always',
272
+ },
273
+ {
274
+ name: 'plugins',
275
+ aliases: [],
276
+ description: 'Manage plugins',
277
+ priority: 60,
278
+ availability: 'always',
279
+ },
280
+ {
281
+ name: 'add-dir',
282
+ aliases: [],
283
+ description: 'Add or list an additional workspace directory',
284
+ priority: 60,
285
+ availability: 'idle-only',
286
+ argumentHint: '[list] | <path>',
287
+ completeArgs: addDirArgumentCompletions,
288
+ },
289
+ {
290
+ name: 'experiments',
291
+ aliases: ['experimental'],
292
+ description: 'Manage experimental features',
293
+ priority: 60,
294
+ availability: 'idle-only',
295
+ },
296
+ {
297
+ name: 'reload',
298
+ aliases: [],
299
+ description: 'Reload session and apply config.toml settings plus tui.toml UI preferences',
300
+ priority: 60,
301
+ availability: 'idle-only',
302
+ },
303
+ {
304
+ name: 'reload-tui',
305
+ aliases: [],
306
+ description: 'Reload only tui.toml UI preferences',
307
+ priority: 60,
308
+ availability: 'always',
309
+ },
310
+ {
311
+ name: 'compact',
312
+ aliases: [],
313
+ description: 'Compact the conversation context',
314
+ priority: 80,
315
+ argumentHint: '<instruction>',
316
+ },
317
+ {
318
+ name: 'goal',
319
+ aliases: [],
320
+ description: 'Start or manage an autonomous goal',
321
+ priority: 80,
322
+ argumentHint: '[status|pause|resume|cancel|replace|next] | <objective>',
323
+ completeArgs: goalArgumentCompletions,
324
+ // status / pause / cancel are always available; creation, replacement, and
325
+ // resume start (or restart) a turn and so are idle-only.
326
+ availability: (args) => {
327
+ const trimmed = args.trim();
328
+ if (trimmed === 'next' || trimmed.startsWith('next ')) return 'always';
329
+ return trimmed === '' || trimmed === 'status' || trimmed === 'pause' || trimmed === 'cancel'
330
+ ? 'always'
331
+ : 'idle-only';
332
+ },
333
+ },
334
+ {
335
+ name: 'init',
336
+ aliases: [],
337
+ description: 'Analyze the codebase and generate AGENTS.md',
338
+ },
339
+ {
340
+ name: 'fork',
341
+ aliases: [],
342
+ description: 'Fork the current session into a copy without switching to it',
343
+ priority: 80,
344
+ },
345
+ {
346
+ name: 'title',
347
+ aliases: ['rename'],
348
+ description: 'Set or show session title',
349
+ priority: 60,
350
+ argumentHint: '<title>',
351
+ availability: 'always',
352
+ },
353
+ {
354
+ name: 'usage',
355
+ aliases: [],
356
+ description: 'Show session tokens + context window + plan quotas',
357
+ priority: 60,
358
+ availability: 'always',
359
+ },
360
+ {
361
+ name: 'status',
362
+ aliases: [],
363
+ description: 'Show current session and runtime status',
364
+ priority: 60,
365
+ availability: 'always',
366
+ },
367
+ {
368
+ name: 'feedback',
369
+ aliases: ['bug'],
370
+ description: 'Send feedback to make Kimi Code better',
371
+ priority: 60,
372
+ availability: 'always',
373
+ },
374
+ {
375
+ name: 'undo',
376
+ aliases: [],
377
+ description: 'Withdraw the last prompt from the transcript',
378
+ priority: 80,
379
+ availability: 'idle-only',
380
+ },
381
+ {
382
+ name: 'editor',
383
+ aliases: [],
384
+ description: 'Set the external editor for Ctrl-G',
385
+ priority: 60,
386
+ availability: 'always',
387
+ },
388
+ {
389
+ name: 'theme',
390
+ aliases: [],
391
+ description: 'Set the terminal UI theme',
392
+ priority: 60,
393
+ availability: 'always',
394
+ },
395
+ {
396
+ name: 'logout',
397
+ aliases: ['disconnect'],
398
+ description: 'Log out of a configured provider',
399
+ priority: 40,
400
+ },
401
+ {
402
+ name: 'login',
403
+ aliases: [],
404
+ description: 'Select a platform and authenticate',
405
+ priority: 40,
406
+ },
407
+ {
408
+ name: 'export-md',
409
+ aliases: ['export'],
410
+ description: 'Export current session as a Markdown file',
411
+ priority: 40,
412
+ },
413
+ {
414
+ name: 'export-debug-zip',
415
+ aliases: [],
416
+ description: 'Export current session as a debug ZIP archive',
417
+ priority: 40,
418
+ },
419
+ {
420
+ name: 'copy',
421
+ aliases: [],
422
+ description: 'Copy the last assistant message to the clipboard',
423
+ priority: 40,
424
+ },
425
+ {
426
+ name: 'web',
427
+ aliases: [],
428
+ description: 'Open the current session in the Web UI by starting a new server',
429
+ priority: 40,
430
+ availability: 'always',
431
+ },
432
+ {
433
+ name: 'remote-control',
434
+ aliases: ['rc'],
435
+ description: 'Open the current session through Kimi Remote Control',
436
+ priority: 40,
437
+ availability: 'always',
438
+ },
439
+ {
440
+ name: 'exit',
441
+ aliases: ['quit', 'q'],
442
+ description: 'Exit the application',
443
+ priority: 20,
444
+ },
445
+ {
446
+ name: 'version',
447
+ aliases: [],
448
+ description: 'Show version information',
449
+ priority: 20,
450
+ availability: 'always',
451
+ },
452
+ ] as const satisfies readonly KimiSlashCommand[];
453
+
454
+ export type BuiltinSlashCommand = (typeof BUILTIN_SLASH_COMMANDS)[number];
455
+ export type BuiltinSlashCommandName = BuiltinSlashCommand['name'];
456
+
457
+ export function findBuiltInSlashCommand(commandName: string): BuiltinSlashCommand | undefined {
458
+ const commands = BUILTIN_SLASH_COMMANDS as readonly KimiSlashCommand<BuiltinSlashCommandName>[];
459
+ return commands.find(
460
+ (command) => command.name === commandName || command.aliases.includes(commandName),
461
+ ) as BuiltinSlashCommand | undefined;
462
+ }
463
+
464
+ export function resolveSlashCommandAvailability(
465
+ command: KimiSlashCommand,
466
+ args: string,
467
+ ): SlashCommandAvailability {
468
+ const availability = command.availability ?? 'idle-only';
469
+ return typeof availability === 'function' ? availability(args) : availability;
470
+ }
471
+
472
+ export function sortSlashCommands(commands: readonly KimiSlashCommand[]): KimiSlashCommand[] {
473
+ return [...commands].toSorted(
474
+ (a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.name.localeCompare(b.name),
475
+ );
476
+ }
apps/kimi-code/src/tui/commands/reload.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk';
2
+
3
+ import { currentTheme, lightColors } from '#/tui/theme';
4
+ import { loadTuiConfig, type TuiConfig } from '../config';
5
+ import { setMarkdownRenderLatex } from '../utils/markdown-options';
6
+ import type { SlashCommandHost } from './dispatch';
7
+ import { setExperimentalFeatures } from './experimental-flags';
8
+
9
+ export async function handleReloadTuiCommand(host: SlashCommandHost): Promise<void> {
10
+ const tuiConfig = await loadTuiConfig(undefined, (message) =>
11
+ host.showStatus(message, 'warning'),
12
+ );
13
+ await applyReloadedTuiConfig(host, tuiConfig);
14
+ host.showStatus('TUI config reloaded.', 'success');
15
+ }
16
+
17
+ export async function handleReloadCommand(host: SlashCommandHost): Promise<void> {
18
+ const tuiConfig = await loadTuiConfig(undefined, (message) =>
19
+ host.showStatus(message, 'warning'),
20
+ );
21
+ const session = host.session;
22
+
23
+ if (session !== undefined) {
24
+ const reloadedSession = await host.harness.reloadSession({
25
+ id: session.id,
26
+ forcePluginSessionStartReminder: true,
27
+ });
28
+ await host.reloadCurrentSessionView(reloadedSession, 'Session reloaded.');
29
+ }
30
+
31
+ const config = await host.harness.getConfig({ reload: true });
32
+ setExperimentalFeatures(await host.harness.getExperimentalFeatures());
33
+ if (session === undefined) {
34
+ // Session-less v2: rebuild the workspace-level dynamic commands too, so
35
+ // skill/plugin changes apply before the first session exists.
36
+ await host.refreshSkillCommands();
37
+ await host.refreshPluginCommands();
38
+ }
39
+ host.refreshSlashCommandAutocomplete();
40
+ applyRuntimeConfig(host, config);
41
+ await applyReloadedTuiConfig(host, tuiConfig);
42
+
43
+ if (session === undefined) {
44
+ // Still session-less on the v2 engine: refresh the lazy defaults too, so
45
+ // defaults edited externally (config.toml, a newly added default model)
46
+ // reach the first lazy-created session instead of staying stale.
47
+ await host.hydrateLazyConfigDefaults();
48
+ host.showStatus(
49
+ 'Runtime and TUI config reloaded; no active session.',
50
+ 'success',
51
+ );
52
+ }
53
+ }
54
+
55
+ export async function applyReloadedTuiConfig(
56
+ host: SlashCommandHost,
57
+ config: TuiConfig,
58
+ ): Promise<void> {
59
+ // Set the LaTeX toggle before applyTheme: theme application invalidates the
60
+ // transcript components, which rebuild their Markdown children and copy the
61
+ // options at construction — so the new value must be live by then.
62
+ setMarkdownRenderLatex(config.renderLatex ?? true);
63
+ const resolved = config.theme === 'auto'
64
+ ? (currentTheme.palette === lightColors ? 'light' : 'dark')
65
+ : undefined;
66
+ await host.applyTheme(config.theme, resolved);
67
+ host.refreshTerminalThemeTracking();
68
+ host.setAppState({
69
+ editorCommand: config.editorCommand,
70
+ disablePasteBurst: config.disablePasteBurst,
71
+ renderLatex: config.renderLatex,
72
+ cacheExpiryHint: config.cacheExpiryHint,
73
+ disableFeedbackSurvey: config.disableFeedbackSurvey,
74
+ notifications: config.notifications,
75
+ upgrade: config.upgrade,
76
+ statusLine: config.statusLine,
77
+ });
78
+ host.state.editor.setDisablePasteBurst(config.disablePasteBurst);
79
+ }
80
+
81
+ function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void {
82
+ host.setAppState({
83
+ availableModels: config.models ?? {},
84
+ availableProviders: config.providers ?? {},
85
+ });
86
+ }
apps/kimi-code/src/tui/commands/resolve.ts ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ findBuiltInSlashCommand,
3
+ resolveSlashCommandAvailability,
4
+ type BuiltinSlashCommand,
5
+ type BuiltinSlashCommandName,
6
+ } from './registry';
7
+ import { isExperimentalFlagEnabled } from './experimental-flags';
8
+ import { parseSlashInput } from './parse';
9
+ import type { TUIState } from '../tui-state';
10
+ import type {
11
+ KimiSlashCommand,
12
+ SlashCommandBusyReason,
13
+ SlashCommandInvalidReason,
14
+ } from './types';
15
+
16
+ export type SlashCommandIntent =
17
+ | { readonly kind: 'not-command' }
18
+ | {
19
+ readonly kind: 'builtin';
20
+ readonly command: BuiltinSlashCommand;
21
+ readonly name: BuiltinSlashCommandName;
22
+ readonly args: string;
23
+ }
24
+ | {
25
+ readonly kind: 'skill';
26
+ readonly commandName: string;
27
+ readonly skillName: string;
28
+ readonly args: string;
29
+ }
30
+ | {
31
+ readonly kind: 'plugin-command';
32
+ readonly commandName: string;
33
+ readonly pluginId: string;
34
+ readonly args: string;
35
+ }
36
+ | { readonly kind: 'message'; readonly input: string }
37
+ | {
38
+ readonly kind: 'blocked';
39
+ readonly commandName: string;
40
+ readonly reason: SlashCommandBusyReason;
41
+ }
42
+ | {
43
+ readonly kind: 'invalid';
44
+ readonly commandName: string;
45
+ readonly reason: SlashCommandInvalidReason;
46
+ };
47
+
48
+ export interface ResolveSlashCommandInput {
49
+ readonly input: string;
50
+ readonly skillCommandMap: ReadonlyMap<string, string>;
51
+ readonly pluginCommandMap: ReadonlyMap<string, string>;
52
+ readonly isStreaming: boolean;
53
+ readonly isCompacting: boolean;
54
+ }
55
+
56
+ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): SlashCommandIntent {
57
+ const parsed = parseSlashInput(options.input);
58
+ if (parsed === null) return { kind: 'not-command' };
59
+
60
+ const command = findBuiltInSlashCommand(parsed.name);
61
+ // `command` is a literal union where only some members carry `experimentalFlag`; widen to read it.
62
+ if (
63
+ command !== undefined &&
64
+ isExperimentalFlagEnabled((command as KimiSlashCommand).experimentalFlag)
65
+ ) {
66
+ const busyReason = slashCommandBusyReason(options);
67
+ if (
68
+ busyReason !== undefined &&
69
+ resolveSlashCommandAvailability(command, parsed.args) === 'idle-only'
70
+ ) {
71
+ return {
72
+ kind: 'blocked',
73
+ commandName: parsed.name,
74
+ reason: busyReason,
75
+ };
76
+ }
77
+ return {
78
+ kind: 'builtin',
79
+ command,
80
+ name: command.name,
81
+ args: parsed.args,
82
+ };
83
+ }
84
+
85
+ const skillName = resolveSkillCommand(options.skillCommandMap, parsed.name);
86
+ if (skillName !== undefined) {
87
+ // Skill activations are never blocked by a busy session: the TUI queues
88
+ // them behind the running turn exactly like normal messages (see
89
+ // sendSkillActivation), and Ctrl-S steers them as real activations, so
90
+ // skill commands can be issued any time.
91
+ return {
92
+ kind: 'skill',
93
+ commandName: parsed.name,
94
+ skillName,
95
+ args: parsed.args.trim(),
96
+ };
97
+ }
98
+
99
+ if (options.pluginCommandMap.has(parsed.name)) {
100
+ const busyReason = slashCommandBusyReason(options);
101
+ if (busyReason !== undefined) {
102
+ return {
103
+ kind: 'blocked',
104
+ commandName: parsed.name,
105
+ reason: busyReason,
106
+ };
107
+ }
108
+ const separator = parsed.name.indexOf(':');
109
+ const pluginId = separator === -1 ? parsed.name : parsed.name.slice(0, separator);
110
+ const commandName = separator === -1 ? '' : parsed.name.slice(separator + 1);
111
+ return {
112
+ kind: 'plugin-command',
113
+ commandName,
114
+ pluginId,
115
+ args: parsed.args.trim(),
116
+ };
117
+ }
118
+
119
+ return {
120
+ kind: 'message',
121
+ input: options.input,
122
+ };
123
+ }
124
+
125
+ export function resolveSkillCommand(
126
+ skillCommandMap: ReadonlyMap<string, string>,
127
+ commandName: string,
128
+ ): string | undefined {
129
+ return skillCommandMap.get(commandName) ?? skillCommandMap.get(`skill:${commandName}`);
130
+ }
131
+
132
+ export function slashCommandBusyReason(
133
+ options: Pick<ResolveSlashCommandInput, 'isStreaming' | 'isCompacting'>,
134
+ ): SlashCommandBusyReason | undefined {
135
+ if (options.isStreaming) return 'streaming';
136
+ if (options.isCompacting) return 'compacting';
137
+ return undefined;
138
+ }
139
+
140
+ export function slashBusyMessage(
141
+ commandName: string,
142
+ reason: SlashCommandBusyReason,
143
+ ): string {
144
+ if (reason === 'streaming') {
145
+ return `Cannot /${commandName} while streaming — press Esc or Ctrl-C first.`;
146
+ }
147
+ return `Cannot /${commandName} while compacting — wait for compaction to finish first.`;
148
+ }
149
+
150
+ /**
151
+ * Whether a delayed input restore is still safe: the editor must be empty
152
+ * (no newer draft) and still mounted (no editor-replacement panel opened
153
+ * meanwhile). Restores that run synchronously with submit do not need this.
154
+ */
155
+ export function canRestoreSubmittedInput(host: { state: TUIState }): boolean {
156
+ return host.state.editor.getText().length === 0 && !host.state.editorReplacementMounted;
157
+ }
apps/kimi-code/src/tui/commands/session.ts ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ import type { Session } from '@moonshot-ai/kimi-code-sdk';
6
+
7
+ import { detectInstallSource } from '#/cli/update/source';
8
+ import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text';
9
+ import { detectShellEnvironment } from '#/utils/process/shell-env';
10
+ import { quoteShellArg } from '#/utils/shell-quote';
11
+ import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
12
+ import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
13
+ import { isAbortError } from '../utils/errors';
14
+ import { formatErrorMessage } from '../utils/event-payload';
15
+ import { buildExportMarkdown } from '../utils/export-markdown';
16
+ import type { SlashCommandHost } from './dispatch';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Session commands
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export async function handleTitleCommand(host: SlashCommandHost, args: string): Promise<void> {
23
+ const title = args.trim();
24
+ if (title.length === 0) {
25
+ const current = host.state.appState.sessionTitle;
26
+ host.showStatus(
27
+ current !== null && current.length > 0
28
+ ? `Session title: ${current}`
29
+ : `Session title: (not set) — id: ${host.state.appState.sessionId}`,
30
+ );
31
+ return;
32
+ }
33
+
34
+ let session = host.session;
35
+ if (session === undefined) {
36
+ // Setting a title needs a live session; lazy-create it on first use (the
37
+ // bare read-only form above works session-less).
38
+ session = await host.ensureSession();
39
+ if (session === undefined) return;
40
+ }
41
+
42
+ const newTitle = title.slice(0, 200);
43
+ try {
44
+ await host.harness.renameSession({ id: session.id, title: newTitle });
45
+ } catch (error) {
46
+ const msg = formatErrorMessage(error);
47
+ host.showError(`Failed to set title: ${msg}`);
48
+ return;
49
+ }
50
+ host.showStatus(`Session title set to: ${newTitle}`);
51
+ }
52
+
53
+ export async function handleForkCommand(host: SlashCommandHost, args: string): Promise<void> {
54
+ void args;
55
+ const session = host.session;
56
+ if (session === undefined) {
57
+ host.showError(NO_ACTIVE_SESSION_MESSAGE);
58
+ return;
59
+ }
60
+
61
+ const sourceTitle = forkSourceTitle(host, session);
62
+ try {
63
+ const forked = await host.harness.forkSession({
64
+ id: session.id,
65
+ title: `Fork: ${sourceTitle}`,
66
+ });
67
+ const forkId = forked.id;
68
+ try {
69
+ await forked.close();
70
+ } catch (error) {
71
+ const msg = formatErrorMessage(error);
72
+ host.showError(`Session forked (${forkId}), but failed to release its runtime: ${msg}`);
73
+ return;
74
+ }
75
+ // Stay in the source session: switching to the fork would close the
76
+ // source, killing its in-flight turn and background tasks. The fork is
77
+ // an independent copy the user can switch to explicitly via /sessions,
78
+ // or enter from a new CLI process with the printed resume command.
79
+ const command = forkResumeCommand(host.state.appState.workDir, forkId);
80
+ let clipboardNote: string;
81
+ try {
82
+ const method = await copyTextToClipboard(command);
83
+ // OSC 52 delivery is fire-and-forget: terminals without OSC 52 support
84
+ // silently drop the sequence, so only native delivery may claim success
85
+ // (same wording convention as /copy).
86
+ clipboardNote =
87
+ method === 'native'
88
+ ? 'Command copied to clipboard'
89
+ : 'Command copied via terminal escape sequence (unverified)';
90
+ } catch {
91
+ clipboardNote = 'Failed to copy command to clipboard';
92
+ }
93
+ host.showStatus(
94
+ `Session forked (${forkId}). Still in the original session; switch to the fork via /sessions.\n` +
95
+ ` To enter the fork in a new process, run: ${command}\n` +
96
+ ` ${clipboardNote}`,
97
+ );
98
+ } catch (error) {
99
+ const msg = formatErrorMessage(error);
100
+ host.showError(`Failed to fork session: ${msg}`);
101
+ }
102
+ }
103
+
104
+ function forkResumeCommand(workDir: string, forkId: string): string {
105
+ const dir = quoteShellArg(workDir);
106
+ // cmd.exe's `cd` only updates the given drive's remembered directory — a
107
+ // terminal on a different drive stays put, and the resume then runs in the
108
+ // wrong working directory. `pushd` switches drive + directory in both
109
+ // cmd.exe and PowerShell (`cd /d` would break PowerShell).
110
+ const changeDir = process.platform === 'win32' ? `pushd ${dir}` : `cd ${dir}`;
111
+ return `${changeDir} && kimi --resume ${quoteShellArg(forkId)}`;
112
+ }
113
+
114
+ function forkSourceTitle(host: SlashCommandHost, session: Session): string {
115
+ const currentTitle = host.state.appState.sessionTitle?.trim();
116
+ if (currentTitle !== undefined && currentTitle.length > 0) return currentTitle;
117
+
118
+ const summaryTitle =
119
+ typeof session.summary?.title === 'string' ? session.summary.title.trim() : '';
120
+ return summaryTitle.length > 0 ? summaryTitle : session.id;
121
+ }
122
+
123
+ export async function handleExportMdCommand(host: SlashCommandHost, args: string): Promise<void> {
124
+ const session = host.session;
125
+ if (session === undefined) {
126
+ host.showError(NO_ACTIVE_SESSION_MESSAGE);
127
+ return;
128
+ }
129
+
130
+ host.showStatus('Exporting session as Markdown…');
131
+ try {
132
+ const context = await session.getContext();
133
+ if (context.history.length === 0) {
134
+ host.showError('No messages to export.');
135
+ return;
136
+ }
137
+
138
+ const now = new Date();
139
+ const shortId = session.id.slice(0, 8);
140
+ const timestamp = now.toISOString().replaceAll(/[-:]/g, '').replace(/T/, '-').slice(0, 15);
141
+ const defaultName = `kimi-export-${shortId}-${timestamp}.md`;
142
+
143
+ const trimmedArgs = args.trim();
144
+ const outputPath = trimmedArgs.length > 0
145
+ ? resolve(trimmedArgs)
146
+ : resolve(host.state.appState.workDir, defaultName);
147
+
148
+ const md = buildExportMarkdown({
149
+ sessionId: session.id,
150
+ workDir: host.state.appState.workDir,
151
+ history: context.history,
152
+ tokenCount: context.tokenCount,
153
+ now,
154
+ });
155
+
156
+ await mkdir(dirname(outputPath), { recursive: true });
157
+ await writeFile(outputPath, md, 'utf-8');
158
+
159
+ const linked = toTerminalHyperlink(outputPath, pathToFileURL(outputPath).href);
160
+ host.showNotice(`Exported ${String(context.history.length)} messages`, linked);
161
+ } catch (error) {
162
+ const msg = formatErrorMessage(error);
163
+ host.showError(`Failed to export session: ${msg}`);
164
+ }
165
+ }
166
+
167
+ export async function handleExportDebugZipCommand(host: SlashCommandHost): Promise<void> {
168
+ const session = host.session;
169
+ if (session === undefined) {
170
+ host.showError(NO_ACTIVE_SESSION_MESSAGE);
171
+ return;
172
+ }
173
+
174
+ host.showStatus('Exporting session…');
175
+ try {
176
+ const installSource = await detectInstallSource();
177
+ const shellEnv = detectShellEnvironment();
178
+ const result = await host.harness.exportSession({
179
+ id: session.id,
180
+ version: host.state.appState.version,
181
+ installSource,
182
+ shellEnv,
183
+ includeGlobalLog: true,
184
+ });
185
+ const linked = toTerminalHyperlink(result.zipPath, pathToFileURL(result.zipPath).href);
186
+ host.showNotice('Export complete', linked);
187
+ } catch (error) {
188
+ const msg = formatErrorMessage(error);
189
+ host.showError(`Failed to export session: ${msg}`);
190
+ }
191
+ }
192
+
193
+ export async function handleInitCommand(host: SlashCommandHost): Promise<void> {
194
+ const session = host.session;
195
+ if (host.state.appState.model.trim().length === 0 || session === undefined) {
196
+ host.showError(LLM_NOT_SET_MESSAGE);
197
+ return;
198
+ }
199
+
200
+ host.deferUserMessages = true;
201
+ host.beginSessionRequest();
202
+ try {
203
+ await session.init();
204
+ host.track('init_complete');
205
+ host.streamingUI.finalizeTurn((item) => {
206
+ host.sendQueuedMessage(session, item);
207
+ });
208
+ } catch (error) {
209
+ if (isAbortError(error)) {
210
+ host.setAppState({ streamingPhase: 'idle' });
211
+ host.resetLivePane();
212
+ return;
213
+ }
214
+ const msg = error instanceof Error ? error.message : String(error);
215
+ host.failSessionRequest(`Init failed: ${msg}`);
216
+ } finally {
217
+ host.deferUserMessages = false;
218
+ }
219
+ }
apps/kimi-code/src/tui/commands/skills.ts ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Session, SkillSummary } from '@moonshot-ai/kimi-code-sdk';
2
+
3
+ import type { KimiSlashCommand } from './types';
4
+
5
+ export type SkillListSession = Pick<Session, 'listSkills'>;
6
+
7
+ export interface SkillSlashCommands {
8
+ readonly commands: readonly KimiSlashCommand[];
9
+ readonly commandMap: ReadonlyMap<string, string>;
10
+ }
11
+
12
+ export function isUserActivatableSkill(skill: SkillSummary): boolean {
13
+ return (
14
+ skill.type === undefined ||
15
+ skill.type === 'prompt' ||
16
+ skill.type === 'inline' ||
17
+ skill.type === 'flow'
18
+ );
19
+ }
20
+
21
+ function compareSkillSlashCommands(a: SkillSummary, b: SkillSummary): number {
22
+ return (
23
+ getSkillSlashCommandGroup(a.source) - getSkillSlashCommandGroup(b.source) ||
24
+ a.name.localeCompare(b.name)
25
+ );
26
+ }
27
+
28
+ function getSkillSlashCommandGroup(source: SkillSummary['source']): number {
29
+ return source === 'builtin' ? 0 : 1;
30
+ }
31
+
32
+ export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillSlashCommands {
33
+ const commandMap = new Map<string, string>();
34
+ const sortedSkills = [...skills].toSorted(compareSkillSlashCommands);
35
+ const commands = sortedSkills.filter(isUserActivatableSkill).map((skill) => {
36
+ const commandName =
37
+ skill.source === 'builtin' || skill.isSubSkill === true
38
+ ? skill.name
39
+ : `skill:${skill.name}`;
40
+ commandMap.set(commandName, skill.name);
41
+ return {
42
+ name: commandName,
43
+ aliases: [],
44
+ description: skill.description ?? '',
45
+ };
46
+ });
47
+ return { commands, commandMap };
48
+ }
apps/kimi-code/src/tui/commands/swarm.ts ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { PermissionMode } from '@moonshot-ai/kimi-code-sdk';
2
+
3
+ import {
4
+ SwarmStartPermissionPromptComponent,
5
+ type SwarmStartPermissionChoice,
6
+ } from '../components/dialogs/swarm-start-permission-prompt';
7
+ import {
8
+ SwarmModeMarkerComponent,
9
+ type SwarmModeMarkerState,
10
+ } from '../components/messages/swarm-markers';
11
+ import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
12
+ import { formatErrorMessage } from '../utils/event-payload';
13
+ import { PERMISSION_MODE_DESCRIPTIONS, PERMISSION_MODE_DISPLAY_NAMES } from '../utils/permission-mode';
14
+ import type { SlashCommandHost } from './dispatch';
15
+
16
+ export async function handleSwarmCommand(host: SlashCommandHost, args: string): Promise<void> {
17
+ if (host.session === undefined) {
18
+ host.showError(NO_ACTIVE_SESSION_MESSAGE);
19
+ return;
20
+ }
21
+
22
+ const prompt = args.trim();
23
+ const mode = swarmModeSubcommand(prompt);
24
+ if (mode !== undefined) {
25
+ await applySwarmMode(host, mode, `/swarm ${prompt}`);
26
+ return;
27
+ }
28
+
29
+ if (prompt.length === 0) {
30
+ await applySwarmMode(host, !host.state.appState.swarmMode, '/swarm');
31
+ return;
32
+ }
33
+
34
+ if (host.state.appState.model.trim().length === 0) {
35
+ host.showError(LLM_NOT_SET_MESSAGE);
36
+ return;
37
+ }
38
+
39
+ if (host.state.appState.permissionMode === 'manual') {
40
+ showSwarmStartPermissionPrompt(host, `/swarm ${prompt}`, 'Swarm task not started.', (choice) =>
41
+ startSwarmWithPermission(host, prompt, choice),
42
+ );
43
+ return;
44
+ }
45
+
46
+ await startSwarmTask(host, prompt);
47
+ }
48
+
49
+ function showSwarmStartPermissionPrompt(
50
+ host: SlashCommandHost,
51
+ commandText: string,
52
+ cancelStatus: string,
53
+ onSelect: (choice: SwarmStartPermissionChoice) => Promise<void>,
54
+ ): void {
55
+ const cancelStart = (): void => {
56
+ host.restoreInputText(commandText);
57
+ host.showStatus(cancelStatus);
58
+ };
59
+ host.mountEditorReplacement(
60
+ new SwarmStartPermissionPromptComponent({
61
+ onSelect: (choice) => {
62
+ host.restoreEditor();
63
+ void onSelect(choice);
64
+ },
65
+ onCancel: cancelStart,
66
+ }),
67
+ );
68
+ }
69
+
70
+ async function startSwarmWithPermission(
71
+ host: SlashCommandHost,
72
+ prompt: string,
73
+ choice: SwarmStartPermissionChoice,
74
+ ): Promise<void> {
75
+ if (choice === 'auto' || choice === 'yolo') {
76
+ if (!(await setPermissionForSwarm(host, choice))) return;
77
+ }
78
+ await startSwarmTask(host, prompt);
79
+ }
80
+
81
+ async function setPermissionForSwarm(host: SlashCommandHost, mode: PermissionMode): Promise<boolean> {
82
+ try {
83
+ await host.requireSession().setPermission(mode);
84
+ } catch (error) {
85
+ host.showError(`Failed to set permission mode: ${formatErrorMessage(error)}`);
86
+ return false;
87
+ }
88
+ host.setAppState({ permissionMode: mode });
89
+ host.showNotice(`Permission mode: ${PERMISSION_MODE_DISPLAY_NAMES[mode]}`);
90
+ host.showStatus(PERMISSION_MODE_DESCRIPTIONS[mode], 'warning');
91
+ return true;
92
+ }
93
+
94
+ async function startSwarmTask(host: SlashCommandHost, prompt: string): Promise<void> {
95
+ if (!host.state.appState.swarmMode && !(await setSwarmMode(host, true, 'task'))) {
96
+ return;
97
+ }
98
+ renderSwarmModeMarker(host, 'active');
99
+ host.sendNormalUserInput(prompt);
100
+ }
101
+
102
+ async function applySwarmMode(
103
+ host: SlashCommandHost,
104
+ enabled: boolean,
105
+ commandText: string,
106
+ ): Promise<void> {
107
+ if (enabled && host.state.appState.swarmMode) {
108
+ host.showStatus('Swarm mode is already on.');
109
+ return;
110
+ }
111
+ if (!enabled && !host.state.appState.swarmMode) {
112
+ host.showStatus('Swarm mode is already off.');
113
+ return;
114
+ }
115
+ if (enabled && host.state.appState.permissionMode === 'manual') {
116
+ showSwarmStartPermissionPrompt(host, commandText, 'Swarm mode not enabled.', async (choice) => {
117
+ if ((choice === 'auto' || choice === 'yolo') && !(await setPermissionForSwarm(host, choice))) {
118
+ return;
119
+ }
120
+ if (!(await setSwarmMode(host, true, 'manual'))) return;
121
+ renderSwarmModeMarker(host, 'active');
122
+ });
123
+ return;
124
+ }
125
+ if (!(await setSwarmMode(host, enabled, 'manual'))) return;
126
+ renderSwarmModeMarker(host, enabled ? 'active' : 'inactive');
127
+ }
128
+
129
+ async function setSwarmMode(
130
+ host: SlashCommandHost,
131
+ enabled: boolean,
132
+ trigger: 'manual' | 'task',
133
+ ): Promise<boolean> {
134
+ try {
135
+ await host.requireSession().setSwarmMode(enabled, trigger);
136
+ } catch (error) {
137
+ host.showError(
138
+ `Failed to ${enabled ? 'enable' : 'disable'} swarm mode: ${formatErrorMessage(error)}`,
139
+ );
140
+ return false;
141
+ }
142
+ host.setAppState({ swarmMode: enabled });
143
+ host.state.swarmModeEntry = enabled ? trigger : undefined;
144
+ return true;
145
+ }
146
+
147
+ function swarmModeSubcommand(input: string): boolean | undefined {
148
+ const command = input.toLowerCase();
149
+ if (command === 'on') return true;
150
+ if (command === 'off') return false;
151
+ return undefined;
152
+ }
153
+
154
+ function renderSwarmModeMarker(host: SlashCommandHost, state: SwarmModeMarkerState): void {
155
+ host.state.transcriptContainer.addChild(
156
+ new SwarmModeMarkerComponent(state),
157
+ );
158
+ host.state.ui.requestRender();
159
+ }
apps/kimi-code/src/tui/commands/tower.ts ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Session } from '@moonshot-ai/kimi-code-sdk';
2
+
3
+ import { TOWER_STATUS_PROMPT, TOWER_TEARDOWN_PROMPT } from '../constant/kimi-tui';
4
+ import { formatErrorMessage } from '../utils/event-payload';
5
+ import type { SlashCommandHost } from './dispatch';
6
+
7
+ export async function handleTowerCommand(host: SlashCommandHost, args: string): Promise<void> {
8
+ const input = args.trim();
9
+ const sub = input.toLowerCase();
10
+
11
+ if (sub === 'on') {
12
+ await applyTowerMode(host, true);
13
+ return;
14
+ }
15
+ if (sub === 'off') {
16
+ await applyTowerMode(host, false);
17
+ return;
18
+ }
19
+ if (sub === '' || sub === 'status') {
20
+ host.sendNormalUserInput(TOWER_STATUS_PROMPT);
21
+ return;
22
+ }
23
+ if (sub === 'teardown') {
24
+ host.sendNormalUserInput(TOWER_TEARDOWN_PROMPT);
25
+ return;
26
+ }
27
+
28
+ await startTowerWithBase(host, input);
29
+ }
30
+
31
+ async function startTowerWithBase(host: SlashCommandHost, base: string): Promise<void> {
32
+ // `/tower <base>` is manual activation too: it turns tower mode on and pins
33
+ // the branch missions merge back into (the engine validates that it is a
34
+ // local branch). Only the agent can never enter the mode by itself.
35
+ const wasActive = host.state.appState.towerMode;
36
+ if (!(await setTowerMode(host, true, base))) return;
37
+ host.showNotice(wasActive ? `Tower base: ${base}` : `Tower mode: ON (base: ${base})`);
38
+ }
39
+
40
+ async function applyTowerMode(host: SlashCommandHost, enabled: boolean): Promise<void> {
41
+ const wasActive = host.state.appState.towerMode;
42
+ // The setter is idempotent engine-side, so always reassert — a stale cache
43
+ // must not leave the authoritative mode unchanged.
44
+ if (!(await setTowerMode(host, enabled))) return;
45
+ if (wasActive === enabled) {
46
+ host.showStatus(`Tower mode is already ${enabled ? 'on' : 'off'}.`);
47
+ return;
48
+ }
49
+ host.showNotice(enabled ? 'Tower mode: ON' : 'Tower mode: OFF');
50
+ }
51
+
52
+ async function setTowerMode(
53
+ host: SlashCommandHost,
54
+ enabled: boolean,
55
+ base?: string,
56
+ ): Promise<boolean> {
57
+ const session = await requireSessionEnsured(host);
58
+ if (session === undefined) return false;
59
+ try {
60
+ await session.setTowerMode(enabled, base);
61
+ // The engine may silently refuse entry (flag off, feature not assembled
62
+ // until a restart, another session owning the workspace tower) — confirm
63
+ // the mode actually took before reporting success.
64
+ const status = await session.getStatus();
65
+ const effective = status.towerMode ?? false;
66
+ if (effective !== enabled) {
67
+ host.setAppState({ towerMode: effective });
68
+ host.showError(
69
+ enabled
70
+ ? '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.'
71
+ : 'Tower mode could not be disabled.',
72
+ );
73
+ return false;
74
+ }
75
+ } catch (error) {
76
+ host.showError(
77
+ `Failed to ${enabled ? 'enable' : 'disable'} tower mode: ${formatErrorMessage(error)}`,
78
+ );
79
+ return false;
80
+ }
81
+ host.setAppState({ towerMode: enabled });
82
+ return true;
83
+ }
84
+
85
+ async function requireSessionEnsured(host: SlashCommandHost): Promise<Session | undefined> {
86
+ if (host.session !== undefined) return host.session;
87
+ // v2 session-less: lazy-create the session, then toggle — the same path
88
+ // the first prompt takes.
89
+ return host.ensureSession();
90
+ }
apps/kimi-code/src/tui/commands/types.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AutocompleteItem, SlashCommand } from '@moonshot-ai/pi-tui';
2
+
3
+ export type SlashCommandAvailability = 'always' | 'idle-only';
4
+
5
+ export interface KimiSlashCommand<Name extends string = string> extends SlashCommand {
6
+ readonly name: Name;
7
+ readonly aliases: readonly string[];
8
+ readonly description: string;
9
+ readonly priority?: number;
10
+ readonly availability?: SlashCommandAvailability | ((args: string) => SlashCommandAvailability);
11
+ /** When set, the command is hidden from the palette and blocked unless this flag is enabled.
12
+ * A plain string: the gating flag may live in either engine's registry (v1 core or v2 domain). */
13
+ readonly experimentalFlag?: string;
14
+ /**
15
+ * Generic argument autocompletion. `argumentPrefix` is the text typed after
16
+ * `/<command> `; return suggestions or `null`. Declared as a plain function
17
+ * property (not a method) so passing it around is `this`-free. Adapted to
18
+ * pi-tui's `getArgumentCompletions` in the autocomplete setup.
19
+ */
20
+ readonly completeArgs?: (argumentPrefix: string) => AutocompleteItem[] | null;
21
+ }
22
+
23
+ export interface ParsedSlashInput {
24
+ readonly name: string;
25
+ readonly args: string;
26
+ }
27
+
28
+ export type SlashCommandBusyReason = 'streaming' | 'compacting';
29
+
30
+ export type SlashCommandInvalidReason = 'unknown';
apps/kimi-code/src/tui/commands/undo.ts ADDED
@@ -0,0 +1,558 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Component } from '@moonshot-ai/pi-tui';
2
+ import type { ContextMessage } from '@moonshot-ai/kimi-code-sdk';
3
+ import { isKimiError } from '@moonshot-ai/kimi-code-sdk';
4
+
5
+ import { WelcomeComponent } from '../components/chrome/welcome';
6
+ import { CompactionComponent } from '../components/dialogs/compaction';
7
+ import {
8
+ UndoSelectorComponent,
9
+ type UndoChoice,
10
+ } from '../components/dialogs/undo-selector';
11
+ import { AgentGroupComponent } from '../components/messages/agent-group';
12
+ import { AgentSwarmProgressComponent } from '../components/messages/agent-swarm-progress';
13
+ import { AssistantMessageComponent } from '../components/messages/assistant-message';
14
+ import { BackgroundAgentStatusComponent } from '../components/messages/background-agent-status';
15
+ import { CronMessageComponent } from '../components/messages/cron-message';
16
+ import { ReadGroupComponent } from '../components/messages/read-group';
17
+ import { SkillActivationComponent } from '../components/messages/skill-activation';
18
+ import { PluginCommandComponent } from '../components/messages/plugin-command';
19
+ import { ThinkingComponent } from '../components/messages/thinking';
20
+ import { ToolCallComponent } from '../components/messages/tool-call';
21
+ import { UserMessageComponent } from '../components/messages/user-message';
22
+ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
23
+ import type { TranscriptEntry } from '../types';
24
+ import { formatErrorMessage } from '../utils/event-payload';
25
+ import { getTranscriptComponentEntry } from '../utils/transcript-component-metadata';
26
+ import { nextTranscriptId } from '../utils/transcript-id';
27
+ import type { SlashCommandHost } from './dispatch';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Undo command
31
+ // ---------------------------------------------------------------------------
32
+
33
+ interface UndoAvailability {
34
+ readonly maxCount: number;
35
+ readonly stoppedAtCompaction: boolean;
36
+ }
37
+
38
+ type UndoSessionContext = Awaited<
39
+ ReturnType<NonNullable<SlashCommandHost['session']>['getContext']>
40
+ >;
41
+
42
+ const UNDO_LIMIT_STATUS_TURN_ID = 'undo-limit-status';
43
+
44
+ export async function handleUndoCommand(
45
+ host: SlashCommandHost,
46
+ args: string = '',
47
+ ): Promise<void> {
48
+ if (host.state.appState.streamingPhase !== 'idle') {
49
+ host.showError('Cannot undo while streaming — press Esc or Ctrl-C first.');
50
+ return;
51
+ }
52
+
53
+ const trimmed = args.trim();
54
+ if (trimmed.length === 0) {
55
+ await showUndoSelector(host);
56
+ return;
57
+ }
58
+
59
+ const count = parseUndoCount(trimmed);
60
+ if (count === undefined) {
61
+ host.showError('Usage: /undo [count], where count is a positive integer.');
62
+ return;
63
+ }
64
+
65
+ const session = host.session;
66
+ if (session === undefined) {
67
+ host.showError(NO_ACTIVE_SESSION_MESSAGE);
68
+ return;
69
+ }
70
+
71
+ const availability = await resolveUndoAvailability(host);
72
+ if (count > availability.maxCount) {
73
+ showUndoLimitStatus(host, formatUndoLimitMessage(count, availability));
74
+ return;
75
+ }
76
+
77
+ await undoByCount(host, count);
78
+ }
79
+
80
+ async function undoByCount(host: SlashCommandHost, count: number): Promise<boolean> {
81
+ const session = host.session;
82
+ if (session === undefined) {
83
+ host.showError(NO_ACTIVE_SESSION_MESSAGE);
84
+ return false;
85
+ }
86
+
87
+ const entries = host.state.transcriptEntries;
88
+ const lastUserIndex = findUndoAnchorEntryIndex(entries, count);
89
+ if (lastUserIndex === undefined) {
90
+ showUndoLimitStatus(host, 'Nothing to undo.');
91
+ return false;
92
+ }
93
+ // When the anchor is a bundled prompt, its skill activation cards sit
94
+ // before it (contiguous, marked at submission/replay time) and are removed
95
+ // together with it.
96
+
97
+ try {
98
+ await session.undoHistory(count);
99
+ } catch (error) {
100
+ const limit = undoLimitFromError(error);
101
+ if (limit !== undefined) {
102
+ showUndoLimitStatus(host, formatUndoLimitMessage(limit.requestedCount, limit));
103
+ return false;
104
+ }
105
+ const message = formatErrorMessage(error);
106
+ host.showError(`Failed to undo: ${message}`);
107
+ return false;
108
+ }
109
+ host.noteContextCut?.();
110
+ await refreshTodoPanel(host);
111
+
112
+ const children = host.state.transcriptContainer.children;
113
+ const lastUserComponentIndex = findUndoAnchorComponentIndex(children, count);
114
+ if (lastUserComponentIndex !== undefined) {
115
+ // A hook result may interleave between the bundle's cards and its prompt
116
+ // and survives undo in the engine, so it is skipped (kept) while the
117
+ // cards around it are removed. Only the contiguous marked run belongs to
118
+ // this submission: a standalone `/skill` card is unmarked and never
119
+ // swept. Structural removal only: the container's ref-checked render
120
+ // cache detects the child-list change; no tree-wide invalidate needed.
121
+ const groupChildIndices = new Set<number>();
122
+ for (let i = lastUserComponentIndex - 1; i >= 0; i--) {
123
+ const entry = getTranscriptComponentEntry(children[i]!);
124
+ if (entry?.bundledWithPrompt === true) {
125
+ groupChildIndices.add(i);
126
+ continue;
127
+ }
128
+ if (entry?.hookResult === true) continue;
129
+ break;
130
+ }
131
+ removeUndoContextComponents(children, lastUserComponentIndex, groupChildIndices);
132
+ }
133
+
134
+ const groupEntryIndices = new Set<number>();
135
+ for (let i = lastUserIndex - 1; i >= 0; i--) {
136
+ const prev = entries[i];
137
+ if (prev?.bundledWithPrompt === true) {
138
+ groupEntryIndices.add(i);
139
+ continue;
140
+ }
141
+ if (prev?.hookResult === true) continue;
142
+ break;
143
+ }
144
+ const preservedEntries = entries.filter(
145
+ (entry, index) =>
146
+ !(
147
+ (index >= lastUserIndex || groupEntryIndices.has(index)) &&
148
+ isUndoContextEntry(entry)
149
+ ),
150
+ );
151
+ entries.splice(0, entries.length, ...preservedEntries);
152
+
153
+ if (entries.length === 0) {
154
+ renderWelcome(host);
155
+ }
156
+
157
+ host.state.ui.requestRender();
158
+ return true;
159
+ }
160
+
161
+ async function refreshTodoPanel(host: SlashCommandHost): Promise<void> {
162
+ const session = host.session;
163
+ if (session === undefined) return;
164
+ try {
165
+ const todos = await session.getTodos();
166
+ if (todos.length > 0 && todos.every((todo) => todo.status === 'done')) {
167
+ host.streamingUI.setTodoList([]);
168
+ return;
169
+ }
170
+ host.streamingUI.setTodoList(todos);
171
+ } catch {
172
+ return;
173
+ }
174
+ }
175
+
176
+ async function showUndoSelector(host: SlashCommandHost): Promise<void> {
177
+ if (host.session === undefined) {
178
+ host.showError(NO_ACTIVE_SESSION_MESSAGE);
179
+ return;
180
+ }
181
+
182
+ const availability = await resolveUndoAvailability(host);
183
+ const choices = createUndoChoices(
184
+ host.state.transcriptEntries,
185
+ host.state.transcriptContainer.children,
186
+ availability.maxCount,
187
+ );
188
+ if (choices.length === 0) {
189
+ showUndoLimitStatus(host, formatNothingToUndoMessage(availability));
190
+ return;
191
+ }
192
+
193
+ host.mountEditorReplacement(
194
+ new UndoSelectorComponent({
195
+ choices,
196
+ onSelect: (choice) => {
197
+ void undoByCount(host, choice.count).then((undone) => {
198
+ if (undone) {
199
+ host.restoreInputText(choice.input);
200
+ return;
201
+ }
202
+ host.restoreEditor();
203
+ });
204
+ },
205
+ onCancel: () => {
206
+ host.restoreEditor();
207
+ },
208
+ }),
209
+ );
210
+ }
211
+
212
+ function parseUndoCount(args: string): number | undefined {
213
+ const value = args.trim();
214
+ if (value.length === 0) return 1;
215
+ if (!/^[1-9]\d*$/.test(value)) return undefined;
216
+ const count = Number(value);
217
+ return Number.isSafeInteger(count) ? count : undefined;
218
+ }
219
+
220
+ async function resolveUndoAvailability(
221
+ host: SlashCommandHost,
222
+ ): Promise<UndoAvailability> {
223
+ const local = undoAvailabilityFromTranscript(
224
+ host.state.transcriptEntries,
225
+ host.state.transcriptContainer.children,
226
+ );
227
+ const context = await getSessionContext(host.session);
228
+ if (context === undefined) return local;
229
+
230
+ const activeContext = undoAvailabilityFromContext(context.history);
231
+ return {
232
+ maxCount: Math.min(local.maxCount, activeContext.maxCount),
233
+ stoppedAtCompaction:
234
+ local.stoppedAtCompaction || activeContext.stoppedAtCompaction,
235
+ };
236
+ }
237
+
238
+ async function getSessionContext(
239
+ session: SlashCommandHost['session'],
240
+ ): Promise<UndoSessionContext | undefined> {
241
+ const getContext = (
242
+ session as { getContext?: () => Promise<UndoSessionContext> } | undefined
243
+ )?.getContext;
244
+ if (session === undefined || getContext === undefined) return undefined;
245
+ try {
246
+ return await getContext.call(session);
247
+ } catch {
248
+ return undefined;
249
+ }
250
+ }
251
+
252
+ function undoAvailabilityFromTranscript(
253
+ entries: readonly TranscriptEntry[],
254
+ children: readonly Component[],
255
+ ): UndoAvailability {
256
+ const { anchors, stoppedAtCompaction } = activeUndoAnchorEntries(entries, children);
257
+ return {
258
+ maxCount: anchors.length,
259
+ stoppedAtCompaction,
260
+ };
261
+ }
262
+
263
+ function undoAvailabilityFromContext(
264
+ history: readonly ContextMessage[],
265
+ ): UndoAvailability {
266
+ let maxCount = 0;
267
+ let stoppedAtCompaction = false;
268
+
269
+ for (let i = history.length - 1; i >= 0; i--) {
270
+ const message = history[i];
271
+ if (message === undefined) continue;
272
+ if (message.origin?.kind === 'injection') continue;
273
+ if (message.origin?.kind === 'compaction_summary') {
274
+ stoppedAtCompaction = true;
275
+ break;
276
+ }
277
+ if (isContextUndoAnchor(message)) maxCount++;
278
+ }
279
+
280
+ return { maxCount, stoppedAtCompaction };
281
+ }
282
+
283
+ function isContextUndoAnchor(message: ContextMessage): boolean {
284
+ if (message.role !== 'user') return false;
285
+ const origin = message.origin;
286
+ if (origin === undefined || origin.kind === 'user') return true;
287
+ if (origin.kind === 'skill_activation') {
288
+ return origin.trigger === 'user-slash';
289
+ }
290
+ if (origin.kind === 'plugin_command') {
291
+ return origin.trigger === 'user-slash';
292
+ }
293
+ return false;
294
+ }
295
+
296
+ function createUndoChoices(
297
+ entries: readonly TranscriptEntry[],
298
+ children: readonly Component[],
299
+ maxCount: number,
300
+ ): readonly UndoChoice[] {
301
+ if (maxCount <= 0) return [];
302
+ const anchors = activeUndoAnchorEntries(entries, children).anchors.slice(-maxCount);
303
+ return anchors.map((entry, index) => ({
304
+ id: entry.id,
305
+ count: anchors.length - index,
306
+ input: formatUndoChoiceInput(entry),
307
+ label: formatUndoChoiceLabel(entry),
308
+ }));
309
+ }
310
+
311
+ function activeUndoAnchorEntries(
312
+ entries: readonly TranscriptEntry[],
313
+ children: readonly Component[],
314
+ ): { readonly anchors: readonly TranscriptEntry[]; readonly stoppedAtCompaction: boolean } {
315
+ const lastCompactionChildIndex = children.findLastIndex(
316
+ (child) => child instanceof CompactionComponent,
317
+ );
318
+ if (lastCompactionChildIndex >= 0) {
319
+ return {
320
+ anchors: children
321
+ .slice(lastCompactionChildIndex + 1)
322
+ .map((child) => getTranscriptComponentEntry(child))
323
+ .filter((entry): entry is TranscriptEntry => entry !== undefined)
324
+ .filter(isUndoAnchorEntry),
325
+ stoppedAtCompaction: true,
326
+ };
327
+ }
328
+
329
+ const lastCompactionEntryIndex = entries.findLastIndex(
330
+ (entry) => entry.compactionData !== undefined,
331
+ );
332
+ const activeEntries =
333
+ lastCompactionEntryIndex >= 0 ? entries.slice(lastCompactionEntryIndex + 1) : entries;
334
+ return {
335
+ anchors: activeEntries.filter(isUndoAnchorEntry),
336
+ stoppedAtCompaction: lastCompactionEntryIndex >= 0,
337
+ };
338
+ }
339
+
340
+ function formatUndoChoiceLabel(
341
+ entry: TranscriptEntry,
342
+ ): string {
343
+ if (entry.kind === 'skill_activation') {
344
+ const name = singleLine(
345
+ entry.skillName ?? entry.content.replace(/^Activated skill:\s*/, ''),
346
+ );
347
+ const args = singleLine(entry.skillArgs ?? '');
348
+ if (name.length === 0) return 'Skill: unknown';
349
+ return args.length > 0 ? `/${name} ${args}` : `/${name}`;
350
+ }
351
+ if (entry.kind === 'plugin_command' && entry.pluginCommandData !== undefined) {
352
+ return formatPluginCommandSlash(entry.pluginCommandData) ?? 'User message';
353
+ }
354
+
355
+ const content = singleLine(entry.content);
356
+ const imageCount = entry.imageAttachmentIds?.length ?? 0;
357
+ if (content.length > 0) return content;
358
+ if (imageCount > 0) {
359
+ return `User message (${String(imageCount)} ${imageCount === 1 ? 'image' : 'images'})`;
360
+ }
361
+ return 'User message';
362
+ }
363
+
364
+ function formatUndoChoiceInput(entry: TranscriptEntry): string {
365
+ if (entry.kind === 'skill_activation') {
366
+ const name = singleLine(
367
+ entry.skillName ?? entry.content.replace(/^Activated skill:\s*/, ''),
368
+ );
369
+ const args = singleLine(entry.skillArgs ?? '');
370
+ if (name.length === 0) return '';
371
+ return args.length > 0 ? `/${name} ${args}` : `/${name}`;
372
+ }
373
+ if (entry.kind === 'plugin_command' && entry.pluginCommandData !== undefined) {
374
+ return formatPluginCommandSlash(entry.pluginCommandData) ?? entry.content;
375
+ }
376
+ return entry.content;
377
+ }
378
+
379
+ function formatPluginCommandSlash(data: NonNullable<TranscriptEntry['pluginCommandData']>): string | undefined {
380
+ const name = `${data.pluginId}:${data.commandName}`;
381
+ const args = singleLine(data.args ?? '');
382
+ if (name.length === 0) return undefined;
383
+ return args.length > 0 ? `/${name} ${args}` : `/${name}`;
384
+ }
385
+
386
+ function singleLine(text: string): string {
387
+ return text.replaceAll(/\s+/g, ' ').trim();
388
+ }
389
+
390
+ function formatUndoLimitMessage(
391
+ requestedCount: number,
392
+ availability: UndoAvailability,
393
+ ): string {
394
+ const reason = availability.stoppedAtCompaction ? ' after the last compaction' : '';
395
+ const requested = formatPromptCount(requestedCount);
396
+ const max = formatPromptCount(availability.maxCount);
397
+ return `Cannot undo ${requested}; only ${max} can be undone in the active context${reason}.`;
398
+ }
399
+
400
+ function formatNothingToUndoMessage(availability: UndoAvailability): string {
401
+ if (availability.stoppedAtCompaction) {
402
+ return 'Nothing to undo after the last compaction.';
403
+ }
404
+ return 'Nothing to undo.';
405
+ }
406
+
407
+ function formatPromptCount(count: number): string {
408
+ return `${String(count)} ${count === 1 ? 'prompt' : 'prompts'}`;
409
+ }
410
+
411
+ function showUndoLimitStatus(host: SlashCommandHost, message: string): void {
412
+ host.appendTranscriptEntry({
413
+ id: nextTranscriptId(),
414
+ kind: 'status',
415
+ turnId: UNDO_LIMIT_STATUS_TURN_ID,
416
+ renderMode: 'plain',
417
+ content: message,
418
+ });
419
+ }
420
+
421
+ function undoLimitFromError(
422
+ error: unknown,
423
+ ): (UndoAvailability & { readonly requestedCount: number }) | undefined {
424
+ if (!isKimiError(error)) return undefined;
425
+ const details = error.details;
426
+ if (details?.['reason'] !== 'undo_limit') return undefined;
427
+ const requestedCount = details['requestedCount'];
428
+ const maxCount = details['undoableCount'];
429
+ const stoppedAtCompaction = details['stoppedAtCompaction'];
430
+ if (
431
+ typeof requestedCount !== 'number' ||
432
+ typeof maxCount !== 'number' ||
433
+ typeof stoppedAtCompaction !== 'boolean'
434
+ ) {
435
+ return undefined;
436
+ }
437
+ return { requestedCount, maxCount, stoppedAtCompaction };
438
+ }
439
+
440
+ function isUndoAnchorEntry(entry: TranscriptEntry): boolean {
441
+ return (
442
+ entry.kind === 'user' ||
443
+ (entry.kind === 'skill_activation' &&
444
+ entry.skillTrigger === 'user-slash' &&
445
+ entry.bundledWithPrompt !== true) ||
446
+ entry.kind === 'plugin_command'
447
+ );
448
+ }
449
+
450
+ function findUndoAnchorEntryIndex(
451
+ entries: readonly TranscriptEntry[],
452
+ count: number,
453
+ ): number | undefined {
454
+ let found = 0;
455
+ for (let i = entries.length - 1; i >= 0; i--) {
456
+ const entry = entries[i];
457
+ if (entry !== undefined && isUndoAnchorEntry(entry)) {
458
+ found++;
459
+ if (found === count) return i;
460
+ }
461
+ }
462
+ return undefined;
463
+ }
464
+
465
+ function isUndoContextEntry(entry: TranscriptEntry): boolean {
466
+ switch (entry.kind) {
467
+ case 'user':
468
+ case 'assistant':
469
+ case 'tool_call':
470
+ case 'thinking':
471
+ case 'skill_activation':
472
+ case 'plugin_command':
473
+ case 'cron':
474
+ return true;
475
+ case 'status':
476
+ case 'goal':
477
+ return entry.turnId !== undefined;
478
+ case 'welcome':
479
+ return false;
480
+ }
481
+ }
482
+
483
+ function findUndoAnchorComponentIndex(
484
+ children: readonly Component[],
485
+ count: number,
486
+ ): number | undefined {
487
+ let found = 0;
488
+ for (let i = children.length - 1; i >= 0; i--) {
489
+ const child = children[i];
490
+ if (child !== undefined && isUndoAnchorComponent(child)) {
491
+ found++;
492
+ if (found === count) return i;
493
+ }
494
+ }
495
+ return undefined;
496
+ }
497
+
498
+ function removeUndoContextComponents(
499
+ children: Component[],
500
+ startIndex: number,
501
+ additionalIndices: ReadonlySet<number>,
502
+ ): void {
503
+ for (let i = children.length - 1; i >= 0; i--) {
504
+ const child = children[i];
505
+ if (
506
+ child !== undefined &&
507
+ (i >= startIndex || additionalIndices.has(i)) &&
508
+ isUndoContextComponent(child)
509
+ ) {
510
+ children.splice(i, 1);
511
+ }
512
+ }
513
+ }
514
+
515
+ function isUndoAnchorComponent(child: Component): boolean {
516
+ const entry = getTranscriptComponentEntry(child);
517
+ return (
518
+ child instanceof UserMessageComponent ||
519
+ (child instanceof SkillActivationComponent &&
520
+ child.trigger === 'user-slash' &&
521
+ entry?.bundledWithPrompt !== true) ||
522
+ child instanceof PluginCommandComponent
523
+ );
524
+ }
525
+
526
+ function isUndoContextComponent(child: Component): boolean {
527
+ const entry = getTranscriptComponentEntry(child);
528
+ if (entry !== undefined) {
529
+ return isUndoContextEntry(entry);
530
+ }
531
+
532
+ return (
533
+ child instanceof UserMessageComponent ||
534
+ child instanceof AssistantMessageComponent ||
535
+ child instanceof ThinkingComponent ||
536
+ child instanceof ToolCallComponent ||
537
+ child instanceof AgentGroupComponent ||
538
+ child instanceof AgentSwarmProgressComponent ||
539
+ child instanceof ReadGroupComponent ||
540
+ child instanceof SkillActivationComponent ||
541
+ child instanceof PluginCommandComponent ||
542
+ child instanceof BackgroundAgentStatusComponent ||
543
+ child instanceof CronMessageComponent
544
+ );
545
+ }
546
+
547
+ function renderWelcome(host: SlashCommandHost): void {
548
+ if (
549
+ host.state.transcriptContainer.children.some(
550
+ (child) => child instanceof WelcomeComponent,
551
+ )
552
+ ) {
553
+ return;
554
+ }
555
+ host.state.transcriptContainer.addChild(
556
+ new WelcomeComponent(host.state.appState),
557
+ );
558
+ }