pi-bro 0.19.5 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bro.ts CHANGED
@@ -17,7 +17,7 @@ import { parseHTML } from "linkedom";
17
17
  import mammoth from "mammoth";
18
18
  import { Type } from "typebox";
19
19
  import { extractText } from "unpdf";
20
- import { BRO_MODES, DEFAULT_BRO_MODE, buildAdvisorPrompt, buildBtwPrompt, buildDefaultPrompt, buildShowPrompt, parseBroMode, type BroMode } from "./prompt.ts";
20
+ import { BRO_MODES, DEFAULT_BRO_MODE, MAX_PREFERENCES_CHARS, STARTER_PREFERENCES, buildAdvisorPrompt, buildBtwPrompt, buildDefaultPrompt, buildShowPrompt, nextBroMode, parseBroMode, type BroMode } from "./prompt.ts";
21
21
  import {
22
22
  agyFailureMessage,
23
23
  agySelection,
@@ -38,7 +38,9 @@ export { agyFailureMessage, agySelection, advisorFlagErrorHint, parseBtwAgyLine
38
38
  const AGENT_DIR = getAgentDir();
39
39
  const ENV_MODEL = process.env.PI_BRO_MODEL?.trim();
40
40
  const DEFAULT_MODEL = ENV_MODEL || "gemini-3.7-flash";
41
- const PROMPT_FILE = join(AGENT_DIR, "bro-prompt.md");
41
+ const PREFERENCES_FILE = join(AGENT_DIR, "bro-preferences.md");
42
+ // Larger files are rejected before reading, even by the editor; the prompt limit is MAX_PREFERENCES_CHARS.
43
+ const MAX_PREFERENCES_FILE_BYTES = 64 * 1024;
42
44
  const SETTINGS_FILE = join(AGENT_DIR, "bro-settings.json");
43
45
  const LOADING_TEXT = "Simplifying for my bro…";
44
46
  const MAX_FILE_BYTES = 10 * 1024 * 1024;
@@ -62,12 +64,15 @@ type TuiLike = {
62
64
  };
63
65
  type ModalKind = "loading" | "streaming" | "result" | "help" | "empty" | "error";
64
66
  type BroSource = { text: string; label?: string };
65
- type BroResult = { source: BroSource; text: string; model?: string };
66
- type ModalResult = { source?: BroSource; text: string; htmlPath?: string; model?: string };
67
+ // `mode` is the built-in explain mode that produced the text; absent for Show and Doctor.
68
+ // `preferences` records whether bro-preferences.md shaped this result; like `mode`, it belongs to the result.
69
+ type BroResult = { source: BroSource; text: string; model?: string; mode?: BroMode; preferences?: boolean };
70
+ type ModalResult = { source?: BroSource; text: string; htmlPath?: string; model?: string; mode?: BroMode; preferences?: boolean };
67
71
  type BtwTurn = { question: string; answer: string };
68
72
  // `context` keeps the main-session seed so a fresh native session can be reseeded with the whole
69
- // thread; `sessionFull` is the access mode the native session last ran in.
70
- type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean; backend?: BackendName; model?: string; context?: string; sessionFull?: boolean };
73
+ // thread; `sessionFull` and `sessionPreferences` are the access mode and preferences the native
74
+ // session last ran with. `preferences` records whether the latest turn used them (header tag).
75
+ type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean; backend?: BackendName; model?: string; preferences?: boolean; context?: string; sessionFull?: boolean; sessionPreferences?: string };
71
76
  const EFFORTS = ["default", "low", "medium", "high"] as const;
72
77
  const BACKENDS = ["agy", "claude", "grok", "codex", "muse"] as const;
73
78
  type BackendName = (typeof BACKENDS)[number];
@@ -138,6 +143,7 @@ const COMMANDS = [
138
143
  { value: "doctor", label: "doctor", description: "Check whether Bro is ready" },
139
144
  { value: "show", label: "show", description: "Draw what happened in recent session turns as shapes" },
140
145
  { value: "mode", label: "mode", description: "View or choose explanation mode (brief, balanced, faithful)" },
146
+ { value: "preferences", label: "preferences", description: "View or edit what Bro knows about you and how you like answers" },
141
147
  { value: "btw", label: "btw", description: "Open a side conversation (starts conversation-only; /mode toggles full permission)" },
142
148
  { value: "config", label: "config", description: "Configure shared defaults and per-capability model/effort overrides" },
143
149
  { value: "advisor", label: "advisor", description: "Check whether the executor's advisor tool is available right now" },
@@ -1138,10 +1144,10 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1138
1144
  }
1139
1145
 
1140
1146
  try {
1141
- const prompt = await promptFor("", settings?.mode ?? DEFAULT_BRO_MODE);
1142
- pass("Prompt", prompt.custom ? "valid custom override" : `valid built-in ${settings?.mode ?? DEFAULT_BRO_MODE} mode`);
1147
+ const preferences = await readPreferences();
1148
+ pass("Preferences", preferences ? `${preferences.length.toLocaleString("en-US")} characters · used by explain, show, btw` : "none — /bro preferences to add some");
1143
1149
  } catch (error) {
1144
- fail("Prompt", error);
1150
+ fail("Preferences", error);
1145
1151
  }
1146
1152
 
1147
1153
  // Probe only the backends some feature actually selects: a Claude/Grok/Codex/Muse-only setup never
@@ -1595,20 +1601,32 @@ function openShowHtml(path: string): boolean {
1595
1601
  return result.status === 0;
1596
1602
  }
