pi-advisor-flow 0.2.3 → 0.2.4

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/README.md CHANGED
@@ -36,7 +36,7 @@ Restart or reload Pi after installation, then run `/advisor` in a session.
36
36
 
37
37
  1. Run `/advisor` to enable the flow and register `ask_advisor`.
38
38
  2. Run `/advisor-models` to choose the Executor and Advisor models.
39
- 3. Run `/advisor-settings` to set context, gates, privacy controls, and output limits.
39
+ 3. Run `/advisor-settings` to set Simple mode, persistent activation, context, gates, privacy controls, and output limits.
40
40
 
41
41
  Enable with models in one command when preferred:
42
42
 
@@ -62,7 +62,7 @@ The Executor calls `ask_advisor({})` for a general review of the current task an
62
62
 
63
63
  Use the Advisor after the Executor has investigated and formed a candidate direction. It is intended to challenge assumptions, expose risks, and confirm the next verification step—not to replace the Executor's work.
64
64
 
65
- Normal consultations preserve the provider's final Markdown. They do not parse a verdict or block execution.
65
+ Normal consultations preserve the provider's final Markdown and never block execution. When the Advisor has no material concern or recommendation, it may begin with the exact first line `Verdict: sound`; Pi renders that response with the static `◆ ADVISOR · SOUND` header, for both `ask_advisor` results and `/advisor-manual`.
66
66
 
67
67
  ## Automatic loop gate
68
68
 
@@ -125,7 +125,9 @@ All fields are optional. This example shows the available settings and their nor
125
125
  "advisorBlockOnBlocked": true,
126
126
  "gateFailureMode": "block-session",
127
127
 
128
- "advisorSessionSummary": true,
128
+ "advisorSessionSummary": false,
129
+ "simpleMode": false,
130
+ "alwaysOn": false,
129
131
  "advisorHerdrIntegration": true,
130
132
  "advisorToolResultMaxLines": 2000,
131
133
  "advisorToolResultMaxBytes": 51200,
@@ -138,6 +140,12 @@ All fields are optional. This example shows the available settings and their nor
138
140
  }
