@hicaru/pi-rlm 0.3.8 → 0.3.13

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.
Files changed (55) hide show
  1. package/README.md +3 -4
  2. package/package.json +1 -1
  3. package/src/bridge/handlers/completion.ts +5 -0
  4. package/src/bridge/handlers/emitting.ts +33 -23
  5. package/src/bridge/handlers/index.ts +1 -1
  6. package/src/bridge/handlers/llm-query.ts +23 -24
  7. package/src/bridge/handlers/rlm-query.ts +10 -32
  8. package/src/bridge/handlers/types.ts +8 -1
  9. package/src/bridge/model.ts +33 -15
  10. package/src/commands/pins.ts +51 -0
  11. package/src/commands/rlm-config.ts +4 -88
  12. package/src/commands/rlm-llm.ts +59 -0
  13. package/src/commands/rlm-rlm.ts +58 -0
  14. package/src/commands/rlm.ts +2 -2
  15. package/src/config/defaults.ts +14 -4
  16. package/src/config/settings.ts +26 -3
  17. package/src/core/budget.ts +1 -1
  18. package/src/core/compaction.ts +4 -0
  19. package/src/core/engine.ts +21 -4
  20. package/src/core/iteration.ts +12 -0
  21. package/src/core/ledger.ts +15 -123
  22. package/src/core/memory.ts +13 -1
  23. package/src/core/model-registry.ts +1 -1
  24. package/src/core/types.ts +14 -0
  25. package/src/index.ts +53 -4
  26. package/src/mode/rlm-mode.ts +11 -1
  27. package/src/prompts/glossary.ts +11 -3
  28. package/src/prompts/native.ts +1 -1
  29. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  30. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  31. package/src/tool/repl-render.ts +4 -10
  32. package/src/tool/repl-tool.ts +30 -17
  33. package/src/tool/rlm-aggregator.ts +16 -3
  34. package/src/tool/rlm-details.ts +8 -0
  35. package/src/tool/rlm-events.ts +17 -1
  36. package/src/tool/rlm-tool.ts +25 -14
  37. package/src/tool/subcall-render.ts +14 -129
  38. package/src/tool/subcall-store.ts +11 -1
  39. package/src/ui/intro.ts +13 -4
  40. package/src/ui/modal/agent-modal.ts +104 -0
  41. package/src/ui/modal/modal-view.ts +132 -0
  42. package/src/ui/modal/timeline-store.ts +85 -0
  43. package/src/ui/model-picker/drilldown.ts +173 -0
  44. package/src/ui/model-picker/grouping.ts +81 -0
  45. package/src/ui/model-picker/levels.ts +63 -0
  46. package/src/ui/model-picker.ts +7 -197
  47. package/src/ui/panel/run-registry.ts +135 -0
  48. package/src/ui/panel/tree-panel.ts +46 -0
  49. package/src/ui/status.ts +26 -13
  50. package/src/ui/theme.ts +0 -4
  51. package/src/ui/tree/tree-model.ts +226 -0
  52. package/src/ui/tree/tree-rows.ts +74 -0
  53. package/src/ui/tree/tree-widget.ts +186 -0
  54. package/src/util/retry.ts +180 -0
  55. package/src/util/throttle.ts +90 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * drilldown — the grouped model picker: provider → model → thinking level.
