@underactive/pi-topping-moa-fusion 0.4.5 → 0.5.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.
@@ -21,7 +21,8 @@ import {
21
21
  MAX_VERIFICATION_REPAIRS_LIMIT,
22
22
  MIN_CONCURRENT_AGENTS,
23
23
  MIN_VERIFICATION_REPAIRS,
24
- saveMoaConfig,
24
+ moaSaveFailureMessage,
25
+ updateMoaConfig,
25
26
  type MoaConfig,
26
27
  } from "../config/settings.ts";
27
28
  import type { PlanRoster } from "../config/rosters.ts";
@@ -29,8 +30,8 @@ import { modelRefLabel, type ModelRef, type ThinkingLevel } from "../shared/mode
29
30
  import { CONFIGURABLE_AGENTS, readAgentDefault, writeAgentDefault } from "../agents/defaults.ts";
30
31
  import { getAvailableModelRefs } from "./moaModelPicker.ts";
31
32
  import { showRosterManager } from "./rosterEditor.ts";
32
- import { TwoPaneModelThinking } from "./twoPaneModelThinking.ts";
33
- import { getModelCatalogue } from "../config/modelCatalogue.ts";
33
+ import { createMoaModelPicker, type TwoPaneModelThinking } from "./twoPaneModelThinking.ts";
34
+ import { getModelCatalogue } from "@underactive/pi-topping-primitives/models";
34
35
  import {
35
36
  MenuComponent,
36
37
  PLAN_OVERLAY_OPTIONS,
@@ -108,7 +109,7 @@ class MoaSetupComponent implements Component {
108
109
  title: string,
109
110
  private readonly done: (result: SetupResult) => void,
110
111
  ) {
111
- this.twoPane = new TwoPaneModelThinking(tui, theme, availableRefs, config, currentThinking, ctx);
112
+ this.twoPane = createMoaModelPicker(tui, theme, availableRefs, config, currentThinking, ctx);
112
113
  this.autoResolveConflicts = draft.autoResolveConflicts;
113
114
  this.useSummaryName = draft.useSummaryName;
114
115
  this.maxConcurrentAgents = draft.maxConcurrentAgents;
@@ -377,8 +378,8 @@ export async function showMoaSetup(
377
378
  draft.rosters = await showRosterManager(ctx, draft.rosters, {
378
379
  currentThinking,
379
380
  onChange: (rosters) => {
380
- const current = loadMoaConfig();
381
- saveMoaConfig({ ...current, rosters });
381
+ const persisted = updateMoaConfig((current) => ({ ...current, rosters }));
382
+ if (!persisted.ok) ctx.ui.notify(moaSaveFailureMessage(persisted), "warning");
382
383
  },
383
384
  });
384
385
  continue;
@@ -408,8 +409,7 @@ export async function showMoaSetup(
408
409
  // intentionally left untouched: they remain load-bearing for resume,
409
410
  // verifier fallback, and picker seeding, and are only written by a run's
410
411
  // own confirmations.
411
- const current = loadMoaConfig();
412
- saveMoaConfig({
412
+ const persisted = updateMoaConfig((current) => ({
413
413
  ...current,
414
414
  mode: "moa",
415
415
  agentDefaultsConfigured: true,
@@ -421,6 +421,10 @@ export async function showMoaSetup(
421
421
  cheap: finalDraft.selections[AGENT_SLOT_COUNT],
422
422
  rosters: finalDraft.rosters,
423
423
  thinkingOverrides: { ...current.thinkingOverrides, ...finalDraft.thinkingOverrides },
424
- });
424
+ }));
425
+ if (!persisted.ok) {
426
+ ctx.ui.notify(moaSaveFailureMessage(persisted), "warning");
427
+ return false;
428
+ }
425
429
  return true;
426
430
  }
@@ -10,8 +10,8 @@ import {
10
10
  ratioViewport,
11
11
  } from "./chrome.ts";
12
12
  import { getAvailableModelRefs } from "./moaModelPicker.ts";
13
- import { smartTruncateModelLabel } from "./modelLabel.ts";
14
- import { TwoPaneModelThinking } from "./twoPaneModelThinking.ts";
13
+ import { smartTruncateModelLabel } from "@underactive/pi-topping-primitives/models";
14
+ import { createMoaModelPicker, type TwoPaneModelThinking } from "./twoPaneModelThinking.ts";
15
15
 
16
16
  const HINT_BASE = "type filters models • ↑↓ navigate pane • tab panes/buttons • ←→ switch/select • enter select";
17
17
 
@@ -54,7 +54,7 @@ export class OpinionModelPickerComponent implements Component {
54
54
  this.theme = theme;
55
55
  this.defaults = defaults;
56
56
  this.done = done;
57
- this.twoPane = new TwoPaneModelThinking(tui, theme, availableRefs, config, currentThinking, ctx);
57
+ this.twoPane = createMoaModelPicker(tui, theme, availableRefs, config, currentThinking, ctx);
58
58
  }
59
59
 
60
60
  private assignedCount(): number {
@@ -1,272 +1,35 @@
1
1
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
- import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
3
- import { fuzzyFilter, Key, matchesKey, SelectList, truncateToWidth, visibleWidth, type SelectItem, type TUI } from "@earendil-works/pi-tui";
2
+ import type { TUI } from "@earendil-works/pi-tui";
3
+ import { registryLevelOptions, TwoPaneModelThinking } from "@underactive/pi-topping-primitives/models";
4
+ import { defaultThinkingForModel, type MoaConfig } from "../config/settings.ts";
5
+ import { modelRefLabel, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
4
6
  import { SELECTOR_POINTER } from "./chrome.ts";
5
- import { renderMenuFooterContents } from "./menu.ts";
6
- import { MODEL_LIST_LAYOUT } from "./modelLabel.ts";
7
- import { getModelCatalogue } from "../config/modelCatalogue.ts";
8
- import {
9
- defaultThinkingForModel,
10
- thinkingOptionsForModel,
11
- type MoaConfig,
12
- } from "../config/settings.ts";
13
- import {
14
- isThinkingLevel,
15
- modelRefLabel,
16
- parseRef,
17
- THINKING_LEVELS,
18
- type ModelRef,
19
- type ThinkingLevel,
20
- } from "../shared/modelRefs.ts";
21
7
 
22
- const MAX_VISIBLE_ROWS = 10;
23
-
24
- /** SelectList hardcodes "→ "; the extension's pointer is a plain "> " everywhere. */
25
- const withPointer = (line: string): string => line.replace("→ ", SELECTOR_POINTER);
26
-
27
- function modelThinkingPaneWidths(bodyWidth: number): { left: number; right: number } {
28
- const divider = 1;
29
- const longestLevel = THINKING_LEVELS.reduce((longest, level) => level.length > longest.length ? level : longest);
30
- // One composed inset plus SelectList's two safety columns after its selector.
31
- const rightMin = Math.max(visibleWidth(" Thinking"), visibleWidth(`→ ${longestLevel}`) + 3);
32
- const right = Math.max(1, Math.min(rightMin, Math.max(1, bodyWidth - divider - 1)));
33
- return { left: Math.max(1, bodyWidth - divider - right), right };
34
- }
35
-
36
- function toModelItems(refs: ModelRef[]): SelectItem[] {
37
- return refs.map((ref) => ({ value: modelRefLabel(ref), label: modelRefLabel(ref) }));
38
- }
39
-
40
- /**
41
- * Reusable two-pane selector: models on the left and model-specific thinking
42
- * levels on the right. The model filter always owns text input; Tab or the
43
- * left/right arrows move the selection focus between panes.
44
- */
45
- export class TwoPaneModelThinking {
46
- private modelList: SelectList;
47
- private levelList: SelectList;
48
- private filter = "";
49
- private activePane: "model" | "level" | "buttons" = "model";
50
- private activeButton = 0;
51
- private maxVisibleRows = MAX_VISIBLE_ROWS;
52
- private readonly modelItems: SelectItem[];
53
-
54
- constructor(
55
- private readonly tui: TUI,
56
- private readonly theme: Theme,
57
- availableRefs: ModelRef[],
58
- private readonly config: MoaConfig,
59
- private readonly currentThinking: ThinkingLevel,
60
- private readonly ctx: ExtensionContext,
61
- ) {
62
- this.modelItems = toModelItems(availableRefs);
63
- this.modelList = new SelectList([], 1, getSelectListTheme());
64
- this.levelList = new SelectList([], 1, getSelectListTheme());
65
- this.reset();
66
- }
67
-
68
- isModelPane(): boolean {
69
- return this.activePane === "model";
70
- }
71
-
72
- /** Limit the complete two-pane body, including filter/header/scroll rows. */
73
- setMaxVisibleRows(limit: number): void {
74
- const next = Math.max(1, Math.floor(limit));
75
- if (next === this.maxVisibleRows) return;
76
- this.maxVisibleRows = next;
77
- this.rebuildPreservingSelection();
78
- }
79
-
80
- reset(defaultRef?: ModelRef, defaultThinking?: ThinkingLevel): void {
81
- this.filter = "";
82
- this.activePane = "model";
83
- this.activeButton = 0;
84
- this.rebuildModelList();
85
- if (defaultRef) {
86
- const defaultIndex = this.modelItems.findIndex((item) => item.value === modelRefLabel(defaultRef));
87
- if (defaultIndex >= 0) this.modelList.setSelectedIndex(defaultIndex);
88
- }
89
- this.rebuildThinkingList();
90
- if (defaultThinking) this.selectThinking(defaultThinking);
91
- }
92
-
93
- private selectThinking(thinking: ThinkingLevel): void {
94
- const key = this.modelList.getSelectedItem()?.value;
95
- if (!key) return;
96
- const levels = thinkingOptionsForModel(getModelCatalogue(this.ctx.modelRegistry).thinkingLevelsFor(parseRef(key)));
97
- const index = levels.indexOf(thinking);
98
- if (index >= 0) this.levelList.setSelectedIndex(index);
99
- }
100
-
101
- private listItemRows(itemCount: number): number {
102
- const chromeRows = 1 + (this.filter ? 1 : 0);
103
- const available = Math.max(1, this.maxVisibleRows - chromeRows);
104
- return itemCount > available ? Math.max(1, available - 1) : available;
105
- }
106
-
107
- private filteredModelItems(): SelectItem[] {
108
- return this.filter.trim()
109
- ? fuzzyFilter(this.modelItems, this.filter, (item) => item.label)
110
- : this.modelItems;
111
- }
112
-
113
- private rebuildModelList(): SelectItem[] {
114
- const items = this.filteredModelItems();
115
- this.modelList = new SelectList(
116
- items,
117
- Math.min(Math.max(items.length, 1), this.listItemRows(items.length)),
118
- getSelectListTheme(),
119
- MODEL_LIST_LAYOUT,
120
- );
121
- this.modelList.onSelectionChange = () => {
122
- this.rebuildThinkingList();
123
- this.tui.requestRender();
124
- };
125
- return items;
126
- }
127
-
128
- private rebuildPreservingSelection(): void {
129
- const selectedModel = this.modelList.getSelectedItem()?.value;
130
- const selectedLevel = this.levelList.getSelectedItem()?.value;
131
- const items = this.rebuildModelList();
132
- if (selectedModel) {
133
- const index = items.findIndex((item) => item.value === selectedModel);
134
- if (index >= 0) this.modelList.setSelectedIndex(index);
135
- }
136
- this.rebuildThinkingList();
137
- if (selectedLevel && isThinkingLevel(selectedLevel)) this.selectThinking(selectedLevel);
138
- }
139
-
140
- private rebuildThinkingList(): void {
141
- const key = this.modelList.getSelectedItem()?.value;
142
- const registryLevels = key
143
- ? getModelCatalogue(this.ctx.modelRegistry).thinkingLevelsFor(parseRef(key))
144
- : [];
145
- const levels = thinkingOptionsForModel(registryLevels);
146
- const items = (levels.length > 0 ? levels : ["off"]).map((level) => ({ value: level, label: level }));
147
- this.levelList = new SelectList(items, Math.min(Math.max(items.length, 1), this.listItemRows(items.length)), getSelectListTheme());
148
- if (!key) return;
149
- const preferred = defaultThinkingForModel(key, this.config, this.currentThinking, registryLevels);
150
- const preferredIndex = levels.indexOf(preferred);
151
- if (preferredIndex >= 0) this.levelList.setSelectedIndex(preferredIndex);
152
- }
153
-
154
- handleInput(data: string): "confirm" | "back" | undefined {
155
- if (matchesKey(data, Key.escape)) return "back";
156
- if (matchesKey(data, Key.tab)) {
157
- // Tab cycles Models → Thinking → action buttons → Models;
158
- // left/right continue to switch between Models and Thinking (or
159
- // between buttons, once the action bar is focused).
160
- this.activePane = this.activePane === "model" ? "level" : this.activePane === "level" ? "buttons" : "model";
161
- this.tui.requestRender();
162
- return undefined;
163
- }
164
- if (this.activePane === "buttons") {
165
- if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
166
- this.activeButton = this.activeButton === 0 ? 1 : 0;
167
- this.tui.requestRender();
168
- return undefined;
169
- }
170
- if (matchesKey(data, Key.enter)) {
171
- return this.activeButton === 0 && this.modelList.getSelectedItem() && this.levelList.getSelectedItem()
172
- ? "confirm"
173
- : "back";
174
- }
175
- return undefined;
176
- }
177
- if (matchesKey(data, Key.enter)) {
178
- return this.modelList.getSelectedItem() && this.levelList.getSelectedItem() ? "confirm" : undefined;
179
- }
180
- if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
181
- this.activePane = this.activePane === "model" ? "level" : "model";
182
- this.tui.requestRender();
183
- return undefined;
184
- }
185
- if (data === "\x7f" || data === "\b") {
186
- this.activePane = "model";
187
- if (this.filter.length > 0) {
188
- this.filter = this.filter.slice(0, -1);
189
- this.rebuildPreservingSelection();
190
- this.tui.requestRender();
191
- }
192
- return undefined;
193
- }
194
- if (data.length === 1 && data >= " " && data !== "\x7f") {
195
- this.activePane = "model";
196
- this.filter += data;
197
- this.rebuildPreservingSelection();
198
- this.tui.requestRender();
199
- return undefined;
200
- }
201
- (this.activePane === "model" ? this.modelList : this.levelList).handleInput(data);
202
- this.tui.requestRender();
203
- return undefined;
204
- }
205
-
206
- getSelected(): { ref: ModelRef; thinking: ThinkingLevel } {
207
- const model = this.modelList.getSelectedItem();
208
- const level = this.levelList.getSelectedItem();
209
- if (!model || !level || !isThinkingLevel(level.value)) {
210
- throw new Error("Cannot confirm an empty model or thinking-level selection");
211
- }
212
- return { ref: parseRef(model.value), thinking: level.value };
213
- }
214
-
215
- render(bodyWidth: number): string[] {
216
- const { left: leftWidth, right: rightWidth } = modelThinkingPaneWidths(bodyWidth);
217
- const column = (text: string, width: number) => truncateToWidth(text, width, "", true);
218
- const paneDivider = this.theme.fg("border", "│");
219
- const headers = column(
220
- this.activePane === "model" ? this.theme.bold(this.theme.fg("accent", " Models")) : this.theme.bold(" Models"),
221
- leftWidth,
222
- ) + paneDivider + column(
223
- ` ${this.activePane === "level" ? this.theme.bold(this.theme.fg("accent", "Thinking")) : this.theme.bold("Thinking")}`,
224
- rightWidth,
225
- );
226
- const modelLines = this.modelList.render(Math.max(1, leftWidth - 1));
227
- const levelLines = this.levelList.render(Math.max(1, rightWidth - 1));
228
- const rows = Math.max(modelLines.length, levelLines.length);
229
- const lines = [
230
- ...(this.filter ? [column(this.theme.fg("muted", ` filter: ${this.filter}`), bodyWidth)] : []),
231
- headers,
232
- ];
233
- for (let index = 0; index < rows; index++) {
234
- const modelLine = modelLines[index] ?? "";
235
- const levelLine = levelLines[index] ?? "";
236
- let leftCell = column(` ${withPointer(modelLine)}`, leftWidth);
237
- let rightCell = column(` ${withPointer(levelLine)}`, rightWidth);
238
- // SelectList renders "→ " only on the selected row; other rows use " ".
239
- if (this.activePane === "model" && modelLine.includes("→")) {
240
- leftCell = this.theme.bg("selectedBg", leftCell);
241
- }
242
- if (this.activePane === "level" && levelLine.includes("→")) {
243
- rightCell = this.theme.bg("selectedBg", rightCell);
244
- }
245
- lines.push(leftCell + paneDivider + rightCell);
246
- }
247
- return lines.slice(0, this.maxVisibleRows);
248
- }
249
-
250
- renderFooter(bodyWidth: number, hints: string): { actionRow: string; hintRows: string[] } {
251
- const buttonText = (label: string, index: number) => {
252
- const isActive = this.activeButton === index;
253
- const text = isActive ? `[ ${label} ]` : `‹ ${label} ›`;
254
- return isActive && this.activePane === "buttons"
255
- ? this.theme.bold(this.theme.fg("accent", text))
256
- : this.theme.fg("muted", text);
257
- };
258
- const rows = renderMenuFooterContents(
259
- this.theme,
260
- bodyWidth,
261
- `${buttonText("Select", 0)} ${buttonText("Cancel", 1)}`,
262
- hints,
263
- { wrapHints: true },
264
- );
265
- return { actionRow: rows[1] ?? "", hintRows: rows.slice(3) };
266
- }
267
-
268
- invalidate(): void {
269
- this.modelList.invalidate();
270
- this.levelList.invalidate();
271
- }
8
+ export type { TwoPaneModelThinking } from "@underactive/pi-topping-primitives/models";
9
+
10
+ /** Keep MoA's registry fallback and saved/current thinking policy in the host. */
11
+ export function createMoaModelPicker(
12
+ tui: TUI,
13
+ theme: Theme,
14
+ refs: ModelRef[],
15
+ config: MoaConfig,
16
+ currentThinking: ThinkingLevel,
17
+ ctx: ExtensionContext,
18
+ ): TwoPaneModelThinking {
19
+ // Filter edits rebuild the level list for the same model: keep the user's
20
+ // level then, and apply the saved/current default only when the model changes.
21
+ let lastKey: string | undefined;
22
+ return new TwoPaneModelThinking({
23
+ theme,
24
+ refs,
25
+ levelOptions: registryLevelOptions(ctx.modelRegistry, { fallback: ["off"] }),
26
+ preferredLevel: (ref, options, previous) => {
27
+ const key = modelRefLabel(ref);
28
+ const keep = key === lastKey && previous !== undefined && options.includes(previous);
29
+ lastKey = key;
30
+ return keep ? previous : defaultThinkingForModel(key, config, currentThinking, options);
31
+ },
32
+ requestRender: () => tui.requestRender(),
33
+ marker: SELECTOR_POINTER,
34
+ });
272
35
  }
@@ -11,9 +11,9 @@
11
11
  import type { ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
12
12
  import { isKeyRelease, Key, matchesKey, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
13
13
  import { setTimeout as sleep } from "node:timers/promises";
14
+ import { shimmerString, type AnsiTheme } from "@underactive/pi-topping-primitives/activity-tui";
14
15
  import { formatElapsed } from "./agentStatus.ts";
15
16
  import { BRAILLE_SPINNER_FRAMES, ROUNDED_SINGLE_BOX, UI_TICK_MS, createFrame } from "./chrome.ts";
16
- import { shimmerString, type ShimmerTheme } from "./shimmer.ts";
17
17
 
18
18
  const OVERLAY_MARGIN = 1;
19
19
  /** Grace before mounting: work that settles inside this never flashes a card. */
@@ -246,7 +246,7 @@ class WorkingOverlayComponent implements Component {
246
246
 
247
247
  const spin = th.fg("accent", BRAILLE_SPINNER_FRAMES[this.spinFrame % BRAILLE_SPINNER_FRAMES.length]!);
248
248
  const detailText = th.getFgAnsi
249
- ? shimmerString(this.options.detail, Date.now() - this.startedAt, th as ShimmerTheme, "ltr", "normal")
249
+ ? shimmerString(this.options.detail, Date.now() - this.startedAt, th as AnsiTheme, "ltr", "normal")
250
250
  : th.fg("dim", this.options.detail);
251
251
  const detailRow = frame.row(`${spin} ${detailText}`);
252
252
 
@@ -1,193 +0,0 @@
1
- /**
2
- * Output-token activity meter.
3
- *
4
- * Vendored from pi-topping (`src/activity-meter.ts` for the meter/rate
5
- * tracker, `src/format.ts` for StreamingWordCounter) rather than depended on,
6
- * so the two extensions stay independently installable. Behavior is meant to
7
- * stay identical — glyph ramp, thresholds, EMA coefficient, direction and
8
- * colouring are all load-bearing for visual parity, so port changes upstream
9
- * rather than tuning them here. test/activity-meter.test.mjs mirrors
10
- * pi-topping's tests/activity-meter.test.ts to catch drift.
11
- */
12
-
13
- import type { ThemeColor } from "@earendil-works/pi-coding-agent";
14
-
15
- export const ActivityMeterLevel = {
16
- IDLE: 0,
17
- LOW: 1,
18
- MEDIUM: 2,
19
- HIGH: 3,
20
- PEAK_1: 4,
21
- PEAK_2: 5,
22
- PEAK_3: 6,
23
- FULL: 7,
24
- } as const;
25
-
26
- export type ActivityMeterLevel = (typeof ActivityMeterLevel)[keyof typeof ActivityMeterLevel];
27
-
28
- const EMA_ALPHA = 0.4;
29
- const BRAILLE: Record<ActivityMeterLevel, string> = {
30
- [ActivityMeterLevel.IDLE]: "⢀",
31
- [ActivityMeterLevel.LOW]: "⣀",
32
- [ActivityMeterLevel.MEDIUM]: "⣠",
33
- [ActivityMeterLevel.HIGH]: "⣤",
34
- [ActivityMeterLevel.PEAK_1]: "⣴",
35
- [ActivityMeterLevel.PEAK_2]: "⣶",
36
- [ActivityMeterLevel.PEAK_3]: "⣾",
37
- [ActivityMeterLevel.FULL]: "⣿",
38
- };
39
- export const ACTIVITY_METER_WIDTH = 8;
40
- type CellColorizer = (level: ActivityMeterLevel, char: string) => string;
41
-
42
- /** Convert an estimated output-token rate to a display level. */
43
- export function rateToLevel(tokensPerSecond: number): ActivityMeterLevel {
44
- const THRESHOLDS = [0, 5, 10, 15, 22, 30, 40];
45
- for (let i = THRESHOLDS.length - 1; i >= 0; i--) {
46
- if (tokensPerSecond > THRESHOLDS[i]) return (i + 1) as ActivityMeterLevel;
47
- }
48
- return 0;
49
- }
50
-
51
- /** EMA-smoothed rate tracker for a cumulative output-token estimate. */
52
- export class TokRateTracker {
53
- #lastTotal = 0;
54
- #lastTime = 0;
55
- #rate = 0;
56
- #hasSample = false;
57
- #pendingTokens = 0;
58
-
59
- sample(totalTokens: number, now: number): number {
60
- if (!this.#hasSample) {
61
- this.#lastTotal = totalTokens;
62
- this.#hasSample = true;
63
- this.#lastTime = now;
64
- return this.#rate;
65
- }
66
-
67
- const elapsedSeconds = (now - this.#lastTime) / 1_000;
68
- if (elapsedSeconds <= 0) {
69
- this.#pendingTokens += Math.max(0, totalTokens - this.#lastTotal);
70
- this.#lastTotal = totalTokens;
71
- return this.#rate;
72
- }
73
-
74
- const totalDelta = this.#pendingTokens + Math.max(0, totalTokens - this.#lastTotal);
75
- const instantRate = totalDelta / elapsedSeconds;
76
- this.#rate = EMA_ALPHA * instantRate + (1 - EMA_ALPHA) * this.#rate;
77
- this.#lastTotal = totalTokens;
78
- this.#lastTime = now;
79
- this.#pendingTokens = 0;
80
- return this.#rate;
81
- }
82
-
83
- reset(): void {
84
- this.#lastTotal = 0;
85
- this.#lastTime = 0;
86
- this.#rate = 0;
87
- this.#hasSample = false;
88
- this.#pendingTokens = 0;
89
- }
90
- }
91
-
92
- /** Eight-column scrolling activity meter, scrolling either left-to-right or right-to-left. */
93
- export class ActivityMeter {
94
- #levels: ActivityMeterLevel[] = Array<ActivityMeterLevel>(ACTIVITY_METER_WIDTH).fill(0);
95
- #direction: "ltr" | "rtl";
96
-
97
- constructor(direction: "ltr" | "rtl" = "ltr") {
98
- this.#direction = direction;
99
- }
100
-
101
- setDirection(direction: "ltr" | "rtl"): void {
102
- if (direction !== this.#direction) {
103
- this.#direction = direction;
104
- // Reverse the existing data so the visual flow instantly flips.
105
- this.#levels.reverse();
106
- }
107
- }
108
-
109
- push(level: ActivityMeterLevel): void {
110
- if (this.#direction === "rtl") {
111
- this.#levels.shift();
112
- this.#levels.push(level);
113
- } else {
114
- this.#levels.pop();
115
- this.#levels.unshift(level);
116
- }
117
- }
118
-
119
- render(colorize?: CellColorizer): string {
120
- return this.#levels
121
- .map((level) => {
122
- const char = BRAILLE[level];
123
- return colorize ? colorize(level, char) : char;
124
- })
125
- .join("");
126
- }
127
-
128
- reset(): void {
129
- this.#levels.fill(0);
130
- }
131
-
132
- /** Colorize a meter cell using the default theme mapping (dim at IDLE, `color` otherwise). */
133
- static colorizeCell(
134
- level: ActivityMeterLevel,
135
- char: string,
136
- theme: { fg: (style: ThemeColor, s: string) => string },
137
- color: ThemeColor = "accent",
138
- dimmed?: boolean,
139
- ): string {
140
- if (level === ActivityMeterLevel.IDLE) return theme.fg("dim", char);
141
- const colored = theme.fg(color, char);
142
- // ANSI dim (SGR 2) reduces color brightness; SGR 22 resets dim/bold.
143
- // This is necessary because theme.fg("dim", ...) sets a gray color instead
144
- // of applying the ANSI dim attribute, so nesting would overwrite the accent color.
145
- return dimmed ? `\x1b[2m${colored}\x1b[22m` : colored;
146
- }
147
- }
148
-
149
- /**
150
- * Counts words across a split stream without re-scanning earlier chunks: each
151
- * stream remembers whether it ended mid-word, so a word broken across two
152
- * deltas is counted once. Used as the output-token estimate for providers that
153
- * never report usage.
154
- */
155
- export class StreamingWordCounter {
156
- #inWordByStream = new Map<string, boolean>();
157
-
158
- count(text: string, stream = "default"): number {
159
- let inWord = this.#inWordByStream.get(stream) ?? false;
160
- let count = 0;
161
- for (let i = 0; i < text.length; i++) {
162
- const code = text.charCodeAt(i);
163
- if (isWhitespace(code)) {
164
- inWord = false;
165
- } else if (!inWord) {
166
- count++;
167
- inWord = true;
168
- }
169
- }
170
- this.#inWordByStream.set(stream, inWord);
171
- return count;
172
- }
173
-
174
- reset(): void {
175
- this.#inWordByStream.clear();
176
- }
177
- }
178
-
179
- function isWhitespace(code: number): boolean {
180
- return (
181
- code === 32 ||
182
- (code >= 9 && code <= 13) ||
183
- code === 160 ||
184
- code === 0x1680 ||
185
- (code >= 0x2000 && code <= 0x200a) ||
186
- code === 0x2028 ||
187
- code === 0x2029 ||
188
- code === 0x202f ||
189
- code === 0x205f ||
190
- code === 0x3000 ||
191
- code === 0xfeff
192
- );
193
- }
@@ -1,54 +0,0 @@
1
- /**
2
- * Cached view of the models pi reports as usable, with the thinking levels the
3
- * registry declares for each.
4
- *
5
- * The registry is the only authority the pickers consult: it decides both which
6
- * models may be selected and which thinking levels each one supports, so a
7
- * confirmed selection needs no further verification. Whether a model actually
8
- * answers is decided when it runs, not here.
9
- */
10
-
11
- import { getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai";
12
- import { isThinkingLevel, modelRefLabel, parseRef, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
13
-
14
- export interface ModelCatalogue {
15
- /** Selectable models, sorted by `provider/id`. */
16
- availableRefs(): ModelRef[];
17
- /** Levels the registry declares for a model, in pi's canonical order. */
18
- thinkingLevelsFor(ref: ModelRef): ThinkingLevel[];
19
- }
20
-
21
- /** The slice of pi's `ModelRegistry` a catalogue is built from. */
22
- export interface CatalogueRegistry {
23
- getAvailable(): Model<Api>[];
24
- }
25
-
26
- // Lives for as long as the registry object itself: pi hands extensions a stable
27
- // registry per session, and dropping the registry drops its catalogue with it.
28
- const CATALOGUES = new WeakMap<CatalogueRegistry, ModelCatalogue>();
29
-
30
- export function getModelCatalogue(registry: CatalogueRegistry): ModelCatalogue {
31
- const cached = CATALOGUES.get(registry);
32
- if (cached) return cached;
33
- const built = buildCatalogue(registry);
34
- CATALOGUES.set(registry, built);
35
- return built;
36
- }
37
-
38
- function buildCatalogue(registry: CatalogueRegistry): ModelCatalogue {
39
- const levels = new Map<string, ThinkingLevel[]>();
40
- for (const model of registry.getAvailable()) {
41
- const key = `${model.provider}/${model.id}`;
42
- if (levels.has(key)) continue;
43
- levels.set(key, getSupportedThinkingLevels(model).filter(isThinkingLevel));
44
- }
45
-
46
- const refs = [...levels.keys()]
47
- .sort((a, b) => a.localeCompare(b))
48
- .map((key) => parseRef(key));
49
-
50
- return {
51
- availableRefs: () => [...refs],
52
- thinkingLevelsFor: (ref) => levels.get(modelRefLabel(ref)) ?? [],
53
- };
54
- }