1597
1603
 
1598
- async function promptFor(response: string, mode: BroMode): Promise<{ text: string; custom: boolean }> {
1599
- let template: string;
1604
+ // Raw file text for the editor, undefined when there is no file: never rejects for length, so an
1605
+ // oversize file can be opened and trimmed.
1606
+ export async function readPreferencesRaw(): Promise<string | undefined> {
1607
+ let size: number;
1600
1608
  try {
1601
- template = await readFile(PROMPT_FILE, "utf8");
1609
+ size = (await stat(PREFERENCES_FILE)).size;
1602
1610
  } catch (error) {
1603
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
1604
- return { text: buildDefaultPrompt(response, mode), custom: false };
1605
- }
1606
- throw error;
1611
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
1612
+ throw new Error(`Cannot read ${PREFERENCES_FILE}: ${errorMessage(error)}`);
1607
1613
  }
1614
+ if (size > MAX_PREFERENCES_FILE_BYTES) throw new Error(`${PREFERENCES_FILE} is larger than 64 KB. Edit or delete it directly.`);
1615
+ try {
1616
+ return (await readFile(PREFERENCES_FILE, "utf8")).replace(/^\uFEFF/, "");
1617
+ } catch (error) {
1618
+ throw new Error(`Cannot read ${PREFERENCES_FILE}: ${errorMessage(error)}`);
1619
+ }
1620
+ }
1608
1621
 
1609
- const parts = template.split("{{response}}");
1610
- if (parts.length !== 2) throw new Error(`${PROMPT_FILE} must contain {{response}} exactly once.`);
1611
- return { text: parts.join(JSON.stringify(response)), custom: true };
1622
+ // Preferences for prompts: "" when absent or blank. Re-read on every request. An oversize file stops
1623
+ // the request before any backend call instead of being silently truncated.
1624
+ export async function readPreferences(): Promise<string> {
1625
+ const text = ((await readPreferencesRaw()) ?? "").trim();
1626
+ if (text.length > MAX_PREFERENCES_CHARS) {
1627
+ throw new Error(`${PREFERENCES_FILE} is ${text.length.toLocaleString("en-US")} characters; keep it under ${MAX_PREFERENCES_CHARS.toLocaleString("en-US")} (it's sent with every request). Use /bro preferences to trim it.`);
1628
+ }
1629
+ return text;
1612
1630
  }
1613
1631
 
1614
1632
  async function simplify(
@@ -1616,9 +1634,12 @@ async function simplify(
1616
1634
  signal: AbortSignal,
1617
1635
  settings: BroSettings,
1618
1636
  onProgress?: (text: string) => void,
1619
- ): Promise<{ text: string; model: string }> {
1637
+ mode = settings.mode,
1638
+ ): Promise<{ text: string; model: string; mode: BroMode; preferences: boolean }> {
1620
1639
  const selection = selectionForCapability(settings, "explain");
1621
- return { text: await runAgyText((await promptFor(response, settings.mode)).text, selection, signal, onProgress), model: selectionLabel(selection) };
1640
+ const preferences = await readPreferences();
1641
+ const text = await runAgyText(buildDefaultPrompt(response, mode, preferences), selection, signal, onProgress);
1642
+ return { text, model: selectionLabel(selection), mode, preferences: Boolean(preferences) };
1622
1643
  }
1623
1644
 
1624
1645
  async function runShowExplanation(
@@ -1627,9 +1648,11 @@ async function runShowExplanation(
1627
1648
  signal: AbortSignal,
1628
1649
  settings: BroSettings,
1629
1650
  onProgress?: (text: string) => void,
1630
- ): Promise<{ text: string; model: string }> {
1651
+ ): Promise<{ text: string; model: string; preferences: boolean }> {
1631
1652
  const selection = selectionForCapability(settings, "show");
1632
- return { text: await runAgyText(buildShowPrompt(transcript, steering), selection, signal, onProgress, "show"), model: selectionLabel(selection) };
1653
+ const preferences = await readPreferences();
1654
+ const text = await runAgyText(buildShowPrompt(transcript, steering, preferences), selection, signal, onProgress, "show");
1655
+ return { text, model: selectionLabel(selection), preferences: Boolean(preferences) };
1633
1656
  }
1634
1657
 
1635
1658
  // Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
@@ -2531,31 +2554,71 @@ export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: Exten
2531
2554
  });
2532
2555
  }
2533
2556
 
