pi-plus 0.1.3 → 0.1.5

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 CHANGED
@@ -4,9 +4,10 @@ Claude Code-exact context usage detection for [pi](https://github.com/earendil-w
4
4
 
5
5
  This extension measures pi's context usage with the **same arithmetic as Claude Code** —
6
6
  usage-based prefix counting plus rough tail estimation, CC's context-window resolution,
7
- and CC's warning thresholds — and renders CC's `TokenWarning` indicator as a footer
8
- status line. Optionally, it can also take over auto-compaction and fire `ctx.compact()`
9
- at CC's auto-compact threshold.
7
+ and CC's warning thresholds. It renders CC's `TokenWarning` indicator as a footer
8
+ status line, and can replace pi's built-in footer so its context field shows the
9
+ plugin's CC-exact usage. Optionally, it can also take over auto-compaction and fire
10
+ `ctx.compact()` at CC's auto-compact threshold.
10
11
 
11
12
  ## What it replicates (from Claude Code's source)
12
13
 
@@ -20,6 +21,7 @@ at CC's auto-compact threshold.
20
21
  | Statusline percentage excludes output tokens; raw window denominator | `utils/context.ts` | `src/percentages.ts` |
21
22
  | Warning/error/blocking thresholds (20k/20k/3k buffers) | `services/compact/autoCompact.ts` | `src/percentages.ts` |
22
23
  | Indicator text: dim `N% until auto-compact`, `Context low (N% remaining) · Run /compact to compact & continue` | `components/TokenWarning.tsx` | `src/indicator.ts` |
24
+ | Footer replication: cwd/branch line, token stats, model right side, extension-status line | `modes/interactive/components/footer.ts` | `src/footer-render.ts` |
23
25
 
24
26
  This plugin supports **no Claude Code environment variables**
25
27
  (`CLAUDE_CODE_*`, `DISABLE_COMPACT`, … are all ignored). All configuration is
@@ -42,6 +44,39 @@ ln -s "$PWD/src" ~/.pi/agent/extensions/pi-plus
42
44
  # (or copy src/ there)
43
45
  ```
44
46
 
47
+ ## Custom footer (default on)
48
+
49
+ By default the plugin takes over pi's footer via `ctx.ui.setFooter()` and
50
+ replicates the built-in rendering — cwd/git-branch line, token stats
51
+ (`↑ ↓ R W CH $`), right-aligned model/thinking level, and the extension-status
52
+ line — with one substitution: **the context field shows the plugin's CC-exact
53
+ usage measured against the auto-compact threshold**, e.g. `17%/187k (auto)`.
54
+
55
+ Semantics differ deliberately from pi's native `1.8%/1.0M` field:
56
+
57
+ - **Numerator** is the same measurement both sides already share (last valid
58
+ API usage + chars/4 tail estimation).
59
+ - **Denominator is the threshold** (effective window − 13k, settings-overridable),
60
+ not the raw registry window — so the percentage reaches 100% exactly when
61
+ the plugin's auto-compact fires. It rises as context fills, like pi's field.
62
+ - ` (auto)` is appended when auto-compact is enabled; right after `/compact`
63
+ the field shows `?/187k` until the next assistant response (pi's own
64
+ convention for unknown post-compaction usage).
65
+ - Colors follow the plugin's warning state: warning color inside the 20k
66
+ warning buffer, error past the error threshold or blocking limit.
67
+
68
+ Two built-in details are not reproducible from the extension API and are
69
+ omitted: the `• xp` experimental-features marker, and the `(sub)` suffix for
70
+ subscription providers other than `kimi-coding`.
71
+
72
+ **Conflict warning:** `setFooter` is last-caller-wins — pi-plus must be the
73
+ only footer-taking extension. Another extension calling `setFooter` (e.g.
74
+ `better-claude-code-ui`) replaces this footer entirely. Plain `setStatus`
75
+ extensions are fine; they appear on the status line as usual.
76
+
77
+ Set `"customFooter": "false"` (below) and `/reload` (or start a new session)
78
+ to restore pi's built-in footer.
79
+
45
80
  ## Auto-compact takeover (optional)
46
81
 
47
82
  pi's built-in auto-compaction fires at `contextWindow − 16,384` tokens, which is
@@ -83,7 +118,8 @@ also appear in its `/extension-settings` UI.
83
118
  "autoCompactWindowCap": "200000",
84
119
  "maxOutputTokens": "8192",
85
120
  "disableOutputTokenCap": "false",
86
- "blockingLimit": "190000"
121
+ "blockingLimit": "190000",
122
+ "customFooter": "true"
87
123
  }
88
124
  }
89
125
  ```
@@ -91,6 +127,7 @@ also appear in its `/extension-settings` UI.
91
127
  | Setting | Effect | Default |
92
128
  |---|---|---|
93
129
  | `autoCompact` | Master switch for the plugin's auto-compact trigger (`true`/`false`) | `true` |
130
+ | `customFooter` | Replace pi's built-in footer with the plugin's (see above); takes effect on next session/`/reload` | `true` |
94
131
  | `contextWindow` | Override the model-derived context window (tokens) | model default (200k) |
95
132
  | `autoCompactThreshold` | Absolute token threshold for auto-compact; wins over everything below | effective window − 13k |
96
133
  | `autoCompactPercent` | Threshold as % of effective window (1–100, for testing) | off |
@@ -116,8 +153,10 @@ Set `PI_PLUS_NO_LOG=1` to disable it.
116
153
 
117
154
  ## Known limits
118
155
 
119
- - pi's built-in footer keeps showing its own `percent%` (different math: includes
120
- output tokens). This plugin adds its own status line; it does not replace the footer.
156
+ - With `customFooter` off (or replaced by another extension), pi's built-in
157
+ footer keeps showing its own `percent%` (raw-window denominator — a much
158
+ larger number for models like `kimi-for-coding` whose registry window is 1M).
159
+ The status line still shows the plugin's indicator either way.
121
160
  - pi core owns the compaction cut point (keeps last ~20k tokens) — only the trigger
