@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.
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Usage data types shared across modules.
3
+ */
4
+
5
+ export type { ProviderUsageEntry } from "@eiei114/pi-sub-shared";
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
+ }
@@ -0,0 +1,6 @@
1
+ import "./formatting.test.js";
2
+ import "./settings.test.js";
3
+ import "./dividers.test.js";
4
+ import "./providers.test.js";
5
+ import "./status.test.js";
6
+ import "./keybindings.test.js";
@@ -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
+ });