@oh-my-pi/pi-coding-agent 1.337.1 → 1.338.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/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [1.338.0] - 2026-01-03
4
+
5
+ ### Added
6
+
7
+ - Bash interceptor setting to block shell commands that have dedicated tools (disabled by default, enable via `/settings`)
8
+
9
+ ### Changed
10
+
11
+ - Refactored settings UI to declarative definitions for easier maintenance
12
+ - Shell detection now respects `$SHELL` environment variable before falling back to bash/sh
13
+ - Tool binary detection now uses `Bun.which()` instead of spawning processes
14
+
15
+ ### Fixed
16
+
17
+ - CLI help text now accurately lists all default tools
4
18
 
5
19
  ## [1.337.1] - 2026-01-02
6
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "1.337.1",
3
+ "version": "1.338.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
package/src/cli/args.ts CHANGED
@@ -234,13 +234,19 @@ ${chalk.bold("Environment Variables:")}
234
234
  ${chalk.dim("# Configuration")}
235
235
  ${ENV_AGENT_DIR.padEnd(23)} - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent)
236
236
 
237
- ${chalk.bold("Available Tools (default: read, bash, edit, write):")}
238
- read - Read file contents
239
- bash - Execute bash commands
240
- edit - Edit files with find/replace
241
- write - Write files (creates/overwrites)
242
- grep - Search file contents (read-only, off by default)
243
- find - Find files by glob pattern (read-only, off by default)
244
- ls - List directory contents (read-only, off by default)
237
+ ${chalk.bold("Available Tools (all enabled by default):")}
238
+ read - Read file contents
239
+ bash - Execute bash commands
240
+ edit - Edit files with find/replace
241
+ write - Write files (creates/overwrites)
242
+ grep - Search file contents
243
+ find - Find files by glob pattern
244
+ ls - List directory contents
245
+ lsp - Language server protocol (code intelligence)
246
+ notebook - Edit Jupyter notebooks
247
+ task - Launch sub-agents for parallel tasks
248
+ web_fetch - Fetch and process web pages
249
+ web_search - Search the web
250
+ ask - Ask user questions (interactive mode only)
245
251
  `);
246
252
  }
package/src/core/sdk.ts CHANGED
@@ -664,8 +664,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
664
664
  let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools];
665
665
  time("combineTools");
666
666
 
667
- // Apply bash interception to redirect common shell patterns to proper tools
668
- allToolsArray = applyBashInterception(allToolsArray);
667
+ // Apply bash interception to redirect common shell patterns to proper tools (if enabled)
668
+ if (settingsManager.getBashInterceptorEnabled()) {
669
+ allToolsArray = applyBashInterception(allToolsArray);
670
+ }
669
671
  time("applyBashInterception");
670
672
 
671
673
  if (hookRunner) {
@@ -43,6 +43,10 @@ export interface ExaSettings {
43
43
  enableWebsets?: boolean; // default: false
44
44
  }
45
45
 
46
+ export interface BashInterceptorSettings {
47
+ enabled?: boolean; // default: false (blocks shell commands that have dedicated tools)
48
+ }
49
+
46
50
  export interface Settings {
47
51
  lastChangelogVersion?: string;
48
52
  defaultProvider?: string;
@@ -62,6 +66,7 @@ export interface Settings {
62
66
  terminal?: TerminalSettings;
63
67
  enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
64
68
  exa?: ExaSettings;
69
+ bashInterceptor?: BashInterceptorSettings;
65
70
  }
66
71
 
67
72
  /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
@@ -440,4 +445,16 @@ export class SettingsManager {
440
445
  this.globalSettings.exa.enableWebsets = enabled;
441
446
  this.save();
442
447
  }
448
+
449
+ getBashInterceptorEnabled(): boolean {
450
+ return this.settings.bashInterceptor?.enabled ?? false;
451
+ }
452
+
453
+ setBashInterceptorEnabled(enabled: boolean): void {
454
+ if (!this.globalSettings.bashInterceptor) {
455
+ this.globalSettings.bashInterceptor = {};
456
+ }
457
+ this.globalSettings.bashInterceptor.enabled = enabled;
458
+ this.save();
459
+ }
443
460
  }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Declarative settings definitions.
3
+ *
4
+ * Each setting is defined once here and the UI is generated automatically.
5
+ * To add a new setting:
6
+ * 1. Add it to SettingsManager (getter/setter)
7
+ * 2. Add the definition here
8
+ * 3. Add the handler in interactive-mode.ts settingsHandlers
9
+ */
10
+
11
+ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
12
+ import { getCapabilities } from "@oh-my-pi/pi-tui";
13
+ import type { SettingsManager } from "../../../core/settings-manager.js";
14
+
15
+ // Setting value types
16
+ export type SettingValue = boolean | string;
17
+
18
+ // Base definition for all settings
19
+ interface BaseSettingDef {
20
+ id: string;
21
+ label: string;
22
+ description: string;
23
+ tab: "config" | "exa";
24
+ }
25
+
26
+ // Boolean toggle setting
27
+ export interface BooleanSettingDef extends BaseSettingDef {
28
+ type: "boolean";
29
+ get: (sm: SettingsManager) => boolean;
30
+ set: (sm: SettingsManager, value: boolean) => void;
31
+ /** If provided, setting is only shown when this returns true */
32
+ condition?: () => boolean;
33
+ }
34
+
35
+ // Enum setting (inline toggle between values)
36
+ export interface EnumSettingDef extends BaseSettingDef {
37
+ type: "enum";
38
+ values: readonly string[];
39
+ get: (sm: SettingsManager) => string;
40
+ set: (sm: SettingsManager, value: string) => void;
41
+ }
42
+
43
+ // Submenu setting (opens a selection list)
44
+ export interface SubmenuSettingDef extends BaseSettingDef {
45
+ type: "submenu";
46
+ get: (sm: SettingsManager) => string;
47
+ set: (sm: SettingsManager, value: string) => void;
48
+ /** Get available options dynamically */
49
+ getOptions: (sm: SettingsManager) => Array<{ value: string; label: string; description?: string }>;
50
+ /** Called when selection changes (for preview) */
51
+ onPreview?: (value: string) => void;
52
+ /** Called when submenu is cancelled (to restore preview) */
53
+ onPreviewCancel?: (originalValue: string) => void;
54
+ }
55
+
56
+ export type SettingDef = BooleanSettingDef | EnumSettingDef | SubmenuSettingDef;
57
+
58
+ const THINKING_DESCRIPTIONS: Record<ThinkingLevel, string> = {
59
+ off: "No reasoning",
60
+ minimal: "Very brief reasoning (~1k tokens)",
61
+ low: "Light reasoning (~2k tokens)",
62
+ medium: "Moderate reasoning (~8k tokens)",
63
+ high: "Deep reasoning (~16k tokens)",
64
+ xhigh: "Maximum reasoning (~32k tokens)",
65
+ };
66
+
67
+ /**
68
+ * All settings definitions.
69
+ * Order determines display order within each tab.
70
+ */
71
+ export const SETTINGS_DEFS: SettingDef[] = [
72
+ // Config tab
73
+ {
74
+ id: "autoCompact",
75
+ tab: "config",
76
+ type: "boolean",
77
+ label: "Auto-compact",
78
+ description: "Automatically compact context when it gets too large",
79
+ get: (sm) => sm.getCompactionEnabled(),
80
+ set: (sm, v) => sm.setCompactionEnabled(v), // Also handled in session
81
+ },
82
+ {
83
+ id: "showImages",
84
+ tab: "config",
85
+ type: "boolean",
86
+ label: "Show images",
87
+ description: "Render images inline in terminal",
88
+ get: (sm) => sm.getShowImages(),
89
+ set: (sm, v) => sm.setShowImages(v),
90
+ condition: () => !!getCapabilities().images,
91
+ },
92
+ {
93
+ id: "queueMode",
94
+ tab: "config",
95
+ type: "enum",
96
+ label: "Queue mode",
97
+ description: "How to process queued messages while agent is working",
98
+ values: ["one-at-a-time", "all"],
99
+ get: (sm) => sm.getQueueMode(),
100
+ set: (sm, v) => sm.setQueueMode(v as "all" | "one-at-a-time"), // Also handled in session
101
+ },
102
+ {
103
+ id: "hideThinking",
104
+ tab: "config",
105
+ type: "boolean",
106
+ label: "Hide thinking",
107
+ description: "Hide thinking blocks in assistant responses",
108
+ get: (sm) => sm.getHideThinkingBlock(),
109
+ set: (sm, v) => sm.setHideThinkingBlock(v),
110
+ },
111
+ {
112
+ id: "collapseChangelog",
113
+ tab: "config",
114
+ type: "boolean",
115
+ label: "Collapse changelog",
116
+ description: "Show condensed changelog after updates",
117
+ get: (sm) => sm.getCollapseChangelog(),
118
+ set: (sm, v) => sm.setCollapseChangelog(v),
119
+ },
120
+ {
121
+ id: "bashInterceptor",
122
+ tab: "config",
123
+ type: "boolean",
124
+ label: "Bash interceptor",
125
+ description: "Block shell commands that have dedicated tools (grep, cat, etc.)",
126
+ get: (sm) => sm.getBashInterceptorEnabled(),
127
+ set: (sm, v) => sm.setBashInterceptorEnabled(v),
128
+ },
129
+ {
130
+ id: "thinkingLevel",
131
+ tab: "config",
132
+ type: "submenu",
133
+ label: "Thinking level",
134
+ description: "Reasoning depth for thinking-capable models",
135
+ get: (sm) => sm.getDefaultThinkingLevel() ?? "off",
136
+ set: (sm, v) => sm.setDefaultThinkingLevel(v as ThinkingLevel), // Also handled in session
137
+ getOptions: () =>
138
+ (["off", "minimal", "low", "medium", "high", "xhigh"] as ThinkingLevel[]).map((level) => ({
139
+ value: level,
140
+ label: level,
141
+ description: THINKING_DESCRIPTIONS[level],
142
+ })),
143
+ },
144
+ {
145
+ id: "theme",
146
+ tab: "config",
147
+ type: "submenu",
148
+ label: "Theme",
149
+ description: "Color theme for the interface",
150
+ get: (sm) => sm.getTheme() ?? "dark",
151
+ set: (sm, v) => sm.setTheme(v),
152
+ getOptions: () => [], // Filled dynamically from context
153
+ },
154
+
155
+ // Exa tab
156
+ {
157
+ id: "exaEnabled",
158
+ tab: "exa",
159
+ type: "boolean",
160
+ label: "Exa enabled",
161
+ description: "Master toggle for all Exa search tools",
162
+ get: (sm) => sm.getExaSettings().enabled,
163
+ set: (sm, v) => sm.setExaEnabled(v),
164
+ },
165
+ {
166
+ id: "exaSearch",
167
+ tab: "exa",
168
+ type: "boolean",
169
+ label: "Exa search",
170
+ description: "Basic search, deep search, code search, crawl",
171
+ get: (sm) => sm.getExaSettings().enableSearch,
172
+ set: (sm, v) => sm.setExaSearchEnabled(v),
173
+ },
174
+ {
175
+ id: "exaLinkedin",
176
+ tab: "exa",
177
+ type: "boolean",
178
+ label: "Exa LinkedIn",
179
+ description: "Search LinkedIn for people and companies",
180
+ get: (sm) => sm.getExaSettings().enableLinkedin,
181
+ set: (sm, v) => sm.setExaLinkedinEnabled(v),
182
+ },
183
+ {
184
+ id: "exaCompany",
185
+ tab: "exa",
186
+ type: "boolean",
187
+ label: "Exa company",
188
+ description: "Comprehensive company research tool",
189
+ get: (sm) => sm.getExaSettings().enableCompany,
190
+ set: (sm, v) => sm.setExaCompanyEnabled(v),
191
+ },
192
+ {
193
+ id: "exaResearcher",
194
+ tab: "exa",
195
+ type: "boolean",
196
+ label: "Exa researcher",
197
+ description: "AI-powered deep research tasks",
198
+ get: (sm) => sm.getExaSettings().enableResearcher,
199
+ set: (sm, v) => sm.setExaResearcherEnabled(v),
200
+ },
201
+ {
202
+ id: "exaWebsets",
203
+ tab: "exa",
204
+ type: "boolean",
205
+ label: "Exa websets",
206
+ description: "Webset management and enrichment tools",
207
+ get: (sm) => sm.getExaSettings().enableWebsets,
208
+ set: (sm, v) => sm.setExaWebsetsEnabled(v),
209
+ },
210
+ ];
211
+
212
+ /** Get settings for a specific tab */
213
+ export function getSettingsForTab(tab: "config" | "exa"): SettingDef[] {
214
+ return SETTINGS_DEFS.filter((def) => def.tab === tab);
215
+ }
216
+
217
+ /** Get a setting definition by id */
218
+ export function getSettingDef(id: string): SettingDef | undefined {
219
+ return SETTINGS_DEFS.find((def) => def.id === id);
220
+ }
@@ -1,7 +1,6 @@
1
1
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import {
3
3
  Container,
4
- getCapabilities,
5
4
  isArrowLeft,
6
5
  isArrowRight,
7
6
  isEscape,
@@ -17,55 +16,11 @@ import {
17
16
  type TabBarTheme,
18
17
  Text,
19
18
  } from "@oh-my-pi/pi-tui";
19
+ import type { SettingsManager } from "../../../core/settings-manager.js";
20
20
  import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.js";
21
21
  import { DynamicBorder } from "./dynamic-border.js";
22
22
  import { PluginSettingsComponent } from "./plugin-settings.js";
23
-
24
- const THINKING_DESCRIPTIONS: Record<ThinkingLevel, string> = {
25
- off: "No reasoning",
26
- minimal: "Very brief reasoning (~1k tokens)",
27
- low: "Light reasoning (~2k tokens)",
28
- medium: "Moderate reasoning (~8k tokens)",
29
- high: "Deep reasoning (~16k tokens)",
30
- xhigh: "Maximum reasoning (~32k tokens)",
31
- };
32
-
33
- export interface ExaToolsConfig {
34
- enabled: boolean;
35
- enableSearch: boolean;
36
- enableLinkedin: boolean;
37
- enableCompany: boolean;
38
- enableResearcher: boolean;
39
- enableWebsets: boolean;
40
- }
41
-
42
- export interface SettingsConfig {
43
- autoCompact: boolean;
44
- showImages: boolean;
45
- queueMode: "all" | "one-at-a-time";
46
- thinkingLevel: ThinkingLevel;
47
- availableThinkingLevels: ThinkingLevel[];
48
- currentTheme: string;
49
- availableThemes: string[];
50
- hideThinkingBlock: boolean;
51
- collapseChangelog: boolean;
52
- cwd: string;
53
- exa: ExaToolsConfig;
54
- }
55
-
56
- export interface SettingsCallbacks {
57
- onAutoCompactChange: (enabled: boolean) => void;
58
- onShowImagesChange: (enabled: boolean) => void;
59
- onQueueModeChange: (mode: "all" | "one-at-a-time") => void;
60
- onThinkingLevelChange: (level: ThinkingLevel) => void;
61
- onThemeChange: (theme: string) => void;
62
- onThemePreview?: (theme: string) => void;
63
- onHideThinkingBlockChange: (hidden: boolean) => void;
64
- onCollapseChangelogChange: (collapsed: boolean) => void;
65
- onPluginsChanged?: () => void;
66
- onExaSettingChange: (setting: keyof ExaToolsConfig, enabled: boolean) => void;
67
- onCancel: () => void;
68
- }
23
+ import { getSettingsForTab, type SettingDef } from "./settings-defs.js";
69
24
 
70
25
  function getTabBarTheme(): TabBarTheme {
71
26
  return {
@@ -146,8 +101,41 @@ const SETTINGS_TABS: Tab[] = [
146
101
  { id: "plugins", label: "Plugins" },
147
102
  ];
148
103
 
104
+ /**
105
+ * Dynamic context for settings that need runtime data.
106
+ * Some settings (like thinking level) are managed by the session, not SettingsManager.
107
+ */
108
+ export interface SettingsRuntimeContext {
109
+ /** Available thinking levels (from session) */
110
+ availableThinkingLevels: ThinkingLevel[];
111
+ /** Current thinking level (from session) */
112
+ thinkingLevel: ThinkingLevel;
113
+ /** Available themes */
114
+ availableThemes: string[];
115
+ /** Working directory for plugins tab */
116
+ cwd: string;
117
+ }
118
+
119
+ /**
120
+ * Callback when any setting changes.
121
+ * The handler should dispatch based on settingId.
122
+ */
123
+ export type SettingChangeHandler = (settingId: string, newValue: string | boolean) => void;
124
+
125
+ export interface SettingsCallbacks {
126
+ /** Called when any setting value changes */
127
+ onChange: SettingChangeHandler;
128
+ /** Called for theme preview while browsing */
129
+ onThemePreview?: (theme: string) => void;
130
+ /** Called when plugins change */
131
+ onPluginsChanged?: () => void;
132
+ /** Called when settings panel is closed */
133
+ onCancel: () => void;
134
+ }
135
+
149
136
  /**
150
137
  * Main tabbed settings selector component.
138
+ * Uses declarative settings definitions from settings-defs.ts.
151
139
  */
152
140
  export class SettingsSelectorComponent extends Container {
153
141
  private tabBar: TabBar;
@@ -155,13 +143,15 @@ export class SettingsSelectorComponent extends Container {
155
143
  private currentSubmenu: Container | null = null;
156
144
  private pluginComponent: PluginSettingsComponent | null = null;
157
145
 
158
- private config: SettingsConfig;
146
+ private settingsManager: SettingsManager;
147
+ private context: SettingsRuntimeContext;
159
148
  private callbacks: SettingsCallbacks;
160
149
 
161
- constructor(config: SettingsConfig, callbacks: SettingsCallbacks) {
150
+ constructor(settingsManager: SettingsManager, context: SettingsRuntimeContext, callbacks: SettingsCallbacks) {
162
151
  super();
163
152
 
164
- this.config = config;
153
+ this.settingsManager = settingsManager;
154
+ this.context = context;
165
155
  this.callbacks = callbacks;
166
156
 
167
157
  // Add top border
@@ -201,10 +191,8 @@ export class SettingsSelectorComponent extends Container {
201
191
 
202
192
  switch (tabId) {
203
193
  case "config":
204
- this.showConfigTab();
205
- break;
206
194
  case "exa":
207
- this.showExaTab();
195
+ this.showSettingsTab(tabId);
208
196
  break;
209
197
  case "plugins":
210
198
  this.showPluginsTab();
@@ -215,201 +203,135 @@ export class SettingsSelectorComponent extends Container {
215
203
  this.addChild(bottomBorder);
216
204
  }
217
205
 
218
- private showConfigTab(): void {
219
- const supportsImages = getCapabilities().images;
206
+ /**
207
+ * Convert a setting definition to a SettingItem for the UI.
208
+ */
209
+ private defToItem(def: SettingDef): SettingItem | null {
210
+ // Check condition
211
+ if (def.type === "boolean" && def.condition && !def.condition()) {
212
+ return null;
213
+ }
220
214
 
221
- const items: SettingItem[] = [
222
- {
223
- id: "autocompact",
224
- label: "Auto-compact",
225
- description: "Automatically compact context when it gets too large",
226
- currentValue: this.config.autoCompact ? "true" : "false",
227
- values: ["true", "false"],
228
- },
229
- {
230
- id: "queue-mode",
231
- label: "Queue mode",
232
- description: "How to process queued messages while agent is working",
233
- currentValue: this.config.queueMode,
234
- values: ["one-at-a-time", "all"],
235
- },
236
- {
237
- id: "hide-thinking",
238
- label: "Hide thinking",
239
- description: "Hide thinking blocks in assistant responses",
240
- currentValue: this.config.hideThinkingBlock ? "true" : "false",
241
- values: ["true", "false"],
242
- },
243
- {
244
- id: "collapse-changelog",
245
- label: "Collapse changelog",
246
- description: "Show condensed changelog after updates",
247
- currentValue: this.config.collapseChangelog ? "true" : "false",
248
- values: ["true", "false"],
249
- },
250
- {
251
- id: "thinking",
252
- label: "Thinking level",
253
- description: "Reasoning depth for thinking-capable models",
254
- currentValue: this.config.thinkingLevel,
255
- submenu: (currentValue, done) =>
256
- new SelectSubmenu(
257
- "Thinking Level",
258
- "Select reasoning depth for thinking-capable models",
259
- this.config.availableThinkingLevels.map((level) => ({
260
- value: level,
261
- label: level,
262
- description: THINKING_DESCRIPTIONS[level],
263
- })),
264
- currentValue,
265
- (value) => {
266
- this.callbacks.onThinkingLevelChange(value as ThinkingLevel);
267
- done(value);
268
- },
269
- () => done(),
270
- ),
271
- },
272
- {
273
- id: "theme",
274
- label: "Theme",
275
- description: "Color theme for the interface",
276
- currentValue: this.config.currentTheme,
277
- submenu: (currentValue, done) =>
278
- new SelectSubmenu(
279
- "Theme",
280
- "Select color theme",
281
- this.config.availableThemes.map((t) => ({
282
- value: t,
283
- label: t,
284
- })),
285
- currentValue,
286
- (value) => {
287
- this.callbacks.onThemeChange(value);
288
- done(value);
289
- },
290
- () => {
291
- this.callbacks.onThemePreview?.(currentValue);
292
- done();
293
- },
294
- (value) => {
295
- this.callbacks.onThemePreview?.(value);
296
- },
297
- ),
298
- },
299
- ];
300
-
301
- // Add image toggle if supported
302
- if (supportsImages) {
303
- items.splice(1, 0, {
304
- id: "show-images",
305
- label: "Show images",
306
- description: "Render images inline in terminal",
307
- currentValue: this.config.showImages ? "true" : "false",
308
- values: ["true", "false"],
215
+ const currentValue = this.getCurrentValue(def);
216
+
217
+ switch (def.type) {
218
+ case "boolean":
219
+ return {
220
+ id: def.id,
221
+ label: def.label,
222
+ description: def.description,
223
+ currentValue: currentValue ? "true" : "false",
224
+ values: ["true", "false"],
225
+ };
226
+
227
+ case "enum":
228
+ return {
229
+ id: def.id,
230
+ label: def.label,
231
+ description: def.description,
232
+ currentValue: currentValue as string,
233
+ values: [...def.values],
234
+ };
235
+
236
+ case "submenu":
237
+ return {
238
+ id: def.id,
239
+ label: def.label,
240
+ description: def.description,
241
+ currentValue: currentValue as string,
242
+ submenu: (cv, done) => this.createSubmenu(def, cv, done),
243
+ };
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Get the current value for a setting, using runtime context for special cases.
249
+ */
250
+ private getCurrentValue(def: SettingDef): string | boolean {
251
+ // Special cases that come from runtime context instead of SettingsManager
252
+ switch (def.id) {
253
+ case "thinkingLevel":
254
+ return this.context.thinkingLevel;
255
+ default:
256
+ return def.get(this.settingsManager);
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Create a submenu for a submenu-type setting.
262
+ */
263
+ private createSubmenu(
264
+ def: SettingDef & { type: "submenu" },
265
+ currentValue: string,
266
+ done: (value?: string) => void,
267
+ ): Container {
268
+ let options = def.getOptions(this.settingsManager);
269
+
270
+ // Special case: inject runtime options
271
+ if (def.id === "thinkingLevel") {
272
+ options = this.context.availableThinkingLevels.map((level) => {
273
+ const baseOpt = def.getOptions(this.settingsManager).find((o) => o.value === level);
274
+ return baseOpt || { value: level, label: level };
309
275
  });
276
+ } else if (def.id === "theme") {
277
+ options = this.context.availableThemes.map((t) => ({ value: t, label: t }));
310
278
  }
311
279
 
312
- this.currentList = new SettingsList(
313
- items,
314
- 10,
315
- getSettingsListTheme(),
316
- (id, newValue) => {
317
- switch (id) {
318
- case "autocompact":
319
- this.callbacks.onAutoCompactChange(newValue === "true");
320
- break;
321
- case "show-images":
322
- this.callbacks.onShowImagesChange(newValue === "true");
323
- break;
324
- case "queue-mode":
325
- this.callbacks.onQueueModeChange(newValue as "all" | "one-at-a-time");
326
- break;
327
- case "hide-thinking":
328
- this.callbacks.onHideThinkingBlockChange(newValue === "true");
329
- break;
330
- case "collapse-changelog":
331
- this.callbacks.onCollapseChangelogChange(newValue === "true");
332
- break;
333
- }
280
+ const onPreview = def.id === "theme" ? this.callbacks.onThemePreview : undefined;
281
+ const onPreviewCancel = def.id === "theme" ? () => this.callbacks.onThemePreview?.(currentValue) : undefined;
282
+
283
+ return new SelectSubmenu(
284
+ def.label,
285
+ def.description,
286
+ options,
287
+ currentValue,
288
+ (value) => {
289
+ // Persist to SettingsManager
290
+ def.set(this.settingsManager, value);
291
+ // Notify for side effects
292
+ this.callbacks.onChange(def.id, value);
293
+ done(value);
334
294
  },
335
- () => this.callbacks.onCancel(),
295
+ () => {
296
+ onPreviewCancel?.();
297
+ done();
298
+ },
299
+ onPreview,
336
300
  );
337
-
338
- this.addChild(this.currentList);
339
301
  }
340
302
 
341
- private showExaTab(): void {
342
- const items: SettingItem[] = [
343
- {
344
- id: "exa-enabled",
345
- label: "Exa enabled",
346
- description: "Master toggle for all Exa search tools",
347
- currentValue: this.config.exa.enabled ? "true" : "false",
348
- values: ["true", "false"],
349
- },
350
- {
351
- id: "exa-search",
352
- label: "Exa search",
353
- description: "Basic search, deep search, code search, crawl",
354
- currentValue: this.config.exa.enableSearch ? "true" : "false",
355
- values: ["true", "false"],
356
- },
357
- {
358
- id: "exa-linkedin",
359
- label: "Exa LinkedIn",
360
- description: "Search LinkedIn for people and companies",
361
- currentValue: this.config.exa.enableLinkedin ? "true" : "false",
362
- values: ["true", "false"],
363
- },
364
- {
365
- id: "exa-company",
366
- label: "Exa company",
367
- description: "Comprehensive company research tool",
368
- currentValue: this.config.exa.enableCompany ? "true" : "false",
369
- values: ["true", "false"],
370
- },
371
- {
372
- id: "exa-researcher",
373
- label: "Exa researcher",
374
- description: "AI-powered deep research tasks",
375
- currentValue: this.config.exa.enableResearcher ? "true" : "false",
376
- values: ["true", "false"],
377
- },
378
- {
379
- id: "exa-websets",
380
- label: "Exa websets",
381
- description: "Webset management and enrichment tools",
382
- currentValue: this.config.exa.enableWebsets ? "true" : "false",
383
- values: ["true", "false"],
384
- },
385
- ];
303
+ /**
304
+ * Show a settings tab (config or exa) using definitions.
305
+ */
306
+ private showSettingsTab(tabId: "config" | "exa"): void {
307
+ const defs = getSettingsForTab(tabId);
308
+ const items: SettingItem[] = [];
309
+
310
+ for (const def of defs) {
311
+ const item = this.defToItem(def);
312
+ if (item) {
313
+ items.push(item);
314
+ }
315
+ }
386
316
 
387
317
  this.currentList = new SettingsList(
388
318
  items,
389
319
  10,
390
320
  getSettingsListTheme(),
391
321
  (id, newValue) => {
392
- const enabled = newValue === "true";
393
- switch (id) {
394
- case "exa-enabled":
395
- this.callbacks.onExaSettingChange("enabled", enabled);
396
- break;
397
- case "exa-search":
398
- this.callbacks.onExaSettingChange("enableSearch", enabled);
399
- break;
400
- case "exa-linkedin":
401
- this.callbacks.onExaSettingChange("enableLinkedin", enabled);
402
- break;
403
- case "exa-company":
404
- this.callbacks.onExaSettingChange("enableCompany", enabled);
405
- break;
406
- case "exa-researcher":
407
- this.callbacks.onExaSettingChange("enableResearcher", enabled);
408
- break;
409
- case "exa-websets":
410
- this.callbacks.onExaSettingChange("enableWebsets", enabled);
411
- break;
322
+ const def = defs.find((d) => d.id === id);
323
+ if (!def) return;
324
+
325
+ // Persist to SettingsManager based on type
326
+ if (def.type === "boolean") {
327
+ const boolValue = newValue === "true";
328
+ def.set(this.settingsManager, boolValue);
329
+ this.callbacks.onChange(id, boolValue);
330
+ } else if (def.type === "enum") {
331
+ def.set(this.settingsManager, newValue);
332
+ this.callbacks.onChange(id, newValue);
412
333
  }
334
+ // Submenu types are handled in createSubmenu
413
335
  },
414
336
  () => this.callbacks.onCancel(),
415
337
  );
@@ -418,7 +340,7 @@ export class SettingsSelectorComponent extends Container {
418
340
  }
419
341
 
420
342
  private showPluginsTab(): void {
421
- this.pluginComponent = new PluginSettingsComponent(this.config.cwd, {
343
+ this.pluginComponent = new PluginSettingsComponent(this.context.cwd, {
422
344
  onClose: () => this.callbacks.onCancel(),
423
345
  onPluginChanged: () => this.callbacks.onPluginsChanged?.(),
424
346
  });
@@ -6,7 +6,7 @@
6
6
  import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
- import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
9
+ import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
10
10
  import type { AssistantMessage, ImageContent, Message, OAuthProvider } from "@oh-my-pi/pi-ai";
11
11
  import type { SlashCommand } from "@oh-my-pi/pi-tui";
12
12
  import {
@@ -1597,48 +1597,15 @@ export class InteractiveMode {
1597
1597
  private showSettingsSelector(): void {
1598
1598
  this.showSelector((done) => {
1599
1599
  const selector = new SettingsSelectorComponent(
1600
+ this.settingsManager,
1600
1601
  {
1601
- autoCompact: this.session.autoCompactionEnabled,
1602
- showImages: this.settingsManager.getShowImages(),
1603
- queueMode: this.session.queueMode,
1604
- thinkingLevel: this.session.thinkingLevel,
1605
1602
  availableThinkingLevels: this.session.getAvailableThinkingLevels(),
1606
- currentTheme: this.settingsManager.getTheme() || "dark",
1603
+ thinkingLevel: this.session.thinkingLevel,
1607
1604
  availableThemes: getAvailableThemes(),
1608
- hideThinkingBlock: this.hideThinkingBlock,
1609
- collapseChangelog: this.settingsManager.getCollapseChangelog(),
1610
1605
  cwd: process.cwd(),
1611
- exa: this.settingsManager.getExaSettings(),
1612
1606
  },
1613
1607
  {
1614
- onAutoCompactChange: (enabled) => {
1615
- this.session.setAutoCompactionEnabled(enabled);
1616
- this.footer.setAutoCompactEnabled(enabled);
1617
- },
1618
- onShowImagesChange: (enabled) => {
1619
- this.settingsManager.setShowImages(enabled);
1620
- for (const child of this.chatContainer.children) {
1621
- if (child instanceof ToolExecutionComponent) {
1622
- child.setShowImages(enabled);
1623
- }
1624
- }
1625
- },
1626
- onQueueModeChange: (mode) => {
1627
- this.session.setQueueMode(mode);
1628
- },
1629
- onThinkingLevelChange: (level) => {
1630
- this.session.setThinkingLevel(level);
1631
- this.footer.invalidate();
1632
- this.updateEditorBorderColor();
1633
- },
1634
- onThemeChange: (themeName) => {
1635
- const result = setTheme(themeName, true);
1636
- this.settingsManager.setTheme(themeName);
1637
- this.ui.invalidate();
1638
- if (!result.success) {
1639
- this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`);
1640
- }
1641
- },
1608
+ onChange: (id, value) => this.handleSettingChange(id, value),
1642
1609
  onThemePreview: (themeName) => {
1643
1610
  const result = setTheme(themeName, true);
1644
1611
  if (result.success) {
@@ -1646,46 +1613,9 @@ export class InteractiveMode {
1646
1613
  this.ui.requestRender();
1647
1614
  }
1648
1615
  },
1649
- onHideThinkingBlockChange: (hidden) => {
1650
- this.hideThinkingBlock = hidden;
1651
- this.settingsManager.setHideThinkingBlock(hidden);
1652
- for (const child of this.chatContainer.children) {
1653
- if (child instanceof AssistantMessageComponent) {
1654
- child.setHideThinkingBlock(hidden);
1655
- }
1656
- }
1657
- this.chatContainer.clear();
1658
- this.rebuildChatFromMessages();
1659
- },
1660
- onCollapseChangelogChange: (collapsed) => {
1661
- this.settingsManager.setCollapseChangelog(collapsed);
1662
- },
1663
1616
  onPluginsChanged: () => {
1664
- // Plugin config changed - could trigger reload if needed
1665
1617
  this.ui.requestRender();
1666
1618
  },
1667
- onExaSettingChange: (setting, enabled) => {
1668
- switch (setting) {
1669
- case "enabled":
1670
- this.settingsManager.setExaEnabled(enabled);
1671
- break;
1672
- case "enableSearch":
1673
- this.settingsManager.setExaSearchEnabled(enabled);
1674
- break;
1675
- case "enableLinkedin":
1676
- this.settingsManager.setExaLinkedinEnabled(enabled);
1677
- break;
1678
- case "enableCompany":
1679
- this.settingsManager.setExaCompanyEnabled(enabled);
1680
- break;
1681
- case "enableResearcher":
1682
- this.settingsManager.setExaResearcherEnabled(enabled);
1683
- break;
1684
- case "enableWebsets":
1685
- this.settingsManager.setExaWebsetsEnabled(enabled);
1686
- break;
1687
- }
1688
- },
1689
1619
  onCancel: () => {
1690
1620
  done();
1691
1621
  this.ui.requestRender();
@@ -1696,6 +1626,59 @@ export class InteractiveMode {
1696
1626
  });
1697
1627
  }
