Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- apps/kimi-code/dist-web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- apps/kimi-code/dist-web/assets/arc-Bh_RIraT.js +1 -0
- apps/kimi-code/dist-web/assets/chunk-32BRIVSS-Dvm33eUZ.js +1 -0
- apps/kimi-code/dist-web/assets/clojure-P80f7IUj.js +1 -0
- apps/kimi-code/dist-web/assets/dagre-VKFMJZFB-DkhizOKY.js +4 -0
- apps/kimi-code/dist-web/assets/mindmap-definition-LN4V7U3C-BYqiMfi6.js +96 -0
- apps/kimi-code/dist-web/assets/pkl-u5AG7uiY.js +1 -0
- apps/kimi-code/dist-web/assets/timeline-definition-FHXFAJF6-CHrctaTh.js +120 -0
- apps/kimi-code/src/cli/sub/fork.ts +197 -0
- apps/kimi-code/src/cli/sub/login-flow.ts +83 -0
- apps/kimi-code/src/cli/sub/provider.ts +602 -0
- apps/kimi-code/src/cli/sub/session.ts +154 -0
- apps/kimi-code/src/cli/sub/upgrade.ts +232 -0
- apps/kimi-code/src/cli/sub/web/access-urls.ts +97 -0
- apps/kimi-code/src/cli/sub/web/deprecated-server.ts +38 -0
- apps/kimi-code/src/cli/sub/web/index.ts +34 -0
- apps/kimi-code/src/cli/sub/web/legacy-kill.ts +263 -0
- apps/kimi-code/src/cli/sub/web/networks.ts +84 -0
- apps/kimi-code/src/cli/sub/web/remote-control.ts +92 -0
- apps/kimi-code/src/cli/sub/web/rotate-token.ts +56 -0
- apps/kimi-code/src/cli/sub/web/run.ts +528 -0
- apps/kimi-code/src/cli/sub/web/shared.ts +164 -0
- apps/kimi-code/src/tui/banner/banner-config.ts +24 -0
- apps/kimi-code/src/tui/banner/banner-provider.ts +356 -0
- apps/kimi-code/src/tui/banner/state.ts +69 -0
- apps/kimi-code/src/tui/commands/add-dir.ts +107 -0
- apps/kimi-code/src/tui/commands/auth.ts +262 -0
- apps/kimi-code/src/tui/commands/btw.ts +28 -0
- apps/kimi-code/src/tui/commands/complete-args.ts +41 -0
- apps/kimi-code/src/tui/commands/config.ts +912 -0
- apps/kimi-code/src/tui/commands/copy.ts +41 -0
- apps/kimi-code/src/tui/commands/dispatch.ts +607 -0
- apps/kimi-code/src/tui/commands/experimental-flags.ts +19 -0
- apps/kimi-code/src/tui/commands/goal.ts +576 -0
- apps/kimi-code/src/tui/commands/index.ts +43 -0
- apps/kimi-code/src/tui/commands/info.ts +255 -0
- apps/kimi-code/src/tui/commands/parse.ts +14 -0
- apps/kimi-code/src/tui/commands/plugin-commands.ts +27 -0
- apps/kimi-code/src/tui/commands/plugins.ts +901 -0
- apps/kimi-code/src/tui/commands/prompts.ts +251 -0
- apps/kimi-code/src/tui/commands/provider.ts +432 -0
- apps/kimi-code/src/tui/commands/registry.ts +476 -0
- apps/kimi-code/src/tui/commands/reload.ts +86 -0
- apps/kimi-code/src/tui/commands/resolve.ts +157 -0
- apps/kimi-code/src/tui/commands/session.ts +219 -0
- apps/kimi-code/src/tui/commands/skills.ts +48 -0
- apps/kimi-code/src/tui/commands/swarm.ts +159 -0
- apps/kimi-code/src/tui/commands/tower.ts +90 -0
- apps/kimi-code/src/tui/commands/types.ts +30 -0
- 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&<(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 |
+
}
|