@ian-pascoe/pi-advisor 0.6.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"];
@@ -119,12 +110,6 @@ const inputHints = {
119
110
  reviewEvery: "turn, request, a number of turns, or inherit",
120
111
  maxSessionTokens: "a token count, auto, or inherit",
121
112
  } as const;
122
- const listActions = [
123
- "tui.select.up",
124
- "tui.select.down",
125
- "tui.select.confirm",
126
- "tui.select.cancel",
127
- ] as const;
128
113
 
129
114
  /** Convert one typed or selected menu value into a validated change. */
130
115
  function parseAdvisorMenuValue(
@@ -164,188 +149,34 @@ function parseAdvisorMenuValue(
164
149
  return { action: "set", key, patch: parseAdvisorOptions(patch, scope) };
165
150
  }
166
151
 
167
- function errorText(cause: unknown): string {
168
- return cause instanceof Error ? cause.message : String(cause);
169
- }
170
-
171
- /** Single-line value entry with inline validation. */
172
- class ValueInput implements Component {
173
- private readonly input: Input;
174
- private error: string | undefined;
175
-
176
- constructor(
177
- private readonly title: string,
178
- private readonly hint: string,
179
- private readonly theme: AdvisorRenderTheme,
180
- submit: (text: string) => void,
181
- cancel: () => void,
182
- ) {
183
- this.input = new Input({ placeholder: hint });
184
- this.input.focused = true;
185
- this.input.onSubmit = (text) => {
186
- try {
187
- submit(text);
188
- } catch (cause) {
189
- this.error = errorText(cause);
190
- }
191
- };
192
- this.input.onEscape = cancel;
193
- }
194
- handleInput(data: string): void {
195
- this.error = undefined;
196
- this.input.handleInput(data);
197
- }
198
- render(width: number): string[] {
199
- return [
200
- this.theme.bold(this.title),
201
- ...this.input.render(width),
202
- this.error ? this.theme.fg("error", `✖ ${this.error}`) : this.theme.fg("dim", this.hint),
203
- ];
204
- }
205
- invalidate(): void {
206
- this.input.invalidate();
207
- }
208
- }
209
-
210
- /** Fuzzy-searchable model list with an inherit choice. */
211
- class ModelPicker implements Component {
212
- private readonly input = new Input({ placeholder: "type to search" });
213
- private list: SelectList;
214
-
215
- constructor(
216
- private readonly models: readonly string[],
217
- private readonly choose: (value: string) => void,
218
- private readonly cancel: () => void,
219
- ) {
220
- this.input.focused = true;
221
- this.list = this.createList("");
222
- }
223
- private createList(query: string): SelectList {
224
- const items: SelectItem[] = ["inherit", ...this.models].map((value) => ({
225
- value,
226
- label: value,
227
- }));
228
- const list = new SelectList(
229
- query ? fuzzyFilter(items, query, (item) => item.value) : items,
230
- 10,
231
- getSelectListTheme(),
232
- );
233
- list.onSelect = (item) => this.choose(item.value);
234
- list.onCancel = this.cancel;
235
- return list;
236
- }
237
- handleInput(data: string): void {
238
- const bindings = getKeybindings();
239
- if (listActions.some((action) => bindings.matches(data, action))) {
240
- this.list.handleInput(data);
241
- return;
242
- }
243
- this.input.handleInput(data);
244
- this.list = this.createList(this.input.getValue());
245
- }
246
- render(width: number): string[] {
247
- return [...this.input.render(width), ...this.list.render(width)];
248
- }
249
- invalidate(): void {
250
- this.input.invalidate();
251
- this.list.invalidate();
252
- }
253
- }
254
-
255
- /** Choose between editing the prompt in Pi's editor component and inheriting it. */
256
- class PromptChooser implements Component {
257
- private readonly list: SelectList;
258
- private editor: ExtensionEditorComponent | undefined;
259
- private error: string | undefined;
260
-
261
- /** `submit` throws a user-facing message for an invalid prompt; the editor stays open. */
262
- constructor(
263
- ui: AdvisorMenuUi,
264
- prompt: string,
265
- submit: (text: string) => void,
266
- inherit: () => void,
267
- cancel: () => void,
268
- ) {
269
- this.list = new SelectList(
270
- [
271
- { value: "edit", label: "Edit…" },
272
- { value: "inherit", label: "inherit" },
273
- ],
274
- 2,
275
- getSelectListTheme(),
276
- );
277
- this.list.onCancel = cancel;
278
- this.list.onSelect = (item) => {
279
- if (item.value === "inherit") {
280
- inherit();
281
- return;
282
- }
283
- this.editor = new ExtensionEditorComponent(
284
- ui.tui,
285
- ui.keybindings,
286
- "Advisor Prompt",
287
- prompt,
288
- (text) => {
289
- try {
290
- submit(text);
291
- } catch (cause) {
292
- this.error = ui.theme.fg("error", `✖ ${errorText(cause)}`);
293
- }
294
- },
295
- cancel,
296
- undefined,
297
- ui.externalEditorCommand,
298
- );
299
- this.editor.focused = true;
300
- };
301
- }
302
- handleInput(data: string): void {
303
- this.error = undefined;
304
- (this.editor ?? this.list).handleInput(data);
305
- }
306
- render(width: number): string[] {
307
- const lines = (this.editor ?? this.list).render(width);
308
- return this.error ? [...lines, this.error] : lines;
309
- }
310
- invalidate(): void {
311
- (this.editor ?? this.list).invalidate();
312
- }
313
- }
314
-
315
152
  /** Menu row ids that are not Advisor options. */
316
153
  const actionRows = { resume: "resume", scope: "scope" } as const;
317
154
 
318
155
  /** `/advisor` settings menu built from Pi's native settings list. */
319
- export class AdvisorSettingsMenu implements Component {
156
+ export class AdvisorSettingsMenu extends SettingsMenu {
320
157
  private scope: AdvisorSettingScope = "session";
321
158
  private view: AdvisorMenuView;
322
159
  private rows: SettingItem[] = [];
323
- private list: SettingsList;
324
- private error: string | undefined;
325
160
  private lastRow: string = actionRows.scope;
326
- /** Edits run one at a time so each reads the result of the previous one. */
327
- private pending: Promise<void> = Promise.resolve();
328
161
  private submenuOpen = false;
329
162
  private rebuildPending = false;
330
- private readonly border: DynamicBorder;
331
163
 
332
164
  constructor(
333
165
  private readonly host: AdvisorMenuHost,
334
- private readonly ui: AdvisorMenuUi,
335
- private readonly done: () => void,
166
+ protected override readonly ui: AdvisorMenuUi,
167
+ done: () => void,
336
168
  ) {
169
+ super("Advisor settings", ui, done);
337
170
  this.view = host.view();
338
- this.border = new DynamicBorder((text) => ui.theme.fg("border", text));
339
- this.list = this.createList();
171
+ this.createList();
340
172
  }
341
173
 
342
- /** Resolves once every edit started so far has been applied or rejected. */
343
- settled(): Promise<void> {
344
- return this.pending;
174
+ protected headline(): readonly string[] {
175
+ return this.view.headline;
345
176
  }
346
177
 
347
178
  /** Re-read the host after external state changes, such as a Review starting. */
348
- refresh(): void {
179
+ override refresh(): void {
349
180
  const paused = this.view.paused;
350
181
  try {
351
182
  this.view = this.host.view();
@@ -365,8 +196,7 @@ export class AdvisorSettingsMenu implements Component {
365
196
 
366
197
  private rebuild(): void {
367
198
  this.rebuildPending = false;
368
- this.list = this.createList();
369
- this.list.selectItem(this.view.paused ? actionRows.resume : this.lastRow);
199
+ this.createList().selectItem(this.view.paused ? actionRows.resume : this.lastRow);
370
200
  }
371
201
 
372
202
  private createList(): SettingsList {
@@ -376,13 +206,7 @@ export class AdvisorSettingsMenu implements Component {
376
206
  ...advisorOptionKeys,
377
207
  ];
378
208
  this.rows = ids.map((id) => this.createRow(id));
379
- return new SettingsList(
380
- this.rows,
381
- this.rows.length,
382
- getSettingsListTheme(),
383
- (id, value) => this.change(id, value),
384
- this.done,
385
- );
209
+ return this.setList(this.rows, (id, value) => this.change(id, value));
386
210
  }
387
211
 
388
212
  private createRow(id: string): SettingItem {
@@ -394,14 +218,7 @@ export class AdvisorSettingsMenu implements Component {
394
218
  values: ["retry"],
395
219
  description: "Retry the Paused Advisor with its current settings",
396
220
  };
397
- if (id === actionRows.scope)
398
- return {
399
- id,
400
- label: "Scope",
401
- currentValue: this.scope,
402
- values: [...this.view.scopes],
403
- description: "Where edits are written",
404
- };
221
+ if (id === actionRows.scope) return scopeRow(this.scope, this.view.scopes);
405
222
  return this.optionRow(advisorOptionKey(id));
406
223
  }
407
224
 
@@ -470,8 +287,9 @@ export class AdvisorSettingsMenu implements Component {
470
287
  submenu: this.submenu(
471
288
  key,
472
289
  (close) =>
473
- new PromptChooser(
290
+ new EditorChooser(
474
291
  this.ui,
292
+ "Advisor Prompt",
475
293
  settings.prompt ?? "",
476
294
  (text) => {
477
295
  // Throws for an invalid prompt, keeping the editor open.
@@ -560,12 +378,14 @@ export class AdvisorSettingsMenu implements Component {
560
378
 
561
379
  /** This scope's own value, or what it inherits; notes when another scope overrides it. */
562
380
  private cycleDisplay(key: CycleKey): string {
563
- const own = cycleValue(key, this.view.authored[this.scope] ?? {});
564
381
  const effective = cycleValue(key, this.view.settings);
565
382
  const source = this.view.sources[key] ?? "default";
566
- const inEffect = effective === undefined ? "observed agent" : `${effective} · ${source}`;
567
- if (own === undefined) return `inherit (${inEffect})`;
568
- 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
+ });
569
389
  }
570
390
 
571
391
  private change(id: string, value: string): void {
@@ -586,8 +406,7 @@ export class AdvisorSettingsMenu implements Component {
586
406
  // text is not one of the values, so ignore its suggestion.
587
407
  const values = cycleValues[key];
588
408
  const own = cycleValue(key, this.view.authored[this.scope] ?? {}) ?? "inherit";
589
- const next =
590
- values[(values.findIndex((option) => option === own) + 1) % values.length] ?? "inherit";
409
+ const next = nextCycleValue(values, own) ?? "inherit";
591
410
  let change: AdvisorChange;
592
411
  try {
593
412
  change = parseAdvisorMenuValue(key, next, this.scope);
@@ -608,42 +427,4 @@ export class AdvisorSettingsMenu implements Component {
608
427
  const scope = this.scope;
609
428
  this.run(() => this.host.apply(scope, change));
610
429
  }
611
-
612
- private run(task: () => Promise<void>): void {
613
- this.error = undefined;
614
- this.pending = this.pending
615
- .then(task)
616
- .catch((cause: unknown) => {
617
- this.error = errorText(cause);
618
- })
619
- .finally(() => {
620
- this.refresh();
621
- this.ui.tui.requestRender();
622
- });
623
- }
624
-
625
- handleInput(data: string): void {
626
- this.list.handleInput(data);
627
- }
628
-
629
- handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
630
- return this.list.handleMouse(event);
631
- }
632
-
633
- render(width: number): string[] {
634
- const { theme } = this.ui;
635
- return [
636
- ...this.border.render(width),
637
- ` ${theme.bold("Advisor settings")}`,
638
- ...this.view.headline.map((line) => ` ${line}`),
639
- "",
640
- ...this.list.render(width),
641
- ...(this.error ? [theme.fg("error", ` ✖ ${this.error}`)] : []),
642
- ...this.border.render(width),
643
- ];
644
- }
645
-
646
- invalidate(): void {
647
- this.list.invalidate();
648
- }
649
430
  }