@neilurk12/pi-clean-footer 0.2.0 → 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 +2 -2
- package/dist/index.js +5 -0
- package/package.json +22 -3
- package/src/config.ts +0 -150
- package/src/index.ts +0 -463
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Clean, minimal, and lightweight powerline-style footer extension for [pi](https:
|
|
|
4
4
|
|
|
5
5
|
Shows a compact split footer:
|
|
6
6
|
|
|
7
|
-

|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
@@ -116,7 +116,7 @@ This package declares its extension through `package.json`:
|
|
|
116
116
|
```json
|
|
117
117
|
{
|
|
118
118
|
"pi": {
|
|
119
|
-
"extensions": ["./
|
|
119
|
+
"extensions": ["./dist/index.js"]
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
```
|
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": [
|
|
@@ -9,23 +9,42 @@
|
|
|
9
9
|
"footer",
|
|
10
10
|
"terminal"
|
|
11
11
|
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/Neil-urk12/pi-dots.git"
|
|
15
|
+
},
|
|
12
16
|
"license": "MIT",
|
|
13
17
|
"files": [
|
|
14
|
-
"
|
|
18
|
+
"dist",
|
|
15
19
|
"README.md",
|
|
16
20
|
"example.png"
|
|
17
21
|
],
|
|
22
|
+
"main": "./dist/index.js",
|
|
23
|
+
"module": "./dist/index.js",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./dist/index.js"
|
|
26
|
+
},
|
|
18
27
|
"pi": {
|
|
19
28
|
"extensions": [
|
|
20
|
-
"./
|
|
29
|
+
"./dist/index.js"
|
|
21
30
|
]
|
|
22
31
|
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsup",
|
|
34
|
+
"dev": "tsup --watch"
|
|
35
|
+
},
|
|
23
36
|
"peerDependencies": {
|
|
24
37
|
"@earendil-works/pi-ai": "*",
|
|
25
38
|
"@earendil-works/pi-coding-agent": "*",
|
|
26
39
|
"@earendil-works/pi-tui": "*"
|
|
27
40
|
},
|
|
28
41
|
"devDependencies": {
|
|
42
|
+
"tsup": "^8.5.1",
|
|
29
43
|
"typescript": "^6.0.3"
|
|
44
|
+
},
|
|
45
|
+
"pnpm": {
|
|
46
|
+
"onlyBuiltDependencies": [
|
|
47
|
+
"esbuild"
|
|
48
|
+
]
|
|
30
49
|
}
|
|
31
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
|
-
}
|