@ian-pascoe/pi-advisor 0.6.0 → 0.8.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"];
@@ -73,12 +64,18 @@ const thinkingLevels = ["off", "minimal", "low", "medium", "high", "xhigh", "max
73
64
  const cycleValues = {
74
65
  enabled: ["inherit", "on", "off"],
75
66
  includeSubagents: ["inherit", "on", "off"],
67
+ anthropicLongCache: ["inherit", "on", "off"],
76
68
  thinkingLevel: ["inherit", ...thinkingLevels],
77
69
  } as const satisfies Record<string, readonly string[]>;
78
70
  type CycleKey = keyof typeof cycleValues;
79
71
 
80
72
  function isCycleKey(key: keyof AdvisorOptions): key is CycleKey {
81
- return key === "enabled" || key === "includeSubagents" || key === "thinkingLevel";
73
+ return (
74
+ key === "enabled" ||
75
+ key === "includeSubagents" ||
76
+ key === "anthropicLongCache" ||
77
+ key === "thinkingLevel"
78
+ );
82
79
  }
83
80
 
84
81
  /** A cycled option's value as shown and cycled; undefined when absent. */
@@ -92,7 +89,7 @@ const descriptions = {
92
89
  includeSubagents: "Also review Minimal Subagents Child Agents",
93
90
  prompt: "Advisor Prompt; replaces the whole prompt",
94
91
  model: "Advisor model; inherit follows the observed agent",
95
- thinkingLevel: "Advisor thinking level; inherit follows the observed agent",
92
+ thinkingLevel: "Advisor thinking level; inherit uses the default (high)",
96
93
  allowedTools: "Tools the Advisor may call (Tool Grant)",
97
94
  catchUpThreshold: "Review Backlog that starts a Catch-up Wait; a positive integer or off",
98
95
  reviewTimeoutMs: "Deadline for each Review, in seconds",
@@ -101,11 +98,15 @@ const descriptions = {
101
98
  maxFindingsPerReview: "Findings accepted from one Review (1–32)",
102
99
  maxNitsPerRequest: "Nits delivered per request; further Nits are dropped (0 delivers none)",
103
100
  seedBudgetTokens:
104
- "Token budget for the Context Seed; auto is a quarter of the Advisor model's context window, at most 100k",
101
+ "Token budget for the Context Seed; auto is a quarter of the Advisor model's context window, at most 50k",
105
102
  reviewEvery:
106
103
  "When Reviews run: every turn, every N turns, or once per request; a tool error reviews at once",
107
104
  maxSessionTokens:
108
- "Advisor Session size that triggers native compaction; auto is half the Advisor model's context window, at most 200k",
105
+ "Advisor Session size that triggers native compaction; auto is half the Advisor model's context window, at most 100k",
106
+ maxToolResultChars:
107
+ "Longest tool-result text kept whole in Review Evidence, in characters; longer results keep their head and tail",
108
+ anthropicLongCache:
109
+ "Ask Anthropic for 1h prompt caching on Advisor requests; cache writes cost 2× instead of 1.25×",
109
110
  } satisfies Record<keyof AdvisorOptions, string>;
110
111
  const inheritRow = "\u0000inherit";
111
112
  const inputHints = {
@@ -118,13 +119,8 @@ const inputHints = {
118
119
  seedBudgetTokens: "a token count, auto, or inherit",
119
120
  reviewEvery: "turn, request, a number of turns, or inherit",
120
121
  maxSessionTokens: "a token count, auto, or inherit",
122
+ maxToolResultChars: "a number of characters, or inherit",
121
123
  } 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
124
 
129
125
  /** Convert one typed or selected menu value into a validated change. */
130
126
  function parseAdvisorMenuValue(
@@ -139,6 +135,7 @@ function parseAdvisorMenuValue(
139
135
  switch (key) {
140
136
  case "enabled":
141
137
  case "includeSubagents":
138
+ case "anthropicLongCache":
142
139
  return { [key]: value === "on" ? true : value === "off" ? false : value };
143
140
  case "catchUpThreshold":
144
141
  return { catchUpThreshold: value === "off" ? value : Number(value) };
@@ -156,6 +153,7 @@ function parseAdvisorMenuValue(
156
153
  case "maxCorrectiveTurns":
157
154
  case "maxFindingsPerReview":
158
155
  case "maxNitsPerRequest":
156
+ case "maxToolResultChars":
159
157
  return { [key]: Number(value) };
160
158
  default:
161
159
  return { [key]: value };
@@ -164,188 +162,34 @@ function parseAdvisorMenuValue(
164
162
  return { action: "set", key, patch: parseAdvisorOptions(patch, scope) };
165
163
  }
166
164
 
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
165
  /** Menu row ids that are not Advisor options. */
316
166
  const actionRows = { resume: "resume", scope: "scope" } as const;
317
167
 
318
168
  /** `/advisor` settings menu built from Pi's native settings list. */
319
- export class AdvisorSettingsMenu implements Component {
169
+ export class AdvisorSettingsMenu extends SettingsMenu {
320
170
  private scope: AdvisorSettingScope = "session";
321
171
  private view: AdvisorMenuView;
322
172
  private rows: SettingItem[] = [];
323
- private list: SettingsList;
324
- private error: string | undefined;
325
173
  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
174
  private submenuOpen = false;
329
175
  private rebuildPending = false;
330
- private readonly border: DynamicBorder;
331
176
 
332
177
  constructor(
333
178
  private readonly host: AdvisorMenuHost,
334
- private readonly ui: AdvisorMenuUi,
335
- private readonly done: () => void,
179
+ protected override readonly ui: AdvisorMenuUi,
180
+ done: () => void,
336
181
  ) {
182
+ super("Advisor settings", ui, done);
337
183
  this.view = host.view();
338
- this.border = new DynamicBorder((text) => ui.theme.fg("border", text));
339
- this.list = this.createList();
184
+ this.createList();
340
185
  }
341
186
 
342
- /** Resolves once every edit started so far has been applied or rejected. */
343
- settled(): Promise<void> {
344
- return this.pending;
187
+ protected headline(): readonly string[] {
188
+ return this.view.headline;
345
189
  }
346
190
 
347
191
  /** Re-read the host after external state changes, such as a Review starting. */
348
- refresh(): void {
192
+ override refresh(): void {
349
193
  const paused = this.view.paused;
350
194
  try {
351
195
  this.view = this.host.view();
@@ -365,8 +209,7 @@ export class AdvisorSettingsMenu implements Component {
365
209
 
366
210
  private rebuild(): void {
367
211
  this.rebuildPending = false;
368
- this.list = this.createList();
369
- this.list.selectItem(this.view.paused ? actionRows.resume : this.lastRow);
212
+ this.createList().selectItem(this.view.paused ? actionRows.resume : this.lastRow);
370
213
  }
371
214
 
372
215
  private createList(): SettingsList {
@@ -376,13 +219,7 @@ export class AdvisorSettingsMenu implements Component {
376
219
  ...advisorOptionKeys,
377
220
  ];
378
221
  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
- );
222
+ return this.setList(this.rows, (id, value) => this.change(id, value));
386
223
  }
387
224
 
388
225
  private createRow(id: string): SettingItem {
@@ -394,14 +231,7 @@ export class AdvisorSettingsMenu implements Component {
394
231
  values: ["retry"],
395
232
  description: "Retry the Paused Advisor with its current settings",
396
233
  };
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
- };
234
+ if (id === actionRows.scope) return scopeRow(this.scope, this.view.scopes);
405
235
  return this.optionRow(advisorOptionKey(id));
406
236
  }
407
237
 
@@ -432,6 +262,7 @@ export class AdvisorSettingsMenu implements Component {
432
262
  switch (key) {
433
263
  case "enabled":
434
264
  case "includeSubagents":
265
+ case "anthropicLongCache":
435
266
  case "thinkingLevel":
436
267
  // The row shows and cycles this scope's own value, so every state is reachable.
437
268
  return {
@@ -470,8 +301,9 @@ export class AdvisorSettingsMenu implements Component {
470
301
  submenu: this.submenu(
471
302
  key,
472
303
  (close) =>
473
- new PromptChooser(
304
+ new EditorChooser(
474
305
  this.ui,
306
+ "Advisor Prompt",
475
307
  settings.prompt ?? "",
476
308
  (text) => {
477
309
  // Throws for an invalid prompt, keeping the editor open.
@@ -560,12 +392,14 @@ export class AdvisorSettingsMenu implements Component {
560
392
 
561
393
  /** This scope's own value, or what it inherits; notes when another scope overrides it. */
562
394
  private cycleDisplay(key: CycleKey): string {
563
- const own = cycleValue(key, this.view.authored[this.scope] ?? {});
564
395
  const effective = cycleValue(key, this.view.settings);
565
396
  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})`;
397
+ return cycleDisplay({
398
+ own: cycleValue(key, this.view.authored[this.scope] ?? {}),
399
+ inEffect: effective === undefined ? "observed agent" : effectiveWithSource(effective, source),
400
+ source,
401
+ scope: this.scope,
402
+ });
569
403
  }
570
404
 
571
405
  private change(id: string, value: string): void {
@@ -586,8 +420,7 @@ export class AdvisorSettingsMenu implements Component {
586
420
  // text is not one of the values, so ignore its suggestion.
587
421
  const values = cycleValues[key];
588
422
  const own = cycleValue(key, this.view.authored[this.scope] ?? {}) ?? "inherit";
589
- const next =
590
- values[(values.findIndex((option) => option === own) + 1) % values.length] ?? "inherit";
423
+ const next = nextCycleValue(values, own) ?? "inherit";
591
424
  let change: AdvisorChange;
592
425
  try {
593
426
  change = parseAdvisorMenuValue(key, next, this.scope);
@@ -608,42 +441,4 @@ export class AdvisorSettingsMenu implements Component {
608
441
  const scope = this.scope;
609
442
  this.run(() => this.host.apply(scope, change));
610
443
  }
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
444
  }