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.
@@ -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 { type EditorTheme, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
- import {
10
- type PolishedTuiConfig,
11
- colorize,
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 { type RuntimeInfo, readRuntimeInfo } from "./runtime";
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 stopProjectRefreshInterval: StopProjectRefreshInterval = () => {};
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 installFooter = (ctx: ExtensionContext) => {
189
- syncState(ctx);
190
-
191
- ctx.ui.setFooter((tui, theme, footerData) => {
192
- requestFooterRender = () => tui.requestRender();
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
- syncState(ctx);
276
-
277
- const editorFactory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) =>
278
- new PolishedEditor(
279
- tui,
280
- theme,
281
- keybindings,
282
- ctx.ui.theme,
283
- () =>
284
- [
285
- ctx.ui.theme.fg("accent", state.modelLabel),
286
- ctx.ui.theme.fg("text", state.providerLabel),
287
- ].join(ctx.ui.theme.fg("borderMuted", " ")),
288
- () => pi.getThinkingLevel(),
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
- installFooter(ctx);
87
+ syncState(state, ctx);
88
+ installFooter(ctx, state, currentConfig, {
89
+ setRequestRender: (fn) => {
90
+ requestFooterRender = fn;
91
+ },
92
+ scheduleProjectRefresh,
93
+ });
301
94
  installEditor(ctx);
302
- stopProjectRefreshInterval();
303
- stopProjectRefreshInterval = startProjectRefreshInterval(
304
- currentConfig.projectRefreshIntervalMs,
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
  });