pi-zentui 0.1.6 → 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/README.md +1 -0
- 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 +56 -245
- package/extensions/zentui/runtime.ts +531 -603
- package/extensions/zentui/state.ts +41 -0
- package/extensions/zentui/style.ts +94 -0
- package/extensions/zentui/user-message.ts +133 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ Zentui brings two popular aesthetics to Pi:
|
|
|
28
28
|
- Bordered input box with accent-colored left rail
|
|
29
29
|
- Model name and provider displayed inside the editor frame
|
|
30
30
|
- Thinking level indicator when enabled
|
|
31
|
+
- Prompt-box-style user messages matching the ZentUI input chrome
|
|
31
32
|
|
|
32
33
|
### Git Status Icons
|
|
33
34
|
|
|
@@ -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,159 +1,31 @@
|
|
|
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
|
-
|
|
21
|
-
type FooterState = GitStatusSummary & {
|
|
22
|
-
modelLabel: string;
|
|
23
|
-
providerLabel: string;
|
|
24
|
-
contextLabel: string;
|
|
25
|
-
tokenLabel: string;
|
|
26
|
-
costLabel: string;
|
|
27
|
-
runtime?: RuntimeInfo;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
type UsageTotals = {
|
|
31
|
-
input: number;
|
|
32
|
-
output: number;
|
|
33
|
-
cost: number;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
function formatCount(value: number): string {
|
|
37
|
-
if (value < 1000) return `${value}`;
|
|
38
|
-
if (value < 10_000) return `${(value / 1000).toFixed(1)}k`;
|
|
39
|
-
return `${Math.round(value / 1000)}k`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function formatProviderLabel(provider: string | undefined): string {
|
|
43
|
-
if (!provider) return "Unknown";
|
|
44
|
-
|
|
45
|
-
const known: Record<string, string> = {
|
|
46
|
-
anthropic: "Anthropic",
|
|
47
|
-
gemini: "Google",
|
|
48
|
-
google: "Google",
|
|
49
|
-
ollama: "Ollama",
|
|
50
|
-
openai: "OpenAI",
|
|
51
|
-
"openai-codex": "OpenAI",
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
return (
|
|
55
|
-
known[provider] ?? provider.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase())
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function getUsageTotals(ctx: ExtensionContext): UsageTotals {
|
|
60
|
-
let input = 0;
|
|
61
|
-
let output = 0;
|
|
62
|
-
let cost = 0;
|
|
63
|
-
|
|
64
|
-
for (const entry of ctx.sessionManager.getBranch()) {
|
|
65
|
-
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
66
|
-
const message = entry.message as AssistantMessage;
|
|
67
|
-
input += message.usage?.input ?? 0;
|
|
68
|
-
output += message.usage?.output ?? 0;
|
|
69
|
-
cost += message.usage?.cost?.total ?? 0;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return { input, output, cost };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function buildTokenLabel(totals: UsageTotals): string {
|
|
76
|
-
return `↑${formatCount(totals.input)} ↓${formatCount(totals.output)}`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function buildCostLabel(totals: UsageTotals): string {
|
|
80
|
-
return `$${totals.cost.toFixed(3)}`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function buildContextLabel(ctx: ExtensionContext): string {
|
|
84
|
-
const usage = ctx.getContextUsage();
|
|
85
|
-
const contextWindow = ctx.model?.contextWindow ?? usage?.contextWindow;
|
|
86
|
-
|
|
87
|
-
if (!usage || !contextWindow || contextWindow <= 0) return "--";
|
|
88
|
-
|
|
89
|
-
const percent =
|
|
90
|
-
usage.percent === null ? "?" : `${Math.max(0, Math.min(999, Math.round(usage.percent)))}%`;
|
|
91
|
-
return `${percent}/${formatCount(contextWindow)}`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function formatRuntimeSegment(
|
|
95
|
-
theme: Pick<Theme, "fg">,
|
|
96
|
-
runtime: RuntimeInfo | undefined,
|
|
97
|
-
mutedColor: string,
|
|
98
|
-
): string {
|
|
99
|
-
if (!runtime) return "";
|
|
100
|
-
const label = runtime.version ? `${runtime.symbol} ${runtime.version}` : runtime.symbol;
|
|
101
|
-
return `${colorize(theme, mutedColor, "via")} ${renderTerminalStyle(runtime.style, label)}`;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function formatCwdLabel(cwd: string, cwdIcon: string): string {
|
|
105
|
-
const normalized = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
106
|
-
const parts = normalized.split("/").filter(Boolean);
|
|
107
|
-
const last = parts[parts.length - 1] ?? cwd;
|
|
108
|
-
return cwdIcon ? `${cwdIcon} ${last}` : last;
|
|
109
|
-
}
|
|
15
|
+
import { installUserMessageStyle } from "./user-message";
|
|
110
16
|
|
|
111
17
|
export default function (pi: ExtensionAPI) {
|
|
112
|
-
const state: FooterState =
|
|
113
|
-
modelLabel: "no-model",
|
|
114
|
-
providerLabel: "Unknown",
|
|
115
|
-
contextLabel: "--",
|
|
116
|
-
tokenLabel: "↑0 ↓0",
|
|
117
|
-
costLabel: "$0.000",
|
|
118
|
-
runtime: undefined,
|
|
119
|
-
...emptyGitStatus(),
|
|
120
|
-
};
|
|
18
|
+
const state: FooterState = createInitialState(emptyGitStatus());
|
|
121
19
|
|
|
122
20
|
let currentConfig: PolishedTuiConfig = loadConfig();
|
|
21
|
+
let activeTheme: Theme | undefined;
|
|
123
22
|
let requestFooterRender: (() => void) | undefined;
|
|
124
|
-
let
|
|
23
|
+
let stopRefreshInterval: StopProjectRefreshInterval = () => {};
|
|
125
24
|
let projectRefreshInFlight = false;
|
|
126
25
|
let projectRefreshPending = false;
|
|
127
26
|
|
|
128
27
|
const refresh = () => requestFooterRender?.();
|
|
129
|
-
|
|
130
|
-
const cleanupUi = (ctx?: ExtensionContext) => {
|
|
131
|
-
stopProjectRefreshInterval();
|
|
132
|
-
stopProjectRefreshInterval = () => {};
|
|
133
|
-
projectRefreshInFlight = false;
|
|
134
|
-
projectRefreshPending = false;
|
|
135
|
-
requestFooterRender = undefined;
|
|
136
|
-
if (ctx?.hasUI) {
|
|
137
|
-
ctx.ui.setFooter(undefined);
|
|
138
|
-
ctx.ui.setEditorComponent(undefined);
|
|
139
|
-
}
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
const refreshInteractiveState = (ctx: ExtensionContext, project = false) => {
|
|
143
|
-
if (!ctx.hasUI) return;
|
|
144
|
-
syncState(ctx);
|
|
145
|
-
if (project) scheduleProjectRefresh(ctx);
|
|
146
|
-
refresh();
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
const syncState = (ctx: ExtensionContext) => {
|
|
150
|
-
const totals = getUsageTotals(ctx);
|
|
151
|
-
state.modelLabel = ctx.model?.id ?? "no-model";
|
|
152
|
-
state.providerLabel = formatProviderLabel(ctx.model?.provider);
|
|
153
|
-
state.contextLabel = buildContextLabel(ctx);
|
|
154
|
-
state.tokenLabel = buildTokenLabel(totals);
|
|
155
|
-
state.costLabel = buildCostLabel(totals);
|
|
156
|
-
};
|
|
28
|
+
const getActiveTheme = () => activeTheme;
|
|
157
29
|
|
|
158
30
|
const refreshProjectState = async (ctx: ExtensionContext) => {
|
|
159
31
|
const [gitStatus, runtime] = await Promise.all([
|
|
@@ -181,127 +53,66 @@ export default function (pi: ExtensionAPI) {
|
|
|
181
53
|
});
|
|
182
54
|
};
|
|
183
55
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const unsubscribeBranch = footerData.onBranchChange(() => {
|
|
190
|
-
scheduleProjectRefresh(ctx);
|
|
191
|
-
tui.requestRender();
|
|
192
|
-
});
|
|
193
|
-
const separator = colorize(theme, currentConfig.colors.separator, " | ");
|
|
194
|
-
|
|
195
|
-
return {
|
|
196
|
-
dispose: () => {
|
|
197
|
-
unsubscribeBranch();
|
|
198
|
-
requestFooterRender = undefined;
|
|
199
|
-
},
|
|
200
|
-
invalidate() {},
|
|
201
|
-
render(width: number): string[] {
|
|
202
|
-
if (width <= 0) return [""];
|
|
203
|
-
const innerWidth = Math.max(1, width - 2);
|
|
204
|
-
const cwdLabel = colorize(
|
|
205
|
-
theme,
|
|
206
|
-
currentConfig.colors.cwdText,
|
|
207
|
-
formatCwdLabel(ctx.cwd, currentConfig.icons.cwd),
|
|
208
|
-
);
|
|
209
|
-
const branch = state.branch;
|
|
210
|
-
const contextUsage = ctx.getContextUsage();
|
|
211
|
-
const contextColor =
|
|
212
|
-
contextUsage?.percent !== null && contextUsage?.percent !== undefined
|
|
213
|
-
? contextUsage.percent >= 90
|
|
214
|
-
? currentConfig.colors.contextError
|
|
215
|
-
: contextUsage.percent >= 70
|
|
216
|
-
? currentConfig.colors.contextWarning
|
|
217
|
-
: currentConfig.colors.contextNormal
|
|
218
|
-
: currentConfig.colors.contextNormal;
|
|
219
|
-
const gitColor = (text: string) => colorize(theme, currentConfig.colors.git, text);
|
|
220
|
-
const gitStatusColor = (text: string) =>
|
|
221
|
-
colorize(theme, currentConfig.colors.gitStatus, text);
|
|
222
|
-
const gitIcon = gitColor(currentConfig.icons.git);
|
|
223
|
-
const allStatus = [
|
|
224
|
-
state.conflicted > 0 ? currentConfig.icons.conflicted : "",
|
|
225
|
-
state.stashed ? currentConfig.icons.stashed : "",
|
|
226
|
-
state.deleted > 0 ? currentConfig.icons.deleted : "",
|
|
227
|
-
state.renamed > 0 ? currentConfig.icons.renamed : "",
|
|
228
|
-
state.modified > 0 ? currentConfig.icons.modified : "",
|
|
229
|
-
state.typechanged > 0 ? currentConfig.icons.typechanged : "",
|
|
230
|
-
state.staged > 0 ? currentConfig.icons.staged : "",
|
|
231
|
-
state.untracked > 0 ? currentConfig.icons.untracked : "",
|
|
232
|
-
].join("");
|
|
233
|
-
const aheadBehind =
|
|
234
|
-
state.ahead > 0 && state.behind > 0
|
|
235
|
-
? currentConfig.icons.diverged
|
|
236
|
-
: state.ahead > 0
|
|
237
|
-
? currentConfig.icons.ahead
|
|
238
|
-
: state.behind > 0
|
|
239
|
-
? currentConfig.icons.behind
|
|
240
|
-
: "";
|
|
241
|
-
const statusBlock =
|
|
242
|
-
allStatus || aheadBehind ? gitStatusColor(`[${allStatus}${aheadBehind}]`) : "";
|
|
243
|
-
const branchLabel = branch
|
|
244
|
-
? `${colorize(theme, "text", "on")} ${gitIcon} ${gitColor(branch)}${statusBlock ? ` ${statusBlock}` : ""}`
|
|
245
|
-
: "";
|
|
246
|
-
const runtimeLabel = formatRuntimeSegment(theme, state.runtime, "text");
|
|
247
|
-
|
|
248
|
-
const left = [cwdLabel, branchLabel, runtimeLabel].filter(Boolean).join(" ");
|
|
249
|
-
const right = [
|
|
250
|
-
colorize(theme, contextColor, state.contextLabel),
|
|
251
|
-
colorize(theme, currentConfig.colors.tokens, state.tokenLabel),
|
|
252
|
-
colorize(theme, currentConfig.colors.cost, state.costLabel),
|
|
253
|
-
].join(separator);
|
|
254
|
-
|
|
255
|
-
const leftWidth = visibleWidth(left);
|
|
256
|
-
const rightWidth = visibleWidth(right);
|
|
257
|
-
const content =
|
|
258
|
-
leftWidth >= innerWidth
|
|
259
|
-
? truncateToWidth(left, innerWidth, "")
|
|
260
|
-
: leftWidth + 1 + rightWidth <= innerWidth
|
|
261
|
-
? `${left}${" ".repeat(innerWidth - leftWidth - rightWidth)}${right}`
|
|
262
|
-
: truncateToWidth(left, innerWidth, "");
|
|
263
|
-
const framed = width > 2 ? ` ${truncateToWidth(content, width - 2, "")} ` : content;
|
|
264
|
-
return [truncateToWidth(framed, width, "")];
|
|
265
|
-
},
|
|
266
|
-
};
|
|
267
|
-
});
|
|
56
|
+
const refreshInteractiveState = (ctx: ExtensionContext, project = false) => {
|
|
57
|
+
if (!ctx.hasUI) return;
|
|
58
|
+
syncState(state, ctx);
|
|
59
|
+
if (project) scheduleProjectRefresh(ctx);
|
|
60
|
+
refresh();
|
|
268
61
|
};
|
|
269
62
|
|
|
270
63
|
const installEditor = (ctx: ExtensionContext) => {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
ctx.ui.theme.fg("
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
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
|
+
);
|
|
288
79
|
};
|
|
289
80
|
|
|
290
81
|
const installUi = (ctx: ExtensionContext) => {
|
|
291
82
|
if (!ctx.hasUI) return;
|
|
83
|
+
activeTheme = ctx.ui.theme;
|
|
84
|
+
installUserMessageStyle(getActiveTheme);
|
|
292
85
|
ensureConfigExists();
|
|
293
86
|
currentConfig = loadConfig();
|
|
294
|
-
|
|
87
|
+
syncState(state, ctx);
|
|
88
|
+
installFooter(ctx, state, currentConfig, {
|
|
89
|
+
setRequestRender: (fn) => {
|
|
90
|
+
requestFooterRender = fn;
|
|
91
|
+
},
|
|
92
|
+
scheduleProjectRefresh,
|
|
93
|
+
});
|
|
295
94
|
installEditor(ctx);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
() => scheduleProjectRefresh(ctx),
|
|
95
|
+
stopRefreshInterval();
|
|
96
|
+
stopRefreshInterval = startProjectRefreshInterval(currentConfig.projectRefreshIntervalMs, () =>
|
|
97
|
+
scheduleProjectRefresh(ctx),
|
|
300
98
|
);
|
|
301
99
|
scheduleProjectRefresh(ctx);
|
|
302
100
|
refresh();
|
|
303
101
|
};
|
|
304
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
|
+
|
|
305
116
|
pi.on("session_start", async (_event, ctx) => {
|
|
306
117
|
installUi(ctx);
|
|
307
118
|
});
|