@ian-pascoe/pi-advisor 0.5.0 → 0.7.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.
@@ -1,23 +1,17 @@
1
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
2
+ import { SettingsList, type Component, type SettingItem } from "@earendil-works/pi-tui";
1
3
  import {
2
- DynamicBorder,
3
- ExtensionEditorComponent,
4
- getSelectListTheme,
5
- getSettingsListTheme,
6
- type KeybindingsManager,
7
- } from "@earendil-works/pi-coding-agent";
8
- import {
9
- fuzzyFilter,
10
- getKeybindings,
11
- Input,
12
- SelectList,
13
- SettingsList,
14
- type Component,
15
- type SelectItem,
16
- type SettingItem,
17
- type TUI,
18
- type TuiMouseEvent,
19
- type TuiMouseEventResult,
20
- } from "@earendil-works/pi-tui";
4
+ cycleDisplay,
5
+ EditorChooser,
6
+ effectiveWithSource,
7
+ errorText,
8
+ ModelPicker,
9
+ nextCycleValue,
10
+ scopeRow,
11
+ SettingsMenu,
12
+ ValueInput,
13
+ type SettingsMenuUi,
14
+ } from "@ian-pascoe/pi-utils/settings-menu";
21
15
  import { Value } from "typebox/value";
22
16
  import { formatAdvisorOption, type AdvisorRenderTheme } from "./advisor-rendering.js";
