apex-code 0.2.0 → 0.3.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -1
  3. package/dist/core/export-html/tool-renderer.d.ts.map +1 -1
  4. package/dist/core/export-html/tool-renderer.js +3 -0
  5. package/dist/core/export-html/tool-renderer.js.map +1 -1
  6. package/dist/core/extensions/types.d.ts +7 -0
  7. package/dist/core/extensions/types.d.ts.map +1 -1
  8. package/dist/core/extensions/types.js.map +1 -1
  9. package/dist/core/keybindings.d.ts +1 -1
  10. package/dist/core/keybindings.d.ts.map +1 -1
  11. package/dist/core/keybindings.js +1 -1
  12. package/dist/core/keybindings.js.map +1 -1
  13. package/dist/core/tools/bash.d.ts +6 -0
  14. package/dist/core/tools/bash.d.ts.map +1 -1
  15. package/dist/core/tools/bash.js +71 -24
  16. package/dist/core/tools/bash.js.map +1 -1
  17. package/dist/core/tools/edit.d.ts.map +1 -1
  18. package/dist/core/tools/edit.js +47 -7
  19. package/dist/core/tools/edit.js.map +1 -1
  20. package/dist/core/tools/shell-operation.d.ts +34 -0
  21. package/dist/core/tools/shell-operation.d.ts.map +1 -0
  22. package/dist/core/tools/shell-operation.js +69 -0
  23. package/dist/core/tools/shell-operation.js.map +1 -0
  24. package/dist/modes/interactive/components/keybinding-hints.d.ts +7 -1
  25. package/dist/modes/interactive/components/keybinding-hints.d.ts.map +1 -1
  26. package/dist/modes/interactive/components/keybinding-hints.js +3 -2
  27. package/dist/modes/interactive/components/keybinding-hints.js.map +1 -1
  28. package/dist/modes/interactive/components/model-row.d.ts +84 -0
  29. package/dist/modes/interactive/components/model-row.d.ts.map +1 -0
  30. package/dist/modes/interactive/components/model-row.js +206 -0
  31. package/dist/modes/interactive/components/model-row.js.map +1 -0
  32. package/dist/modes/interactive/components/model-selector.d.ts +39 -4
  33. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  34. package/dist/modes/interactive/components/model-selector.js +158 -27
  35. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  36. package/dist/modes/interactive/components/tool-execution.d.ts +2 -0
  37. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  38. package/dist/modes/interactive/components/tool-execution.js +8 -0
  39. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  40. package/dist/modes/interactive/interactive-mode.d.ts +53 -1
  41. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  42. package/dist/modes/interactive/interactive-mode.js +99 -26
  43. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  44. package/dist/modes/interactive/theme/theme.d.ts +16 -0
  45. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  46. package/dist/modes/interactive/theme/theme.js +19 -1
  47. package/dist/modes/interactive/theme/theme.js.map +1 -1
  48. package/npm-shrinkwrap.json +5 -5
  49. package/package.json +2 -2
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Row rendering for the model step of the model picker.
3
+ *
4
+ * The effort-cluster placement and the price panel are adapted from Prime Agent
5
+ * (https://github.com/PrimeIntellect-ai/prime-agent), a separate Pi-derived fork
6
+ * under the MIT License. ADR 0002 permits that for MIT prior art; NOTICE records
7
+ * the attribution. Apex Code's own panel chrome, cursor, and theme tokens are
8
+ * kept, so this is the row and nothing above it.
9
+ */
10
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
+ import { theme } from "../theme/theme.js";
12
+ /** Columns the cursor cell occupies at the head of every row. */
13
+ const CURSOR_WIDTH = 2;
14
+ const NAME_COLUMN_MAX = 30;
15
+ const NAME_COLUMN_MIN = 12;
16
+ /** `← ` + squares + ` → ` around the cluster, plus at least one column of gap before it. */
17
+ const CLUSTER_CHROME = 5;
18
+ const CLUSTER_CHROME_AND_GAP = CLUSTER_CHROME + 1;
19
+ const PRICE_UNIT_TEXT = "$ / 1M tokens";
20
+ /** A wide price column must still fit the longest label, "Cached input". */
21
+ const PRICE_COLUMN_MIN_WIDTH = 13;
22
+ /**
23
+ * Past this the three prices stop reading as one group and become three lonely
24
+ * numbers spread across the terminal, so the block stays put and the row ends early.
25
+ */
26
+ const PRICE_COLUMN_MAX_WIDTH = 18;
27
+ const PRICE_WIDE_LAYOUT_MIN_WIDTH = 58;
28
+ const THINKING_COLORS = {
29
+ off: "thinkingOff",
30
+ minimal: "thinkingMinimal",
31
+ low: "thinkingLow",
32
+ medium: "thinkingMedium",
33
+ high: "thinkingHigh",
34
+ xhigh: "thinkingXhigh",
35
+ max: "thinkingMax",
36
+ };
37
+ export const NO_EFFORT_CLUSTER = {
38
+ nameColumn: 0,
39
+ squareSlots: 0,
40
+ gap: 0,
41
+ labelWidth: 0,
42
+ showLabel: false,
43
+ showCluster: false,
44
+ };
45
+ /** Drop the least valuable segments until the cluster fits, preserving reading order. */
46
+ function reduceTrailingSegments(segments, budget) {
47
+ let current = segments.filter((segment) => visibleWidth(segment.text) > 0);
48
+ while (current.length > 1 && visibleWidth(joinSegments(current)) > budget) {
49
+ let weakest = 0;
50
+ for (let i = 1; i < current.length; i++) {
51
+ if ((current[i]?.priority ?? 0) < (current[weakest]?.priority ?? 0))
52
+ weakest = i;
53
+ }
54
+ current = current.filter((_, i) => i !== weakest);
55
+ }
56
+ return current;
57
+ }
58
+ function joinSegments(segments) {
59
+ return segments.map((segment) => segment.text).join(theme.fg("borderMuted", " · "));
60
+ }
61
+ function trailingBudget(width) {
62
+ return Math.max(1, width - CURSOR_WIDTH - 5);
63
+ }
64
+ /** Rendered width of a trailing cluster, degraded and truncated exactly as {@link renderTrailing} will. */
65
+ export function getTrailingWidth(segments, width) {
66
+ const budget = trailingBudget(width);
67
+ const reduced = reduceTrailingSegments(segments, budget);
68
+ if (reduced.length === 0)
69
+ return 0;
70
+ return Math.min(visibleWidth(joinSegments(reduced)), budget);
71
+ }
72
+ function renderTrailing(segments, width) {
73
+ const budget = trailingBudget(width);
74
+ const reduced = reduceTrailingSegments(segments, budget);
75
+ if (reduced.length === 0)
76
+ return "";
77
+ return truncateToWidth(joinSegments(reduced), budget, "…");
78
+ }
79
+ /**
80
+ * Where the effort cluster sits on this page, or {@link NO_EFFORT_CLUSTER} when it does not fit.
81
+ *
82
+ * The cluster is placed near the row's horizontal centre and then given up one
83
+ * piece at a time as the terminal narrows: first the centring gap, then the
84
+ * name column, then the level label, then the cluster itself. Rows stay aligned
85
+ * at every width because each step is taken for the whole page at once.
86
+ */
87
+ export function getEffortLayout(rows, width) {
88
+ const reasoningRows = rows.filter((row) => row.levels.length > 0);
89
+ if (reasoningRows.length === 0)
90
+ return NO_EFFORT_CLUSTER;
91
+ const maxTrailingWidth = Math.max(0, ...rows.map((row) => getTrailingWidth(row.trailingSegments, width)));
92
+ const available = Math.max(1, width - CURSOR_WIDTH - maxTrailingWidth - 2);
93
+ const maxNameColumn = Math.min(Math.max(...reasoningRows.map((row) => visibleWidth(row.name))), NAME_COLUMN_MAX);
94
+ const squareSlots = Math.max(...reasoningRows.map((row) => row.levels.filter((level) => level !== "off").length));
95
+ // A fixed label cell sized to the longest level name, so dialling effort up
96
+ // and down never changes the cluster's span or its centred gap.
97
+ const labelWidth = Math.max(...reasoningRows.flatMap((row) => row.levels.map((level) => visibleWidth(level))));
98
+ const place = (nameColumn, showLabel) => {
99
+ const span = squareSlots + (showLabel ? labelWidth + CLUSTER_CHROME : CLUSTER_CHROME - 1);
100
+ const desired = Math.floor(width / 2 - span / 2) - CURSOR_WIDTH - nameColumn;
101
+ const gap = Math.max(1, Math.min(desired, available - nameColumn - span));
102
+ return { nameColumn, squareSlots, gap, labelWidth, showLabel, showCluster: true };
103
+ };
104
+ if (maxNameColumn + squareSlots + labelWidth + CLUSTER_CHROME_AND_GAP <= available) {
105
+ return place(maxNameColumn, true);
106
+ }
107
+ const labelNameColumn = available - squareSlots - labelWidth - CLUSTER_CHROME_AND_GAP;
108
+ if (labelNameColumn >= NAME_COLUMN_MIN) {
109
+ return place(Math.min(maxNameColumn, labelNameColumn), true);
110
+ }
111
+ if (maxNameColumn + squareSlots + CLUSTER_CHROME_AND_GAP <= available) {
112
+ return place(maxNameColumn, false);
113
+ }
114
+ const clusterNameColumn = available - squareSlots - CLUSTER_CHROME_AND_GAP;
115
+ if (clusterNameColumn >= NAME_COLUMN_MIN) {
116
+ return place(Math.min(maxNameColumn, clusterNameColumn), false);
117
+ }
118
+ return NO_EFFORT_CLUSTER;
119
+ }
120
+ function renderEffortSquares(levels, effort, squareSlots, selected) {
121
+ const onLevels = levels.filter((level) => level !== "off");
122
+ if (onLevels.length === 0)
123
+ return "";
124
+ const filledCount = effort === "off" ? 0 : onLevels.indexOf(effort) + 1;
125
+ // Only the highlighted row's squares carry the level's colour. Colouring
126
+ // every row turned the list into a stripe of unrelated hues.
127
+ const paintFilled = (glyph) => selected ? theme.fg(THINKING_COLORS[effort], glyph) : theme.fg("muted", glyph);
128
+ const marks = onLevels.map((_, index) => (index < filledCount ? paintFilled("■") : theme.fg("dim", "□"))).join("");
129
+ return marks + " ".repeat(Math.max(0, squareSlots - visibleWidth(marks)));
130
+ }
131
+ /**
132
+ * One model row: cursor, name, optional effort cluster, right-aligned trailing context.
133
+ *
134
+ * The line is padded out to `width` so the caller's selection fill covers the
135
+ * whole row rather than stopping at the last glyph.
136
+ */
137
+ export function composeModelRow(options) {
138
+ const { name, levels, effort, trailingSegments, selected, layout, width } = options;
139
+ // Selection is carried by the background step alone, so the cursor stays
140
+ // uncoloured: the accent means "Apex owns this" everywhere else on screen.
141
+ const cursor = selected ? "→ " : " ";
142
+ const inner = Math.max(1, width - CURSOR_WIDTH);
143
+ const trailing = renderTrailing(trailingSegments, width);
144
+ const trailingWidth = visibleWidth(trailing);
145
+ let primary;
146
+ if (layout.showCluster && levels.length > 0 && effort !== undefined) {
147
+ const nameCell = truncateToWidth(name, layout.nameColumn, "…", true);
148
+ const gap = " ".repeat(layout.gap);
149
+ // The arrows are the affordance for left/right, so they appear only on the
150
+ // row they would act on. Their columns are reserved on every row regardless.
151
+ const leftArrow = selected ? theme.fg("dim", "←") : " ";
152
+ const rightArrow = selected ? theme.fg("dim", "→") : " ";
153
+ const squares = renderEffortSquares(levels, effort, layout.squareSlots, selected);
154
+ const label = layout.showLabel ? ` ${theme.fg("muted", effort.padEnd(layout.labelWidth))}` : "";
155
+ primary = `${nameCell}${gap}${leftArrow} ${squares} ${rightArrow}${label}`;
156
+ }
157
+ else {
158
+ primary = name;
159
+ }
160
+ // Truncate before painting, never after: a cut inside the bold wrapper would
161
+ // drop its closing code and bleed the weight across the rest of the line.
162
+ const gapBeforeTrailing = trailingWidth > 0 ? 2 : 0;
163
+ const capped = truncateToWidth(primary, Math.max(1, inner - trailingWidth - gapBeforeTrailing), "…");
164
+ const painted = selected ? theme.bold(theme.fg("text", capped)) : theme.fg("text", capped);
165
+ const filler = " ".repeat(Math.max(0, inner - visibleWidth(capped) - trailingWidth));
166
+ return `${cursor}${painted}${filler}${trailing}`;
167
+ }
168
+ function formatPrice(value) {
169
+ if (value === undefined || !Number.isFinite(value) || value < 0)
170
+ return "—";
171
+ if (value === 0)
172
+ return "$0";
173
+ const rounded = Math.round(value * 1000) / 1000;
174
+ return rounded === 0 ? "<$0.001" : `$${rounded}`;
175
+ }
176
+ /**
177
+ * Per-million-token prices for the highlighted model.
178
+ *
179
+ * This is the detail the old list carried as a second "Model Name" line, which
180
+ * became redundant once the name became the row itself. Price is what you
181
+ * actually weigh when two models are otherwise interchangeable.
182
+ */
183
+ export function renderPricePanel(cost, width) {
184
+ const entries = [
185
+ ["Input", formatPrice(cost?.input)],
186
+ ["Cached input", formatPrice(cost?.cacheRead)],
187
+ ["Output", formatPrice(cost?.output)],
188
+ ];
189
+ const unit = theme.fg("muted", PRICE_UNIT_TEXT);
190
+ const lines = [""];
191
+ if (width >= PRICE_WIDE_LAYOUT_MIN_WIDTH) {
192
+ const columnWidth = Math.min(PRICE_COLUMN_MAX_WIDTH, Math.max(PRICE_COLUMN_MIN_WIDTH, Math.floor((width - CURSOR_WIDTH - (visibleWidth(PRICE_UNIT_TEXT) + 1)) / entries.length)));
193
+ const row = (index) => entries
194
+ .map((entry) => entry[index] + " ".repeat(Math.max(0, columnWidth - visibleWidth(entry[index]))))
195
+ .join("");
196
+ lines.push(`${theme.fg("muted", row(0))} ${unit}`, theme.fg("text", row(1)));
197
+ }
198
+ else {
199
+ lines.push(...entries.map(([label, value], index) => {
200
+ const suffix = index === entries.length - 1 ? ` ${unit}` : "";
201
+ return `${theme.fg("muted", `${label}:`)} ${theme.fg("text", value)}${suffix}`;
202
+ }));
203
+ }
204
+ return lines.map((line) => truncateToWidth(` ${line}`, width, "…"));
205
+ }
206
+ //# sourceMappingURL=model-row.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model-row.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/model-row.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Row rendering for the model step of the model picker.\n *\n * The effort-cluster placement and the price panel are adapted from Prime Agent\n * (https://github.com/PrimeIntellect-ai/prime-agent), a separate Pi-derived fork\n * under the MIT License. ADR 0002 permits that for MIT prior art; NOTICE records\n * the attribution. Apex Code's own panel chrome, cursor, and theme tokens are\n * kept, so this is the row and nothing above it.\n */\n\nimport type { ModelCost, ModelThinkingLevel } from \"@earendil-works/pi-ai\";\nimport { truncateToWidth, visibleWidth } from \"@earendil-works/pi-tui\";\nimport { type ThemeColor, theme } from \"../theme/theme.ts\";\n\n/** Columns the cursor cell occupies at the head of every row. */\nconst CURSOR_WIDTH = 2;\nconst NAME_COLUMN_MAX = 30;\nconst NAME_COLUMN_MIN = 12;\n/** `← ` + squares + ` → ` around the cluster, plus at least one column of gap before it. */\nconst CLUSTER_CHROME = 5;\nconst CLUSTER_CHROME_AND_GAP = CLUSTER_CHROME + 1;\nconst PRICE_UNIT_TEXT = \"$ / 1M tokens\";\n/** A wide price column must still fit the longest label, \"Cached input\". */\nconst PRICE_COLUMN_MIN_WIDTH = 13;\n/**\n * Past this the three prices stop reading as one group and become three lonely\n * numbers spread across the terminal, so the block stays put and the row ends early.\n */\nconst PRICE_COLUMN_MAX_WIDTH = 18;\nconst PRICE_WIDE_LAYOUT_MIN_WIDTH = 58;\n\nconst THINKING_COLORS: Record<ModelThinkingLevel, ThemeColor> = {\n\toff: \"thinkingOff\",\n\tminimal: \"thinkingMinimal\",\n\tlow: \"thinkingLow\",\n\tmedium: \"thinkingMedium\",\n\thigh: \"thinkingHigh\",\n\txhigh: \"thinkingXhigh\",\n\tmax: \"thinkingMax\",\n};\n\n/**\n * Column budget the effort cluster gets on every row of one rendered page.\n *\n * It is computed once per page rather than per row so the squares sit in the\n * same column all the way down. A per-row measurement would shift the cluster\n * whenever a neighbouring name or level label changed length, which reads as\n * the list wobbling while you arrow through it.\n */\nexport interface EffortLayout {\n\tnameColumn: number;\n\tsquareSlots: number;\n\tgap: number;\n\tlabelWidth: number;\n\tshowLabel: boolean;\n\tshowCluster: boolean;\n}\n\nexport const NO_EFFORT_CLUSTER: EffortLayout = {\n\tnameColumn: 0,\n\tsquareSlots: 0,\n\tgap: 0,\n\tlabelWidth: 0,\n\tshowLabel: false,\n\tshowCluster: false,\n};\n\n/** One visible row's inputs to the page-wide layout measurement. */\nexport interface EffortLayoutRow {\n\tname: string;\n\t/** Selectable thinking levels, empty when the model has no reasoning to dial. */\n\tlevels: ReadonlyArray<ModelThinkingLevel>;\n\ttrailingSegments: ReadonlyArray<TrailingSegment>;\n}\n\n/**\n * One piece of a row's trailing context, with what it costs to lose.\n *\n * Reading order and dropping order are not the same thing. `id · current` reads\n * correctly in that order, but under a squeeze `current` is the segment worth\n * keeping, because the row already carries the model's name and the id is\n * recoverable from it, while which model is active is not shown anywhere else.\n * Dropping purely from the front would have kept the wrong one.\n */\nexport interface TrailingSegment {\n\ttext: string;\n\t/** Lower drops first. */\n\tpriority: number;\n}\n\n/** Drop the least valuable segments until the cluster fits, preserving reading order. */\nfunction reduceTrailingSegments(segments: ReadonlyArray<TrailingSegment>, budget: number): TrailingSegment[] {\n\tlet current = segments.filter((segment) => visibleWidth(segment.text) > 0);\n\twhile (current.length > 1 && visibleWidth(joinSegments(current)) > budget) {\n\t\tlet weakest = 0;\n\t\tfor (let i = 1; i < current.length; i++) {\n\t\t\tif ((current[i]?.priority ?? 0) < (current[weakest]?.priority ?? 0)) weakest = i;\n\t\t}\n\t\tcurrent = current.filter((_, i) => i !== weakest);\n\t}\n\treturn current;\n}\n\nfunction joinSegments(segments: ReadonlyArray<TrailingSegment>): string {\n\treturn segments.map((segment) => segment.text).join(theme.fg(\"borderMuted\", \" · \"));\n}\n\nfunction trailingBudget(width: number): number {\n\treturn Math.max(1, width - CURSOR_WIDTH - 5);\n}\n\n/** Rendered width of a trailing cluster, degraded and truncated exactly as {@link renderTrailing} will. */\nexport function getTrailingWidth(segments: ReadonlyArray<TrailingSegment>, width: number): number {\n\tconst budget = trailingBudget(width);\n\tconst reduced = reduceTrailingSegments(segments, budget);\n\tif (reduced.length === 0) return 0;\n\treturn Math.min(visibleWidth(joinSegments(reduced)), budget);\n}\n\nfunction renderTrailing(segments: ReadonlyArray<TrailingSegment>, width: number): string {\n\tconst budget = trailingBudget(width);\n\tconst reduced = reduceTrailingSegments(segments, budget);\n\tif (reduced.length === 0) return \"\";\n\treturn truncateToWidth(joinSegments(reduced), budget, \"…\");\n}\n\n/**\n * Where the effort cluster sits on this page, or {@link NO_EFFORT_CLUSTER} when it does not fit.\n *\n * The cluster is placed near the row's horizontal centre and then given up one\n * piece at a time as the terminal narrows: first the centring gap, then the\n * name column, then the level label, then the cluster itself. Rows stay aligned\n * at every width because each step is taken for the whole page at once.\n */\nexport function getEffortLayout(rows: ReadonlyArray<EffortLayoutRow>, width: number): EffortLayout {\n\tconst reasoningRows = rows.filter((row) => row.levels.length > 0);\n\tif (reasoningRows.length === 0) return NO_EFFORT_CLUSTER;\n\n\tconst maxTrailingWidth = Math.max(0, ...rows.map((row) => getTrailingWidth(row.trailingSegments, width)));\n\tconst available = Math.max(1, width - CURSOR_WIDTH - maxTrailingWidth - 2);\n\n\tconst maxNameColumn = Math.min(Math.max(...reasoningRows.map((row) => visibleWidth(row.name))), NAME_COLUMN_MAX);\n\tconst squareSlots = Math.max(...reasoningRows.map((row) => row.levels.filter((level) => level !== \"off\").length));\n\t// A fixed label cell sized to the longest level name, so dialling effort up\n\t// and down never changes the cluster's span or its centred gap.\n\tconst labelWidth = Math.max(...reasoningRows.flatMap((row) => row.levels.map((level) => visibleWidth(level))));\n\n\tconst place = (nameColumn: number, showLabel: boolean): EffortLayout => {\n\t\tconst span = squareSlots + (showLabel ? labelWidth + CLUSTER_CHROME : CLUSTER_CHROME - 1);\n\t\tconst desired = Math.floor(width / 2 - span / 2) - CURSOR_WIDTH - nameColumn;\n\t\tconst gap = Math.max(1, Math.min(desired, available - nameColumn - span));\n\t\treturn { nameColumn, squareSlots, gap, labelWidth, showLabel, showCluster: true };\n\t};\n\n\tif (maxNameColumn + squareSlots + labelWidth + CLUSTER_CHROME_AND_GAP <= available) {\n\t\treturn place(maxNameColumn, true);\n\t}\n\tconst labelNameColumn = available - squareSlots - labelWidth - CLUSTER_CHROME_AND_GAP;\n\tif (labelNameColumn >= NAME_COLUMN_MIN) {\n\t\treturn place(Math.min(maxNameColumn, labelNameColumn), true);\n\t}\n\tif (maxNameColumn + squareSlots + CLUSTER_CHROME_AND_GAP <= available) {\n\t\treturn place(maxNameColumn, false);\n\t}\n\tconst clusterNameColumn = available - squareSlots - CLUSTER_CHROME_AND_GAP;\n\tif (clusterNameColumn >= NAME_COLUMN_MIN) {\n\t\treturn place(Math.min(maxNameColumn, clusterNameColumn), false);\n\t}\n\treturn NO_EFFORT_CLUSTER;\n}\n\nfunction renderEffortSquares(\n\tlevels: ReadonlyArray<ModelThinkingLevel>,\n\teffort: ModelThinkingLevel,\n\tsquareSlots: number,\n\tselected: boolean,\n): string {\n\tconst onLevels = levels.filter((level) => level !== \"off\");\n\tif (onLevels.length === 0) return \"\";\n\tconst filledCount = effort === \"off\" ? 0 : onLevels.indexOf(effort) + 1;\n\t// Only the highlighted row's squares carry the level's colour. Colouring\n\t// every row turned the list into a stripe of unrelated hues.\n\tconst paintFilled = (glyph: string) =>\n\t\tselected ? theme.fg(THINKING_COLORS[effort], glyph) : theme.fg(\"muted\", glyph);\n\tconst marks = onLevels.map((_, index) => (index < filledCount ? paintFilled(\"■\") : theme.fg(\"dim\", \"□\"))).join(\"\");\n\treturn marks + \" \".repeat(Math.max(0, squareSlots - visibleWidth(marks)));\n}\n\nexport interface ModelRowOptions {\n\tname: string;\n\tlevels: ReadonlyArray<ModelThinkingLevel>;\n\teffort: ModelThinkingLevel | undefined;\n\ttrailingSegments: ReadonlyArray<TrailingSegment>;\n\tselected: boolean;\n\tlayout: EffortLayout;\n\twidth: number;\n}\n\n/**\n * One model row: cursor, name, optional effort cluster, right-aligned trailing context.\n *\n * The line is padded out to `width` so the caller's selection fill covers the\n * whole row rather than stopping at the last glyph.\n */\nexport function composeModelRow(options: ModelRowOptions): string {\n\tconst { name, levels, effort, trailingSegments, selected, layout, width } = options;\n\t// Selection is carried by the background step alone, so the cursor stays\n\t// uncoloured: the accent means \"Apex owns this\" everywhere else on screen.\n\tconst cursor = selected ? \"→ \" : \" \";\n\tconst inner = Math.max(1, width - CURSOR_WIDTH);\n\tconst trailing = renderTrailing(trailingSegments, width);\n\tconst trailingWidth = visibleWidth(trailing);\n\n\tlet primary: string;\n\tif (layout.showCluster && levels.length > 0 && effort !== undefined) {\n\t\tconst nameCell = truncateToWidth(name, layout.nameColumn, \"…\", true);\n\t\tconst gap = \" \".repeat(layout.gap);\n\t\t// The arrows are the affordance for left/right, so they appear only on the\n\t\t// row they would act on. Their columns are reserved on every row regardless.\n\t\tconst leftArrow = selected ? theme.fg(\"dim\", \"←\") : \" \";\n\t\tconst rightArrow = selected ? theme.fg(\"dim\", \"→\") : \" \";\n\t\tconst squares = renderEffortSquares(levels, effort, layout.squareSlots, selected);\n\t\tconst label = layout.showLabel ? ` ${theme.fg(\"muted\", effort.padEnd(layout.labelWidth))}` : \"\";\n\t\tprimary = `${nameCell}${gap}${leftArrow} ${squares} ${rightArrow}${label}`;\n\t} else {\n\t\tprimary = name;\n\t}\n\n\t// Truncate before painting, never after: a cut inside the bold wrapper would\n\t// drop its closing code and bleed the weight across the rest of the line.\n\tconst gapBeforeTrailing = trailingWidth > 0 ? 2 : 0;\n\tconst capped = truncateToWidth(primary, Math.max(1, inner - trailingWidth - gapBeforeTrailing), \"…\");\n\tconst painted = selected ? theme.bold(theme.fg(\"text\", capped)) : theme.fg(\"text\", capped);\n\tconst filler = \" \".repeat(Math.max(0, inner - visibleWidth(capped) - trailingWidth));\n\treturn `${cursor}${painted}${filler}${trailing}`;\n}\n\nfunction formatPrice(value: number | undefined): string {\n\tif (value === undefined || !Number.isFinite(value) || value < 0) return \"—\";\n\tif (value === 0) return \"$0\";\n\tconst rounded = Math.round(value * 1000) / 1000;\n\treturn rounded === 0 ? \"<$0.001\" : `$${rounded}`;\n}\n\n/**\n * Per-million-token prices for the highlighted model.\n *\n * This is the detail the old list carried as a second \"Model Name\" line, which\n * became redundant once the name became the row itself. Price is what you\n * actually weigh when two models are otherwise interchangeable.\n */\nexport function renderPricePanel(cost: ModelCost | undefined, width: number): string[] {\n\tconst entries: [string, string][] = [\n\t\t[\"Input\", formatPrice(cost?.input)],\n\t\t[\"Cached input\", formatPrice(cost?.cacheRead)],\n\t\t[\"Output\", formatPrice(cost?.output)],\n\t];\n\tconst unit = theme.fg(\"muted\", PRICE_UNIT_TEXT);\n\tconst lines: string[] = [\"\"];\n\tif (width >= PRICE_WIDE_LAYOUT_MIN_WIDTH) {\n\t\tconst columnWidth = Math.min(\n\t\t\tPRICE_COLUMN_MAX_WIDTH,\n\t\t\tMath.max(\n\t\t\t\tPRICE_COLUMN_MIN_WIDTH,\n\t\t\t\tMath.floor((width - CURSOR_WIDTH - (visibleWidth(PRICE_UNIT_TEXT) + 1)) / entries.length),\n\t\t\t),\n\t\t);\n\t\tconst row = (index: 0 | 1) =>\n\t\t\tentries\n\t\t\t\t.map((entry) => entry[index] + \" \".repeat(Math.max(0, columnWidth - visibleWidth(entry[index]))))\n\t\t\t\t.join(\"\");\n\t\tlines.push(`${theme.fg(\"muted\", row(0))} ${unit}`, theme.fg(\"text\", row(1)));\n\t} else {\n\t\tlines.push(\n\t\t\t...entries.map(([label, value], index) => {\n\t\t\t\tconst suffix = index === entries.length - 1 ? ` ${unit}` : \"\";\n\t\t\t\treturn `${theme.fg(\"muted\", `${label}:`)} ${theme.fg(\"text\", value)}${suffix}`;\n\t\t\t}),\n\t\t);\n\t}\n\treturn lines.map((line) => truncateToWidth(` ${line}`, width, \"…\"));\n}\n"]}
@@ -1,4 +1,4 @@
1
- import { type Model } from "@earendil-works/pi-ai";
1
+ import { type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
2
2
  import { Container, type Focusable, Input, type TUI } from "@earendil-works/pi-tui";
3
3
  import type { ModelRuntime } from "../../../core/model-runtime.ts";
4
4
  interface ScopedModelItem {
@@ -42,7 +42,20 @@ export declare class ModelSelectorComponent extends Container implements Focusab
42
42
  private readonly refreshAbortController;
43
43
  private refreshTimeout?;
44
44
  private closed;
45
- constructor(tui: TUI, currentModel: Model<any> | undefined, modelRuntime: ModelRuntime, scopedModels: ReadonlyArray<ScopedModelItem>, onSelect: (model: Model<any>) => void, onCancel: () => void, initialSearchInput?: string, onSelectAsDefault?: (model: Model<any>) => void, defaultModel?: DefaultModelReference);
45
+ /** Effort dialled per model in this session of the picker, keyed by `provider/id`. */
46
+ private readonly effortLevels;
47
+ /** Models whose effort the user actually moved. Only those override the session level. */
48
+ private readonly editedEffortModels;
49
+ private readonly initialThinkingLevel?;
50
+ /**
51
+ * True once up or down has moved the selection into the list. Until then
52
+ * left and right belong to the search field's cursor, which is where they
53
+ * are needed while a query is still being typed.
54
+ */
55
+ private navigatedIntoList;
56
+ constructor(tui: TUI, currentModel: Model<any> | undefined, modelRuntime: ModelRuntime, scopedModels: ReadonlyArray<ScopedModelItem>, onSelect: (model: Model<any>, thinkingLevel?: ModelThinkingLevel) => void, onCancel: () => void, initialSearchInput?: string, onSelectAsDefault?: (model: Model<any>, thinkingLevel?: ModelThinkingLevel) => void, defaultModel?: DefaultModelReference, options?: {
57
+ thinkingLevel?: ModelThinkingLevel;
58
+ });
46
59
  private initialStep;
47
60
  private activeProviders;
48
61
  private loadModelsFromSnapshot;
@@ -59,17 +72,39 @@ export declare class ModelSelectorComponent extends Container implements Focusab
59
72
  private renderFooter;
60
73
  private getScopeText;
61
74
  private getHintText;
75
+ /** Levels worth dialling. A model with nothing but "off" has no cluster to show. */
76
+ private getSelectableLevels;
77
+ private getEffort;
78
+ private adjustEffort;
79
+ /** The effort to hand back on select, or undefined when the user left it alone. */
80
+ private getChosenEffort;
81
+ private hasAdjustableEffort;
82
+ /**
83
+ * Context that trails a model row, right-aligned, in reading order.
84
+ *
85
+ * Each segment carries what it costs to lose, because a narrow row drops by
86
+ * that and not by position. `current` outranks the id: the row already shows
87
+ * the model's name, so the id is largely recoverable from it, while nothing
88
+ * else on screen says which model the session is on. The provider badge goes
89
+ * first, being the least you lose.
90
+ */
91
+ private getTrailingSegments;
62
92
  private isDefaultModel;
63
93
  private isDefaultSearch;
64
94
  private setScope;
65
95
  private rowSearchText;
66
96
  private isDefaultRow;
67
97
  private filterRows;
68
- private renderRow;
98
+ private renderProviderRow;
99
+ /**
100
+ * The visible page of rows, rendered together because the effort cluster is
101
+ * placed once for the whole page rather than per row.
102
+ */
103
+ private createRowsComponent;
104
+ private pageEffortLayout;
69
105
  private updateList;
70
106
  handleInput(keyData: string): void;
71
107
  private confirmSelection;
72
- private handleSelect;
73
108
  getSearchInput(): Input;
74
109
  }
75
110
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"model-selector.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/model-selector.ts"],"names":[],"mappings":"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","sourcesContent":["import { type Model, modelsAreEqual } from \"@earendil-works/pi-ai\";\nimport {\n\tContainer,\n\ttype Focusable,\n\tfuzzyFilter,\n\tgetKeybindings,\n\tInput,\n\tKey,\n\tmatchesKey,\n\tSpacer,\n\tText,\n\ttype TUI,\n} from \"@earendil-works/pi-tui\";\nimport type { ModelRuntime } from \"../../../core/model-runtime.ts\";\nimport { refreshModelCatalogs } from \"../model-catalog-refresh.ts\";\nimport { getModelSelectorSearchText } from \"../model-search.ts\";\nimport { paintSelectedRow, theme } from \"../theme/theme.ts\";\nimport { DynamicBorder } from \"./dynamic-border.ts\";\nimport { keyHint, keyText, rawKeyHint } from \"./keybinding-hints.ts\";\n\ninterface ModelItem {\n\tprovider: string;\n\tid: string;\n\tmodel: Model<any>;\n}\n\ninterface ScopedModelItem {\n\tmodel: Model<any>;\n\tthinkingLevel?: string;\n}\n\ninterface DefaultModelReference {\n\tprovider: string;\n\tid: string;\n}\n\ntype ModelScope = \"all\" | \"scoped\";\n\n/**\n * Which of the two steps the selector is on. `provider: null` lists every provider's models at once,\n * which is how a `/model <search>` invocation and a single-provider setup both open.\n */\ntype Step = { kind: \"providers\" } | { kind: \"models\"; provider: string | null };\n\ntype Row =\n\t| { kind: \"provider\"; provider: string; count: number; hasCurrent: boolean }\n\t| { kind: \"model\"; item: ModelItem };\n\nconst MAX_VISIBLE_ROWS = 10;\n\n/**\n * Component that renders a two-step model selector: provider first, then that provider's models.\n */\nexport class ModelSelectorComponent extends Container implements Focusable {\n\tprivate searchInput: Input;\n\n\t// Focusable implementation - propagate to searchInput for IME cursor positioning\n\tprivate _focused = false;\n\tget focused(): boolean {\n\t\treturn this._focused;\n\t}\n\tset focused(value: boolean) {\n\t\tthis._focused = value;\n\t\tthis.searchInput.focused = value;\n\t}\n\tprivate headerContainer: Container;\n\tprivate listContainer: Container;\n\tprivate footerContainer: Container;\n\tprivate allModels: ModelItem[] = [];\n\tprivate scopedModelItems: ModelItem[] = [];\n\tprivate activeModels: ModelItem[] = [];\n\tprivate rows: Row[] = [];\n\tprivate filteredRows: Row[] = [];\n\tprivate selectedIndex: number = 0;\n\tprivate currentModel?: Model<any>;\n\tprivate modelRuntime: ModelRuntime;\n\tprivate onSelectCallback: (model: Model<any>) => void;\n\tprivate onSelectAsDefaultCallback?: (model: Model<any>) => void;\n\tprivate onCancelCallback: () => void;\n\tprivate errorMessage?: string;\n\tprivate refreshStatusMessage = \"Refreshing model catalogs…\";\n\tprivate refreshStatusSuccess = false;\n\tprivate tui: TUI;\n\tprivate scopedModels: ReadonlyArray<ScopedModelItem>;\n\tprivate defaultModel?: DefaultModelReference;\n\tprivate scope: ModelScope = \"all\";\n\tprivate step: Step = { kind: \"providers\" };\n\tprivate readonly refreshAbortController = new AbortController();\n\tprivate refreshTimeout?: ReturnType<typeof setTimeout>;\n\tprivate closed = false;\n\n\tconstructor(\n\t\ttui: TUI,\n\t\tcurrentModel: Model<any> | undefined,\n\t\tmodelRuntime: ModelRuntime,\n\t\tscopedModels: ReadonlyArray<ScopedModelItem>,\n\t\tonSelect: (model: Model<any>) => void,\n\t\tonCancel: () => void,\n\t\tinitialSearchInput?: string,\n\t\tonSelectAsDefault?: (model: Model<any>) => void,\n\t\tdefaultModel?: DefaultModelReference,\n\t) {\n\t\tsuper();\n\n\t\tthis.tui = tui;\n\t\tthis.currentModel = currentModel;\n\t\tthis.modelRuntime = modelRuntime;\n\t\tthis.scopedModels = scopedModels;\n\t\tthis.defaultModel = defaultModel;\n\t\tthis.scope = scopedModels.length > 0 ? \"scoped\" : \"all\";\n\t\tthis.onSelectCallback = onSelect;\n\t\tthis.onSelectAsDefaultCallback = onSelectAsDefault;\n\t\tthis.onCancelCallback = onCancel;\n\n\t\t// Add top border\n\t\tthis.addChild(new DynamicBorder());\n\t\tthis.addChild(new Spacer(1));\n\n\t\tthis.headerContainer = new Container();\n\t\tthis.addChild(this.headerContainer);\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create search input\n\t\tthis.searchInput = new Input();\n\t\tif (initialSearchInput) {\n\t\t\tthis.searchInput.setValue(initialSearchInput);\n\t\t}\n\t\tthis.searchInput.onSubmit = () => this.confirmSelection();\n\t\tthis.addChild(this.searchInput);\n\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create list container\n\t\tthis.listContainer = new Container();\n\t\tthis.addChild(this.listContainer);\n\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Keybindings live under the list, where the eye already is once it has\n\t\t// finished reading. Above the list they were the first thing read and the\n\t\t// least useful.\n\t\tthis.footerContainer = new Container();\n\t\tthis.addChild(this.footerContainer);\n\n\t\t// Add bottom border\n\t\tthis.addChild(new DynamicBorder());\n\n\t\t// Render the current snapshot immediately, then refresh in the background.\n\t\tthis.loadModelsFromSnapshot();\n\t\tthis.step = this.initialStep(initialSearchInput);\n\t\tthis.rebuildRows();\n\t\tthis.selectedIndex = this.defaultSelectedIndex();\n\t\tthis.renderHeader();\n\t\tthis.filterRows(this.searchInput.getValue());\n\t\tthis.tui.requestRender();\n\t\tvoid this.refreshModels();\n\t}\n\n\tprivate initialStep(initialSearchInput: string | undefined): Step {\n\t\tconst providers = this.activeProviders();\n\t\tif (initialSearchInput) return { kind: \"models\", provider: null };\n\t\tif (providers.length <= 1) return { kind: \"models\", provider: providers[0] ?? null };\n\t\treturn { kind: \"providers\" };\n\t}\n\n\tprivate activeProviders(): string[] {\n\t\treturn [...new Set(this.activeModels.map((item) => item.provider))];\n\t}\n\n\tprivate loadModelsFromSnapshot(): void {\n\t\tconst models = this.modelRuntime.getAvailableSnapshot().map((model: Model<any>) => ({\n\t\t\tprovider: model.provider,\n\t\t\tid: model.id,\n\t\t\tmodel,\n\t\t}));\n\t\tthis.allModels = this.sortModels(models);\n\t\tthis.scopedModels = this.scopedModels.map((scoped) => {\n\t\t\tconst refreshed = this.modelRuntime.getModel(scoped.model.provider, scoped.model.id);\n\t\t\treturn refreshed ? { ...scoped, model: refreshed } : scoped;\n\t\t});\n\t\tthis.scopedModelItems = this.scopedModels.map((scoped) => ({\n\t\t\tprovider: scoped.model.provider,\n\t\t\tid: scoped.model.id,\n\t\t\tmodel: scoped.model,\n\t\t}));\n\t\tthis.activeModels = this.scope === \"scoped\" ? this.scopedModelItems : this.allModels;\n\t}\n\n\tprivate async refreshModels(): Promise<void> {\n\t\tconst timeoutMs = 15_000;\n\t\tlet timedOut = false;\n\t\tthis.refreshTimeout = setTimeout(() => {\n\t\t\ttimedOut = true;\n\t\t\tthis.refreshAbortController.abort();\n\t\t}, timeoutMs);\n\t\ttry {\n\t\t\tconst result = await refreshModelCatalogs(this.modelRuntime, this.refreshAbortController.signal);\n\t\t\tif (this.closed) return;\n\t\t\tthis.refreshStatusMessage = \"\";\n\t\t\tif (result.aborted && timedOut) {\n\t\t\t\tthis.errorMessage = \"Model refresh timed out; showing cached models.\";\n\t\t\t} else if (result.errors.size === 1) {\n\t\t\t\tthis.errorMessage = `Could not refresh ${result.errors.keys().next().value}; showing cached models.`;\n\t\t\t} else if (result.errors.size > 1) {\n\t\t\t\tthis.errorMessage = `Could not refresh ${result.errors.size} model catalogs (${[...result.errors.keys()].join(\", \")}); showing cached models.`;\n\t\t\t} else {\n\t\t\t\tthis.errorMessage = this.modelRuntime.getError();\n\t\t\t\tif (!this.errorMessage) {\n\t\t\t\t\tthis.refreshStatusMessage = \"Model catalogs refreshed.\";\n\t\t\t\t\tthis.refreshStatusSuccess = true;\n\t\t\t\t}\n\t\t\t}\n\t\t\tthis.loadModelsFromSnapshot();\n\t\t\tthis.rebuildRows();\n\t\t\tthis.renderHeader();\n\t\t\tthis.filterRows(this.searchInput.getValue());\n\t\t\tthis.tui.requestRender();\n\t\t} catch (error) {\n\t\t\tif (this.closed) return;\n\t\t\tthis.refreshStatusMessage = \"\";\n\t\t\tthis.errorMessage = timedOut\n\t\t\t\t? \"Model refresh timed out; showing cached models.\"\n\t\t\t\t: `Could not refresh model catalogs: ${error instanceof Error ? error.message : String(error)}`;\n\t\t\tthis.updateList();\n\t\t\tthis.tui.requestRender();\n\t\t} finally {\n\t\t\tif (this.refreshTimeout) clearTimeout(this.refreshTimeout);\n\t\t}\n\t}\n\n\tdispose(): void {\n\t\tif (this.closed) return;\n\t\tthis.closed = true;\n\t\tif (this.refreshTimeout) clearTimeout(this.refreshTimeout);\n\t\tthis.refreshAbortController.abort();\n\t}\n\n\tprivate sortModels(models: ModelItem[]): ModelItem[] {\n\t\tconst sorted = [...models];\n\t\t// Sort: current model first, default model second, then by provider.\n\t\tsorted.sort((a, b) => {\n\t\t\tconst aIsCurrent = modelsAreEqual(this.currentModel, a.model);\n\t\t\tconst bIsCurrent = modelsAreEqual(this.currentModel, b.model);\n\t\t\tif (aIsCurrent && !bIsCurrent) return -1;\n\t\t\tif (!aIsCurrent && bIsCurrent) return 1;\n\t\t\tconst aIsDefault = this.isDefaultModel(a.model);\n\t\t\tconst bIsDefault = this.isDefaultModel(b.model);\n\t\t\tif (aIsDefault && !bIsDefault) return -1;\n\t\t\tif (!aIsDefault && bIsDefault) return 1;\n\t\t\treturn a.provider.localeCompare(b.provider);\n\t\t});\n\t\treturn sorted;\n\t}\n\n\tprivate rebuildRows(): void {\n\t\tif (this.step.kind === \"providers\") {\n\t\t\tconst byProvider = new Map<string, ModelItem[]>();\n\t\t\tfor (const item of this.activeModels) {\n\t\t\t\tconst existing = byProvider.get(item.provider);\n\t\t\t\tif (existing) existing.push(item);\n\t\t\t\telse byProvider.set(item.provider, [item]);\n\t\t\t}\n\t\t\tconst providerRows: Row[] = [...byProvider].map(([provider, items]) => ({\n\t\t\t\tkind: \"provider\",\n\t\t\t\tprovider,\n\t\t\t\tcount: items.length,\n\t\t\t\thasCurrent: items.some((item) => modelsAreEqual(this.currentModel, item.model)),\n\t\t\t}));\n\t\t\tproviderRows.sort((a, b) => {\n\t\t\t\tif (a.kind !== \"provider\" || b.kind !== \"provider\") return 0;\n\t\t\t\tif (a.hasCurrent !== b.hasCurrent) return a.hasCurrent ? -1 : 1;\n\t\t\t\treturn a.provider.localeCompare(b.provider);\n\t\t\t});\n\t\t\tthis.rows = providerRows;\n\t\t\treturn;\n\t\t}\n\t\tconst provider = this.step.provider;\n\t\tconst models = provider === null ? this.activeModels : this.activeModels.filter((m) => m.provider === provider);\n\t\tthis.rows = models.map((item) => ({ kind: \"model\", item }));\n\t}\n\n\tprivate defaultSelectedIndex(): number {\n\t\tconst index = this.rows.findIndex((row) =>\n\t\t\trow.kind === \"provider\" ? row.hasCurrent : modelsAreEqual(this.currentModel, row.item.model),\n\t\t);\n\t\treturn index >= 0 ? index : 0;\n\t}\n\n\tprivate setStep(step: Step): void {\n\t\tthis.step = step;\n\t\tthis.searchInput.setValue(\"\");\n\t\tthis.rebuildRows();\n\t\tthis.selectedIndex = this.defaultSelectedIndex();\n\t\tthis.renderHeader();\n\t\tthis.filterRows(\"\");\n\t\tthis.tui.requestRender();\n\t}\n\n\tprivate isDrilledIn(): boolean {\n\t\treturn this.step.kind === \"models\" && this.step.provider !== null;\n\t}\n\n\t/** Escape returns to the provider list whenever there is a provider list worth returning to. */\n\tprivate canGoBack(): boolean {\n\t\treturn this.step.kind === \"models\" && this.activeProviders().length > 1;\n\t}\n\n\tprivate renderHeader(): void {\n\t\tthis.headerContainer.clear();\n\n\t\tif (this.step.kind === \"providers\") {\n\t\t\tthis.headerContainer.addChild(new Text(theme.bold(theme.fg(\"accent\", \"Select a provider\")), 0, 0));\n\t\t} else {\n\t\t\tconst label = this.step.provider ?? \"All providers\";\n\t\t\tconst crumb = `${theme.bold(theme.fg(\"accent\", label))}${theme.fg(\"muted\", \" › select a model\")}`;\n\t\t\tthis.headerContainer.addChild(new Text(crumb, 0, 0));\n\t\t}\n\n\t\tif (this.scopedModelItems.length > 0) {\n\t\t\tthis.headerContainer.addChild(new Text(this.getScopeText(), 0, 0));\n\t\t} else if (!this.isDrilledIn()) {\n\t\t\tconst hintText = \"Only showing models from configured providers. Use /login to add providers.\";\n\t\t\tthis.headerContainer.addChild(new Text(theme.fg(\"warning\", hintText), 0, 0));\n\t\t}\n\n\t\tthis.renderFooter();\n\t}\n\n\tprivate renderFooter(): void {\n\t\tthis.footerContainer.clear();\n\t\tthis.footerContainer.addChild(new Text(this.getHintText(), 0, 0));\n\t}\n\n\tprivate getScopeText(): string {\n\t\tconst allText = this.scope === \"all\" ? theme.fg(\"accent\", \"all\") : theme.fg(\"muted\", \"all\");\n\t\tconst scopedText = this.scope === \"scoped\" ? theme.fg(\"accent\", \"scoped\") : theme.fg(\"muted\", \"scoped\");\n\t\treturn `${theme.fg(\"muted\", \"Scope: \")}${allText}${theme.fg(\"muted\", \" | \")}${scopedText}`;\n\t}\n\n\tprivate getHintText(): string {\n\t\tconst hints: string[] = [\n\t\t\t// Both arrows, since either one moves the selection.\n\t\t\trawKeyHint(`${keyText(\"tui.select.up\")}/${keyText(\"tui.select.down\")}`, \"move\"),\n\t\t\tkeyHint(\"tui.select.confirm\", \"select\"),\n\t\t];\n\t\tif (this.scopedModelItems.length > 0) {\n\t\t\thints.push(keyHint(\"tui.input.tab\", \"scope\") + theme.fg(\"muted\", \" (all/scoped)\"));\n\t\t}\n\t\tif (this.onSelectAsDefaultCallback) hints.push(rawKeyHint(\"ctrl+s\", \"set as default\"));\n\t\tif (this.canGoBack()) hints.push(rawKeyHint(\"escape\", \"providers\"), rawKeyHint(\"ctrl+c\", \"close\"));\n\t\telse hints.push(keyHint(\"tui.select.cancel\", \"close\"));\n\t\treturn hints.join(theme.fg(\"borderMuted\", \" · \"));\n\t}\n\n\tprivate isDefaultModel(model: Model<any>): boolean {\n\t\treturn this.defaultModel?.provider === model.provider && this.defaultModel.id === model.id;\n\t}\n\n\tprivate isDefaultSearch(query: string): boolean {\n\t\tconst normalized = query.trim().toLowerCase();\n\t\treturn normalized.length > 0 && \"default\".startsWith(normalized);\n\t}\n\n\tprivate setScope(scope: ModelScope): void {\n\t\tif (this.scope === scope) return;\n\t\tthis.scope = scope;\n\t\tthis.activeModels = this.scope === \"scoped\" ? this.scopedModelItems : this.allModels;\n\t\tthis.rebuildRows();\n\t\tthis.selectedIndex = this.defaultSelectedIndex();\n\t\tthis.renderHeader();\n\t\tthis.filterRows(this.searchInput.getValue());\n\t}\n\n\tprivate rowSearchText(row: Row): string {\n\t\tif (row.kind === \"provider\") return row.provider;\n\t\tconst defaultText = this.isDefaultModel(row.item.model) ? \" default\" : \"\";\n\t\treturn `${getModelSelectorSearchText({ id: row.item.id, provider: row.item.provider, name: row.item.model.name })}${defaultText}`;\n\t}\n\n\tprivate isDefaultRow(row: Row): boolean {\n\t\treturn row.kind === \"model\" && this.isDefaultModel(row.item.model);\n\t}\n\n\tprivate filterRows(query: string): void {\n\t\tif (!query) {\n\t\t\tthis.filteredRows = this.rows;\n\t\t} else {\n\t\t\tconst filtered = fuzzyFilter(this.rows, query, (row) => this.rowSearchText(row));\n\t\t\tif (this.isDefaultSearch(query)) {\n\t\t\t\t// Typing \"default\" (or a prefix of it) surfaces the default model even\n\t\t\t\t// when its name doesn't otherwise fuzzy-match the query.\n\t\t\t\tconst defaultRows = this.rows.filter((row) => this.isDefaultRow(row));\n\t\t\t\tthis.filteredRows = [...defaultRows, ...filtered.filter((row) => !this.isDefaultRow(row))];\n\t\t\t} else {\n\t\t\t\tthis.filteredRows = filtered;\n\t\t\t}\n\t\t}\n\t\t// When filtering by a query, move the selector to the top row so the best\n\t\t// match is highlighted. When the query is cleared, keep the current position\n\t\t// clamped to the (restored) list length.\n\t\tthis.selectedIndex = query ? 0 : Math.min(this.selectedIndex, Math.max(0, this.filteredRows.length - 1));\n\t\tthis.updateList();\n\t}\n\n\tprivate renderRow(row: Row, isSelected: boolean): string {\n\t\tconst prefix = isSelected ? \"→ \" : \" \";\n\t\tif (row.kind === \"provider\") {\n\t\t\tconst count = theme.fg(\"muted\", ` (${row.count} model${row.count === 1 ? \"\" : \"s\"})`);\n\t\t\tconst checkmark = row.hasCurrent ? theme.fg(\"success\", \" ✓\") : \"\";\n\t\t\tconst composed = `${prefix}${row.provider}${count}${checkmark}`;\n\t\t\treturn isSelected ? paintSelectedRow(composed) : composed;\n\t\t}\n\t\tconst showProvider = this.step.kind === \"models\" && this.step.provider === null;\n\t\tconst badge = showProvider ? ` ${theme.fg(\"muted\", `[${row.item.provider}]`)}` : \"\";\n\t\tconst defaultBadge = this.isDefaultModel(row.item.model) ? theme.fg(\"muted\", \" · default\") : \"\";\n\t\tconst checkmark = modelsAreEqual(this.currentModel, row.item.model) ? theme.fg(\"success\", \" ✓\") : \"\";\n\t\tconst composed = `${prefix}${row.item.id}${badge}${defaultBadge}${checkmark}`;\n\t\treturn isSelected ? paintSelectedRow(composed) : composed;\n\t}\n\n\tprivate updateList(): void {\n\t\tthis.listContainer.clear();\n\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(this.selectedIndex - Math.floor(MAX_VISIBLE_ROWS / 2), this.filteredRows.length - MAX_VISIBLE_ROWS),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + MAX_VISIBLE_ROWS, this.filteredRows.length);\n\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst row = this.filteredRows[i];\n\t\t\tif (!row) continue;\n\t\t\tthis.listContainer.addChild(new Text(this.renderRow(row, i === this.selectedIndex), 0, 0));\n\t\t}\n\n\t\t// Add scroll indicator if needed\n\t\tif (startIndex > 0 || endIndex < this.filteredRows.length) {\n\t\t\tconst scrollInfo = theme.fg(\"muted\", ` (${this.selectedIndex + 1}/${this.filteredRows.length})`);\n\t\t\tthis.listContainer.addChild(new Text(scrollInfo, 0, 0));\n\t\t}\n\n\t\t// Show error message or \"no results\" if empty\n\t\tif (this.errorMessage) {\n\t\t\t// Show error in red\n\t\t\tconst errorLines = this.errorMessage.split(\"\\n\");\n\t\t\tfor (const line of errorLines) {\n\t\t\t\tthis.listContainer.addChild(new Text(theme.fg(\"error\", line), 0, 0));\n\t\t\t}\n\t\t} else if (this.filteredRows.length === 0) {\n\t\t\tconst empty = this.step.kind === \"providers\" ? \" No matching providers\" : \" No matching models\";\n\t\t\tthis.listContainer.addChild(new Text(theme.fg(\"muted\", empty), 0, 0));\n\t\t} else {\n\t\t\tconst selected = this.filteredRows[this.selectedIndex];\n\t\t\tif (selected?.kind === \"model\") {\n\t\t\t\tthis.listContainer.addChild(new Spacer(1));\n\t\t\t\tthis.listContainer.addChild(new Text(theme.fg(\"muted\", ` Model Name: ${selected.item.model.name}`), 0, 0));\n\t\t\t}\n\t\t}\n\t\tif (this.refreshStatusMessage) {\n\t\t\tthis.listContainer.addChild(new Spacer(1));\n\t\t\tthis.listContainer.addChild(\n\t\t\t\tnew Text(theme.fg(this.refreshStatusSuccess ? \"success\" : \"muted\", ` ${this.refreshStatusMessage}`), 0, 0),\n\t\t\t);\n\t\t}\n\t}\n\n\thandleInput(keyData: string): void {\n\t\tconst kb = getKeybindings();\n\t\tif (kb.matches(keyData, \"tui.input.tab\")) {\n\t\t\tif (this.scopedModelItems.length > 0) {\n\t\t\t\tthis.setScope(this.scope === \"all\" ? \"scoped\" : \"all\");\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\t// Up arrow - wrap to bottom when at top\n\t\tif (kb.matches(keyData, \"tui.select.up\")) {\n\t\t\tif (this.filteredRows.length === 0) return;\n\t\t\tthis.selectedIndex = this.selectedIndex === 0 ? this.filteredRows.length - 1 : this.selectedIndex - 1;\n\t\t\tthis.updateList();\n\t\t}\n\t\t// Down arrow - wrap to top when at bottom\n\t\telse if (kb.matches(keyData, \"tui.select.down\")) {\n\t\t\tif (this.filteredRows.length === 0) return;\n\t\t\tthis.selectedIndex = this.selectedIndex === this.filteredRows.length - 1 ? 0 : this.selectedIndex + 1;\n\t\t\tthis.updateList();\n\t\t}\n\t\t// Enter\n\t\telse if (kb.matches(keyData, \"tui.select.confirm\")) {\n\t\t\tthis.confirmSelection();\n\t\t}\n\t\t// Escape or Ctrl+C\n\t\telse if (kb.matches(keyData, \"tui.select.cancel\")) {\n\t\t\t// Escape steps back through the two levels; Ctrl+C always closes.\n\t\t\tif (matchesKey(keyData, Key.escape) && this.canGoBack()) {\n\t\t\t\tthis.setStep({ kind: \"providers\" });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.dispose();\n\t\t\tthis.onCancelCallback();\n\t\t}\n\t\t// Ctrl+S — select and save as default\n\t\telse if (matchesKey(keyData, \"ctrl+s\") && this.onSelectAsDefaultCallback) {\n\t\t\tconst row = this.filteredRows[this.selectedIndex];\n\t\t\tif (row?.kind === \"model\") {\n\t\t\t\tthis.dispose();\n\t\t\t\tthis.onSelectAsDefaultCallback(row.item.model);\n\t\t\t}\n\t\t}\n\t\t// Pass everything else to search input\n\t\telse {\n\t\t\tthis.searchInput.handleInput(keyData);\n\t\t\tthis.filterRows(this.searchInput.getValue());\n\t\t}\n\t}\n\n\tprivate confirmSelection(): void {\n\t\tconst row = this.filteredRows[this.selectedIndex];\n\t\tif (!row) return;\n\t\tif (row.kind === \"provider\") {\n\t\t\tthis.setStep({ kind: \"models\", provider: row.provider });\n\t\t\treturn;\n\t\t}\n\t\tthis.handleSelect(row.item.model);\n\t}\n\n\tprivate handleSelect(model: Model<any>): void {\n\t\tthis.dispose();\n\t\tthis.onSelectCallback(model);\n\t}\n\n\tgetSearchInput(): Input {\n\t\treturn this.searchInput;\n\t}\n}\n"]}
1
+ {"version":3,"file":"model-selector.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/model-selector.ts"],"names":[],"mappings":"AAAA,OAAO,EAGN,KAAK,KAAK,EACV,KAAK,kBAAkB,EAEvB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAEN,SAAS,EACT,KAAK,SAAS,EAGd,KAAK,EAKL,KAAK,GAAG,EACR,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAsBnE,UAAU,eAAe;IACxB,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,UAAU,qBAAqB;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;CACX;AAgBD;;GAEG;AACH,qBAAa,sBAAuB,SAAQ,SAAU,YAAW,SAAS;IACzE,OAAO,CAAC,WAAW,CAAQ;IAG3B,OAAO,CAAC,QAAQ,CAAS;IACzB,IAAI,OAAO,IAAI,OAAO,CAErB;IACD,IAAI,OAAO,CAAC,KAAK,EAAE,OAAO,EAGzB;IACD,OAAO,CAAC,eAAe,CAAY;IACnC,OAAO,CAAC,aAAa,CAAY;IACjC,OAAO,CAAC,eAAe,CAAY;IACnC,OAAO,CAAC,SAAS,CAAmB;IACpC,OAAO,CAAC,gBAAgB,CAAmB;IAC3C,OAAO,CAAC,YAAY,CAAmB;IACvC,OAAO,CAAC,IAAI,CAAa;IACzB,OAAO,CAAC,YAAY,CAAa;IACjC,OAAO,CAAC,aAAa,CAAa;IAClC,OAAO,CAAC,YAAY,CAAC,CAAa;IAClC,OAAO,CAAC,YAAY,CAAe;IACnC,OAAO,CAAC,gBAAgB,CAAkE;IAC1F,OAAO,CAAC,yBAAyB,CAAC,CAAkE;IACpG,OAAO,CAAC,gBAAgB,CAAa;IACrC,OAAO,CAAC,YAAY,CAAC,CAAS;IAC9B,OAAO,CAAC,oBAAoB,CAAgC;IAC5D,OAAO,CAAC,oBAAoB,CAAS;IACrC,OAAO,CAAC,GAAG,CAAM;IACjB,OAAO,CAAC,YAAY,CAAiC;IACrD,OAAO,CAAC,YAAY,CAAC,CAAwB;IAC7C,OAAO,CAAC,KAAK,CAAqB;IAClC,OAAO,CAAC,IAAI,CAA+B;IAC3C,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAyB;IAChE,OAAO,CAAC,cAAc,CAAC,CAAgC;IACvD,OAAO,CAAC,MAAM,CAAS;IACvB,sFAAsF;IACtF,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAyC;IACtE,0FAA0F;IAC1F,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAqB;IACxD,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAC,CAAqB;IAC3D;;;;OAIG;IACH,OAAO,CAAC,iBAAiB,CAAS;IAElC,YACC,GAAG,EAAE,GAAG,EACR,YAAY,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,SAAS,EACpC,YAAY,EAAE,YAAY,EAC1B,YAAY,EAAE,aAAa,CAAC,eAAe,CAAC,EAC5C,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,EAAE,aAAa,CAAC,EAAE,kBAAkB,KAAK,IAAI,EACzE,QAAQ,EAAE,MAAM,IAAI,EACpB,kBAAkB,CAAC,EAAE,MAAM,EAC3B,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,EAAE,aAAa,CAAC,EAAE,kBAAkB,KAAK,IAAI,EACnF,YAAY,CAAC,EAAE,qBAAqB,EACpC,OAAO,GAAE;QAAE,aAAa,CAAC,EAAE,kBAAkB,CAAA;KAAO,EAyDpD;IAED,OAAO,CAAC,WAAW;IAOnB,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,sBAAsB;YAmBhB,aAAa;IA0C3B,OAAO,IAAI,IAAI,CAKd;IAED,OAAO,CAAC,UAAU;IAiBlB,OAAO,CAAC,WAAW;IA2BnB,OAAO,CAAC,oBAAoB;IAO5B,OAAO,CAAC,OAAO;IAWf,OAAO,CAAC,WAAW;IAInB,gGAAgG;IAChG,OAAO,CAAC,SAAS;IAIjB,OAAO,CAAC,YAAY;IAqBpB,OAAO,CAAC,YAAY;IAKpB,OAAO,CAAC,YAAY;IAMpB,OAAO,CAAC,WAAW;IAoBnB,oFAAoF;IACpF,OAAO,CAAC,mBAAmB;IAM3B,OAAO,CAAC,SAAS;IAajB,OAAO,CAAC,YAAY;IAWpB,mFAAmF;IACnF,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,mBAAmB;IAI3B;;;;;;;;OAQG;IACH,OAAO,CAAC,mBAAmB;IAa3B,OAAO,CAAC,cAAc;IAItB,OAAO,CAAC,eAAe;IAKvB,OAAO,CAAC,QAAQ;IAUhB,OAAO,CAAC,aAAa;IAMrB,OAAO,CAAC,YAAY;IAIpB,OAAO,CAAC,UAAU;IAwBlB,OAAO,CAAC,iBAAiB;IAQzB;;;OAGG;IACH,OAAO,CAAC,mBAAmB;IAwB3B,OAAO,CAAC,gBAAgB;IAaxB,OAAO,CAAC,UAAU;IA+ClB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAmEjC;IAED,OAAO,CAAC,gBAAgB;IAWxB,cAAc,IAAI,KAAK,CAEtB;CACD","sourcesContent":["import {\n\tclampThinkingLevel,\n\tgetSupportedThinkingLevels,\n\ttype Model,\n\ttype ModelThinkingLevel,\n\tmodelsAreEqual,\n} from \"@earendil-works/pi-ai\";\nimport {\n\ttype Component,\n\tContainer,\n\ttype Focusable,\n\tfuzzyFilter,\n\tgetKeybindings,\n\tInput,\n\tKey,\n\tmatchesKey,\n\tSpacer,\n\tText,\n\ttype TUI,\n} from \"@earendil-works/pi-tui\";\nimport type { ModelRuntime } from \"../../../core/model-runtime.ts\";\nimport { refreshModelCatalogs } from \"../model-catalog-refresh.ts\";\nimport { getModelSelectorSearchText } from \"../model-search.ts\";\nimport { paintSelectedRow, paintSelectedRowToWidth, theme } from \"../theme/theme.ts\";\nimport { DynamicBorder } from \"./dynamic-border.ts\";\nimport { keyHint, keyText, rawKeyHint } from \"./keybinding-hints.ts\";\nimport {\n\tcomposeModelRow,\n\ttype EffortLayout,\n\ttype EffortLayoutRow,\n\tgetEffortLayout,\n\tNO_EFFORT_CLUSTER,\n\trenderPricePanel,\n\ttype TrailingSegment,\n} from \"./model-row.ts\";\n\ninterface ModelItem {\n\tprovider: string;\n\tid: string;\n\tmodel: Model<any>;\n}\n\ninterface ScopedModelItem {\n\tmodel: Model<any>;\n\tthinkingLevel?: string;\n}\n\ninterface DefaultModelReference {\n\tprovider: string;\n\tid: string;\n}\n\ntype ModelScope = \"all\" | \"scoped\";\n\n/**\n * Which of the two steps the selector is on. `provider: null` lists every provider's models at once,\n * which is how a `/model <search>` invocation and a single-provider setup both open.\n */\ntype Step = { kind: \"providers\" } | { kind: \"models\"; provider: string | null };\n\ntype Row =\n\t| { kind: \"provider\"; provider: string; count: number; hasCurrent: boolean }\n\t| { kind: \"model\"; item: ModelItem };\n\nconst MAX_VISIBLE_ROWS = 10;\n\n/**\n * Component that renders a two-step model selector: provider first, then that provider's models.\n */\nexport class ModelSelectorComponent extends Container implements Focusable {\n\tprivate searchInput: Input;\n\n\t// Focusable implementation - propagate to searchInput for IME cursor positioning\n\tprivate _focused = false;\n\tget focused(): boolean {\n\t\treturn this._focused;\n\t}\n\tset focused(value: boolean) {\n\t\tthis._focused = value;\n\t\tthis.searchInput.focused = value;\n\t}\n\tprivate headerContainer: Container;\n\tprivate listContainer: Container;\n\tprivate footerContainer: Container;\n\tprivate allModels: ModelItem[] = [];\n\tprivate scopedModelItems: ModelItem[] = [];\n\tprivate activeModels: ModelItem[] = [];\n\tprivate rows: Row[] = [];\n\tprivate filteredRows: Row[] = [];\n\tprivate selectedIndex: number = 0;\n\tprivate currentModel?: Model<any>;\n\tprivate modelRuntime: ModelRuntime;\n\tprivate onSelectCallback: (model: Model<any>, thinkingLevel?: ModelThinkingLevel) => void;\n\tprivate onSelectAsDefaultCallback?: (model: Model<any>, thinkingLevel?: ModelThinkingLevel) => void;\n\tprivate onCancelCallback: () => void;\n\tprivate errorMessage?: string;\n\tprivate refreshStatusMessage = \"Refreshing model catalogs…\";\n\tprivate refreshStatusSuccess = false;\n\tprivate tui: TUI;\n\tprivate scopedModels: ReadonlyArray<ScopedModelItem>;\n\tprivate defaultModel?: DefaultModelReference;\n\tprivate scope: ModelScope = \"all\";\n\tprivate step: Step = { kind: \"providers\" };\n\tprivate readonly refreshAbortController = new AbortController();\n\tprivate refreshTimeout?: ReturnType<typeof setTimeout>;\n\tprivate closed = false;\n\t/** Effort dialled per model in this session of the picker, keyed by `provider/id`. */\n\tprivate readonly effortLevels = new Map<string, ModelThinkingLevel>();\n\t/** Models whose effort the user actually moved. Only those override the session level. */\n\tprivate readonly editedEffortModels = new Set<string>();\n\tprivate readonly initialThinkingLevel?: ModelThinkingLevel;\n\t/**\n\t * True once up or down has moved the selection into the list. Until then\n\t * left and right belong to the search field's cursor, which is where they\n\t * are needed while a query is still being typed.\n\t */\n\tprivate navigatedIntoList = false;\n\n\tconstructor(\n\t\ttui: TUI,\n\t\tcurrentModel: Model<any> | undefined,\n\t\tmodelRuntime: ModelRuntime,\n\t\tscopedModels: ReadonlyArray<ScopedModelItem>,\n\t\tonSelect: (model: Model<any>, thinkingLevel?: ModelThinkingLevel) => void,\n\t\tonCancel: () => void,\n\t\tinitialSearchInput?: string,\n\t\tonSelectAsDefault?: (model: Model<any>, thinkingLevel?: ModelThinkingLevel) => void,\n\t\tdefaultModel?: DefaultModelReference,\n\t\toptions: { thinkingLevel?: ModelThinkingLevel } = {},\n\t) {\n\t\tsuper();\n\n\t\tthis.tui = tui;\n\t\tthis.initialThinkingLevel = options.thinkingLevel;\n\t\tthis.currentModel = currentModel;\n\t\tthis.modelRuntime = modelRuntime;\n\t\tthis.scopedModels = scopedModels;\n\t\tthis.defaultModel = defaultModel;\n\t\tthis.scope = scopedModels.length > 0 ? \"scoped\" : \"all\";\n\t\tthis.onSelectCallback = onSelect;\n\t\tthis.onSelectAsDefaultCallback = onSelectAsDefault;\n\t\tthis.onCancelCallback = onCancel;\n\n\t\t// Add top border\n\t\tthis.addChild(new DynamicBorder());\n\t\tthis.addChild(new Spacer(1));\n\n\t\tthis.headerContainer = new Container();\n\t\tthis.addChild(this.headerContainer);\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create search input\n\t\tthis.searchInput = new Input();\n\t\tif (initialSearchInput) {\n\t\t\tthis.searchInput.setValue(initialSearchInput);\n\t\t}\n\t\tthis.searchInput.onSubmit = () => this.confirmSelection();\n\t\tthis.addChild(this.searchInput);\n\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create list container\n\t\tthis.listContainer = new Container();\n\t\tthis.addChild(this.listContainer);\n\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Keybindings live under the list, where the eye already is once it has\n\t\t// finished reading. Above the list they were the first thing read and the\n\t\t// least useful.\n\t\tthis.footerContainer = new Container();\n\t\tthis.addChild(this.footerContainer);\n\n\t\t// Add bottom border\n\t\tthis.addChild(new DynamicBorder());\n\n\t\t// Render the current snapshot immediately, then refresh in the background.\n\t\tthis.loadModelsFromSnapshot();\n\t\tthis.step = this.initialStep(initialSearchInput);\n\t\tthis.rebuildRows();\n\t\tthis.selectedIndex = this.defaultSelectedIndex();\n\t\tthis.renderHeader();\n\t\tthis.filterRows(this.searchInput.getValue());\n\t\tthis.tui.requestRender();\n\t\tvoid this.refreshModels();\n\t}\n\n\tprivate initialStep(initialSearchInput: string | undefined): Step {\n\t\tconst providers = this.activeProviders();\n\t\tif (initialSearchInput) return { kind: \"models\", provider: null };\n\t\tif (providers.length <= 1) return { kind: \"models\", provider: providers[0] ?? null };\n\t\treturn { kind: \"providers\" };\n\t}\n\n\tprivate activeProviders(): string[] {\n\t\treturn [...new Set(this.activeModels.map((item) => item.provider))];\n\t}\n\n\tprivate loadModelsFromSnapshot(): void {\n\t\tconst models = this.modelRuntime.getAvailableSnapshot().map((model: Model<any>) => ({\n\t\t\tprovider: model.provider,\n\t\t\tid: model.id,\n\t\t\tmodel,\n\t\t}));\n\t\tthis.allModels = this.sortModels(models);\n\t\tthis.scopedModels = this.scopedModels.map((scoped) => {\n\t\t\tconst refreshed = this.modelRuntime.getModel(scoped.model.provider, scoped.model.id);\n\t\t\treturn refreshed ? { ...scoped, model: refreshed } : scoped;\n\t\t});\n\t\tthis.scopedModelItems = this.scopedModels.map((scoped) => ({\n\t\t\tprovider: scoped.model.provider,\n\t\t\tid: scoped.model.id,\n\t\t\tmodel: scoped.model,\n\t\t}));\n\t\tthis.activeModels = this.scope === \"scoped\" ? this.scopedModelItems : this.allModels;\n\t}\n\n\tprivate async refreshModels(): Promise<void> {\n\t\tconst timeoutMs = 15_000;\n\t\tlet timedOut = false;\n\t\tthis.refreshTimeout = setTimeout(() => {\n\t\t\ttimedOut = true;\n\t\t\tthis.refreshAbortController.abort();\n\t\t}, timeoutMs);\n\t\ttry {\n\t\t\tconst result = await refreshModelCatalogs(this.modelRuntime, this.refreshAbortController.signal);\n\t\t\tif (this.closed) return;\n\t\t\tthis.refreshStatusMessage = \"\";\n\t\t\tif (result.aborted && timedOut) {\n\t\t\t\tthis.errorMessage = \"Model refresh timed out; showing cached models.\";\n\t\t\t} else if (result.errors.size === 1) {\n\t\t\t\tthis.errorMessage = `Could not refresh ${result.errors.keys().next().value}; showing cached models.`;\n\t\t\t} else if (result.errors.size > 1) {\n\t\t\t\tthis.errorMessage = `Could not refresh ${result.errors.size} model catalogs (${[...result.errors.keys()].join(\", \")}); showing cached models.`;\n\t\t\t} else {\n\t\t\t\tthis.errorMessage = this.modelRuntime.getError();\n\t\t\t\tif (!this.errorMessage) {\n\t\t\t\t\tthis.refreshStatusMessage = \"Model catalogs refreshed.\";\n\t\t\t\t\tthis.refreshStatusSuccess = true;\n\t\t\t\t}\n\t\t\t}\n\t\t\tthis.loadModelsFromSnapshot();\n\t\t\tthis.rebuildRows();\n\t\t\tthis.renderHeader();\n\t\t\tthis.filterRows(this.searchInput.getValue());\n\t\t\tthis.tui.requestRender();\n\t\t} catch (error) {\n\t\t\tif (this.closed) return;\n\t\t\tthis.refreshStatusMessage = \"\";\n\t\t\tthis.errorMessage = timedOut\n\t\t\t\t? \"Model refresh timed out; showing cached models.\"\n\t\t\t\t: `Could not refresh model catalogs: ${error instanceof Error ? error.message : String(error)}`;\n\t\t\tthis.updateList();\n\t\t\tthis.tui.requestRender();\n\t\t} finally {\n\t\t\tif (this.refreshTimeout) clearTimeout(this.refreshTimeout);\n\t\t}\n\t}\n\n\tdispose(): void {\n\t\tif (this.closed) return;\n\t\tthis.closed = true;\n\t\tif (this.refreshTimeout) clearTimeout(this.refreshTimeout);\n\t\tthis.refreshAbortController.abort();\n\t}\n\n\tprivate sortModels(models: ModelItem[]): ModelItem[] {\n\t\tconst sorted = [...models];\n\t\t// Sort: current model first, default model second, then by provider.\n\t\tsorted.sort((a, b) => {\n\t\t\tconst aIsCurrent = modelsAreEqual(this.currentModel, a.model);\n\t\t\tconst bIsCurrent = modelsAreEqual(this.currentModel, b.model);\n\t\t\tif (aIsCurrent && !bIsCurrent) return -1;\n\t\t\tif (!aIsCurrent && bIsCurrent) return 1;\n\t\t\tconst aIsDefault = this.isDefaultModel(a.model);\n\t\t\tconst bIsDefault = this.isDefaultModel(b.model);\n\t\t\tif (aIsDefault && !bIsDefault) return -1;\n\t\t\tif (!aIsDefault && bIsDefault) return 1;\n\t\t\treturn a.provider.localeCompare(b.provider);\n\t\t});\n\t\treturn sorted;\n\t}\n\n\tprivate rebuildRows(): void {\n\t\tif (this.step.kind === \"providers\") {\n\t\t\tconst byProvider = new Map<string, ModelItem[]>();\n\t\t\tfor (const item of this.activeModels) {\n\t\t\t\tconst existing = byProvider.get(item.provider);\n\t\t\t\tif (existing) existing.push(item);\n\t\t\t\telse byProvider.set(item.provider, [item]);\n\t\t\t}\n\t\t\tconst providerRows: Row[] = [...byProvider].map(([provider, items]) => ({\n\t\t\t\tkind: \"provider\",\n\t\t\t\tprovider,\n\t\t\t\tcount: items.length,\n\t\t\t\thasCurrent: items.some((item) => modelsAreEqual(this.currentModel, item.model)),\n\t\t\t}));\n\t\t\tproviderRows.sort((a, b) => {\n\t\t\t\tif (a.kind !== \"provider\" || b.kind !== \"provider\") return 0;\n\t\t\t\tif (a.hasCurrent !== b.hasCurrent) return a.hasCurrent ? -1 : 1;\n\t\t\t\treturn a.provider.localeCompare(b.provider);\n\t\t\t});\n\t\t\tthis.rows = providerRows;\n\t\t\treturn;\n\t\t}\n\t\tconst provider = this.step.provider;\n\t\tconst models = provider === null ? this.activeModels : this.activeModels.filter((m) => m.provider === provider);\n\t\tthis.rows = models.map((item) => ({ kind: \"model\", item }));\n\t}\n\n\tprivate defaultSelectedIndex(): number {\n\t\tconst index = this.rows.findIndex((row) =>\n\t\t\trow.kind === \"provider\" ? row.hasCurrent : modelsAreEqual(this.currentModel, row.item.model),\n\t\t);\n\t\treturn index >= 0 ? index : 0;\n\t}\n\n\tprivate setStep(step: Step): void {\n\t\tthis.step = step;\n\t\tthis.navigatedIntoList = false;\n\t\tthis.searchInput.setValue(\"\");\n\t\tthis.rebuildRows();\n\t\tthis.selectedIndex = this.defaultSelectedIndex();\n\t\tthis.renderHeader();\n\t\tthis.filterRows(\"\");\n\t\tthis.tui.requestRender();\n\t}\n\n\tprivate isDrilledIn(): boolean {\n\t\treturn this.step.kind === \"models\" && this.step.provider !== null;\n\t}\n\n\t/** Escape returns to the provider list whenever there is a provider list worth returning to. */\n\tprivate canGoBack(): boolean {\n\t\treturn this.step.kind === \"models\" && this.activeProviders().length > 1;\n\t}\n\n\tprivate renderHeader(): void {\n\t\tthis.headerContainer.clear();\n\n\t\tif (this.step.kind === \"providers\") {\n\t\t\tthis.headerContainer.addChild(new Text(theme.bold(theme.fg(\"accent\", \"Select a provider\")), 0, 0));\n\t\t} else {\n\t\t\tconst label = this.step.provider ?? \"All providers\";\n\t\t\tconst crumb = `${theme.bold(theme.fg(\"accent\", label))}${theme.fg(\"muted\", \" › select a model\")}`;\n\t\t\tthis.headerContainer.addChild(new Text(crumb, 0, 0));\n\t\t}\n\n\t\tif (this.scopedModelItems.length > 0) {\n\t\t\tthis.headerContainer.addChild(new Text(this.getScopeText(), 0, 0));\n\t\t} else if (!this.isDrilledIn()) {\n\t\t\tconst hintText = \"Only showing models from configured providers. Use /login to add providers.\";\n\t\t\tthis.headerContainer.addChild(new Text(theme.fg(\"warning\", hintText), 0, 0));\n\t\t}\n\n\t\tthis.renderFooter();\n\t}\n\n\tprivate renderFooter(): void {\n\t\tthis.footerContainer.clear();\n\t\tthis.footerContainer.addChild(new Text(this.getHintText(), 0, 0));\n\t}\n\n\tprivate getScopeText(): string {\n\t\tconst allText = this.scope === \"all\" ? theme.fg(\"accent\", \"all\") : theme.fg(\"muted\", \"all\");\n\t\tconst scopedText = this.scope === \"scoped\" ? theme.fg(\"accent\", \"scoped\") : theme.fg(\"muted\", \"scoped\");\n\t\treturn `${theme.fg(\"muted\", \"Scope: \")}${allText}${theme.fg(\"muted\", \" | \")}${scopedText}`;\n\t}\n\n\tprivate getHintText(): string {\n\t\tconst hints: string[] = [\n\t\t\t// Both arrows, since either one moves the selection.\n\t\t\trawKeyHint(`${keyText(\"tui.select.up\")}/${keyText(\"tui.select.down\")}`, \"move\"),\n\t\t\tkeyHint(\"tui.select.confirm\", \"select\"),\n\t\t];\n\t\tif (this.hasAdjustableEffort()) {\n\t\t\tconst left = keyText(\"tui.editor.cursorLeft\", { primaryOnly: true });\n\t\t\tconst right = keyText(\"tui.editor.cursorRight\", { primaryOnly: true });\n\t\t\thints.push(rawKeyHint(`${left}/${right}`, \"effort\"));\n\t\t}\n\t\tif (this.scopedModelItems.length > 0) {\n\t\t\thints.push(keyHint(\"tui.input.tab\", \"scope\") + theme.fg(\"muted\", \" (all/scoped)\"));\n\t\t}\n\t\tif (this.onSelectAsDefaultCallback) hints.push(rawKeyHint(\"ctrl+s\", \"set as default\"));\n\t\tif (this.canGoBack()) hints.push(rawKeyHint(\"escape\", \"providers\"), rawKeyHint(\"ctrl+c\", \"close\"));\n\t\telse hints.push(keyHint(\"tui.select.cancel\", \"close\"));\n\t\treturn hints.join(theme.fg(\"borderMuted\", \" · \"));\n\t}\n\n\t/** Levels worth dialling. A model with nothing but \"off\" has no cluster to show. */\n\tprivate getSelectableLevels(item: ModelItem): ModelThinkingLevel[] {\n\t\tconst levels = getSupportedThinkingLevels(item.model);\n\t\tif (levels.length === 1 && levels[0] === \"off\") return [];\n\t\treturn levels;\n\t}\n\n\tprivate getEffort(item: ModelItem): ModelThinkingLevel | undefined {\n\t\tconst levels = this.getSelectableLevels(item);\n\t\tif (levels.length === 0) return undefined;\n\t\tconst key = `${item.provider}/${item.id}`;\n\t\tconst stored = this.effortLevels.get(key);\n\t\tif (stored !== undefined && levels.includes(stored)) return stored;\n\t\tconst requested = this.initialThinkingLevel ?? \"off\";\n\t\tconst clamped = levels.includes(requested) ? requested : clampThinkingLevel(item.model, requested);\n\t\tconst resolved = levels.includes(clamped) ? clamped : levels[0]!;\n\t\tthis.effortLevels.set(key, resolved);\n\t\treturn resolved;\n\t}\n\n\tprivate adjustEffort(item: ModelItem, direction: number): boolean {\n\t\tconst levels = this.getSelectableLevels(item);\n\t\tif (levels.length === 0) return false;\n\t\tconst key = `${item.provider}/${item.id}`;\n\t\tconst current = this.getEffort(item) ?? levels[0]!;\n\t\tconst next = levels[(levels.indexOf(current) + direction + levels.length) % levels.length]!;\n\t\tthis.effortLevels.set(key, next);\n\t\tthis.editedEffortModels.add(key);\n\t\treturn true;\n\t}\n\n\t/** The effort to hand back on select, or undefined when the user left it alone. */\n\tprivate getChosenEffort(item: ModelItem): ModelThinkingLevel | undefined {\n\t\treturn this.editedEffortModels.has(`${item.provider}/${item.id}`) ? this.getEffort(item) : undefined;\n\t}\n\n\tprivate hasAdjustableEffort(): boolean {\n\t\treturn this.filteredRows.some((row) => row.kind === \"model\" && this.getSelectableLevels(row.item).length > 0);\n\t}\n\n\t/**\n\t * Context that trails a model row, right-aligned, in reading order.\n\t *\n\t * Each segment carries what it costs to lose, because a narrow row drops by\n\t * that and not by position. `current` outranks the id: the row already shows\n\t * the model's name, so the id is largely recoverable from it, while nothing\n\t * else on screen says which model the session is on. The provider badge goes\n\t * first, being the least you lose.\n\t */\n\tprivate getTrailingSegments(item: ModelItem): TrailingSegment[] {\n\t\tconst segments: TrailingSegment[] = [];\n\t\tif (this.step.kind === \"models\" && this.step.provider === null) {\n\t\t\tsegments.push({ text: theme.fg(\"muted\", item.provider), priority: 0 });\n\t\t}\n\t\tsegments.push({ text: theme.fg(\"muted\", item.id), priority: 2 });\n\t\tif (this.isDefaultModel(item.model)) segments.push({ text: theme.fg(\"muted\", \"default\"), priority: 1 });\n\t\tif (modelsAreEqual(this.currentModel, item.model)) {\n\t\t\tsegments.push({ text: theme.fg(\"success\", \"current\"), priority: 3 });\n\t\t}\n\t\treturn segments;\n\t}\n\n\tprivate isDefaultModel(model: Model<any>): boolean {\n\t\treturn this.defaultModel?.provider === model.provider && this.defaultModel.id === model.id;\n\t}\n\n\tprivate isDefaultSearch(query: string): boolean {\n\t\tconst normalized = query.trim().toLowerCase();\n\t\treturn normalized.length > 0 && \"default\".startsWith(normalized);\n\t}\n\n\tprivate setScope(scope: ModelScope): void {\n\t\tif (this.scope === scope) return;\n\t\tthis.scope = scope;\n\t\tthis.activeModels = this.scope === \"scoped\" ? this.scopedModelItems : this.allModels;\n\t\tthis.rebuildRows();\n\t\tthis.selectedIndex = this.defaultSelectedIndex();\n\t\tthis.renderHeader();\n\t\tthis.filterRows(this.searchInput.getValue());\n\t}\n\n\tprivate rowSearchText(row: Row): string {\n\t\tif (row.kind === \"provider\") return row.provider;\n\t\tconst defaultText = this.isDefaultModel(row.item.model) ? \" default\" : \"\";\n\t\treturn `${getModelSelectorSearchText({ id: row.item.id, provider: row.item.provider, name: row.item.model.name })}${defaultText}`;\n\t}\n\n\tprivate isDefaultRow(row: Row): boolean {\n\t\treturn row.kind === \"model\" && this.isDefaultModel(row.item.model);\n\t}\n\n\tprivate filterRows(query: string): void {\n\t\tif (!query) {\n\t\t\tthis.filteredRows = this.rows;\n\t\t} else {\n\t\t\tconst filtered = fuzzyFilter(this.rows, query, (row) => this.rowSearchText(row));\n\t\t\tif (this.isDefaultSearch(query)) {\n\t\t\t\t// Typing \"default\" (or a prefix of it) surfaces the default model even\n\t\t\t\t// when its name doesn't otherwise fuzzy-match the query.\n\t\t\t\tconst defaultRows = this.rows.filter((row) => this.isDefaultRow(row));\n\t\t\t\tthis.filteredRows = [...defaultRows, ...filtered.filter((row) => !this.isDefaultRow(row))];\n\t\t\t} else {\n\t\t\t\tthis.filteredRows = filtered;\n\t\t\t}\n\t\t}\n\t\t// When filtering by a query, move the selector to the top row so the best\n\t\t// match is highlighted. When the query is cleared, keep the current position\n\t\t// clamped to the (restored) list length.\n\t\tthis.selectedIndex = query ? 0 : Math.min(this.selectedIndex, Math.max(0, this.filteredRows.length - 1));\n\t\t// The effort hint depends on what survived the filter, so the footer is\n\t\t// rebuilt here rather than with the header, which runs before filtering.\n\t\tthis.renderFooter();\n\t\tthis.updateList();\n\t}\n\n\tprivate renderProviderRow(row: Extract<Row, { kind: \"provider\" }>, isSelected: boolean): string {\n\t\tconst prefix = isSelected ? \"→ \" : \" \";\n\t\tconst count = theme.fg(\"muted\", ` (${row.count} model${row.count === 1 ? \"\" : \"s\"})`);\n\t\tconst checkmark = row.hasCurrent ? theme.fg(\"success\", \" ✓\") : \"\";\n\t\tconst composed = `${prefix}${row.provider}${count}${checkmark}`;\n\t\treturn isSelected ? paintSelectedRow(composed) : composed;\n\t}\n\n\t/**\n\t * The visible page of rows, rendered together because the effort cluster is\n\t * placed once for the whole page rather than per row.\n\t */\n\tprivate createRowsComponent(startIndex: number, endIndex: number): Component {\n\t\tconst rows = this.filteredRows.slice(startIndex, endIndex);\n\t\treturn {\n\t\t\trender: (width: number): string[] => {\n\t\t\t\tconst layout = this.pageEffortLayout(rows, width);\n\t\t\t\treturn rows.map((row, offset) => {\n\t\t\t\t\tconst isSelected = startIndex + offset === this.selectedIndex;\n\t\t\t\t\tif (row.kind === \"provider\") return this.renderProviderRow(row, isSelected);\n\t\t\t\t\tconst composed = composeModelRow({\n\t\t\t\t\t\tname: row.item.model.name,\n\t\t\t\t\t\tlevels: this.getSelectableLevels(row.item),\n\t\t\t\t\t\teffort: this.getEffort(row.item),\n\t\t\t\t\t\ttrailingSegments: this.getTrailingSegments(row.item),\n\t\t\t\t\t\tselected: isSelected,\n\t\t\t\t\t\tlayout,\n\t\t\t\t\t\twidth,\n\t\t\t\t\t});\n\t\t\t\t\treturn isSelected ? paintSelectedRowToWidth(composed, width) : composed;\n\t\t\t\t});\n\t\t\t},\n\t\t\tinvalidate: () => {},\n\t\t};\n\t}\n\n\tprivate pageEffortLayout(rows: ReadonlyArray<Row>, width: number): EffortLayout {\n\t\tconst layoutRows: EffortLayoutRow[] = [];\n\t\tfor (const row of rows) {\n\t\t\tif (row.kind !== \"model\") continue;\n\t\t\tlayoutRows.push({\n\t\t\t\tname: row.item.model.name,\n\t\t\t\tlevels: this.getSelectableLevels(row.item),\n\t\t\t\ttrailingSegments: this.getTrailingSegments(row.item),\n\t\t\t});\n\t\t}\n\t\treturn layoutRows.length > 0 ? getEffortLayout(layoutRows, width) : NO_EFFORT_CLUSTER;\n\t}\n\n\tprivate updateList(): void {\n\t\tthis.listContainer.clear();\n\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(this.selectedIndex - Math.floor(MAX_VISIBLE_ROWS / 2), this.filteredRows.length - MAX_VISIBLE_ROWS),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + MAX_VISIBLE_ROWS, this.filteredRows.length);\n\n\t\tif (endIndex > startIndex) {\n\t\t\tthis.listContainer.addChild(this.createRowsComponent(startIndex, endIndex));\n\t\t}\n\n\t\t// Add scroll indicator if needed\n\t\tif (startIndex > 0 || endIndex < this.filteredRows.length) {\n\t\t\tconst scrollInfo = theme.fg(\"muted\", ` (${this.selectedIndex + 1}/${this.filteredRows.length})`);\n\t\t\tthis.listContainer.addChild(new Text(scrollInfo, 0, 0));\n\t\t}\n\n\t\t// Show error message or \"no results\" if empty\n\t\tif (this.errorMessage) {\n\t\t\t// Show error in red\n\t\t\tconst errorLines = this.errorMessage.split(\"\\n\");\n\t\t\tfor (const line of errorLines) {\n\t\t\t\tthis.listContainer.addChild(new Text(theme.fg(\"error\", line), 0, 0));\n\t\t\t}\n\t\t} else if (this.filteredRows.length === 0) {\n\t\t\tconst empty = this.step.kind === \"providers\" ? \" No matching providers\" : \" No matching models\";\n\t\t\tthis.listContainer.addChild(new Text(theme.fg(\"muted\", empty), 0, 0));\n\t\t} else {\n\t\t\tconst selected = this.filteredRows[this.selectedIndex];\n\t\t\tif (selected?.kind === \"model\") {\n\t\t\t\tconst cost = selected.item.model.cost;\n\t\t\t\tthis.listContainer.addChild({\n\t\t\t\t\trender: (width: number) => renderPricePanel(cost, width),\n\t\t\t\t\tinvalidate: () => {},\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\tif (this.refreshStatusMessage) {\n\t\t\tthis.listContainer.addChild(new Spacer(1));\n\t\t\tthis.listContainer.addChild(\n\t\t\t\tnew Text(theme.fg(this.refreshStatusSuccess ? \"success\" : \"muted\", ` ${this.refreshStatusMessage}`), 0, 0),\n\t\t\t);\n\t\t}\n\t}\n\n\thandleInput(keyData: string): void {\n\t\tconst kb = getKeybindings();\n\t\tif (kb.matches(keyData, \"tui.input.tab\")) {\n\t\t\tif (this.scopedModelItems.length > 0) {\n\t\t\t\tthis.setScope(this.scope === \"all\" ? \"scoped\" : \"all\");\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\t// Left/right dial the highlighted model's effort, but only once they are\n\t\t// free to: while a query is being typed they still drive the search cursor.\n\t\tif (\n\t\t\t(this.searchInput.getValue() === \"\" || this.navigatedIntoList) &&\n\t\t\t(kb.matches(keyData, \"tui.editor.cursorLeft\") || kb.matches(keyData, \"tui.editor.cursorRight\"))\n\t\t) {\n\t\t\tconst row = this.filteredRows[this.selectedIndex];\n\t\t\tconst direction = kb.matches(keyData, \"tui.editor.cursorLeft\") ? -1 : 1;\n\t\t\tif (row?.kind === \"model\" && this.adjustEffort(row.item, direction)) {\n\t\t\t\tthis.updateList();\n\t\t\t\tthis.tui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\t// Up arrow - wrap to bottom when at top\n\t\tif (kb.matches(keyData, \"tui.select.up\")) {\n\t\t\tif (this.filteredRows.length === 0) return;\n\t\t\tthis.navigatedIntoList = true;\n\t\t\tthis.selectedIndex = this.selectedIndex === 0 ? this.filteredRows.length - 1 : this.selectedIndex - 1;\n\t\t\tthis.updateList();\n\t\t}\n\t\t// Down arrow - wrap to top when at bottom\n\t\telse if (kb.matches(keyData, \"tui.select.down\")) {\n\t\t\tif (this.filteredRows.length === 0) return;\n\t\t\tthis.navigatedIntoList = true;\n\t\t\tthis.selectedIndex = this.selectedIndex === this.filteredRows.length - 1 ? 0 : this.selectedIndex + 1;\n\t\t\tthis.updateList();\n\t\t}\n\t\t// Enter\n\t\telse if (kb.matches(keyData, \"tui.select.confirm\")) {\n\t\t\tthis.confirmSelection();\n\t\t}\n\t\t// Escape or Ctrl+C\n\t\telse if (kb.matches(keyData, \"tui.select.cancel\")) {\n\t\t\t// Escape steps back through the two levels; Ctrl+C always closes.\n\t\t\tif (matchesKey(keyData, Key.escape) && this.canGoBack()) {\n\t\t\t\tthis.setStep({ kind: \"providers\" });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.dispose();\n\t\t\tthis.onCancelCallback();\n\t\t}\n\t\t// Ctrl+S — select and save as default\n\t\telse if (matchesKey(keyData, \"ctrl+s\") && this.onSelectAsDefaultCallback) {\n\t\t\tconst row = this.filteredRows[this.selectedIndex];\n\t\t\tif (row?.kind === \"model\") {\n\t\t\t\tthis.dispose();\n\t\t\t\tthis.onSelectAsDefaultCallback(row.item.model, this.getChosenEffort(row.item));\n\t\t\t}\n\t\t}\n\t\t// Pass everything else to search input\n\t\telse {\n\t\t\tconst previousQuery = this.searchInput.getValue();\n\t\t\tthis.searchInput.handleInput(keyData);\n\t\t\tif (previousQuery !== this.searchInput.getValue()) {\n\t\t\t\tthis.navigatedIntoList = false;\n\t\t\t}\n\t\t\tthis.filterRows(this.searchInput.getValue());\n\t\t}\n\t}\n\n\tprivate confirmSelection(): void {\n\t\tconst row = this.filteredRows[this.selectedIndex];\n\t\tif (!row) return;\n\t\tif (row.kind === \"provider\") {\n\t\t\tthis.setStep({ kind: \"models\", provider: row.provider });\n\t\t\treturn;\n\t\t}\n\t\tthis.dispose();\n\t\tthis.onSelectCallback(row.item.model, this.getChosenEffort(row.item));\n\t}\n\n\tgetSearchInput(): Input {\n\t\treturn this.searchInput;\n\t}\n}\n"]}