pi-advisor-flow 0.4.0 → 0.5.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.
package/src/ui.ts CHANGED
@@ -1,16 +1,32 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ getSettingsListTheme,
3
+ type Theme,
4
+ } from "@earendil-works/pi-coding-agent";
2
5
  import {
3
6
  type Component,
7
+ Editor,
8
+ type EditorTheme,
4
9
  type Focusable,
5
10
  fuzzyFilter,
6
11
  Input,
7
12
  Key,
8
13
  type Keybindings,
9
14
  type KeybindingsManager,
15
+ type KeyId,
10
16
  matchesKey,
17
+ type SettingItem,
18
+ SettingsList,
19
+ type TUI,
11
20
  truncateToWidth,
21
+ visibleWidth,
22
+ wrapTextWithAnsi,
12
23
  } from "@earendil-works/pi-tui";
13
24
  import { isValidAdvisorToolPolicies } from "./config.js";
25
+ import {
26
+ clampGitContextLevel,
27
+ GIT_CONTEXT_LEVELS,
28
+ type GitContextLevel,
29
+ } from "./git.js";
14
30
 
15
31
  interface RenderRequester {
16
32
  requestRender: () => void;
@@ -25,28 +41,6 @@ interface SearchableModelSelectorOptions {
25
41
  title: string;
26
42
  tui: RenderRequester;
27
43
  }
28
- const stepNumericPreset = (
29
- current: number,
30
- presets: number[],
31
- direction: number
32
- ) => {
33
- const values = presets.includes(current)
34
- ? presets
35
- : [...presets, current].sort((a, b) => a - b);
36
- const index = values.indexOf(current);
37
- return values[Math.max(0, Math.min(values.length - 1, index + direction))];
38
- };
39
-
40
- interface AdvisorSettingsSelectorOptions {
41
- effortLevels: string[];
42
- initial: AdvisorSettings;
43
- onCancel: () => void;
44
- onSave: (settings: AdvisorSettings) => void;
45
- presets: ContextPreset[];
46
- theme: Theme;
47
- tui: RenderRequester;
48
- }
49
-
50
44
  export class SearchableModelSelector implements Component, Focusable {
51
45
  private readonly tui: RenderRequester;
52
46
  private readonly searchInput: Input;
@@ -203,6 +197,427 @@ export class SearchableModelSelector implements Component, Focusable {
203
197
  }
204
198
  }
205
199
 
200
+ export interface ManualAdvisorRequest {
201
+ /** The repository disclosure level selected for this consultation. */
202
+ gitContext: GitContextLevel;
203
+ /** The unnormalized message entered in the dialog, when one was supplied. */
204
+ message?: string;
205
+ }
206
+
207
+ interface ManualAdvisorDialogOptions {
208
+ gitContext: GitContextLevel;
209
+ initialMessage?: string;
210
+ keybindings: KeybindingsManager;
211
+ onCancel: () => void;
212
+ onSubmit: (request: ManualAdvisorRequest) => void;
213
+ theme: Theme;
214
+ tui: TUI;
215
+ }
216
+
217
+ type ManualAdvisorFocus = "editor" | "git" | "actions";
218
+
219
+ const MANUAL_GIT_CONTEXT_COPY: Record<
220
+ GitContextLevel,
221
+ { description: string; label: string }
222
+ > = {
223
+ full: { description: "summary plus patch", label: "Full" },
224
+ off: { description: "no Git data", label: "None" },
225
+ summary: { description: "changed paths and status", label: "Summary" },
226
+ };
227
+
228
+ /**
229
+ * The small, deliberately non-persistent form used by `/advisor-manual`.
230
+ *
231
+ * The dialog owns only local input state. Callers decide when to consume a
232
+ * budget or start a consultation after the submitted request is returned.
233
+ */
234
+ export class ManualAdvisorDialog implements Component, Focusable {
235
+ private readonly options: ManualAdvisorDialogOptions;
236
+ private readonly editor: Editor;
237
+ private readonly gitLevels: GitContextLevel[];
238
+ private gitIndex: number;
239
+ private actionIndex = 0;
240
+ private focusTarget: ManualAdvisorFocus = "editor";
241
+ private readonly state: { completed: boolean; focused: boolean } = {
242
+ completed: false,
243
+ focused: false,
244
+ };
245
+
246
+ get focused(): boolean {
247
+ return this.state.focused;
248
+ }
249
+
250
+ set focused(value: boolean) {
251
+ this.state.focused = value;
252
+ this.updateEditorFocus();
253
+ this.options.tui.requestRender();
254
+ }
255
+
256
+ constructor(options: ManualAdvisorDialogOptions) {
257
+ this.options = options;
258
+ // Show the configured default first, followed by progressively narrower
259
+ // choices. That makes Down do the useful thing from the initial selection
260
+ // while the configured level remains the hard ceiling.
261
+ this.gitLevels = GIT_CONTEXT_LEVELS.filter(
262
+ (level) => clampGitContextLevel(level, options.gitContext) === level
263
+ ).reverse();
264
+ this.gitIndex = Math.max(0, this.gitLevels.indexOf(options.gitContext));
265
+
266
+ const editorTheme: EditorTheme = {
267
+ borderColor: (text) => options.theme.fg("border", text),
268
+ selectList: {
269
+ description: (text) => options.theme.fg("muted", text),
270
+ noMatch: (text) => options.theme.fg("warning", text),
271
+ scrollInfo: (text) => options.theme.fg("dim", text),
272
+ selectedPrefix: (text) => options.theme.fg("accent", text),
273
+ selectedText: (text) => options.theme.fg("accent", text),
274
+ },
275
+ };
276
+ this.editor = new Editor(options.tui, editorTheme);
277
+ this.editor.setText(options.initialMessage ?? "");
278
+ this.editor.onChange = () => options.tui.requestRender();
279
+ this.editor.onSubmit = (message) => this.submit(message);
280
+ this.updateEditorFocus();
281
+ }
282
+
283
+ invalidate(): void {
284
+ this.editor.invalidate();
285
+ }
286
+
287
+ dispose(): void {
288
+ this.state.completed = true;
289
+ }
290
+
291
+ handleInput(keyData: string): void {
292
+ if (this.state.completed) {
293
+ return;
294
+ }
295
+ if (this.isCancel(keyData)) {
296
+ this.cancel();
297
+ return;
298
+ }
299
+ if (this.isShiftTab(keyData)) {
300
+ this.changeFocus(-1);
301
+ return;
302
+ }
303
+ if (this.isTab(keyData)) {
304
+ this.changeFocus(1);
305
+ return;
306
+ }
307
+
308
+ switch (this.focusTarget) {
309
+ case "editor":
310
+ // Editor owns Enter/Shift+Enter and all cursor/editing semantics. Its
311
+ // onSubmit callback above is the sole editor submission path. At the
312
+ // edge of the message, a directional key also moves to the adjacent
313
+ // form control so the Git selector is reachable without Tab.
314
+ this.handleEditorInput(keyData);
315
+ return;
316
+ case "git":
317
+ this.handleGitInput(keyData);
318
+ return;
319
+ case "actions":
320
+ this.handleActionInput(keyData);
321
+ return;
322
+ default:
323
+ return;
324
+ }
325
+ }
326
+
327
+ render(width: number): string[] {
328
+ const renderWidth = Math.max(1, Math.floor(width));
329
+ const innerWidth = Math.max(0, renderWidth - 2);
330
+ const horizontalPadding = renderWidth >= 4 ? 1 : 0;
331
+ const contentWidth = Math.max(0, innerWidth - horizontalPadding * 2);
332
+ const lines: string[] = [];
333
+
334
+ const focusMarker = (target: ManualAdvisorFocus) =>
335
+ this.focusTarget === target
336
+ ? this.options.theme.fg("accent", "▸ ")
337
+ : " ";
338
+ const addLine = (text: string) => {
339
+ if (renderWidth < 2) {
340
+ lines.push(truncateToWidth(text, renderWidth, ""));
341
+ return;
342
+ }
343
+ lines.push(
344
+ `${this.options.theme.fg("border", "│")}${" ".repeat(
345
+ horizontalPadding
346
+ )}${truncateToWidth(text, contentWidth, "", true)}${" ".repeat(
347
+ horizontalPadding
348
+ )}${this.options.theme.fg("border", "│")}`
349
+ );
350
+ };
351
+ const addWrapped = (
352
+ text: string,
353
+ color: "accent" | "text" | "muted" | "dim" = "text"
354
+ ) => {
355
+ const content = this.options.theme.fg(color, text);
356
+ const wrapped = wrapTextWithAnsi(content, Math.max(1, contentWidth - 2));
357
+ for (const line of wrapped.length > 0 ? wrapped : [""]) {
358
+ addLine(` ${line}`);
359
+ }
360
+ };
361
+
362
+ if (renderWidth >= 2) {
363
+ const title = truncateToWidth(
364
+ " Ask Advisor ",
365
+ Math.max(0, innerWidth),
366
+ "",
367
+ false
368
+ );
369
+ const titleWidth = visibleWidth(title);
370
+ const remaining = Math.max(0, innerWidth - titleWidth);
371
+ const left = Math.floor(remaining / 2);
372
+ const right = remaining - left;
373
+ lines.push(
374
+ this.options.theme.fg("border", `╭${"─".repeat(left)}`) +
375
+ this.options.theme.fg("accent", title) +
376
+ this.options.theme.fg("border", `${"─".repeat(right)}╮`)
377
+ );
378
+ addLine("");
379
+ }
380
+
381
+ addLine(
382
+ `${focusMarker("editor")}${this.options.theme.bold(
383
+ "Message for Advisor (optional)"
384
+ )}`
385
+ );
386
+ const editorWidth = Math.max(1, contentWidth);
387
+ for (const editorLine of this.editor.render(editorWidth)) {
388
+ addLine(editorLine);
389
+ }
390
+ addLine("");
391
+ addWrapped(
392
+ "Automatic context follows your settings: conversation history, tool disclosure, project preferences, redaction, and configured limits remain unchanged.",
393
+ "muted"
394
+ );
395
+ addWrapped(
396
+ "This dialog does not expose drafts or explicit file handoff controls.",
397
+ "dim"
398
+ );
399
+ addWrapped(
400
+ "Git None only withholds repository data; it does not remove configured conversation history.",
401
+ "dim"
402
+ );
403
+ addLine("");
404
+ const gitCeiling = MANUAL_GIT_CONTEXT_COPY[this.options.gitContext].label;
405
+ addLine(
406
+ `${focusMarker("git")}${this.options.theme.bold(
407
+ `Git repository context (max: ${gitCeiling}; ↑/↓ or Space to choose)`
408
+ )}`
409
+ );
410
+ for (let index = 0; index < this.gitLevels.length; index += 1) {
411
+ const level = this.gitLevels[index];
412
+ const copy = MANUAL_GIT_CONTEXT_COPY[level];
413
+ const selected = index === this.gitIndex;
414
+ const marker = selected ? "●" : "○";
415
+ const optionFocus = this.focusTarget === "git" && selected ? "▸ " : " ";
416
+ const color = selected ? "accent" : "text";
417
+ addWrapped(
418
+ `${optionFocus}${marker} ${copy.label} — ${copy.description}`,
419
+ color
420
+ );
421
+ }
422
+ addLine("");
423
+
424
+ let interactionHint =
425
+ "Enter submit · Shift+Enter newline · Tab/Shift+Tab focus · Esc cancel";
426
+ if (this.focusTarget === "git") {
427
+ interactionHint =
428
+ "↑/↓ or Space choose · Enter next · Tab/Shift+Tab focus · Esc cancel";
429
+ } else if (this.focusTarget === "actions") {
430
+ interactionHint =
431
+ "←/→ choose · Enter/Space activate · Tab/Shift+Tab focus · Esc cancel";
432
+ }
433
+ const submit = this.focusTarget === "actions" && this.actionIndex === 0;
434
+ const submitLabel = submit
435
+ ? this.options.theme.fg("accent", "[Submit]")
436
+ : this.options.theme.fg("text", "[Submit]");
437
+ const cancel = this.focusTarget === "actions" && this.actionIndex === 1;
438
+ const cancelLabel = cancel
439
+ ? this.options.theme.fg("accent", "[Cancel]")
440
+ : this.options.theme.fg("text", "[Cancel]");
441
+ addLine(
442
+ `${focusMarker("actions")} ${submitLabel} ${cancelLabel}`
443
+ );
444
+ addWrapped(interactionHint, "dim");
445
+ addLine("");
446
+
447
+ if (renderWidth >= 2) {
448
+ lines.push(
449
+ this.options.theme.fg(
450
+ "border",
451
+ `╰${"─".repeat(Math.max(0, renderWidth - 2))}╯`
452
+ )
453
+ );
454
+ }
455
+ return lines.map((line) => truncateToWidth(line, renderWidth, ""));
456
+ }
457
+
458
+ private handleEditorInput(keyData: string): void {
459
+ let direction: -1 | 1 | undefined;
460
+ if (this.matches(keyData, "tui.editor.cursorDown", Key.down)) {
461
+ direction = 1;
462
+ } else if (this.matches(keyData, "tui.editor.cursorUp", Key.up)) {
463
+ direction = -1;
464
+ }
465
+
466
+ const beforeCursor =
467
+ direction === undefined ? undefined : this.editor.getCursor();
468
+ const beforeText =
469
+ direction === undefined ? undefined : this.editor.getText();
470
+ this.editor.handleInput(keyData);
471
+
472
+ if (direction !== undefined) {
473
+ const afterCursor = this.editor.getCursor();
474
+ if (
475
+ beforeCursor?.line === afterCursor.line &&
476
+ beforeCursor.col === afterCursor.col &&
477
+ beforeText === this.editor.getText()
478
+ ) {
479
+ this.changeFocus(direction);
480
+ return;
481
+ }
482
+ }
483
+ this.options.tui.requestRender();
484
+ }
485
+
486
+ private handleGitInput(keyData: string): void {
487
+ // Accept both the selector bindings and the editor arrow bindings. The
488
+ // latter keeps the form usable with user keymaps that customize selector
489
+ // actions, while the raw-key fallback keeps terminal arrow sequences
490
+ // working across Pi/pi-tui versions.
491
+ const up =
492
+ this.matches(keyData, "tui.select.up", Key.up) ||
493
+ this.matches(keyData, "tui.editor.cursorUp", Key.up);
494
+ const down =
495
+ this.matches(keyData, "tui.select.down", Key.down) ||
496
+ this.matches(keyData, "tui.editor.cursorDown", Key.down);
497
+ if (up) {
498
+ if (this.gitIndex === 0) {
499
+ this.changeFocus(-1);
500
+ } else {
501
+ this.moveGit(-1);
502
+ }
503
+ return;
504
+ }
505
+ if (down) {
506
+ if (this.gitIndex === this.gitLevels.length - 1) {
507
+ this.changeFocus(1);
508
+ } else {
509
+ this.moveGit(1);
510
+ }
511
+ return;
512
+ }
513
+ if (matchesKey(keyData, Key.space)) {
514
+ this.moveGit(1);
515
+ return;
516
+ }
517
+ if (this.matches(keyData, "tui.input.submit", Key.enter)) {
518
+ this.changeFocus(1);
519
+ return;
520
+ }
521
+ this.options.tui.requestRender();
522
+ }
523
+
524
+ private handleActionInput(keyData: string): void {
525
+ if (this.matches(keyData, "tui.editor.cursorUp", Key.up)) {
526
+ this.changeFocus(-1);
527
+ return;
528
+ }
529
+ if (this.matches(keyData, "tui.editor.cursorDown", Key.down)) {
530
+ this.changeFocus(1);
531
+ return;
532
+ }
533
+ if (matchesKey(keyData, Key.left) || matchesKey(keyData, Key.right)) {
534
+ this.actionIndex = this.actionIndex === 0 ? 1 : 0;
535
+ this.options.tui.requestRender();
536
+ return;
537
+ }
538
+ if (
539
+ this.matches(keyData, "tui.input.submit", Key.enter) ||
540
+ matchesKey(keyData, Key.space)
541
+ ) {
542
+ if (this.actionIndex === 0) {
543
+ this.submit();
544
+ } else {
545
+ this.cancel();
546
+ }
547
+ }
548
+ }
549
+
550
+ private changeFocus(direction: -1 | 1): void {
551
+ const targets: ManualAdvisorFocus[] = ["editor", "git", "actions"];
552
+ const current = targets.indexOf(this.focusTarget);
553
+ this.focusTarget =
554
+ targets[(current + direction + targets.length) % targets.length];
555
+ this.updateEditorFocus();
556
+ this.options.tui.requestRender();
557
+ }
558
+
559
+ private moveGit(direction: -1 | 1): void {
560
+ if (this.gitLevels.length > 0) {
561
+ this.gitIndex =
562
+ (this.gitIndex + direction + this.gitLevels.length) %
563
+ this.gitLevels.length;
564
+ }
565
+ this.options.tui.requestRender();
566
+ }
567
+
568
+ private updateEditorFocus(): void {
569
+ this.editor.focused = this.state.focused && this.focusTarget === "editor";
570
+ }
571
+
572
+ private isTab(keyData: string): boolean {
573
+ return (
574
+ this.matches(keyData, "tui.input.tab", Key.tab) &&
575
+ !matchesKey(keyData, Key.shift("tab"))
576
+ );
577
+ }
578
+
579
+ private isShiftTab(keyData: string): boolean {
580
+ return matchesKey(keyData, Key.shift("tab"));
581
+ }
582
+
583
+ private isCancel(keyData: string): boolean {
584
+ return (
585
+ matchesKey(keyData, Key.escape) ||
586
+ this.options.keybindings.matches(keyData, "tui.select.cancel")
587
+ );
588
+ }
589
+
590
+ private matches(
591
+ keyData: string,
592
+ action: keyof Keybindings,
593
+ fallback: KeyId
594
+ ): boolean {
595
+ return (
596
+ this.options.keybindings.matches(keyData, action) ||
597
+ matchesKey(keyData, fallback)
598
+ );
599
+ }
600
+
601
+ private submit(message = this.editor.getText()): void {
602
+ if (this.state.completed) {
603
+ return;
604
+ }
605
+ this.state.completed = true;
606
+ this.options.onSubmit({
607
+ gitContext: this.gitLevels[this.gitIndex] ?? "off",
608
+ ...(message ? { message } : {}),
609
+ });
610
+ }
611
+
612
+ private cancel(): void {
613
+ if (this.state.completed) {
614
+ return;
615
+ }
616
+ this.state.completed = true;
617
+ this.options.onCancel();
618
+ }
619
+ }
620
+
206
621
  export interface ContextPreset {
207
622
  description: string;
208
623
  label: string;
@@ -230,6 +645,8 @@ export interface AdvisorSettings {
230
645
  redactSecrets?: boolean;
231
646
  scoutEnabled?: boolean;
232
647
  sessionSummary?: boolean;
648
+ showUsageDetails?: boolean;
649
+ showUsageFooter?: boolean;
233
650
  simpleMode?: boolean;
234
651
  toolPolicies?: Record<string, "full" | "summary" | "exclude">;
235
652
  toolResultMaxBytes?: number;
@@ -238,231 +655,644 @@ export interface AdvisorSettings {
238
655
  untrackedContent?: boolean;
239
656
  }
240
657
 
241
- type AdvisorSettingsRow =
242
- | "simpleMode"
243
- | "alwaysOn"
244
- | "context"
245
- | "effort"
246
- | "scoutEnabled"
247
- | "planGate"
248
- | "failureGate"
249
- | "completionGate"
250
- | "collapseResponses"
251
- | "customRule"
252
- | "blockOnBlocked"
253
- | "autoLoopGate"
254
- | "loopThreshold"
255
- | "maxCallsPerSession"
256
- | "sessionSummary"
257
- | "failureMode"
258
- | "herdrIntegration"
259
- | "toolResultMaxLines"
260
- | "toolResultMaxBytes"
261
- | "redactSecrets"
262
- | "gitContext"
263
- | "gitContextMaxChars"
264
- | "toolPolicies"
265
- | "outcomeLogging"
266
- | "trackedFileContent"
267
- | "untrackedContent"
268
- | "save";
269
-
270
- const ADVANCED_ROWS: AdvisorSettingsRow[] = [
271
- "context",
272
- "effort",
658
+ type SettingValue = string | undefined;
659
+
660
+ interface AdvisorSettingsSelectorOptions {
661
+ effortLevels: string[];
662
+ initial: AdvisorSettings;
663
+ onCancel: () => void;
664
+ onChange?: (settings: AdvisorSettings) => void;
665
+ /** @deprecated Use onChange; retained for extensions embedding this component. */
666
+ onSave?: (settings: AdvisorSettings) => void;
667
+ presets: ContextPreset[];
668
+ theme: Theme;
669
+ tui: RenderRequester;
670
+ }
671
+
672
+ interface TextSettingSubmenuOptions {
673
+ description: string;
674
+ initial: string;
675
+ onCancel: (value?: string) => void;
676
+ onSubmit: (value: string) => { error?: string; value?: string };
677
+ theme: Theme;
678
+ title: string;
679
+ tui: RenderRequester;
680
+ }
681
+
682
+ /** Small inline editor used by SettingsList for the two free-form settings. */
683
+ class TextSettingSubmenu implements Component, Focusable {
684
+ private readonly input = new Input();
685
+ private readonly options: TextSettingSubmenuOptions;
686
+ private _focused = true;
687
+ private error: string | undefined;
688
+
689
+ get focused(): boolean {
690
+ return this._focused;
691
+ }
692
+
693
+ set focused(value: boolean) {
694
+ this._focused = value;
695
+ this.input.focused = value;
696
+ }
697
+
698
+ constructor(options: TextSettingSubmenuOptions) {
699
+ this.options = options;
700
+ this.input.setValue(options.initial);
701
+ this.input.focused = true;
702
+ this.input.onSubmit = (value) => {
703
+ const result = options.onSubmit(value);
704
+ if (result.error) {
705
+ this.error = result.error;
706
+ options.tui.requestRender();
707
+ return;
708
+ }
709
+ this.error = undefined;
710
+ options.onCancel(result.value);
711
+ };
712
+ this.input.onEscape = () => options.onCancel();
713
+ }
714
+
715
+ invalidate(): void {
716
+ this.input.invalidate();
717
+ }
718
+
719
+ render(width: number): string[] {
720
+ const { theme } = this.options;
721
+ const input = this.input.render(Math.max(10, width - 4))[0] || "";
722
+ const lines = [
723
+ theme.fg("accent", theme.bold(` ${this.options.title}`)),
724
+ "",
725
+ theme.fg("muted", ` ${this.options.description}`),
726
+ "",
727
+ ` ${input}`,
728
+ ];
729
+ if (this.error) {
730
+ lines.push(theme.fg("error", ` ${this.error}`));
731
+ }
732
+ lines.push("", theme.fg("dim", " Enter: apply · Esc: cancel"));
733
+ return lines.map((line) => truncateToWidth(line, width));
734
+ }
735
+
736
+ handleInput(keyData: string): void {
737
+ this.input.handleInput(keyData);
738
+ }
739
+ }
740
+
741
+ const DEFAULT_EFFORT_LEVEL = "Default (Model Default)";
742
+ const TOGGLE_VALUES = ["On", "Off"];
743
+ const SIMPLE_MODE_GRADIENT_INTERVAL_MS = 100;
744
+ // Purple steps with a moving light highlight, retained from the original UI.
745
+ const SIMPLE_MODE_GRADIENT_COLORS = [
746
+ [125, 79, 205],
747
+ [143, 96, 218],
748
+ [160, 114, 230],
749
+ [178, 135, 238],
750
+ [195, 157, 245],
751
+ [168, 120, 230],
752
+ [143, 89, 215],
753
+ ] as const;
754
+ const BOOLEAN_SETTING_IDS = new Set([
273
755
  "scoutEnabled",
756
+ "showUsageDetails",
757
+ "showUsageFooter",
274
758
  "planGate",
275
759
  "failureGate",
276
760
  "completionGate",
277
761
  "collapseResponses",
278
- "customRule",
279
762
  "blockOnBlocked",
280
763
  "autoLoopGate",
281
- "loopThreshold",
282
- "maxCallsPerSession",
283
764
  "sessionSummary",
284
- "failureMode",
285
765
  "herdrIntegration",
286
- "toolResultMaxLines",
287
- "toolResultMaxBytes",
288
766
  "redactSecrets",
289
- "gitContext",
290
- "gitContextMaxChars",
291
- "toolPolicies",
292
767
  "trackedFileContent",
293
768
  "untrackedContent",
294
769
  "outcomeLogging",
295
- ];
770
+ ]);
296
771
 
297
- const SIMPLE_MODE_GRADIENT_INTERVAL_MS = 100;
298
- // #763FCD and nearby lighter/darker purple steps; white is the moving shine.
299
- const SIMPLE_MODE_GRADIENT_COLORS = [
300
- [125, 79, 205],
301
- [143, 96, 218],
302
- [160, 114, 230],
303
- [178, 135, 238],
304
- [195, 157, 245],
305
- [168, 120, 230],
306
- [143, 89, 215],
307
- ] as const;
772
+ const withCurrentValue = (current: string, values: string[]) =>
773
+ values.includes(current) ? values : [current, ...values];
774
+
775
+ const numericValues = (current: number, values: number[]) => {
776
+ const all = values.includes(current) ? values : [...values, current];
777
+ return all.sort((a, b) => a - b).map(String);
778
+ };
779
+
780
+ const maxCallValues = (current: string) => {
781
+ const values = ["0", "1", "2", "3", "5", "10", "25", "50", "∞"];
782
+ if (values.includes(current)) {
783
+ return values;
784
+ }
785
+ const numeric = Number(current);
786
+ const insertionIndex = values.findIndex(
787
+ (value) => value !== "∞" && Number(value) > numeric
788
+ );
789
+ return insertionIndex === -1
790
+ ? [...values.slice(0, -1), current, "∞"]
791
+ : [
792
+ ...values.slice(0, insertionIndex),
793
+ current,
794
+ ...values.slice(insertionIndex),
795
+ ];
796
+ };
797
+
798
+ const settingValue = (value: boolean | undefined, defaultValue: boolean) =>
799
+ (value ?? defaultValue) ? "On" : "Off";
308
800
 
309
801
  export class AdvisorSettingsSelector implements Component, Focusable {
310
- private selectedRow: number;
311
- private contextIndex: number;
312
- private effortIndex: number;
802
+ private readonly options: AdvisorSettingsSelectorOptions;
313
803
  private readonly settings: AdvisorSettings;
314
- private readonly customInput = new Input();
315
- private readonly policiesInput = new Input();
316
- private editingCustom: boolean;
317
- private editingPolicies: boolean;
318
- private policiesError: string | undefined;
804
+ private readonly presets: ContextPreset[];
805
+ private settingsList: SettingsList;
319
806
  private simpleModeGradientStartedAt: number | undefined;
320
807
  private simpleModeGradientTimer: ReturnType<typeof setInterval> | undefined;
321
808
  private _focused = false;
322
- private readonly options: AdvisorSettingsSelectorOptions;
323
809
 
324
810
  get focused(): boolean {
325
811
  return this._focused;
326
812
  }
813
+
327
814
  set focused(value: boolean) {
328
815
  this._focused = value;
329
- this.customInput.focused = value && this.editingCustom;
330
- this.policiesInput.focused = value && this.editingPolicies;
816
+ const submenu = this.settingsList as unknown as {
817
+ submenuComponent?: Focusable;
818
+ };
819
+ if (submenu.submenuComponent) {
820
+ submenu.submenuComponent.focused = value;
821
+ }
331
822
  }
332
823
 
333
824
  constructor(options: AdvisorSettingsSelectorOptions) {
334
- this.selectedRow = 0;
335
- this.editingCustom = false;
336
- this.editingPolicies = false;
337
- this.policiesError = undefined;
338
- // Retain every advanced value even when Simple mode hides its controls.
825
+ this.options = options;
339
826
  this.settings = { ...options.initial };
340
827
  const configuredContext = this.settings.contextMaxChars;
341
- const presets = options.presets.some(
828
+ this.presets = options.presets.some(
342
829
  (preset) => preset.value === configuredContext
343
830
  )
344
831
  ? [...options.presets]
345
832
  : [
346
833
  ...options.presets,
347
834
  {
348
- description: "Custom configured value",
835
+ description: "Current custom context limit",
349
836
  label: String(configuredContext),
350
837
  value: configuredContext,
351
838
  },
352
839
  ].sort((a, b) => a.value - b.value);
353
- this.options = { ...options, presets };
354
- this.contextIndex = presets.findIndex(
355
- (preset) => preset.value === configuredContext
356
- );
357
- this.effortIndex = Math.max(
358
- 0,
359
- options.effortLevels.indexOf(
360
- this.settings.effort || "Default (Model Default)"
361
- )
362
- );
363
840
  if (this.settings.simpleMode) {
364
841
  this.startSimpleModeGradient();
365
842
  }
366
- this.customInput.onSubmit = (value) => {
367
- this.settings.customRule = value.trim() || undefined;
368
- this.editingCustom = false;
369
- this.customInput.focused = false;
370
- this.options.tui.requestRender();
371
- };
372
- this.customInput.onEscape = () => {
373
- this.editingCustom = false;
374
- this.customInput.focused = false;
375
- this.options.tui.requestRender();
376
- };
377
- this.policiesInput.onSubmit = (value) => {
378
- let parsed: unknown;
379
- try {
380
- parsed = JSON.parse(value || "{}");
381
- } catch {
382
- this.policiesError = "Enter a valid JSON object.";
383
- this.options.tui.requestRender();
384
- return;
385
- }
386
- if (!isValidAdvisorToolPolicies(parsed)) {
387
- this.policiesError =
388
- "Use non-empty tool names with full, summary, or exclude values.";
389
- this.options.tui.requestRender();
390
- return;
391
- }
392
- this.settings.toolPolicies = parsed;
393
- this.policiesError = undefined;
394
- this.editingPolicies = false;
395
- this.policiesInput.focused = false;
396
- this.options.tui.requestRender();
397
- };
398
- this.policiesInput.onEscape = () => {
399
- this.editingPolicies = false;
400
- this.policiesInput.focused = false;
401
- this.options.tui.requestRender();
402
- };
843
+ this.settingsList = this.createSettingsList();
403
844
  }
404
845
 
405
846
  invalidate(): void {
406
- this.options.tui.requestRender();
847
+ this.settingsList.invalidate();
407
848
  }
408
849
 
409
- /** Called by TUI teardown when it supports component disposal. */
410
850
  dispose(): void {
411
851
  this.stopSimpleModeGradient();
412
852
  }
413
853
 
414
- private visibleRows(): AdvisorSettingsRow[] {
415
- return this.settings.simpleMode
416
- ? ["context", "simpleMode", "alwaysOn", "save"]
417
- : [
418
- "context",
419
- "simpleMode",
420
- "alwaysOn",
421
- ...ADVANCED_ROWS.slice(1),
422
- "save",
423
- ];
854
+ render(width: number): string[] {
855
+ const border = this.options.theme.fg(
856
+ "border",
857
+ "─".repeat(Math.max(1, width))
858
+ );
859
+ return [border, ...this.settingsList.render(width), border].map((line) =>
860
+ truncateToWidth(line, width)
861
+ );
424
862
  }
425
863
 
426
- private selectedRowId(): AdvisorSettingsRow {
427
- return this.visibleRows()[this.selectedRow] ?? "simpleMode";
864
+ handleInput(keyData: string): void {
865
+ if (!this.changeWithArrow(keyData)) {
866
+ this.settingsList.handleInput(keyData);
867
+ }
868
+ this.options.tui.requestRender();
428
869
  }
429
870
 
430
- private focusRow(rowId: AdvisorSettingsRow): void {
431
- this.selectedRow = Math.max(0, this.visibleRows().indexOf(rowId));
871
+ private changeWithArrow(keyData: string): boolean {
872
+ let direction = 0;
873
+ if (matchesKey(keyData, Key.left) || keyData === "\u001b[D") {
874
+ direction = -1;
875
+ } else if (matchesKey(keyData, Key.right) || keyData === "\u001b[C") {
876
+ direction = 1;
877
+ }
878
+ if (direction === 0) {
879
+ return false;
880
+ }
881
+ // SettingsList has no public selection/value-adjustment API. Keep this
882
+ // compatibility shim isolated to the native component's current fields.
883
+ const list = this.settingsList as unknown as {
884
+ filteredItems: SettingItem[];
885
+ searchInput?: Input;
886
+ selectedIndex: number;
887
+ submenuComponent?: Component | null;
888
+ };
889
+ if (list.submenuComponent || list.searchInput?.getValue()) {
890
+ return false;
891
+ }
892
+ const item = list.filteredItems[list.selectedIndex];
893
+ if (!item?.values?.length) {
894
+ return false;
895
+ }
896
+ const currentIndex = item.values.indexOf(item.currentValue);
897
+ let nextIndex: number;
898
+ if (currentIndex === -1) {
899
+ nextIndex = direction > 0 ? 0 : item.values.length - 1;
900
+ } else {
901
+ nextIndex =
902
+ (currentIndex + direction + item.values.length) % item.values.length;
903
+ }
904
+ const nextValue = item.values[nextIndex];
905
+ if (nextValue === undefined) {
906
+ return false;
907
+ }
908
+ item.currentValue = nextValue;
909
+ this.change(item.id, nextValue);
910
+ return true;
432
911
  }
433
912
 
434
- private currentContext(): ContextPreset {
435
- const preset = this.options.presets.find(
436
- (item) => item.value === this.settings.contextMaxChars
913
+ private createSettingsList(selectedId?: string): SettingsList {
914
+ const listTheme = getSettingsListTheme();
915
+ const defaultLabel = listTheme.label;
916
+ listTheme.label = (text, selected) => {
917
+ if (this.settings.simpleMode && text.startsWith("Simple mode")) {
918
+ return `${this.rainbowGradient("Simple mode")}${text.slice("Simple mode".length)}`;
919
+ }
920
+ return defaultLabel(text, selected);
921
+ };
922
+ const items = this.items();
923
+ const list = new SettingsList(
924
+ items,
925
+ 10,
926
+ listTheme,
927
+ (id, value) => this.change(id, value),
928
+ this.options.onCancel,
929
+ { enableSearch: true }
437
930
  );
438
- return (
439
- preset ?? {
440
- description: "Current custom context limit",
441
- label: String(this.settings.contextMaxChars),
442
- value: this.settings.contextMaxChars,
931
+ if (selectedId) {
932
+ const selectedIndex = items.findIndex((item) => item.id === selectedId);
933
+ if (selectedIndex >= 0) {
934
+ (list as unknown as { selectedIndex: number }).selectedIndex =
935
+ selectedIndex;
443
936
  }
937
+ }
938
+ return list;
939
+ }
940
+
941
+ private items(): SettingItem[] {
942
+ const items: SettingItem[] = [
943
+ {
944
+ currentValue: this.currentContextLabel(),
945
+ description: this.contextDescription(),
946
+ id: "context",
947
+ label: "Context window",
948
+ values: this.presets.map((preset) => preset.label),
949
+ },
950
+ {
951
+ currentValue: settingValue(this.settings.simpleMode, false),
952
+ description:
953
+ "Keep the Advisor available on demand without automatic gates or blocks.",
954
+ id: "simpleMode",
955
+ label: "Simple mode",
956
+ values: TOGGLE_VALUES,
957
+ },
958
+ {
959
+ currentValue: settingValue(this.settings.alwaysOn, false),
960
+ description:
961
+ "Run the Advisor flow automatically for supported agent turns.",
962
+ id: "alwaysOn",
963
+ label: "Always on",
964
+ values: TOGGLE_VALUES,
965
+ },
966
+ ];
967
+ if (this.settings.simpleMode) {
968
+ return items;
969
+ }
970
+
971
+ items.push(
972
+ {
973
+ currentValue: this.currentEffort(),
974
+ description: "Reasoning level used for Advisor calls.",
975
+ id: "effort",
976
+ label: "Advisor reasoning",
977
+ values: withCurrentValue(
978
+ this.currentEffort(),
979
+ this.options.effortLevels
980
+ ),
981
+ },
982
+ this.toggle(
983
+ "scoutEnabled",
984
+ "Experimental Advisor Scout",
985
+ "Enable the experimental Scout before Advisor calls.",
986
+ this.settings.scoutEnabled,
987
+ false
988
+ ),
989
+ this.toggle(
990
+ "showUsageDetails",
991
+ "Show usage and cost details",
992
+ "Show token usage and cost details in Advisor responses.",
993
+ this.settings.showUsageDetails,
994
+ true
995
+ ),
996
+ this.toggle(
997
+ "showUsageFooter",
998
+ "Show usage in footer",
999
+ "Show the current Advisor usage summary in the footer.",
1000
+ this.settings.showUsageFooter,
1001
+ false
1002
+ ),
1003
+ this.toggle(
1004
+ "planGate",
1005
+ "Plan gate",
1006
+ "Ask the Advisor to review implementation plans.",
1007
+ this.settings.planGate,
1008
+ true
1009
+ ),
1010
+ this.toggle(
1011
+ "failureGate",
1012
+ "Failure gate",
1013
+ "Ask the Advisor to review repeated failures.",
1014
+ this.settings.failureGate,
1015
+ true
1016
+ ),
1017
+ this.toggle(
1018
+ "completionGate",
1019
+ "Completion gate",
1020
+ "Ask the Advisor to review work before declaring success.",
1021
+ this.settings.completionGate,
1022
+ true
1023
+ ),
1024
+ this.toggle(
1025
+ "collapseResponses",
1026
+ "Collapse long responses",
1027
+ "Collapse long Advisor responses in the transcript.",
1028
+ this.settings.collapseResponses,
1029
+ false
1030
+ ),
1031
+ {
1032
+ currentValue: this.settings.customRule || "None",
1033
+ description: "Add a rule that triggers Advisor involvement.",
1034
+ id: "customRule",
1035
+ label: "Custom invocation",
1036
+ submenu: (_currentValue, done) =>
1037
+ new TextSettingSubmenu({
1038
+ description: "Enter a custom invocation rule.",
1039
+ initial: this.settings.customRule || "",
1040
+ onCancel: done,
1041
+ onSubmit: (value) => ({ value: value.trim() }),
1042
+ theme: this.options.theme,
1043
+ title: "Custom invocation",
1044
+ tui: this.options.tui,
1045
+ }),
1046
+ },
1047
+ this.toggle(
1048
+ "blockOnBlocked",
1049
+ "Block on critical advice",
1050
+ "Block the agent when the Advisor returns a critical decision.",
1051
+ this.settings.blockOnBlocked,
1052
+ true
1053
+ ),
1054
+ this.toggle(
1055
+ "autoLoopGate",
1056
+ "Automatic loop gate",
1057
+ "Ask the Advisor to review repeated equivalent attempts.",
1058
+ this.settings.autoLoopGate,
1059
+ true
1060
+ ),
1061
+ {
1062
+ currentValue: `After ${this.settings.loopThreshold ?? 3} repeats`,
1063
+ description: "Number of equivalent attempts before automatic review.",
1064
+ id: "loopThreshold",
1065
+ label: "Loop threshold",
1066
+ values: numericValues(
1067
+ this.settings.loopThreshold ?? 3,
1068
+ Array.from({ length: 99 }, (_, index) => index + 2)
1069
+ ).map((value) => `After ${value} repeats`),
1070
+ },
1071
+ {
1072
+ currentValue:
1073
+ this.settings.maxCallsPerSession === undefined
1074
+ ? "∞"
1075
+ : String(this.settings.maxCallsPerSession),
1076
+ description: "Limit automatic Advisor calls in one session.",
1077
+ id: "maxCallsPerSession",
1078
+ label: "Max Advisor calls/session",
1079
+ values: maxCallValues(
1080
+ this.settings.maxCallsPerSession === undefined
1081
+ ? "∞"
1082
+ : String(this.settings.maxCallsPerSession)
1083
+ ),
1084
+ },
1085
+ this.toggle(
1086
+ "sessionSummary",
1087
+ "Session Advisor Summary",
1088
+ "Show a local summary when the session ends.",
1089
+ this.settings.sessionSummary,
1090
+ false
1091
+ ),
1092
+ {
1093
+ currentValue: this.settings.failureMode ?? "block-session",
1094
+ description: "Choose what happens when an Advisor gate fails.",
1095
+ id: "failureMode",
1096
+ label: "Gate failure mode",
1097
+ values: withCurrentValue(this.settings.failureMode ?? "block-session", [
1098
+ "block-session",
1099
+ "block-tool",
1100
+ "warn-and-continue",
1101
+ ]),
1102
+ },
1103
+ this.toggle(
1104
+ "herdrIntegration",
1105
+ "Herdr integration",
1106
+ "Send Advisor activity to the optional Herdr integration.",
1107
+ this.settings.herdrIntegration,
1108
+ true
1109
+ ),
1110
+ {
1111
+ currentValue: String(this.settings.toolResultMaxLines ?? 2000),
1112
+ description: "Maximum lines included from a tool result.",
1113
+ id: "toolResultMaxLines",
1114
+ label: "Tool result lines",
1115
+ values: numericValues(
1116
+ this.settings.toolResultMaxLines ?? 2000,
1117
+ [0, 500, 1000, 2000, 5000, 10_000]
1118
+ ),
1119
+ },
1120
+ {
1121
+ currentValue: String(this.settings.toolResultMaxBytes ?? 50 * 1024),
1122
+ description: "Maximum bytes included from a tool result.",
1123
+ id: "toolResultMaxBytes",
1124
+ label: "Tool result bytes",
1125
+ values: numericValues(this.settings.toolResultMaxBytes ?? 50 * 1024, [
1126
+ 0,
1127
+ 10 * 1024,
1128
+ 50 * 1024,
1129
+ 100 * 1024,
1130
+ 500 * 1024,
1131
+ ]),
1132
+ },
1133
+ this.toggle(
1134
+ "redactSecrets",
1135
+ "Redact common secrets",
1136
+ "Redact common credential patterns before Advisor calls.",
1137
+ this.settings.redactSecrets,
1138
+ false
1139
+ ),
1140
+ {
1141
+ currentValue: this.settings.gitContext ?? "summary",
1142
+ description: "How much repository context is shared with the Advisor.",
1143
+ id: "gitContext",
1144
+ label: "Repository context",
1145
+ values: withCurrentValue(this.settings.gitContext ?? "summary", [
1146
+ "off",
1147
+ "summary",
1148
+ "full",
1149
+ ]),
1150
+ },
1151
+ {
1152
+ currentValue: String(this.settings.gitContextMaxChars ?? 20_000),
1153
+ description: "Maximum repository context characters included.",
1154
+ id: "gitContextMaxChars",
1155
+ label: "Repository context chars",
1156
+ values: numericValues(
1157
+ this.settings.gitContextMaxChars ?? 20_000,
1158
+ [0, 5000, 10_000, 20_000, 50_000, 100_000]
1159
+ ),
1160
+ },
1161
+ {
1162
+ currentValue: Object.keys(this.settings.toolPolicies ?? {}).length
1163
+ ? "Configured"
1164
+ : "All tools: full",
1165
+ description:
1166
+ "Choose which tools are shared in full, summarized, or excluded.",
1167
+ id: "toolPolicies",
1168
+ label: "Tool disclosure policies",
1169
+ submenu: (_currentValue, done) =>
1170
+ new TextSettingSubmenu({
1171
+ description:
1172
+ 'Enter a JSON object with "full", "summary", or "exclude" values.',
1173
+ initial: JSON.stringify(this.settings.toolPolicies ?? {}),
1174
+ onCancel: done,
1175
+ onSubmit: (value) => {
1176
+ let parsed: unknown;
1177
+ try {
1178
+ parsed = JSON.parse(value || "{}");
1179
+ } catch {
1180
+ return { error: "Enter a valid JSON object." };
1181
+ }
1182
+ if (!isValidAdvisorToolPolicies(parsed)) {
1183
+ return {
1184
+ error:
1185
+ "Use non-empty tool names with full, summary, or exclude values.",
1186
+ };
1187
+ }
1188
+ return { value: JSON.stringify(parsed) };
1189
+ },
1190
+ theme: this.options.theme,
1191
+ title: "Tool disclosure policies",
1192
+ tui: this.options.tui,
1193
+ }),
1194
+ },
1195
+ this.toggle(
1196
+ "trackedFileContent",
1197
+ "Tracked file content",
1198
+ "Allow tracked file contents to be sent with Advisor context.",
1199
+ this.settings.trackedFileContent,
1200
+ false
1201
+ ),
1202
+ this.toggle(
1203
+ "untrackedContent",
1204
+ "Untracked file content",
1205
+ "Allow untracked file contents to be sent with Advisor context.",
1206
+ this.settings.untrackedContent,
1207
+ false
1208
+ ),
1209
+ this.toggle(
1210
+ "outcomeLogging",
1211
+ "Outcome logging (global)",
1212
+ "Allow anonymized Advisor outcomes to be logged globally.",
1213
+ this.settings.outcomeLogging,
1214
+ false
1215
+ )
444
1216
  );
1217
+ return items;
445
1218
  }
446
1219
 
447
- private currentEffort() {
448
- return this.settings.effort || "Default (Model Default)";
1220
+ private toggle(
1221
+ id: string,
1222
+ label: string,
1223
+ description: string,
1224
+ value: boolean | undefined,
1225
+ defaultValue: boolean
1226
+ ): SettingItem {
1227
+ return {
1228
+ currentValue: settingValue(value, defaultValue),
1229
+ description,
1230
+ id,
1231
+ label,
1232
+ values: TOGGLE_VALUES,
1233
+ };
449
1234
  }
450
1235
 
451
- private row(label: string, value: string, rowId: AdvisorSettingsRow): string {
452
- const { theme } = this.options;
453
- const selected = rowId === this.selectedRowId();
454
- const prefix = selected ? theme.fg("accent", "›") : " ";
455
- const text = `${prefix} ${label.padEnd(28)} ${value}`;
456
- if (
457
- rowId === "simpleMode" &&
458
- this.settings.simpleMode &&
459
- this.simpleModeGradientStartedAt !== undefined
460
- ) {
461
- return `${prefix} ${this.rainbowGradient(label)}${" ".repeat(Math.max(0, 28 - label.length))} ${value}`;
462
- }
463
- return selected
464
- ? theme.fg("accent", theme.bold(text))
465
- : theme.fg("text", text);
1236
+ private currentContextLabel(): string {
1237
+ return (
1238
+ this.presets.find(
1239
+ (preset) => preset.value === this.settings.contextMaxChars
1240
+ )?.label ?? String(this.settings.contextMaxChars)
1241
+ );
1242
+ }
1243
+
1244
+ private contextDescription(): string {
1245
+ const exactIndex = this.presets.findIndex(
1246
+ (preset) => preset.value === this.settings.contextMaxChars
1247
+ );
1248
+ const selectedIndex =
1249
+ exactIndex >= 0
1250
+ ? exactIndex
1251
+ : this.presets.reduce(
1252
+ (closestIndex, preset, index) =>
1253
+ Math.abs(preset.value - this.settings.contextMaxChars) <
1254
+ Math.abs(
1255
+ this.presets[closestIndex].value - this.settings.contextMaxChars
1256
+ )
1257
+ ? index
1258
+ : closestIndex,
1259
+ 0
1260
+ );
1261
+ const selectedPreset = this.presets[selectedIndex];
1262
+ const isFullContext =
1263
+ selectedPreset?.value === Number.MAX_SAFE_INTEGER ||
1264
+ selectedPreset?.label.toUpperCase() === "FULL" ||
1265
+ selectedPreset?.label.toUpperCase() === "ALL";
1266
+ const progress = isFullContext
1267
+ ? 1
1268
+ : selectedIndex / Math.max(1, this.presets.length - 1);
1269
+ const meterWidth = 20;
1270
+ const marker = Math.round(progress * meterWidth);
1271
+ const meter = Array.from({ length: meterWidth + 1 }, (_, index) => {
1272
+ if (index === marker) {
1273
+ return "●";
1274
+ }
1275
+ return index < marker ? "━" : "─";
1276
+ }).join("");
1277
+ const label = this.currentContextLabel();
1278
+ const labelWidth = visibleWidth(label);
1279
+ const meterPrefix = "none ";
1280
+ const markerColumn = meterPrefix.length + marker;
1281
+ const labelStart = Math.max(
1282
+ 0,
1283
+ Math.min(
1284
+ meterPrefix.length + meter.length + 2 - labelWidth,
1285
+ markerColumn - Math.floor((labelWidth - 1) / 2)
1286
+ )
1287
+ );
1288
+ const markerLabel = `${" ".repeat(labelStart)}${label}`;
1289
+ const description =
1290
+ exactIndex >= 0 ? selectedPreset?.description : "Custom context limit.";
1291
+ return `${description ?? "Custom context limit."}\n${meterPrefix}${meter} full\n${markerLabel}`;
1292
+ }
1293
+
1294
+ private currentEffort(): string {
1295
+ return this.settings.effort || DEFAULT_EFFORT_LEVEL;
466
1296
  }
467
1297
 
468
1298
  private rainbowGradient(text: string): string {
@@ -510,449 +1340,79 @@ export class AdvisorSettingsSelector implements Component, Focusable {
510
1340
  this.simpleModeGradientStartedAt = undefined;
511
1341
  }
512
1342
 
513
- private save(): void {
514
- this.stopSimpleModeGradient();
515
- this.options.onSave({
516
- ...this.settings,
517
- contextMaxChars: this.currentContext().value,
518
- effort: this.currentEffort(),
519
- });
520
- }
521
-
522
- private cancel(): void {
523
- this.stopSimpleModeGradient();
524
- this.options.onCancel();
525
- }
526
-
527
- /** Disclosure and output-limit rows, split out to keep each builder simple. */
528
- private disclosureRows(): string[] {
529
- const onOff = (value: boolean) => (value ? "On" : "Off");
530
- return [
531
- this.row(
532
- "Tool result lines",
533
- String(this.settings.toolResultMaxLines ?? 2000),
534
- "toolResultMaxLines"
535
- ),
536
- this.row(
537
- "Tool result bytes",
538
- String(this.settings.toolResultMaxBytes ?? 50 * 1024),
539
- "toolResultMaxBytes"
540
- ),
541
- this.row(
542
- "Redact common secrets",
543
- onOff(this.settings.redactSecrets ?? false),
544
- "redactSecrets"
545
- ),
546
- this.row(
547
- "Repository context",
548
- this.settings.gitContext ?? "summary",
549
- "gitContext"
550
- ),
551
- this.row(
552
- "Repository context chars",
553
- String(this.settings.gitContextMaxChars ?? 20_000),
554
- "gitContextMaxChars"
555
- ),
556
- this.row(
557
- "Tool disclosure policies",
558
- Object.keys(this.settings.toolPolicies ?? {}).length
559
- ? "Exact names configured"
560
- : "All tools: full",
561
- "toolPolicies"
562
- ),
563
- this.row(
564
- "Tracked file content",
565
- onOff(this.settings.trackedFileContent ?? false),
566
- "trackedFileContent"
567
- ),
568
- this.row(
569
- "Untracked file content",
570
- onOff(this.settings.untrackedContent ?? false),
571
- "untrackedContent"
572
- ),
573
- this.row(
574
- "Outcome logging (global)",
575
- onOff(this.settings.outcomeLogging ?? false),
576
- "outcomeLogging"
577
- ),
578
- ];
579
- }
580
-
581
- private advancedRows(width: number): string[] {
582
- const onOff = (value: boolean) => (value ? "On" : "Off");
583
- const rows = [
584
- this.row("Advisor reasoning", this.currentEffort(), "effort"),
585
- this.row(
586
- "Experimental Advisor Scout",
587
- onOff(this.settings.scoutEnabled ?? false),
588
- "scoutEnabled"
589
- ),
590
- this.row("Plan gate", onOff(this.settings.planGate), "planGate"),
591
- this.row("Failure gate", onOff(this.settings.failureGate), "failureGate"),
592
- this.row(
593
- "Completion gate",
594
- onOff(this.settings.completionGate),
595
- "completionGate"
596
- ),
597
- this.row(
598
- "Collapse long responses",
599
- onOff(this.settings.collapseResponses),
600
- "collapseResponses"
601
- ),
602
- this.row(
603
- "Custom invocation",
604
- this.settings.customRule || "None",
605
- "customRule"
606
- ),
607
- this.row(
608
- "Block on critical advice",
609
- onOff(this.settings.blockOnBlocked ?? true),
610
- "blockOnBlocked"
611
- ),
612
- this.row(
613
- "Automatic loop gate",
614
- onOff(this.settings.autoLoopGate ?? true),
615
- "autoLoopGate"
616
- ),
617
- this.row(
618
- "Loop threshold",
619
- `After ${this.settings.loopThreshold ?? 3} repeats`,
620
- "loopThreshold"
621
- ),
622
- this.row(
623
- "Max Advisor calls/session",
624
- this.settings.maxCallsPerSession === undefined
625
- ? "∞"
626
- : String(this.settings.maxCallsPerSession),
627
- "maxCallsPerSession"
628
- ),
629
- this.row(
630
- "Session Advisor Summary",
631
- onOff(this.settings.sessionSummary ?? false),
632
- "sessionSummary"
633
- ),
634
- this.row(
635
- "Gate failure mode",
636
- this.settings.failureMode ?? "block-session",
637
- "failureMode"
638
- ),
639
- this.row(
640
- "Herdr integration",
641
- onOff(this.settings.herdrIntegration ?? true),
642
- "herdrIntegration"
643
- ),
644
- ...this.disclosureRows(),
645
- ];
646
- if (this.editingCustom) {
647
- rows.push(
648
- ` ${this.customInput.render(Math.max(10, width - 6))[0] || ""}`
649
- );
650
- }
651
- if (this.editingPolicies) {
652
- rows.push(
653
- ` ${this.policiesInput.render(Math.max(10, width - 6))[0] || ""}`
654
- );
655
- if (this.policiesError) {
656
- rows.push(` ${this.options.theme.fg("error", this.policiesError)}`);
657
- }
658
- }
659
- rows.push(this.row("Save changes", "", "save"));
660
- return rows;
661
- }
662
-
663
- render(width: number): string[] {
664
- const { theme, presets } = this.options;
665
- const simpleMode = this.settings.simpleMode ?? false;
666
- const modeRows = [
667
- this.row("Simple mode", simpleMode ? "On" : "Off", "simpleMode"),
668
- this.row("Always on", this.settings.alwaysOn ? "On" : "Off", "alwaysOn"),
669
- ];
670
- const lines = [theme.fg("accent", theme.bold(" Advisor settings")), ""];
671
- if (simpleMode) {
672
- const trackWidth = Math.max(24, Math.min(60, width - 4));
673
- const positions = presets.map((_, index) =>
674
- Math.round((index * (trackWidth - 1)) / Math.max(1, presets.length - 1))
675
- );
676
- const track = Array.from({ length: trackWidth }, () => "─");
677
- track[positions[this.contextIndex]] = "▲";
678
- const labels = Array.from({ length: trackWidth }, () => " ");
679
- for (let index = 0; index < presets.length; index += 1) {
680
- const { label } = presets[index];
681
- const start = Math.max(
682
- 0,
683
- Math.min(
684
- trackWidth - label.length,
685
- positions[index] - Math.floor(label.length / 2)
686
- )
687
- );
688
- for (let char = 0; char < label.length; char += 1) {
689
- labels[start + char] = label[char];
690
- }
691
- }
692
- lines.push(
693
- ` ${this.row("Context window", this.currentContext().label, "context")}`
694
- );
695
- lines.push(` ${theme.fg("muted", "Recent history")}`);
696
- lines.push(` ${theme.fg("muted", track.join(""))}`);
697
- lines.push(` ${theme.fg("text", labels.join(""))}`);
698
- lines.push("");
699
- lines.push(...modeRows.map((line) => ` ${line}`));
700
- lines.push(` ${this.row("Save changes", "", "save")}`);
701
- lines.push("");
702
- lines.push(
703
- ` ${theme.fg("muted", "↑/↓ select · ←/→ adjust · Enter saves · Esc cancels")}`
704
- );
705
- return lines.map((line) => truncateToWidth(line, width));
706
- }
707
-
708
- const trackWidth = Math.max(24, Math.min(60, width - 4));
709
- const positions = presets.map((_, index) =>
710
- Math.round((index * (trackWidth - 1)) / Math.max(1, presets.length - 1))
711
- );
712
- const track = Array.from({ length: trackWidth }, () => "─");
713
- track[positions[this.contextIndex]] = "▲";
714
- const labels = Array.from({ length: trackWidth }, () => " ");
715
- for (let index = 0; index < presets.length; index += 1) {
716
- const { label } = presets[index];
717
- const start = Math.max(
718
- 0,
719
- Math.min(
720
- trackWidth - label.length,
721
- positions[index] - Math.floor(label.length / 2)
722
- )
723
- );
724
- for (let char = 0; char < label.length; char += 1) {
725
- labels[start + char] = label[char];
726
- }
727
- }
728
- const heading = `Recent history${" ".repeat(Math.max(1, trackWidth - "Recent history".length - "Full branch".length))}Full branch`;
729
- lines.push(
730
- ` ${this.row("Context window", this.currentContext().label, "context")}`
731
- );
732
- lines.push(` ${theme.fg("muted", heading)}`);
733
- lines.push(` ${theme.fg("muted", track.join(""))}`);
734
- lines.push(` ${theme.fg("text", labels.join(""))}`);
735
- lines.push("");
736
- lines.push(
737
- ...[...modeRows, ...this.advancedRows(width)].map((line) => ` ${line}`)
738
- );
739
- lines.push("");
740
- lines.push(
741
- ` ${theme.fg("muted", "↑/↓ select · ←/→ adjust · Enter edits or saves · Esc cancels")}`
742
- );
743
- return lines.map((line) => truncateToWidth(line, width));
744
- }
745
-
746
- handleInput(keyData: string): void {
747
- const { tui } = this.options;
748
- if (this.editingCustom) {
749
- this.customInput.handleInput(keyData);
750
- return;
751
- }
752
- if (this.editingPolicies) {
753
- this.policiesInput.handleInput(keyData);
754
- return;
755
- }
756
- if (matchesKey(keyData, Key.up)) {
757
- this.selectedRow = Math.max(0, this.selectedRow - 1);
758
- } else if (matchesKey(keyData, Key.down)) {
759
- this.selectedRow = Math.min(
760
- this.visibleRows().length - 1,
761
- this.selectedRow + 1
762
- );
763
- } else if (matchesKey(keyData, Key.left)) {
764
- this.adjust(-1);
765
- } else if (matchesKey(keyData, Key.right)) {
766
- this.adjust(1);
767
- } else if (matchesKey(keyData, Key.enter)) {
768
- const row = this.selectedRowId();
769
- if (row === "customRule") {
770
- this.editingCustom = true;
771
- this.customInput.setValue(this.settings.customRule || "");
772
- this.customInput.focused = this.focused;
773
- tui.requestRender();
774
- return;
775
- }
776
- if (row === "toolPolicies") {
777
- this.editingPolicies = true;
778
- this.policiesError = undefined;
779
- this.policiesInput.setValue(
780
- JSON.stringify(this.settings.toolPolicies ?? {})
781
- );
782
- this.policiesInput.focused = this.focused;
783
- tui.requestRender();
784
- return;
785
- }
786
- if (row === "save") {
787
- this.save();
788
- return;
789
- }
790
- this.adjust(1);
791
- } else if (matchesKey(keyData, Key.escape)) {
792
- this.cancel();
793
- return;
794
- } else {
795
- return;
796
- }
797
- tui.requestRender();
798
- }
799
-
800
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one keyboard dispatcher maps each settings row to its bounded adjustment.
801
- private adjust(direction: number): void {
802
- switch (this.selectedRowId()) {
1343
+ private change(id: string, value: string): void {
1344
+ switch (id) {
1345
+ case "context":
1346
+ this.settings.contextMaxChars =
1347
+ this.presets.find((preset) => preset.label === value)?.value ??
1348
+ this.settings.contextMaxChars;
1349
+ break;
803
1350
  case "simpleMode":
804
- this.settings.simpleMode = !(this.settings.simpleMode ?? false);
1351
+ this.settings.simpleMode = value === "On";
805
1352
  if (this.settings.simpleMode) {
806
1353
  this.startSimpleModeGradient();
807
1354
  } else {
808
1355
  this.stopSimpleModeGradient();
809
1356
  }
810
- // The visible row set changes with the mode, so keep the cursor on the
811
- // row the user is actually operating rather than on its old index.
812
- this.focusRow("simpleMode");
813
1357
  break;
814
1358
  case "alwaysOn":
815
- this.settings.alwaysOn = !(this.settings.alwaysOn ?? false);
816
- break;
817
- case "context":
818
- this.contextIndex = Math.max(
819
- 0,
820
- Math.min(
821
- this.options.presets.length - 1,
822
- this.contextIndex + direction
823
- )
824
- );
825
- this.settings.contextMaxChars =
826
- this.options.presets[this.contextIndex].value;
1359
+ this.settings.alwaysOn = value === "On";
827
1360
  break;
828
1361
  case "effort":
829
- this.effortIndex = Math.max(
830
- 0,
831
- Math.min(
832
- this.options.effortLevels.length - 1,
833
- this.effortIndex + direction
834
- )
835
- );
836
- this.settings.effort = this.options.effortLevels[this.effortIndex];
837
- break;
838
- case "scoutEnabled":
839
- this.settings.scoutEnabled = !(this.settings.scoutEnabled ?? false);
840
- break;
841
- case "planGate":
842
- this.settings.planGate = !this.settings.planGate;
843
- break;
844
- case "failureGate":
845
- this.settings.failureGate = !this.settings.failureGate;
846
- break;
847
- case "completionGate":
848
- this.settings.completionGate = !this.settings.completionGate;
1362
+ this.settings.effort = value;
849
1363
  break;
850
- case "collapseResponses":
851
- this.settings.collapseResponses = !this.settings.collapseResponses;
1364
+ case "customRule":
1365
+ this.settings.customRule = value.trim() || undefined;
852
1366
  break;
853
- case "blockOnBlocked":
854
- this.settings.blockOnBlocked = !(this.settings.blockOnBlocked ?? true);
855
- break;
856
- case "autoLoopGate":
857
- this.settings.autoLoopGate = !(this.settings.autoLoopGate ?? true);
1367
+ case "toolPolicies":
1368
+ this.settings.toolPolicies = JSON.parse(
1369
+ value
1370
+ ) as AdvisorSettings["toolPolicies"];
858
1371
  break;
859
1372
  case "loopThreshold":
860
- this.settings.loopThreshold = Math.max(
861
- 2,
862
- (this.settings.loopThreshold ?? 3) + direction
1373
+ this.settings.loopThreshold = Number(
1374
+ value.replace("After ", "").replace(" repeats", "")
863
1375
  );
864
1376
  break;
865
- case "maxCallsPerSession": {
866
- const current = this.settings.maxCallsPerSession;
867
- const numeric = [0, 1, 2, 3, 5, 10, 25, 50];
868
- const sorted =
869
- current === undefined || numeric.includes(current)
870
- ? numeric
871
- : [...numeric, current].sort((a, b) => a - b);
872
- const values: (number | undefined)[] = [undefined, ...sorted];
873
- const index = values.indexOf(current);
1377
+ case "maxCallsPerSession":
874
1378
  this.settings.maxCallsPerSession =
875
- values[Math.max(0, Math.min(values.length - 1, index + direction))];
1379
+ value === "∞" ? undefined : Number(value);
876
1380
  break;
877
- }
878
- case "sessionSummary":
879
- this.settings.sessionSummary = !(this.settings.sessionSummary ?? false);
1381
+ case "failureMode":
1382
+ this.settings.failureMode = value as AdvisorSettings["failureMode"];
880
1383
  break;
881
- case "failureMode": {
882
- const modes: AdvisorSettings["failureMode"][] = [
883
- "block-session",
884
- "block-tool",
885
- "warn-and-continue",
886
- ];
887
- const index = Math.max(
888
- 0,
889
- modes.indexOf(this.settings.failureMode ?? "block-session")
890
- );
891
- this.settings.failureMode =
892
- modes[Math.max(0, Math.min(modes.length - 1, index + direction))];
1384
+ case "gitContext":
1385
+ this.settings.gitContext = value as AdvisorSettings["gitContext"];
893
1386
  break;
894
- }
895
- case "herdrIntegration":
896
- this.settings.herdrIntegration = !(
897
- this.settings.herdrIntegration ?? true
898
- );
899
- break;
900
- case "redactSecrets":
901
- this.settings.redactSecrets = !(this.settings.redactSecrets ?? false);
902
- break;
903
- case "trackedFileContent":
904
- this.settings.trackedFileContent = !(
905
- this.settings.trackedFileContent ?? false
906
- );
907
- break;
908
- case "untrackedContent":
909
- this.settings.untrackedContent = !(
910
- this.settings.untrackedContent ?? false
911
- );
912
- break;
913
- case "outcomeLogging":
914
- this.settings.outcomeLogging = !(this.settings.outcomeLogging ?? false);
915
- break;
916
- case "gitContext": {
917
- const levels: AdvisorSettings["gitContext"][] = [
918
- "off",
919
- "summary",
920
- "full",
921
- ];
922
- const index = Math.max(
923
- 0,
924
- levels.indexOf(this.settings.gitContext ?? "summary")
925
- );
926
- this.settings.gitContext =
927
- levels[Math.max(0, Math.min(levels.length - 1, index + direction))];
928
- break;
929
- }
930
- case "gitContextMaxChars": {
931
- this.settings.gitContextMaxChars = stepNumericPreset(
932
- this.settings.gitContextMaxChars ?? 20_000,
933
- [0, 5000, 10_000, 20_000, 50_000, 100_000],
934
- direction
935
- );
1387
+ case "toolResultMaxLines":
1388
+ this.settings.toolResultMaxLines = Number(value);
936
1389
  break;
937
- }
938
- case "toolResultMaxLines": {
939
- this.settings.toolResultMaxLines = stepNumericPreset(
940
- this.settings.toolResultMaxLines ?? 2000,
941
- [0, 500, 1000, 2000, 5000, 10_000],
942
- direction
943
- );
1390
+ case "toolResultMaxBytes":
1391
+ this.settings.toolResultMaxBytes = Number(value);
944
1392
  break;
945
- }
946
- case "toolResultMaxBytes": {
947
- this.settings.toolResultMaxBytes = stepNumericPreset(
948
- this.settings.toolResultMaxBytes ?? 50 * 1024,
949
- [0, 10 * 1024, 50 * 1024, 100 * 1024, 500 * 1024],
950
- direction
951
- );
1393
+ case "gitContextMaxChars":
1394
+ this.settings.gitContextMaxChars = Number(value);
952
1395
  break;
953
- }
954
1396
  default:
1397
+ if (BOOLEAN_SETTING_IDS.has(id)) {
1398
+ (this.settings as unknown as Record<string, SettingValue | boolean>)[
1399
+ id
1400
+ ] = value === "On";
1401
+ }
955
1402
  break;
956
1403
  }
1404
+ (this.options.onChange ?? this.options.onSave)?.({
1405
+ ...this.settings,
1406
+ showUsageDetails: this.settings.showUsageDetails ?? true,
1407
+ toolPolicies: { ...(this.settings.toolPolicies ?? {}) },
1408
+ });
1409
+ if (
1410
+ id === "context" ||
1411
+ id === "simpleMode" ||
1412
+ id === "customRule" ||
1413
+ id === "toolPolicies"
1414
+ ) {
1415
+ this.settingsList = this.createSettingsList(id);
1416
+ }
957
1417
  }
958
1418
  }