23
17
  import {
@@ -61,11 +55,8 @@ export interface AdvisorMenuHost {
61
55
  }
62
56
 
63
57
  /** Native UI collaborators supplied by `ctx.ui.custom`. */
64
- export interface AdvisorMenuUi {
65
- tui: TUI;
66
- keybindings: KeybindingsManager;
58
+ export interface AdvisorMenuUi extends SettingsMenuUi {
67
59
  theme: AdvisorRenderTheme;
68
- externalEditorCommand?: string;
69
60
  }
70
61
 
71
62
  const thinkingLevels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
@@ -99,6 +90,13 @@ const descriptions = {
99
90
  maxToolCalls: "Investigative tool calls per Review",
100
91
  maxCorrectiveTurns: "Automatic Corrective Turns per request",
101
92
  maxFindingsPerReview: "Findings accepted from one Review (1–32)",
93
+ maxNitsPerRequest: "Nits delivered per request; further Nits are dropped (0 delivers none)",
94
+ seedBudgetTokens:
95
+ "Token budget for the Context Seed; auto is a quarter of the Advisor model's context window, at most 100k",
96
+ reviewEvery:
97
+ "When Reviews run: every turn, every N turns, or once per request; a tool error reviews at once",
98
+ maxSessionTokens:
99
+ "Advisor Session size that triggers native compaction; auto is half the Advisor model's context window, at most 200k",
102
100
  } satisfies Record<keyof AdvisorOptions, string>;
103
101
  const inheritRow = "\u0000inherit";
104
102
  const inputHints = {
@@ -107,13 +105,11 @@ const inputHints = {
107
105
  maxToolCalls: "a number, or inherit",
108
106
  maxCorrectiveTurns: "a number, or inherit",
109
107
  maxFindingsPerReview: "a number from 1 to 32, or inherit",
108
+ maxNitsPerRequest: "a number, or inherit",
109
+ seedBudgetTokens: "a token count, auto, or inherit",
110
+ reviewEvery: "turn, request, a number of turns, or inherit",
111
+ maxSessionTokens: "a token count, auto, or inherit",
110
112
  } as const;
111
- const listActions = [
112
- "tui.select.up",
113
- "tui.select.down",
114
- "tui.select.confirm",
115
- "tui.select.cancel",
116
- ] as const;
117
113
 
118
114
  /** Convert one typed or selected menu value into a validated change. */
119
115
  function parseAdvisorMenuValue(
@@ -133,9 +129,18 @@ function parseAdvisorMenuValue(
133
129
  return { catchUpThreshold: value === "off" ? value : Number(value) };
134
130
  case "reviewTimeoutMs":
135
131
  return { reviewTimeoutMs: Math.round(Number(value) * 1_000) };
132
+ case "seedBudgetTokens":
133
+ return { seedBudgetTokens: value === "auto" ? value : Number(value) };
134
+ case "maxSessionTokens":
135
+ return { maxSessionTokens: value === "auto" ? value : Number(value) };
136
+ case "reviewEvery":
137
+ return {
138
+ reviewEvery: value === "turn" || value === "request" ? value : Number(value),
139
+ };
136
140
  case "maxToolCalls":
137
141
  case "maxCorrectiveTurns":
138
142
  case "maxFindingsPerReview":
143
+ case "maxNitsPerRequest":
139
144
  return { [key]: Number(value) };
140
145
  default:
141
146
  return { [key]: value };
@@ -144,188 +149,34 @@ function parseAdvisorMenuValue(
144
149
  return { action: "set", key, patch: parseAdvisorOptions(patch, scope) };
145
150
  }
146
151
 
147
- function errorText(cause: unknown): string {
148
- return cause instanceof Error ? cause.message : String(cause);
149
- }
150
-
151
- /** Single-line value entry with inline validation. */
152
- class ValueInput implements Component {
153
- private readonly input: Input;
154
- private error: string | undefined;
155
-
156
- constructor(
157
- private readonly title: string,
158
- private readonly hint: string,
159
- private readonly theme: AdvisorRenderTheme,
160
- submit: (text: string) => void,
161
- cancel: () => void,
162
- ) {
163
- this.input = new Input({ placeholder: hint });
164
- this.input.focused = true;
165
- this.input.onSubmit = (text) => {
166
- try {
167
- submit(text);
168
- } catch (cause) {
169
- this.error = errorText(cause);
170
- }
171
- };
172
- this.input.onEscape = cancel;
173
- }
174
- handleInput(data: string): void {
175
- this.error = undefined;
176
- this.input.handleInput(data);
177
- }
178
- render(width: number): string[] {
179
- return [
180
- this.theme.bold(this.title),
181
- ...this.input.render(width),
182
- this.error ? this.theme.fg("error", `✖ ${this.error}`) : this.theme.fg("dim", this.hint),
183
- ];
184
- }
185
- invalidate(): void {
186
- this.input.invalidate();
187
- }
188
- }
189
-
190
- /** Fuzzy-searchable model list with an inherit choice. */
191
- class ModelPicker implements Component {
192
- private readonly input = new Input({ placeholder: "type to search" });
193
- private list: SelectList;
194
-
195
- constructor(
196
- private readonly models: readonly string[],
197
- private readonly choose: (value: string) => void,
198
- private readonly cancel: () => void,
199
- ) {
200
- this.input.focused = true;
201
- this.list = this.createList("");
202
- }
203
- private createList(query: string): SelectList {
204
- const items: SelectItem[] = ["inherit", ...this.models].map((value) => ({
205
- value,
206
- label: value,
207
- }));
208
- const list = new SelectList(
209
- query ? fuzzyFilter(items, query, (item) => item.value) : items,
210
- 10,
211
- getSelectListTheme(),
212
- );
213
- list.onSelect = (item) => this.choose(item.value);
214
- list.onCancel = this.cancel;
215
- return list;
216
- }
217
- handleInput(data: string): void {
218
- const bindings = getKeybindings();
219
- if (listActions.some((action) => bindings.matches(data, action))) {
220
- this.list.handleInput(data);
221
- return;
222
- }
223
- this.input.handleInput(data);
224
- this.list = this.createList(this.input.getValue());
225
- }
226
- render(width: number): string[] {
227
- return [...this.input.render(width), ...this.list.render(width)];
228
- }
229
- invalidate(): void {
230
- this.input.invalidate();
231
- this.list.invalidate();
232
- }
233
- }
234
-
235
- /** Choose between editing the prompt in Pi's editor component and inheriting it. */
236
- class PromptChooser implements Component {
237
- private readonly list: SelectList;
238
- private editor: ExtensionEditorComponent | undefined;
239
- private error: string | undefined;
240
-
241
- /** `submit` throws a user-facing message for an invalid prompt; the editor stays open. */
242
- constructor(
243
- ui: AdvisorMenuUi,
244
- prompt: string,
245
- submit: (text: string) => void,
246
- inherit: () => void,
247
- cancel: () => void,
248
- ) {
249
- this.list = new SelectList(
250
- [
251
- { value: "edit", label: "Edit…" },
252
- { value: "inherit", label: "inherit" },
253
- ],
254
- 2,
255
- getSelectListTheme(),
256
- );
257
- this.list.onCancel = cancel;
258
- this.list.onSelect = (item) => {
259
- if (item.value === "inherit") {
260
- inherit();
261
- return;
262
- }
263
- this.editor = new ExtensionEditorComponent(
264
- ui.tui,
265
- ui.keybindings,
266
- "Advisor Prompt",
267
- prompt,
268
- (text) => {
269
- try {
270
- submit(text);
271
- } catch (cause) {
272
- this.error = ui.theme.fg("error", `✖ ${errorText(cause)}`);
273
- }
274
- },
275
- cancel,
276
- undefined,
277
- ui.externalEditorCommand,
278
- );
279
- this.editor.focused = true;
280
- };
281
- }
282
- handleInput(data: string): void {
283
- this.error = undefined;
284
- (this.editor ?? this.list).handleInput(data);
285
- }
286
- render(width: number): string[] {
287
- const lines = (this.editor ?? this.list).render(width);
288
- return this.error ? [...lines, this.error] : lines;
289
- }
290
- invalidate(): void {
291
- (this.editor ?? this.list).invalidate();
292
- }
293
- }
294
-
295
152
  /** Menu row ids that are not Advisor options. */
296
153
  const actionRows = { resume: "resume", scope: "scope" } as const;
297
154
 
298
155
  /** `/advisor` settings menu built from Pi's native settings list. */
299
- export class AdvisorSettingsMenu implements Component {
156
+ export class AdvisorSettingsMenu extends SettingsMenu {
300
157
  private scope: AdvisorSettingScope = "session";
301
158
  private view: AdvisorMenuView;
302
159
  private rows: SettingItem[] = [];
303
- private list: SettingsList;
304
- private error: string | undefined;
305
160
  private lastRow: string = actionRows.scope;
306
- /** Edits run one at a time so each reads the result of the previous one. */
307
- private pending: Promise<void> = Promise.resolve();
308
161
  private submenuOpen = false;
309
162
  private rebuildPending = false;
310
- private readonly border: DynamicBorder;
311
163
 
312
164
  constructor(
313
165
  private readonly host: AdvisorMenuHost,
314
- private readonly ui: AdvisorMenuUi,
315
- private readonly done: () => void,
166
+ protected override readonly ui: AdvisorMenuUi,
167
+ done: () => void,
316
168
  ) {
169
+ super("Advisor settings", ui, done);
317
170
  this.view = host.view();
318
- this.border = new DynamicBorder((text) => ui.theme.fg("border", text));
319
- this.list = this.createList();
171
+ this.createList();
320
172
  }
321
173
 
322
- /** Resolves once every edit started so far has been applied or rejected. */
323
- settled(): Promise<void> {
324
- return this.pending;
174
+ protected headline(): readonly string[] {
175
+ return this.view.headline;
325
176
  }
326
177
 
327
178
  /** Re-read the host after external state changes, such as a Review starting. */
328
- refresh(): void {
179
+ override refresh(): void {
329
180
  const paused = this.view.paused;
330
181
  try {
331
182
  this.view = this.host.view();
@@ -345,8 +196,7 @@ export class AdvisorSettingsMenu implements Component {
345
196
 
346
197
  private rebuild(): void {
347
198
  this.rebuildPending = false;
348
- this.list = this.createList();
349
- this.list.selectItem(this.view.paused ? actionRows.resume : this.lastRow);
199
+ this.createList().selectItem(this.view.paused ? actionRows.resume : this.lastRow);
350
200
  }
351
201
 
352
202
  private createList(): SettingsList {
@@ -356,13 +206,7 @@ export class AdvisorSettingsMenu implements Component {
356
206
  ...advisorOptionKeys,
357
207
  ];
358
208
  this.rows = ids.map((id) => this.createRow(id));
359
- return new SettingsList(
360
- this.rows,
361
- this.rows.length,
362
- getSettingsListTheme(),
363
- (id, value) => this.change(id, value),
364
- this.done,
365
- );
209
+ return this.setList(this.rows, (id, value) => this.change(id, value));
366
210
  }
367
211
 
368
212
  private createRow(id: string): SettingItem {
@@ -374,14 +218,7 @@ export class AdvisorSettingsMenu implements Component {
374
218
  values: ["retry"],
375
219
  description: "Retry the Paused Advisor with its current settings",
376
220
  };
377
- if (id === actionRows.scope)
378
- return {
379
- id,
380
- label: "Scope",
381
- currentValue: this.scope,
382
- values: [...this.view.scopes],
383
- description: "Where edits are written",
384
- };
221
+ if (id === actionRows.scope) return scopeRow(this.scope, this.view.scopes);
385
222
  return this.optionRow(advisorOptionKey(id));
386
223
  }
387
224
 
@@ -450,8 +287,9 @@ export class AdvisorSettingsMenu implements Component {
450
287
  submenu: this.submenu(
451
288
  key,
452
289
  (close) =>
453
- new PromptChooser(
290
+ new EditorChooser(
454
291
  this.ui,
292
+ "Advisor Prompt",
455
293
  settings.prompt ?? "",
456
294
  (text) => {
457
295
  // Throws for an invalid prompt, keeping the editor open.
@@ -540,12 +378,14 @@ export class AdvisorSettingsMenu implements Component {
540
378
 
541
379
  /** This scope's own value, or what it inherits; notes when another scope overrides it. */
542
380
  private cycleDisplay(key: CycleKey): string {
543
- const own = cycleValue(key, this.view.authored[this.scope] ?? {});
544
381
  const effective = cycleValue(key, this.view.settings);
545
382
  const source = this.view.sources[key] ?? "default";
546
- const inEffect = effective === undefined ? "observed agent" : `${effective} · ${source}`;
547
- if (own === undefined) return `inherit (${inEffect})`;
548
- return source === this.scope ? own : `${own} (overridden: ${inEffect})`;
383
+ return cycleDisplay({
384
+ own: cycleValue(key, this.view.authored[this.scope] ?? {}),
385
+ inEffect: effective === undefined ? "observed agent" : effectiveWithSource(effective, source),
386
+ source,
387
+ scope: this.scope,
388
+ });
549
389
  }
550
390
 
551
391
  private change(id: string, value: string): void {
@@ -566,8 +406,7 @@ export class AdvisorSettingsMenu implements Component {
566
406
  // text is not one of the values, so ignore its suggestion.
567
407
  const values = cycleValues[key];
568
408
  const own = cycleValue(key, this.view.authored[this.scope] ?? {}) ?? "inherit";
569
- const next =
570
- values[(values.findIndex((option) => option === own) + 1) % values.length] ?? "inherit";
409
+ const next = nextCycleValue(values, own) ?? "inherit";
571
410
  let change: AdvisorChange;
572
411
  try {
573
412
  change = parseAdvisorMenuValue(key, next, this.scope);
@@ -588,42 +427,4 @@ export class AdvisorSettingsMenu implements Component {
588
427
  const scope = this.scope;
589
428
  this.run(() => this.host.apply(scope, change));
590
429
  }
591
-
592
- private run(task: () => Promise<void>): void {
593
- this.error = undefined;
594
- this.pending = this.pending
595
- .then(task)
596
- .catch((cause: unknown) => {
597
- this.error = errorText(cause);
598
- })
599
- .finally(() => {
600
- this.refresh();
601
- this.ui.tui.requestRender();
602
- });
603
- }
604
-
605
- handleInput(data: string): void {
606
- this.list.handleInput(data);
607
- }
608
-
609
- handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
610
- return this.list.handleMouse(event);
611
- }
612
-
613
- render(width: number): string[] {
614
- const { theme } = this.ui;
615
- return [
616
- ...this.border.render(width),
617
- ` ${theme.bold("Advisor settings")}`,
618
- ...this.view.headline.map((line) => ` ${line}`),
619
- "",
620
- ...this.list.render(width),
621
- ...(this.error ? [theme.fg("error", ` ✖ ${this.error}`)] : []),
622
- ...this.border.render(width),
623
- ];
624
- }
625
-
626
- invalidate(): void {
627
- this.list.invalidate();
628
- }
629
430
  }