122
161
  threshold is CC-exact.
123
162
  - Exactness is guaranteed for Anthropic-style usage fields; other providers may map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-plus",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Claude Code-exact context usage detection for pi (extension)",
5
5
  "type": "module",
6
6
  "pi": {
@@ -16,6 +16,7 @@
16
16
  "devDependencies": {
17
17
  "@earendil-works/pi-ai": "0.85.1",
18
18
  "@earendil-works/pi-coding-agent": "0.85.1",
19
+ "@earendil-works/pi-tui": "0.85.1",
19
20
  "typescript": "^5.6.0",
20
21
  "vitest": "^2.1.9"
21
22
  },
@@ -0,0 +1,91 @@
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
+ }
@@ -0,0 +1,248 @@
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 ADDED
@@ -0,0 +1,139 @@
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
+ }
package/src/index.ts CHANGED
@@ -15,22 +15,15 @@
15
15
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
16
  import type { SettingDefinition } from "@juanibiapina/pi-extension-settings";
17
17
  import { initialTriggerState, shouldFireAutoCompact, type AutoCompactTriggerState } from "./auto-compact.ts";
18
- import { getAutoCompactThreshold } from "./model-config.ts";
19
- import { isAutoCompactEnabled, calculateTokenWarningState } from "./percentages.ts";
18
+ import { computePluginContextUsage } from "./context-usage.ts";
19
+ import { installFooter } from "./footer.ts";
20
+ import { isAutoCompactEnabled } from "./percentages.ts";
20
21
  import { renderTokenWarning } from "./indicator.ts";
21
22
  import { appendUsageLog } from "./logger.ts";
22
23
  import { loadPiPlusSettings } from "./settings.ts";
23
- import { tokenCountWithEstimation, type RoughMessage } from "./usage.ts";
24
24
 
25
25
  const STATUS_KEY = "cc-context";
26
26
 
27
- function branchMessages(ctx: ExtensionContext): RoughMessage[] {
28
- return ctx.sessionManager
29
- .getBranch()
30
- .filter((entry) => entry.type === "message")
31
- .map((entry) => entry.message as unknown as RoughMessage);
32
- }
33
-
34
27
  export default function (pi: ExtensionAPI) {
35
28
  // Register our settings so they appear in the /extension-settings UI
36
29
  // (provided by @juanibiapina/pi-extension-settings when installed).
@@ -87,6 +80,14 @@ export default function (pi: ExtensionAPI) {
87
80
  description: "Absolute token usage that blocks further work. Empty = effective window − 3k.",
88
81
  defaultValue: "",
89
82
  },
83
+ {
84
+ id: "customFooter",
85
+ label: "Custom footer (context field)",
86
+ description:
87
+ "Replace pi's built-in footer so its context field shows pi-plus's CC-exact usage (% used of the auto-compact threshold). Takes effect on the next session or /reload.",
88
+ defaultValue: "true",
89
+ values: ["true", "false"],
90
+ },
90
91
  ] satisfies SettingDefinition[],
91
92
  });
92
93
 
@@ -101,13 +102,14 @@ export default function (pi: ExtensionAPI) {
101
102
  return;
102
103
  }
103
104
  const settings = loadPiPlusSettings();
104
- const tokenCount = tokenCountWithEstimation(branchMessages(ctx));
105
- const state = calculateTokenWarningState(tokenCount, model.id, model.contextWindow, model.maxTokens, settings);
105
+ const computed = computePluginContextUsage(ctx, settings);
106
+ if (!computed) return;
107
+ const state = computed.state;
106
108
  appendUsageLog({
107
109
  event: "usage_check",
108
110
  source: "indicator",
109
111
  model: model.id,
110
- tokens: tokenCount,
112
+ tokens: computed.tokens,
111
113
  percentLeft: state.percentLeft,
112
114
  aboveWarning: state.isAboveWarningThreshold,
113
115
  aboveError: state.isAboveErrorThreshold,
@@ -115,7 +117,7 @@ export default function (pi: ExtensionAPI) {
115
117
  atBlockingLimit: state.isAtBlockingLimit,
116
118
  });
117
119
  if (!ctx.hasUI) return;
118
- ctx.ui.setStatus(STATUS_KEY, renderTokenWarning(state, isAutoCompactEnabled(settings), ctx.ui.theme));
120
+ ctx.ui.setStatus(STATUS_KEY, renderTokenWarning(state, computed.autoCompactEnabled, ctx.ui.theme));
119
121
  }