139
141
  ```
140
142
 
143
+ ### Simple mode and persistent activation
144
+
145
+ - `simpleMode` defaults to `false`. When enabled, `ask_advisor` and `/advisor-manual` remain available for voluntary second opinions, while plan/failure/completion rules, loop gates, blocking, call budgets, and session summaries are disabled. Context limits, result caps, redaction, and tool disclosure policies still apply.
146
+ - `alwaysOn` defaults to `false`. When enabled, Pi restores the configured Executor and activates `ask_advisor` for new, resumed, forked, and reloaded sessions. While the Advisor flow is active, an explicit `/model` selection becomes the persisted Executor for the next activation; a model restored with a session does not change the saved Executor. `/advisor-off` turns `alwaysOn` off so the flow stays disabled in later sessions.
147
+ - In Simple mode, settings keeps the Context window/history slider alongside Simple mode and Always on; advanced values remain saved and take effect when Simple mode is disabled.
148
+
141
149
  ### Context and limits
142
150
 
143
151
  - `contextMaxChars` defaults to `15000`. It preserves complete semantic entries and adds an omission marker rather than splitting a message.
@@ -161,7 +169,7 @@ Redaction and output limits reduce accidental disclosure; they are not a data-cl
161
169
 
162
170
  ### Session summary and Herdr
163
171
 
164
- The optional Session Advisor Summary is local and in-memory only. It appears after a non-blocked settled run and is never persisted.
172
+ The optional Session Advisor Summary defaults to off. When enabled, it is local and in-memory only, appears after a non-blocked settled run, and is never persisted.
165
173
 
166
174
  It distinguishes regular Markdown advice from gate decisions and records the trigger, model, usage/cost when available, failures, budget, and execution effect.
167
175
 
package/package.json CHANGED
@@ -32,7 +32,7 @@
32
32
  "*.{json,jsonc,ts}": "bun run lint:fix --"
33
33
  },
34
34
  "type": "module",
35
- "version": "0.2.3",
35
+ "version": "0.2.4",
36
36
  "license": "MIT",
37
37
  "repository": {
38
38
  "type": "git",
package/src/commands.ts CHANGED
@@ -8,9 +8,11 @@ import {
8
8
  advisorEffortRef,
9
9
  advisorMaxCallsPerSessionRef,
10
10
  advisorRef,
11
+ alwaysOnRef,
11
12
  executorEffortRef,
12
13
  executorRef,
13
14
  getAdvisorSettings,
15
+ isSimpleMode,
14
16
  loadConfig,
15
17
  parseArgs,
16
18
  saveConfig,
@@ -32,9 +34,11 @@ import {
32
34
  setAdvisorToolPoliciesRef,
33
35
  setAdvisorToolResultMaxBytesRef,
34
36
  setAdvisorToolResultMaxLinesRef,
37
+ setAlwaysOnRef,
35
38
  setContextMaxCharsRef,
36
39
  setExecutorEffortRef,
37
40
  setExecutorRef,
41
+ setSimpleModeRef,
38
42
  splitRef,
39
43
  } from "./config.js";
40
44
  import { herdrAdvisorActivity, notifyHerdrAdvisorFailure } from "./herdr.js";
@@ -42,7 +46,9 @@ import {
42
46
  adviceForDisplay,
43
47
  advisorSessionState,
44
48
  consultAdvisor,
49
+ hasSoundVerdict,
45
50
  renderAdvisorCallBox,
51
+ renderAdvisorResponseHeader,
46
52
  resolveAdvisorRequest,
47
53
  } from "./tools.js";
48
54
  import {
@@ -196,25 +202,47 @@ export const registerCommands = (
196
202
  });
197
203
  };
198
204
 
199
- const activateAdvisor = async (args: string, ctx: ExtensionContext) => {
205
+ /** Resolves both models and their auth, or reports why activation cannot proceed. */
206
+ const resolveActivationModels = async (ctx: ExtensionContext) => {
207
+ const executor = findConfiguredModel(ctx, executorRef);
208
+ if (!executor) {
209
+ return { error: `Executor model not found: ${executorRef}` };
210
+ }
211
+ const advisor = findConfiguredModel(ctx, advisorRef);
212
+ if (!advisor) {
213
+ return { error: `Advisor model not found: ${advisorRef}` };
214
+ }
215
+ const advisorAuth = await ctx.modelRegistry.getApiKeyAndHeaders(advisor);
216
+ if (!(advisorAuth.ok && advisorAuth.apiKey)) {
217
+ return { error: `No API key for Advisor ${advisorRef}` };
218
+ }
219
+ if (!(await pi.setModel(executor))) {
220
+ return { error: `No API key for Executor ${executorRef}` };
221
+ }
222
+ return {};
223
+ };
224
+
225
+ const activateAdvisor = async (
226
+ args: string,
227
+ ctx: ExtensionContext,
228
+ announce = true
229
+ ) => {
200
230
  loadConfig(ctx);
201
231
  const argumentError = parseArgs(args);
202
232
  if (argumentError) {
203
233
  notify(ctx, argumentError, "error");
204
234
  return;
205
235
  }
206
- const executor = findConfiguredModel(ctx, executorRef);
207
- if (!executor) {
208
- notify(ctx, `Executor model not found: ${executorRef}`, "error");
209
- return;
210
- }
211
- if (!findConfiguredModel(ctx, advisorRef)) {
212
- notify(ctx, `Advisor model not found: ${advisorRef}`, "error");
236
+ const { error } = await resolveActivationModels(ctx);
237
+ if (error) {
238
+ notify(ctx, error, "error");
213
239
  return;
214
240
  }
215
- if (!(await pi.setModel(executor))) {
216
- notify(ctx, `No API key for Executor ${executorRef}`, "error");
217
- return;
241
+ // parseArgs only mutates in-memory refs, and every later loadConfig resets
242
+ // them from disk. Persist supplied arguments once they are known to resolve,
243
+ // so an unusable model reference is never written to the configuration.
244
+ if (args.trim()) {
245
+ saveConfig(ctx);
218
246
  }
219
247
  if (executorEffortRef) {
220
248
  pi.setThinkingLevel(executorEffortRef as ThinkingLevel);
@@ -222,11 +250,13 @@ export const registerCommands = (
222
250
  if (!flowEnabled()) {
223
251
  pi.setActiveTools([...pi.getActiveTools(), "ask_advisor"]);
224
252
  }
225
- notify(
226
- ctx,
227
- `Advisor flow ready — Executor: ${executorRef} (thinking: ${executorEffortRef || "default"}) · Advisor: ${advisorRef} (thinking: ${advisorEffortRef || "default"})`,
228
- "info"
229
- );
253
+ if (announce) {
254
+ notify(
255
+ ctx,
256
+ `Advisor flow ready — Executor: ${executorRef} (thinking: ${executorEffortRef || "default"}) · Advisor: ${advisorRef} (thinking: ${advisorEffortRef || "default"})`,
257
+ "info"
258
+ );
259
+ }
230
260
  };
231
261
 
232
262
  pi.registerEntryRenderer?.(
@@ -244,17 +274,22 @@ export const registerCommands = (
244
274
  | { advisor?: string; text?: string }
245
275
  | undefined;
246
276
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
247
- box.addChild(
248
- new Text(theme.fg("warning", theme.bold("◆ ADVISOR RESPONSE")), 0, 0)
249
- );
250
- if (details?.advisor) {
251
- box.addChild(new Text(theme.fg("dim", ` ${details.advisor}`), 0, 0));
252
- }
253
277
  const advice =
254
278
  details?.text ??
255
279
  (typeof message.content === "string"
256
280
  ? message.content
257
281
  : "(Advisor returned no advice.)");
282
+ // Manual consultations must render exactly like an Executor ask_advisor call.
283
+ box.addChild(
284
+ new Text(
285
+ renderAdvisorResponseHeader(hasSoundVerdict(advice), theme),
286
+ 0,
287
+ 0
288
+ )
289
+ );
290
+ if (details?.advisor) {
291
+ box.addChild(new Text(theme.fg("dim", ` ${details.advisor}`), 0, 0));
292
+ }
258
293
  box.addChild(
259
294
  new Markdown(
260
295
  adviceForDisplay(advice, expanded),
@@ -267,6 +302,34 @@ export const registerCommands = (
267
302
  }
268
303
  );
269
304
 
305
+ pi.on("session_start", async (_event, ctx) => {
306
+ // A malformed advisor.json or a provider auth failure must not reject a
307
+ // lifecycle handler and break session startup.
308
+ try {
309
+ loadConfig(ctx);
310
+ if (alwaysOnRef) {
311
+ await activateAdvisor("", ctx, false);
312
+ }
313
+ } catch (error) {
314
+ const message = error instanceof Error ? error.message : String(error);
315
+ notify(ctx, `Advisor activation failed: ${message}`, "error");
316
+ }
317
+ });
318
+
319
+ pi.on("model_select", (event, ctx) => {
320
+ // Only an explicit user selection redefines the Executor. "restore" replays a
321
+ // stored session model and would otherwise overwrite saved configuration.
322
+ if (event.source !== "set" || !flowEnabled()) {
323
+ return;
324
+ }
325
+ const selected = `${event.model.provider}/${event.model.id}`;
326
+ if (selected === executorRef) {
327
+ return;
328
+ }
329
+ setExecutorRef(selected);
330
+ saveConfig(ctx);
331
+ });
332
+
270
333
  pi.on("session_shutdown", () => {
271
334
  for (const controller of manualConsultations) {
272
335
  controller.abort();
@@ -280,13 +343,20 @@ export const registerCommands = (
280
343
  "Consult the Advisor in parallel; accepts an optional focused question and fans its response out to the Executor",
281
344
  handler: (args, ctx) => {
282
345
  loadConfig(ctx);
283
- if (!advisorSessionState.canConsult(advisorMaxCallsPerSessionRef)) {
346
+ if (
347
+ !(
348
+ isSimpleMode() ||
349
+ advisorSessionState.canConsult(advisorMaxCallsPerSessionRef)
350
+ )
351
+ ) {
284
352
  const message = "Advisor call budget exhausted for this session.";
285
353
  notify(ctx, message, "warning");
286
354
  notifyHerdrAdvisorFailure("Advisor budget exhausted", message);
287
355
  return Promise.resolve();
288
356
  }
289
- advisorSessionState.consumeCall();
357
+ if (!isSimpleMode()) {
358
+ advisorSessionState.consumeCall();
359
+ }
290
360
  const question = resolveAdvisorRequest(args);
291
361
  // A single visible progress surface avoids competing consultations overwriting
292
362
  // each other's streamed state. A newer manual request replaces the previous one.
@@ -427,7 +497,9 @@ export const registerCommands = (
427
497
  setAdvisorAutoLoopGateRef(settings.autoLoopGate ?? true);
428
498
  setAdvisorLoopThresholdRef(settings.loopThreshold ?? 3);
429
499
  setAdvisorMaxCallsPerSessionRef(settings.maxCallsPerSession);
430
- setAdvisorSessionSummaryRef(settings.sessionSummary ?? true);
500
+ setAdvisorSessionSummaryRef(settings.sessionSummary ?? false);
501
+ setSimpleModeRef(settings.simpleMode ?? false);
502
+ setAlwaysOnRef(settings.alwaysOn ?? false);
431
503
  setAdvisorFailureModeRef(settings.failureMode ?? "block-session");
432
504
  setAdvisorHerdrIntegrationRef(settings.herdrIntegration ?? true);
433
505
  setAdvisorToolResultMaxLinesRef(settings.toolResultMaxLines ?? 2000);
@@ -445,12 +517,17 @@ export const registerCommands = (
445
517
  pi.setActiveTools(
446
518
  pi.getActiveTools().filter((name) => name !== "ask_advisor")
447
519
  );
448
- if (ctx.hasUI) {
449
- ctx.ui.notify(
450
- "Advisor flow disabled. Current model unchanged.",
451
- "info"
452
- );
520
+ // Leaving alwaysOn set would silently reactivate the flow next session.
521
+ const wasAlwaysOn = alwaysOnRef;
522
+ if (wasAlwaysOn) {
523
+ setAlwaysOnRef(false);
524
+ saveConfig(ctx);
453
525
  }
526
+ notify(
527
+ ctx,
528
+ `Advisor flow disabled. Current model unchanged.${wasAlwaysOn ? " Always on turned off." : ""}`,
529
+ "info"
530
+ );
454
531
  return Promise.resolve();
455
532
  },
456
533
  });
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import {
4
4
  CONFIG_DIR_NAME,
@@ -45,7 +45,9 @@ export let advisorBlockOnBlockedRef = true;
45
45
  export let advisorAutoLoopGateRef = true;
46
46
  export let advisorLoopThresholdRef = 3;
47
47
  export let advisorMaxCallsPerSessionRef: number | undefined;
48
- export let advisorSessionSummaryRef = true;
48
+ export let advisorSessionSummaryRef = false;
49
+ export let simpleModeRef = false;
50
+ export let alwaysOnRef = false;
49
51
  export let advisorFailureModeRef: GateFailureMode = "block-session";
50
52
  export let advisorHerdrIntegrationRef = true;
51
53
  export let advisorToolResultMaxLinesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES;
@@ -107,6 +109,14 @@ export const setAdvisorMaxCallsPerSessionRef = (value: number | undefined) => {
107
109
  export const setAdvisorSessionSummaryRef = (enabled: boolean) => {
108
110
  advisorSessionSummaryRef = enabled;
109
111
  };
112
+ export const setSimpleModeRef = (enabled: boolean) => {
113
+ simpleModeRef = enabled;
114
+ };
115
+ export const setAlwaysOnRef = (enabled: boolean) => {
116
+ alwaysOnRef = enabled;
117
+ };
118
+ /** Whether advanced Advisor automation is suppressed for this session. */
119
+ export const isSimpleMode = () => simpleModeRef;
110
120
  export const isValidGateFailureMode = (
111
121
  value: unknown
112
122
  ): value is GateFailureMode =>
@@ -140,6 +150,7 @@ export const setAdvisorToolPoliciesRef = (policies: AdvisorToolPolicies) => {
140
150
  * imported mutable bindings, which can be snapshotted by extension loaders.
141
151
  */
142
152
  export const getAdvisorSettings = () => ({
153
+ alwaysOn: alwaysOnRef,
143
154
  autoLoopGate: advisorAutoLoopGateRef,
144
155
  blockOnBlocked: advisorBlockOnBlockedRef,
145
156
  collapseResponses: advisorCollapseResponsesRef,
@@ -155,6 +166,7 @@ export const getAdvisorSettings = () => ({
155
166
  planGate: advisorPlanGateRef,
156
167
  redactSecrets: advisorRedactSecretsRef,
157
168
  sessionSummary: advisorSessionSummaryRef,
169
+ simpleMode: simpleModeRef,
158
170
  toolPolicies: { ...advisorToolPoliciesRef },
159
171
  toolResultMaxBytes: advisorToolResultMaxBytesRef,
160
172
  toolResultMaxLines: advisorToolResultMaxLinesRef,
@@ -190,10 +202,12 @@ export interface AdvisorConfig {
190
202
  advisorToolPolicies?: AdvisorToolPolicies;
191
203
  advisorToolResultMaxBytes?: number;
192
204
  advisorToolResultMaxLines?: number;
205
+ alwaysOn?: boolean;
193
206
  contextMaxChars?: number;
194
207
  executor?: string;
195
208
  executorEffort?: string;
196
209
  gateFailureMode?: GateFailureMode;
210
+ simpleMode?: boolean;
197
211
  }
198
212
 
199
213
  const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
@@ -210,6 +224,8 @@ const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
210
224
  "advisorMaxCallsPerSession",
211
225
  "advisorPlanGate",
212
226
  "advisorSessionSummary",
227
+ "simpleMode",
228
+ "alwaysOn",
213
229
  "advisorToolResultMaxBytes",
214
230
  "advisorToolResultMaxLines",
215
231
  "advisorRedactSecrets",
@@ -227,6 +243,8 @@ const BOOLEAN_CONFIG_KEYS = [
227
243
  "advisorBlockOnBlocked",
228
244
  "advisorAutoLoopGate",
229
245
  "advisorSessionSummary",
246
+ "simpleMode",
247
+ "alwaysOn",
230
248
  "advisorHerdrIntegration",
231
249
  "advisorRedactSecrets",
232
250
  ] as const;
@@ -385,7 +403,9 @@ const resetDefaults = () => {
385
403
  advisorAutoLoopGateRef = true;
386
404
  advisorLoopThresholdRef = 3;
387
405
  advisorMaxCallsPerSessionRef = undefined;
388
- advisorSessionSummaryRef = true;
406
+ advisorSessionSummaryRef = false;
407
+ simpleModeRef = false;
408
+ alwaysOnRef = false;
389
409
  advisorFailureModeRef = "block-session";
390
410
  advisorHerdrIntegrationRef = true;
391
411
  advisorToolResultMaxLinesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES;
@@ -458,6 +478,8 @@ const applyConfig = (config: AdvisorConfig) => {
458
478
  "advisorSessionSummary",
459
479
  setAdvisorSessionSummaryRef
460
480
  );
481
+ applyOptionalConfig(config, "simpleMode", setSimpleModeRef);
482
+ applyOptionalConfig(config, "alwaysOn", setAlwaysOnRef);
461
483
  applyOptionalConfig(config, "gateFailureMode", setAdvisorFailureModeRef);
462
484
  applyOptionalConfig(
463
485
  config,
@@ -492,6 +514,43 @@ const readConfig = (path: string): AdvisorConfig => {
492
514
  }
493
515
  };
494
516
 
517
+ // loadConfig runs on every tool call and every consultation. Caching the parsed
518
+ // file by path and stat identity removes that read and parse from the hot path
519
+ // while still applying the full reset-then-apply sequence on each call.
520
+ let configCache:
521
+ | { config: AdvisorConfig; identity: string; path: string }
522
+ | undefined;
523
+
524
+ /** Drops the parsed-configuration cache; the next load re-reads from disk. */
525
+ export const resetConfigCache = () => {
526
+ configCache = undefined;
527
+ };
528
+
529
+ /**
530
+ * Nanosecond timestamps plus size and inode so a same-millisecond or
531
+ * same-size external rewrite cannot be served from the cache. An unstattable
532
+ * path yields a unique value, which always forces a re-read.
533
+ */
534
+ const configIdentity = (path: string): string => {
535
+ try {
536
+ const stats = statSync(path, { bigint: true });
537
+ return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}:${stats.ino}:${stats.dev}`;
538
+ } catch {
539
+ return `unstattable:${process.hrtime.bigint()}`;
540
+ }
541
+ };
542
+
543
+ const readConfigCached = (path: string): AdvisorConfig => {
544
+ const identity = configIdentity(path);
545
+ const cached = configCache;
546
+ if (cached && cached.path === path && cached.identity === identity) {
547
+ return cached.config;
548
+ }
549
+ const config = readConfig(path);
550
+ configCache = { config, identity, path };
551
+ return config;
552
+ };
553
+
495
554
  export const loadConfig = (ctx: ExtensionContext) => {
496
555
  resetDefaults();
497
556
  const path = configPaths(ctx).find(
@@ -501,7 +560,7 @@ export const loadConfig = (ctx: ExtensionContext) => {
501
560
  if (!path) {
502
561
  return null;
503
562
  }
504
- applyConfig(readConfig(path));
563
+ applyConfig(readConfigCached(path));
505
564
  return path;
506
565
  };
507
566
 
@@ -547,9 +606,12 @@ export const saveConfig = (ctx: ExtensionContext) => {
547
606
  advisorToolPolicies: advisorToolPoliciesRef,
548
607
  advisorToolResultMaxBytes: advisorToolResultMaxBytesRef,
549
608
  advisorToolResultMaxLines: advisorToolResultMaxLinesRef,
609
+ alwaysOn: alwaysOnRef,
550
610
  gateFailureMode: advisorFailureModeRef,
611
+ simpleMode: simpleModeRef,
551
612
  };
552
613
  writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
614
+ resetConfigCache();
553
615
  return path;
554
616
  };
555
617
 
@@ -256,10 +256,18 @@ const selectRecentEntries = (entries: string[], maxChars: number): string => {
256
256
  return joined;
257
257
  }
258
258
  const selected: string[] = [];
259
+ // Track the joined length instead of re-joining the accumulator each step;
260
+ // long branches with a large maxChars are otherwise quadratic in bytes.
261
+ let selectedLength = 0;
259
262
  for (let index = entries.length - 1; index >= 0; index -= 1) {
260
- const candidate = [entries[index], ...selected].join(separator);
261
- if (candidate.length <= maxChars || selected.length === 0) {
262
- selected.unshift(entries[index]);
263
+ const entry = entries[index];
264
+ const candidateLength =
265
+ selectedLength +
266
+ entry.length +
267
+ (selected.length === 0 ? 0 : separator.length);
268
+ if (candidateLength <= maxChars || selected.length === 0) {
269
+ selected.unshift(entry);
270
+ selectedLength = candidateLength;
263
271
  } else {
264
272
  break;
265
273
  }
package/src/tools.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  advisorRef,
31
31
  advisorSessionSummaryRef,
32
32
  contextMaxCharsRef,
33
+ isSimpleMode,
33
34
  loadConfig,
34
35
  splitRef,
35
36
  } from "./config.js";
@@ -69,8 +70,15 @@ export const SPINNER_FRAMES = [
69
70
  ];
70
71
  export const resolveAdvisorRequest = (question?: string) =>
71
72
  question?.trim() || undefined;
72
- export const advisorMessageText = (conversation: string, question?: string) =>
73
- `${conversation ? `<conversation>\n${conversation}\n</conversation>` : ""}${question ? `\n\nTargeted focus:\n${question}` : ""}`;
73
+ export const advisorMessageText = (conversation: string, question?: string) => {
74
+ const text = `${conversation ? `<conversation>\n${conversation}\n</conversation>` : ""}${question ? `\n\nTargeted focus:\n${question}` : ""}`;
75
+ // A zero context limit with no targeted focus would otherwise send an empty
76
+ // user message, which several providers reject outright.
77
+ return (
78
+ text.trim() ||
79
+ "No conversation context is available. State that you cannot review without context."
80
+ );
81
+ };
74
82
 
75
83
  /** The sole reconstructed-context boundary for outgoing Advisor requests. */
76
84
  export const advisorRequestConversation = (ctx: ExtensionContext) =>
@@ -96,6 +104,19 @@ export const renderAdvisorCallBox = (
96
104
  };
97
105
 
98
106
  const COLLAPSED_ADVICE_LINES = 12;
107
+ // The system prompt requires this exact first line, so the match is exact too.
108
+ const SOUND_VERDICT = /^Verdict:\s*sound$/;
109
+
110
+ export const hasSoundVerdict = (advice: string) =>
111
+ SOUND_VERDICT.test(
112
+ (advice.split("\n").find((line) => line.trim()) ?? "").trim()
113
+ );
114
+
115
+ /** The single Advisor response header shared by tool and manual renderers. */
116
+ export const renderAdvisorResponseHeader = (sound: boolean, theme: Theme) =>
117
+ sound
118
+ ? theme.fg("accent", theme.bold("◆ ADVISOR · SOUND"))
119
+ : theme.fg("warning", theme.bold("◆ ADVISOR RESPONSE"));
99
120
 
100
121
  export const adviceForDisplay = (advice: string, expanded: boolean) => {
101
122
  if (!advisorCollapseResponsesRef || expanded) {
@@ -109,6 +130,11 @@ export const adviceForDisplay = (advice: string, expanded: boolean) => {
109
130
  };
110
131
 
111
132
  export const advisorInvocationGuidelines = () => {
133
+ if (isSimpleMode()) {
134
+ return [
135
+ "When uncertain and normal available tools cannot resolve it, call ask_advisor for a second opinion.",
136
+ ];
137
+ }
112
138
  const guidelines: string[] = [];
113
139
  if (advisorPlanGateRef) {
114
140
  guidelines.push(
@@ -141,6 +167,7 @@ export const ADVISOR_SYSTEM = [
141
167
  "You already have the relevant reconstructed conversation context. No question or other input from the Executor is needed for a general review.",
142
168
  "When no targeted focus is supplied, proactively review the task, risks, proposed direction, and validation from the context. Do not ask the Executor for a question, clarification, more input, or confirmation.",
143
169
  "The context may be truncated, so state any material uncertainty and make the best recommendation you can from what is present.",
170
+ "When the implementation is fully sound based on the supplied evidence and you have no material concern or recommended change, begin with exactly `Verdict: sound`. Do not use that verdict when uncertainty, a risk, or a recommendation remains.",
144
171
  "You do not act or take over planning. Answer the Executor's request directly in concise, human-readable Markdown. State uncertainty plainly and never claim verification that the supplied evidence does not show.",
145
172
  ].join(" ");
146
173
 
@@ -193,6 +220,7 @@ export const advisorUsageCost = (usage: unknown): number | undefined => {
193
220
 
194
221
  const DECISION_LINE = /^Decision\s*:\s*(proceed|revise|blocked)\s*$/i;
195
222
  const ANY_DECISION_LINE = /^Decision\s*:\s*(.*?)\s*$/i;
223
+ const CODE_FENCE = /^(?:```|~~~)/;
196
224
  const LINE_BREAK = /\r?\n/;
197
225
 
198
226
  export const parseAutomaticDecision = (
@@ -221,8 +249,19 @@ export const parseAutomaticDecision = (
221
249
  };
222
250
  }
223
251
  const decision = match[1].toLowerCase() as GateDecision;
252
+ let insideFence = false;
224
253
  for (const line of lines.slice(nonEmpty + 1)) {
225
- const subsequent = ANY_DECISION_LINE.exec(line.trim());
254
+ const trimmed = line.trim();
255
+ // A decision quoted inside a fenced example is illustrative, not a second
256
+ // decision, and must not fail the gate.
257
+ if (CODE_FENCE.test(trimmed)) {
258
+ insideFence = !insideFence;
259
+ continue;
260
+ }
261
+ if (insideFence) {
262
+ continue;
263
+ }
264
+ const subsequent = ANY_DECISION_LINE.exec(trimmed);
226
265
  if (!subsequent) {
227
266
  continue;
228
267
  }
@@ -453,7 +492,7 @@ const reserveAdvisorCall = (
453
492
  session: AdvisorSessionState,
454
493
  reservedCalls: Set<string>
455
494
  ): ToolCallEventResult | undefined => {
456
- if (event.toolName !== "ask_advisor") {
495
+ if (event.toolName !== "ask_advisor" || isSimpleMode()) {
457
496
  return;
458
497
  }
459
498
  if (!session.canConsult(advisorMaxCallsPerSessionRef)) {
@@ -521,6 +560,7 @@ const handleAutomaticGate = async (
521
560
  session: AdvisorSessionState
522
561
  ): Promise<ToolCallEventResult | undefined> => {
523
562
  if (
563
+ isSimpleMode() ||
524
564
  event.toolName === "ask_advisor" ||
525
565
  !advisorAutoLoopGateRef ||
526
566
  !session.recordToolCall(
@@ -665,7 +705,8 @@ const renderFinalAdvisorResult = (
665
705
  context.state.timerId = undefined;
666
706
  }
667
707
  const details = advisorResultDetails(result);
668
- const lines = [theme.fg("warning", theme.bold("◆ ADVISOR RESPONSE"))];
708
+ const advice = details?.text || textFrom(result.content);
709
+ const lines = [renderAdvisorResponseHeader(hasSoundVerdict(advice), theme)];
669
710
  if (details?.advisor) {
670
711
  lines.push(theme.fg("dim", ` ${details.advisor}`));
671
712
  }
@@ -678,13 +719,15 @@ const renderFinalAdvisorResult = (
678
719
  )
679
720
  );
680
721
  }
681
- const advice =
682
- details?.text ||
683
- textFrom(result.content) ||
684
- "(Advisor returned no advice.)";
722
+ const displayAdvice = advice || "(Advisor returned no advice.)";
685
723
  box.addChild(new Text(lines.join("\n"), 0, 0));
686
724
  box.addChild(
687
- new Markdown(adviceForDisplay(advice, expanded), 0, 0, getMarkdownTheme())
725
+ new Markdown(
726
+ adviceForDisplay(displayAdvice, expanded),
727
+ 0,
728
+ 0,
729
+ getMarkdownTheme()
730
+ )
688
731
  );
689
732
  };
690
733
 
@@ -779,7 +822,9 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
779
822
  }
780
823
  loadConfig(ctx);
781
824
  const guidelines = advisorInvocationGuidelines();
782
- const budget = session.remainingCalls(advisorMaxCallsPerSessionRef);
825
+ const budget = isSimpleMode()
826
+ ? undefined
827
+ : session.remainingCalls(advisorMaxCallsPerSessionRef);
783
828
  if (budget !== undefined) {
784
829
  guidelines.push(
785
830
  `Advisor calls remaining this session: ${budget}.\nReserve calls for material decisions, repeated failures, or final review.`
@@ -797,7 +842,14 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
797
842
  return;
798
843
  }
799
844
  loadConfig(ctx);
800
- if (session.blocked) {
845
+ if (isSimpleMode()) {
846
+ // Simple mode does not gate, so a block recorded before it was enabled must
847
+ // not linger in session or Herdr state and misreport the session as blocked.
848
+ if (session.blocked) {
849
+ session.clearBlocked();
850
+ herdrAdvisorBlock.clear();
851
+ }
852
+ } else if (session.blocked) {
801
853
  return {
802
854
  block: true,
803
855
  reason: session.blockedReason ?? "Advisor session is blocked.",
@@ -811,7 +863,7 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
811
863
  });
812
864
 
813
865
  pi.on("agent_settled", (_event, ctx) => {
814
- if (session.blocked || !advisorSessionSummaryRef) {
866
+ if (isSimpleMode() || session.blocked || !advisorSessionSummaryRef) {
815
867
  return;
816
868
  }
817
869
  const summary = session.summary(advisorMaxCallsPerSessionRef);
@@ -829,7 +881,7 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
829
881
  description:
830
882
  "Consult the on-demand Advisor model for strategic guidance. Call with an empty object for a context-aware review; add question only for a genuinely targeted focus.",
831
883
  async execute(_id, params, signal, onUpdate, ctx) {
832
- if (!reservedCalls.delete(_id)) {
884
+ if (!(reservedCalls.delete(_id) || isSimpleMode())) {
833
885
  if (!session.canConsult(advisorMaxCallsPerSessionRef)) {
834
886
  throw new Error("Advisor call budget exhausted for this session.");
835
887
  }
package/src/ui.ts CHANGED
@@ -185,6 +185,7 @@ export interface ContextPreset {
185
185
  }
186
186
 
187
187
  export interface AdvisorSettings {
188
+ alwaysOn?: boolean;
188
189
  autoLoopGate?: boolean;
189
190
  blockOnBlocked?: boolean;
190
191
  collapseResponses: boolean;
@@ -200,11 +201,68 @@ export interface AdvisorSettings {
200
201
  planGate: boolean;
201
202
  redactSecrets?: boolean;
202
203
  sessionSummary?: boolean;
204
+ simpleMode?: boolean;
203
205
  toolPolicies?: Record<string, "full" | "summary" | "exclude">;
204
206
  toolResultMaxBytes?: number;
205
207
  toolResultMaxLines?: number;
206
208
  }
207
209
 
210
+ type AdvisorSettingsRow =
211
+ | "simpleMode"
212
+ | "alwaysOn"
213
+ | "context"
214
+ | "effort"
215
+ | "planGate"
216
+ | "failureGate"
217
+ | "completionGate"
218
+ | "collapseResponses"
219
+ | "customRule"
220
+ | "blockOnBlocked"
221
+ | "autoLoopGate"
222
+ | "loopThreshold"
223
+ | "maxCallsPerSession"
224
+ | "sessionSummary"
225
+ | "failureMode"
226
+ | "herdrIntegration"
227
+ | "toolResultMaxLines"
228
+ | "toolResultMaxBytes"
229
+ | "redactSecrets"
230
+ | "toolPolicies"
231
+ | "save";
232
+
233
+ const ADVANCED_ROWS: AdvisorSettingsRow[] = [
234
+ "context",
235
+ "effort",
236
+ "planGate",
237
+ "failureGate",
238
+ "completionGate",
239
+ "collapseResponses",
240
+ "customRule",
241
+ "blockOnBlocked",
242
+ "autoLoopGate",
243
+ "loopThreshold",
244
+ "maxCallsPerSession",
245
+ "sessionSummary",
246
+ "failureMode",
247
+ "herdrIntegration",
248
+ "toolResultMaxLines",
249
+ "toolResultMaxBytes",
250
+ "redactSecrets",
251
+ "toolPolicies",
252
+ ];
253
+
254
+ const SIMPLE_MODE_GRADIENT_INTERVAL_MS = 100;
255
+ // #763FCD and nearby lighter/darker purple steps; white is the moving shine.
256
+ const SIMPLE_MODE_GRADIENT_COLORS = [
257
+ [125, 79, 205],
258
+ [143, 96, 218],
259
+ [160, 114, 230],
260
+ [178, 135, 238],
261
+ [195, 157, 245],
262
+ [168, 120, 230],
263
+ [143, 89, 215],
264
+ ] as const;
265
+
208
266
  export class AdvisorSettingsSelector implements Component, Focusable {
209
267
  private selectedRow: number;
210
268
  private contextIndex: number;
@@ -215,6 +273,8 @@ export class AdvisorSettingsSelector implements Component, Focusable {
215
273
  private editingCustom: boolean;
216
274
  private editingPolicies: boolean;
217
275
  private policiesError: string | undefined;
276
+ private simpleModeGradientStartedAt: number | undefined;
277
+ private simpleModeGradientTimer: ReturnType<typeof setInterval> | undefined;
218
278
  private _focused = false;
219
279
  private readonly options: AdvisorSettingsSelectorOptions;
220
280
 
@@ -233,6 +293,7 @@ export class AdvisorSettingsSelector implements Component, Focusable {
233
293
  this.editingPolicies = false;
234
294
  this.policiesError = undefined;
235
295
  this.options = options;
296
+ // Retain every advanced value even when Simple mode hides its controls.
236
297
  this.settings = { ...options.initial };
237
298
  this.contextIndex = Math.max(
238
299
  0,
@@ -246,6 +307,9 @@ export class AdvisorSettingsSelector implements Component, Focusable {
246
307
  this.settings.effort || "Default (Model Default)"
247
308
  )
248
309
  );
310
+ if (this.settings.simpleMode) {
311
+ this.startSimpleModeGradient();
312
+ }
249
313
  this.customInput.onSubmit = (value) => {
250
314
  this.settings.customRule = value.trim() || undefined;
251
315
  this.editingCustom = false;
@@ -289,6 +353,25 @@ export class AdvisorSettingsSelector implements Component, Focusable {
289
353
  this.options.tui.requestRender();
290
354
  }
291
355
 
356
+ /** Called by TUI teardown when it supports component disposal. */
357
+ dispose(): void {
358
+ this.stopSimpleModeGradient();
359
+ }
360
+
361
+ private visibleRows(): AdvisorSettingsRow[] {
362
+ return this.settings.simpleMode
363
+ ? ["context", "simpleMode", "alwaysOn", "save"]
364
+ : ["simpleMode", "alwaysOn", ...ADVANCED_ROWS, "save"];
365
+ }
366
+
367
+ private selectedRowId(): AdvisorSettingsRow {
368
+ return this.visibleRows()[this.selectedRow] ?? "simpleMode";
369
+ }
370
+
371
+ private focusRow(rowId: AdvisorSettingsRow): void {
372
+ this.selectedRow = Math.max(0, this.visibleRows().indexOf(rowId));
373
+ }
374
+
292
375
  private currentContext(): ContextPreset {
293
376
  const preset = this.options.presets.find(
294
377
  (item) => item.value === this.settings.contextMaxChars
@@ -301,125 +384,167 @@ export class AdvisorSettingsSelector implements Component, Focusable {
301
384
  }
302
385
  );
303
386
  }
387
+
304
388
  private currentEffort() {
305
389
  return this.settings.effort || "Default (Model Default)";
306
390
  }
307
- private row(label: string, value: string, index: number) {
391
+
392
+ private row(label: string, value: string, rowId: AdvisorSettingsRow): string {
308
393
  const { theme } = this.options;
309
- const prefix = index === this.selectedRow ? theme.fg("accent", "›") : " ";
394
+ const selected = rowId === this.selectedRowId();
395
+ const prefix = selected ? theme.fg("accent", "›") : " ";
310
396
  const text = `${prefix} ${label.padEnd(28)} ${value}`;
311
- return index === this.selectedRow
397
+ if (
398
+ rowId === "simpleMode" &&
399
+ this.settings.simpleMode &&
400
+ this.simpleModeGradientStartedAt !== undefined
401
+ ) {
402
+ return `${prefix} ${this.rainbowGradient(label)}${" ".repeat(Math.max(0, 28 - label.length))} ${value}`;
403
+ }
404
+ return selected
312
405
  ? theme.fg("accent", theme.bold(text))
313
406
  : theme.fg("text", text);
314
407
  }
315
408
 
316
- private policyInputRows(width: number): string[] {
317
- if (!this.editingPolicies) {
318
- return [];
319
- }
320
- const rows = [
321
- ` ${this.policiesInput.render(Math.max(10, width - 6))[0] || ""}`,
322
- ];
323
- if (this.policiesError) {
324
- rows.push(` ${this.options.theme.fg("error", this.policiesError)}`);
325
- }
326
- return rows;
409
+ private rainbowGradient(text: string): string {
410
+ const frame = Math.floor(
411
+ (Date.now() - (this.simpleModeGradientStartedAt ?? 0)) /
412
+ SIMPLE_MODE_GRADIENT_INTERVAL_MS
413
+ );
414
+ const shinePosition = frame % (text.length * 2);
415
+ return [...text]
416
+ .map((character, index) => {
417
+ const [baseRed, baseGreen, baseBlue] =
418
+ SIMPLE_MODE_GRADIENT_COLORS[
419
+ index % SIMPLE_MODE_GRADIENT_COLORS.length
420
+ ];
421
+ const distance = Math.abs(index - shinePosition);
422
+ let brightness = 0;
423
+ if (distance === 0) {
424
+ brightness = 0.7;
425
+ } else if (distance === 1) {
426
+ brightness = 0.35;
427
+ }
428
+ const red = Math.round(baseRed + (255 - baseRed) * brightness);
429
+ const green = Math.round(baseGreen + (255 - baseGreen) * brightness);
430
+ const blue = Math.round(baseBlue + (255 - baseBlue) * brightness);
431
+ return `\x1b[38;2;${red};${green};${blue}m${character}`;
432
+ })
433
+ .join("")
434
+ .concat("\x1b[0m");
327
435
  }
328
436
 
329
- render(width: number): string[] {
330
- const { theme, presets } = this.options;
331
- const trackWidth = Math.max(24, Math.min(60, width - 4));
332
- const positions = presets.map((_, index) =>
333
- Math.round((index * (trackWidth - 1)) / Math.max(1, presets.length - 1))
334
- );
335
- const track = Array.from({ length: trackWidth }, () => "─");
336
- track[positions[this.contextIndex]] = "▲";
337
- const labels = Array.from({ length: trackWidth }, () => " ");
338
- for (let index = 0; index < presets.length; index += 1) {
339
- const { label } = presets[index];
340
- const start = Math.max(
341
- 0,
342
- Math.min(
343
- trackWidth - label.length,
344
- positions[index] - Math.floor(label.length / 2)
345
- )
346
- );
347
- for (let char = 0; char < label.length; char += 1) {
348
- labels[start + char] = label[char];
349
- }
437
+ private startSimpleModeGradient(): void {
438
+ this.stopSimpleModeGradient();
439
+ this.simpleModeGradientStartedAt = Date.now();
440
+ this.simpleModeGradientTimer = setInterval(() => {
441
+ this.options.tui.requestRender();
442
+ }, SIMPLE_MODE_GRADIENT_INTERVAL_MS);
443
+ this.simpleModeGradientTimer.unref?.();
444
+ }
445
+
446
+ private stopSimpleModeGradient(): void {
447
+ if (this.simpleModeGradientTimer) {
448
+ clearInterval(this.simpleModeGradientTimer);
449
+ this.simpleModeGradientTimer = undefined;
350
450
  }
351
- const heading = `Recent history${" ".repeat(Math.max(1, trackWidth - "Recent history".length - "Full branch".length))}Full branch`;
451
+ this.simpleModeGradientStartedAt = undefined;
452
+ }
453
+
454
+ private save(): void {
455
+ this.stopSimpleModeGradient();
456
+ this.options.onSave({
457
+ ...this.settings,
458
+ contextMaxChars: this.currentContext().value,
459
+ effort: this.currentEffort(),
460
+ });
461
+ }
462
+
463
+ private cancel(): void {
464
+ this.stopSimpleModeGradient();
465
+ this.options.onCancel();
466
+ }
467
+
468
+ private advancedRows(width: number): string[] {
352
469
  const onOff = (value: boolean) => (value ? "On" : "Off");
353
470
  const rows = [
354
- this.row("Context window", this.currentContext().label, 0),
355
- this.row("Advisor reasoning", this.currentEffort(), 1),
356
- this.row("Plan gate", onOff(this.settings.planGate), 2),
357
- this.row("Failure gate", onOff(this.settings.failureGate), 3),
358
- this.row("Completion gate", onOff(this.settings.completionGate), 4),
471
+ this.row("Context window", this.currentContext().label, "context"),
472
+ this.row("Advisor reasoning", this.currentEffort(), "effort"),
473
+ this.row("Plan gate", onOff(this.settings.planGate), "planGate"),
474
+ this.row("Failure gate", onOff(this.settings.failureGate), "failureGate"),
475
+ this.row(
476
+ "Completion gate",
477
+ onOff(this.settings.completionGate),
478
+ "completionGate"
479
+ ),
359
480
  this.row(
360
481
  "Collapse long responses",
361
482
  onOff(this.settings.collapseResponses),
362
- 5
483
+ "collapseResponses"
484
+ ),
485
+ this.row(
486
+ "Custom invocation",
487
+ this.settings.customRule || "None",
488
+ "customRule"
363
489
  ),
364
- this.row("Custom invocation", this.settings.customRule || "None", 6),
365
490
  this.row(
366
491
  "Block on critical advice",
367
492
  onOff(this.settings.blockOnBlocked ?? true),
368
- 7
493
+ "blockOnBlocked"
369
494
  ),
370
495
  this.row(
371
496
  "Automatic loop gate",
372
497
  onOff(this.settings.autoLoopGate ?? true),
373
- 8
498
+ "autoLoopGate"
374
499
  ),
375
500
  this.row(
376
501
  "Loop threshold",
377
502
  `After ${this.settings.loopThreshold ?? 3} repeats`,
378
- 9
503
+ "loopThreshold"
379
504
  ),
380
505
  this.row(
381
506
  "Max Advisor calls/session",
382
507
  this.settings.maxCallsPerSession === undefined
383
508
  ? "∞"
384
509
  : String(this.settings.maxCallsPerSession),
385
- 10
510
+ "maxCallsPerSession"
386
511
  ),
387
512
  this.row(
388
513
  "Session Advisor Summary",
389
- onOff(this.settings.sessionSummary ?? true),
390
- 11
514
+ onOff(this.settings.sessionSummary ?? false),
515
+ "sessionSummary"
391
516
  ),
392
517
  this.row(
393
518
  "Gate failure mode",
394
519
  this.settings.failureMode ?? "block-session",
395
- 12
520
+ "failureMode"
396
521
  ),
397
522
  this.row(
398
523
  "Herdr integration",
399
524
  onOff(this.settings.herdrIntegration ?? true),
400
- 13
525
+ "herdrIntegration"
401
526
  ),
402
527
  this.row(
403
528
  "Tool result lines",
404
529
  String(this.settings.toolResultMaxLines ?? 2000),
405
- 14
530
+ "toolResultMaxLines"
406
531
  ),
407
532
  this.row(
408
533
  "Tool result bytes",
409
534
  String(this.settings.toolResultMaxBytes ?? 50 * 1024),
410
- 15
535
+ "toolResultMaxBytes"
411
536
  ),
412
537
  this.row(
413
538
  "Redact common secrets",
414
539
  onOff(this.settings.redactSecrets ?? false),
415
- 16
540
+ "redactSecrets"
416
541
  ),
417
542
  this.row(
418
543
  "Tool disclosure policies",
419
544
  Object.keys(this.settings.toolPolicies ?? {}).length
420
545
  ? "Exact names configured"
421
546
  : "All tools: full",
422
- 17
547
+ "toolPolicies"
423
548
  ),
424
549
  ];
425
550
  if (this.editingCustom) {
@@ -427,19 +552,96 @@ export class AdvisorSettingsSelector implements Component, Focusable {
427
552
  ` ${this.customInput.render(Math.max(10, width - 6))[0] || ""}`
428
553
  );
429
554
  }
430
- rows.push(...this.policyInputRows(width));
431
- rows.push(this.row("Save changes", "", 18));
432
- return [
433
- theme.fg("accent", theme.bold(" Advisor settings")),
434
- "",
435
- ` ${theme.fg("muted", heading)}`,
436
- ` ${theme.fg("muted", track.join(""))}`,
437
- ` ${theme.fg("text", labels.join(""))}`,
438
- "",
439
- ...rows.map((line) => ` ${line}`),
440
- "",
441
- ` ${theme.fg("muted", "↑/↓ select · ←/→ adjust · Enter edits or saves · Esc cancels")}`,
442
- ].map((line) => truncateToWidth(line, width));
555
+ if (this.editingPolicies) {
556
+ rows.push(
557
+ ` ${this.policiesInput.render(Math.max(10, width - 6))[0] || ""}`
558
+ );
559
+ if (this.policiesError) {
560
+ rows.push(` ${this.options.theme.fg("error", this.policiesError)}`);
561
+ }
562
+ }
563
+ rows.push(this.row("Save changes", "", "save"));
564
+ return rows;
565
+ }
566
+
567
+ render(width: number): string[] {
568
+ const { theme, presets } = this.options;
569
+ const simpleMode = this.settings.simpleMode ?? false;
570
+ const modeRows = [
571
+ this.row("Simple mode", simpleMode ? "On" : "Off", "simpleMode"),
572
+ this.row("Always on", this.settings.alwaysOn ? "On" : "Off", "alwaysOn"),
573
+ ];
574
+ const lines = [theme.fg("accent", theme.bold(" Advisor settings")), ""];
575
+ if (simpleMode) {
576
+ const trackWidth = Math.max(24, Math.min(60, width - 4));
577
+ const positions = presets.map((_, index) =>
578
+ Math.round((index * (trackWidth - 1)) / Math.max(1, presets.length - 1))
579
+ );
580
+ const track = Array.from({ length: trackWidth }, () => "─");
581
+ track[positions[this.contextIndex]] = "▲";
582
+ const labels = Array.from({ length: trackWidth }, () => " ");
583
+ for (let index = 0; index < presets.length; index += 1) {
584
+ const { label } = presets[index];
585
+ const start = Math.max(
586
+ 0,
587
+ Math.min(
588
+ trackWidth - label.length,
589
+ positions[index] - Math.floor(label.length / 2)
590
+ )
591
+ );
592
+ for (let char = 0; char < label.length; char += 1) {
593
+ labels[start + char] = label[char];
594
+ }
595
+ }
596
+ lines.push(
597
+ ` ${this.row("Context window", this.currentContext().label, "context")}`
598
+ );
599
+ lines.push(` ${theme.fg("muted", "Recent history")}`);
600
+ lines.push(` ${theme.fg("muted", track.join(""))}`);
601
+ lines.push(` ${theme.fg("text", labels.join(""))}`);
602
+ lines.push("");
603
+ lines.push(...modeRows.map((line) => ` ${line}`));
604
+ lines.push(` ${this.row("Save changes", "", "save")}`);
605
+ lines.push("");
606
+ lines.push(
607
+ ` ${theme.fg("muted", "↑/↓ select · ←/→ adjust · Enter saves · Esc cancels")}`
608
+ );
609
+ return lines.map((line) => truncateToWidth(line, width));
610
+ }
611
+
612
+ const trackWidth = Math.max(24, Math.min(60, width - 4));
613
+ const positions = presets.map((_, index) =>
614
+ Math.round((index * (trackWidth - 1)) / Math.max(1, presets.length - 1))
615
+ );
616
+ const track = Array.from({ length: trackWidth }, () => "─");
617
+ track[positions[this.contextIndex]] = "▲";
618
+ const labels = Array.from({ length: trackWidth }, () => " ");
619
+ for (let index = 0; index < presets.length; index += 1) {
620
+ const { label } = presets[index];
621
+ const start = Math.max(
622
+ 0,
623
+ Math.min(
624
+ trackWidth - label.length,
625
+ positions[index] - Math.floor(label.length / 2)
626
+ )
627
+ );
628
+ for (let char = 0; char < label.length; char += 1) {
629
+ labels[start + char] = label[char];
630
+ }
631
+ }
632
+ const heading = `Recent history${" ".repeat(Math.max(1, trackWidth - "Recent history".length - "Full branch".length))}Full branch`;
633
+ lines.push(` ${theme.fg("muted", heading)}`);
634
+ lines.push(` ${theme.fg("muted", track.join(""))}`);
635
+ lines.push(` ${theme.fg("text", labels.join(""))}`);
636
+ lines.push("");
637
+ lines.push(
638
+ ...[...modeRows, ...this.advancedRows(width)].map((line) => ` ${line}`)
639
+ );
640
+ lines.push("");
641
+ lines.push(
642
+ ` ${theme.fg("muted", "↑/↓ select · ←/→ adjust · Enter edits or saves · Esc cancels")}`
643
+ );
644
+ return lines.map((line) => truncateToWidth(line, width));
443
645
  }
444
646
 
445
647
  handleInput(keyData: string): void {
@@ -455,20 +657,24 @@ export class AdvisorSettingsSelector implements Component, Focusable {
455
657
  if (matchesKey(keyData, Key.up)) {
456
658
  this.selectedRow = Math.max(0, this.selectedRow - 1);
457
659
  } else if (matchesKey(keyData, Key.down)) {
458
- this.selectedRow = Math.min(18, this.selectedRow + 1);
660
+ this.selectedRow = Math.min(
661
+ this.visibleRows().length - 1,
662
+ this.selectedRow + 1
663
+ );
459
664
  } else if (matchesKey(keyData, Key.left)) {
460
665
  this.adjust(-1);
461
666
  } else if (matchesKey(keyData, Key.right)) {
462
667
  this.adjust(1);
463
668
  } else if (matchesKey(keyData, Key.enter)) {
464
- if (this.selectedRow === 6) {
669
+ const row = this.selectedRowId();
670
+ if (row === "customRule") {
465
671
  this.editingCustom = true;
466
672
  this.customInput.setValue(this.settings.customRule || "");
467
673
  this.customInput.focused = this.focused;
468
674
  tui.requestRender();
469
675
  return;
470
676
  }
471
- if (this.selectedRow === 17) {
677
+ if (row === "toolPolicies") {
472
678
  this.editingPolicies = true;
473
679
  this.policiesError = undefined;
474
680
  this.policiesInput.setValue(
@@ -478,17 +684,13 @@ export class AdvisorSettingsSelector implements Component, Focusable {
478
684
  tui.requestRender();
479
685
  return;
480
686
  }
481
- if (this.selectedRow === 18) {
482
- this.options.onSave({
483
- ...this.settings,
484
- contextMaxChars: this.currentContext().value,
485
- effort: this.currentEffort(),
486
- });
687
+ if (row === "save") {
688
+ this.save();
487
689
  return;
488
690
  }
489
691
  this.adjust(1);
490
692
  } else if (matchesKey(keyData, Key.escape)) {
491
- this.options.onCancel();
693
+ this.cancel();
492
694
  return;
493
695
  } else {
494
696
  return;
@@ -496,9 +698,23 @@ export class AdvisorSettingsSelector implements Component, Focusable {
496
698
  tui.requestRender();
497
699
  }
498
700
 
499
- private adjust(direction: number) {
500
- switch (this.selectedRow) {
501
- case 0:
701
+ private adjust(direction: number): void {
702
+ switch (this.selectedRowId()) {
703
+ case "simpleMode":
704
+ this.settings.simpleMode = !(this.settings.simpleMode ?? false);
705
+ if (this.settings.simpleMode) {
706
+ this.startSimpleModeGradient();
707
+ } else {
708
+ this.stopSimpleModeGradient();
709
+ }
710
+ // The visible row set changes with the mode, so keep the cursor on the
711
+ // row the user is actually operating rather than on its old index.
712
+ this.focusRow("simpleMode");
713
+ break;
714
+ case "alwaysOn":
715
+ this.settings.alwaysOn = !(this.settings.alwaysOn ?? false);
716
+ break;
717
+ case "context":
502
718
  this.contextIndex = Math.max(
503
719
  0,
504
720
  Math.min(
@@ -509,7 +725,7 @@ export class AdvisorSettingsSelector implements Component, Focusable {
509
725
  this.settings.contextMaxChars =
510
726
  this.options.presets[this.contextIndex].value;
511
727
  break;
512
- case 1:
728
+ case "effort":
513
729
  this.effortIndex = Math.max(
514
730
  0,
515
731
  Math.min(
@@ -519,31 +735,31 @@ export class AdvisorSettingsSelector implements Component, Focusable {
519
735
  );
520
736
  this.settings.effort = this.options.effortLevels[this.effortIndex];
521
737
  break;
522
- case 2:
738
+ case "planGate":
523
739
  this.settings.planGate = !this.settings.planGate;
524
740
  break;
525
- case 3:
741
+ case "failureGate":
526
742
  this.settings.failureGate = !this.settings.failureGate;
527
743
  break;
528
- case 4:
744
+ case "completionGate":
529
745
  this.settings.completionGate = !this.settings.completionGate;
530
746
  break;
531
- case 5:
747
+ case "collapseResponses":
532
748
  this.settings.collapseResponses = !this.settings.collapseResponses;
533
749
  break;
534
- case 7:
750
+ case "blockOnBlocked":
535
751
  this.settings.blockOnBlocked = !(this.settings.blockOnBlocked ?? true);
536
752
  break;
537
- case 8:
753
+ case "autoLoopGate":
538
754
  this.settings.autoLoopGate = !(this.settings.autoLoopGate ?? true);
539
755
  break;
540
- case 9:
756
+ case "loopThreshold":
541
757
  this.settings.loopThreshold = Math.max(
542
758
  2,
543
759
  (this.settings.loopThreshold ?? 3) + direction
544
760
  );
545
761
  break;
546
- case 10: {
762
+ case "maxCallsPerSession": {
547
763
  const values = [undefined, 0, 1, 2, 3, 5, 10, 25, 50];
548
764
  const index = Math.max(
549
765
  0,
@@ -553,10 +769,10 @@ export class AdvisorSettingsSelector implements Component, Focusable {
553
769
  values[Math.max(0, Math.min(values.length - 1, index + direction))];
554
770
  break;
555
771
  }
556
- case 11:
557
- this.settings.sessionSummary = !(this.settings.sessionSummary ?? true);
772
+ case "sessionSummary":
773
+ this.settings.sessionSummary = !(this.settings.sessionSummary ?? false);
558
774
  break;
559
- case 12: {
775
+ case "failureMode": {
560
776
  const modes: AdvisorSettings["failureMode"][] = [
561
777
  "block-session",
562
778
  "block-tool",
@@ -570,15 +786,15 @@ export class AdvisorSettingsSelector implements Component, Focusable {
570
786
  modes[Math.max(0, Math.min(modes.length - 1, index + direction))];
571
787
  break;
572
788
  }
573
- case 13:
789
+ case "herdrIntegration":
574
790
  this.settings.herdrIntegration = !(
575
791
  this.settings.herdrIntegration ?? true
576
792
  );
577
793
  break;
578
- case 16:
794
+ case "redactSecrets":
579
795
  this.settings.redactSecrets = !(this.settings.redactSecrets ?? false);
580
796
  break;
581
- case 14: {
797
+ case "toolResultMaxLines": {
582
798
  const values = [0, 500, 1000, 2000, 5000, 10_000];
583
799
  const index = Math.max(
584
800
  0,
@@ -588,7 +804,7 @@ export class AdvisorSettingsSelector implements Component, Focusable {
588
804
  values[Math.max(0, Math.min(values.length - 1, index + direction))];
589
805
  break;
590
806
  }
591
- case 15: {
807
+ case "toolResultMaxBytes": {
592
808
  const values = [0, 10 * 1024, 50 * 1024, 100 * 1024, 500 * 1024];
593
809
  const index = Math.max(
594
810
  0,