pi-advisor-flow 0.7.0 → 0.8.1-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +27 -18
  2. package/README.md +9 -8
  3. package/dist/index.js +1968 -1081
  4. package/extensions/index.ts +2 -1
  5. package/package.json +48 -35
  6. package/src/attachments.ts +11 -10
  7. package/src/child-session.ts +15 -0
  8. package/src/commands/activation-preparation.ts +1 -0
  9. package/src/commands/activation.ts +12 -3
  10. package/src/commands/lifecycle.ts +7 -1
  11. package/src/commands/manual-command.ts +7 -1
  12. package/src/commands/manual-consultation.ts +102 -92
  13. package/src/commands/manual-progress.ts +7 -5
  14. package/src/commands/model-commands.ts +1 -1
  15. package/src/commands/model-options.ts +14 -3
  16. package/src/commands/model-picker.ts +1 -0
  17. package/src/commands/registration.ts +1 -0
  18. package/src/commands/renderers.ts +67 -55
  19. package/src/commands/runtime.ts +16 -14
  20. package/src/commands/settings-commands.ts +10 -4
  21. package/src/commands/settings-persistence.ts +16 -2
  22. package/src/commands/types.ts +1 -0
  23. package/src/commands.ts +0 -1
  24. package/src/config/args.ts +1 -1
  25. package/src/config/defaults.ts +8 -1
  26. package/src/config/schema.ts +42 -17
  27. package/src/config/state.ts +12 -3
  28. package/src/config/storage.ts +34 -23
  29. package/src/config/types.ts +16 -3
  30. package/src/config/validation.ts +33 -21
  31. package/src/config.ts +3 -0
  32. package/src/content-utils.ts +24 -4
  33. package/src/conversation.ts +16 -15
  34. package/src/git.ts +10 -3
  35. package/src/herdr-block.ts +83 -0
  36. package/src/herdr-shared.ts +108 -0
  37. package/src/herdr.ts +30 -192
  38. package/src/jev/client.ts +75 -85
  39. package/src/jev/failure.ts +19 -0
  40. package/src/jev/key-store.ts +26 -11
  41. package/src/jev/ledger.ts +58 -64
  42. package/src/jev/questions.ts +26 -23
  43. package/src/jev/state.ts +15 -12
  44. package/src/jev/transport.ts +8 -3
  45. package/src/model-stream.ts +35 -37
  46. package/src/outcomes.ts +15 -11
  47. package/src/pi-settings.ts +4 -9
  48. package/src/preferences.ts +4 -2
  49. package/src/redaction.ts +10 -12
  50. package/src/scout-context.ts +59 -60
  51. package/src/scout-curation.ts +4 -13
  52. package/src/scout-groups.ts +178 -175
  53. package/src/scout-protocol.ts +74 -70
  54. package/src/scout-reconstruct.ts +1 -1
  55. package/src/scout.ts +128 -77
  56. package/src/session-state.ts +64 -49
  57. package/src/tool-result-cap.ts +18 -17
  58. package/src/tools/consult-context.ts +7 -15
  59. package/src/tools/consultation.ts +14 -4
  60. package/src/tools/gate-policy.ts +1 -0
  61. package/src/tools/gate-protocol.ts +36 -23
  62. package/src/tools/jev-filter.ts +43 -41
  63. package/src/tools/jev-turn-gate.ts +29 -9
  64. package/src/tools/loop-gate.ts +31 -20
  65. package/src/tools/model-access.ts +46 -0
  66. package/src/tools/prompts.ts +20 -25
  67. package/src/tools/register-ask-advisor.ts +36 -16
  68. package/src/tools/register-lifecycle.ts +22 -0
  69. package/src/tools/register-outcome.ts +2 -0
  70. package/src/tools/register-renderers.ts +146 -128
  71. package/src/tools/registration.ts +12 -14
  72. package/src/tools/render-advisor-result.ts +35 -22
  73. package/src/tools/render-common.ts +16 -19
  74. package/src/tools/scout-status.ts +42 -27
  75. package/src/tools/types.ts +1 -0
  76. package/src/tools.ts +0 -1
  77. package/src/ui/jev-setup-submenu.ts +65 -59
  78. package/src/ui/manual-dialog-render.ts +1 -0
  79. package/src/ui/manual-dialog.ts +39 -43
  80. package/src/ui/masked-input.ts +5 -11
  81. package/src/ui/model-multi-selector.ts +9 -0
  82. package/src/ui/model-selector-adapter.ts +31 -0
  83. package/src/ui/model-selector.ts +14 -161
  84. package/src/ui/searchable-model-list.ts +230 -0
  85. package/src/ui/settings-formatting.ts +24 -14
  86. package/src/ui/settings-items.ts +38 -1
  87. package/src/ui/settings-list-adapter.ts +15 -12
  88. package/src/ui/settings-mutations.ts +157 -70
  89. package/src/ui/settings-selector.ts +18 -9
  90. package/src/ui/text-setting-submenu.ts +3 -6
  91. package/src/ui/types.ts +14 -0
  92. package/src/ui.ts +0 -1
  93. package/src/usage.ts +26 -19
