pi-plus 0.1.4 → 0.1.6
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 +47 -8
- package/package.json +4 -2
- package/src/context-usage.ts +91 -0
- package/src/footer-render.ts +248 -0
- package/src/footer.ts +139 -0
- package/src/index.ts +31 -33
- package/src/settings.ts +4 -0
- package/src/indicator.ts +0 -33
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
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
and CC's warning thresholds. It replaces pi's built-in footer so its context field
|
|
8
|
+
shows the plugin's CC-exact usage measured against the auto-compact threshold.
|
|
9
|
+
Optionally, it can also take over auto-compaction and fire `ctx.compact()` at CC's
|
|
10
|
+
auto-compact threshold.
|
|
10
11
|
|
|
11
12
|
## What it replicates (from Claude Code's source)
|
|
12
13
|
|
|
@@ -19,7 +20,7 @@ at CC's auto-compact threshold.
|
|
|
19
20
|
| Context measurement: last API usage (input+cacheWrite+cacheRead+output) + chars/4 tail estimate; images/PDFs flat 2000 | `utils/tokens.ts`, `services/tokenEstimation.ts` | `src/usage.ts` |
|
|
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
|
+
| Footer replication: cwd/branch line, token stats, model right side, extension-status line | `modes/interactive/components/footer.ts` | `src/footer-render.ts` |
|
|
23
24
|
|
|
24
25
|
This plugin supports **no Claude Code environment variables**
|
|
25
26
|
(`CLAUDE_CODE_*`, `DISABLE_COMPACT`, … are all ignored). All configuration is
|
|
@@ -42,6 +43,39 @@ ln -s "$PWD/src" ~/.pi/agent/extensions/pi-plus
|
|
|
42
43
|
# (or copy src/ there)
|
|
43
44
|
```
|
|
44
45
|
|
|
46
|
+
## Custom footer (default on)
|
|
47
|
+
|
|
48
|
+
By default the plugin takes over pi's footer via `ctx.ui.setFooter()` and
|
|
49
|
+
replicates the built-in rendering — cwd/git-branch line, token stats
|
|
50
|
+
(`↑ ↓ R W CH $`), right-aligned model/thinking level, and the extension-status
|
|
51
|
+
line — with one substitution: **the context field shows the plugin's CC-exact
|
|
52
|
+
usage measured against the auto-compact threshold**, e.g. `17%/187k (auto)`.
|
|
53
|
+
|
|
54
|
+
Semantics differ deliberately from pi's native `1.8%/1.0M` field:
|
|
55
|
+
|
|
56
|
+
- **Numerator** is the same measurement both sides already share (last valid
|
|
57
|
+
API usage + chars/4 tail estimation).
|
|
58
|
+
- **Denominator is the threshold** (effective window − 13k, settings-overridable),
|
|
59
|
+
not the raw registry window — so the percentage reaches 100% exactly when
|
|
60
|
+
the plugin's auto-compact fires. It rises as context fills, like pi's field.
|
|
61
|
+
- ` (auto)` is appended when auto-compact is enabled; right after `/compact`
|
|
62
|
+
the field shows `?/187k` until the next assistant response (pi's own
|
|
63
|
+
convention for unknown post-compaction usage).
|
|
64
|
+
- Colors follow the plugin's warning state: warning color inside the 20k
|
|
65
|
+
warning buffer, error past the error threshold or blocking limit.
|
|
66
|
+
|
|
67
|
+
Two built-in details are not reproducible from the extension API and are
|
|
68
|
+
omitted: the `• xp` experimental-features marker, and the `(sub)` suffix for
|
|
69
|
+
subscription providers other than `kimi-coding`.
|
|
70
|
+
|
|
71
|
+
**Conflict warning:** `setFooter` is last-caller-wins — pi-plus must be the
|
|
72
|
+
only footer-taking extension. Another extension calling `setFooter` (e.g.
|
|
73
|
+
`better-claude-code-ui`) replaces this footer entirely. Plain `setStatus`
|
|
74
|
+
extensions are fine; they appear on the status line as usual.
|
|
75
|
+
|
|
76
|
+
Set `"customFooter": "false"` (below) and `/reload` (or start a new session)
|
|
77
|
+
to restore pi's built-in footer.
|
|
78
|
+
|
|
45
79
|
## Auto-compact takeover (optional)
|
|
46
80
|
|
|
47
81
|
pi's built-in auto-compaction fires at `contextWindow − 16,384` tokens, which is
|
|
@@ -61,7 +95,7 @@ auto-compact and CC's autocompact use). The trigger is edge-triggered: it
|
|
|
61
95
|
fires once per crossing and re-arms after post-compact usage drops below the
|
|
62
96
|
threshold. Manual `/compact` keeps working.
|
|
63
97
|
|
|
64
|
-
Set `PI_PLUS_NO_AUTO_COMPACT=1` to disable the plugin's trigger (
|
|
98
|
+
Set `PI_PLUS_NO_AUTO_COMPACT=1` to disable the plugin's trigger (footer field only).
|
|
65
99
|
|
|
66
100
|
## Configuration
|
|
67
101
|
|
|
@@ -83,7 +117,8 @@ also appear in its `/extension-settings` UI.
|
|
|
83
117
|
"autoCompactWindowCap": "200000",
|
|
84
118
|
"maxOutputTokens": "8192",
|
|
85
119
|
"disableOutputTokenCap": "false",
|
|
86
|
-
"blockingLimit": "190000"
|
|
120
|
+
"blockingLimit": "190000",
|
|
121
|
+
"customFooter": "true"
|
|
87
122
|
}
|
|
88
123
|
}
|
|
89
124
|
```
|
|
@@ -91,6 +126,7 @@ also appear in its `/extension-settings` UI.
|
|
|
91
126
|
| Setting | Effect | Default |
|
|
92
127
|
|---|---|---|
|
|
93
128
|
| `autoCompact` | Master switch for the plugin's auto-compact trigger (`true`/`false`) | `true` |
|
|
129
|
+
| `customFooter` | Replace pi's built-in footer with the plugin's (see above); takes effect on next session/`/reload` | `true` |
|
|
94
130
|
| `contextWindow` | Override the model-derived context window (tokens) | model default (200k) |
|
|
95
131
|
| `autoCompactThreshold` | Absolute token threshold for auto-compact; wins over everything below | effective window − 13k |
|
|
96
132
|
| `autoCompactPercent` | Threshold as % of effective window (1–100, for testing) | off |
|
|
@@ -116,8 +152,11 @@ Set `PI_PLUS_NO_LOG=1` to disable it.
|
|
|
116
152
|
|
|
117
153
|
## Known limits
|
|
118
154
|
|
|
119
|
-
-
|
|
120
|
-
|
|
155
|
+
- With `customFooter` off (or replaced by another extension), pi's built-in
|
|
156
|
+
footer keeps showing its own `percent%` (raw-window denominator — a much
|
|
157
|
+
larger number for models like `kimi-for-coding` whose registry window is 1M);
|
|
158
|
+
the plugin then has no on-screen display, only the log and the auto-compact
|
|
159
|
+
trigger.
|
|
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
|
+
"version": "0.1.6",
|
|
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
|
},
|
|
@@ -43,7 +44,8 @@
|
|
|
43
44
|
],
|
|
44
45
|
"peerDependencies": {
|
|
45
46
|
"@earendil-works/pi-ai": "*",
|
|
46
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
47
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
+
"@earendil-works/pi-tui": "*"
|
|
47
49
|
},
|
|
48
50
|
"dependencies": {
|
|
49
51
|
"@juanibiapina/pi-extension-settings": "^0.10.0"
|
|
@@ -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
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A pi extension that measures context usage with the same arithmetic as
|
|
5
5
|
* Claude Code (usage-based prefix count + rough tail estimation, CC's context
|
|
6
|
-
* window resolution and warning thresholds) and
|
|
7
|
-
*
|
|
6
|
+
* window resolution and warning thresholds) and shows it in the footer's
|
|
7
|
+
* context field via a custom footer. When pi's built-in auto-compaction is
|
|
8
8
|
* disabled, the plugin takes over and calls ctx.compact() at CC's
|
|
9
9
|
* auto-compact threshold (effective window − 13,000 tokens).
|
|
10
10
|
*
|
|
@@ -15,21 +15,12 @@
|
|
|
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 {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
18
|
+
import { computePluginContextUsage } from "./context-usage.ts";
|
|
19
|
+
import { installFooter } from "./footer.ts";
|
|
20
|
+
import { isAutoCompactEnabled } from "./percentages.ts";
|
|
21
21
|
import { appendUsageLog } from "./logger.ts";
|
|
22
22
|
import { loadPiPlusSettings } from "./settings.ts";
|
|
23
|
-
import { tokenCountWithEstimation, type RoughMessage } from "./usage.ts";
|
|
24
23
|
|
|
25
|
-
const STATUS_KEY = "cc-context";
|
|
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
24
|
|
|
34
25
|
export default function (pi: ExtensionAPI) {
|
|
35
26
|
// Register our settings so they appear in the /extension-settings UI
|
|
@@ -87,6 +78,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
87
78
|
description: "Absolute token usage that blocks further work. Empty = effective window − 3k.",
|
|
88
79
|
defaultValue: "",
|
|
89
80
|
},
|
|
81
|
+
{
|
|
82
|
+
id: "customFooter",
|
|
83
|
+
label: "Custom footer (context field)",
|
|
84
|
+
description:
|
|
85
|
+
"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.",
|
|
86
|
+
defaultValue: "true",
|
|
87
|
+
values: ["true", "false"],
|
|
88
|
+
},
|
|
90
89
|
] satisfies SettingDefinition[],
|
|
91
90
|
});
|
|
92
91
|
|
|
@@ -94,28 +93,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
94
93
|
// re-arms automatically once post-compact usage drops below the threshold.
|
|
95
94
|
let compactTrigger: AutoCompactTriggerState = initialTriggerState();
|
|
96
95
|
|
|
97
|
-
function
|
|
96
|
+
function logUsageCheck(ctx: ExtensionContext): void {
|
|
98
97
|
const model = ctx.model;
|
|
99
|
-
if (!model)
|
|
100
|
-
if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
98
|
+
if (!model) return;
|
|
103
99
|
const settings = loadPiPlusSettings();
|
|
104
|
-
const
|
|
105
|
-
|
|
100
|
+
const computed = computePluginContextUsage(ctx, settings);
|
|
101
|
+
if (!computed) return;
|
|
102
|
+
const state = computed.state;
|
|
106
103
|
appendUsageLog({
|
|
107
104
|
event: "usage_check",
|
|
108
105
|
source: "indicator",
|
|
109
106
|
model: model.id,
|
|
110
|
-
tokens:
|
|
107
|
+
tokens: computed.tokens,
|
|
111
108
|
percentLeft: state.percentLeft,
|
|
112
109
|
aboveWarning: state.isAboveWarningThreshold,
|
|
113
110
|
aboveError: state.isAboveErrorThreshold,
|
|
114
111
|
aboveAutoCompact: state.isAboveAutoCompactThreshold,
|
|
115
112
|
atBlockingLimit: state.isAtBlockingLimit,
|
|
116
113
|
});
|
|
117
|
-
if (!ctx.hasUI) return;
|
|
118
|
-
ctx.ui.setStatus(STATUS_KEY, renderTokenWarning(state, isAutoCompactEnabled(settings), ctx.ui.theme));
|
|
119
114
|
}
|
|
120
115
|
|
|
121
116
|
/**
|
|
@@ -134,8 +129,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
134
129
|
const settings = loadPiPlusSettings();
|
|
135
130
|
if (!isAutoCompactEnabled(settings)) return;
|
|
136
131
|
if (process.env.PI_PLUS_NO_AUTO_COMPACT !== undefined) return;
|
|
137
|
-
const
|
|
138
|
-
|
|
132
|
+
const computed = computePluginContextUsage(ctx, settings);
|
|
133
|
+
if (!computed) return;
|
|
134
|
+
const tokenCount = computed.tokens;
|
|
135
|
+
const threshold = computed.threshold;
|
|
139
136
|
const fired = shouldFireAutoCompact(compactTrigger, tokenCount, threshold);
|
|
140
137
|
appendUsageLog({
|
|
141
138
|
event: "usage_check",
|
|
@@ -158,17 +155,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
158
155
|
|
|
159
156
|
pi.on("session_start", (_event, ctx) => {
|
|
160
157
|
compactTrigger = initialTriggerState();
|
|
161
|
-
|
|
158
|
+
installFooter(ctx);
|
|
159
|
+
logUsageCheck(ctx);
|
|
162
160
|
maybeAutoCompact(ctx);
|
|
163
161
|
});
|
|
164
|
-
pi.on("turn_end", (_event, ctx) =>
|
|
165
|
-
pi.on("agent_settled", (_event, ctx) =>
|
|
162
|
+
pi.on("turn_end", (_event, ctx) => logUsageCheck(ctx));
|
|
163
|
+
pi.on("agent_settled", (_event, ctx) => logUsageCheck(ctx));
|
|
166
164
|
// Check token overflow on every tool task finish.
|
|
167
165
|
pi.on("tool_execution_end", (_event, ctx) => {
|
|
168
|
-
|
|
166
|
+
logUsageCheck(ctx);
|
|
169
167
|
maybeAutoCompact(ctx);
|
|
170
168
|
});
|
|
171
|
-
pi.on("message_end", (_event, ctx) =>
|
|
172
|
-
pi.on("model_select", (_event, ctx) =>
|
|
173
|
-
pi.on("session_compact", (_event, ctx) =>
|
|
169
|
+
pi.on("message_end", (_event, ctx) => logUsageCheck(ctx));
|
|
170
|
+
pi.on("model_select", (_event, ctx) => logUsageCheck(ctx));
|
|
171
|
+
pi.on("session_compact", (_event, ctx) => logUsageCheck(ctx));
|
|
174
172
|
}
|
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
|
}
|
package/src/indicator.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Port of Claude Code's TokenWarning indicator (src/components/TokenWarning.tsx),
|
|
3
|
-
* adapted for a permanent footer status line.
|
|
4
|
-
*
|
|
5
|
-
* Rendering rules (auto-compact reactive/collapse modes omitted — ant-only):
|
|
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)
|
|
8
|
-
* - auto-compact on: dim "N% until auto-compact"
|
|
9
|
-
* - auto-compact off: "Context low (N% remaining) · Run /compact to compact & continue",
|
|
10
|
-
* warning color, or error color once past the error threshold / blocking limit
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import type { TokenWarningState } from "./percentages.ts";
|
|
14
|
-
|
|
15
|
-
/** Structural subset of pi's Theme — avoids a hard pi-tui dependency. */
|
|
16
|
-
export interface ThemeLike {
|
|
17
|
-
fg(color: string, text: string): string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function renderTokenWarning(
|
|
21
|
-
state: TokenWarningState,
|
|
22
|
-
autoCompactEnabled: boolean,
|
|
23
|
-
theme: ThemeLike,
|
|
24
|
-
): string | undefined {
|
|
25
|
-
if (autoCompactEnabled) {
|
|
26
|
-
return theme.fg("dim", `${state.percentLeft}% until auto-compact`);
|
|
27
|
-
}
|
|
28
|
-
if (!state.isAboveWarningThreshold) {
|
|
29
|
-
return theme.fg("dim", `${state.percentLeft}% remaining`);
|
|
30
|
-
}
|
|
31
|
-
const color = state.isAboveErrorThreshold || state.isAtBlockingLimit ? "error" : "warning";
|
|
32
|
-
return theme.fg(color, `Context low (${state.percentLeft}% remaining) · Run /compact to compact & continue`);
|
|
33
|
-
}
|