120
122
 
121
123
  /**
@@ -134,8 +136,10 @@ export default function (pi: ExtensionAPI) {
134
136
  const settings = loadPiPlusSettings();
135
137
  if (!isAutoCompactEnabled(settings)) return;
136
138
  if (process.env.PI_PLUS_NO_AUTO_COMPACT !== undefined) return;
137
- const tokenCount = tokenCountWithEstimation(branchMessages(ctx));
138
- const threshold = getAutoCompactThreshold(model.id, model.contextWindow, model.maxTokens, settings);
139
+ const computed = computePluginContextUsage(ctx, settings);
140
+ if (!computed) return;
141
+ const tokenCount = computed.tokens;
142
+ const threshold = computed.threshold;
139
143
  const fired = shouldFireAutoCompact(compactTrigger, tokenCount, threshold);
140
144
  appendUsageLog({
141
145
  event: "usage_check",
@@ -158,6 +162,7 @@ export default function (pi: ExtensionAPI) {
158
162
 
159
163
  pi.on("session_start", (_event, ctx) => {
160
164
  compactTrigger = initialTriggerState();
165
+ installFooter(ctx);
161
166
  updateIndicator(ctx);
162
167
  maybeAutoCompact(ctx);
163
168
  });
package/src/indicator.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
- * Port of Claude Code's TokenWarning indicator (src/components/TokenWarning.tsx).
2
+ * Port of Claude Code's TokenWarning indicator (src/components/TokenWarning.tsx),
3
+ * adapted for a permanent footer status line.
3
4
  *
4
5
  * Rendering rules (auto-compact reactive/collapse modes omitted — ant-only):
5
- * - below the warning threshold: render nothing
6
+ * - below the warning threshold: dim "N% until auto-compact" (CC renders nothing
7
+ * here, but a status line should always show the current usage)
6
8
  * - auto-compact on: dim "N% until auto-compact"
7
9
  * - auto-compact off: "Context low (N% remaining) · Run /compact to compact & continue",
8
10
  * warning color, or error color once past the error threshold / blocking limit
@@ -20,11 +22,12 @@ export function renderTokenWarning(
20
22
  autoCompactEnabled: boolean,
21
23
  theme: ThemeLike,
22
24
  ): string | undefined {
23
- if (!state.isAboveWarningThreshold) return undefined;
24
-
25
25
  if (autoCompactEnabled) {
26
26
  return theme.fg("dim", `${state.percentLeft}% until auto-compact`);
27
27
  }
28
+ if (!state.isAboveWarningThreshold) {
29
+ return theme.fg("dim", `${state.percentLeft}% remaining`);
30
+ }
28
31
  const color = state.isAboveErrorThreshold || state.isAtBlockingLimit ? "error" : "warning";
29
32
  return theme.fg(color, `Context low (${state.percentLeft}% remaining) · Run /compact to compact & continue`);
30
33
  }
package/src/settings.ts CHANGED
@@ -40,6 +40,8 @@ export interface PiPlusSettings extends ModelConfigSettings {
40
40
  autoCompact?: boolean;
41
41
  /** Override the blocking limit (absolute tokens). */
42
42
  blockingLimit?: number;
43
+ /** Master switch for the custom footer takeover (default true). */
44
+ customFooter?: boolean;
43
45
  }
44
46
 
45
47
  const EXTENSION_NAME = "pi-plus";
@@ -84,6 +86,7 @@ export function loadPiPlusSettings(options?: SettingStorageOptions): PiPlusSetti
84
86
  const blockingLimit = positiveInt("blockingLimit", options);
85
87
  const autoCompact = booleanValue("autoCompact", options);
86
88
  const disableOutputTokenCap = booleanValue("disableOutputTokenCap", options);
89
+ const customFooter = booleanValue("customFooter", options);
87
90
 
88
91
  if (contextWindow !== undefined) settings.contextWindow = contextWindow;
89
92
  if (autoCompactWindowCap !== undefined) settings.autoCompactWindowCap = autoCompactWindowCap;
@@ -93,5 +96,6 @@ export function loadPiPlusSettings(options?: SettingStorageOptions): PiPlusSetti
93
96
  if (blockingLimit !== undefined) settings.blockingLimit = blockingLimit;
94
97
  if (autoCompact !== undefined) settings.autoCompact = autoCompact;
95
98
  if (disableOutputTokenCap !== undefined) settings.disableOutputTokenCap = disableOutputTokenCap;
99
+ if (customFooter !== undefined) settings.customFooter = customFooter;
96
100
  return settings;
97
101
  }