1698
1628
 
1629
+ /**
1630
+ * Handle setting changes from the settings selector.
1631
+ * Most settings are saved directly via SettingsManager in the definitions.
1632
+ * This handles side effects and session-specific settings.
1633
+ */
1634
+ private handleSettingChange(id: string, value: string | boolean): void {
1635
+ switch (id) {
1636
+ // Session-managed settings (not in SettingsManager)
1637
+ case "autoCompact":
1638
+ this.session.setAutoCompactionEnabled(value as boolean);
1639
+ this.footer.setAutoCompactEnabled(value as boolean);
1640
+ break;
1641
+ case "queueMode":
1642
+ this.session.setQueueMode(value as "all" | "one-at-a-time");
1643
+ break;
1644
+ case "thinkingLevel":
1645
+ this.session.setThinkingLevel(value as ThinkingLevel);
1646
+ this.footer.invalidate();
1647
+ this.updateEditorBorderColor();
1648
+ break;
1649
+
1650
+ // Settings with UI side effects
1651
+ case "showImages":
1652
+ for (const child of this.chatContainer.children) {
1653
+ if (child instanceof ToolExecutionComponent) {
1654
+ child.setShowImages(value as boolean);
1655
+ }
1656
+ }
1657
+ break;
1658
+ case "hideThinking":
1659
+ this.hideThinkingBlock = value as boolean;
1660
+ for (const child of this.chatContainer.children) {
1661
+ if (child instanceof AssistantMessageComponent) {
1662
+ child.setHideThinkingBlock(value as boolean);
1663
+ }
1664
+ }
1665
+ this.chatContainer.clear();
1666
+ this.rebuildChatFromMessages();
1667
+ break;
1668
+ case "theme": {
1669
+ const result = setTheme(value as string, true);
1670
+ this.ui.invalidate();
1671
+ if (!result.success) {
1672
+ this.showError(`Failed to load theme "${value}": ${result.error}\nFell back to dark theme.`);
1673
+ }
1674
+ break;
1675
+ }
1676
+
1677
+ // All other settings are handled by the definitions (get/set on SettingsManager)
1678
+ // No additional side effects needed
1679
+ }
1680
+ }
1681
+
1699
1682
  private showModelSelector(): void {
1700
1683
  this.showSelector((done) => {
1701
1684
  const selector = new ModelSelectorComponent(
@@ -83,13 +83,21 @@ export function getShellConfig(): { shell: string; args: string[] } {
83
83
  );
84
84
  }
85
85
 
86
- // Unix: prefer bash over sh
87
- if (existsSync("/bin/bash")) {
88
- cachedShellConfig = { shell: "/bin/bash", args: ["-c"] };
86
+ // Unix: prefer user's shell from $SHELL, fallback to bash, then sh
87
+ const userShell = process.env.SHELL;
88
+ if (userShell && existsSync(userShell)) {
89
+ cachedShellConfig = { shell: userShell, args: ["-c"] };
89
90
  return cachedShellConfig;
90
91
  }
91
92
 
92
- cachedShellConfig = { shell: "sh", args: ["-c"] };
93
+ const bashPath = Bun.which("bash");
94
+ if (bashPath) {
95
+ cachedShellConfig = { shell: bashPath, args: ["-c"] };
96
+ return cachedShellConfig;
97
+ }
98
+
99
+ const shPath = Bun.which("sh");
100
+ cachedShellConfig = { shell: shPath || "sh", args: ["-c"] };
93
101
  return cachedShellConfig;
94
102
  }
95
103
 
@@ -95,14 +95,9 @@ const TOOLS: Record<string, ToolConfig> = {
95
95
  },
96
96
  };
97
97
 
98
- // Check if a command exists in PATH by trying to run it
99
- function commandExists(cmd: string): boolean {
100
- try {
101
- const proc = Bun.spawnSync([cmd, "--version"], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
102
- return proc.exitCode !== null;
103
- } catch {
104
- return false;
105
- }
98
+ // Check if a command exists in PATH
99
+ function commandExists(cmd: string): string | null {
100
+ return Bun.which(cmd);
106
101
  }
107
102
 
108
103
  // Get the path to a tool (system-wide or in our tools dir)
@@ -116,12 +111,8 @@ export function getToolPath(tool: "fd" | "rg" | "sd" | "sg"): string | null {
116
111
  return localPath;
117
112
  }
118
113
 
119
- // Check system PATH - if found, just return the command name (it's in PATH)
120
- if (commandExists(config.binaryName)) {
121
- return config.binaryName;
122
- }
123
-
124
- return null;
114
+ // Check system PATH
115
+ return commandExists(config.binaryName);
125
116
  }
126
117
 
127
118
  // Fetch latest release version from GitHub