2534
- // Testable core for /bro advisor-steer: persistence only happens on Ctrl+S/Ctrl+K.
2535
- // Esc leaves the stored brief untouched; only the in-memory draft is discarded.
2536
- export function createAdvisorSteerModal(
2537
- initialText: string,
2538
- onSave: (text: string) => void,
2539
- onClear: () => void,
2540
- copy: (text: string) => Promise<void> = copyToClipboard,
2557
+ type TextEditorModalOptions = {
2558
+ title: string;
2559
+ subtitle: string;
2560
+ initialText: string;
2561
+ initialNotice?: string;
2562
+ // Returns an error message to refuse the save without calling onSave.
2563
+ validate?: (text: string) => string | undefined;
2564
+ // Either may be async; the modal then waits, ignores further edits, and reports success only once it settles.
2565
+ onSave: (text: string) => void | Promise<void>;
2566
+ onClear: () => void | Promise<void>;
2567
+ copy?: (text: string) => Promise<void>;
2568
+ };
2569
+
2570
+ // Testable core shared by /bro advisor-steer and /bro preferences: persistence only happens on
2571
+ // Ctrl+S/Ctrl+K. Esc leaves the stored text untouched; only the in-memory draft is discarded.
2572
+ export function createTextEditorModal(
2573
+ options: TextEditorModalOptions,
2541
2574
  ): (tui: TUI, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
2575
+ const { onSave, onClear, validate, copy = copyToClipboard } = options;
2542
2576
  return (tui, theme, _keybindings, done) => {
2543
2577
  const editorTheme: EditorTheme = { borderColor: (s: string) => theme.fg("border", s), selectList: getSelectListTheme() };
2544
2578
  const editor = new Editor(tui, editorTheme);
2545
2579
  editor.focused = true;
2546
- editor.setText(initialText);
2580
+ editor.setText(options.initialText);
2547
2581
  let disposed = false;
2548
- const notice = new Text("");
2582
+ let pending = false;
2583
+ const notice = new Text(options.initialNotice ? theme.fg("accent", options.initialNotice) : "");
2549
2584
  const showNotice = (message: string, color: "success" | "error") => {
2550
2585
  if (disposed) return;
2551
2586
  notice.setText(theme.fg(color, message));
2552
2587
  tui.requestRender();
2553
2588
  };
2554
2589
  editor.onChange = () => notice.setText("");
2590
+ const persist = (action: () => void | Promise<void>, success: string, failure: string, after?: () => void) => {
2591
+ const fail = (error: unknown) => showNotice(`${failure}: ${errorMessage(error)}`, "error");
2592
+ let result: void | Promise<void>;
2593
+ try {
2594
+ result = action();
2595
+ } catch (error) {
2596
+ fail(error);
2597
+ return;
2598
+ }
2599
+ if (!(result instanceof Promise)) {
2600
+ after?.();
2601
+ showNotice(success, "success");
2602
+ return;
2603
+ }
2604
+ pending = true;
2605
+ result.then(
2606
+ () => {
2607
+ pending = false;
2608
+ if (disposed) return;
2609
+ after?.();
2610
+ showNotice(success, "success");
2611
+ },
2612
+ (error) => {
2613
+ pending = false;
2614
+ fail(error);
2615
+ },
2616
+ );
2617
+ };
2555
2618
 
2556
2619
  const container = new Container();
2557
- container.addChild(new Text(theme.fg("accent", theme.bold("Bro · advisor steer"))));
2558
- container.addChild(new Text(theme.fg("dim", "One persistent steering brief the advisor always sees — never sent to the main model.")));
2620
+ container.addChild(new Text(theme.fg("accent", theme.bold(options.title))));
2621
+ container.addChild(new Text(theme.fg("dim", options.subtitle)));
2559
2622
  container.addChild(editor);
2560
2623
  container.addChild(notice);
2561
2624
  container.addChild(new Text(theme.fg("dim", "Ctrl+S save · Enter newline · Ctrl+K clear · Ctrl+C copy · Esc close")));
@@ -2576,23 +2639,19 @@ export function createAdvisorSteerModal(
2576
2639
  done(undefined);
2577
2640
  return;
2578
2641
  }
2642
+ if (pending) return;
2579
2643
  if (matchesKey(data, "ctrl+s")) {
2580
- try {
2581
- onSave(editor.getExpandedText());
2582
- showNotice("Saved", "success");
2583
- } catch (error) {
2584
- showNotice(`Save failed: ${errorMessage(error)}`, "error");
2644
+ const text = editor.getExpandedText();
2645
+ const invalid = validate?.(text);
2646
+ if (invalid) {
2647
+ showNotice(invalid, "error");
2648
+ return;
2585
2649
  }
2650
+ persist(() => onSave(text), "Saved", "Save failed");
2586
2651
  return;
2587
2652
  }
2588
2653
  if (matchesKey(data, "ctrl+k")) {
2589
- try {
2590
- onClear();
2591
- editor.setText("");
2592
- showNotice("Cleared", "success");
2593
- } catch (error) {
2594
- showNotice(`Clear failed: ${errorMessage(error)}`, "error");
2595
- }
2654
+ persist(onClear, "Cleared", "Clear failed", () => editor.setText(""));
2596
2655
  return;
2597
2656
  }
2598
2657
  if (matchesKey(data, "ctrl+c")) {
@@ -2616,6 +2675,83 @@ export function createAdvisorSteerModal(
2616
2675
  };
2617
2676
  }
2618
2677
 
2678
+ export function createAdvisorSteerModal(
2679
+ initialText: string,
2680
+ onSave: (text: string) => void,
2681
+ onClear: () => void,
2682
+ copy: (text: string) => Promise<void> = copyToClipboard,
2683
+ ): ReturnType<typeof createTextEditorModal> {
2684
+ return createTextEditorModal({
2685
+ title: "Bro · advisor steer",
2686
+ subtitle: "One persistent steering brief the advisor always sees — never sent to the main model.",
2687
+ initialText,
2688
+ onSave,
2689
+ onClear,
2690
+ copy,
2691
+ });
2692
+ }
2693
+
2694
+ function preferencesTooLong(text: string): string | undefined {
2695
+ const length = text.replace(/^\uFEFF/, "").trim().length;
2696
+ return length > MAX_PREFERENCES_CHARS
2697
+ ? `${length.toLocaleString("en-US")}/${MAX_PREFERENCES_CHARS.toLocaleString("en-US")} characters — trim before saving`
2698
+ : undefined;
2699
+ }
2700
+
2701
+ // Testable core for /bro preferences. A missing file opens with the unsaved starter text; an
2702
+ // unreadable or oversize-on-disk file opens empty with the error, and Clear still works.
2703
+ export function createPreferencesModal(
2704
+ loaded: { text?: string; error?: string },
2705
+ persist: { save: (text: string) => Promise<void>; clear: () => Promise<void> },
2706
+ copy?: (text: string) => Promise<void>,
2707
+ ): ReturnType<typeof createTextEditorModal> {
2708
+ const starter = loaded.text === undefined && !loaded.error;
2709
+ return createTextEditorModal({
2710
+ title: "Bro · preferences",
2711
+ subtitle: "About you and how you like answers. Sent to the selected backend with every explain, show, and btw request — never to the advisor or Pi's main model.",
2712
+ initialText: starter ? STARTER_PREFERENCES : (loaded.text ?? ""),
2713
+ initialNotice: loaded.error ?? (starter ? "Starter text — not saved. Ctrl+S saves it; Esc leaves no file." : undefined),
2714
+ validate: preferencesTooLong,
2715
+ onSave: persist.save,
2716
+ onClear: persist.clear,
2717
+ copy,
2718
+ });
2719
+ }
2720
+
2721
+ // Saves and deletes run one at a time, even across a closed and reopened editor, so a slow delete
2722
+ // from an earlier editor can never remove a file saved by a later one.
2723
+ let preferencesWrites: Promise<void> = Promise.resolve();
2724
+ export function queuePreferencesWrite(write: () => Promise<void>): Promise<void> {
2725
+ const next = preferencesWrites.then(write);
2726
+ preferencesWrites = next.catch(() => {});
2727
+ return next;
2728
+ }
2729
+
2730
+ export async function showPreferencesModal(ctx: ExtensionCommandContext): Promise<void> {
2731
+ if (!hasBroCustomUi(ctx)) {
2732
+ ctx.ui.notify(`Edit ${PREFERENCES_FILE} directly.`, "warning");
2733
+ return;
2734
+ }
2735
+ let loaded: { text?: string; error?: string };
2736
+ try {
2737
+ const text = await readPreferencesRaw();
2738
+ loaded = text === undefined ? {} : { text };
2739
+ } catch (error) {
2740
+ loaded = { error: `${errorMessage(error)} Ctrl+K deletes it.` };
2741
+ }
2742
+ await ctx.ui.custom<void>(
2743
+ createPreferencesModal(loaded, {
2744
+ // ponytail: last writer wins against external edits while the editor is open, like writeSettings.
2745
+ save: (text) => queuePreferencesWrite(() => writeFile(PREFERENCES_FILE, `${text.trim()}\n`, "utf8")),
2746
+ clear: () => queuePreferencesWrite(() => rm(PREFERENCES_FILE, { force: true })),
2747
+ }),
2748
+ {
2749
+ overlay: true,
2750
+ overlayOptions: { width: "78%", minWidth: 48, maxHeight: "60%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 } },
2751
+ },
2752
+ );
2753
+ }
2754
+
2619
2755
  export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
2620
2756
  if (!hasBroCustomUi(ctx)) {
2621
2757
  ctx.ui.notify("Use /bro advisor-steer in Pi's interactive UI.", "warning");
@@ -2635,7 +2771,9 @@ export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: Ex
2635
2771
  );
2636
2772
  }
2637
2773
 
2638
- export function helpText(settings?: BroSettings, settingsError?: string): string {
2774
+ // `preferences` is a ready status ("off", "on (312 characters)", or "error: …") so a preferences
2775
+ // problem never hides Help or mixes with settings errors.
2776
+ export function helpText(settings?: BroSettings, settingsError?: string, preferences = "off"): string {
2639
2777
  const overrideLines = settings
2640
2778
  ? CAPABILITIES.map((capability) => {
2641
2779
  const override = capabilityOverride(settings, capability);
@@ -2674,15 +2812,17 @@ Quick reference. The README is the full user guide: https://github.com/tranhoang
2674
2812
 
2675
2813
  ## Configure and check
2676
2814
 
2815
+ - \`/bro preferences\` — tell Bro about yourself and how you like answers; added to explain, show, and btw prompts, never the advisor (**Ctrl+S** save, **Ctrl+K** delete, **Ctrl+C** copy, **Esc** close)
2677
2816
  - \`/bro config\` — shared default and per-capability (explain/show/btw/advisor) backend, model, and effort; explain mode; show turns. Changes save immediately.
2678
2817
  - \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
2679
- - \`/bro doctor\` — check settings, prompt, and every selected backend without running a model turn
2818
+ - \`/bro doctor\` — check settings, preferences, and every selected backend without running a model turn
2680
2819
 
2681
2820
  ## Current settings
2682
2821
 
2683
2822
  ${settingsSummary}
2823
+ - **Preferences:** ${preferences} — \`/bro preferences\`
2684
2824
 
2685
- Saved in \`${SETTINGS_FILE}\`.
2825
+ Saved in \`${SETTINGS_FILE}\` and \`${PREFERENCES_FILE}\`.
2686
2826
 
2687
2827
  ## Explanation modes
2688
2828
 
@@ -2690,19 +2830,22 @@ Saved in \`${SETTINGS_FILE}\`.
2690
2830
  - balanced — default; material detail with clearer structure
2691
2831
  - faithful — closest to the source, with no fixed word limit
2692
2832
 
2693
- A valid \`${PROMPT_FILE}\` (with \`{{response}}\` exactly once) overrides the modes; the saved mode stays inactive until you remove or rename it. Show always uses its own prompt.
2833
+ Press **M** in an explanation to re-simplify it in the next mode without changing the saved one.
2834
+
2835
+ The mode decides how much of the source to keep; your preferences decide who it's written for, its tone, and its language. Neither overrides Bro's source rules.
2694
2836
 
2695
2837
  ## Controls
2696
2838
 
2697
2839
  - **Mouse wheel / trackpad**, **↑ / ↓** — scroll
2698
2840
  - **C** — copy the full explanation
2699
- - **R** — repeat the current action
2841
+ - **R** — repeat the current action (an explanation keeps its mode)
2842
+ - **M** — re-simplify in the next mode (brief → balanced → faithful); not saved
2700
2843
  - **O** — open the HTML diagram when a show reply contains one
2701
2844
  - **Esc** — close, or cancel while Bro is working
2702
2845
 
2703
2846
  ## Privacy
2704
2847
 
2705
- Bro sends the captured source (or, for the advisor, the executor's instructions, tools, and conversation) to the selected backend and its model provider, which may retain it under their own policies. Nothing is added to Pi's conversation unless you insert it. Access controls differ by backend; see the README.`;
2848
+ Bro sends the captured source (or, for the advisor, the executor's instructions, tools, and conversation) to the selected backend and its model provider, which may retain it under their own policies. Your preferences go with every explain, show, and btw request. Nothing is added to Pi's conversation unless you insert it. Access controls differ by backend; see the README.`;
2706
2849
  }
2707
2850
 
2708
2851
  // The overlay framing pattern is adapted from pi-btw (MIT); see THIRD_PARTY_NOTICES.md.
@@ -2721,6 +2864,10 @@ class BroModal implements Focusable {
2721
2864
  private retryable = false;
2722
2865
  private disposed = false;
2723
2866
  private htmlPath = "";
2867
+ // Survives loading/streaming so the header names the mode being produced; empty disables M.
2868
+ private modeLabel = "";
2869
+ // Set only with a result, so the tag never claims preferences for work still in flight.
2870
+ private preferencesUsed = false;
2724
2871
 
2725
2872
  constructor(
2726
2873
  private readonly tui: TuiLike,
@@ -2729,6 +2876,7 @@ class BroModal implements Focusable {
2729
2876
  private readonly onRetry: () => void,
2730
2877
  private readonly onDispose: () => void,
2731
2878
  private readonly retryLabel: string,
2879
+ private readonly onSwitchMode: () => void = () => {},
2732
2880
  ) {
2733
2881
  setRegularMouseReporting(this.tui, true);
2734
2882
  }
@@ -2750,6 +2898,16 @@ class BroModal implements Focusable {
2750
2898
  this.tui.requestRender();
2751
2899
  }
2752
2900
 
2901
+ setMode(mode: BroMode | undefined): void {
2902
+ this.modeLabel = mode ?? "";
2903
+ this.tui.requestRender();
2904
+ }
2905
+
2906
+ setPreferences(used: boolean): void {
2907
+ this.preferencesUsed = used;
2908
+ this.tui.requestRender();
2909
+ }
2910
+
2753
2911
  setStatic(kind: "help" | "empty", text: string, copyable: boolean): void {
2754
2912
  this.setContent(kind, text, text, copyable, false);
2755
2913
  }
@@ -2800,11 +2958,16 @@ class BroModal implements Focusable {
2800
2958
  return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`);
2801
2959
  }
2802
2960
 
2961
+ private canSwitchMode(): boolean {
2962
+ return Boolean(this.modeLabel) && (this.kind === "loading" || this.kind === "streaming" || this.kind === "result");
2963
+ }
2964
+
2803
2965
  private controls(): string {
2804
- if (this.kind === "loading") return "Esc cancel";
2805
- if (this.kind === "streaming") return "Simplifying… · ↑/↓ scroll · Esc cancel";
2966
+ const mode = this.canSwitchMode() ? " · M mode" : "";
2967
+ if (this.kind === "loading") return mode ? "M mode · Esc cancel" : "Esc cancel";
2968
+ if (this.kind === "streaming") return `Simplifying… · ↑/↓ scroll${mode} · Esc cancel`;
2806
2969
  if (this.kind === "result") {
2807
- return `↑/↓ scroll · C copy${this.htmlPath ? " · O open diagram" : ""}${this.retryable ? ` · R ${this.retryLabel}` : ""} · Esc close`;
2970
+ return `↑/↓ scroll · C copy${this.htmlPath ? " · O open diagram" : ""}${mode}${this.retryable ? ` · R ${this.retryLabel}` : ""} · Esc close`;
2808
2971
  }
2809
2972
  if (this.kind === "help") return "↑/↓ scroll · C copy · Esc close";
2810
2973
  if (this.kind === "error") return "R try again · Esc close";
@@ -2830,7 +2993,7 @@ class BroModal implements Focusable {
2830
2993
  this.borderLine(innerWidth, "top"),
2831
2994
  this.frameLine(
2832
2995
  this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}`)) +
2833
- this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${scroll}`),
2996
+ this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${this.modeLabel ? ` · ${this.modeLabel}` : ""}${this.preferencesUsed ? " · prefs" : ""}${scroll}`),
2834
2997
  innerWidth,
2835
2998
  ),
2836
2999
  this.ruleLine(innerWidth),
@@ -2889,6 +3052,11 @@ class BroModal implements Focusable {
2889
3052
  return;
2890
3053
  }
2891
3054
 
3055
+ if ((matchesKey(data, "m") || matchesKey(data, "shift+m")) && this.canSwitchMode()) {
3056
+ this.onSwitchMode();
3057
+ return;
3058
+ }
3059
+
2892
3060
  if ((matchesKey(data, "o") || matchesKey(data, "shift+o")) && this.htmlPath && this.kind === "result") {
2893
3061
  this.notice = openShowHtml(this.htmlPath)
2894
3062
  ? "Opening diagram"
@@ -2914,6 +3082,7 @@ interface BroModalOptions {
2914
3082
  signal: AbortSignal,
2915
3083
  source?: BroSource,
2916
3084
  onProgress?: (text: string) => void,
3085
+ mode?: BroMode,
2917
3086
  ) => Promise<ModalResult>;
2918
3087
  onResult?: (result: ModalResult) => void;
2919
3088
  loadingText?: string;
@@ -2935,7 +3104,8 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
2935
3104
  let closed = false;
2936
3105
  let controller: AbortController | undefined;
2937
3106
  let current = options.result;
2938
- let execute: (source?: BroSource) => void = () => {};
3107
+ let inFlightMode: BroMode | undefined;
3108
+ let execute: (source?: BroSource, mode?: BroMode, notice?: string) => void = () => {};
2939
3109
 
2940
3110
  const close = () => {
2941
3111
  if (closed) return;
@@ -2948,37 +3118,51 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
2948
3118
  tui,
2949
3119
  theme,
2950
3120
  close,
2951
- () => execute(current?.source),
3121
+ () => execute(current?.source, current?.mode),
2952
3122
  () => {
2953
3123
  closed = true;
2954
3124
  controller?.abort();
2955
3125
  },
2956
3126
  options.retryLabel ?? "simplify again",
3127
+ // M advances from the mode in flight, so repeated presses cancel and skip ahead; the saved default is untouched.
3128
+ () => {
3129
+ const from = inFlightMode ?? current?.mode;
3130
+ if (!from || !current?.source || closed) return;
3131
+ const next = nextBroMode(from);
3132
+ controller?.abort();
3133
+ controller = undefined;
3134
+ execute(current.source, next, `Mode: ${next} (not saved)`);
3135
+ },
2957
3136
  );
2958
3137
 
2959
3138
  const present = (result: ModalResult, notice = "") => {
2960
3139
  const display = result.htmlPath ? stripShowHtmlFence(result.text) : result.text;
2961
3140
  modal.setResult(display, options.retryable ?? Boolean(options.run), notice, result.source?.label, result.text, result.model);
2962
3141
  modal.setHtmlPath(result.htmlPath ?? "");
3142
+ modal.setMode(result.mode);
3143
+ modal.setPreferences(Boolean(result.preferences));
2963
3144
  };
2964
3145
 
2965
- execute = (source?: BroSource) => {
3146
+ execute = (source?: BroSource, mode?: BroMode, notice = "") => {
2966
3147
  if (!options.run || controller || closed) return;
2967
3148
  const previous = current;
2968
3149
  const nextController = new AbortController();
2969
3150
  controller = nextController;
3151
+ inFlightMode = mode;
2970
3152
  modal.setLoading(options.loadingText);
3153
+ modal.setMode(mode);
3154
+ modal.setPreferences(false);
2971
3155
 
2972
3156
  void options
2973
3157
  .run(nextController.signal, source, (text) => {
2974
3158
  if (closed || nextController.signal.aborted || controller !== nextController) return;
2975
3159
  modal.setStreaming(text);
2976
- })
3160
+ }, mode)
2977
3161
  .then((result) => {
2978
3162
  if (closed || nextController.signal.aborted) return;
2979
3163
  current = result;
2980
3164
  options.onResult?.(result);
2981
- present(result);
3165
+ present(result, notice);
2982
3166
  })
2983
3167
  .catch((error) => {
2984
3168
  if (closed || nextController.signal.aborted) return;
@@ -2987,11 +3171,15 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
2987
3171
  current = previous;
2988
3172
  present(previous, `Retry failed: ${message}`);
2989
3173
  } else {
3174
+ modal.setMode(undefined);
2990
3175
  modal.setError(message);
2991
3176
  }
2992
3177
  })
2993
3178
  .finally(() => {
2994
- if (controller === nextController) controller = undefined;
3179
+ if (controller === nextController) {
3180
+ controller = undefined;
3181
+ inFlightMode = undefined;
3182
+ }
2995
3183
  });
2996
3184
  };
2997
3185
 
@@ -3038,7 +3226,7 @@ export function resolveBtwThread(existing: BtwThread | undefined): BtwThread {
3038
3226
 
3039
3227
  export function bindBtwBackend(thread: BtwThread, backend: BackendName): boolean {
3040
3228
  const changed = thread.backend !== undefined && thread.backend !== backend;
3041
- if (changed) { thread.turns = []; thread.conversationId = undefined; thread.context = undefined; thread.sessionFull = undefined; }
3229
+ if (changed) { thread.turns = []; thread.conversationId = undefined; thread.context = undefined; thread.sessionFull = undefined; thread.sessionPreferences = undefined; }
3042
3230
  thread.backend = backend;
3043
3231
  return changed;
3044
3232
  }
@@ -3057,13 +3245,20 @@ export function toggleBtwMode(thread: BtwThread): void {
3057
3245
  // /mode switches. An Agy conversation stays bound to the workspace it started in (a sandbox scratch
3058
3246
  // dir vs. the repo), so an access change drops it; the next turn reseeds a fresh native session
3059
3247
  // with the main-session context and the whole thread.
3060
- export function nativeBtwContinuation(thread: BtwThread, backend: BackendName): string | undefined {
3248
+ // A native session remembers every earlier prompt, including old preferences, so a preferences
3249
+ // change (or deletion) on any backend starts a fresh session reseeded with the quoted thread.
3250
+ export function nativeBtwContinuation(thread: BtwThread, backend: BackendName, preferences = ""): string | undefined {
3061
3251
  if (backend === "agy" && thread.conversationId && thread.sessionFull !== undefined && thread.sessionFull !== thread.full) {
3062
3252
  thread.conversationId = undefined;
3063
3253
  }
3254
+ if (thread.conversationId && (thread.sessionPreferences ?? "") !== preferences) thread.conversationId = undefined;
3064
3255
  return thread.conversationId;
3065
3256
  }
3066
3257
 
3258
+ export function btwHeaderLabel(thread: Pick<BtwThread, "model" | "preferences">): string {
3259
+ return thread.model ? `${thread.model}${thread.preferences ? " · prefs" : ""}` : "";
3260
+ }
3261
+
3067
3262
  export function formatBtwTranscript(turns: readonly BtwTurn[]): string {
3068
3263
  return turns
3069
3264
  .map((turn) => {
@@ -3320,7 +3515,8 @@ async function openBtwModal(
3320
3515
  controller?.abort();
3321
3516
  });
3322
3517
 
3323
- const runTurn = async (question: string) => {
3518
+ // `replaced` is the turn /retry removed; it is put back if the turn never starts.
3519
+ const runTurn = async (question: string, replaced?: BtwTurn) => {
3324
3520
  if (controller) return;
3325
3521
  const turnController = new AbortController();
3326
3522
  controller = turnController;
@@ -3328,13 +3524,23 @@ async function openBtwModal(
3328
3524
  modal.clearComposer();
3329
3525
 
3330
3526
  let settings: BroSettings;
3527
+ let preferences: string;
3331
3528
  try {
3332
3529
  settings = await readSettings();
3333
- const backend = capabilityBackend(settings, "btw");
3334
- if (bindBtwBackend(thread, backend)) modal.setNotice("Backend changed — started a fresh side thread.");
3530
+ preferences = await readPreferences();
3335
3531
  } catch (error) {
3336
- controller = undefined; modal.setRunning(false); modal.setNotice(errorMessage(error)); return;
3532
+ if (replaced) thread.turns.push(replaced);
3533
+ if (controller === turnController) controller = undefined;
3534
+ if (!closed) { modal.setRunning(false); modal.setNotice(errorMessage(error)); }
3535
+ return;
3337
3536
  }
3537
+ // Closing (or reopening) during the reads above must not touch the shared thread.
3538
+ if (closed || turnController.signal.aborted) {
3539
+ if (replaced) thread.turns.push(replaced);
3540
+ if (controller === turnController) controller = undefined;
3541
+ return;
3542
+ }
3543
+ if (bindBtwBackend(thread, capabilityBackend(settings, "btw"))) modal.setNotice("Backend changed — started a fresh side thread.");
3338
3544
  if (thread.turns.length === 0) {
3339
3545
  thread.conversationId = undefined;
3340
3546
  const context = captureShowTranscript(ctx, BTW_CONTEXT_TURNS, BTW_CONTEXT_MAX)?.text;
@@ -3342,7 +3548,7 @@ async function openBtwModal(
3342
3548
  }
3343
3549
  // Resume natively when possible; otherwise seed the fresh native session with the main-session
3344
3550
  // context plus every earlier turn so nothing is silently lost.
3345
- const conversationId = nativeBtwContinuation(thread, capabilityBackend(settings, "btw"));
3551
+ const conversationId = nativeBtwContinuation(thread, capabilityBackend(settings, "btw"), preferences);
3346
3552
  const history = !conversationId && thread.turns.length ? formatBtwTranscript(thread.turns) : undefined;
3347
3553
  const context = conversationId ? undefined : thread.context;
3348
3554
  const full = thread.full;
@@ -3353,9 +3559,10 @@ async function openBtwModal(
3353
3559
  try {
3354
3560
  const selection = selectionForCapability(settings, "btw");
3355
3561
  thread.model = selectionLabel(selection);
3356
- modal.setModel(thread.model);
3562
+ thread.preferences = Boolean(preferences);
3563
+ modal.setModel(btwHeaderLabel(thread));
3357
3564
  const result = await runBtwTurn(
3358
- buildBtwPrompt(context, question, { full, history }),
3565
+ buildBtwPrompt(context, question, { full, history, preferences }),
3359
3566
  selection,
3360
3567
  { full, cwd: ctx.cwd, conversationId },
3361
3568
  turnController.signal,
@@ -3369,6 +3576,7 @@ async function openBtwModal(
3369
3576
  if (result.conversationId) {
3370
3577
  thread.conversationId = result.conversationId;
3371
3578
  thread.sessionFull = full;
3579
+ thread.sessionPreferences = preferences;
3372
3580
  }
3373
3581
  thread.turns[thread.turns.length - 1]!.answer = result.text;
3374
3582
  } catch (error) {
@@ -3394,6 +3602,8 @@ async function openBtwModal(
3394
3602
  thread.turns = [];
3395
3603
  thread.conversationId = undefined;
3396
3604
  thread.context = undefined;
3605
+ thread.sessionFull = undefined;
3606
+ thread.sessionPreferences = undefined;
3397
3607
  modal.clearComposer();
3398
3608
  modal.setNotice("");
3399
3609
  modal.setText("");
@@ -3407,7 +3617,7 @@ async function openBtwModal(
3407
3617
  return;
3408
3618
  }
3409
3619
  thread.turns.pop();
3410
- void runTurn(last.question);
3620
+ void runTurn(last.question, last);
3411
3621
  };
3412
3622
 
3413
3623
  const copyOut = async (all: boolean) => {
@@ -3479,7 +3689,7 @@ async function openBtwModal(
3479
3689
  }
3480
3690
 
3481
3691
  modal.setFull(thread.full);
3482
- modal.setModel(thread.model ?? "");
3692
+ modal.setModel(btwHeaderLabel(thread));
3483
3693
  modal.setText(transcript());
3484
3694
 
3485
3695
  if (options.initialQuestion) void runTurn(options.initialQuestion);
@@ -3674,15 +3884,14 @@ export default async function bro(pi: ExtensionAPI) {
3674
3884
  if (!captured) {
3675
3885
  return { text: "**Nothing to show yet**\n\nThis session has no conversation turns to draw. Run something first, then press **R**." };
3676
3886
  }
3677
- let text: string;
3678
- let model: string;
3887
+ let result: Awaited<ReturnType<typeof runShowExplanation>>;
3679
3888
  try {
3680
- ({ text, model } = await runShowExplanation(captured.text, steering, signal, await readSettings(), onProgress));
3889
+ result = await runShowExplanation(captured.text, steering, signal, await readSettings(), onProgress);
3681
3890
  } catch (error) {
3682
3891
  throw new Error(withDoctor(error));
3683
3892
  }
3684
- const html = extractShowHtml(text);
3685
- return { source: captured, text, model, ...(html ? { htmlPath: await writeShowHtml(html) } : {}) };
3893
+ const html = extractShowHtml(result.text);
3894
+ return { source: captured, ...result, ...(html ? { htmlPath: await writeShowHtml(html) } : {}) };
3686
3895
  };
3687
3896
  try {
3688
3897
  await showBroModal(ctx, {
@@ -3706,6 +3915,7 @@ export default async function bro(pi: ExtensionAPI) {
3706
3915
  signal: AbortSignal,
3707
3916
  source?: BroSource,
3708
3917
  onProgress?: (text: string) => void,
3918
+ mode?: BroMode,
3709
3919
  ): Promise<BroResult> => {
3710
3920
  const target = source ?? (action === "url"
3711
3921
  ? await extractWebPage(value, signal)
@@ -3713,7 +3923,7 @@ export default async function bro(pi: ExtensionAPI) {
3713
3923
  try {
3714
3924
  return {
3715
3925
  source: target,
3716
- ...(await simplify(target.text, signal, await readSettings(), onProgress)),
3926
+ ...(await simplify(target.text, signal, await readSettings(), onProgress, mode)),
3717
3927
  };
3718
3928
  } catch (error) {
3719
3929
  throw new Error(withDoctor(error));
@@ -3839,6 +4049,15 @@ export default async function bro(pi: ExtensionAPI) {
3839
4049
  return;
3840
4050
  }
3841
4051
 
4052
+ if (action === "preferences") {
4053
+ if (parts.length !== 1) {
4054
+ ctx.ui.notify("Use /bro preferences.", "warning");
4055
+ return;
4056
+ }
4057
+ await showPreferencesModal(ctx);
4058
+ return;
4059
+ }
4060
+
3842
4061
  if (action === "help") {
3843
4062
  if (parts.length !== 1) {
3844
4063
  ctx.ui.notify("Use /bro help.", "warning");
@@ -3851,7 +4070,14 @@ export default async function bro(pi: ExtensionAPI) {
3851
4070
  } catch (error) {
3852
4071
  settingsError = errorMessage(error);
3853
4072
  }
3854
- await showBroModal(ctx, { text: helpText(settings, settingsError), kind: "help", copyable: true });
4073
+ let preferences: string;
4074
+ try {
4075
+ const text = await readPreferences();
4076
+ preferences = text ? `on (${text.length.toLocaleString("en-US")} characters)` : "off";
4077
+ } catch (error) {
4078
+ preferences = `error: ${errorMessage(error)}`;
4079
+ }
4080
+ await showBroModal(ctx, { text: helpText(settings, settingsError, preferences), kind: "help", copyable: true });
3855
4081
  return;
3856
4082
  }
3857
4083
 
@@ -3859,6 +4085,7 @@ export default async function bro(pi: ExtensionAPI) {
3859
4085
  signal: AbortSignal,
3860
4086
  source?: BroSource,
3861
4087
  onProgress?: (text: string) => void,
4088
+ mode?: BroMode,
3862
4089
  ): Promise<BroResult> => {
3863
4090
  let target = source ?? (action === "text" && value ? { text: value } : undefined);
3864
4091
  if (!target) {
@@ -3870,7 +4097,7 @@ export default async function bro(pi: ExtensionAPI) {
3870
4097
  const settings = await readSettings();
3871
4098
  return {
3872
4099
  source: target,
3873
- ...(await simplify(target.text, signal, settings, onProgress)),
4100
+ ...(await simplify(target.text, signal, settings, onProgress, mode)),
3874
4101
  };
3875
4102
  } catch (error) {
3876
4103
  throw new Error(withDoctor(error));