pi-advisor-flow 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +37 -71
  3. package/extensions/index.ts +7 -7
  4. package/package.json +19 -6
  5. package/src/commands/activation-preparation.ts +117 -0
  6. package/src/commands/activation.ts +135 -0
  7. package/src/commands/lifecycle.ts +83 -0
  8. package/src/commands/manual-command.ts +107 -0
  9. package/src/commands/manual-consultation.ts +144 -0
  10. package/src/commands/manual-progress.ts +93 -0
  11. package/src/commands/model-commands.ts +66 -0
  12. package/src/commands/model-options.ts +181 -0
  13. package/src/commands/model-picker.ts +110 -0
  14. package/src/commands/registration.ts +27 -0
  15. package/src/commands/renderers.ts +76 -0
  16. package/src/commands/runtime.ts +114 -0
  17. package/src/commands/settings-commands.ts +80 -0
  18. package/src/commands/settings-persistence.ts +81 -0
  19. package/src/commands/types.ts +78 -0
  20. package/src/commands.ts +9 -946
  21. package/src/config/args.ts +37 -0
  22. package/src/config/defaults.ts +190 -0
  23. package/src/config/state.ts +199 -0
  24. package/src/config/storage.ts +224 -0
  25. package/src/config/types.ts +63 -0
  26. package/src/config/validation.ts +234 -0
  27. package/src/config.ts +104 -791
  28. package/src/conversation.ts +4 -2
  29. package/src/herdr.ts +1 -1
  30. package/src/model-stream.ts +25 -2
  31. package/src/scout-context.ts +2 -2
  32. package/src/scout.ts +1 -1
  33. package/src/tools/consultation.ts +337 -0
  34. package/src/tools/gate-policy.ts +132 -0
  35. package/src/tools/gate-protocol.ts +127 -0
  36. package/src/tools/loop-gate.ts +210 -0
  37. package/src/tools/prompts.ts +159 -0
  38. package/src/tools/register-ask-advisor.ts +225 -0
  39. package/src/tools/register-lifecycle.ts +89 -0
  40. package/src/tools/register-outcome.ts +75 -0
  41. package/src/tools/register-renderers.ts +88 -0
  42. package/src/tools/registration.ts +35 -0
  43. package/src/tools/render-advisor-result.ts +168 -0
  44. package/src/tools/render-common.ts +120 -0
  45. package/src/tools/scout-status.ts +206 -0
  46. package/src/tools/session.ts +3 -0
  47. package/src/tools/types.ts +119 -0
  48. package/src/tools.ts +42 -1811
  49. package/src/ui/manual-dialog-render.ts +150 -0
  50. package/src/ui/manual-dialog.ts +305 -0
  51. package/src/ui/model-selector.ts +166 -0
  52. package/src/ui/settings-formatting.ts +133 -0
  53. package/src/ui/settings-items.ts +323 -0
  54. package/src/ui/settings-list-adapter.ts +103 -0
  55. package/src/ui/settings-mutations.ts +80 -0
  56. package/src/ui/settings-selector.ts +161 -0
  57. package/src/ui/text-setting-submenu.ts +66 -0
  58. package/src/ui/types.ts +111 -0
  59. package/src/ui.ts +9 -1418
