@neilurk12/pi-clean-footer 0.2.1 → 0.2.2
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/README.md +1 -1
- package/dist/index.js +5 -0
- package/package.json +18 -3
- package/src/config.ts +0 -150
- package/src/index.ts +0 -463
package/README.md
CHANGED
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import D from"path";import H from"os";import W from"path";import{existsSync as $,readFileSync as _}from"fs";var I=500,c={enabled:!0,showGit:!0,showTokens:!0,showCache:!0,showContext:!0,showDirectory:!0,showEffort:!0,gitRefreshDebounceMs:I,contextWarningPercent:70,contextDangerPercent:85,modelAliases:{},colors:{model:"accent",directory:"dim",git:"success",gitDirty:"warning",contextNormal:"success",contextWarning:"warning",contextDanger:"error",tokens:"muted",separator:"dim"}};function x(e,t){return L([e,t])}function L(e){let t=[],n={},r;for(let o of e)if($(o))try{let i=JSON.parse(_(o,"utf8"));n=j(n,i),t.push(o)}catch(i){r=`${o}: ${i instanceof Error?i.message:String(i)}`}return{config:A(n),loadedPaths:t,error:r}}function j(e,t){return{...e,...t,modelAliases:{...e.modelAliases??{},...t.modelAliases??{}},colors:{...e.colors??{},...t.colors??{}}}}function A(e){return{...c,...e,gitRefreshDebounceMs:G(e.gitRefreshDebounceMs,c.gitRefreshDebounceMs),contextWarningPercent:w(e.contextWarningPercent,c.contextWarningPercent),contextDangerPercent:w(e.contextDangerPercent,c.contextDangerPercent),modelAliases:{...c.modelAliases,...e.modelAliases??{}},colors:{...c.colors,...e.colors??{}}}}function G(e,t){return typeof e=="number"&&Number.isFinite(e)&&e>0?e:t}function w(e,t){return typeof e=="number"&&Number.isFinite(e)&&e>=0&&e<=100?e:t}import{execFile as N}from"child_process";import{promisify as U}from"util";var R=U(N);function m(e){let t={inRepo:!1,dirtyCount:0},n,r=[];e.onChange&&r.push(e.onChange);async function o(){if(!e.enabled){t={inRepo:!1,dirtyCount:0};return}try{let[d,s]=await Promise.all([R("git",["branch","--show-current"],{cwd:e.cwd,timeout:2e3}),R("git",["status","--porcelain"],{cwd:e.cwd,timeout:2e3})]),a=d.stdout.trim()||"detached",g=s.stdout.split(`
|
|
2
|
+
`).filter(Boolean).length;t={inRepo:!0,branch:a,dirtyCount:g}}catch{t={inRepo:!1,dirtyCount:0}}for(let d of r)d()}function i(){l(),n=setTimeout(()=>{n=void 0,o()},e.debounceMs)}function l(){n&&(clearTimeout(n),n=void 0)}return{get state(){return t},schedule:i,clear:l,refresh:o,onChange(d){return r.push(d),()=>{let s=r.indexOf(d);s>=0&&r.splice(s,1)}}}}function P(e){var n,r,o,i,l;let t={input:0,output:0,cacheRead:0,cacheWrite:0};for(let d of e){if(d.type!=="message"||((n=d.message)==null?void 0:n.role)!=="assistant")continue;let s=d.message;t.input+=((r=s.usage)==null?void 0:r.input)??0,t.output+=((o=s.usage)==null?void 0:o.output)??0,t.cacheRead+=((i=s.usage)==null?void 0:i.cacheRead)??0,t.cacheWrite+=((l=s.usage)==null?void 0:l.cacheWrite)??0}return t}function p(e){if(typeof e!="string")return;let t=e.toLowerCase();if(t==="medium")return"med";if(t==="extra-high"||t==="extra_high"||t==="x-high")return"xhigh";if(["low","med","high","xhigh"].includes(t))return t}var C=class{#t;#r;#i;#e;#n;#s;#a;#d;#l;#o;constructor(t){this.#a=t.globalConfigPath,this.#d=t.getProjectConfigPath,this.#l=t.getThinkingLevel,this.#o=t.onRenderNeeded,this.#t=c,this.#r={config:this.#t,loadedPaths:[]},this.#i=void 0,this.#e=void 0,this.#n=!0,this.#s=""}async start(t){this.#s=t.cwd,this.#r=x(this.#a,this.#d(t.cwd)),this.#t=this.#r.config,this.#i=p(this.#l()),this.#n=this.#t.enabled,this.#n&&(this.#e=m({cwd:t.cwd,debounceMs:this.#t.gitRefreshDebounceMs,enabled:this.#t.showGit,onChange:()=>this.#o()}),await this.#e.refresh())}shutdown(){var t;(t=this.#e)==null||t.clear(),this.#e=void 0}onThinkingLevel(t){this.#i=p(t),this.#o()}onModelSelect(){this.#o()}onMessageEnd(t){t==="assistant"&&this.#o()}onToolEnd(t){var n;["bash","edit","write"].includes(t)&&((n=this.#e)==null||n.schedule()),this.#o()}onUserBash(){var t;(t=this.#e)==null||t.schedule()}async refresh(){var t;await((t=this.#e)==null?void 0:t.refresh())}async reload(t){var n;this.#r=x(this.#a,this.#d(t.cwd)),this.#t=this.#r.config,this.#n=this.#t.enabled,(n=this.#e)==null||n.clear(),this.#e=void 0,this.#n&&(this.#e=m({cwd:this.#s,debounceMs:this.#t.gitRefreshDebounceMs,enabled:this.#t.showGit,onChange:()=>this.#o()}),await this.#e.refresh()),this.#o()}async toggle(){var t;return this.#n=!this.#n,this.#n?(this.#i=p(this.#l()),this.#e=m({cwd:this.#s,debounceMs:this.#t.gitRefreshDebounceMs,enabled:this.#t.showGit,onChange:()=>this.#o()}),await this.#e.refresh()):((t=this.#e)==null||t.clear(),this.#e=void 0),this.#o(),this.#n}getFooterInput(t){var n,r,o,i,l,d;return{modelId:((n=t.model)==null?void 0:n.id)??"no-model",thinkingLevel:this.#i,directory:W.basename(t.cwd),gitBranch:(r=this.#e)==null?void 0:r.state.branch,gitDirtyCount:((o=this.#e)==null?void 0:o.state.dirtyCount)??0,contextUsed:((l=(i=t.getContextUsage)==null?void 0:i.call(t))==null?void 0:l.tokens)??0,contextMax:(d=t.model)==null?void 0:d.contextWindow,totals:P(t.sessionManager.getBranch()),config:this.#t}}get isEnabled(){return this.#n}get loadedError(){return this.#r.error}get loadedPaths(){return this.#r.loadedPaths}get config(){return this.#t}};import{truncateToWidth as u,visibleWidth as T}from"@earendil-works/pi-tui";function f(e){return!Number.isFinite(e)||e<=0?"0":e<1e3?`${Math.round(e)}`:e<1e6?`${(e/1e3).toFixed(e<1e4?1:0)}k`:`${(e/1e6).toFixed(1)}m`}function B(e,t){if(t[e])return t[e];let n=e.toLowerCase(),r=n.includes("/")?n.split("/").pop():n;if(t[r])return t[r];if(r.includes("claude")&&r.includes("sonnet"))return r.includes("4-5")||r.includes("4.5")?"sonnet-4.5":r.includes("4")?"sonnet-4":"sonnet";if(r.includes("claude")&&r.includes("opus"))return"opus";if(r.includes("claude")&&r.includes("haiku"))return"haiku";let o=r.match(/gpt-5(?:[.-][a-z0-9]+)*/);if(o)return o[0];let i=r.match(/gpt-4(?:[.-][a-z0-9]+)*/);if(i)return i[0];let l=r.match(/gemini-[a-z0-9.-]+/);return l?l[0].replace(/-preview.*/,""):r.length>24?`${r.slice(0,21)}\u2026`:r}function F(e,t,n,r,o,i){let l=B(e,n),d=r&&t?` \u2022 ${t}`:"";return o(i,`${l}${d}`)}function k(e,t,n){if(e)return t(n,e)}function v(e,t,n,r,o){if(!e)return;let i=n(r,e);return t<=0?i:`${i} ${n(o,`\u25CF${t}`)}`}function b(e,t,n,r,o,i){if(!n)return;let l=r?t:t==="full"?"no-cache":t,d=e.input+e.output,s;if(l==="total-only")s=`\u03A3${f(d)}`;else{let a=`\u2191${f(e.input)} \u2193${f(e.output)} \u03A3${f(d)}`;s=l==="full"?`${a} \u21AF${f(e.cacheRead)} \u21A5${f(e.cacheWrite)}`:a}return o(i,s)}function E(e,t,n,r,o,i){let l=`ctx ${f(e)}/${t?f(t):"--"}`;if(!t||t<=0)return o(i.dim,l);let d=e/t*100;return d>=r?o(i.danger,l):d>=n?o(i.warning,l):o(i.normal,l)}function O(e,t){let n=e.config,r=F(e.modelId,e.thinkingLevel,n.modelAliases,n.showEffort,t,n.colors.model),o=n.showDirectory?k(e.directory,t,n.colors.directory):void 0,i=n.showGit?v(e.gitBranch,e.gitDirtyCount,t,n.colors.git,n.colors.gitDirty):void 0,l=n.showContext?E(e.contextUsed,e.contextMax,n.contextWarningPercent,n.contextDangerPercent,t,{normal:n.colors.contextNormal,warning:n.colors.contextWarning,danger:n.colors.contextDanger,dim:"dim"}):void 0,d={};return n.showTokens&&(d.full=b(e.totals,"full",!0,n.showCache,t,n.colors.tokens),d.noCache=b(e.totals,"no-cache",!0,n.showCache,t,n.colors.tokens),d.totalOnly=b(e.totals,"total-only",!0,n.showCache,t,n.colors.tokens)),{model:r,dir:o,git:i,context:l,tokens:d}}function z(e,t,n){let r=[e.model,e.dir,e.git].filter(Boolean).join(t),o=e.model;if(n>=100){let i=[e.context,e.tokens.full].filter(Boolean).join(t);return[h(r,i,n)]}if(n>=80){let i=[e.context,e.tokens.noCache].filter(Boolean).join(t);return[h(r,i,n)]}if(n>=60){let i=[e.context,e.tokens.totalOnly].filter(Boolean).join(t);return[h(r,i,n)]}return n>=40?[h(r,e.context??"",n)]:[h(o,e.context??"",n)]}function S(e,t,n){let r=(l,d)=>t.fg(l,d),o=O(e,r),i=r(e.config.colors.separator," | ");return z(o,i,n)}function h(e,t,n){if(!t)return u(e,n);if(!e)return u(t,n);let r=n-T(e)-T(t);if(r>=1)return u(e+" ".repeat(r)+t,n);let o=Math.max(1,Math.floor((n-1)/2));return u(e,o)+" "+u(t,n-o-1)}function ue(e){let t=D.join(H.homedir(),".pi","agent","clean-footer.json"),n=s=>D.join(s,".pi","clean-footer.json"),r=()=>{},o=new C({globalConfigPath:t,getProjectConfigPath:n,getThinkingLevel:()=>{var s;return(s=e.getThinkingLevel)==null?void 0:s.call(e)},onRenderNeeded:()=>r()});e.registerCommand("footer",{description:"Toggle, refresh, or configure the clean footer",handler:async(s,a)=>{let g=s.trim();if(g==="refresh"){await o.refresh(),a.hasUI&&a.ui.notify("Footer refreshed","info");return}if(g==="reload"){await o.reload(a),a.hasUI&&o.isEnabled&&i(a),a.hasUI&&!o.isEnabled&&a.ui.setFooter(void 0),r(),l(a);return}if(g==="config"){d(a);return}let y=await o.toggle();a.hasUI&&(y?(i(a),a.ui.notify("Clean footer enabled","info")):(a.ui.setFooter(void 0),a.ui.notify("Default footer restored","info")))}}),e.on("session_start",async(s,a)=>{await o.start(a),a.hasUI&&o.loadedError&&o.isEnabled&&a.ui.notify(`Config error: ${o.loadedError}`,"error"),a.hasUI&&o.isEnabled&&i(a)}),e.on("session_shutdown",(s,a)=>{o.shutdown(),r=()=>{},a.hasUI&&a.ui.setFooter(void 0)}),e.on("thinking_level_select",s=>{o.onThinkingLevel(s.level)}),e.on("model_select",()=>{o.onModelSelect()}),e.on("message_end",s=>{o.onMessageEnd(s.message.role)}),e.on("tool_execution_end",s=>{o.onToolEnd(s.toolName)}),e.on("user_bash",()=>{o.onUserBash()});function i(s){s.hasUI&&s.ui.setFooter((a,g)=>(r=()=>a.requestRender(),{invalidate(){},render(y){let M=o.getFooterInput(s);return S(M,g,y)}}))}function l(s){s.hasUI&&(o.loadedError?s.ui.notify(`Clean footer config error: ${o.loadedError}`,"error"):s.ui.notify("Clean footer config loaded","info"))}function d(s){if(!s.hasUI)return;let a=o.loadedPaths.length?o.loadedPaths.join(`
|
|
3
|
+
`):"none",g=n(s.cwd);s.ui.notify(["Clean footer config",`global: ${t}`,`project: ${g}`,`loaded:
|
|
4
|
+
${a}`,o.loadedError?`error: ${o.loadedError}`:"error: none",`resolved: ${JSON.stringify(o.config)}`].join(`
|
|
5
|
+
`),"info")}}export{ue as default};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neilurk12/pi-clean-footer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Clean, minimal, and lightweight powerline-style footer extension for pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -15,21 +15,36 @@
|
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"files": [
|
|
18
|
-
"
|
|
18
|
+
"dist",
|
|
19
19
|
"README.md",
|
|
20
20
|
"example.png"
|
|
21
21
|
],
|
|
22
|
+
"main": "./dist/index.js",
|
|
23
|
+
"module": "./dist/index.js",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./dist/index.js"
|
|
26
|
+
},
|
|
22
27
|
"pi": {
|
|
23
28
|
"extensions": [
|
|
24
|
-
"./
|
|
29
|
+
"./dist/index.js"
|
|
25
30
|
]
|
|
26
31
|
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsup",
|
|
34
|
+
"dev": "tsup --watch"
|
|
35
|
+
},
|
|
27
36
|
"peerDependencies": {
|
|
28
37
|
"@earendil-works/pi-ai": "*",
|
|
29
38
|
"@earendil-works/pi-coding-agent": "*",
|
|
30
39
|
"@earendil-works/pi-tui": "*"
|
|
31
40
|
},
|
|
32
41
|
"devDependencies": {
|
|
42
|
+
"tsup": "^8.5.1",
|
|
33
43
|
"typescript": "^6.0.3"
|
|
44
|
+
},
|
|
45
|
+
"pnpm": {
|
|
46
|
+
"onlyBuiltDependencies": [
|
|
47
|
+
"esbuild"
|
|
48
|
+
]
|
|
34
49
|
}
|
|
35
50
|
}
|
package/src/config.ts
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
|
|
3
|
-
export const DEFAULT_GIT_REFRESH_DEBOUNCE_MS = 500;
|
|
4
|
-
|
|
5
|
-
export type CleanFooterConfig = {
|
|
6
|
-
enabled?: boolean;
|
|
7
|
-
showGit?: boolean;
|
|
8
|
-
showTokens?: boolean;
|
|
9
|
-
showCache?: boolean;
|
|
10
|
-
showContext?: boolean;
|
|
11
|
-
showDirectory?: boolean;
|
|
12
|
-
showEffort?: boolean;
|
|
13
|
-
gitRefreshDebounceMs?: number;
|
|
14
|
-
contextWarningPercent?: number;
|
|
15
|
-
contextDangerPercent?: number;
|
|
16
|
-
modelAliases?: Record<string, string>;
|
|
17
|
-
colors?: Partial<ColorConfig>;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export type ResolvedConfig = Required<
|
|
21
|
-
Omit<CleanFooterConfig, "modelAliases" | "colors">
|
|
22
|
-
> & {
|
|
23
|
-
modelAliases: Record<string, string>;
|
|
24
|
-
colors: ColorConfig;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export type ColorConfig = {
|
|
28
|
-
model: string;
|
|
29
|
-
directory: string;
|
|
30
|
-
git: string;
|
|
31
|
-
gitDirty: string;
|
|
32
|
-
contextNormal: string;
|
|
33
|
-
contextWarning: string;
|
|
34
|
-
contextDanger: string;
|
|
35
|
-
tokens: string;
|
|
36
|
-
separator: string;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
export type ConfigLoadResult = {
|
|
40
|
-
config: ResolvedConfig;
|
|
41
|
-
loadedPaths: string[];
|
|
42
|
-
error?: string;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export const defaultConfig: ResolvedConfig = {
|
|
46
|
-
enabled: true,
|
|
47
|
-
showGit: true,
|
|
48
|
-
showTokens: true,
|
|
49
|
-
showCache: true,
|
|
50
|
-
showContext: true,
|
|
51
|
-
showDirectory: true,
|
|
52
|
-
showEffort: true,
|
|
53
|
-
gitRefreshDebounceMs: DEFAULT_GIT_REFRESH_DEBOUNCE_MS,
|
|
54
|
-
contextWarningPercent: 70,
|
|
55
|
-
contextDangerPercent: 85,
|
|
56
|
-
modelAliases: {},
|
|
57
|
-
colors: {
|
|
58
|
-
model: "accent",
|
|
59
|
-
directory: "dim",
|
|
60
|
-
git: "success",
|
|
61
|
-
gitDirty: "warning",
|
|
62
|
-
contextNormal: "success",
|
|
63
|
-
contextWarning: "warning",
|
|
64
|
-
contextDanger: "error",
|
|
65
|
-
tokens: "muted",
|
|
66
|
-
separator: "dim",
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
export function loadConfig(paths: string[]): ConfigLoadResult {
|
|
71
|
-
const loaded: string[] = [];
|
|
72
|
-
let merged: CleanFooterConfig = {};
|
|
73
|
-
let error: string | undefined;
|
|
74
|
-
|
|
75
|
-
for (const configPath of paths) {
|
|
76
|
-
if (!existsSync(configPath)) continue;
|
|
77
|
-
try {
|
|
78
|
-
const parsed = JSON.parse(
|
|
79
|
-
readFileSync(configPath, "utf8"),
|
|
80
|
-
) as CleanFooterConfig;
|
|
81
|
-
merged = mergeConfig(merged, parsed);
|
|
82
|
-
loaded.push(configPath);
|
|
83
|
-
} catch (err) {
|
|
84
|
-
error = `${configPath}: ${err instanceof Error ? err.message : String(err)}`;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return {
|
|
89
|
-
config: resolveConfig(merged),
|
|
90
|
-
loadedPaths: loaded,
|
|
91
|
-
error,
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function mergeConfig(
|
|
96
|
-
base: CleanFooterConfig,
|
|
97
|
-
override: CleanFooterConfig,
|
|
98
|
-
): CleanFooterConfig {
|
|
99
|
-
return {
|
|
100
|
-
...base,
|
|
101
|
-
...override,
|
|
102
|
-
modelAliases: {
|
|
103
|
-
...(base.modelAliases ?? {}),
|
|
104
|
-
...(override.modelAliases ?? {}),
|
|
105
|
-
},
|
|
106
|
-
colors: {
|
|
107
|
-
...(base.colors ?? {}),
|
|
108
|
-
...(override.colors ?? {}),
|
|
109
|
-
},
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function resolveConfig(config: CleanFooterConfig): ResolvedConfig {
|
|
114
|
-
return {
|
|
115
|
-
...defaultConfig,
|
|
116
|
-
...config,
|
|
117
|
-
gitRefreshDebounceMs: positiveNumber(
|
|
118
|
-
config.gitRefreshDebounceMs,
|
|
119
|
-
defaultConfig.gitRefreshDebounceMs,
|
|
120
|
-
),
|
|
121
|
-
contextWarningPercent: percentNumber(
|
|
122
|
-
config.contextWarningPercent,
|
|
123
|
-
defaultConfig.contextWarningPercent,
|
|
124
|
-
),
|
|
125
|
-
contextDangerPercent: percentNumber(
|
|
126
|
-
config.contextDangerPercent,
|
|
127
|
-
defaultConfig.contextDangerPercent,
|
|
128
|
-
),
|
|
129
|
-
modelAliases: {
|
|
130
|
-
...defaultConfig.modelAliases,
|
|
131
|
-
...(config.modelAliases ?? {}),
|
|
132
|
-
},
|
|
133
|
-
colors: { ...defaultConfig.colors, ...(config.colors ?? {}) },
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function positiveNumber(value: unknown, fallback: number): number {
|
|
138
|
-
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
139
|
-
? value
|
|
140
|
-
: fallback;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function percentNumber(value: unknown, fallback: number): number {
|
|
144
|
-
return typeof value === "number" &&
|
|
145
|
-
Number.isFinite(value) &&
|
|
146
|
-
value >= 0 &&
|
|
147
|
-
value <= 100
|
|
148
|
-
? value
|
|
149
|
-
: fallback;
|
|
150
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,463 +0,0 @@
|
|
|
1
|
-
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
2
|
-
import type {
|
|
3
|
-
ExtensionAPI,
|
|
4
|
-
ExtensionContext,
|
|
5
|
-
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
-
import { execFile } from "node:child_process";
|
|
8
|
-
import os from "node:os";
|
|
9
|
-
import path from "node:path";
|
|
10
|
-
import { promisify } from "node:util";
|
|
11
|
-
|
|
12
|
-
const execFileAsync = promisify(execFile);
|
|
13
|
-
|
|
14
|
-
type Theme = ExtensionContext["ui"]["theme"];
|
|
15
|
-
|
|
16
|
-
import {
|
|
17
|
-
defaultConfig,
|
|
18
|
-
loadConfig,
|
|
19
|
-
type ResolvedConfig,
|
|
20
|
-
} from "./config.js";
|
|
21
|
-
|
|
22
|
-
type GitState = {
|
|
23
|
-
inRepo: boolean;
|
|
24
|
-
branch?: string;
|
|
25
|
-
dirtyCount: number;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
type Totals = {
|
|
29
|
-
input: number;
|
|
30
|
-
output: number;
|
|
31
|
-
cacheRead: number;
|
|
32
|
-
cacheWrite: number;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
type FooterRuntime = {
|
|
36
|
-
enabled: boolean;
|
|
37
|
-
git: GitState;
|
|
38
|
-
thinkingLevel?: string;
|
|
39
|
-
refreshTimer?: ReturnType<typeof setTimeout>;
|
|
40
|
-
requestRender?: () => void;
|
|
41
|
-
config: ResolvedConfig;
|
|
42
|
-
configPaths: { global: string; project: string };
|
|
43
|
-
loadedConfigPaths: string[];
|
|
44
|
-
configError?: string;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const runtime: FooterRuntime = {
|
|
48
|
-
enabled: true,
|
|
49
|
-
git: { inRepo: false, dirtyCount: 0 },
|
|
50
|
-
config: defaultConfig,
|
|
51
|
-
configPaths: {
|
|
52
|
-
global: path.join(os.homedir(), ".pi", "agent", "clean-footer.json"),
|
|
53
|
-
project: path.join(process.cwd(), ".pi", "clean-footer.json"),
|
|
54
|
-
},
|
|
55
|
-
loadedConfigPaths: [],
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
export default function (pi: ExtensionAPI) {
|
|
59
|
-
pi.registerCommand("footer", {
|
|
60
|
-
description: "Toggle, refresh, or configure the clean footer",
|
|
61
|
-
handler: async (args, ctx) => {
|
|
62
|
-
const command = args.trim();
|
|
63
|
-
|
|
64
|
-
if (command === "refresh") {
|
|
65
|
-
await refreshGit(ctx, true);
|
|
66
|
-
runtime.requestRender?.();
|
|
67
|
-
if (ctx.hasUI) ctx.ui.notify("Footer refreshed", "info");
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (command === "reload") {
|
|
72
|
-
loadRuntimeConfig(ctx.cwd);
|
|
73
|
-
runtime.enabled = runtime.config.enabled;
|
|
74
|
-
if (ctx.hasUI && runtime.enabled) installFooter(ctx);
|
|
75
|
-
if (ctx.hasUI && !runtime.enabled) ctx.ui.setFooter(undefined);
|
|
76
|
-
runtime.requestRender?.();
|
|
77
|
-
notifyConfigStatus(ctx);
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (command === "config") {
|
|
82
|
-
showConfig(ctx);
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
runtime.enabled = !runtime.enabled;
|
|
87
|
-
if (!ctx.hasUI) return;
|
|
88
|
-
|
|
89
|
-
if (runtime.enabled) {
|
|
90
|
-
runtime.thinkingLevel = normalizeThinkingLevel(pi.getThinkingLevel?.());
|
|
91
|
-
installFooter(ctx);
|
|
92
|
-
await refreshGit(ctx, true);
|
|
93
|
-
ctx.ui.notify("Clean footer enabled", "info");
|
|
94
|
-
} else {
|
|
95
|
-
clearScheduledRefresh();
|
|
96
|
-
ctx.ui.setFooter(undefined);
|
|
97
|
-
ctx.ui.notify("Default footer restored", "info");
|
|
98
|
-
}
|
|
99
|
-
},
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
103
|
-
loadRuntimeConfig(ctx.cwd);
|
|
104
|
-
runtime.enabled = runtime.config.enabled;
|
|
105
|
-
runtime.thinkingLevel = normalizeThinkingLevel(pi.getThinkingLevel?.());
|
|
106
|
-
if (!ctx.hasUI || !runtime.enabled) return;
|
|
107
|
-
installFooter(ctx);
|
|
108
|
-
await refreshGit(ctx, true);
|
|
109
|
-
if (runtime.configError) notifyConfigStatus(ctx);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
pi.on("session_shutdown", async (_event, ctx) => {
|
|
113
|
-
clearScheduledRefresh();
|
|
114
|
-
runtime.requestRender = undefined;
|
|
115
|
-
if (ctx.hasUI) ctx.ui.setFooter(undefined);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
pi.on("thinking_level_select", (event) => {
|
|
119
|
-
runtime.thinkingLevel = normalizeThinkingLevel(event.level);
|
|
120
|
-
runtime.requestRender?.();
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
pi.on("model_select", () => {
|
|
124
|
-
runtime.requestRender?.();
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
pi.on("message_end", (event) => {
|
|
128
|
-
if (event.message.role === "assistant") runtime.requestRender?.();
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
pi.on("tool_execution_end", (event, ctx) => {
|
|
132
|
-
if (["bash", "edit", "write"].includes(event.toolName))
|
|
133
|
-
scheduleGitRefresh(ctx);
|
|
134
|
-
runtime.requestRender?.();
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
pi.on("user_bash", (_event, ctx) => {
|
|
138
|
-
scheduleGitRefresh(ctx);
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function installFooter(ctx: ExtensionContext) {
|
|
143
|
-
if (!ctx.hasUI) return;
|
|
144
|
-
|
|
145
|
-
ctx.ui.setFooter((tui, theme) => {
|
|
146
|
-
runtime.requestRender = () => tui.requestRender();
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
invalidate() {},
|
|
150
|
-
render(width: number): string[] {
|
|
151
|
-
const cfg = runtime.config;
|
|
152
|
-
const modelSegment = formatModelSegment(ctx, theme);
|
|
153
|
-
const dirSegment = cfg.showDirectory
|
|
154
|
-
? color(theme, cfg.colors.directory, path.basename(ctx.cwd))
|
|
155
|
-
: undefined;
|
|
156
|
-
const gitSegment = cfg.showGit ? formatGitSegment(theme) : undefined;
|
|
157
|
-
const ctxSegment = cfg.showContext
|
|
158
|
-
? formatContextSegment(ctx, theme)
|
|
159
|
-
: undefined;
|
|
160
|
-
const totals = getTotals(ctx);
|
|
161
|
-
const separator = color(theme, cfg.colors.separator, " | ");
|
|
162
|
-
|
|
163
|
-
const leftFull = [modelSegment, dirSegment, gitSegment]
|
|
164
|
-
.filter(Boolean)
|
|
165
|
-
.join(separator);
|
|
166
|
-
const leftMin = modelSegment;
|
|
167
|
-
|
|
168
|
-
if (width >= 100) {
|
|
169
|
-
return [
|
|
170
|
-
joinLeftRight(
|
|
171
|
-
leftFull,
|
|
172
|
-
joinRightSegments(
|
|
173
|
-
theme,
|
|
174
|
-
ctxSegment,
|
|
175
|
-
tokenSegment(theme, totals, "full"),
|
|
176
|
-
),
|
|
177
|
-
width,
|
|
178
|
-
),
|
|
179
|
-
];
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (width >= 80) {
|
|
183
|
-
return [
|
|
184
|
-
joinLeftRight(
|
|
185
|
-
leftFull,
|
|
186
|
-
joinRightSegments(
|
|
187
|
-
theme,
|
|
188
|
-
ctxSegment,
|
|
189
|
-
tokenSegment(theme, totals, "no-cache"),
|
|
190
|
-
),
|
|
191
|
-
width,
|
|
192
|
-
),
|
|
193
|
-
];
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (width >= 60) {
|
|
197
|
-
return [
|
|
198
|
-
joinLeftRight(
|
|
199
|
-
leftFull,
|
|
200
|
-
joinRightSegments(
|
|
201
|
-
theme,
|
|
202
|
-
ctxSegment,
|
|
203
|
-
tokenSegment(theme, totals, "total-only"),
|
|
204
|
-
),
|
|
205
|
-
width,
|
|
206
|
-
),
|
|
207
|
-
];
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (width >= 40)
|
|
211
|
-
return [joinLeftRight(leftFull, ctxSegment ?? "", width)];
|
|
212
|
-
return [joinLeftRight(leftMin, ctxSegment ?? "", width)];
|
|
213
|
-
},
|
|
214
|
-
};
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function loadRuntimeConfig(cwd: string) {
|
|
219
|
-
const projectPath = path.join(cwd, ".pi", "clean-footer.json");
|
|
220
|
-
const result = loadConfig([runtime.configPaths.global, projectPath]);
|
|
221
|
-
runtime.configPaths = { global: runtime.configPaths.global, project: projectPath };
|
|
222
|
-
runtime.loadedConfigPaths = result.loadedPaths;
|
|
223
|
-
runtime.configError = result.error;
|
|
224
|
-
runtime.config = result.config;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function notifyConfigStatus(ctx: ExtensionContext) {
|
|
228
|
-
if (!ctx.hasUI) return;
|
|
229
|
-
if (runtime.configError) {
|
|
230
|
-
ctx.ui.notify(`Clean footer config error: ${runtime.configError}`, "error");
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
ctx.ui.notify("Clean footer config loaded", "info");
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function showConfig(ctx: ExtensionContext) {
|
|
237
|
-
if (!ctx.hasUI) return;
|
|
238
|
-
const loaded = runtime.loadedConfigPaths.length
|
|
239
|
-
? runtime.loadedConfigPaths.join("\n")
|
|
240
|
-
: "none";
|
|
241
|
-
ctx.ui.notify(
|
|
242
|
-
[
|
|
243
|
-
"Clean footer config",
|
|
244
|
-
`global: ${runtime.configPaths.global}`,
|
|
245
|
-
`project: ${runtime.configPaths.project}`,
|
|
246
|
-
`loaded:\n${loaded}`,
|
|
247
|
-
runtime.configError ? `error: ${runtime.configError}` : "error: none",
|
|
248
|
-
`resolved: ${JSON.stringify(runtime.config)}`,
|
|
249
|
-
].join("\n"),
|
|
250
|
-
"info",
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function formatModelSegment(ctx: ExtensionContext, theme: Theme): string {
|
|
255
|
-
const modelId = ctx.model?.id ?? "no-model";
|
|
256
|
-
const model = formatModelName(modelId);
|
|
257
|
-
const effort =
|
|
258
|
-
runtime.config.showEffort && runtime.thinkingLevel
|
|
259
|
-
? ` • ${runtime.thinkingLevel}`
|
|
260
|
-
: "";
|
|
261
|
-
return color(theme, runtime.config.colors.model, `${model}${effort}`);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function formatModelName(modelId: string): string {
|
|
265
|
-
const aliases = runtime.config.modelAliases;
|
|
266
|
-
if (aliases[modelId]) return aliases[modelId];
|
|
267
|
-
|
|
268
|
-
const lower = modelId.toLowerCase();
|
|
269
|
-
const withoutProvider = lower.includes("/") ? lower.split("/").pop()! : lower;
|
|
270
|
-
if (aliases[withoutProvider]) return aliases[withoutProvider];
|
|
271
|
-
|
|
272
|
-
if (
|
|
273
|
-
withoutProvider.includes("claude") &&
|
|
274
|
-
withoutProvider.includes("sonnet")
|
|
275
|
-
) {
|
|
276
|
-
if (withoutProvider.includes("4-5") || withoutProvider.includes("4.5"))
|
|
277
|
-
return "sonnet-4.5";
|
|
278
|
-
if (withoutProvider.includes("4")) return "sonnet-4";
|
|
279
|
-
return "sonnet";
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (withoutProvider.includes("claude") && withoutProvider.includes("opus"))
|
|
283
|
-
return "opus";
|
|
284
|
-
if (withoutProvider.includes("claude") && withoutProvider.includes("haiku"))
|
|
285
|
-
return "haiku";
|
|
286
|
-
|
|
287
|
-
const gpt5 = withoutProvider.match(/gpt-5(?:[.-][a-z0-9]+)*/);
|
|
288
|
-
if (gpt5) return gpt5[0];
|
|
289
|
-
|
|
290
|
-
const gpt4 = withoutProvider.match(/gpt-4(?:[.-][a-z0-9]+)*/);
|
|
291
|
-
if (gpt4) return gpt4[0];
|
|
292
|
-
|
|
293
|
-
const gemini = withoutProvider.match(/gemini-[a-z0-9.-]+/);
|
|
294
|
-
if (gemini) return gemini[0].replace(/-preview.*/, "");
|
|
295
|
-
|
|
296
|
-
return withoutProvider.length > 24
|
|
297
|
-
? `${withoutProvider.slice(0, 21)}…`
|
|
298
|
-
: withoutProvider;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function normalizeThinkingLevel(level: unknown): string | undefined {
|
|
302
|
-
if (typeof level !== "string") return undefined;
|
|
303
|
-
|
|
304
|
-
const normalized = level.toLowerCase();
|
|
305
|
-
if (normalized === "medium") return "med";
|
|
306
|
-
if (
|
|
307
|
-
normalized === "extra-high" ||
|
|
308
|
-
normalized === "extra_high" ||
|
|
309
|
-
normalized === "x-high"
|
|
310
|
-
)
|
|
311
|
-
return "xhigh";
|
|
312
|
-
if (["low", "med", "high", "xhigh"].includes(normalized)) return normalized;
|
|
313
|
-
|
|
314
|
-
return undefined;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function getTotals(ctx: ExtensionContext): Totals {
|
|
318
|
-
const totals: Totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
319
|
-
|
|
320
|
-
for (const entry of ctx.sessionManager.getBranch()) {
|
|
321
|
-
if (entry.type !== "message" || entry.message.role !== "assistant")
|
|
322
|
-
continue;
|
|
323
|
-
|
|
324
|
-
const message = entry.message as AssistantMessage;
|
|
325
|
-
totals.input += message.usage?.input ?? 0;
|
|
326
|
-
totals.output += message.usage?.output ?? 0;
|
|
327
|
-
totals.cacheRead += message.usage?.cacheRead ?? 0;
|
|
328
|
-
totals.cacheWrite += message.usage?.cacheWrite ?? 0;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
return totals;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function tokenSegment(
|
|
335
|
-
theme: Theme,
|
|
336
|
-
totals: Totals,
|
|
337
|
-
mode: "full" | "no-cache" | "total-only",
|
|
338
|
-
): string | undefined {
|
|
339
|
-
if (!runtime.config.showTokens) return undefined;
|
|
340
|
-
const effectiveMode = runtime.config.showCache
|
|
341
|
-
? mode
|
|
342
|
-
: mode === "full"
|
|
343
|
-
? "no-cache"
|
|
344
|
-
: mode;
|
|
345
|
-
return color(
|
|
346
|
-
theme,
|
|
347
|
-
runtime.config.colors.tokens,
|
|
348
|
-
formatTokenSegment(totals, effectiveMode),
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function formatTokenSegment(
|
|
353
|
-
totals: Totals,
|
|
354
|
-
mode: "full" | "no-cache" | "total-only",
|
|
355
|
-
): string {
|
|
356
|
-
const total = totals.input + totals.output;
|
|
357
|
-
if (mode === "total-only") return `Σ${formatCount(total)}`;
|
|
358
|
-
|
|
359
|
-
const base = `↑${formatCount(totals.input)} ↓${formatCount(totals.output)} Σ${formatCount(total)}`;
|
|
360
|
-
if (mode === "no-cache") return base;
|
|
361
|
-
|
|
362
|
-
return `${base} ↯${formatCount(totals.cacheRead)} ↥${formatCount(totals.cacheWrite)}`;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function formatContextSegment(ctx: ExtensionContext, theme: Theme): string {
|
|
366
|
-
const usage = ctx.getContextUsage?.();
|
|
367
|
-
const used = usage?.tokens ?? 0;
|
|
368
|
-
const max = ctx.model?.contextWindow;
|
|
369
|
-
const text = `ctx ${formatCount(used)}/${max ? formatCount(max) : "--"}`;
|
|
370
|
-
|
|
371
|
-
if (!max || max <= 0) return color(theme, "dim", text);
|
|
372
|
-
|
|
373
|
-
const percent = (used / max) * 100;
|
|
374
|
-
if (percent >= runtime.config.contextDangerPercent)
|
|
375
|
-
return color(theme, runtime.config.colors.contextDanger, text);
|
|
376
|
-
if (percent >= runtime.config.contextWarningPercent)
|
|
377
|
-
return color(theme, runtime.config.colors.contextWarning, text);
|
|
378
|
-
return color(theme, runtime.config.colors.contextNormal, text);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function formatGitSegment(theme: Theme): string | undefined {
|
|
382
|
-
if (!runtime.git.inRepo || !runtime.git.branch) return undefined;
|
|
383
|
-
|
|
384
|
-
const branch = color(theme, runtime.config.colors.git, runtime.git.branch);
|
|
385
|
-
if (runtime.git.dirtyCount <= 0) return branch;
|
|
386
|
-
|
|
387
|
-
return `${branch} ${color(theme, runtime.config.colors.gitDirty, `●${runtime.git.dirtyCount}`)}`;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
async function refreshGit(ctx: ExtensionContext, immediate = false) {
|
|
391
|
-
if (!runtime.config.showGit) {
|
|
392
|
-
runtime.git = { inRepo: false, dirtyCount: 0 };
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
try {
|
|
397
|
-
const [branchResult, statusResult] = await Promise.all([
|
|
398
|
-
execFileAsync("git", ["branch", "--show-current"], {
|
|
399
|
-
cwd: ctx.cwd,
|
|
400
|
-
timeout: 2_000,
|
|
401
|
-
}),
|
|
402
|
-
execFileAsync("git", ["status", "--porcelain"], {
|
|
403
|
-
cwd: ctx.cwd,
|
|
404
|
-
timeout: 2_000,
|
|
405
|
-
}),
|
|
406
|
-
]);
|
|
407
|
-
|
|
408
|
-
const branch = branchResult.stdout.trim() || "detached";
|
|
409
|
-
const dirtyCount = statusResult.stdout.split("\n").filter(Boolean).length;
|
|
410
|
-
runtime.git = { inRepo: true, branch, dirtyCount };
|
|
411
|
-
} catch {
|
|
412
|
-
runtime.git = { inRepo: false, dirtyCount: 0 };
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
if (immediate) runtime.requestRender?.();
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function scheduleGitRefresh(ctx: ExtensionContext) {
|
|
419
|
-
clearScheduledRefresh();
|
|
420
|
-
runtime.refreshTimer = setTimeout(() => {
|
|
421
|
-
runtime.refreshTimer = undefined;
|
|
422
|
-
void refreshGit(ctx, true);
|
|
423
|
-
}, runtime.config.gitRefreshDebounceMs);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
function clearScheduledRefresh() {
|
|
427
|
-
if (runtime.refreshTimer) clearTimeout(runtime.refreshTimer);
|
|
428
|
-
runtime.refreshTimer = undefined;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
function joinRightSegments(
|
|
432
|
-
theme: Theme,
|
|
433
|
-
...segments: Array<string | undefined>
|
|
434
|
-
): string {
|
|
435
|
-
return segments
|
|
436
|
-
.filter(Boolean)
|
|
437
|
-
.join(color(theme, runtime.config.colors.separator, " | "));
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
function joinLeftRight(left: string, right: string, width: number): string {
|
|
441
|
-
if (!right) return truncateToWidth(left, width);
|
|
442
|
-
if (!left) return truncateToWidth(right, width);
|
|
443
|
-
|
|
444
|
-
const gap = width - visibleWidth(left) - visibleWidth(right);
|
|
445
|
-
if (gap >= 1) return truncateToWidth(left + " ".repeat(gap) + right, width);
|
|
446
|
-
|
|
447
|
-
const half = Math.max(1, Math.floor((width - 1) / 2));
|
|
448
|
-
return (
|
|
449
|
-
truncateToWidth(left, half) + " " + truncateToWidth(right, width - half - 1)
|
|
450
|
-
);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
function color(theme: Theme, colorName: string, text: string): string {
|
|
454
|
-
return theme.fg(colorName as never, text);
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
function formatCount(value: number): string {
|
|
458
|
-
if (!Number.isFinite(value) || value <= 0) return "0";
|
|
459
|
-
if (value < 1_000) return `${Math.round(value)}`;
|
|
460
|
-
if (value < 1_000_000)
|
|
461
|
-
return `${(value / 1_000).toFixed(value < 10_000 ? 1 : 0)}k`;
|
|
462
|
-
return `${(value / 1_000_000).toFixed(1)}m`;
|
|
463
|
-
}
|