pi-plus 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/radius.js ADDED
@@ -0,0 +1,82 @@
1
+ import { createRequire as __piCreateRequire } from "node:module"; const require = __piCreateRequire(import.meta.url);
2
+ {
3
+ const __noEnvIndex = process.argv.indexOf("--no-env");
4
+ if (__noEnvIndex !== -1) {
5
+ process.argv.splice(__noEnvIndex, 1);
6
+ for (const __key of ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY", "CEREBRAS_API_KEY", "XAI_API_KEY", "OPENROUTER_API_KEY", "ZAI_API_KEY", "MISTRAL_API_KEY", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "AI_GATEWAY_API_KEY", "OPENCODE_API_KEY", "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN", "HF_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "AWS_PROFILE", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_BEARER_TOKEN_BEDROCK", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_CONTAINER_CREDENTIALS_FULL_URI", "AWS_WEB_IDENTITY_TOKEN_FILE", "AZURE_OPENAI_API_KEY", "AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_RESOURCE_NAME"]) delete process.env[__key];
7
+ }
8
+ }
9
+ function normalizeRadiusGatewayUrl(value){return(/^https?:\/\//iu.test(value)?value:`https://${value}`).replace(/\/+$/u,"")}var CANCEL_MESSAGE="Login cancelled",TIMEOUT_MESSAGE="Device flow timed out",SLOW_DOWN_TIMEOUT_MESSAGE="Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.";function abortableSleep(ms,signal,cancelMessage){return new Promise((resolve,reject)=>{if(signal.aborted){reject(new Error(cancelMessage));return}let onAbort=()=>{clearTimeout(timeout),reject(new Error(cancelMessage))},timeout=setTimeout(()=>{signal.removeEventListener("abort",onAbort),resolve()},ms);signal.addEventListener("abort",onAbort,{once:!0})})}async function pollOAuthDeviceCodeFlow(options){let deadline=typeof options.expiresInSeconds=="number"?Date.now()+options.expiresInSeconds*1e3:Number.POSITIVE_INFINITY,intervalMs=Math.max(1e3,Math.floor((options.intervalSeconds??5)*1e3)),slowDownResponses=0;if(options.waitBeforeFirstPoll){let remainingMs=deadline-Date.now();remainingMs>0&&await abortableSleep(Math.min(intervalMs,remainingMs),options.signal,CANCEL_MESSAGE)}for(;Date.now()<deadline;){if(options.signal.aborted)throw new Error(CANCEL_MESSAGE);let result=await options.poll();if(result.status==="complete")return result.value;if(result.status==="failed")throw new Error(result.message);result.status==="slow_down"&&(slowDownResponses+=1,intervalMs=typeof result.intervalSeconds=="number"&&Number.isFinite(result.intervalSeconds)&&result.intervalSeconds>0?Math.max(1e3,Math.floor(result.intervalSeconds*1e3)):Math.max(1e3,intervalMs+5e3));let remainingMs=deadline-Date.now();if(remainingMs<=0)break;await abortableSleep(Math.min(intervalMs,remainingMs),options.signal,CANCEL_MESSAGE)}throw new Error(slowDownResponses>0?SLOW_DOWN_TIMEOUT_MESSAGE:TIMEOUT_MESSAGE)}var LOGO_SVG='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" aria-hidden="true"><path fill="#fff" fill-rule="evenodd" d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"/><path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/></svg>';function escapeHtml(value){return value.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;").replaceAll("'","&#39;")}function renderPage(options){let title=escapeHtml(options.title),heading=escapeHtml(options.heading),message=escapeHtml(options.message),details=options.details?escapeHtml(options.details):void 0;return`<!doctype html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="utf-8" />
13
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
14
+ <title>${title}</title>
15
+ <style>
16
+ :root {
17
+ --text: #fafafa;
18
+ --text-dim: #a1a1aa;
19
+ --page-bg: #09090b;
20
+ --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
21
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
22
+ }
23
+ * { box-sizing: border-box; }
24
+ html { color-scheme: dark; }
25
+ body {
26
+ margin: 0;
27
+ min-height: 100vh;
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ padding: 24px;
32
+ background: var(--page-bg);
33
+ color: var(--text);
34
+ font-family: var(--font-sans);
35
+ text-align: center;
36
+ }
37
+ main {
38
+ width: 100%;
39
+ max-width: 560px;
40
+ display: flex;
41
+ flex-direction: column;
42
+ align-items: center;
43
+ justify-content: center;
44
+ }
45
+ .logo {
46
+ width: 72px;
47
+ height: 72px;
48
+ display: block;
49
+ margin-bottom: 24px;
50
+ }
51
+ h1 {
52
+ margin: 0 0 10px;
53
+ font-size: 28px;
54
+ line-height: 1.15;
55
+ font-weight: 650;
56
+ color: var(--text);
57
+ }
58
+ p {
59
+ margin: 0;
60
+ line-height: 1.7;
61
+ color: var(--text-dim);
62
+ font-size: 15px;
63
+ }
64
+ .details {
65
+ margin-top: 16px;
66
+ font-family: var(--font-mono);
67
+ font-size: 13px;
68
+ color: var(--text-dim);
69
+ white-space: pre-wrap;
70
+ word-break: break-word;
71
+ }
72
+ </style>
73
+ </head>
74
+ <body>
75
+ <main>
76
+ <div class="logo">${LOGO_SVG}</div>
77
+ <h1>${heading}</h1>
78
+ <p>${message}</p>
79
+ ${details?`<div class="details">${details}</div>`:""}
80
+ </main>
81
+ </body>
82
+ </html>`}function oauthSuccessHtml(message){return renderPage({title:"Authentication successful",heading:"Authentication successful",message})}function oauthErrorHtml(message,details){return renderPage({title:"Authentication failed",heading:"Authentication failed",message,details})}function base64urlEncode(bytes){let binary="";for(let byte of bytes)binary+=String.fromCharCode(byte);return btoa(binary).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}async function generatePKCE(){let verifierBytes=new Uint8Array(32);crypto.getRandomValues(verifierBytes);let verifier=base64urlEncode(verifierBytes),data=new TextEncoder().encode(verifier),hashBuffer=await crypto.subtle.digest("SHA-256",data),challenge=base64urlEncode(new Uint8Array(hashBuffer));return{verifier,challenge}}var _http=null;typeof process<"u"&&(process.versions?.node||process.versions?.bun)&&import("node:http").then(m=>{_http=m});var CALLBACK_HOST="127.0.0.1",CALLBACK_PORT=1456,CALLBACK_PATH="/oauth/callback",REDIRECT_URI=`http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`,TOKEN_EXPIRY_SKEW_MS=6e4,LOGIN_METHOD_BROWSER="browser",LOGIN_METHOD_DEVICE_CODE="device-code",OAUTH_CLIENT_ID="pi-gateway",OAUTH_SCOPE="gateway offline_access",OAUTH_DEVICE_CODE_GRANT_TYPE="urn:ietf:params:oauth:grant-type:device_code";async function loadRadiusOAuthDiscovery(gateway,signal){let response=await fetch(new URL("/v1/oauth",gateway),{headers:{accept:"application/json"},signal});if(!response.ok)throw new Error(`Could not load Radius OAuth config from ${gateway}: ${response.status} ${await response.text()}`);let discovery=await response.json();if(typeof discovery.authorizationEndpoint!="string")throw new Error(`Invalid Radius OAuth config from ${gateway}`);return{authorizationEndpoint:discovery.authorizationEndpoint}}var OAuthResponseError=class extends Error{status;oauthError;constructor(status,oauthError,description,message){let detail=oauthError?description?`${oauthError}: ${description}`:oauthError:description||String(status);super(`${message}: ${detail}`),this.status=status,this.oauthError=oauthError}};async function readOAuthResponseError(response,message){let text=await response.text().catch(()=>""),oauthError,description;if(text)try{let data=JSON.parse(text);oauthError=typeof data.error=="string"?data.error:void 0,description=typeof data.error_description=="string"?data.error_description:void 0}catch{description=text}return new OAuthResponseError(response.status,oauthError,description,message)}async function requestOAuthToken(gateway,body,signal){let response;try{response=await fetch(new URL("/v1/oauth/token",gateway),{method:"POST",headers:{accept:"application/json","content-type":"application/x-www-form-urlencoded"},body,signal})}catch(error){throw signal.aborted?new Error("Login cancelled"):error}if(!response.ok)throw await readOAuthResponseError(response,"Radius OAuth token request failed");let data=await response.json();return{type:"oauth",access:data.access_token,refresh:data.refresh_token,expires:Date.now()+data.expires_in*1e3-TOKEN_EXPIRY_SKEW_MS,scope:data.scope}}function startOAuthCallbackServer(expectedState,signal){if(!_http)throw new Error("Radius OAuth is only available in Node.js environments");let settle=()=>{},settled=!1,wait=new Promise(resolve=>{settle=resolve}),finish=code=>{settled||(settled=!0,signal.removeEventListener("abort",onAbort),settle(code))},onAbort=()=>finish(null);signal.addEventListener("abort",onAbort,{once:!0});let sendPage=(response,status,html)=>{response.statusCode=status,response.setHeader("content-type","text/html; charset=utf-8"),response.end(html)},server=_http.createServer((request,response)=>{let url=new URL(request.url??"/",REDIRECT_URI);if(url.pathname!==CALLBACK_PATH){sendPage(response,404,oauthErrorHtml("Callback route not found."));return}if(url.searchParams.get("state")!==expectedState){sendPage(response,400,oauthErrorHtml("OAuth state mismatch."));return}let error=url.searchParams.get("error");if(error){sendPage(response,400,oauthErrorHtml(url.searchParams.get("error_description")??error)),finish(null);return}let code=url.searchParams.get("code");if(!code){sendPage(response,400,oauthErrorHtml("Missing authorization code."));return}sendPage(response,200,oauthSuccessHtml("Signed in to Radius. You may now close this page.")),finish(code)});return new Promise(resolve=>{server.listen(CALLBACK_PORT,CALLBACK_HOST,()=>{resolve({waitForCode:()=>wait,close:()=>{finish(null),server.close()}})}).once("error",()=>{finish(null),resolve({waitForCode:async()=>null,close:()=>{}})})})}async function loginWithBrowser(gateway,authorizationEndpoint,interaction){let{verifier,challenge}=await generatePKCE(),state=crypto.randomUUID(),authorizeUrl=new URL(authorizationEndpoint);authorizeUrl.search=new URLSearchParams({response_type:"code",client_id:OAUTH_CLIENT_ID,redirect_uri:REDIRECT_URI,scope:OAUTH_SCOPE,code_challenge:challenge,code_challenge_method:"S256",handoff:"url",state}).toString();let callbackServer=await startOAuthCallbackServer(state,interaction.signal);interaction.notify({type:"progress",message:`Listening for OAuth callback on ${REDIRECT_URI}`}),interaction.notify({type:"auth_url",url:authorizeUrl.toString(),instructions:"Continue in your browser."});try{let code=await callbackServer.waitForCode();if(!code)throw interaction.signal.aborted?new Error("Login cancelled"):new Error("OAuth callback did not complete.");return await requestOAuthToken(gateway,new URLSearchParams({grant_type:"authorization_code",client_id:OAUTH_CLIENT_ID,redirect_uri:REDIRECT_URI,code,code_verifier:verifier}),interaction.signal)}finally{callbackServer.close()}}async function requestDeviceAuthorization(gateway,signal){let response;try{response=await fetch(new URL("/v1/oauth/device",gateway),{method:"POST",headers:{accept:"application/json","content-type":"application/x-www-form-urlencoded"},body:new URLSearchParams({client_id:OAUTH_CLIENT_ID,scope:OAUTH_SCOPE}),signal})}catch(error){throw signal.aborted?new Error("Login cancelled"):error}if(!response.ok)throw await readOAuthResponseError(response,"Radius OAuth device authorization failed");let data=await response.json();if(!data.device_code||!data.user_code||!data.verification_uri||!data.expires_in)throw new Error("Radius OAuth device authorization response is missing required fields");return{device_code:data.device_code,user_code:data.user_code,verification_uri:data.verification_uri,expires_in:data.expires_in,interval:data.interval}}async function loginWithDeviceCode(gateway,interaction){let device=await requestDeviceAuthorization(gateway,interaction.signal);return interaction.notify({type:"device_code",userCode:device.user_code,verificationUri:device.verification_uri,intervalSeconds:device.interval,expiresInSeconds:device.expires_in}),pollOAuthDeviceCodeFlow({intervalSeconds:device.interval,expiresInSeconds:device.expires_in,signal:interaction.signal,poll:async()=>{try{return{status:"complete",value:await requestOAuthToken(gateway,new URLSearchParams({grant_type:OAUTH_DEVICE_CODE_GRANT_TYPE,client_id:OAUTH_CLIENT_ID,device_code:device.device_code}),interaction.signal)}}catch(error){if(!(error instanceof OAuthResponseError))throw error;switch(error.oauthError){case"authorization_pending":return{status:"pending"};case"slow_down":return{status:"slow_down"};case"expired_token":return{status:"failed",message:"Device authorization expired."};case"access_denied":return{status:"failed",message:"Device authorization was denied."};default:throw error}}}})}function createRadiusOAuth(options){let gateway=normalizeRadiusGatewayUrl(options.gateway);return{name:options.name,async login(interaction){let loginMethod=await interaction.prompt({type:"select",message:`Sign in to ${options.name}:`,options:[{id:LOGIN_METHOD_BROWSER,label:"Sign in with browser (recommended)"},{id:LOGIN_METHOD_DEVICE_CODE,label:"Sign in with device code (when signing in from another device)"}]});if(loginMethod===LOGIN_METHOD_DEVICE_CODE)return loginWithDeviceCode(gateway,interaction);if(loginMethod===LOGIN_METHOD_BROWSER){let discovery=await loadRadiusOAuthDiscovery(gateway,interaction.signal);return loginWithBrowser(gateway,discovery.authorizationEndpoint,interaction)}throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`)},async refresh(credential,signal){return await requestOAuthToken(gateway,new URLSearchParams({grant_type:"refresh_token",client_id:OAUTH_CLIENT_ID,refresh_token:credential.refresh}),signal)},async toAuth(credential){return{apiKey:credential.access}}}}export{createRadiusOAuth};
package/xai.js ADDED
@@ -0,0 +1,9 @@
1
+ import { createRequire as __piCreateRequire } from "node:module"; const require = __piCreateRequire(import.meta.url);
2
+ {
3
+ const __noEnvIndex = process.argv.indexOf("--no-env");
4
+ if (__noEnvIndex !== -1) {
5
+ process.argv.splice(__noEnvIndex, 1);
6
+ for (const __key of ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY", "CEREBRAS_API_KEY", "XAI_API_KEY", "OPENROUTER_API_KEY", "ZAI_API_KEY", "MISTRAL_API_KEY", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "AI_GATEWAY_API_KEY", "OPENCODE_API_KEY", "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN", "HF_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "AWS_PROFILE", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_BEARER_TOKEN_BEDROCK", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_CONTAINER_CREDENTIALS_FULL_URI", "AWS_WEB_IDENTITY_TOKEN_FILE", "AZURE_OPENAI_API_KEY", "AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_RESOURCE_NAME"]) delete process.env[__key];
7
+ }
8
+ }
9
+ var CANCEL_MESSAGE="Login cancelled",TIMEOUT_MESSAGE="Device flow timed out",SLOW_DOWN_TIMEOUT_MESSAGE="Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.";function abortableSleep(ms,signal,cancelMessage){return new Promise((resolve,reject)=>{if(signal.aborted){reject(new Error(cancelMessage));return}let onAbort=()=>{clearTimeout(timeout),reject(new Error(cancelMessage))},timeout=setTimeout(()=>{signal.removeEventListener("abort",onAbort),resolve()},ms);signal.addEventListener("abort",onAbort,{once:!0})})}async function pollOAuthDeviceCodeFlow(options){let deadline=typeof options.expiresInSeconds=="number"?Date.now()+options.expiresInSeconds*1e3:Number.POSITIVE_INFINITY,intervalMs=Math.max(1e3,Math.floor((options.intervalSeconds??5)*1e3)),slowDownResponses=0;if(options.waitBeforeFirstPoll){let remainingMs=deadline-Date.now();remainingMs>0&&await abortableSleep(Math.min(intervalMs,remainingMs),options.signal,CANCEL_MESSAGE)}for(;Date.now()<deadline;){if(options.signal.aborted)throw new Error(CANCEL_MESSAGE);let result=await options.poll();if(result.status==="complete")return result.value;if(result.status==="failed")throw new Error(result.message);result.status==="slow_down"&&(slowDownResponses+=1,intervalMs=typeof result.intervalSeconds=="number"&&Number.isFinite(result.intervalSeconds)&&result.intervalSeconds>0?Math.max(1e3,Math.floor(result.intervalSeconds*1e3)):Math.max(1e3,intervalMs+5e3));let remainingMs=deadline-Date.now();if(remainingMs<=0)break;await abortableSleep(Math.min(intervalMs,remainingMs),options.signal,CANCEL_MESSAGE)}throw new Error(slowDownResponses>0?SLOW_DOWN_TIMEOUT_MESSAGE:TIMEOUT_MESSAGE)}var XAI_CLIENT_ID="b1a00492-073a-47ea-816f-4c329264a828",XAI_SCOPE="openid profile email offline_access grok-cli:access api:access",XAI_DEVICE_CODE_URL="https://auth.x.ai/oauth2/device/code",XAI_TOKEN_URL="https://auth.x.ai/oauth2/token",REFRESH_SKEW_MS=300*1e3,DEFAULT_TOKEN_LIFETIME_SECONDS=3600;function requiredString(body,field){let value=body[field];if(typeof value!="string"||value.length===0)throw new Error(`Invalid xAI OAuth response field: ${field}`);return value}function positiveNumber(body,field){let value=body[field];if(typeof value!="number"||!Number.isFinite(value)||value<=0)throw new Error(`Invalid xAI OAuth response field: ${field}`);return value}function validateVerificationUri(raw){let url;try{url=new URL(raw)}catch{throw new Error("Untrusted verification URI in xAI OAuth response")}if(url.protocol!=="https:")throw new Error("Untrusted verification URI in xAI OAuth response");return url.href}async function postForm(url,fields,signal){let response;try{response=await fetch(url,{method:"POST",headers:{Accept:"application/json","Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams(fields),signal})}catch(error){throw signal.aborted?new Error("Login cancelled"):error}let body;try{let parsed=await response.json();body=parsed&&typeof parsed=="object"&&!Array.isArray(parsed)?parsed:{}}catch{throw signal.aborted?new Error("Login cancelled"):new Error(`xAI OAuth returned invalid JSON (HTTP ${response.status})`)}return{ok:response.ok,status:response.status,body}}function requestFailure(action,response){let error=typeof response.body.error=="string"?response.body.error:void 0,description=typeof response.body.error_description=="string"?response.body.error_description:void 0,detail=[error,description].filter(Boolean).join(": ");return new Error(`xAI OAuth ${action} failed (HTTP ${response.status})${detail?`: ${detail}`:""}`)}function parseDeviceCode(body){let interval=body.interval,intervalSeconds=typeof interval=="number"&&Number.isFinite(interval)&&interval>0?interval:void 0,verificationUriComplete=typeof body.verification_uri_complete=="string"&&body.verification_uri_complete.length>0?validateVerificationUri(body.verification_uri_complete):void 0;return{deviceCode:requiredString(body,"device_code"),userCode:requiredString(body,"user_code"),verificationUri:validateVerificationUri(requiredString(body,"verification_uri")),verificationUriComplete,intervalSeconds,expiresInSeconds:positiveNumber(body,"expires_in")}}function credentialsFromTokenResponse(body,previousRefreshToken){let access=requiredString(body,"access_token"),refresh=body.refresh_token===void 0&&previousRefreshToken?previousRefreshToken:requiredString(body,"refresh_token"),expiresInSeconds=body.expires_in===void 0?DEFAULT_TOKEN_LIFETIME_SECONDS:positiveNumber(body,"expires_in");return{type:"oauth",access,refresh,expires:Date.now()+expiresInSeconds*1e3-REFRESH_SKEW_MS}}async function requestDeviceCode(signal){let response=await postForm(XAI_DEVICE_CODE_URL,{client_id:XAI_CLIENT_ID,scope:XAI_SCOPE,referrer:"pi"},signal);if(!response.ok)throw requestFailure("device authorization",response);return parseDeviceCode(response.body)}async function pollForTokens(device,signal){return pollOAuthDeviceCodeFlow({intervalSeconds:device.intervalSeconds,expiresInSeconds:device.expiresInSeconds,waitBeforeFirstPoll:!0,signal,poll:async()=>{let response=await postForm(XAI_TOKEN_URL,{grant_type:"urn:ietf:params:oauth:grant-type:device_code",client_id:XAI_CLIENT_ID,device_code:device.deviceCode},signal);if(response.ok)return{status:"complete",value:credentialsFromTokenResponse(response.body)};let error=response.body.error;if(error==="authorization_pending")return{status:"pending"};if(error==="slow_down"){let interval=response.body.interval;return{status:"slow_down",intervalSeconds:typeof interval=="number"?interval:void 0}}return error==="access_denied"||error==="authorization_denied"?{status:"failed",message:"xAI device authorization was denied"}:error==="expired_token"?{status:"failed",message:"xAI device code expired"}:{status:"failed",message:requestFailure("device token polling",response).message}}})}async function loginXai(interaction){let device=await requestDeviceCode(interaction.signal);return interaction.notify({type:"device_code",userCode:device.userCode,verificationUri:device.verificationUriComplete??device.verificationUri,intervalSeconds:device.intervalSeconds,expiresInSeconds:device.expiresInSeconds}),pollForTokens(device,interaction.signal)}async function refreshXaiToken(refreshToken,signal){let response=await postForm(XAI_TOKEN_URL,{grant_type:"refresh_token",client_id:XAI_CLIENT_ID,refresh_token:refreshToken},signal);if(!response.ok)throw requestFailure("token refresh",response);return credentialsFromTokenResponse(response.body,refreshToken)}var xaiOAuth={name:"xAI (Grok/X subscription)",isSubscription:!0,loginLabel:"Sign in with SuperGrok or X Premium",login:loginXai,refresh:(credential,signal)=>refreshXaiToken(credential.refresh,signal),async toAuth(credential){return{apiKey:credential.access}}};export{xaiOAuth};
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Bing Tong
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
@@ -1,43 +0,0 @@
1
- /**
2
- * Edge-triggered auto-compact firing.
3
- *
4
- * pi's built-in auto-compaction uses its own threshold (contextWindow −
5
- * reserveTokens, default reserve 16384). To make Claude Code's threshold
6
- * (effective window − 13000) govern, disable pi's built-in trigger in
7
- * settings.json (`"compaction": { "enabled": false }`) and let this plugin
8
- * call ctx.compact() at the CC threshold instead.
9
- *
10
- * Trigger policy: the overflow check runs on every tool task finish
11
- * (tool_execution_end). When the CC threshold is crossed, ctx.compact() fires
12
- * immediately — pi inserts the compaction between agent steps and continues
13
- * the turn with the compacted context (the same window pi's own auto-compact
14
- * and CC's autocompact use), so no work is deferred until idle.
15
- *
16
- * Edge-triggered: fires once per threshold crossing, re-arms after the
17
- * post-compaction count drops below the threshold.
18
- */
19
-
20
- export interface AutoCompactTriggerState {
21
- /** True while usage is below the threshold (re-armed after compaction). */
22
- below: boolean;
23
- }
24
-
25
- export function initialTriggerState(): AutoCompactTriggerState {
26
- return { below: true };
27
- }
28
-
29
- /**
30
- * Edge trigger: fires exactly once per threshold crossing. After firing, stays
31
- * disarmed until the token count drops below the threshold again (the
32
- * post-compaction count is small, so this re-arms naturally).
33
- */
34
- export function shouldFireAutoCompact(
35
- state: AutoCompactTriggerState,
36
- tokenCount: number,
37
- threshold: number,
38
- ): boolean {
39
- const below = tokenCount < threshold;
40
- const fire = !below && state.below;
41
- state.below = below;
42
- return fire;
43
- }
@@ -1,91 +0,0 @@
1
- /**
2
- * Shared CC-exact context usage computation for pi-plus.
3
- *
4
- * Single source of truth for both display surfaces — the footer status line
5
- * (setStatus) and the custom footer's context field — so the two can never
6
- * diverge. Extracted from index.ts when the custom footer landed.
7
- *
8
- * The "known" flag ports pi's own getContextUsage boundary logic
9
- * (core/agent-session.ts): after a compaction entry, the pre-compaction usage
10
- * no longer describes the context, so the count is unknown until a valid
11
- * assistant response arrives.
12
- */
13
-
14
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
15
- import { getAutoCompactThreshold, getEffectiveContextWindowSize } from "./model-config.ts";
16
- import { calculateTokenWarningState, isAutoCompactEnabled, type TokenWarningState } from "./percentages.ts";
17
- import type { PiPlusSettings } from "./settings.ts";
18
- import { getTokenCountFromUsage, tokenCountWithEstimation, type RoughMessage } from "./usage.ts";
19
-
20
- export interface PluginContextUsage {
21
- /** false right after compaction, before the next valid assistant response
22
- * (mirrors pi's "?/window" convention). */
23
- known: boolean;
24
- /** CC-exact token count (meaningful only when known). */
25
- tokens: number;
26
- /** The threshold percentUsed is measured against: the auto-compact
27
- * threshold when auto-compact is on, else the effective window. */
28
- threshold: number;
29
- autoCompactEnabled: boolean;
30
- state: TokenWarningState;
31
- }
32
-
33
- export function branchMessages(ctx: ExtensionContext): RoughMessage[] {
34
- return ctx.sessionManager
35
- .getBranch()
36
- .filter((entry) => entry.type === "message")
37
- .map((entry) => entry.message as unknown as RoughMessage);
38
- }
39
-
40
- /**
41
- * Port of agent-session.ts getContextUsage's boundary rule: the count is
42
- * unknown ONLY when a `compaction` entry exists with no valid assistant usage
43
- * after it (the pre-compaction usage no longer describes the context).
44
- * `branch_summary` is NOT a boundary — matching getLatestCompactionEntry.
45
- * With no compaction, even a usage-less session is "known": both pi and the
46
- * plugin's tokenCountWithEstimation fall back to estimating every message.
47
- */
48
- export function hasPostCompactionUsage(ctx: ExtensionContext): boolean {
49
- const entries = ctx.sessionManager.getBranch();
50
- let compactionIndex = -1;
51
- for (let i = entries.length - 1; i >= 0; i--) {
52
- if (entries[i].type === "compaction") {
53
- compactionIndex = i;
54
- break;
55
- }
56
- }
57
- if (compactionIndex === -1) return true;
58
- for (let i = entries.length - 1; i > compactionIndex; i--) {
59
- const entry = entries[i];
60
- if (entry.type !== "message" || entry.message.role !== "assistant") continue;
61
- const assistant = entry.message;
62
- if (assistant.stopReason === "aborted" || assistant.stopReason === "error") continue;
63
- const usage = assistant.usage;
64
- if (usage && getTokenCountFromUsage(usage) > 0) return true;
65
- }
66
- return false;
67
- }
68
-
69
- /**
70
- * Compute the plugin's context usage snapshot for the current session state.
71
- * Returns undefined when no model is selected.
72
- */
73
- export function computePluginContextUsage(
74
- ctx: ExtensionContext,
75
- settings: PiPlusSettings,
76
- ): PluginContextUsage | undefined {
77
- const model = ctx.model;
78
- if (!model) return undefined;
79
-
80
- const tokens = tokenCountWithEstimation(branchMessages(ctx));
81
- const known = hasPostCompactionUsage(ctx);
82
- const autoCompactEnabled = isAutoCompactEnabled(settings);
83
- // Mirror calculateTokenWarningState's denominator: the auto-compact
84
- // threshold when auto-compact is on, else the effective window.
85
- const threshold = autoCompactEnabled
86
- ? getAutoCompactThreshold(model.id, model.contextWindow, model.maxTokens, settings)
87
- : getEffectiveContextWindowSize(model.id, model.contextWindow, model.maxTokens, settings);
88
- const state = calculateTokenWarningState(tokens, model.id, model.contextWindow, model.maxTokens, settings);
89
-
90
- return { known, tokens, threshold, autoCompactEnabled, state };
91
- }
@@ -1,248 +0,0 @@
1
- /**
2
- * Pure line builders for pi-plus's custom footer — exact ports of pi's
3
- * built-in footer rendering (coding-agent src/modes/interactive/components/footer.ts),
4
- * minus the pieces extensions cannot access (the `xp` experimental marker and
5
- * `modelRuntime.isUsingSubscription`), with the context-usage field replaced
6
- * by pi-plus's CC-exact "% used of threshold".
7
- *
8
- * Everything here is pure and free of runtime pi dependencies: truncation and
9
- * width measurement are injected via `Measure` so the module stays testable
10
- * without pi-tui, and the theme is the structural `ThemeLike` subset.
11
- */
12
-
13
- import { isAbsolute, relative, resolve, sep } from "node:path";
14
- import type { SessionEntry } from "@earendil-works/pi-coding-agent";
15
- import type { PluginContextUsage } from "./context-usage.ts";
16
-
17
- export interface ThemeLike {
18
- fg(color: string, text: string): string;
19
- }
20
-
21
- /** Width primitives supplied by the caller (pi-tui at runtime, test stubs in vitest). */
22
- export interface Measure {
23
- truncate(text: string, width: number, ellipsis: string): string;
24
- visibleWidth(text: string): number;
25
- }
26
-
27
- /** Sanitize text for display in a single-line status (footer.ts:13). */
28
- export function sanitizeStatusText(text: string): string {
29
- return text
30
- .replace(/[\r\n\t]/g, " ")
31
- .replace(/ +/g, " ")
32
- .trim();
33
- }
34
-
35
- /** Format token counts for compact footer display (footer.ts:24). */
36
- export function formatTokens(count: number): string {
37
- if (count < 1000) return count.toString();
38
- if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
39
- if (count < 1000000) return `${Math.round(count / 1000)}k`;
40
- if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
41
- return `${Math.round(count / 1000000)}M`;
42
- }
43
-
44
- /** Replace the home directory with ~ (footer.ts:33). */
45
- export function formatCwdForFooter(cwd: string, home: string | undefined): string {
46
- if (!home) return cwd;
47
-
48
- const resolvedCwd = resolve(cwd);
49
- const resolvedHome = resolve(home);
50
- const relativeToHome = relative(resolvedHome, resolvedCwd);
51
- const isInsideHome =
52
- relativeToHome === "" ||
53
- (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
54
-
55
- if (!isInsideHome) return cwd;
56
- return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
57
- }
58
-
59
- export interface UsageTotals {
60
- input: number;
61
- output: number;
62
- cacheRead: number;
63
- cacheWrite: number;
64
- cost: number;
65
- }
66
-
67
- /**
68
- * Cumulative usage over ALL session entries plus the latest assistant cache
69
- * hit rate — a port of footer.ts:88-104. The entry scan: assistant messages,
70
- * toolResult messages with usage, and branch_summary/compaction entries with
71
- * usage. The CH rate comes from the latest assistant message regardless of
72
- * stopReason, matching the built-in footer.
73
- */
74
- export function computeUsageTotals(entries: readonly SessionEntry[]): {
75
- totals: UsageTotals;
76
- latestCacheHitRate: number | undefined;
77
- } {
78
- const totals: UsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
79
- let latestCacheHitRate: number | undefined;
80
-
81
- for (const entry of entries) {
82
- if (entry.type === "message" && entry.message.role === "assistant") {
83
- const usage = entry.message.usage;
84
- totals.input += usage.input;
85
- totals.output += usage.output;
86
- totals.cacheRead += usage.cacheRead;
87
- totals.cacheWrite += usage.cacheWrite;
88
- totals.cost += usage.cost.total;
89
-
90
- const latestPromptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
91
- latestCacheHitRate = latestPromptTokens > 0 ? (usage.cacheRead / latestPromptTokens) * 100 : undefined;
92
- } else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
93
- const usage = entry.message.usage;
94
- totals.input += usage.input;
95
- totals.output += usage.output;
96
- totals.cacheRead += usage.cacheRead;
97
- totals.cacheWrite += usage.cacheWrite;
98
- totals.cost += usage.cost.total;
99
- } else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
100
- const usage = entry.usage;
101
- totals.input += usage.input;
102
- totals.output += usage.output;
103
- totals.cacheRead += usage.cacheRead;
104
- totals.cacheWrite += usage.cacheWrite;
105
- totals.cost += usage.cost.total;
106
- }
107
- }
108
-
109
- return { totals, latestCacheHitRate };
110
- }
111
-
112
- /**
113
- * The substituted context field: `{percentUsed}%/{threshold}` + ` (auto)`.
114
- * percentUsed is the complement of the plugin's percentLeft (CC's
115
- * calculateTokenWarningState), measured against the auto-compact threshold —
116
- * so the number rises as context fills, like pi's native field, but the
117
- * denominator is the threshold (effective window − 13k), not the raw window.
118
- * Colors follow the plugin's warning state; `?/{threshold}` when unknown.
119
- */
120
- export function buildContextField(usage: PluginContextUsage | undefined, theme: ThemeLike): string {
121
- const autoIndicator = usage?.autoCompactEnabled ? " (auto)" : "";
122
- if (!usage || !usage.known) {
123
- return `?/${formatTokens(usage?.threshold ?? 0)}${autoIndicator}`;
124
- }
125
- const percentUsed = 100 - usage.state.percentLeft;
126
- const display = `${percentUsed}%/${formatTokens(usage.threshold)}${autoIndicator}`;
127
- if (usage.state.isAboveErrorThreshold || usage.state.isAtBlockingLimit) return theme.fg("error", display);
128
- if (usage.state.isAboveWarningThreshold) return theme.fg("warning", display);
129
- return display;
130
- }
131
-
132
- export interface StatsLineInput {
133
- totals: UsageTotals;
134
- latestCacheHitRate: number | undefined;
135
- /** Pre-colored context field from buildContextField. */
136
- contextField: string;
137
- /** model.provider === "kimi-coding" (the only subscription check extensions can port). */
138
- usingKimiCoding: boolean;
139
- modelName: string;
140
- modelReasoning: boolean;
141
- thinkingLevel: string | undefined;
142
- provider: string | undefined;
143
- availableProviderCount: number;
144
- }
145
-
146
- /**
147
- * Port of footer.ts:128-227: stats parts, right-aligned model + thinking
148
- * level, provider prefix when multiple providers are available, min-2-space
149
- * gap, truncation fallbacks, and the dim-left/dim-remainder split around the
150
- * colored context segment. Omits only the `xp` marker (internal API).
151
- */
152
- export function buildStatsLine(input: StatsLineInput, width: number, measure: Measure, theme: ThemeLike): string {
153
- const { totals, latestCacheHitRate, contextField } = input;
154
- const statsParts: string[] = [];
155
- if (totals.input) statsParts.push(`↑${formatTokens(totals.input)}`);
156
- if (totals.output) statsParts.push(`↓${formatTokens(totals.output)}`);
157
- if (totals.cacheRead) statsParts.push(`R${formatTokens(totals.cacheRead)}`);
158
- if (totals.cacheWrite) statsParts.push(`W${formatTokens(totals.cacheWrite)}`);
159
- if ((totals.cacheRead > 0 || totals.cacheWrite > 0) && latestCacheHitRate !== undefined) {
160
- statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
161
- }
162
-
163
- if (totals.cost || input.usingKimiCoding) {
164
- statsParts.push(`$${totals.cost.toFixed(3)}${input.usingKimiCoding ? " (sub)" : ""}`);
165
- }
166
-
167
- statsParts.push(contextField);
168
-
169
- let statsLeft = statsParts.join(" ");
170
-
171
- let statsLeftWidth = measure.visibleWidth(statsLeft);
172
- if (statsLeftWidth > width) {
173
- statsLeft = measure.truncate(statsLeft, width, "...");
174
- statsLeftWidth = measure.visibleWidth(statsLeft);
175
- }
176
-
177
- const minPadding = 2;
178
-
179
- let rightSideWithoutProvider = input.modelName;
180
- if (input.modelReasoning) {
181
- const thinkingLevel = input.thinkingLevel || "off";
182
- rightSideWithoutProvider =
183
- thinkingLevel === "off" ? `${input.modelName} • thinking off` : `${input.modelName} • ${thinkingLevel}`;
184
- }
185
-
186
- let rightSide = rightSideWithoutProvider;
187
- if (input.availableProviderCount > 1 && input.provider) {
188
- rightSide = `(${input.provider}) ${rightSideWithoutProvider}`;
189
- if (statsLeftWidth + minPadding + measure.visibleWidth(rightSide) > width) {
190
- rightSide = rightSideWithoutProvider;
191
- }
192
- }
193
-
194
- const rightSideWidth = measure.visibleWidth(rightSide);
195
- const totalNeeded = statsLeftWidth + minPadding + rightSideWidth;
196
-
197
- let statsLine: string;
198
- if (totalNeeded <= width) {
199
- const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
200
- statsLine = statsLeft + padding + rightSide;
201
- } else {
202
- const availableForRight = width - statsLeftWidth - minPadding;
203
- if (availableForRight > 0) {
204
- const truncatedRight = measure.truncate(rightSide, availableForRight, "");
205
- const truncatedRightWidth = measure.visibleWidth(truncatedRight);
206
- const padding = " ".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth));
207
- statsLine = statsLeft + padding + truncatedRight;
208
- } else {
209
- statsLine = statsLeft;
210
- }
211
- }
212
-
213
- // statsLeft may contain color codes (the context field) that reset an outer
214
- // dim wrapper, so dim the parts before and after it independently.
215
- const dimStatsLeft = theme.fg("dim", statsLeft);
216
- const remainder = statsLine.slice(statsLeft.length);
217
- return dimStatsLeft + theme.fg("dim", remainder);
218
- }
219
-
220
- /** Line 1: dim cwd (home collapsed), git branch in parens, session name. */
221
- export function buildPwdLine(
222
- cwd: string,
223
- home: string | undefined,
224
- branch: string | null,
225
- sessionName: string | undefined,
226
- width: number,
227
- measure: Measure,
228
- theme: ThemeLike,
229
- ): string {
230
- let pwd = formatCwdForFooter(cwd, home);
231
- if (branch) pwd = `${pwd} (${branch})`;
232
- if (sessionName) pwd = `${pwd} • ${sessionName}`;
233
- return measure.truncate(theme.fg("dim", pwd), width, theme.fg("dim", "..."));
234
- }
235
-
236
- /** Line 3: extension statuses sorted by key, sanitized, space-joined. */
237
- export function buildStatusLine(
238
- statuses: ReadonlyMap<string, string>,
239
- width: number,
240
- measure: Measure,
241
- theme: ThemeLike,
242
- ): string | undefined {
243
- if (statuses.size === 0) return undefined;
244
- const sortedStatuses = Array.from(statuses.entries())
245
- .sort(([a], [b]) => a.localeCompare(b))
246
- .map(([, text]) => sanitizeStatusText(text));
247
- return measure.truncate(sortedStatuses.join(" "), width, theme.fg("dim", "..."));
248
- }
package/src/footer.ts DELETED
@@ -1,139 +0,0 @@
1
- /**
2
- * Custom footer installer: pi-plus takes over pi's footer via
3
- * ctx.ui.setFooter() and replicates the built-in rendering (see
4
- * footer-render.ts) with the CC-exact "% used of threshold" in the context
5
- * field.
6
- *
7
- * Contract with pi: the setFooter factory receives (tui, theme, footerData);
8
- * pi disposes the previous custom footer before installing ours, and
9
- * setFooter(undefined) restores the built-in footer. pi-plus must therefore
10
- * be the ONLY extension calling setFooter — another caller silently replaces
11
- * this footer (that is how better-claude-code-ui hid the status line).
12
- *
13
- * The usage snapshot is memoized: tokenCountWithEstimation is O(content) and
14
- * renders fire on every keystroke, so we recompute only when the session leaf
15
- * or the model changes.
16
- */
17
-
18
- import {
19
- type Component,
20
- type TUI,
21
- truncateToWidth,
22
- visibleWidth,
23
- } from "@earendil-works/pi-tui";
24
- import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
25
- import { computePluginContextUsage, type PluginContextUsage } from "./context-usage.ts";
26
- import {
27
- buildContextField,
28
- buildPwdLine,
29
- buildStatsLine,
30
- buildStatusLine,
31
- computeUsageTotals,
32
- type Measure,
33
- } from "./footer-render.ts";
34
- import { loadPiPlusSettings } from "./settings.ts";
35
-
36
- const measure: Measure = {
37
- truncate: truncateToWidth,
38
- visibleWidth,
39
- };
40
-
41
- interface FooterDeps {
42
- ctx: ExtensionContext;
43
- theme: Theme;
44
- footerData: ReadonlyFooterDataProvider;
45
- }
46
-
47
- export class PiPlusFooterComponent implements Component {
48
- private deps: FooterDeps;
49
- private snapshot: {
50
- leafId: string | null | undefined;
51
- modelId: string | undefined;
52
- usage: PluginContextUsage | undefined;
53
- } = {
54
- leafId: undefined,
55
- modelId: undefined,
56
- usage: undefined,
57
- };
58
- private disposeHook: (() => void) | undefined;
59
-
60
- constructor(deps: FooterDeps) {
61
- this.deps = deps;
62
- }
63
-
64
- /** pi calls dispose() before replacing the footer — release our subscriptions. */
65
- onDispose(hook: () => void): void {
66
- this.disposeHook = hook;
67
- }
68
-
69
- invalidate(): void {
70
- // No-op: all data is pulled fresh per render (same as pi's built-in footer).
71
- }
72
-
73
- dispose(): void {
74
- this.disposeHook?.();
75
- }
76
-
77
- private getUsage(): PluginContextUsage | undefined {
78
- const { ctx } = this.deps;
79
- const leafId = ctx.sessionManager.getLeafId();
80
- const modelId = ctx.model?.id;
81
- if (this.snapshot.leafId !== leafId || this.snapshot.modelId !== modelId) {
82
- this.snapshot = { leafId, modelId, usage: computePluginContextUsage(ctx, loadPiPlusSettings()) };
83
- }
84
- return this.snapshot.usage;
85
- }
86
-
87
- render(width: number): string[] {
88
- const { ctx, theme, footerData } = this.deps;
89
-
90
- const lines = [
91
- buildPwdLine(ctx.cwd, process.env.HOME || process.env.USERPROFILE, footerData.getGitBranch(), ctx.sessionManager.getSessionName(), width, measure, theme),
92
- ];
93
-
94
- const { totals, latestCacheHitRate } = computeUsageTotals(ctx.sessionManager.getEntries());
95
- const model = ctx.model;
96
- lines.push(
97
- buildStatsLine(
98
- {
99
- totals,
100
- latestCacheHitRate,
101
- contextField: buildContextField(this.getUsage(), theme),
102
- usingKimiCoding: model?.provider === "kimi-coding",
103
- modelName: model?.id || "no-model",
104
- modelReasoning: model?.reasoning ?? false,
105
- thinkingLevel: ctx.thinkingLevel,
106
- provider: model?.provider,
107
- availableProviderCount: footerData.getAvailableProviderCount(),
108
- },
109
- width,
110
- measure,
111
- theme,
112
- ),
113
- );
114
-
115
- const statusLine = buildStatusLine(footerData.getExtensionStatuses(), width, measure, theme);
116
- if (statusLine !== undefined) lines.push(statusLine);
117
-
118
- return lines;
119
- }
120
- }
121
-
122
- /**
123
- * Install (or, when the `customFooter` setting is false, explicitly restore)
124
- * pi's footer. Called on session_start — pi's resetExtensionUI() drops custom
125
- * footers on session switch and /reload, so this re-asserts each session.
126
- */
127
- export function installFooter(ctx: ExtensionContext): void {
128
- if (ctx.mode !== "tui") return;
129
- if (loadPiPlusSettings().customFooter === false) {
130
- ctx.ui.setFooter(undefined);
131
- return;
132
- }
133
- ctx.ui.setFooter((tui: TUI, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
134
- const component = new PiPlusFooterComponent({ ctx, theme, footerData });
135
- const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
136
- component.onDispose(unsubscribe);
137
- return component;
138
- });
139
- }