3
+ *
4
+ * Three sequential pi overlays, one per level; SelectList has no native groups,
5
+ * so grouping is navigation instead of headers. Every level gets a "← back"
6
+ * row; esc cancels the whole flow. Level 3 (thinking level) is skipped for
7
+ * models that support none.
8
+ *
9
+ * Roles differ only in the level-1 sentinel row:
10
+ * llm → "(cheapest, auto)" — always the cheapest configured model
11
+ * rlm → "(follow session model)" — child engines track pi's active model
12
+ */
13
+
14
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
16
+ import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
17
+ import { Container, type Component, type SelectItem, SelectList, Text, truncateToWidth } from "@earendil-works/pi-tui";
18
+ import { CHEAPEST_VALUE, SESSION_VALUE, buildCatalog, modelRefOf, type ProviderGroup } from "./grouping.ts";
19
+ import { selectThinkingLevel, supportedThinkingLevels } from "./levels.ts";
20
+
21
+ export type PickerRole = "llm" | "rlm";
22
+
23
+ export interface ModelSelection {
24
+ readonly model: Model<Api>;
25
+ readonly thinkingLevel?: ThinkingLevel;
26
+ }
27
+
28
+ const BACK_VALUE = "__rlm_back__";
29
+ const MAX_VISIBLE = 13;
30
+
31
+ const TOP_OPTIONS: Readonly<Record<PickerRole, SelectItem>> = Object.freeze({
32
+ llm: { value: CHEAPEST_VALUE, label: "⟳ cheapest (auto)", description: "Always use the cheapest model with a configured key" },
33
+ rlm: { value: SESSION_VALUE, label: "⌁ follow session model", description: "rlm_query / rlm_batch child engines use pi's active model" },
34
+ });
35
+
36
+ /** One overlay: a titled, filterable SelectList. Resolves value, or undefined on esc. */
37
+ async function pickFromList(
38
+ ctx: ExtensionContext,
39
+ title: string,
40
+ items: readonly SelectItem[],
41
+ initialIndex: number,
42
+ ): Promise<string | undefined> {
43
+ const chosen = await ctx.ui.custom<string | null>((_tui, theme, _kb, done) => {
44
+ let query = "";
45
+ const container = new Container();
46
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
47
+ container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
48
+ const filterLine: Component = {
49
+ render: (w) => [truncateToWidth(theme.fg("dim", `Filter: ${query || "type to filter…"}`), w)],
50
+ invalidate: () => {},
51
+ };
52
+ const list = new SelectList([...items], Math.min(items.length, MAX_VISIBLE), {
53
+ selectedPrefix: (t) => theme.fg("accent", t),
54
+ selectedText: (t) => theme.fg("accent", t),
55
+ description: (t) => theme.fg("muted", t),
56
+ scrollInfo: (t) => theme.fg("dim", t),
57
+ noMatch: (t) => theme.fg("warning", t),
58
+ });
59
+ if (initialIndex > 0) list.setSelectedIndex(initialIndex);
60
+ const isFilterText = (s: string): boolean =>
61
+ s.length > 0 && [...s].every((char) => char >= " " && char !== "\x7f");
62
+ const isBackspace = (s: string): boolean => s === "\x7f" || s === "\b";
63
+ list.onSelect = (item) => done(item.value);
64
+ list.onCancel = () => done(null);
65
+ container.addChild(filterLine);
66
+ container.addChild(list);
67
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • type to filter • enter select • esc cancel"), 1, 0));
68
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
69
+ return {
70
+ render: (w) => container.render(w),
71
+ invalidate: () => container.invalidate(),
72
+ handleInput: (data) => {
73
+ if (isFilterText(data)) {
74
+ query = `${query}${data.replace(/ /g, "")}`;
75
+ list.setFilter(query);
76
+ return;
77
+ }
78
+ if (isBackspace(data)) {
79
+ query = query.slice(0, -1);
80
+ list.setFilter(query);
81
+ return;
82
+ }
83
+ list.handleInput(data);
84
+ },
85
+ };
86
+ });
87
+ return chosen === null ? undefined : chosen;
88
+ }
89
+
90
+ function providerItems(role: PickerRole, catalog: readonly ProviderGroup[]): SelectItem[] {
91
+ const items: SelectItem[] = [TOP_OPTIONS[role]];
92
+ for (const g of catalog) items.push({ value: g.provider, label: g.provider, description: `${g.models.length} models` });
93
+ return items;
94
+ }
95
+
96
+ function modelItems(group: ProviderGroup): SelectItem[] {
97
+ const items: SelectItem[] = [{ value: BACK_VALUE, label: "← providers", description: "" }];
98
+ for (const m of group.models) {
99
+ items.push({ value: modelRefOf(m), label: m.id, description: m.reasoning ? "reasoning" : "" });
100
+ }
101
+ return items;
102
+ }
103
+
104
+ function levelItems(group: ProviderGroup, model: Model<Api>): SelectItem[] {
105
+ const items: SelectItem[] = [{ value: BACK_VALUE, label: `← ${group.provider} models`, description: "" }];
106
+ for (const level of supportedThinkingLevels(model)) {
107
+ items.push({ value: level, label: level, description: `Use ${level} reasoning` });
108
+ }
109
+ return items;
110
+ }
111
+
112
+ /** Show the grouped picker; resolves ModelSelection, null = role's top option, undefined = cancel. */
113
+ export async function selectModel(
114
+ ctx: ExtensionContext,
115
+ role: PickerRole,
116
+ models: readonly Model<Api>[],
117
+ current?: Model<Api>,
118
+ currentThinking?: ThinkingLevel,
119
+ currentRef?: string,
120
+ ): Promise<ModelSelection | null | undefined> {
121
+ if (models.length === 0) {
122
+ ctx.ui.notify("RLM: no models available (add a provider key in Pi, or widen --models / enabledModels)", "warning");
123
+ return undefined;
124
+ }
125
+ if (ctx.mode !== "tui") {
126
+ const fallback = models[0];
127
+ if (fallback === undefined) return undefined;
128
+ // Prefer an explicit pin (resolved model or saved ref) over "first = cheapest".
129
+ const fromRef = currentRef ? models.find((m) => modelRefOf(m) === currentRef) : undefined;
130
+ const model = current ?? fromRef ?? fallback;
131
+ return { model, thinkingLevel: await selectThinkingLevel(ctx, model, currentThinking) };
132
+ }
133
+
134
+ const catalog = buildCatalog(models);
135
+ const currentRefStr = current ? modelRefOf(current) : currentRef;
136
+ const providerOf = (ref: string | undefined): string | undefined =>
137
+ ref === undefined ? undefined : catalog.find((g) => g.models.some((m) => modelRefOf(m) === ref))?.provider;
138
+
139
+ let group: ProviderGroup | undefined;
140
+ let model: Model<Api> | undefined;
141
+ // Drill-down with real back navigation: ← returns one level, esc cancels all.
142
+ for (;;) {
143
+ if (group === undefined) {
144
+ const items = providerItems(role, catalog);
145
+ const pre = providerOf(currentRefStr);
146
+ const l1 = await pickFromList(ctx, role === "llm" ? "LLM model — provider" : "RLM model — provider", items,
147
+ pre === undefined ? 0 : Math.max(0, items.findIndex((i) => i.value === pre)));
148
+ if (l1 === undefined) return undefined; // esc — cancel
149
+ if (l1 === CHEAPEST_VALUE || l1 === SESSION_VALUE) return null; // role's top option
150
+ group = catalog.find((g) => g.provider === l1);
151
+ if (group === undefined) return undefined;
152
+ continue;
153
+ }
154
+ if (model === undefined) {
155
+ const mItems = modelItems(group);
156
+ const l2 = await pickFromList(ctx, `${group.provider} › models`, mItems,
157
+ Math.max(0, mItems.findIndex((i) => i.value === currentRefStr)));
158
+ if (l2 === undefined) return undefined;
159
+ if (l2 === BACK_VALUE) { group = undefined; continue; } // ← providers
160
+ const picked = group.models.find((m) => modelRefOf(m) === l2);
161
+ if (picked === undefined) return undefined;
162
+ model = picked;
163
+ if (supportedThinkingLevels(model).length === 0) return { model, thinkingLevel: undefined };
164
+ continue;
165
+ }
166
+ const lvItems = levelItems(group, model);
167
+ const l3 = await pickFromList(ctx, `${group.provider} › ${model.id}`, lvItems,
168
+ Math.max(0, lvItems.findIndex((i) => i.value === currentThinking)));
169
+ if (l3 === undefined) return undefined;
170
+ if (l3 === BACK_VALUE) { model = undefined; continue; } // ← models
171
+ return { model, thinkingLevel: l3 === "off" ? undefined : (l3 as ThinkingLevel) };
172
+ }
173
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * grouping — pure model-catalog grouping for the drill-down picker.
3
+ *
4
+ * Level 1: providers (with model counts).
5
+ * Level 2: models of one provider (label = full id, vendor prefix included —
6
+ * the id IS the user-facing name on openrouter et al).
7
+ * Level 3: thinking levels ("variant") — owned by levels.ts, not here.
8
+ *
9
+ * No TUI imports; everything here is trivially unit-testable.
10
+ */
11
+
12
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
13
+ import type { Api, Model } from "@earendil-works/pi-ai";
14
+ import { compareLlm } from "../../mode/llm-model.ts";
15
+
16
+ /** Sentinel SelectList value for "always use cheapest available" (llm role). */
17
+ export const CHEAPEST_VALUE = "__rlm_cheapest__";
18
+ /** Sentinel SelectList value for "follow pi's session model" (rlm role). */
19
+ export const SESSION_VALUE = "__rlm_session__";
20
+
21
+ export interface ProviderGroup {
22
+ readonly provider: string;
23
+ readonly models: readonly Model<Api>[];
24
+ }
25
+
26
+ /**
27
+ * Models Pi itself would offer for this session, cheapest-first.
28
+ *
29
+ * Mirrors the built-in model switcher:
30
+ * - if the session has scoped models (`--models` / enabledModels) → those only
31
+ * - else → `getAvailable()` (providers with configured auth)
32
+ *
33
+ * Deliberately NOT `getAll()`: the full catalog dumps every provider's catalog entry and is
34
+ * not what the user sees in Pi natively. See Pi extension docs on `ctx.scopedModels`.
35
+ */
36
+ export function pickableModels(
37
+ registry: ModelRegistry,
38
+ scoped?: readonly { readonly model: Model<Api> }[],
39
+ ): readonly Model<Api>[] {
40
+ const source = scoped !== undefined && scoped.length > 0
41
+ ? scoped.map((s) => s.model)
42
+ : registry.getAvailable();
43
+ return [...source].sort(compareLlm);
44
+ }
45
+
46
+ /** Group models by provider, providers alphabetical, models cheapest-first inside. */
47
+ export function buildCatalog(models: readonly Model<Api>[]): readonly ProviderGroup[] {
48
+ const groups = new Map<string, Model<Api>[]>();
49
+ for (const m of models) {
50
+ const list = groups.get(m.provider);
51
+ if (list === undefined) groups.set(m.provider, [m]);
52
+ else list.push(m);
53
+ }
54
+ const out: ProviderGroup[] = [];
55
+ for (const [provider, list] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
56
+ out.push({ provider, models: [...list].sort(compareLlm) });
57
+ }
58
+ return out;
59
+ }
60
+
61
+ export const modelRefOf = (m: Model<Api>): string => `${m.provider}/${m.id}`;
62
+
63
+ /**
64
+ * Index to pre-select in the flat model list (with cheapest row at 0 when included).
65
+ * Without this, the list always opens on "cheapest (auto)" and Enter silently unpins.
66
+ */
67
+ export function initialModelPickerIndex(
68
+ models: readonly Model<Api>[],
69
+ current?: Model<Api>,
70
+ currentRef?: string,
71
+ includeCheapest = true,
72
+ ): number {
73
+ const offset = includeCheapest ? 1 : 0;
74
+ const ref = current ? modelRefOf(current) : currentRef;
75
+ if (!ref) return 0;
76
+ const idx = models.findIndex((m) => modelRefOf(m) === ref);
77
+ // When a saved ref exists but the model is absent from the current catalog
78
+ // (e.g. provider not refreshed yet), pre-select the first real model — NOT
79
+ // "cheapest", which would silently unpin on save.
80
+ return idx >= 0 ? idx + offset : Math.min(offset, models.length);
81
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * levels — the "variant" level of the drill-down: a model's thinking levels.
3
+ *
4
+ * Extracted verbatim from the pre-grouping picker so the non-tui fallback and
5
+ * the level list stay exactly as tested.
6
+ */
7
+
8
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
10
+ import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
11
+ import { Container, SelectList, Text } from "@earendil-works/pi-tui";
12
+
13
+ const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
14
+ export type SelectableThinkingLevel = (typeof LEVELS)[number];
15
+
16
+ /** Levels the model actually supports, in canonical order. */
17
+ export function supportedThinkingLevels(model: Model<Api>): readonly SelectableThinkingLevel[] {
18
+ const map = model.thinkingLevelMap;
19
+ if (map === undefined) return [];
20
+ const supported: SelectableThinkingLevel[] = [];
21
+ for (const level of LEVELS) if (level in map) supported.push(level);
22
+ return supported;
23
+ }
24
+
25
+ /** Ask the thinking level for a model (skipped entirely when unsupported). */
26
+ export async function selectThinkingLevel(
27
+ ctx: ExtensionContext,
28
+ model: Model<Api>,
29
+ current?: ThinkingLevel,
30
+ ): Promise<ThinkingLevel | undefined> {
31
+ const levels = supportedThinkingLevels(model);
32
+ if (levels.length === 0) return undefined;
33
+ if (ctx.mode !== "tui") {
34
+ const level = current ?? levels[0];
35
+ return level === "off" ? undefined : level;
36
+ }
37
+
38
+ const chosen = await ctx.ui.custom<SelectableThinkingLevel | null>((_tui, theme, _kb, done) => {
39
+ const container = new Container();
40
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
41
+ container.addChild(new Text(theme.fg("accent", theme.bold("Thinking level")), 1, 0));
42
+ const list = new SelectList(
43
+ levels.map((level) => ({ value: level, label: level, description: `Use ${level} reasoning for ${model.id}` })),
44
+ levels.length,
45
+ {
46
+ selectedPrefix: (t) => theme.fg("accent", t),
47
+ selectedText: (t) => theme.fg("accent", t),
48
+ description: (t) => theme.fg("muted", t),
49
+ scrollInfo: (t) => theme.fg("dim", t),
50
+ noMatch: (t) => theme.fg("warning", t),
51
+ },
52
+ );
53
+ const initial = levels.indexOf(current ?? "off");
54
+ if (initial >= 0) list.setSelectedIndex(initial);
55
+ list.onSelect = (item) => done(item.value as SelectableThinkingLevel);
56
+ list.onCancel = () => done(null);
57
+ container.addChild(list);
58
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc skip"), 1, 0));
59
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
60
+ return { render: (w) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data) => list.handleInput(data) };
61
+ });
62
+ return chosen === "off" ? undefined : (chosen ?? undefined);
63
+ }
@@ -1,201 +1,11 @@
1
- /** Model picker TUI — choose a model and, when supported, a thinking level. */
2
-
3
- import type { ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
4
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
5
- import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
6
- import { Container, type Component, type SelectItem, SelectList, Text, truncateToWidth } from "@earendil-works/pi-tui";
7
- import { formatCost } from "./theme.ts";
8
- import { compareLlm } from "../mode/llm-model.ts";
9
-
10
- export interface ModelSelection {
11
- readonly model: Model<Api>;
12
- readonly thinkingLevel?: ThinkingLevel;
13
- }
14
-
15
- const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
16
- type SelectableThinkingLevel = (typeof LEVELS)[number];
17
-
18
- /** Sentinel SelectList value for "always use cheapest available". */
19
- export const CHEAPEST_VALUE = "__rlm_cheapest__";
20
-
21
1
  /**
22
- * Models Pi itself would offer for this session, cheapest-first.
23
- *
24
- * Mirrors the built-in model switcher:
25
- * - if the session has scoped models (`--models` / enabledModels) → those only
26
- * - else → `getAvailable()` (providers with configured auth)
2
+ * Model picker facade grouped drill-down: provider model → thinking level.
27
3
  *
28
- * Deliberately NOT `getAll()`: the full catalog dumps every provider's catalog entry and is
29
- * not what the user sees in Pi natively. See Pi extension docs on `ctx.scopedModels`.
4
+ * The implementation is split under model-picker/ (grouping = pure catalog,
5
+ * levels = thinking-level step, drilldown = the overlay flow); this module is
6
+ * the stable import path everything else (commands, tests) talks to.
30
7
  */
31
- export function pickableModels(
32
- registry: ModelRegistry,
33
- scoped?: readonly { readonly model: Model<Api> }[],
34
- ): readonly Model<Api>[] {
35
- const source = scoped !== undefined && scoped.length > 0
36
- ? scoped.map((s) => s.model)
37
- : registry.getAvailable();
38
- return [...source].sort(compareLlm);
39
- }
40
-
41
- function items(models: readonly Model<Api>[], includeCheapest: boolean): SelectItem[] {
42
- const modelItems = models.map((m) => {
43
- const price = `in ${formatCost(m.cost.input)}/Mtok · out ${formatCost(m.cost.output)}/Mtok`;
44
- return {
45
- value: `${m.provider}/${m.id}`,
46
- label: `${m.provider}/${m.id}`,
47
- description: `${price}${m.reasoning ? " · reasoning" : ""}`,
48
- };
49
- });
50
- if (!includeCheapest) return modelItems;
51
- return [
52
- { value: CHEAPEST_VALUE, label: "⟳ cheapest (auto)", description: "Always use the cheapest model with a configured key" },
53
- ...modelItems,
54
- ];
55
- }
56
-
57
- /**
58
- * Index to pre-select in the model list (with cheapest row at 0 when included).
59
- * Without this, the list always opens on "cheapest (auto)" and Enter silently unpins.
60
- */
61
- export function initialModelPickerIndex(
62
- models: readonly Model<Api>[],
63
- current?: Model<Api>,
64
- currentRef?: string,
65
- includeCheapest = true,
66
- ): number {
67
- const offset = includeCheapest ? 1 : 0;
68
- const ref = current ? `${current.provider}/${current.id}` : currentRef;
69
- if (!ref) return 0;
70
- const idx = models.findIndex((m) => `${m.provider}/${m.id}` === ref);
71
- // When a saved ref exists but the model is absent from the current catalog
72
- // (e.g. provider not refreshed yet), pre-select the first real model — NOT
73
- // "cheapest auto". Accidentally hitting Enter on cheapest would wipe the pin
74
- // silently (Root Cause #4, v0.3.2).
75
- if (idx < 0) return ref ? offset : 0;
76
- return idx + offset;
77
- }
78
-
79
- function supportedThinkingLevels(model: Model<Api>): SelectableThinkingLevel[] {
80
- if (!model.reasoning) return [];
81
- const map = model.thinkingLevelMap;
82
- if (!map) return [...LEVELS];
83
- return LEVELS.filter((level) => map[level] !== null);
84
- }
85
-
86
- async function selectThinkingLevel(
87
- ctx: ExtensionContext,
88
- model: Model<Api>,
89
- current?: ThinkingLevel,
90
- ): Promise<ThinkingLevel | undefined> {
91
- const levels = supportedThinkingLevels(model);
92
- if (levels.length === 0) return undefined;
93
- if (ctx.mode !== "tui") {
94
- const level = current ?? levels[0];
95
- return level === "off" ? undefined : level;
96
- }
97
-
98
- const chosen = await ctx.ui.custom<SelectableThinkingLevel | null>((_tui, theme, _kb, done) => {
99
- const container = new Container();
100
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
101
- container.addChild(new Text(theme.fg("accent", theme.bold("Thinking level")), 1, 0));
102
- const list = new SelectList(
103
- levels.map((level) => ({ value: level, label: level, description: `Use ${level} reasoning for ${model.id}` })),
104
- levels.length,
105
- {
106
- selectedPrefix: (t) => theme.fg("accent", t),
107
- selectedText: (t) => theme.fg("accent", t),
108
- description: (t) => theme.fg("muted", t),
109
- scrollInfo: (t) => theme.fg("dim", t),
110
- noMatch: (t) => theme.fg("warning", t),
111
- },
112
- );
113
- const initial = levels.indexOf(current ?? "off");
114
- if (initial >= 0) list.setSelectedIndex(initial);
115
- list.onSelect = (item) => done(item.value as SelectableThinkingLevel);
116
- list.onCancel = () => done(null);
117
- container.addChild(list);
118
- container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc skip"), 1, 0));
119
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
120
- return { render: (w) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data) => list.handleInput(data) };
121
- });
122
- return chosen === "off" ? undefined : (chosen ?? undefined);
123
- }
124
-
125
- /** Show a model selector; resolves to the chosen model plus optional thinking level. */
126
- export async function selectModel(
127
- ctx: ExtensionContext,
128
- title: string,
129
- models: readonly Model<Api>[],
130
- current?: Model<Api>,
131
- currentThinking?: ThinkingLevel,
132
- currentRef?: string,
133
- ): Promise<ModelSelection | null | undefined> {
134
- if (models.length === 0) {
135
- ctx.ui.notify("RLM: no models available (add a provider key in Pi, or widen --models / enabledModels)", "warning");
136
- return undefined;
137
- }
138
- if (ctx.mode !== "tui") {
139
- const fallback = models[0];
140
- if (!fallback) return undefined;
141
- // Prefer an explicit pin (resolved model or saved ref) over "first = cheapest".
142
- const fromRef = currentRef
143
- ? models.find((m) => `${m.provider}/${m.id}` === currentRef)
144
- : undefined;
145
- const model = current ?? fromRef ?? fallback;
146
- return { model, thinkingLevel: await selectThinkingLevel(ctx, model, currentThinking) };
147
- }
148
-
149
- const chosen = await ctx.ui.custom<string | null>((_tui, theme, _kb, done) => {
150
- let query = "";
151
- const container = new Container();
152
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
153
- container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
154
- const filterLine: Component = {
155
- render: (w) => [truncateToWidth(theme.fg("dim", `Filter: ${query || "type to filter…"}`), w)],
156
- invalidate: () => {},
157
- };
158
- const list = new SelectList(items(models, true), Math.min(models.length + 1, 13), {
159
- selectedPrefix: (t) => theme.fg("accent", t),
160
- selectedText: (t) => theme.fg("accent", t),
161
- description: (t) => theme.fg("muted", t),
162
- scrollInfo: (t) => theme.fg("dim", t),
163
- noMatch: (t) => theme.fg("warning", t),
164
- });
165
- const initial = initialModelPickerIndex(models, current, currentRef, true);
166
- if (initial > 0) list.setSelectedIndex(initial);
167
- const isFilterText = (s: string): boolean => {
168
- const sanitized = s.replace(/ /g, "");
169
- return sanitized.length > 0 && Array.from(sanitized).every((char) => char >= " " && char !== "\x7f");
170
- };
171
- const isBackspace = (s: string): boolean => s === "\x7f" || s === "\b";
172
- list.onSelect = (item) => done(item.value);
173
- list.onCancel = () => done(null);
174
- container.addChild(filterLine);
175
- container.addChild(list);
176
- container.addChild(new Text(theme.fg("dim", "↑↓ navigate • type to filter • enter select • esc cancel"), 1, 0));
177
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
178
- return {
179
- render: (w) => container.render(w),
180
- invalidate: () => container.invalidate(),
181
- handleInput: (data) => {
182
- if (isFilterText(data)) {
183
- query = `${query}${data.replace(/ /g, "")}`;
184
- list.setFilter(query);
185
- return;
186
- }
187
- if (isBackspace(data)) {
188
- query = query.slice(0, -1);
189
- list.setFilter(query);
190
- return;
191
- }
192
- list.handleInput(data);
193
- },
194
- };
195
- });
196
8
 
197
- if (chosen === CHEAPEST_VALUE) return null;
198
- const model = chosen ? models.find((m) => `${m.provider}/${m.id}` === chosen) : undefined;
199
- if (!model) return undefined;
200
- return { model, thinkingLevel: await selectThinkingLevel(ctx, model, currentThinking) };
201
- }
9
+ export { CHEAPEST_VALUE, SESSION_VALUE, buildCatalog, initialModelPickerIndex, pickableModels, modelRefOf, type ProviderGroup } from "./model-picker/grouping.ts";
10
+ export { selectThinkingLevel, supportedThinkingLevels, type SelectableThinkingLevel } from "./model-picker/levels.ts";
11
+ export { selectModel, type ModelSelection, type PickerRole } from "./model-picker/drilldown.ts";
@@ -0,0 +1,135 @@
1
+ /**
2
+ * RunRegistry — session-scoped index of every live RLM surface (repl cells,
3
+ * rlm tool runs, detached background work) for the tree widget and agent modal.
4
+ *
5
+ * Sources are registered as ACCESSORS (subcalls/totals) so any owner — a
6
+ * per-call SubcallStore, BackgroundTasks' live view — fits without coupling.
7
+ * The registry fans every emitter's change events out to widget/modal listeners
8
+ * and builds immutable RunSnapshots on demand. register() returns an unregister
9
+ * function; callers invoke it in finally so a failed run never leaks.
10
+ */
11
+
12
+ import type { RlmEmitter } from "../../tool/rlm-events.ts";
13
+ import type { RlmRunStatus, RlmSubcall, SubcallPhase } from "../../tool/rlm-details.ts";
14
+ import type { RunSnapshot } from "../tree/tree-model.ts";
15
+ import { TimelineStore } from "../modal/timeline-store.ts";
16
+
17
+ export interface RunRegistration {
18
+ readonly runId: string;
19
+ /** Root row label — prompt or code preview. */
20
+ readonly label: string;
21
+ readonly emitter: RlmEmitter;
22
+ readonly subcalls: () => readonly RlmSubcall[];
23
+ readonly totals: () => { readonly costUsd: number; readonly tokens: number };
24
+ /** Live root state; defaults: running, no phase, no turns. */
25
+ readonly rootStatus?: () => RlmRunStatus;
26
+ readonly rootPhase?: () => SubcallPhase | undefined;
27
+ readonly turns?: () => { readonly current: number; readonly max: number };
28
+ /** Root's OWN model ("provider/id") — the row never shows a blended/absent model. */
29
+ readonly rootModel?: () => string | undefined;
30
+ /** Root's OWN spend (driver-model turns) — never a subtree sum across models. */
31
+ readonly rootTokens?: () => number;
32
+ /** Persistent entries (background work) stay hidden until they hold subcalls. */
33
+ readonly hideWhenEmpty?: boolean;
34
+ }
35
+
36
+ export interface RunEntry {
37
+ readonly runId: string;
38
+ readonly label: string;
39
+ readonly timeline: TimelineStore;
40
+ readonly subcalls: () => readonly RlmSubcall[];
41
+ readonly totals: () => { readonly costUsd: number; readonly tokens: number };
42
+ readonly rootStatus: () => RlmRunStatus;
43
+ readonly rootPhase: () => SubcallPhase | undefined;
44
+ readonly turns: () => { readonly current: number; readonly max: number };
45
+ readonly rootModel: () => string | undefined;
46
+ readonly rootTokens: () => number;
47
+ readonly hideWhenEmpty: boolean;
48
+ }
49
+
50
+ const DEFAULT_TURNS = Object.freeze({ current: 0, max: 0 });
51
+
52
+ export class RunRegistry {
53
+ private readonly entries = new Map<string, RunEntry>();
54
+ private readonly listeners: (() => void)[] = [];
55
+
56
+ /** Register a live run. Returns an idempotent unregister — call it in finally. */
57
+ register(run: RunRegistration): () => void {
58
+ const entry: RunEntry = {
59
+ runId: run.runId,
60
+ label: run.label,
61
+ timeline: new TimelineStore(run.emitter, run.runId),
62
+ subcalls: run.subcalls,
63
+ totals: run.totals,
64
+ rootStatus: run.rootStatus ?? (() => "running"),
65
+ rootPhase: run.rootPhase ?? (() => undefined),
66
+ turns: run.turns ?? (() => DEFAULT_TURNS),
67
+ rootModel: run.rootModel ?? (() => undefined),
68
+ rootTokens: run.rootTokens ?? (() => 0),
69
+ hideWhenEmpty: run.hideWhenEmpty ?? false,
70
+ };
71
+ this.entries.set(run.runId, entry);
72
+ const unsubs = [
73
+ run.emitter.onSubcallCreated(() => this.notify()),
74
+ run.emitter.onSubcallUpdated(() => this.notify()),
75
+ run.emitter.onRootPhase(() => this.notify()),
76
+ run.emitter.onStatus(() => this.notify()),
77
+ run.emitter.onTurn(() => this.notify()),
78
+ ];
79
+ this.notify();
80
+
81
+ let disposed = false;
82
+ return () => {
83
+ if (disposed) return;
84
+ disposed = true;
85
+ for (const unsub of unsubs) unsub();
86
+ entry.timeline.dispose();
87
+ this.entries.delete(run.runId);
88
+ this.notify();
89
+ };
90
+ }
91
+
92
+ /** Subscribe to any change in any registered run. Returns unsubscribe. */
93
+ onChange(listener: () => void): () => void {
94
+ this.listeners.push(listener);
95
+ return () => {
96
+ const at = this.listeners.indexOf(listener);
97
+ if (at >= 0) this.listeners.splice(at, 1);
98
+ };
99
+ }
100
+
101
+ hasActive(): boolean {
102
+ for (const entry of this.entries.values()) if (this.isVisible(entry)) return true;
103
+ return false;
104
+ }
105
+
106
+ find(runId: string): RunEntry | undefined {
107
+ return this.entries.get(runId);
108
+ }
109
+
110
+ /** Immutable view of every visible live run, registration order. */
111
+ snapshots(): readonly RunSnapshot[] {
112
+ const out: RunSnapshot[] = [];
113
+ for (const entry of this.entries.values()) {
114
+ if (!this.isVisible(entry)) continue;
115
+ out.push({
116
+ runId: entry.runId,
117
+ rootLabel: entry.label,
118
+ status: entry.rootStatus(),
119
+ rootPhase: entry.rootPhase(),
120
+ rootModel: entry.rootModel(),
121
+ rootTokens: entry.rootTokens(),
122
+ subcalls: entry.subcalls(),
123
+ });
124
+ }
125
+ return Object.freeze(out);
126
+ }
127
+
128
+ private isVisible(entry: RunEntry): boolean {
129
+ return !entry.hideWhenEmpty || entry.subcalls().length > 0;
130
+ }
131
+
132
+ private notify(): void {
133
+ for (const listener of this.listeners) listener();
134
+ }
135
+ }