pi-tui-footer 1.2.0
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/LICENSE +21 -0
- package/README.md +56 -0
- package/extensions/config.ts +196 -0
- package/extensions/editor.ts +295 -0
- package/extensions/footer.ts +344 -0
- package/extensions/fullscreen-scroll.ts +34 -0
- package/extensions/git.ts +151 -0
- package/extensions/header.ts +244 -0
- package/extensions/icons.ts +190 -0
- package/extensions/index.ts +457 -0
- package/extensions/peek.ts +168 -0
- package/extensions/rounded-tools.ts +269 -0
- package/extensions/runtime.ts +174 -0
- package/extensions/session-lifecycle.ts +43 -0
- package/extensions/settings-command.ts +511 -0
- package/extensions/state.ts +104 -0
- package/extensions/telemetry.ts +303 -0
- package/extensions/utils.ts +381 -0
- package/package.json +41 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
Input,
|
|
5
|
+
Key,
|
|
6
|
+
matchesKey,
|
|
7
|
+
SelectList,
|
|
8
|
+
type Component,
|
|
9
|
+
type SelectItem,
|
|
10
|
+
type TUI,
|
|
11
|
+
Text,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
import type { CursorStyle, IconMode, OpenTuiConfig, SettingsLanguage, ThinkingPeekLines } from "./config.ts";
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
|
|
16
|
+
normalizeFullscreenWheelScrollLines,
|
|
17
|
+
} from "./fullscreen-scroll.ts";
|
|
18
|
+
|
|
19
|
+
interface SettingItem {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
currentValue: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Tab = "features" | "icons" | "segments" | "telemetry";
|
|
26
|
+
|
|
27
|
+
const TABS: Tab[] = ["features", "icons", "segments", "telemetry"];
|
|
28
|
+
|
|
29
|
+
const COPY = {
|
|
30
|
+
en: {
|
|
31
|
+
title: "Pi TUI Settings",
|
|
32
|
+
tabs: { features: "General", icons: "Appearance", segments: "Footer", telemetry: "Telemetry" },
|
|
33
|
+
hint: "Tab/Shift+Tab/←/→: tabs · ↑/↓: move · Enter/Space: change · Enter on wheel speed: type 1-10 · Esc/q: close",
|
|
34
|
+
labels: {
|
|
35
|
+
enabled: "Enabled",
|
|
36
|
+
roundedTools: "Rounded tool frames",
|
|
37
|
+
thinkingPeek: "Thinking peek",
|
|
38
|
+
language: "Language",
|
|
39
|
+
wheelScrollLines: "Mouse wheel speed",
|
|
40
|
+
cursorStyle: "Cursor style",
|
|
41
|
+
iconMode: "Icon mode",
|
|
42
|
+
cwd: "CWD",
|
|
43
|
+
hostname: "Hostname",
|
|
44
|
+
sessionName: "Session name",
|
|
45
|
+
gitBranch: "Git branch",
|
|
46
|
+
gitStatus: "Git status",
|
|
47
|
+
gitCommit: "Git commit (detached)",
|
|
48
|
+
runtime: "Runtime",
|
|
49
|
+
context: "Context bar",
|
|
50
|
+
tokens: "Tokens",
|
|
51
|
+
cost: "Cost",
|
|
52
|
+
extensionStatuses: "Extension status line",
|
|
53
|
+
capitalizeProviderName: "Capitalize provider name",
|
|
54
|
+
totalDuration: "Total duration",
|
|
55
|
+
tokenCounts: "Token counts",
|
|
56
|
+
stallDetails: "Stall details",
|
|
57
|
+
costRate: "Cost rate",
|
|
58
|
+
},
|
|
59
|
+
values: {
|
|
60
|
+
on: "On",
|
|
61
|
+
off: "Off",
|
|
62
|
+
thinkingPeek: { off: "Off", one: "1 line", two: "2 lines" },
|
|
63
|
+
languages: { en: "English", zh: "简体中文" },
|
|
64
|
+
wheelLines: (count: number) => `${count} ${count === 1 ? "line" : "lines"} / notch`,
|
|
65
|
+
wheelPrompt: (count: number) => `Wheel scroll lines per notch, 1-10 (current: ${count}). Enter: apply · Esc: cancel`,
|
|
66
|
+
cursorStyles: { block: "Block", bar: "Bar", underline: "Underline" },
|
|
67
|
+
icons: { auto: "Auto", nerd: "Nerd", ascii: "ASCII" },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
zh: {
|
|
71
|
+
title: "Pi TUI 设置",
|
|
72
|
+
tabs: { features: "常规", icons: "外观", segments: "Footer", telemetry: "遥测" },
|
|
73
|
+
hint: "Tab/Shift+Tab/←/→:切页 · ↑/↓:移动 · Enter/Space:更改 · 滚轮速度项 Enter 输入 1-10 · Esc/q:关闭",
|
|
74
|
+
labels: {
|
|
75
|
+
enabled: "启用",
|
|
76
|
+
roundedTools: "圆角工具边框",
|
|
77
|
+
thinkingPeek: "思考预览",
|
|
78
|
+
language: "语言",
|
|
79
|
+
wheelScrollLines: "鼠标滚轮速度",
|
|
80
|
+
cursorStyle: "光标样式",
|
|
81
|
+
iconMode: "图标模式",
|
|
82
|
+
cwd: "当前目录",
|
|
83
|
+
hostname: "主机名",
|
|
84
|
+
sessionName: "会话名",
|
|
85
|
+
gitBranch: "Git 分支",
|
|
86
|
+
gitStatus: "Git 状态",
|
|
87
|
+
gitCommit: "Git 提交(分离 HEAD)",
|
|
88
|
+
runtime: "运行环境",
|
|
89
|
+
context: "上下文栏",
|
|
90
|
+
tokens: "Token",
|
|
91
|
+
cost: "费用",
|
|
92
|
+
extensionStatuses: "扩展状态行",
|
|
93
|
+
capitalizeProviderName: "提供商名称首字母大写",
|
|
94
|
+
totalDuration: "总耗时",
|
|
95
|
+
tokenCounts: "Token 数量",
|
|
96
|
+
stallDetails: "停顿详情",
|
|
97
|
+
costRate: "费用速率",
|
|
98
|
+
},
|
|
99
|
+
values: {
|
|
100
|
+
on: "开启",
|
|
101
|
+
off: "关闭",
|
|
102
|
+
thinkingPeek: { off: "关闭", one: "单行", two: "双行" },
|
|
103
|
+
languages: { en: "English", zh: "简体中文" },
|
|
104
|
+
wheelLines: (count: number) => `每格 ${count} 行`,
|
|
105
|
+
wheelPrompt: (count: number) => `滚轮每格滚动行数(当前 ${count},范围 1-10),输入后 Enter 应用 · Esc 取消`,
|
|
106
|
+
cursorStyles: { block: "块", bar: "竖线", underline: "下划线" },
|
|
107
|
+
icons: { auto: "自动", nerd: "Nerd", ascii: "ASCII" },
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
} as const;
|
|
111
|
+
|
|
112
|
+
type SettingsCopy = (typeof COPY)[SettingsLanguage];
|
|
113
|
+
|
|
114
|
+
function formatThinkingPeekLines(lines: ThinkingPeekLines, copy: SettingsCopy): string {
|
|
115
|
+
const values = [copy.values.thinkingPeek.off, copy.values.thinkingPeek.one, copy.values.thinkingPeek.two];
|
|
116
|
+
return values[lines] ?? values[0];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function toggleSetting(config: OpenTuiConfig, key: keyof OpenTuiConfig["footerSegments"]): OpenTuiConfig {
|
|
120
|
+
return {
|
|
121
|
+
...config,
|
|
122
|
+
footerSegments: {
|
|
123
|
+
...config.footerSegments,
|
|
124
|
+
[key]: !config.footerSegments[key],
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function cycleIconMode(config: OpenTuiConfig): OpenTuiConfig {
|
|
130
|
+
const order: IconMode[] = ["auto", "nerd", "ascii"];
|
|
131
|
+
const currentIdx = order.indexOf(config.icons.mode);
|
|
132
|
+
const next = order[(currentIdx + 1) % order.length]!;
|
|
133
|
+
return { ...config, icons: { mode: next } };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function toggleEnabled(config: OpenTuiConfig): OpenTuiConfig {
|
|
137
|
+
return { ...config, enabled: !config.enabled };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function toggleLanguage(config: OpenTuiConfig): OpenTuiConfig {
|
|
141
|
+
return { ...config, settingsLanguage: config.settingsLanguage === "en" ? "zh" : "en" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function cycleCursorStyle(config: OpenTuiConfig): OpenTuiConfig {
|
|
145
|
+
const order: CursorStyle[] = ["block", "bar", "underline"];
|
|
146
|
+
const currentIdx = order.indexOf(config.cursorStyle);
|
|
147
|
+
const next = order[(currentIdx + 1) % order.length]!;
|
|
148
|
+
return { ...config, cursorStyle: next };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function setWheelScrollLines(config: OpenTuiConfig, raw: string): OpenTuiConfig | undefined {
|
|
152
|
+
if (!/^\d+$/.test(raw)) return undefined;
|
|
153
|
+
const parsed = Number(raw);
|
|
154
|
+
const bounded = Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER;
|
|
155
|
+
return {
|
|
156
|
+
...config,
|
|
157
|
+
fullscreen: {
|
|
158
|
+
...config.fullscreen,
|
|
159
|
+
wheelScrollLines: normalizeFullscreenWheelScrollLines(bounded, DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES),
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function toggleTelemetry(config: OpenTuiConfig, key: keyof OpenTuiConfig["telemetry"]): OpenTuiConfig {
|
|
165
|
+
return {
|
|
166
|
+
...config,
|
|
167
|
+
telemetry: { ...config.telemetry, [key]: !config.telemetry[key] },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function cycleThinkingPeek(config: OpenTuiConfig): OpenTuiConfig {
|
|
172
|
+
const next = ([1, 2, 0] as const)[config.thinkingPeek.lines] ?? 0;
|
|
173
|
+
return {
|
|
174
|
+
...config,
|
|
175
|
+
thinkingPeek: { lines: next },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
|
|
180
|
+
const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
|
|
181
|
+
return [
|
|
182
|
+
{ id: "enabled", label: copy.labels.enabled, currentValue: flag(config.enabled) },
|
|
183
|
+
{ id: "roundedTools", label: copy.labels.roundedTools, currentValue: flag(config.roundedTools) },
|
|
184
|
+
{ id: "settingsLanguage", label: copy.labels.language, currentValue: copy.values.languages[config.settingsLanguage] },
|
|
185
|
+
{
|
|
186
|
+
id: "wheelScrollLines",
|
|
187
|
+
label: copy.labels.wheelScrollLines,
|
|
188
|
+
currentValue: copy.values.wheelLines(config.fullscreen.wheelScrollLines),
|
|
189
|
+
},
|
|
190
|
+
{ id: "thinkingPeek", label: copy.labels.thinkingPeek, currentValue: formatThinkingPeekLines(config.thinkingPeek.lines, copy) },
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function buildIconsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
|
|
195
|
+
return [
|
|
196
|
+
{ id: "mode", label: copy.labels.iconMode, currentValue: copy.values.icons[config.icons.mode] },
|
|
197
|
+
{ id: "cursorStyle", label: copy.labels.cursorStyle, currentValue: copy.values.cursorStyles[config.cursorStyle] },
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildSegmentsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
|
|
202
|
+
const segs = config.footerSegments;
|
|
203
|
+
const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
|
|
204
|
+
return [
|
|
205
|
+
{ id: "cwd", label: copy.labels.cwd, currentValue: flag(segs.cwd) },
|
|
206
|
+
{ id: "hostname", label: copy.labels.hostname, currentValue: flag(segs.hostname) },
|
|
207
|
+
{ id: "sessionName", label: copy.labels.sessionName, currentValue: flag(segs.sessionName) },
|
|
208
|
+
{ id: "gitBranch", label: copy.labels.gitBranch, currentValue: flag(segs.gitBranch) },
|
|
209
|
+
{ id: "gitStatus", label: copy.labels.gitStatus, currentValue: flag(segs.gitStatus) },
|
|
210
|
+
{ id: "gitCommit", label: copy.labels.gitCommit, currentValue: flag(segs.gitCommit) },
|
|
211
|
+
{ id: "runtime", label: copy.labels.runtime, currentValue: flag(segs.runtime) },
|
|
212
|
+
{ id: "context", label: copy.labels.context, currentValue: flag(segs.context) },
|
|
213
|
+
{ id: "tokens", label: copy.labels.tokens, currentValue: flag(segs.tokens) },
|
|
214
|
+
{ id: "cost", label: copy.labels.cost, currentValue: flag(segs.cost) },
|
|
215
|
+
{ id: "extensionStatuses", label: copy.labels.extensionStatuses, currentValue: flag(segs.extensionStatuses) },
|
|
216
|
+
{ id: "capitalizeProviderName", label: copy.labels.capitalizeProviderName, currentValue: flag(segs.capitalizeProviderName) },
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function buildTelemetryItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
|
|
221
|
+
const telemetry = config.telemetry;
|
|
222
|
+
const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
|
|
223
|
+
return [
|
|
224
|
+
{ id: "enabled", label: copy.labels.enabled, currentValue: flag(telemetry.enabled) },
|
|
225
|
+
{ id: "tps", label: "TPS", currentValue: flag(telemetry.tps) },
|
|
226
|
+
{ id: "ttft", label: "TTFT", currentValue: flag(telemetry.ttft) },
|
|
227
|
+
{ id: "duration", label: copy.labels.totalDuration, currentValue: flag(telemetry.duration) },
|
|
228
|
+
{ id: "tokens", label: copy.labels.tokenCounts, currentValue: flag(telemetry.tokens) },
|
|
229
|
+
{ id: "stalls", label: copy.labels.stallDetails, currentValue: flag(telemetry.stalls) },
|
|
230
|
+
{ id: "cost", label: copy.labels.costRate, currentValue: flag(telemetry.cost) },
|
|
231
|
+
];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function buildItems(tab: Tab, config: OpenTuiConfig): SettingItem[] {
|
|
235
|
+
const copy = COPY[config.settingsLanguage];
|
|
236
|
+
switch (tab) {
|
|
237
|
+
case "features": return buildFeaturesItems(config, copy);
|
|
238
|
+
case "icons": return buildIconsItems(config, copy);
|
|
239
|
+
case "segments": return buildSegmentsItems(config, copy);
|
|
240
|
+
case "telemetry": return buildTelemetryItems(config, copy);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function handleSettingChange(
|
|
245
|
+
tab: Tab,
|
|
246
|
+
itemId: string,
|
|
247
|
+
config: OpenTuiConfig,
|
|
248
|
+
): OpenTuiConfig {
|
|
249
|
+
if (tab === "features") {
|
|
250
|
+
if (itemId === "enabled") return toggleEnabled(config);
|
|
251
|
+
if (itemId === "roundedTools") return { ...config, roundedTools: !config.roundedTools };
|
|
252
|
+
if (itemId === "settingsLanguage") return toggleLanguage(config);
|
|
253
|
+
if (itemId === "thinkingPeek") return cycleThinkingPeek(config);
|
|
254
|
+
}
|
|
255
|
+
if (tab === "icons") {
|
|
256
|
+
if (itemId === "mode") return cycleIconMode(config);
|
|
257
|
+
if (itemId === "cursorStyle") return cycleCursorStyle(config);
|
|
258
|
+
}
|
|
259
|
+
if (tab === "segments") {
|
|
260
|
+
return toggleSetting(config, itemId as keyof OpenTuiConfig["footerSegments"]);
|
|
261
|
+
}
|
|
262
|
+
if (tab === "telemetry") {
|
|
263
|
+
return toggleTelemetry(config, itemId as keyof OpenTuiConfig["telemetry"]);
|
|
264
|
+
}
|
|
265
|
+
return config;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
interface SettingsUiHandle {
|
|
269
|
+
render: (width: number) => string[];
|
|
270
|
+
invalidate: () => void;
|
|
271
|
+
handleInput: (data: string) => void;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function insertComponentAfter(list: Component, child: Component, index: () => number): Component {
|
|
275
|
+
// SelectList renders one line per item; splice the editor into that output.
|
|
276
|
+
return {
|
|
277
|
+
render(width: number): string[] {
|
|
278
|
+
const lines = list.render(width);
|
|
279
|
+
const insertAt = index();
|
|
280
|
+
if (insertAt < 0 || insertAt >= lines.length) return lines;
|
|
281
|
+
const childLines = child.render(width);
|
|
282
|
+
return [...lines.slice(0, insertAt + 1), ...childLines, ...lines.slice(insertAt + 1)];
|
|
283
|
+
},
|
|
284
|
+
invalidate(): void {
|
|
285
|
+
list.invalidate();
|
|
286
|
+
child.invalidate();
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
class SettingsUi implements SettingsUiHandle {
|
|
292
|
+
private tab: Tab = "features";
|
|
293
|
+
private config: OpenTuiConfig;
|
|
294
|
+
private selectList: SelectList;
|
|
295
|
+
private readonly selectedItemByTab: Partial<Record<Tab, string>> = {};
|
|
296
|
+
private readonly container: Box;
|
|
297
|
+
private readonly theme: Theme;
|
|
298
|
+
private readonly onChange: (config: OpenTuiConfig) => void;
|
|
299
|
+
private readonly onClose: () => void;
|
|
300
|
+
private cachedWidth: number | undefined;
|
|
301
|
+
private cachedLines: string[] | undefined;
|
|
302
|
+
private compact = false;
|
|
303
|
+
private wheelInput: Input | undefined;
|
|
304
|
+
|
|
305
|
+
constructor(
|
|
306
|
+
theme: Theme,
|
|
307
|
+
config: OpenTuiConfig,
|
|
308
|
+
onChange: (config: OpenTuiConfig) => void,
|
|
309
|
+
onClose: () => void,
|
|
310
|
+
) {
|
|
311
|
+
this.theme = theme;
|
|
312
|
+
this.config = config;
|
|
313
|
+
this.onChange = onChange;
|
|
314
|
+
this.onClose = onClose;
|
|
315
|
+
this.container = new Box(1, 1, (s: string) => theme.bg("customMessageBg", s));
|
|
316
|
+
this.selectList = new SelectList([], 12, {
|
|
317
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
318
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
319
|
+
description: (t) => theme.fg("muted", t),
|
|
320
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
321
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
322
|
+
});
|
|
323
|
+
this.rebuild();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private applySetting(itemId: string): void {
|
|
327
|
+
this.selectedItemByTab[this.tab] = itemId;
|
|
328
|
+
if (this.tab === "features" && itemId === "wheelScrollLines") {
|
|
329
|
+
this.openWheelInput();
|
|
330
|
+
this.invalidate();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
this.config = handleSettingChange(this.tab, itemId, this.config);
|
|
334
|
+
this.onChange(this.config);
|
|
335
|
+
this.rebuild(itemId);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private openWheelInput(): void {
|
|
339
|
+
const input = new Input();
|
|
340
|
+
input.onSubmit = (value) => {
|
|
341
|
+
const next = setWheelScrollLines(this.config, value);
|
|
342
|
+
this.wheelInput = undefined;
|
|
343
|
+
if (next) {
|
|
344
|
+
this.config = next;
|
|
345
|
+
this.onChange(this.config);
|
|
346
|
+
}
|
|
347
|
+
this.rebuild("wheelScrollLines");
|
|
348
|
+
};
|
|
349
|
+
input.onEscape = () => {
|
|
350
|
+
this.wheelInput = undefined;
|
|
351
|
+
this.rebuild("wheelScrollLines");
|
|
352
|
+
};
|
|
353
|
+
this.wheelInput = input;
|
|
354
|
+
this.rebuild("wheelScrollLines");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private switchTab(offset: number): void {
|
|
358
|
+
const idx = TABS.indexOf(this.tab);
|
|
359
|
+
this.tab = TABS[(idx + offset + TABS.length) % TABS.length]!;
|
|
360
|
+
this.rebuild();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private rebuild(preferredItemId = this.selectedItemByTab[this.tab]): void {
|
|
364
|
+
const copy = COPY[this.config.settingsLanguage];
|
|
365
|
+
this.container.clear();
|
|
366
|
+
this.container.addChild(new Text(this.theme.bold(this.theme.fg("accent", copy.title)), 1, 0));
|
|
367
|
+
|
|
368
|
+
const tabBar = TABS.map((tab) => {
|
|
369
|
+
const active = tab === this.tab;
|
|
370
|
+
const label = active ? `[${copy.tabs[tab]}]` : ` ${copy.tabs[tab]} `;
|
|
371
|
+
return active ? this.theme.fg("accent", label) : this.theme.fg("dim", label);
|
|
372
|
+
}).join(" ");
|
|
373
|
+
this.container.addChild(new Text(tabBar, 1, 0));
|
|
374
|
+
this.container.addChild(new Text(this.theme.fg("dim", copy.hint), 1, 0));
|
|
375
|
+
|
|
376
|
+
const editingWheel = this.tab === "features" && this.wheelInput !== undefined;
|
|
377
|
+
const items = buildItems(this.tab, this.config).map((item) => {
|
|
378
|
+
const editing = editingWheel && item.id === "wheelScrollLines";
|
|
379
|
+
return {
|
|
380
|
+
value: item.id,
|
|
381
|
+
label: editing
|
|
382
|
+
? (this.compact ? `${item.label}:` : item.label)
|
|
383
|
+
: (this.compact ? `${item.label}: ${item.currentValue}` : item.label),
|
|
384
|
+
description: editing || this.compact ? undefined : item.currentValue,
|
|
385
|
+
} as SelectItem;
|
|
386
|
+
});
|
|
387
|
+
this.selectList = new SelectList(items, Math.min(items.length, 12), {
|
|
388
|
+
selectedPrefix: (t) => this.theme.fg("accent", t),
|
|
389
|
+
selectedText: (t) => this.theme.fg("accent", t),
|
|
390
|
+
description: (t) => this.theme.fg("muted", t),
|
|
391
|
+
scrollInfo: (t) => this.theme.fg("dim", t),
|
|
392
|
+
noMatch: (t) => this.theme.fg("warning", t),
|
|
393
|
+
});
|
|
394
|
+
const selectedIndex = items.findIndex((item) => item.value === preferredItemId);
|
|
395
|
+
if (selectedIndex >= 0) {
|
|
396
|
+
this.selectList.setSelectedIndex(selectedIndex);
|
|
397
|
+
}
|
|
398
|
+
this.selectedItemByTab[this.tab] = this.selectList.getSelectedItem()?.value;
|
|
399
|
+
this.selectList.onSelectionChange = (item) => {
|
|
400
|
+
this.selectedItemByTab[this.tab] = item.value;
|
|
401
|
+
};
|
|
402
|
+
this.selectList.onSelect = (item) => {
|
|
403
|
+
this.applySetting(item.value);
|
|
404
|
+
};
|
|
405
|
+
this.selectList.onCancel = () => {
|
|
406
|
+
this.onClose();
|
|
407
|
+
};
|
|
408
|
+
if (editingWheel) {
|
|
409
|
+
this.wheelInput!.focused = true;
|
|
410
|
+
const wheelInputGroup = new Box(4, 0);
|
|
411
|
+
wheelInputGroup.addChild(new Text(
|
|
412
|
+
this.theme.fg("muted", copy.values.wheelPrompt(this.config.fullscreen.wheelScrollLines)),
|
|
413
|
+
0,
|
|
414
|
+
0,
|
|
415
|
+
));
|
|
416
|
+
wheelInputGroup.addChild(this.wheelInput!);
|
|
417
|
+
const selectedIndex = () => items.findIndex((item) => item.value === this.selectList.getSelectedItem()?.value);
|
|
418
|
+
this.container.addChild(insertComponentAfter(this.selectList, wheelInputGroup, selectedIndex));
|
|
419
|
+
} else {
|
|
420
|
+
this.container.addChild(this.selectList);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
this.cachedWidth = undefined;
|
|
424
|
+
this.cachedLines = undefined;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
handleInput(data: string): void {
|
|
428
|
+
if (this.wheelInput && this.tab === "features") {
|
|
429
|
+
this.wheelInput.handleInput(data);
|
|
430
|
+
this.invalidate();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
434
|
+
this.switchTab(1);
|
|
435
|
+
this.invalidate();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
|
439
|
+
this.switchTab(-1);
|
|
440
|
+
this.invalidate();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, "q")) {
|
|
444
|
+
this.onClose();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (matchesKey(data, Key.space) || data === " ") {
|
|
448
|
+
const selected = this.selectList.getSelectedItem();
|
|
449
|
+
if (selected) this.applySetting(selected.value);
|
|
450
|
+
} else {
|
|
451
|
+
this.selectList.handleInput?.(data);
|
|
452
|
+
}
|
|
453
|
+
this.invalidate();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
render(width: number): string[] {
|
|
457
|
+
const compact = width <= 60;
|
|
458
|
+
if (compact !== this.compact) {
|
|
459
|
+
this.compact = compact;
|
|
460
|
+
this.rebuild();
|
|
461
|
+
}
|
|
462
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
463
|
+
this.cachedWidth = width;
|
|
464
|
+
this.cachedLines = this.container.render(width);
|
|
465
|
+
return this.cachedLines;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
invalidate(): void {
|
|
469
|
+
this.cachedWidth = undefined;
|
|
470
|
+
this.cachedLines = undefined;
|
|
471
|
+
this.container.invalidate();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function registerSettingsCommand(
|
|
476
|
+
pi: ExtensionAPI,
|
|
477
|
+
hooks: {
|
|
478
|
+
getConfig: () => OpenTuiConfig;
|
|
479
|
+
onConfigChanged: (config: OpenTuiConfig) => void;
|
|
480
|
+
onOverlayClosed?: () => void;
|
|
481
|
+
},
|
|
482
|
+
): void {
|
|
483
|
+
const commandDef = {
|
|
484
|
+
description: "Open the Pi TUI settings UI",
|
|
485
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
486
|
+
if (!ctx.hasUI) return;
|
|
487
|
+
await ctx.ui.custom<void>((tui: TUI, theme, _kb, done) => {
|
|
488
|
+
const ui = new SettingsUi(
|
|
489
|
+
theme,
|
|
490
|
+
hooks.getConfig(),
|
|
491
|
+
(config) => hooks.onConfigChanged(config),
|
|
492
|
+
() => done(undefined),
|
|
493
|
+
);
|
|
494
|
+
return {
|
|
495
|
+
render: (w: number) => ui.render(w),
|
|
496
|
+
invalidate: () => ui.invalidate(),
|
|
497
|
+
handleInput: (data: string) => {
|
|
498
|
+
ui.handleInput(data);
|
|
499
|
+
tui.requestRender();
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}, { overlay: true });
|
|
503
|
+
// Overlay is closed and focus is back on the editor. Deferred UI changes
|
|
504
|
+
// (e.g. toggling the extension) run here, so pi core's focus restore
|
|
505
|
+
// cannot strand the overlay without keyboard input.
|
|
506
|
+
hooks.onOverlayClosed?.();
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
pi.registerCommand("pi-tui", commandDef);
|
|
510
|
+
pi.registerCommand("open-tui", commandDef);
|
|
511
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { GitStatus } from "./git.ts";
|
|
4
|
+
import { emptyGitStatus } from "./git.ts";
|
|
5
|
+
import type { RuntimeInfo } from "./runtime.ts";
|
|
6
|
+
import { finiteOrZero, formatProviderLabel } from "./utils.ts";
|
|
7
|
+
|
|
8
|
+
export interface FooterState {
|
|
9
|
+
git: GitStatus;
|
|
10
|
+
runtime: RuntimeInfo | null;
|
|
11
|
+
sessionStartEpoch: number;
|
|
12
|
+
workingSince: number | undefined;
|
|
13
|
+
lastDoneIn: number | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UsageTotals {
|
|
17
|
+
input: number;
|
|
18
|
+
output: number;
|
|
19
|
+
cacheRead: number;
|
|
20
|
+
cacheWrite: number;
|
|
21
|
+
cost: number;
|
|
22
|
+
latestCacheHitRate: number | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let usageCache: { key: string; totals: UsageTotals } | undefined;
|
|
26
|
+
|
|
27
|
+
function entriesKey(ctx: ExtensionContext): string {
|
|
28
|
+
const entries = ctx.sessionManager.getEntries();
|
|
29
|
+
const last = entries.at(-1);
|
|
30
|
+
return `${entries.length}:${last?.id ?? ""}:${last?.timestamp ?? ""}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
|
|
34
|
+
const key = entriesKey(ctx);
|
|
35
|
+
if (usageCache && usageCache.key === key) return usageCache.totals;
|
|
36
|
+
|
|
37
|
+
const totals: UsageTotals = {
|
|
38
|
+
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0,
|
|
39
|
+
latestCacheHitRate: undefined,
|
|
40
|
+
};
|
|
41
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
42
|
+
let u: Usage | undefined;
|
|
43
|
+
let updateCacheHitRate = false;
|
|
44
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
45
|
+
u = entry.message.usage;
|
|
46
|
+
updateCacheHitRate = true;
|
|
47
|
+
} else if (entry.type === "message" && entry.message.role === "toolResult") {
|
|
48
|
+
u = entry.message.usage;
|
|
49
|
+
} else if (entry.type === "branch_summary" || entry.type === "compaction") {
|
|
50
|
+
u = entry.usage;
|
|
51
|
+
}
|
|
52
|
+
if (!u) continue;
|
|
53
|
+
const input = finiteOrZero(u.input);
|
|
54
|
+
const cacheRead = finiteOrZero(u.cacheRead);
|
|
55
|
+
const cacheWrite = finiteOrZero(u.cacheWrite);
|
|
56
|
+
// input matches /session's "uncached" total: cacheWrite is billed near full
|
|
57
|
+
// price (fresh content), only cacheRead is discounted repeat content.
|
|
58
|
+
totals.input += input + cacheWrite;
|
|
59
|
+
totals.output += finiteOrZero(u.output);
|
|
60
|
+
totals.cacheRead += cacheRead;
|
|
61
|
+
totals.cacheWrite += cacheWrite;
|
|
62
|
+
totals.cost += finiteOrZero(u.cost?.total);
|
|
63
|
+
if (updateCacheHitRate) {
|
|
64
|
+
const promptTokens = input + cacheRead + cacheWrite;
|
|
65
|
+
totals.latestCacheHitRate = promptTokens > 0
|
|
66
|
+
? (cacheRead / promptTokens) * 100
|
|
67
|
+
: undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
usageCache = { key, totals };
|
|
71
|
+
return totals;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function invalidateUsageCache(): void {
|
|
75
|
+
usageCache = undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createInitialState(): FooterState {
|
|
79
|
+
return {
|
|
80
|
+
git: emptyGitStatus(),
|
|
81
|
+
runtime: null,
|
|
82
|
+
sessionStartEpoch: Date.now(),
|
|
83
|
+
workingSince: undefined,
|
|
84
|
+
lastDoneIn: undefined,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ModelMeta {
|
|
89
|
+
provider: string;
|
|
90
|
+
model: string;
|
|
91
|
+
effort: string | undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getModelMeta(
|
|
95
|
+
ctx: ExtensionContext,
|
|
96
|
+
getThinkingLevel: () => string,
|
|
97
|
+
capitalizeProviderName: boolean,
|
|
98
|
+
): ModelMeta {
|
|
99
|
+
const provider = formatProviderLabel(ctx.model?.provider, capitalizeProviderName);
|
|
100
|
+
const model = ctx.model?.name ?? ctx.model?.id ?? "no-model";
|
|
101
|
+
const reasoning = ctx.model?.reasoning ?? false;
|
|
102
|
+
const effort = reasoning ? getThinkingLevel() : undefined;
|
|
103
|
+
return { provider, model, effort };
|
|
104
|
+
}
|