@@ -1,9 +1,5 @@
1
- import {
2
- type Component,
3
- type Focusable,
4
- Input,
5
- truncateToWidth,
6
- } from "@earendil-works/pi-tui";
1
+ import { Input, truncateToWidth } from "@earendil-works/pi-tui";
2
+ import type { Component, Focusable } from "@earendil-works/pi-tui";
7
3
 
8
4
  export interface MaskedInputOptions {
9
5
  onEscape?: () => void;
@@ -11,9 +7,7 @@ export interface MaskedInputOptions {
11
7
  placeholder?: string;
12
8
  }
13
9
 
14
- interface InputPrivateFields {
15
- cursor: number;
16
- }
10
+ type InputWithCursor = Input & { cursor: number };
17
11
 
18
12
  /** Single-line secret input rendering bullets; the value never reaches the screen. */
19
13
  export class MaskedInput implements Component, Focusable {
@@ -56,8 +50,8 @@ export class MaskedInput implements Component, Focusable {
56
50
 
57
51
  render(width: number): string[] {
58
52
  const value = this.input.getValue();
59
- // Compatibility-only read for the caret position; Input does not expose it.
60
- const { cursor } = this.input as unknown as InputPrivateFields;
53
+ // SAFETY: compatibility-only read of Input's private cursor; Input does not expose it.
54
+ const { cursor } = this.input as InputWithCursor;
61
55
  const masked = `${"•".repeat(Math.min(cursor, value.length))}█${"•".repeat(Math.max(0, value.length - cursor))}`;
62
56
  const placeholder =
63
57
  value.length === 0 && this.options.placeholder
@@ -0,0 +1,9 @@
1
+ import { ModelSelectorAdapter } from "./model-selector-adapter.ts";
2
+ import { SearchableModelList } from "./searchable-model-list.ts";
3
+ import type { SearchableModelMultiSelectorOptions } from "./types.ts";
4
+
5
+ export class SearchableModelMultiSelector extends ModelSelectorAdapter {
6
+ constructor(options: SearchableModelMultiSelectorOptions) {
7
+ super(new SearchableModelList(options));
8
+ }
9
+ }
@@ -0,0 +1,31 @@
1
+ import type { Component, Focusable } from "@earendil-works/pi-tui";
2
+
3
+ import type { SearchableModelList } from "./searchable-model-list.ts";
4
+
5
+ export class ModelSelectorAdapter implements Component, Focusable {
6
+ protected readonly list: SearchableModelList;
7
+
8
+ constructor(list: SearchableModelList) {
9
+ this.list = list;
10
+ }
11
+
12
+ get focused(): boolean {
13
+ return this.list.focused;
14
+ }
15
+
16
+ set focused(value: boolean) {
17
+ this.list.focused = value;
18
+ }
19
+
20
+ invalidate(): void {
21
+ this.list.invalidate();
22
+ }
23
+
24
+ render(width: number): string[] {
25
+ return this.list.render(width);
26
+ }
27
+
28
+ handleInput(keyData: string): void {
29
+ this.list.handleInput(keyData);
30
+ }
31
+ }
@@ -1,167 +1,20 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import {
3
- type Component,
4
- type Focusable,
5
- fuzzyFilter,
6
- Input,
7
- type Keybindings,
8
- type KeybindingsManager,
9
- truncateToWidth,
10
- } from "@earendil-works/pi-tui";
1
+ import { ModelSelectorAdapter } from "./model-selector-adapter.ts";
2
+ import { SearchableModelList } from "./searchable-model-list.ts";
11
3
  import type { SearchableModelSelectorOptions } from "./types.ts";
12
4
 
13
- export class SearchableModelSelector implements Component, Focusable {
14
- private readonly tui: SearchableModelSelectorOptions["tui"];
15
- private readonly searchInput: Input;
16
- private readonly allOptions: string[];
17
- private readonly currentOption: string | undefined;
18
- private filteredOptions: string[];
19
- private selectedIndex = 0;
20
- private readonly title: string;
21
- private readonly onSelect: (value: string) => void;
22
- private readonly onCancel: () => void;
23
- private readonly theme: Theme;
24
- private readonly keybindings: KeybindingsManager;
25
- private _focused = false;
26
-
27
- get focused(): boolean {
28
- return this._focused;
29
- }
30
- set focused(val: boolean) {
31
- this._focused = val;
32
- this.searchInput.focused = val;
33
- }
34
-
5
+ export class SearchableModelSelector extends ModelSelectorAdapter {
35
6
  constructor(options: SearchableModelSelectorOptions) {
36
- this.tui = options.tui;
37
- this.title = options.title;
38
- this.currentOption =
39
- options.currentOption &&
40
- options.allOptions.includes(options.currentOption)
41
- ? options.currentOption
42
- : undefined;
43
- this.allOptions = this.currentOption
44
- ? [
45
- this.currentOption,
46
- ...options.allOptions.filter((item) => item !== this.currentOption),
47
- ]
48
- : options.allOptions;
49
- this.theme = options.theme;
50
- this.keybindings = options.keybindings;
51
- this.onSelect = options.onSelect;
52
- this.onCancel = options.onCancel;
53
- this.searchInput = new Input();
54
- this.filteredOptions = this.allOptions;
55
- }
56
-
57
- invalidate(): void {
58
- this.searchInput.invalidate();
59
- }
60
-
61
- render(width: number): string[] {
62
- const lines: string[] = [this.theme.fg("border", "─".repeat(width))];
63
- lines.push(` ${this.theme.fg("accent", this.theme.bold(this.title))}`);
64
- const inputLines = this.searchInput.render(Math.max(1, width - 10));
65
- lines.push(
66
- ` ${this.theme.fg("accent", "Search: ")}${inputLines[0] || ""}`
67
- );
68
- lines.push("");
69
-
70
- const query = this.searchInput.getValue().trim();
71
- this.filteredOptions = query
72
- ? fuzzyFilter(this.allOptions, query, (item) => item)
73
- : this.allOptions;
74
- this.selectedIndex = Math.min(
75
- this.selectedIndex,
76
- Math.max(0, this.filteredOptions.length - 1)
77
- );
78
-
79
- const maxVisible = 10;
80
- const total = this.filteredOptions.length;
81
- if (total === 0) {
82
- lines.push(` ${this.theme.fg("muted", "No matching models found.")}`);
83
- } else {
84
- const startIndex = Math.max(
85
- 0,
86
- Math.min(
87
- this.selectedIndex - Math.floor(maxVisible / 2),
88
- total - maxVisible
89
- )
90
- );
91
- const endIndex = Math.min(startIndex + maxVisible, total);
92
- for (let i = startIndex; i < endIndex; i += 1) {
93
- const item = this.filteredOptions[i];
94
- const tick = item === this.currentOption ? "✓ " : " ";
95
- if (i === this.selectedIndex) {
96
- lines.push(
97
- ` ${this.theme.fg("accent", "→ ")}${this.theme.fg("accent", `${tick}${item}`)}`
98
- );
99
- } else {
100
- lines.push(` ${this.theme.fg("text", `${tick}${item}`)}`);
101
- }
102
- }
103
- if (total > maxVisible) {
104
- lines.push(
105
- " " +
106
- this.theme.fg("muted", ` (${this.selectedIndex + 1}/${total})`)
107
- );
108
- }
109
- }
110
- lines.push("");
111
- lines.push(
112
- ` ${this.theme.fg("dim", "Type to search · ↑↓: navigate · Enter: select · Esc: cancel")}`
7
+ super(
8
+ new SearchableModelList({
9
+ ...options,
10
+ currentOptions: options.currentOption ? [options.currentOption] : [],
11
+ multiSelect: false,
12
+ onSelect: ([value]) => {
13
+ if (value !== undefined) {
14
+ options.onSelect(value);
15
+ }
16
+ },
17
+ })
113
18
  );
114
- lines.push(this.theme.fg("border", "─".repeat(width)));
115
- return lines.map((line) => truncateToWidth(line, width));
116
- }
117
-
118
- handleInput(keyData: string): void {
119
- if (this.matchesAction(keyData, "tui.select.up", "\u001b[A")) {
120
- this.moveSelection(-1);
121
- return;
122
- }
123
- if (this.matchesAction(keyData, "tui.select.down", "\u001b[B")) {
124
- this.moveSelection(1);
125
- return;
126
- }
127
- if (
128
- this.matchesAction(keyData, "tui.select.confirm", "\n") ||
129
- keyData === "\r"
130
- ) {
131
- if (this.filteredOptions.length > 0) {
132
- this.onSelect(this.filteredOptions[this.selectedIndex]);
133
- }
134
- return;
135
- }
136
- if (this.matchesAction(keyData, "tui.select.cancel", "\u001b")) {
137
- this.onCancel();
138
- return;
139
- }
140
- this.searchInput.handleInput(keyData);
141
- this.selectedIndex = 0;
142
- this.tui.requestRender();
143
- }
144
-
145
- private matchesAction(
146
- keyData: string,
147
- action: keyof Keybindings,
148
- fallback: string
149
- ) {
150
- return this.keybindings.matches(keyData, action) || keyData === fallback;
151
- }
152
-
153
- private moveSelection(direction: -1 | 1) {
154
- if (this.filteredOptions.length > 0) {
155
- const lastIndex = this.filteredOptions.length - 1;
156
- const nextIndex = this.selectedIndex + direction;
157
- if (nextIndex < 0) {
158
- this.selectedIndex = lastIndex;
159
- } else if (nextIndex > lastIndex) {
160
- this.selectedIndex = 0;
161
- } else {
162
- this.selectedIndex = nextIndex;
163
- }
164
- }
165
- this.tui.requestRender();
166
19
  }
167
20
  }
@@ -0,0 +1,230 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { fuzzyFilter, Input, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type {
4
+ Component,
5
+ Focusable,
6
+ Keybindings,
7
+ KeybindingsManager,
8
+ } from "@earendil-works/pi-tui";
9
+
10
+ import type { RenderRequester } from "./types.ts";
11
+
12
+ interface SearchableModelListOptions {
13
+ allOptions: string[];
14
+ currentOptions: string[];
15
+ keybindings: KeybindingsManager;
16
+ multiSelect: boolean;
17
+ onCancel: () => void;
18
+ onSelect: (values: string[]) => void;
19
+ theme: Theme;
20
+ title: string;
21
+ tui: RenderRequester;
22
+ }
23
+
24
+ export class SearchableModelList implements Component, Focusable {
25
+ private readonly tui: RenderRequester;
26
+ private readonly searchInput: Input;
27
+ private readonly allOptions: string[];
28
+ private readonly currentOption: string | undefined;
29
+ private readonly multiSelect: boolean;
30
+ private readonly selected = new Set<string>();
31
+ private filteredOptions: string[];
32
+ private selectedIndex = 0;
33
+ private readonly title: string;
34
+ private readonly onSelect: (values: string[]) => void;
35
+ private readonly onCancel: () => void;
36
+ private readonly theme: Theme;
37
+ private readonly keybindings: KeybindingsManager;
38
+ private _focused = false;
39
+
40
+ get focused(): boolean {
41
+ return this._focused;
42
+ }
43
+ set focused(val: boolean) {
44
+ this._focused = val;
45
+ this.searchInput.focused = val;
46
+ }
47
+
48
+ constructor(options: SearchableModelListOptions) {
49
+ this.multiSelect = options.multiSelect;
50
+ this.tui = options.tui;
51
+ this.title = options.title;
52
+ const [requestedCurrentOption] = options.currentOptions;
53
+ this.currentOption =
54
+ !this.multiSelect &&
55
+ requestedCurrentOption &&
56
+ options.allOptions.includes(requestedCurrentOption)
57
+ ? requestedCurrentOption
58
+ : undefined;
59
+ let allOptions: string[];
60
+ if (this.multiSelect) {
61
+ allOptions = [...new Set(options.allOptions)].toSorted((left, right) =>
62
+ left.localeCompare(right)
63
+ );
64
+ } else if (this.currentOption) {
65
+ allOptions = [
66
+ this.currentOption,
67
+ ...options.allOptions.filter((item) => item !== this.currentOption),
68
+ ];
69
+ } else {
70
+ allOptions = [...options.allOptions];
71
+ }
72
+ this.allOptions = allOptions;
73
+ if (this.multiSelect) {
74
+ for (const value of options.currentOptions) {
75
+ if (this.allOptions.includes(value)) {
76
+ this.selected.add(value);
77
+ }
78
+ }
79
+ }
80
+ this.theme = options.theme;
81
+ this.keybindings = options.keybindings;
82
+ this.onSelect = options.onSelect;
83
+ this.onCancel = options.onCancel;
84
+ this.searchInput = new Input();
85
+ this.filteredOptions = this.allOptions;
86
+ }
87
+
88
+ invalidate(): void {
89
+ this.searchInput.invalidate();
90
+ }
91
+
92
+ render(width: number): string[] {
93
+ const lines: string[] = [this.theme.fg("border", "─".repeat(width))];
94
+ lines.push(` ${this.theme.fg("accent", this.theme.bold(this.title))}`);
95
+ const inputLines = this.searchInput.render(Math.max(1, width - 10));
96
+ lines.push(
97
+ ` ${this.theme.fg("accent", "Search: ")}${inputLines[0] || ""}`,
98
+ ""
99
+ );
100
+
101
+ const query = this.searchInput.getValue().trim();
102
+ this.filteredOptions = query
103
+ ? fuzzyFilter(this.allOptions, query, (item) => item)
104
+ : this.allOptions;
105
+ this.selectedIndex = Math.min(
106
+ this.selectedIndex,
107
+ Math.max(0, this.filteredOptions.length - 1)
108
+ );
109
+
110
+ const maxVisible = 10;
111
+ const total = this.filteredOptions.length;
112
+ if (total === 0) {
113
+ lines.push(` ${this.theme.fg("muted", "No matching models found.")}`);
114
+ } else {
115
+ const startIndex = Math.max(
116
+ 0,
117
+ Math.min(
118
+ this.selectedIndex - Math.floor(maxVisible / 2),
119
+ total - maxVisible
120
+ )
121
+ );
122
+ const endIndex = Math.min(startIndex + maxVisible, total);
123
+ for (let i = startIndex; i < endIndex; i += 1) {
124
+ lines.push(this.renderOption(this.filteredOptions[i], i));
125
+ }
126
+ if (total > maxVisible) {
127
+ lines.push(
128
+ ` ${this.theme.fg(
129
+ "muted",
130
+ ` (${this.selectedIndex + 1}/${total})`
131
+ )}`
132
+ );
133
+ }
134
+ }
135
+ lines.push(
136
+ "",
137
+ ` ${this.theme.fg("dim", this.interactionHint())}`,
138
+ this.theme.fg("border", "─".repeat(width))
139
+ );
140
+ return lines.map((line) => truncateToWidth(line, width));
141
+ }
142
+
143
+ handleInput(keyData: string): void {
144
+ if (this.matchesAction(keyData, "tui.select.up", "\u001B[A")) {
145
+ this.moveSelection(-1);
146
+ return;
147
+ }
148
+ if (this.matchesAction(keyData, "tui.select.down", "\u001B[B")) {
149
+ this.moveSelection(1);
150
+ return;
151
+ }
152
+ if (this.multiSelect && keyData === " ") {
153
+ const item = this.filteredOptions[this.selectedIndex];
154
+ if (item) {
155
+ if (this.selected.has(item)) {
156
+ this.selected.delete(item);
157
+ } else {
158
+ this.selected.add(item);
159
+ }
160
+ }
161
+ this.tui.requestRender();
162
+ return;
163
+ }
164
+ if (
165
+ this.matchesAction(keyData, "tui.select.confirm", "\n") ||
166
+ keyData === "\r"
167
+ ) {
168
+ if (this.multiSelect) {
169
+ this.onSelect(
170
+ this.allOptions.filter((item) => this.selected.has(item))
171
+ );
172
+ } else {
173
+ const selectedOption = this.filteredOptions[this.selectedIndex];
174
+ if (selectedOption !== undefined) {
175
+ this.onSelect([selectedOption]);
176
+ }
177
+ }
178
+ return;
179
+ }
180
+ if (this.matchesAction(keyData, "tui.select.cancel", "\u001B")) {
181
+ this.onCancel();
182
+ return;
183
+ }
184
+ this.searchInput.handleInput(keyData);
185
+ this.selectedIndex = 0;
186
+ this.tui.requestRender();
187
+ }
188
+
189
+ private interactionHint(): string {
190
+ return this.multiSelect
191
+ ? "Type to search · ↑↓: navigate · Space: toggle · Enter: apply · Esc: cancel"
192
+ : "Type to search · ↑↓: navigate · Enter: select · Esc: cancel";
193
+ }
194
+
195
+ private renderOption(item: string, index: number): string {
196
+ let tick = " ";
197
+ if (
198
+ this.multiSelect ? this.selected.has(item) : item === this.currentOption
199
+ ) {
200
+ tick = "✓ ";
201
+ }
202
+ if (index === this.selectedIndex) {
203
+ return ` ${this.theme.fg("accent", `→ ${tick}${item}`)}`;
204
+ }
205
+ return ` ${this.theme.fg("text", `${tick}${item}`)}`;
206
+ }
207
+
208
+ private matchesAction(
209
+ keyData: string,
210
+ action: keyof Keybindings,
211
+ fallback: string
212
+ ) {
213
+ return this.keybindings.matches(keyData, action) || keyData === fallback;
214
+ }
215
+
216
+ private moveSelection(direction: -1 | 1) {
217
+ if (this.filteredOptions.length > 0) {
218
+ const lastIndex = this.filteredOptions.length - 1;
219
+ const nextIndex = this.selectedIndex + direction;
220
+ if (nextIndex < 0) {
221
+ this.selectedIndex = lastIndex;
222
+ } else if (nextIndex > lastIndex) {
223
+ this.selectedIndex = 0;
224
+ } else {
225
+ this.selectedIndex = nextIndex;
226
+ }
227
+ }
228
+ this.tui.requestRender();
229
+ }
230
+ }
@@ -1,4 +1,5 @@
1
1
  import { visibleWidth } from "@earendil-works/pi-tui";
2
+
2
3
  import { DEFAULT_EFFORT_LEVEL } from "../commands/model-options.ts";
3
4
  import type { ContextPreset } from "./types.ts";
4
5
 
@@ -20,7 +21,7 @@ export const withCurrentValue = (current: string, values: string[]) =>
20
21
 
21
22
  export const numericValues = (current: number, values: number[]) => {
22
23
  const all = values.includes(current) ? values : [...values, current];
23
- return all.sort((a, b) => a - b).map(String);
24
+ return all.toSorted((a, b) => a - b).map(String);
24
25
  };
25
26
 
26
27
  export const maxCallValues = (current: string) => {
@@ -53,6 +54,22 @@ export const currentContextLabel = (
53
54
  presets.find((preset) => preset.value === contextMaxChars)?.label ??
54
55
  String(contextMaxChars);
55
56
 
57
+ const closestPresetIndex = (
58
+ presets: ContextPreset[],
59
+ contextMaxChars: number
60
+ ): number => {
61
+ let closestIndex = 0;
62
+ for (let index = 0; index < presets.length; index += 1) {
63
+ if (
64
+ Math.abs(presets[index].value - contextMaxChars) <
65
+ Math.abs(presets[closestIndex].value - contextMaxChars)
66
+ ) {
67
+ closestIndex = index;
68
+ }
69
+ }
70
+ return closestIndex;
71
+ };
72
+
56
73
  export const contextDescription = (
57
74
  presets: ContextPreset[],
58
75
  contextMaxChars: number
@@ -61,16 +78,9 @@ export const contextDescription = (
61
78
  (preset) => preset.value === contextMaxChars
62
79
  );
63
80
  const selectedIndex =
64
- exactIndex >= 0
65
- ? exactIndex
66
- : presets.reduce(
67
- (closestIndex, preset, index) =>
68
- Math.abs(preset.value - contextMaxChars) <
69
- Math.abs(presets[closestIndex].value - contextMaxChars)
70
- ? index
71
- : closestIndex,
72
- 0
73
- );
81
+ exactIndex === -1
82
+ ? closestPresetIndex(presets, contextMaxChars)
83
+ : exactIndex;
74
84
  const selectedPreset = presets[selectedIndex];
75
85
  const isFullContext =
76
86
  selectedPreset?.value === Number.MAX_SAFE_INTEGER ||
@@ -100,7 +110,7 @@ export const contextDescription = (
100
110
  );
101
111
  const markerLabel = `${" ".repeat(labelStart)}${label}`;
102
112
  const description =
103
- exactIndex >= 0 ? selectedPreset?.description : "Custom context limit.";
113
+ exactIndex === -1 ? "Custom context limit." : selectedPreset?.description;
104
114
  return `${description ?? "Custom context limit."}\n${meterPrefix}${meter} full\n${markerLabel}`;
105
115
  };
106
116
 
@@ -126,8 +136,8 @@ export const rainbowGradient = (text: string, startedAt: number): string => {
126
136
  const red = Math.round(baseRed + (255 - baseRed) * brightness);
127
137
  const green = Math.round(baseGreen + (255 - baseGreen) * brightness);
128
138
  const blue = Math.round(baseBlue + (255 - baseBlue) * brightness);
129
- return `\x1b[38;2;${red};${green};${blue}m${character}`;
139
+ return `\u001B[38;2;${red};${green};${blue}m${character}`;
130
140
  })
131
141
  .join("")
132
- .concat("\x1b[0m");
142
+ .concat("\u001B[0m");
133
143
  };
@@ -1,5 +1,7 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import type { SettingItem } from "@earendil-works/pi-tui";
2
+ import { getKeybindings } from "@earendil-works/pi-tui";
3
+ import type { KeybindingsManager, SettingItem } from "@earendil-works/pi-tui";
4
+
3
5
  import {
4
6
  DEFAULT_JEV_DIGEST_MAX_CHARS,
5
7
  DEFAULT_JEV_FILTER_NOUL_MARGIN,
@@ -13,6 +15,7 @@ import {
13
15
  } from "../config/types.ts";
14
16
  import { isValidAdvisorToolPolicies } from "../config/validation.ts";
15
17
  import { JevSetupSubmenu } from "./jev-setup-submenu.ts";
18
+ import { SearchableModelMultiSelector } from "./model-multi-selector.ts";
16
19
  import {
17
20
  contextDescription,
18
21
  currentContextLabel,
@@ -32,6 +35,7 @@ import type {
32
35
 
33
36
  export interface SettingsItemsOptions {
34
37
  effortLevels: string[];
38
+ modelWhitelist: SettingItem;
35
39
  presets: ContextPreset[];
36
40
  settings: AdvisorSettings;
37
41
  theme: Theme;
@@ -200,8 +204,39 @@ const jevItems = (
200
204
  },
201
205
  ];
202
206
 
207
+ export const advisorModelWhitelistItem = (
208
+ settings: AdvisorSettings,
209
+ modelRefs: string[] | undefined,
210
+ keybindings: KeybindingsManager | undefined,
211
+ theme: Theme,
212
+ tui: RenderRequester
213
+ ): SettingItem => ({
214
+ currentValue: settings.modelWhitelist?.length
215
+ ? settings.modelWhitelist.join(", ")
216
+ : "Any model",
217
+ description:
218
+ "Only the exact provider/model references listed here may call the Advisor; an empty list allows every model.",
219
+ id: "modelWhitelist",
220
+ label: "Advisor model whitelist",
221
+ submenu: (_currentValue, done) =>
222
+ new SearchableModelMultiSelector({
223
+ allOptions: [
224
+ ...new Set([...(modelRefs ?? []), ...(settings.modelWhitelist ?? [])]),
225
+ ],
226
+ currentOptions: settings.modelWhitelist ?? [],
227
+ keybindings: keybindings ?? getKeybindings(),
228
+ multiSelect: true,
229
+ onCancel: done,
230
+ onSelect: (values) => done(values.join(",")),
231
+ theme,
232
+ title: "Advisor model whitelist",
233
+ tui,
234
+ }),
235
+ });
236
+
203
237
  export const createSettingsItems = ({
204
238
  effortLevels,
239
+ modelWhitelist,
205
240
  presets,
206
241
  settings,
207
242
  theme,
@@ -233,6 +268,7 @@ export const createSettingsItems = ({
233
268
  },
234
269
  ];
235
270
  if (settings.simpleMode) {
271
+ items.push(modelWhitelist);
236
272
  return items;
237
273
  }
238
274
 
@@ -244,6 +280,7 @@ export const createSettingsItems = ({
244
280
  label: "Advisor reasoning",
245
281
  values: withCurrentValue(currentEffort(settings.effort), effortLevels),
246
282
  },
283
+ modelWhitelist,
247
284
  toggle(
248
285
  "scoutEnabled",
249
286
  "Experimental Advisor Scout",