package/src/ui.ts CHANGED
@@ -1,1418 +1,9 @@
1
- import {
2
- getSettingsListTheme,
3
- type Theme,
4
- } from "@earendil-works/pi-coding-agent";
5
- import {
6
- type Component,
7
- Editor,
8
- type EditorTheme,
9
- type Focusable,
10
- fuzzyFilter,
11
- Input,
12
- Key,
13
- type Keybindings,
14
- type KeybindingsManager,
15
- type KeyId,
16
- matchesKey,
17
- type SettingItem,
18
- SettingsList,
19
- type TUI,
20
- truncateToWidth,
21
- visibleWidth,
22
- wrapTextWithAnsi,
23
- } from "@earendil-works/pi-tui";
24
- import { isValidAdvisorToolPolicies } from "./config.js";
25
- import {
26
- clampGitContextLevel,
27
- GIT_CONTEXT_LEVELS,
28
- type GitContextLevel,
29
- } from "./git.js";
30
-
31
- interface RenderRequester {
32
- requestRender: () => void;
33
- }
34
- interface SearchableModelSelectorOptions {
35
- allOptions: string[];
36
- currentOption?: string;
37
- keybindings: KeybindingsManager;
38
- onCancel: () => void;
39
- onSelect: (value: string) => void;
40
- theme: Theme;
41
- title: string;
42
- tui: RenderRequester;
43
- }
44
- export class SearchableModelSelector implements Component, Focusable {
45
- private readonly tui: RenderRequester;
46
- private readonly searchInput: Input;
47
- private readonly allOptions: string[];
48
- private readonly currentOption: string | undefined;
49
- private filteredOptions: string[];
50
- private selectedIndex = 0;
51
- private readonly title: string;
52
- private readonly onSelect: (value: string) => void;
53
- private readonly onCancel: () => void;
54
- private readonly theme: Theme;
55
- private readonly keybindings: KeybindingsManager;
56
- private _focused = false;
57
-
58
- get focused(): boolean {
59
- return this._focused;
60
- }
61
- set focused(val: boolean) {
62
- this._focused = val;
63
- this.searchInput.focused = val;
64
- }
65
-
66
- constructor(options: SearchableModelSelectorOptions) {
67
- this.tui = options.tui;
68
- this.title = options.title;
69
- this.currentOption =
70
- options.currentOption &&
71
- options.allOptions.includes(options.currentOption)
72
- ? options.currentOption
73
- : undefined;
74
- this.allOptions = this.currentOption
75
- ? [
76
- this.currentOption,
77
- ...options.allOptions.filter((item) => item !== this.currentOption),
78
- ]
79
- : options.allOptions;
80
- this.theme = options.theme;
81
- this.keybindings = options.keybindings;
82
- this.onSelect = options.onSelect;
83
- this.onCancel = options.onCancel;
84
- this.searchInput = new Input();
85
- this.filteredOptions = this.allOptions;
86
- }
87
-
88
- invalidate(): void {
89
- this.searchInput.invalidate();
90
- }
91
-
92
- render(width: number): string[] {
93
- const lines: string[] = ["═".repeat(width)];
94
- lines.push(` ${this.theme.fg("accent", this.theme.bold(this.title))}`);
95
- const inputLines = this.searchInput.render(width - 12);
96
- lines.push(
97
- ` ${this.theme.fg("accent", "Search: ")}${inputLines[0] || ""}`
98
- );
99
- lines.push("");
100
-
101
- const query = this.searchInput.getValue().trim();
102
- this.filteredOptions = query
103
- ? fuzzyFilter(this.allOptions, query, (item) => item)
104
- : this.allOptions;
105
- this.selectedIndex = Math.min(
106
- this.selectedIndex,
107
- Math.max(0, this.filteredOptions.length - 1)
108
- );
109
-
110
- const maxVisible = 10;
111
- const total = this.filteredOptions.length;
112
- if (total === 0) {
113
- lines.push(` ${this.theme.fg("muted", "No matching models found.")}`);
114
- } else {
115
- const startIndex = Math.max(
116
- 0,
117
- Math.min(
118
- this.selectedIndex - Math.floor(maxVisible / 2),
119
- total - maxVisible
120
- )
121
- );
122
- const endIndex = Math.min(startIndex + maxVisible, total);
123
- for (let i = startIndex; i < endIndex; i += 1) {
124
- const item = this.filteredOptions[i];
125
- const tick = item === this.currentOption ? "✓ " : " ";
126
- if (i === this.selectedIndex) {
127
- lines.push(
128
- ` ${this.theme.fg("accent", "→ ")}${this.theme.fg("accent", `${tick}${item}`)}`
129
- );
130
- } else {
131
- lines.push(` ${this.theme.fg("text", `${tick}${item}`)}`);
132
- }
133
- }
134
- if (total > maxVisible) {
135
- lines.push(
136
- " " +
137
- this.theme.fg("muted", ` (${this.selectedIndex + 1}/${total})`)
138
- );
139
- }
140
- }
141
- lines.push("");
142
- lines.push(
143
- ` ${this.theme.fg("muted", "Type to search · ↑↓: navigate · Enter: select · Esc: cancel")}`
144
- );
145
- lines.push("═".repeat(width));
146
- return lines;
147
- }
148
-
149
- handleInput(keyData: string): void {
150
- if (this.matchesAction(keyData, "tui.select.up", "\u001b[A")) {
151
- this.moveSelection(-1);
152
- return;
153
- }
154
- if (this.matchesAction(keyData, "tui.select.down", "\u001b[B")) {
155
- this.moveSelection(1);
156
- return;
157
- }
158
- if (
159
- this.matchesAction(keyData, "tui.select.confirm", "\n") ||
160
- keyData === "\r"
161
- ) {
162
- if (this.filteredOptions.length > 0) {
163
- this.onSelect(this.filteredOptions[this.selectedIndex]);
164
- }
165
- return;
166
- }
167
- if (this.matchesAction(keyData, "tui.select.cancel", "\u001b")) {
168
- this.onCancel();
169
- return;
170
- }
171
- this.searchInput.handleInput(keyData);
172
- this.selectedIndex = 0;
173
- this.tui.requestRender();
174
- }
175
-
176
- private matchesAction(
177
- keyData: string,
178
- action: keyof Keybindings,
179
- fallback: string
180
- ) {
181
- return this.keybindings.matches(keyData, action) || keyData === fallback;
182
- }
183
-
184
- private moveSelection(direction: -1 | 1) {
185
- if (this.filteredOptions.length > 0) {
186
- const lastIndex = this.filteredOptions.length - 1;
187
- const nextIndex = this.selectedIndex + direction;
188
- if (nextIndex < 0) {
189
- this.selectedIndex = lastIndex;
190
- } else if (nextIndex > lastIndex) {
191
- this.selectedIndex = 0;
192
- } else {
193
- this.selectedIndex = nextIndex;
194
- }
195
- }
196
- this.tui.requestRender();
197
- }
198
- }
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
-
621
- export interface ContextPreset {
622
- description: string;
623
- label: string;
624
- value: number;
625
- }
626
-
627
- export interface AdvisorSettings {
628
- alwaysOn?: boolean;
629
- autoLoopGate?: boolean;
630
- blockOnBlocked?: boolean;
631
- collapseResponses: boolean;
632
- completionGate: boolean;
633
- contextMaxChars: number;
634
- customRule?: string;
635
- effort?: string;
636
- failureGate: boolean;
637
- failureMode?: "block-session" | "block-tool" | "warn-and-continue";
638
- gitContext?: "off" | "summary" | "full";
639
- gitContextMaxChars?: number;
640
- herdrIntegration?: boolean;
641
- loopThreshold?: number;
642
- maxCallsPerSession?: number;
643
- outcomeLogging?: boolean;
644
- planGate: boolean;
645
- redactSecrets?: boolean;
646
- scoutEnabled?: boolean;
647
- sessionSummary?: boolean;
648
- showUsageDetails?: boolean;
649
- showUsageFooter?: boolean;
650
- simpleMode?: boolean;
651
- toolPolicies?: Record<string, "full" | "summary" | "exclude">;
652
- toolResultMaxBytes?: number;
653
- toolResultMaxLines?: number;
654
- trackedFileContent?: boolean;
655
- untrackedContent?: boolean;
656
- }
657
-
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([
755
- "scoutEnabled",
756
- "showUsageDetails",
757
- "showUsageFooter",
758
- "planGate",
759
- "failureGate",
760
- "completionGate",
761
- "collapseResponses",
762
- "blockOnBlocked",
763
- "autoLoopGate",
764
- "sessionSummary",
765
- "herdrIntegration",
766
- "redactSecrets",
767
- "trackedFileContent",
768
- "untrackedContent",
769
- "outcomeLogging",
770
- ]);
771
-
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";
800
-
801
- export class AdvisorSettingsSelector implements Component, Focusable {
802
- private readonly options: AdvisorSettingsSelectorOptions;
803
- private readonly settings: AdvisorSettings;
804
- private readonly presets: ContextPreset[];
805
- private settingsList: SettingsList;
806
- private simpleModeGradientStartedAt: number | undefined;
807
- private simpleModeGradientTimer: ReturnType<typeof setInterval> | undefined;
808
- private _focused = false;
809
-
810
- get focused(): boolean {
811
- return this._focused;
812
- }
813
-
814
- set focused(value: boolean) {
815
- this._focused = value;
816
- const submenu = this.settingsList as unknown as {
817
- submenuComponent?: Focusable;
818
- };
819
- if (submenu.submenuComponent) {
820
- submenu.submenuComponent.focused = value;
821
- }
822
- }
823
-
824
- constructor(options: AdvisorSettingsSelectorOptions) {
825
- this.options = options;
826
- this.settings = { ...options.initial };
827
- const configuredContext = this.settings.contextMaxChars;
828
- this.presets = options.presets.some(
829
- (preset) => preset.value === configuredContext
830
- )
831
- ? [...options.presets]
832
- : [
833
- ...options.presets,
834
- {
835
- description: "Current custom context limit",
836
- label: String(configuredContext),
837
- value: configuredContext,
838
- },
839
- ].sort((a, b) => a.value - b.value);
840
- if (this.settings.simpleMode) {
841
- this.startSimpleModeGradient();
842
- }
843
- this.settingsList = this.createSettingsList();
844
- }
845
-
846
- invalidate(): void {
847
- this.settingsList.invalidate();
848
- }
849
-
850
- dispose(): void {
851
- this.stopSimpleModeGradient();
852
- }
853
-
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
- );
862
- }
863
-
864
- handleInput(keyData: string): void {
865
- if (!this.changeWithArrow(keyData)) {
866
- this.settingsList.handleInput(keyData);
867
- }
868
- this.options.tui.requestRender();
869
- }
870
-
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;
911
- }
912
-
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 }
930
- );
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;
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
- )
1216
- );
1217
- return items;
1218
- }
1219
-
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
- };
1234
- }
1235
-
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;
1296
- }
1297
-
1298
- private rainbowGradient(text: string): string {
1299
- const frame = Math.floor(
1300
- (Date.now() - (this.simpleModeGradientStartedAt ?? 0)) /
1301
- SIMPLE_MODE_GRADIENT_INTERVAL_MS
1302
- );
1303
- const shinePosition = frame % (text.length * 2);
1304
- return [...text]
1305
- .map((character, index) => {
1306
- const [baseRed, baseGreen, baseBlue] =
1307
- SIMPLE_MODE_GRADIENT_COLORS[
1308
- index % SIMPLE_MODE_GRADIENT_COLORS.length
1309
- ];
1310
- const distance = Math.abs(index - shinePosition);
1311
- let brightness = 0;
1312
- if (distance === 0) {
1313
- brightness = 0.7;
1314
- } else if (distance === 1) {
1315
- brightness = 0.35;
1316
- }
1317
- const red = Math.round(baseRed + (255 - baseRed) * brightness);
1318
- const green = Math.round(baseGreen + (255 - baseGreen) * brightness);
1319
- const blue = Math.round(baseBlue + (255 - baseBlue) * brightness);
1320
- return `\x1b[38;2;${red};${green};${blue}m${character}`;
1321
- })
1322
- .join("")
1323
- .concat("\x1b[0m");
1324
- }
1325
-
1326
- private startSimpleModeGradient(): void {
1327
- this.stopSimpleModeGradient();
1328
- this.simpleModeGradientStartedAt = Date.now();
1329
- this.simpleModeGradientTimer = setInterval(() => {
1330
- this.options.tui.requestRender();
1331
- }, SIMPLE_MODE_GRADIENT_INTERVAL_MS);
1332
- this.simpleModeGradientTimer.unref?.();
1333
- }
1334
-
1335
- private stopSimpleModeGradient(): void {
1336
- if (this.simpleModeGradientTimer) {
1337
- clearInterval(this.simpleModeGradientTimer);
1338
- this.simpleModeGradientTimer = undefined;
1339
- }
1340
- this.simpleModeGradientStartedAt = undefined;
1341
- }
1342
-
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;
1350
- case "simpleMode":
1351
- this.settings.simpleMode = value === "On";
1352
- if (this.settings.simpleMode) {
1353
- this.startSimpleModeGradient();
1354
- } else {
1355
- this.stopSimpleModeGradient();
1356
- }
1357
- break;
1358
- case "alwaysOn":
1359
- this.settings.alwaysOn = value === "On";
1360
- break;
1361
- case "effort":
1362
- this.settings.effort = value;
1363
- break;
1364
- case "customRule":
1365
- this.settings.customRule = value.trim() || undefined;
1366
- break;
1367
- case "toolPolicies":
1368
- this.settings.toolPolicies = JSON.parse(
1369
- value
1370
- ) as AdvisorSettings["toolPolicies"];
1371
- break;
1372
- case "loopThreshold":
1373
- this.settings.loopThreshold = Number(
1374
- value.replace("After ", "").replace(" repeats", "")
1375
- );
1376
- break;
1377
- case "maxCallsPerSession":
1378
- this.settings.maxCallsPerSession =
1379
- value === "∞" ? undefined : Number(value);
1380
- break;
1381
- case "failureMode":
1382
- this.settings.failureMode = value as AdvisorSettings["failureMode"];
1383
- break;
1384
- case "gitContext":
1385
- this.settings.gitContext = value as AdvisorSettings["gitContext"];
1386
- break;
1387
- case "toolResultMaxLines":
1388
- this.settings.toolResultMaxLines = Number(value);
1389
- break;
1390
- case "toolResultMaxBytes":
1391
- this.settings.toolResultMaxBytes = Number(value);
1392
- break;
1393
- case "gitContextMaxChars":
1394
- this.settings.gitContextMaxChars = Number(value);
1395
- break;
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
- }
1402
- break;
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
- }
1417
- }
1418
- }
1
+ // biome-ignore lint/performance/noBarrelFile: this facade intentionally preserves the public UI module contract.
2
+ export { ManualAdvisorDialog } from "./ui/manual-dialog.js";
3
+ export { SearchableModelSelector } from "./ui/model-selector.js";
4
+ export { AdvisorSettingsSelector } from "./ui/settings-selector.js";
5
+ export type {
6
+ AdvisorSettings,
7
+ ContextPreset,
8
+ ManualAdvisorRequest,
9
+ } from "./ui/types.js";