pi-zentui 0.1.7 → 0.1.8
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/extensions/zentui/config.ts +1 -135
- package/extensions/zentui/footer.ts +97 -0
- package/extensions/zentui/format.ts +85 -0
- package/extensions/zentui/index.ts +51 -246
- package/extensions/zentui/runtime.ts +531 -603
- package/extensions/zentui/state.ts +41 -0
- package/extensions/zentui/style.ts +94 -0
- package/package.json +2 -2
|
@@ -39,59 +39,11 @@ export type PolishedTuiConfig = {
|
|
|
39
39
|
|
|
40
40
|
export const configPath = join(getAgentDir(), "zentui.json");
|
|
41
41
|
|
|
42
|
-
const themeColorTokens = new Set([
|
|
43
|
-
"accent",
|
|
44
|
-
"border",
|
|
45
|
-
"borderAccent",
|
|
46
|
-
"borderMuted",
|
|
47
|
-
"success",
|
|
48
|
-
"error",
|
|
49
|
-
"warning",
|
|
50
|
-
"muted",
|
|
51
|
-
"dim",
|
|
52
|
-
"text",
|
|
53
|
-
"thinkingText",
|
|
54
|
-
"userMessageText",
|
|
55
|
-
"customMessageText",
|
|
56
|
-
"customMessageLabel",
|
|
57
|
-
"toolTitle",
|
|
58
|
-
"toolOutput",
|
|
59
|
-
"mdHeading",
|
|
60
|
-
"mdLink",
|
|
61
|
-
"mdLinkUrl",
|
|
62
|
-
"mdCode",
|
|
63
|
-
"mdCodeBlock",
|
|
64
|
-
"mdCodeBlockBorder",
|
|
65
|
-
"mdQuote",
|
|
66
|
-
"mdQuoteBorder",
|
|
67
|
-
"mdHr",
|
|
68
|
-
"mdListBullet",
|
|
69
|
-
"toolDiffAdded",
|
|
70
|
-
"toolDiffRemoved",
|
|
71
|
-
"toolDiffContext",
|
|
72
|
-
"syntaxComment",
|
|
73
|
-
"syntaxKeyword",
|
|
74
|
-
"syntaxFunction",
|
|
75
|
-
"syntaxVariable",
|
|
76
|
-
"syntaxString",
|
|
77
|
-
"syntaxNumber",
|
|
78
|
-
"syntaxType",
|
|
79
|
-
"syntaxOperator",
|
|
80
|
-
"syntaxPunctuation",
|
|
81
|
-
"thinkingOff",
|
|
82
|
-
"thinkingMinimal",
|
|
83
|
-
"thinkingLow",
|
|
84
|
-
"thinkingMedium",
|
|
85
|
-
"thinkingHigh",
|
|
86
|
-
"thinkingXhigh",
|
|
87
|
-
"bashMode",
|
|
88
|
-
]);
|
|
89
|
-
|
|
90
42
|
export const defaultConfig: PolishedTuiConfig = {
|
|
91
43
|
projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
|
|
92
44
|
icons: {
|
|
93
45
|
cwd: "",
|
|
94
|
-
git: "
|
|
46
|
+
git: "",
|
|
95
47
|
ahead: "↑",
|
|
96
48
|
behind: "↓",
|
|
97
49
|
diverged: "⇕",
|
|
@@ -117,62 +69,6 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
117
69
|
},
|
|
118
70
|
};
|
|
119
71
|
|
|
120
|
-
function isHexColor(value: string): boolean {
|
|
121
|
-
return /^#(?:[0-9a-fA-F]{6})$/.test(value);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function hexToAnsi(hex: string, isBackground = false): string {
|
|
125
|
-
const normalized = hex.slice(1);
|
|
126
|
-
const r = Number.parseInt(normalized.slice(0, 2), 16);
|
|
127
|
-
const g = Number.parseInt(normalized.slice(2, 4), 16);
|
|
128
|
-
const b = Number.parseInt(normalized.slice(4, 6), 16);
|
|
129
|
-
return `\x1b[${isBackground ? 48 : 38};2;${r};${g};${b}m`;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const terminalColorCodes = new Map([
|
|
133
|
-
["black", 30],
|
|
134
|
-
["red", 31],
|
|
135
|
-
["green", 32],
|
|
136
|
-
["yellow", 33],
|
|
137
|
-
["blue", 34],
|
|
138
|
-
["purple", 35],
|
|
139
|
-
["cyan", 36],
|
|
140
|
-
["white", 37],
|
|
141
|
-
["bright-black", 90],
|
|
142
|
-
["bright-red", 91],
|
|
143
|
-
["bright-green", 92],
|
|
144
|
-
["bright-yellow", 93],
|
|
145
|
-
["bright-blue", 94],
|
|
146
|
-
["bright-purple", 95],
|
|
147
|
-
["bright-cyan", 96],
|
|
148
|
-
["bright-white", 97],
|
|
149
|
-
]);
|
|
150
|
-
|
|
151
|
-
const terminalStyleModifiers = new Map([
|
|
152
|
-
["bold", 1],
|
|
153
|
-
["dim", 2],
|
|
154
|
-
["dimmed", 2],
|
|
155
|
-
["italic", 3],
|
|
156
|
-
["underline", 4],
|
|
157
|
-
]);
|
|
158
|
-
|
|
159
|
-
function terminalColorToAnsi(color: string): string | undefined {
|
|
160
|
-
const normalized = color.toLowerCase();
|
|
161
|
-
const colorCode = terminalColorCodes.get(normalized);
|
|
162
|
-
if (colorCode !== undefined) return `${colorCode}`;
|
|
163
|
-
|
|
164
|
-
if (/^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(normalized)) {
|
|
165
|
-
return `38;5;${normalized}`;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (isHexColor(normalized)) return hexToAnsi(normalized).slice(2, -1);
|
|
169
|
-
return undefined;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
type ThemeLike = {
|
|
173
|
-
fg(color: string, text: string): string;
|
|
174
|
-
};
|
|
175
|
-
|
|
176
72
|
type ConfigRecord = Record<string, unknown>;
|
|
177
73
|
|
|
178
74
|
function isRecord(value: unknown): value is ConfigRecord {
|
|
@@ -191,36 +87,6 @@ function parseProjectRefreshIntervalMs(value: unknown): number {
|
|
|
191
87
|
: defaultConfig.projectRefreshIntervalMs;
|
|
192
88
|
}
|
|
193
89
|
|
|
194
|
-
export function colorize(theme: ThemeLike, color: ColorSpec, text: string): string {
|
|
195
|
-
if (themeColorTokens.has(color)) {
|
|
196
|
-
return theme.fg(color, text);
|
|
197
|
-
}
|
|
198
|
-
if (isHexColor(color)) {
|
|
199
|
-
return `${hexToAnsi(color)}${text}\x1b[39m`;
|
|
200
|
-
}
|
|
201
|
-
return theme.fg("text", text);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export function renderTerminalStyle(style: string, text: string): string {
|
|
205
|
-
const codes: string[] = [];
|
|
206
|
-
for (const token of style.trim().split(/\s+/)) {
|
|
207
|
-
if (!token) continue;
|
|
208
|
-
|
|
209
|
-
const normalized = token.toLowerCase();
|
|
210
|
-
const modifier = terminalStyleModifiers.get(normalized);
|
|
211
|
-
if (modifier !== undefined) {
|
|
212
|
-
codes.push(`${modifier}`);
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const foreground = normalized.startsWith("fg:") ? normalized.slice(3) : normalized;
|
|
217
|
-
const color = terminalColorToAnsi(foreground);
|
|
218
|
-
if (color) codes.push(color);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
return codes.length ? `\x1b[${codes.join(";")}m${text}\x1b[0m` : text;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
90
|
export function ensureConfigExists(): void {
|
|
225
91
|
try {
|
|
226
92
|
if (!existsSync(configPath)) {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { PolishedTuiConfig } from "./config";
|
|
4
|
+
import { formatCwdLabel, formatRuntimeSegment } from "./format";
|
|
5
|
+
import type { FooterState } from "./state";
|
|
6
|
+
import { colorize } from "./style";
|
|
7
|
+
|
|
8
|
+
export function installFooter(
|
|
9
|
+
ctx: ExtensionContext,
|
|
10
|
+
state: FooterState,
|
|
11
|
+
config: PolishedTuiConfig,
|
|
12
|
+
hooks: {
|
|
13
|
+
setRequestRender: (fn: (() => void) | undefined) => void;
|
|
14
|
+
scheduleProjectRefresh: (ctx: ExtensionContext) => void;
|
|
15
|
+
},
|
|
16
|
+
): void {
|
|
17
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
18
|
+
hooks.setRequestRender(() => tui.requestRender());
|
|
19
|
+
const unsubscribeBranch = footerData.onBranchChange(() => {
|
|
20
|
+
hooks.scheduleProjectRefresh(ctx);
|
|
21
|
+
tui.requestRender();
|
|
22
|
+
});
|
|
23
|
+
const separator = colorize(theme, config.colors.separator, " | ");
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
dispose: () => {
|
|
27
|
+
unsubscribeBranch();
|
|
28
|
+
hooks.setRequestRender(undefined);
|
|
29
|
+
},
|
|
30
|
+
invalidate() {},
|
|
31
|
+
render(width: number): string[] {
|
|
32
|
+
if (width <= 0) return [""];
|
|
33
|
+
const innerWidth = Math.max(1, width - 2);
|
|
34
|
+
const cwdLabel = colorize(
|
|
35
|
+
theme,
|
|
36
|
+
config.colors.cwdText,
|
|
37
|
+
formatCwdLabel(ctx.cwd, config.icons.cwd),
|
|
38
|
+
);
|
|
39
|
+
const branch = state.branch;
|
|
40
|
+
const contextUsage = ctx.getContextUsage();
|
|
41
|
+
const contextColor =
|
|
42
|
+
contextUsage?.percent !== null && contextUsage?.percent !== undefined
|
|
43
|
+
? contextUsage.percent >= 90
|
|
44
|
+
? config.colors.contextError
|
|
45
|
+
: contextUsage.percent >= 70
|
|
46
|
+
? config.colors.contextWarning
|
|
47
|
+
: config.colors.contextNormal
|
|
48
|
+
: config.colors.contextNormal;
|
|
49
|
+
const gitColor = (text: string) => colorize(theme, config.colors.git, text);
|
|
50
|
+
const gitStatusColor = (text: string) => colorize(theme, config.colors.gitStatus, text);
|
|
51
|
+
const gitIcon = gitColor(config.icons.git);
|
|
52
|
+
const allStatus = [
|
|
53
|
+
state.conflicted > 0 ? config.icons.conflicted : "",
|
|
54
|
+
state.stashed ? config.icons.stashed : "",
|
|
55
|
+
state.deleted > 0 ? config.icons.deleted : "",
|
|
56
|
+
state.renamed > 0 ? config.icons.renamed : "",
|
|
57
|
+
state.modified > 0 ? config.icons.modified : "",
|
|
58
|
+
state.typechanged > 0 ? config.icons.typechanged : "",
|
|
59
|
+
state.staged > 0 ? config.icons.staged : "",
|
|
60
|
+
state.untracked > 0 ? config.icons.untracked : "",
|
|
61
|
+
].join("");
|
|
62
|
+
const aheadBehind =
|
|
63
|
+
state.ahead > 0 && state.behind > 0
|
|
64
|
+
? config.icons.diverged
|
|
65
|
+
: state.ahead > 0
|
|
66
|
+
? config.icons.ahead
|
|
67
|
+
: state.behind > 0
|
|
68
|
+
? config.icons.behind
|
|
69
|
+
: "";
|
|
70
|
+
const statusBlock =
|
|
71
|
+
allStatus || aheadBehind ? gitStatusColor(`[${allStatus}${aheadBehind}]`) : "";
|
|
72
|
+
const branchLabel = branch
|
|
73
|
+
? `${colorize(theme, "text", "on")} ${gitIcon} ${gitColor(branch)}${statusBlock ? ` ${statusBlock}` : ""}`
|
|
74
|
+
: "";
|
|
75
|
+
const runtimeLabel = formatRuntimeSegment(theme, state.runtime, "text");
|
|
76
|
+
|
|
77
|
+
const left = [cwdLabel, branchLabel, runtimeLabel].filter(Boolean).join(" ");
|
|
78
|
+
const right = [
|
|
79
|
+
colorize(theme, contextColor, state.contextLabel),
|
|
80
|
+
colorize(theme, config.colors.tokens, state.tokenLabel),
|
|
81
|
+
colorize(theme, config.colors.cost, state.costLabel),
|
|
82
|
+
].join(separator);
|
|
83
|
+
|
|
84
|
+
const leftWidth = visibleWidth(left);
|
|
85
|
+
const rightWidth = visibleWidth(right);
|
|
86
|
+
const content =
|
|
87
|
+
leftWidth >= innerWidth
|
|
88
|
+
? truncateToWidth(left, innerWidth, "")
|
|
89
|
+
: leftWidth + 1 + rightWidth <= innerWidth
|
|
90
|
+
? `${left}${" ".repeat(innerWidth - leftWidth - rightWidth)}${right}`
|
|
91
|
+
: truncateToWidth(left, innerWidth, "");
|
|
92
|
+
const framed = width > 2 ? ` ${truncateToWidth(content, width - 2, "")} ` : content;
|
|
93
|
+
return [truncateToWidth(framed, width, "")];
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { RuntimeInfo } from "./runtime";
|
|
4
|
+
import { type ThemeLike, colorize, renderTerminalStyle } from "./style";
|
|
5
|
+
|
|
6
|
+
export type UsageTotals = {
|
|
7
|
+
input: number;
|
|
8
|
+
output: number;
|
|
9
|
+
cost: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function formatCount(value: number): string {
|
|
13
|
+
if (value < 1000) return `${value}`;
|
|
14
|
+
if (value < 10_000) return `${(value / 1000).toFixed(1)}k`;
|
|
15
|
+
return `${Math.round(value / 1000)}k`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatProviderLabel(provider: string | undefined): string {
|
|
19
|
+
if (!provider) return "Unknown";
|
|
20
|
+
|
|
21
|
+
const known: Record<string, string> = {
|
|
22
|
+
anthropic: "Anthropic",
|
|
23
|
+
gemini: "Google",
|
|
24
|
+
google: "Google",
|
|
25
|
+
ollama: "Ollama",
|
|
26
|
+
openai: "OpenAI",
|
|
27
|
+
"openai-codex": "OpenAI",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
known[provider] ?? provider.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase())
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
|
|
36
|
+
let input = 0;
|
|
37
|
+
let output = 0;
|
|
38
|
+
let cost = 0;
|
|
39
|
+
|
|
40
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
41
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
42
|
+
const message = entry.message as AssistantMessage;
|
|
43
|
+
input += message.usage?.input ?? 0;
|
|
44
|
+
output += message.usage?.output ?? 0;
|
|
45
|
+
cost += message.usage?.cost?.total ?? 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { input, output, cost };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function buildTokenLabel(totals: UsageTotals): string {
|
|
52
|
+
return `↑${formatCount(totals.input)} ↓${formatCount(totals.output)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildCostLabel(totals: UsageTotals): string {
|
|
56
|
+
return `$${totals.cost.toFixed(3)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildContextLabel(ctx: ExtensionContext): string {
|
|
60
|
+
const usage = ctx.getContextUsage();
|
|
61
|
+
const contextWindow = ctx.model?.contextWindow ?? usage?.contextWindow;
|
|
62
|
+
|
|
63
|
+
if (!usage || !contextWindow || contextWindow <= 0) return "--";
|
|
64
|
+
|
|
65
|
+
const percent =
|
|
66
|
+
usage.percent === null ? "?" : `${Math.max(0, Math.min(999, Math.round(usage.percent)))}%`;
|
|
67
|
+
return `${percent}/${formatCount(contextWindow)}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatRuntimeSegment(
|
|
71
|
+
theme: Pick<Theme, "fg">,
|
|
72
|
+
runtime: RuntimeInfo | undefined,
|
|
73
|
+
mutedColor: string,
|
|
74
|
+
): string {
|
|
75
|
+
if (!runtime) return "";
|
|
76
|
+
const label = runtime.version ? `${runtime.symbol} ${runtime.version}` : runtime.symbol;
|
|
77
|
+
return `${colorize(theme, mutedColor, "via")} ${renderTerminalStyle(runtime.style, label)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function formatCwdLabel(cwd: string, cwdIcon: string): string {
|
|
81
|
+
const normalized = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
82
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
83
|
+
const last = parts[parts.length - 1] ?? cwd;
|
|
84
|
+
return cwdIcon ? `${cwdIcon} ${last}` : last;
|
|
85
|
+
}
|
|
@@ -1,164 +1,32 @@
|
|
|
1
|
-
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
2
1
|
import type {
|
|
3
2
|
ExtensionAPI,
|
|
4
3
|
ExtensionContext,
|
|
5
4
|
KeybindingsManager,
|
|
6
5
|
Theme,
|
|
7
6
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
ensureConfigExists,
|
|
13
|
-
loadConfig,
|
|
14
|
-
renderTerminalStyle,
|
|
15
|
-
} from "./config";
|
|
16
|
-
import { type GitStatusSummary, emptyGitStatus, readGitStatus } from "./git";
|
|
7
|
+
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
8
|
+
import { type PolishedTuiConfig, ensureConfigExists, loadConfig } from "./config";
|
|
9
|
+
import { installFooter } from "./footer";
|
|
10
|
+
import { emptyGitStatus, readGitStatus } from "./git";
|
|
17
11
|
import { type StopProjectRefreshInterval, startProjectRefreshInterval } from "./project-refresh";
|
|
18
|
-
import {
|
|
12
|
+
import { readRuntimeInfo } from "./runtime";
|
|
13
|
+
import { type FooterState, createInitialState, syncState } from "./state";
|
|
19
14
|
import { PolishedEditor } from "./ui";
|
|
20
15
|
import { installUserMessageStyle } from "./user-message";
|
|
21
16
|
|
|
22
|
-
type FooterState = GitStatusSummary & {
|
|
23
|
-
modelLabel: string;
|
|
24
|
-
providerLabel: string;
|
|
25
|
-
contextLabel: string;
|
|
26
|
-
tokenLabel: string;
|
|
27
|
-
costLabel: string;
|
|
28
|
-
runtime?: RuntimeInfo;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
type UsageTotals = {
|
|
32
|
-
input: number;
|
|
33
|
-
output: number;
|
|
34
|
-
cost: number;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
function formatCount(value: number): string {
|
|
38
|
-
if (value < 1000) return `${value}`;
|
|
39
|
-
if (value < 10_000) return `${(value / 1000).toFixed(1)}k`;
|
|
40
|
-
return `${Math.round(value / 1000)}k`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function formatProviderLabel(provider: string | undefined): string {
|
|
44
|
-
if (!provider) return "Unknown";
|
|
45
|
-
|
|
46
|
-
const known: Record<string, string> = {
|
|
47
|
-
anthropic: "Anthropic",
|
|
48
|
-
gemini: "Google",
|
|
49
|
-
google: "Google",
|
|
50
|
-
ollama: "Ollama",
|
|
51
|
-
openai: "OpenAI",
|
|
52
|
-
"openai-codex": "OpenAI",
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
return (
|
|
56
|
-
known[provider] ?? provider.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase())
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function getUsageTotals(ctx: ExtensionContext): UsageTotals {
|
|
61
|
-
let input = 0;
|
|
62
|
-
let output = 0;
|
|
63
|
-
let cost = 0;
|
|
64
|
-
|
|
65
|
-
for (const entry of ctx.sessionManager.getBranch()) {
|
|
66
|
-
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
67
|
-
const message = entry.message as AssistantMessage;
|
|
68
|
-
input += message.usage?.input ?? 0;
|
|
69
|
-
output += message.usage?.output ?? 0;
|
|
70
|
-
cost += message.usage?.cost?.total ?? 0;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return { input, output, cost };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function buildTokenLabel(totals: UsageTotals): string {
|
|
77
|
-
return `↑${formatCount(totals.input)} ↓${formatCount(totals.output)}`;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function buildCostLabel(totals: UsageTotals): string {
|
|
81
|
-
return `$${totals.cost.toFixed(3)}`;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function buildContextLabel(ctx: ExtensionContext): string {
|
|
85
|
-
const usage = ctx.getContextUsage();
|
|
86
|
-
const contextWindow = ctx.model?.contextWindow ?? usage?.contextWindow;
|
|
87
|
-
|
|
88
|
-
if (!usage || !contextWindow || contextWindow <= 0) return "--";
|
|
89
|
-
|
|
90
|
-
const percent =
|
|
91
|
-
usage.percent === null ? "?" : `${Math.max(0, Math.min(999, Math.round(usage.percent)))}%`;
|
|
92
|
-
return `${percent}/${formatCount(contextWindow)}`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function formatRuntimeSegment(
|
|
96
|
-
theme: Pick<Theme, "fg">,
|
|
97
|
-
runtime: RuntimeInfo | undefined,
|
|
98
|
-
mutedColor: string,
|
|
99
|
-
): string {
|
|
100
|
-
if (!runtime) return "";
|
|
101
|
-
const label = runtime.version ? `${runtime.symbol} ${runtime.version}` : runtime.symbol;
|
|
102
|
-
return `${colorize(theme, mutedColor, "via")} ${renderTerminalStyle(runtime.style, label)}`;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function formatCwdLabel(cwd: string, cwdIcon: string): string {
|
|
106
|
-
const normalized = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
107
|
-
const parts = normalized.split("/").filter(Boolean);
|
|
108
|
-
const last = parts[parts.length - 1] ?? cwd;
|
|
109
|
-
return cwdIcon ? `${cwdIcon} ${last}` : last;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
17
|
export default function (pi: ExtensionAPI) {
|
|
113
|
-
const state: FooterState =
|
|
114
|
-
modelLabel: "no-model",
|
|
115
|
-
providerLabel: "Unknown",
|
|
116
|
-
contextLabel: "--",
|
|
117
|
-
tokenLabel: "↑0 ↓0",
|
|
118
|
-
costLabel: "$0.000",
|
|
119
|
-
runtime: undefined,
|
|
120
|
-
...emptyGitStatus(),
|
|
121
|
-
};
|
|
18
|
+
const state: FooterState = createInitialState(emptyGitStatus());
|
|
122
19
|
|
|
123
20
|
let currentConfig: PolishedTuiConfig = loadConfig();
|
|
124
21
|
let activeTheme: Theme | undefined;
|
|
125
22
|
let requestFooterRender: (() => void) | undefined;
|
|
126
|
-
let
|
|
23
|
+
let stopRefreshInterval: StopProjectRefreshInterval = () => {};
|
|
127
24
|
let projectRefreshInFlight = false;
|
|
128
25
|
let projectRefreshPending = false;
|
|
129
26
|
|
|
130
27
|
const refresh = () => requestFooterRender?.();
|
|
131
28
|
const getActiveTheme = () => activeTheme;
|
|
132
29
|
|
|
133
|
-
const cleanupUi = (ctx?: ExtensionContext) => {
|
|
134
|
-
stopProjectRefreshInterval();
|
|
135
|
-
stopProjectRefreshInterval = () => {};
|
|
136
|
-
projectRefreshInFlight = false;
|
|
137
|
-
projectRefreshPending = false;
|
|
138
|
-
requestFooterRender = undefined;
|
|
139
|
-
if (ctx?.hasUI) {
|
|
140
|
-
ctx.ui.setFooter(undefined);
|
|
141
|
-
ctx.ui.setEditorComponent(undefined);
|
|
142
|
-
}
|
|
143
|
-
activeTheme = undefined;
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
const refreshInteractiveState = (ctx: ExtensionContext, project = false) => {
|
|
147
|
-
if (!ctx.hasUI) return;
|
|
148
|
-
syncState(ctx);
|
|
149
|
-
if (project) scheduleProjectRefresh(ctx);
|
|
150
|
-
refresh();
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
const syncState = (ctx: ExtensionContext) => {
|
|
154
|
-
const totals = getUsageTotals(ctx);
|
|
155
|
-
state.modelLabel = ctx.model?.id ?? "no-model";
|
|
156
|
-
state.providerLabel = formatProviderLabel(ctx.model?.provider);
|
|
157
|
-
state.contextLabel = buildContextLabel(ctx);
|
|
158
|
-
state.tokenLabel = buildTokenLabel(totals);
|
|
159
|
-
state.costLabel = buildCostLabel(totals);
|
|
160
|
-
};
|
|
161
|
-
|
|
162
30
|
const refreshProjectState = async (ctx: ExtensionContext) => {
|
|
163
31
|
const [gitStatus, runtime] = await Promise.all([
|
|
164
32
|
readGitStatus(ctx.cwd),
|
|
@@ -185,110 +53,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
185
53
|
});
|
|
186
54
|
};
|
|
187
55
|
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const unsubscribeBranch = footerData.onBranchChange(() => {
|
|
194
|
-
scheduleProjectRefresh(ctx);
|
|
195
|
-
tui.requestRender();
|
|
196
|
-
});
|
|
197
|
-
const separator = colorize(theme, currentConfig.colors.separator, " | ");
|
|
198
|
-
|
|
199
|
-
return {
|
|
200
|
-
dispose: () => {
|
|
201
|
-
unsubscribeBranch();
|
|
202
|
-
requestFooterRender = undefined;
|
|
203
|
-
},
|
|
204
|
-
invalidate() {},
|
|
205
|
-
render(width: number): string[] {
|
|
206
|
-
if (width <= 0) return [""];
|
|
207
|
-
const innerWidth = Math.max(1, width - 2);
|
|
208
|
-
const cwdLabel = colorize(
|
|
209
|
-
theme,
|
|
210
|
-
currentConfig.colors.cwdText,
|
|
211
|
-
formatCwdLabel(ctx.cwd, currentConfig.icons.cwd),
|
|
212
|
-
);
|
|
213
|
-
const branch = state.branch;
|
|
214
|
-
const contextUsage = ctx.getContextUsage();
|
|
215
|
-
const contextColor =
|
|
216
|
-
contextUsage?.percent !== null && contextUsage?.percent !== undefined
|
|
217
|
-
? contextUsage.percent >= 90
|
|
218
|
-
? currentConfig.colors.contextError
|
|
219
|
-
: contextUsage.percent >= 70
|
|
220
|
-
? currentConfig.colors.contextWarning
|
|
221
|
-
: currentConfig.colors.contextNormal
|
|
222
|
-
: currentConfig.colors.contextNormal;
|
|
223
|
-
const gitColor = (text: string) => colorize(theme, currentConfig.colors.git, text);
|
|
224
|
-
const gitStatusColor = (text: string) =>
|
|
225
|
-
colorize(theme, currentConfig.colors.gitStatus, text);
|
|
226
|
-
const gitIcon = gitColor(currentConfig.icons.git);
|
|
227
|
-
const allStatus = [
|
|
228
|
-
state.conflicted > 0 ? currentConfig.icons.conflicted : "",
|
|
229
|
-
state.stashed ? currentConfig.icons.stashed : "",
|
|
230
|
-
state.deleted > 0 ? currentConfig.icons.deleted : "",
|
|
231
|
-
state.renamed > 0 ? currentConfig.icons.renamed : "",
|
|
232
|
-
state.modified > 0 ? currentConfig.icons.modified : "",
|
|
233
|
-
state.typechanged > 0 ? currentConfig.icons.typechanged : "",
|
|
234
|
-
state.staged > 0 ? currentConfig.icons.staged : "",
|
|
235
|
-
state.untracked > 0 ? currentConfig.icons.untracked : "",
|
|
236
|
-
].join("");
|
|
237
|
-
const aheadBehind =
|
|
238
|
-
state.ahead > 0 && state.behind > 0
|
|
239
|
-
? currentConfig.icons.diverged
|
|
240
|
-
: state.ahead > 0
|
|
241
|
-
? currentConfig.icons.ahead
|
|
242
|
-
: state.behind > 0
|
|
243
|
-
? currentConfig.icons.behind
|
|
244
|
-
: "";
|
|
245
|
-
const statusBlock =
|
|
246
|
-
allStatus || aheadBehind ? gitStatusColor(`[${allStatus}${aheadBehind}]`) : "";
|
|
247
|
-
const branchLabel = branch
|
|
248
|
-
? `${colorize(theme, "text", "on")} ${gitIcon} ${gitColor(branch)}${statusBlock ? ` ${statusBlock}` : ""}`
|
|
249
|
-
: "";
|
|
250
|
-
const runtimeLabel = formatRuntimeSegment(theme, state.runtime, "text");
|
|
251
|
-
|
|
252
|
-
const left = [cwdLabel, branchLabel, runtimeLabel].filter(Boolean).join(" ");
|
|
253
|
-
const right = [
|
|
254
|
-
colorize(theme, contextColor, state.contextLabel),
|
|
255
|
-
colorize(theme, currentConfig.colors.tokens, state.tokenLabel),
|
|
256
|
-
colorize(theme, currentConfig.colors.cost, state.costLabel),
|
|
257
|
-
].join(separator);
|
|
258
|
-
|
|
259
|
-
const leftWidth = visibleWidth(left);
|
|
260
|
-
const rightWidth = visibleWidth(right);
|
|
261
|
-
const content =
|
|
262
|
-
leftWidth >= innerWidth
|
|
263
|
-
? truncateToWidth(left, innerWidth, "")
|
|
264
|
-
: leftWidth + 1 + rightWidth <= innerWidth
|
|
265
|
-
? `${left}${" ".repeat(innerWidth - leftWidth - rightWidth)}${right}`
|
|
266
|
-
: truncateToWidth(left, innerWidth, "");
|
|
267
|
-
const framed = width > 2 ? ` ${truncateToWidth(content, width - 2, "")} ` : content;
|
|
268
|
-
return [truncateToWidth(framed, width, "")];
|
|
269
|
-
},
|
|
270
|
-
};
|
|
271
|
-
});
|
|
56
|
+
const refreshInteractiveState = (ctx: ExtensionContext, project = false) => {
|
|
57
|
+
if (!ctx.hasUI) return;
|
|
58
|
+
syncState(state, ctx);
|
|
59
|
+
if (project) scheduleProjectRefresh(ctx);
|
|
60
|
+
refresh();
|
|
272
61
|
};
|
|
273
62
|
|
|
274
63
|
const installEditor = (ctx: ExtensionContext) => {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
ctx.ui.theme.fg("
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
ctx.ui.setEditorComponent(editorFactory);
|
|
64
|
+
ctx.ui.setEditorComponent(
|
|
65
|
+
(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) =>
|
|
66
|
+
new PolishedEditor(
|
|
67
|
+
tui,
|
|
68
|
+
theme,
|
|
69
|
+
keybindings,
|
|
70
|
+
ctx.ui.theme,
|
|
71
|
+
() =>
|
|
72
|
+
[
|
|
73
|
+
ctx.ui.theme.fg("accent", state.modelLabel),
|
|
74
|
+
ctx.ui.theme.fg("text", state.providerLabel),
|
|
75
|
+
].join(ctx.ui.theme.fg("borderMuted", " ")),
|
|
76
|
+
() => pi.getThinkingLevel(),
|
|
77
|
+
),
|
|
78
|
+
);
|
|
292
79
|
};
|
|
293
80
|
|
|
294
81
|
const installUi = (ctx: ExtensionContext) => {
|
|
@@ -297,17 +84,35 @@ export default function (pi: ExtensionAPI) {
|
|
|
297
84
|
installUserMessageStyle(getActiveTheme);
|
|
298
85
|
ensureConfigExists();
|
|
299
86
|
currentConfig = loadConfig();
|
|
300
|
-
|
|
87
|
+
syncState(state, ctx);
|
|
88
|
+
installFooter(ctx, state, currentConfig, {
|
|
89
|
+
setRequestRender: (fn) => {
|
|
90
|
+
requestFooterRender = fn;
|
|
91
|
+
},
|
|
92
|
+
scheduleProjectRefresh,
|
|
93
|
+
});
|
|
301
94
|
installEditor(ctx);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
() => scheduleProjectRefresh(ctx),
|
|
95
|
+
stopRefreshInterval();
|
|
96
|
+
stopRefreshInterval = startProjectRefreshInterval(currentConfig.projectRefreshIntervalMs, () =>
|
|
97
|
+
scheduleProjectRefresh(ctx),
|
|
306
98
|
);
|
|
307
99
|
scheduleProjectRefresh(ctx);
|
|
308
100
|
refresh();
|
|
309
101
|
};
|
|
310
102
|
|
|
103
|
+
const cleanupUi = (ctx?: ExtensionContext) => {
|
|
104
|
+
stopRefreshInterval();
|
|
105
|
+
stopRefreshInterval = () => {};
|
|
106
|
+
projectRefreshInFlight = false;
|
|
107
|
+
projectRefreshPending = false;
|
|
108
|
+
requestFooterRender = undefined;
|
|
109
|
+
if (ctx?.hasUI) {
|
|
110
|
+
ctx.ui.setFooter(undefined);
|
|
111
|
+
ctx.ui.setEditorComponent(undefined);
|
|
112
|
+
}
|
|
113
|
+
activeTheme = undefined;
|
|
114
|
+
};
|
|
115
|
+
|
|
311
116
|
pi.on("session_start", async (_event, ctx) => {
|
|
312
117
|
installUi(ctx);
|
|
313
118
|
});
|