@eiei114/pi-sub-bar 1.5.1
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/CHANGELOG.md +201 -0
- package/README.md +200 -0
- package/index.ts +1103 -0
- package/package.json +39 -0
- package/src/core-settings.ts +25 -0
- package/src/dividers.ts +48 -0
- package/src/errors.ts +71 -0
- package/src/formatting.ts +937 -0
- package/src/paths.ts +21 -0
- package/src/providers/extras.ts +21 -0
- package/src/providers/metadata.ts +199 -0
- package/src/providers/settings.ts +359 -0
- package/src/providers/windows.ts +23 -0
- package/src/settings/display.ts +786 -0
- package/src/settings/menu.ts +183 -0
- package/src/settings/themes.ts +378 -0
- package/src/settings/ui.ts +1388 -0
- package/src/settings-types.ts +651 -0
- package/src/settings-ui.ts +5 -0
- package/src/settings.ts +176 -0
- package/src/share.ts +75 -0
- package/src/status.ts +103 -0
- package/src/storage.ts +61 -0
- package/src/types.ts +25 -0
- package/src/ui/keybindings.ts +92 -0
- package/src/ui/settings-list.ts +304 -0
- package/src/usage/types.ts +5 -0
- package/src/utils.ts +42 -0
- package/test/all.test.ts +6 -0
- package/test/dividers.test.ts +34 -0
- package/test/formatting.test.ts +437 -0
- package/test/keybindings.test.ts +59 -0
- package/test/providers.test.ts +42 -0
- package/test/settings.test.ts +336 -0
- package/test/status.test.ts +27 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import type { Component, SettingItem, SettingsListTheme } from "@mariozechner/pi-tui";
|
|
2
|
+
import {
|
|
3
|
+
Input,
|
|
4
|
+
fuzzyFilter,
|
|
5
|
+
truncateToWidth,
|
|
6
|
+
visibleWidth,
|
|
7
|
+
wrapTextWithAnsi,
|
|
8
|
+
} from "@mariozechner/pi-tui";
|
|
9
|
+
import { getSettingsKeybindings } from "./keybindings.js";
|
|
10
|
+
|
|
11
|
+
export interface SettingsListOptions {
|
|
12
|
+
enableSearch?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const CUSTOM_OPTION = "__custom__";
|
|
16
|
+
export const CUSTOM_LABEL = "custom";
|
|
17
|
+
|
|
18
|
+
export type { SettingItem, SettingsListTheme };
|
|
19
|
+
|
|
20
|
+
export class SettingsList implements Component {
|
|
21
|
+
private items: SettingItem[];
|
|
22
|
+
private filteredItems: SettingItem[];
|
|
23
|
+
private theme: SettingsListTheme;
|
|
24
|
+
private selectedIndex = 0;
|
|
25
|
+
private maxVisible: number;
|
|
26
|
+
private onChange: (id: string, newValue: string) => void;
|
|
27
|
+
private onCancel: () => void;
|
|
28
|
+
private searchInput?: Input;
|
|
29
|
+
private searchEnabled: boolean;
|
|
30
|
+
private submenuComponent: Component | null = null;
|
|
31
|
+
private submenuItemIndex: number | null = null;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
items: SettingItem[],
|
|
35
|
+
maxVisible: number,
|
|
36
|
+
theme: SettingsListTheme,
|
|
37
|
+
onChange: (id: string, newValue: string) => void,
|
|
38
|
+
onCancel: () => void,
|
|
39
|
+
options: SettingsListOptions = {},
|
|
40
|
+
) {
|
|
41
|
+
this.items = items;
|
|
42
|
+
this.filteredItems = items;
|
|
43
|
+
this.maxVisible = maxVisible;
|
|
44
|
+
this.theme = theme;
|
|
45
|
+
this.onChange = onChange;
|
|
46
|
+
this.onCancel = onCancel;
|
|
47
|
+
this.searchEnabled = options.enableSearch ?? false;
|
|
48
|
+
|
|
49
|
+
if (this.searchEnabled) {
|
|
50
|
+
this.searchInput = new Input();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Update an item's currentValue */
|
|
55
|
+
updateValue(id: string, newValue: string): void {
|
|
56
|
+
const item = this.items.find((i) => i.id === id);
|
|
57
|
+
if (item) {
|
|
58
|
+
item.currentValue = newValue;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getSelectedId(): string | null {
|
|
63
|
+
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
|
64
|
+
const item = displayItems[this.selectedIndex];
|
|
65
|
+
return item?.id ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setSelectedId(id: string): void {
|
|
69
|
+
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
|
70
|
+
const index = displayItems.findIndex((item) => item.id === id);
|
|
71
|
+
if (index >= 0) {
|
|
72
|
+
this.selectedIndex = index;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
invalidate(): void {
|
|
77
|
+
this.submenuComponent?.invalidate?.();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
render(width: number): string[] {
|
|
81
|
+
// If submenu is active, render it instead
|
|
82
|
+
if (this.submenuComponent) {
|
|
83
|
+
return this.submenuComponent.render(width);
|
|
84
|
+
}
|
|
85
|
+
return this.renderMainList(width);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private renderMainList(width: number): string[] {
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
if (this.searchEnabled && this.searchInput) {
|
|
91
|
+
lines.push(...this.searchInput.render(width));
|
|
92
|
+
lines.push("");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (this.items.length === 0) {
|
|
96
|
+
lines.push(this.theme.hint(" No settings available"));
|
|
97
|
+
if (this.searchEnabled) {
|
|
98
|
+
this.addHintLine(lines);
|
|
99
|
+
}
|
|
100
|
+
return lines;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
|
104
|
+
if (displayItems.length === 0) {
|
|
105
|
+
lines.push(this.theme.hint(" No matching settings"));
|
|
106
|
+
this.addHintLine(lines);
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Calculate visible range with scrolling
|
|
111
|
+
const startIndex = Math.max(
|
|
112
|
+
0,
|
|
113
|
+
Math.min(
|
|
114
|
+
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
|
115
|
+
displayItems.length - this.maxVisible,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length);
|
|
119
|
+
|
|
120
|
+
// Calculate max label width for alignment
|
|
121
|
+
const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label))));
|
|
122
|
+
|
|
123
|
+
// Render visible items
|
|
124
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
125
|
+
const item = displayItems[i];
|
|
126
|
+
if (!item) continue;
|
|
127
|
+
const isSelected = i === this.selectedIndex;
|
|
128
|
+
const prefix = isSelected ? this.theme.cursor : " ";
|
|
129
|
+
const prefixWidth = visibleWidth(prefix);
|
|
130
|
+
|
|
131
|
+
// Pad label to align values
|
|
132
|
+
const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
|
|
133
|
+
const labelText = this.theme.label(labelPadded, isSelected);
|
|
134
|
+
|
|
135
|
+
// Calculate space for value
|
|
136
|
+
const separator = " ";
|
|
137
|
+
const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
|
|
138
|
+
const valueMaxWidth = Math.max(1, width - usedWidth - 2);
|
|
139
|
+
const optionLines = isSelected && item.values && item.values.length > 0
|
|
140
|
+
? wrapTextWithAnsi(this.formatOptionsInline(item, item.values), valueMaxWidth)
|
|
141
|
+
: null;
|
|
142
|
+
const valueText = optionLines
|
|
143
|
+
? optionLines[0] ?? ""
|
|
144
|
+
: this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected);
|
|
145
|
+
const line = prefix + labelText + separator + valueText;
|
|
146
|
+
lines.push(truncateToWidth(line, width, ""));
|
|
147
|
+
if (optionLines && optionLines.length > 1) {
|
|
148
|
+
const indent = " ".repeat(prefixWidth + maxLabelWidth + visibleWidth(separator));
|
|
149
|
+
for (const continuation of optionLines.slice(1)) {
|
|
150
|
+
lines.push(truncateToWidth(indent + continuation, width, ""));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Add scroll indicator if needed
|
|
156
|
+
if (startIndex > 0 || endIndex < displayItems.length) {
|
|
157
|
+
const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;
|
|
158
|
+
lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, "")));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Add description for selected item
|
|
162
|
+
const selectedItem = displayItems[this.selectedIndex];
|
|
163
|
+
if (selectedItem?.description) {
|
|
164
|
+
lines.push("");
|
|
165
|
+
const wrapWidth = Math.max(1, width - 4);
|
|
166
|
+
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, wrapWidth);
|
|
167
|
+
for (const line of wrappedDesc) {
|
|
168
|
+
const prefixed = ` ${line}`;
|
|
169
|
+
lines.push(this.theme.description(truncateToWidth(prefixed, width, "")));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
// Add hint
|
|
175
|
+
this.addHintLine(lines);
|
|
176
|
+
return lines;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
handleInput(data: string): void {
|
|
180
|
+
// If submenu is active, delegate all input to it
|
|
181
|
+
// The submenu's onCancel (triggered by escape) will call done() which closes it
|
|
182
|
+
if (this.submenuComponent) {
|
|
183
|
+
this.submenuComponent.handleInput?.(data);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const kb = getSettingsKeybindings();
|
|
188
|
+
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
|
189
|
+
|
|
190
|
+
if (kb.matches(data, "selectUp")) {
|
|
191
|
+
if (displayItems.length === 0) return;
|
|
192
|
+
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
|
|
193
|
+
} else if (kb.matches(data, "selectDown")) {
|
|
194
|
+
if (displayItems.length === 0) return;
|
|
195
|
+
this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;
|
|
196
|
+
} else if (kb.matches(data, "cursorLeft")) {
|
|
197
|
+
this.stepValue(-1);
|
|
198
|
+
} else if (kb.matches(data, "cursorRight")) {
|
|
199
|
+
this.stepValue(1);
|
|
200
|
+
} else if (kb.matches(data, "selectConfirm") || data === " ") {
|
|
201
|
+
this.activateItem();
|
|
202
|
+
} else if (kb.matches(data, "selectCancel")) {
|
|
203
|
+
this.onCancel();
|
|
204
|
+
} else if (this.searchEnabled && this.searchInput) {
|
|
205
|
+
const sanitized = data.replace(/ /g, "");
|
|
206
|
+
if (!sanitized) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
this.searchInput.handleInput(sanitized);
|
|
210
|
+
this.applyFilter(this.searchInput.getValue());
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private stepValue(direction: -1 | 1): void {
|
|
215
|
+
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
|
216
|
+
const item = displayItems[this.selectedIndex];
|
|
217
|
+
if (!item || !item.values || item.values.length === 0) return;
|
|
218
|
+
const values = item.values;
|
|
219
|
+
let currentIndex = values.indexOf(item.currentValue);
|
|
220
|
+
if (currentIndex === -1) {
|
|
221
|
+
currentIndex = direction > 0 ? 0 : values.length - 1;
|
|
222
|
+
}
|
|
223
|
+
const nextIndex = (currentIndex + direction + values.length) % values.length;
|
|
224
|
+
const newValue = values[nextIndex];
|
|
225
|
+
if (newValue === CUSTOM_OPTION) {
|
|
226
|
+
item.currentValue = newValue;
|
|
227
|
+
this.onChange(item.id, newValue);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
item.currentValue = newValue;
|
|
231
|
+
this.onChange(item.id, newValue);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private activateItem(): void {
|
|
235
|
+
const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex];
|
|
236
|
+
if (!item) return;
|
|
237
|
+
|
|
238
|
+
const hasCustom = Boolean(item.values && item.values.includes(CUSTOM_OPTION));
|
|
239
|
+
const currentIsCustom = hasCustom && item.values && !item.values.includes(item.currentValue);
|
|
240
|
+
|
|
241
|
+
if (item.submenu && hasCustom) {
|
|
242
|
+
if (currentIsCustom || item.currentValue === CUSTOM_OPTION) {
|
|
243
|
+
this.openSubmenu(item);
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (item.submenu) {
|
|
249
|
+
this.openSubmenu(item);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private closeSubmenu(): void {
|
|
254
|
+
this.submenuComponent = null;
|
|
255
|
+
// Restore selection to the item that opened the submenu
|
|
256
|
+
if (this.submenuItemIndex !== null) {
|
|
257
|
+
this.selectedIndex = this.submenuItemIndex;
|
|
258
|
+
this.submenuItemIndex = null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private applyFilter(query: string): void {
|
|
263
|
+
this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label);
|
|
264
|
+
this.selectedIndex = 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private formatOptionsInline(item: SettingItem, values: string[]): string {
|
|
268
|
+
const separator = this.theme.description(" • ");
|
|
269
|
+
const hasCustom = values.includes(CUSTOM_OPTION);
|
|
270
|
+
const currentIsCustom = hasCustom && !values.includes(item.currentValue);
|
|
271
|
+
return values
|
|
272
|
+
.map((value) => {
|
|
273
|
+
const label = value === CUSTOM_OPTION
|
|
274
|
+
? (currentIsCustom ? `${CUSTOM_LABEL} (${item.currentValue})` : CUSTOM_LABEL)
|
|
275
|
+
: value;
|
|
276
|
+
const selected = value === item.currentValue || (currentIsCustom && value === CUSTOM_OPTION);
|
|
277
|
+
return this.theme.value(label, selected);
|
|
278
|
+
})
|
|
279
|
+
.join(separator);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private openSubmenu(item: SettingItem): void {
|
|
283
|
+
if (!item.submenu) return;
|
|
284
|
+
this.submenuItemIndex = this.selectedIndex;
|
|
285
|
+
this.submenuComponent = item.submenu(item.currentValue, (selectedValue) => {
|
|
286
|
+
if (selectedValue !== undefined) {
|
|
287
|
+
item.currentValue = selectedValue;
|
|
288
|
+
this.onChange(item.id, selectedValue);
|
|
289
|
+
}
|
|
290
|
+
this.closeSubmenu();
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private addHintLine(lines: string[]): void {
|
|
295
|
+
lines.push("");
|
|
296
|
+
lines.push(
|
|
297
|
+
this.theme.hint(
|
|
298
|
+
this.searchEnabled
|
|
299
|
+
? " Type to search · ←/→ change · Enter/Space edit custom · Esc to cancel"
|
|
300
|
+
: " ←/→ change · Enter/Space edit custom · Esc to cancel",
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for the sub-bar display layer.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { MODEL_MULTIPLIERS } from "@eiei114/pi-sub-shared";
|
|
6
|
+
|
|
7
|
+
export function normalizeTokens(value: string): string[] {
|
|
8
|
+
return value
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
11
|
+
.trim()
|
|
12
|
+
.split(" ")
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const MODEL_MULTIPLIER_TOKENS = Object.entries(MODEL_MULTIPLIERS).map(([label, multiplier]) => ({
|
|
17
|
+
label,
|
|
18
|
+
multiplier,
|
|
19
|
+
tokens: normalizeTokens(label),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get the request multiplier for a model ID
|
|
24
|
+
* Uses fuzzy matching against known model names
|
|
25
|
+
*/
|
|
26
|
+
export function getModelMultiplier(modelId: string | undefined): number | undefined {
|
|
27
|
+
if (!modelId) return undefined;
|
|
28
|
+
const modelTokens = normalizeTokens(modelId);
|
|
29
|
+
if (modelTokens.length === 0) return undefined;
|
|
30
|
+
|
|
31
|
+
let bestMatch: { multiplier: number; tokenCount: number } | undefined;
|
|
32
|
+
for (const entry of MODEL_MULTIPLIER_TOKENS) {
|
|
33
|
+
const isMatch = entry.tokens.every((token) => modelTokens.includes(token));
|
|
34
|
+
if (!isMatch) continue;
|
|
35
|
+
const tokenCount = entry.tokens.length;
|
|
36
|
+
if (!bestMatch || tokenCount > bestMatch.tokenCount) {
|
|
37
|
+
bestMatch = { multiplier: entry.multiplier, tokenCount };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return bestMatch?.multiplier;
|
|
42
|
+
}
|
package/test/all.test.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import { buildDividerLine } from "../src/dividers.js";
|
|
5
|
+
|
|
6
|
+
const theme = {
|
|
7
|
+
fg: (_color: string, text: string) => text,
|
|
8
|
+
} as unknown as Theme;
|
|
9
|
+
|
|
10
|
+
test("divider join aligns after wide emoji", () => {
|
|
11
|
+
const baseLine = "🙂|"; // emoji width 2, divider at column 2
|
|
12
|
+
const line = buildDividerLine(4, baseLine, "|", true, "bottom", "text", theme);
|
|
13
|
+
assert.equal(line[2], "┴");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("divider join disabled keeps base line intact", () => {
|
|
17
|
+
const baseLine = "| | |";
|
|
18
|
+
const line = buildDividerLine(5, baseLine, "|", false, "top", "text", theme);
|
|
19
|
+
assert.equal(line, "─────");
|
|
20
|
+
assert.ok(!line.includes("┬"));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("divider join ignores unsupported characters", () => {
|
|
24
|
+
const baseLine = "• • •";
|
|
25
|
+
const line = buildDividerLine(5, baseLine, "•", true, "bottom", "text", theme);
|
|
26
|
+
assert.equal(line, "─────");
|
|
27
|
+
assert.ok(!line.includes("┴"));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("divider join handles ansi codes and wide characters", () => {
|
|
31
|
+
const baseLine = "\x1b[31m🙂│\x1b[0m";
|
|
32
|
+
const line = buildDividerLine(4, baseLine, "│", true, "top", "text", theme);
|
|
33
|
+
assert.equal(line[2], "┬");
|
|
34
|
+
});
|