@oh-my-pi/pi-coding-agent 9.4.0 → 9.6.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/package.json +9 -8
  3. package/src/capability/index.ts +7 -9
  4. package/src/cli/config-cli.ts +86 -73
  5. package/src/cli/update-cli.ts +45 -3
  6. package/src/commit/agentic/agent.ts +4 -4
  7. package/src/commit/agentic/index.ts +6 -5
  8. package/src/commit/agentic/tools/analyze-file.ts +5 -7
  9. package/src/commit/agentic/tools/index.ts +3 -3
  10. package/src/commit/model-selection.ts +13 -17
  11. package/src/commit/pipeline.ts +5 -5
  12. package/src/config/model-registry.ts +7 -0
  13. package/src/config/settings-schema.ts +836 -0
  14. package/src/config/settings.ts +702 -0
  15. package/src/discovery/helpers.ts +55 -11
  16. package/src/exa/index.ts +1 -1
  17. package/src/exec/bash-executor.ts +13 -13
  18. package/src/exec/shell-session.ts +15 -3
  19. package/src/export/ttsr.ts +1 -1
  20. package/src/extensibility/skills.ts +40 -9
  21. package/src/index.ts +2 -10
  22. package/src/ipy/gateway-coordinator.ts +5 -143
  23. package/src/ipy/kernel.ts +6 -171
  24. package/src/ipy/runtime.ts +198 -0
  25. package/src/lsp/client.ts +14 -1
  26. package/src/lsp/defaults.json +0 -6
  27. package/src/lsp/index.ts +1 -1
  28. package/src/lsp/types.ts +2 -0
  29. package/src/main.ts +26 -48
  30. package/src/modes/components/extensions/extension-dashboard.ts +22 -11
  31. package/src/modes/components/index.ts +1 -1
  32. package/src/modes/components/model-selector.ts +7 -7
  33. package/src/modes/components/settings-defs.ts +210 -915
  34. package/src/modes/components/settings-selector.ts +80 -106
  35. package/src/modes/components/status-line/types.ts +2 -8
  36. package/src/modes/components/status-line-segment-editor.ts +1 -1
  37. package/src/modes/components/status-line.ts +26 -3
  38. package/src/modes/controllers/event-controller.ts +9 -8
  39. package/src/modes/controllers/input-controller.ts +19 -15
  40. package/src/modes/controllers/selector-controller.ts +30 -14
  41. package/src/modes/interactive-mode.ts +10 -10
  42. package/src/modes/rpc/rpc-mode.ts +10 -0
  43. package/src/modes/rpc/rpc-types.ts +3 -0
  44. package/src/modes/types.ts +2 -2
  45. package/src/modes/utils/ui-helpers.ts +4 -3
  46. package/src/patch/index.ts +7 -7
  47. package/src/prompts/system/system-prompt.md +0 -1
  48. package/src/prompts/tools/bash.md +12 -2
  49. package/src/prompts/tools/task.md +180 -73
  50. package/src/sdk.ts +38 -61
  51. package/src/session/agent-session.ts +66 -55
  52. package/src/session/agent-storage.ts +1 -1
  53. package/src/session/session-manager.ts +10 -10
  54. package/src/system-prompt.ts +2 -2
  55. package/src/task/executor.ts +9 -9
  56. package/src/task/index.ts +2 -2
  57. package/src/tools/ask.ts +5 -6
  58. package/src/tools/bash-interceptor.ts +39 -1
  59. package/src/tools/bash-normalize.ts +126 -0
  60. package/src/tools/bash.ts +31 -5
  61. package/src/tools/find.ts +51 -33
  62. package/src/tools/index.ts +5 -23
  63. package/src/tools/plan-mode-guard.ts +1 -6
  64. package/src/tools/python.ts +2 -2
  65. package/src/tools/read.ts +2 -2
  66. package/src/tools/write.ts +2 -2
  67. package/src/utils/ignore-files.ts +119 -0
  68. package/src/web/search/providers/perplexity.ts +1 -1
  69. package/examples/sdk/10-settings.ts +0 -37
  70. package/src/config/settings-manager.ts +0 -2015
@@ -1,2015 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import { isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
4
- import { YAML } from "bun";
5
- import { type Settings as SettingsItem, settingsCapability } from "../capability/settings";
6
- import { getAgentDbPath, getAgentDir } from "../config";
7
- import { loadCapability } from "../discovery";
8
- import type { SymbolPreset } from "../modes/theme/theme";
9
- import { AgentStorage } from "../session/agent-storage";
10
- import { withFileLock } from "./file-lock";
11
-
12
- export interface CompactionSettings {
13
- enabled?: boolean; // default: true
14
- reserveTokens?: number; // default: 16384
15
- keepRecentTokens?: number; // default: 20000
16
- autoContinue?: boolean; // default: true
17
- remoteEndpoint?: string;
18
- }
19
-
20
- export interface BranchSummarySettings {
21
- enabled?: boolean; // default: false (prompt user to summarize when leaving branch)
22
- reserveTokens?: number; // default: 16384 (tokens reserved for prompt + LLM response)
23
- }
24
-
25
- export interface RetrySettings {
26
- enabled?: boolean; // default: true
27
- maxRetries?: number; // default: 3
28
- baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s)
29
- }
30
-
31
- export interface SkillsSettings {
32
- enabled?: boolean; // default: true
33
- enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands
34
- enableCodexUser?: boolean; // default: true
35
- enableClaudeUser?: boolean; // default: true
36
- enableClaudeProject?: boolean; // default: true
37
- enablePiUser?: boolean; // default: true
38
- enablePiProject?: boolean; // default: true
39
- customDirectories?: string[]; // default: []
40
- ignoredSkills?: string[]; // default: [] (glob patterns to exclude; takes precedence over includeSkills)
41
- includeSkills?: string[]; // default: [] (empty = include all; glob patterns to filter)
42
- }
43
-
44
- export interface CommandsSettings {
45
- enableClaudeUser?: boolean; // default: true (load from ~/.claude/commands/)
46
- enableClaudeProject?: boolean; // default: true (load from .claude/commands/)
47
- }
48
-
49
- export interface TerminalSettings {
50
- showImages?: boolean; // default: true (only relevant if terminal supports images)
51
- }
52
-
53
- export interface StartupSettings {
54
- quiet?: boolean; // default: false - suppress welcome screen and startup info
55
- }
56
-
57
- export interface ImageSettings {
58
- autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility)
59
- blockImages?: boolean; // default: false - when true, prevents all images from being sent to LLM providers
60
- }
61
-
62
- export interface ThinkingBudgetsSettings {
63
- minimal?: number;
64
- low?: number;
65
- medium?: number;
66
- high?: number;
67
- }
68
-
69
- export type NotificationMethod = "bell" | "osc99" | "osc9" | "auto" | "off";
70
-
71
- export interface NotificationSettings {
72
- onComplete?: NotificationMethod; // default: "auto"
73
- }
74
-
75
- export interface AskSettings {
76
- /** Timeout in seconds for ask tool selections (0 or null to disable, default: 30) */
77
- timeout?: number | null;
78
- /** Notification method when ask tool is waiting for input (default: "auto") */
79
- notification?: NotificationMethod;
80
- }
81
-
82
- export interface ExaSettings {
83
- enabled?: boolean; // default: true (master toggle for all Exa tools)
84
- enableSearch?: boolean; // default: true (search, deep, code, crawl)
85
- enableLinkedin?: boolean; // default: false
86
- enableCompany?: boolean; // default: false
87
- enableResearcher?: boolean; // default: false
88
- enableWebsets?: boolean; // default: false
89
- }
90
-
91
- export type WebSearchProviderOption = "auto" | "exa" | "perplexity" | "anthropic";
92
- export type ImageProviderOption = "auto" | "gemini" | "openrouter";
93
- export type KimiApiFormatOption = "openai" | "anthropic";
94
-
95
- export interface ProviderSettings {
96
- webSearch?: WebSearchProviderOption; // default: "auto" (exa > perplexity > anthropic)
97
- image?: ImageProviderOption; // default: "auto" (openrouter > gemini)
98
- kimiApiFormat?: KimiApiFormatOption; // default: "anthropic" (use Anthropic-compatible API for Kimi, more stable)
99
- }
100
-
101
- export interface BashInterceptorRule {
102
- pattern: string;
103
- flags?: string;
104
- tool: string;
105
- message: string;
106
- }
107
-
108
- export interface BashInterceptorSettings {
109
- enabled?: boolean; // default: false (blocks shell commands that have dedicated tools)
110
- simpleLs?: boolean; // default: true (intercept bare ls commands)
111
- patterns?: BashInterceptorRule[]; // default: built-in rules
112
- }
113
-
114
- export interface MCPSettings {
115
- enableProjectConfig?: boolean; // default: true (load .mcp.json from project root)
116
- }
117
-
118
- export interface LspSettings {
119
- formatOnWrite?: boolean; // default: false (format files using LSP after write tool writes code files)
120
- diagnosticsOnWrite?: boolean; // default: true (return LSP diagnostics after write tool writes code files)
121
- diagnosticsOnEdit?: boolean; // default: false (return LSP diagnostics after edit tool edits code files)
122
- }
123
-
124
- export type PythonToolMode = "ipy-only" | "bash-only" | "both";
125
- export type PythonKernelMode = "session" | "per-call";
126
-
127
- export interface PythonSettings {
128
- toolMode?: PythonToolMode;
129
- kernelMode?: PythonKernelMode;
130
- sharedGateway?: boolean;
131
- }
132
-
133
- export interface CommitSettings {
134
- mapReduceEnabled?: boolean;
135
- mapReduceMinFiles?: number;
136
- mapReduceMaxFileTokens?: number;
137
- mapReduceTimeoutMs?: number;
138
- mapReduceMaxConcurrency?: number;
139
- changelogMaxDiffChars?: number;
140
- }
141
-
142
- export interface EditSettings {
143
- fuzzyMatch?: boolean; // default: true (accept high-confidence fuzzy matches for whitespace/indentation)
144
- fuzzyThreshold?: number; // default: 0.95 (similarity threshold for fuzzy matching)
145
- patchMode?: boolean; // default: true (use codex-style apply-patch format instead of old_text/new_text)
146
- streamingAbort?: boolean; // default: false (abort streaming edit tool calls when patch preview fails)
147
- /** Model-specific variant overrides. Keys are model pattern substrings (e.g., "kimi", "deepseek"). */
148
- modelVariants?: Record<string, "patch" | "replace">;
149
- }
150
-
151
- export type { SymbolPreset };
152
-
153
- export interface TtsrSettings {
154
- enabled?: boolean; // default: true
155
- /** What to do with partial output when TTSR triggers: "keep" shows interrupted attempt, "discard" removes it */
156
- contextMode?: "keep" | "discard"; // default: "discard"
157
- /** How TTSR rules repeat: "once" = only trigger once per session, "after-gap" = can repeat after N messages */
158
- repeatMode?: "once" | "after-gap"; // default: "once"
159
- /** Number of messages before a rule can trigger again (only used when repeatMode is "after-gap") */
160
- repeatGap?: number; // default: 10
161
- }
162
-
163
- export interface TodoCompletionSettings {
164
- enabled?: boolean; // default: false - warn agent when it stops with incomplete todos
165
- maxReminders?: number; // default: 3 - maximum reminders before giving up
166
- }
167
-
168
- export type StatusLineSegmentId =
169
- | "pi"
170
- | "model"
171
- | "plan_mode"
172
- | "path"
173
- | "git"
174
- | "subagents"
175
- | "token_in"
176
- | "token_out"
177
- | "token_total"
178
- | "cost"
179
- | "context_pct"
180
- | "context_total"
181
- | "time_spent"
182
- | "time"
183
- | "session"
184
- | "hostname"
185
- | "cache_read"
186
- | "cache_write";
187
-
188
- export type StatusLineSeparatorStyle = "powerline" | "powerline-thin" | "slash" | "pipe" | "block" | "none" | "ascii";
189
-
190
- export type StatusLinePreset = "default" | "minimal" | "compact" | "full" | "nerd" | "ascii" | "custom";
191
-
192
- export interface StatusLineSegmentOptions {
193
- model?: { showThinkingLevel?: boolean };
194
- path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean };
195
- git?: { showBranch?: boolean; showStaged?: boolean; showUnstaged?: boolean; showUntracked?: boolean };
196
- time?: { format?: "12h" | "24h"; showSeconds?: boolean };
197
- }
198
-
199
- export interface StatusLineSettings {
200
- preset?: StatusLinePreset;
201
- leftSegments?: StatusLineSegmentId[];
202
- rightSegments?: StatusLineSegmentId[];
203
- separator?: StatusLineSeparatorStyle;
204
- segmentOptions?: StatusLineSegmentOptions;
205
- showHookStatus?: boolean;
206
- }
207
-
208
- export interface Settings {
209
- lastChangelogVersion?: string;
210
- /** Model roles map: { default: "provider/modelId", small: "provider/modelId", ... } */
211
- modelRoles?: Record<string, string>;
212
- defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
213
- steeringMode?: "all" | "one-at-a-time";
214
- followUpMode?: "all" | "one-at-a-time";
215
- queueMode?: "all" | "one-at-a-time"; // legacy
216
- interruptMode?: "immediate" | "wait";
217
- theme?: string;
218
- symbolPreset?: SymbolPreset; // default: uses theme's preset or "unicode"
219
- colorBlindMode?: boolean; // default: false (use blue instead of green for diff additions)
220
- compaction?: CompactionSettings;
221
- branchSummary?: BranchSummarySettings;
222
- retry?: RetrySettings;
223
- hideThinkingBlock?: boolean;
224
- shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
225
- shellForceBasic?: boolean; // Force bash/sh even if user's default shell is different
226
- collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
227
- startup?: StartupSettings;
228
- doubleEscapeAction?: "branch" | "tree"; // Action for double-escape with empty editor (default: "tree")
229
- thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels
230
- /** Environment variables to set automatically on startup */
231
- env?: Record<string, string>;
232
- extensions?: string[]; // Array of extension file paths
233
- skills?: SkillsSettings;
234
- commands?: CommandsSettings;
235
- terminal?: TerminalSettings;
236
- images?: ImageSettings;
237
- notifications?: NotificationSettings;
238
- enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
239
- exa?: ExaSettings;
240
- bashInterceptor?: BashInterceptorSettings;
241
- mcp?: MCPSettings;
242
- lsp?: LspSettings;
243
- python?: PythonSettings;
244
- commit?: CommitSettings;
245
- edit?: EditSettings;
246
- ttsr?: TtsrSettings;
247
- todoCompletion?: TodoCompletionSettings;
248
- providers?: ProviderSettings;
249
- disabledProviders?: string[]; // Discovery provider IDs that are disabled
250
- disabledExtensions?: string[]; // Individual extension IDs that are disabled (e.g., "skill:commit")
251
- statusLine?: StatusLineSettings; // Status line configuration
252
- showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME
253
- normativeRewrite?: boolean; // default: false (rewrite tool call arguments to normalized format in session history)
254
- readLineNumbers?: boolean; // default: false (prepend line numbers to read tool output by default)
255
- ask?: AskSettings;
256
- }
257
-
258
- export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
259
- {
260
- pattern: "^\\s*(cat|head|tail|less|more)\\s+",
261
- tool: "read",
262
- message: "Use the `read` tool instead of cat/head/tail. It provides better context and handles binary files.",
263
- },
264
- {
265
- pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+",
266
- tool: "grep",
267
- message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.",
268
- },
269
- {
270
- pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)",
271
- tool: "find",
272
- message: "Use the `find` tool instead of find/fd. It respects .gitignore and is faster for glob patterns.",
273
- },
274
- {
275
- pattern: "^\\s*sed\\s+(-i|--in-place)",
276
- tool: "edit",
277
- message: "Use the `edit` tool instead of sed -i. It provides diff preview and fuzzy matching.",
278
- },
279
- {
280
- pattern: "^\\s*perl\\s+.*-[pn]?i",
281
- tool: "edit",
282
- message: "Use the `edit` tool instead of perl -i. It provides diff preview and fuzzy matching.",
283
- },
284
- {
285
- pattern: "^\\s*awk\\s+.*-i\\s+inplace",
286
- tool: "edit",
287
- message: "Use the `edit` tool instead of awk -i inplace. It provides diff preview and fuzzy matching.",
288
- },
289
- {
290
- pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*[^|]>\\s*\\S",
291
- tool: "write",
292
- message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.",
293
- },
294
- ];
295
-
296
- const DEFAULT_BASH_INTERCEPTOR_SETTINGS: Required<BashInterceptorSettings> = {
297
- enabled: false,
298
- simpleLs: true,
299
- patterns: DEFAULT_BASH_INTERCEPTOR_RULES,
300
- };
301
-
302
- const DEFAULT_SETTINGS: Settings = {
303
- compaction: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
304
- branchSummary: { enabled: false, reserveTokens: 16384 },
305
- retry: { enabled: true, maxRetries: 3, baseDelayMs: 2000 },
306
- skills: {
307
- enabled: true,
308
- enableCodexUser: true,
309
- enableClaudeUser: true,
310
- enableClaudeProject: true,
311
- enablePiUser: true,
312
- enablePiProject: true,
313
- customDirectories: [],
314
- ignoredSkills: [],
315
- includeSkills: [],
316
- },
317
- commands: { enableClaudeUser: true, enableClaudeProject: true },
318
- terminal: { showImages: true },
319
- images: { autoResize: true },
320
- notifications: { onComplete: "auto" },
321
- ask: { timeout: 30, notification: "auto" },
322
- exa: {
323
- enabled: true,
324
- enableSearch: true,
325
- enableLinkedin: false,
326
- enableCompany: false,
327
- enableResearcher: false,
328
- enableWebsets: false,
329
- },
330
- bashInterceptor: DEFAULT_BASH_INTERCEPTOR_SETTINGS,
331
- mcp: { enableProjectConfig: true },
332
- lsp: { formatOnWrite: false, diagnosticsOnWrite: true, diagnosticsOnEdit: false },
333
- python: { toolMode: "both", kernelMode: "session", sharedGateway: true },
334
- edit: { fuzzyMatch: true, fuzzyThreshold: 0.95, streamingAbort: false },
335
- ttsr: { enabled: true, contextMode: "discard", repeatMode: "once", repeatGap: 10 },
336
- providers: { webSearch: "auto", image: "auto" },
337
- } satisfies Settings;
338
-
339
- function normalizeBashInterceptorRule(rule: unknown): BashInterceptorRule | null {
340
- if (!rule || typeof rule !== "object" || Array.isArray(rule)) return null;
341
-
342
- const candidate = rule as Record<string, unknown>;
343
- const pattern = typeof candidate.pattern === "string" ? candidate.pattern : "";
344
- const tool = typeof candidate.tool === "string" ? candidate.tool : "";
345
- const message = typeof candidate.message === "string" ? candidate.message : "";
346
- const flags = typeof candidate.flags === "string" && candidate.flags.length > 0 ? candidate.flags : undefined;
347
-
348
- if (!pattern || !tool || !message) return null;
349
- return { pattern, flags, tool, message };
350
- }
351
-
352
- function normalizeBashInterceptorSettings(
353
- settings: BashInterceptorSettings | undefined,
354
- ): Required<BashInterceptorSettings> {
355
- const enabled = settings?.enabled ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.enabled;
356
- const simpleLs = settings?.simpleLs ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.simpleLs;
357
- const rawPatterns = settings?.patterns;
358
- let patterns: BashInterceptorRule[];
359
- if (rawPatterns === undefined) {
360
- patterns = DEFAULT_BASH_INTERCEPTOR_RULES;
361
- } else if (Array.isArray(rawPatterns)) {
362
- patterns = rawPatterns
363
- .map(rule => normalizeBashInterceptorRule(rule))
364
- .filter((rule): rule is BashInterceptorRule => rule !== null);
365
- } else {
366
- patterns = DEFAULT_BASH_INTERCEPTOR_RULES;
367
- }
368
-
369
- return { enabled, simpleLs, patterns };
370
- }
371
-
372
- let cachedNerdFonts: boolean | null = null;
373
-
374
- function hasNerdFonts(): boolean {
375
- if (cachedNerdFonts !== null) {
376
- return cachedNerdFonts;
377
- }
378
-
379
- const envOverride = process.env.NERD_FONTS;
380
- if (envOverride === "1") {
381
- cachedNerdFonts = true;
382
- return true;
383
- }
384
- if (envOverride === "0") {
385
- cachedNerdFonts = false;
386
- return false;
387
- }
388
-
389
- const termProgram = (process.env.TERM_PROGRAM || "").toLowerCase();
390
- const term = (process.env.TERM || "").toLowerCase();
391
- const nerdTerms = ["iterm", "wezterm", "kitty", "ghostty", "alacritty"];
392
- cachedNerdFonts = nerdTerms.some(candidate => termProgram.includes(candidate) || term.includes(candidate));
393
- return cachedNerdFonts;
394
- }
395
-
396
- function normalizeSettings(settings: Settings): Settings {
397
- const merged = deepMergeSettings(DEFAULT_SETTINGS, settings);
398
- const symbolPreset = merged.symbolPreset ?? (hasNerdFonts() ? "nerd" : "unicode");
399
- return {
400
- ...merged,
401
- symbolPreset,
402
- bashInterceptor: normalizeBashInterceptorSettings(merged.bashInterceptor),
403
- python: normalizePythonSettings(merged.python),
404
- };
405
- }
406
-
407
- function normalizePythonSettings(settings: PythonSettings | undefined): PythonSettings {
408
- const toolMode = settings?.toolMode;
409
- const kernelMode = settings?.kernelMode;
410
- const sharedGateway = settings?.sharedGateway;
411
- return {
412
- toolMode:
413
- toolMode === "ipy-only" || toolMode === "bash-only" || toolMode === "both"
414
- ? toolMode
415
- : (DEFAULT_SETTINGS.python?.toolMode ?? "both"),
416
- kernelMode:
417
- kernelMode === "session" || kernelMode === "per-call"
418
- ? kernelMode
419
- : (DEFAULT_SETTINGS.python?.kernelMode ?? "session"),
420
- sharedGateway:
421
- typeof sharedGateway === "boolean" ? sharedGateway : (DEFAULT_SETTINGS.python?.sharedGateway ?? true),
422
- };
423
- }
424
-
425
- /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
426
- function deepMergeSettings(base: Settings, overrides: Settings): Settings {
427
- const result: Settings = { ...base };
428
-
429
- for (const key of Object.keys(overrides) as (keyof Settings)[]) {
430
- const overrideValue = overrides[key];
431
- const baseValue = base[key];
432
-
433
- if (overrideValue === undefined) {
434
- continue;
435
- }
436
-
437
- // For nested objects, merge recursively
438
- if (
439
- typeof overrideValue === "object" &&
440
- overrideValue !== null &&
441
- !Array.isArray(overrideValue) &&
442
- typeof baseValue === "object" &&
443
- baseValue !== null &&
444
- !Array.isArray(baseValue)
445
- ) {
446
- (result as Record<string, unknown>)[key] = { ...baseValue, ...overrideValue };
447
- } else {
448
- // For primitives and arrays, override value wins
449
- (result as Record<string, unknown>)[key] = overrideValue;
450
- }
451
- }
452
-
453
- return result;
454
- }
455
-
456
- export class SettingsManager {
457
- /** SQLite storage for auth/cache (null for in-memory mode) */
458
- private storage: AgentStorage | null;
459
- /** Path to config.yml (null for in-memory mode) */
460
- private configPath: string | null;
461
- private cwd: string | null;
462
- private globalSettings: Settings;
463
- private overrides: Settings;
464
- private settings!: Settings;
465
- private persist: boolean;
466
- private modifiedFields = new Set<keyof Settings>();
467
- private modifiedNestedFields = new Map<keyof Settings, Set<string>>();
468
-
469
- static #lastInstance: SettingsManager | null = null;
470
-
471
- /**
472
- * Private constructor - use static factory methods instead.
473
- * @param storage - SQLite storage instance for auth/cache, or null for in-memory mode
474
- * @param configPath - Path to config.yml for persistence, or null for in-memory mode
475
- * @param cwd - Current working directory for project settings discovery
476
- * @param initialSettings - Initial global settings to use
477
- * @param persist - Whether to persist settings changes to storage
478
- * @param projectSettings - Pre-loaded project settings (to avoid async in constructor)
479
- */
480
- private constructor(
481
- storage: AgentStorage | null,
482
- configPath: string | null,
483
- cwd: string | null,
484
- initialSettings: Settings,
485
- persist: boolean,
486
- projectSettings: Settings,
487
- private agentDir: string | null,
488
- ) {
489
- this.storage = storage;
490
- this.configPath = configPath;
491
- this.cwd = cwd;
492
- this.persist = persist;
493
- this.globalSettings = initialSettings;
494
- this.overrides = {};
495
- this.rebuildSettings(projectSettings);
496
-
497
- // Apply environment variables from settings
498
- this.applyEnvironmentVariables();
499
- }
500
-
501
- /**
502
- * Apply environment variables from settings to process.env
503
- * Only sets variables that are not already set in the environment
504
- */
505
- applyEnvironmentVariables(): void {
506
- const envVars = this.settings.env;
507
- if (!envVars || typeof envVars !== "object") {
508
- return;
509
- }
510
-
511
- for (const [key, value] of Object.entries(envVars)) {
512
- if (typeof key === "string" && typeof value === "string") {
513
- // Only set if not already present in environment (allow override with env vars)
514
- if (!(key in process.env)) {
515
- process.env[key] = value;
516
- }
517
- }
518
- }
519
- }
520
-
521
- /**
522
- * Create a SettingsManager that loads from persistent config.yml.
523
- * @param cwd - Current working directory for project settings discovery
524
- * @param agentDir - Agent directory containing config.yml
525
- * @returns Configured SettingsManager with merged global and user settings
526
- */
527
- static async create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): Promise<SettingsManager> {
528
- cwd = path.normalize(cwd);
529
- agentDir = path.normalize(agentDir);
530
-
531
- const configPath = path.join(agentDir, "config.yml");
532
- const storage = await AgentStorage.open(getAgentDbPath(agentDir));
533
-
534
- // Migrate from legacy storage if config.yml doesn't exist
535
- await SettingsManager.migrateToYaml(storage, agentDir, configPath);
536
-
537
- // Use capability API to load user-level settings from all providers
538
- const result = await loadCapability(settingsCapability.id, { cwd });
539
-
540
- // Merge all user-level settings
541
- let globalSettings: Settings = {};
542
- for (const item of result.items as SettingsItem[]) {
543
- if (item.level === "user") {
544
- globalSettings = deepMergeSettings(globalSettings, item.data as Settings);
545
- }
546
- }
547
-
548
- // Load persisted settings from config.yml
549
- const storedSettings = await SettingsManager.loadFromYaml(configPath);
550
- globalSettings = deepMergeSettings(globalSettings, storedSettings);
551
-
552
- // Load project settings before construction (constructor is sync)
553
- const projectSettings = await SettingsManager.loadProjectSettingsStatic(cwd);
554
-
555
- const instance = new SettingsManager(storage, configPath, cwd, globalSettings, true, projectSettings, agentDir);
556
- SettingsManager.#lastInstance = instance;
557
- return instance;
558
- }
559
-
560
- /**
561
- * Create an in-memory SettingsManager without persistence.
562
- * @param settings - Initial settings to use
563
- * @returns SettingsManager that won't persist changes to disk
564
- */
565
- static inMemory(settings: Partial<Settings> = {}): SettingsManager {
566
- return new SettingsManager(null, null, null, settings, false, {}, null);
567
- }
568
-
569
- /**
570
- * Serialize settings for passing to subagent workers.
571
- * Returns the merged settings (global + project + overrides).
572
- */
573
- serialize(): Settings {
574
- return { ...this.settings };
575
- }
576
-
577
- getPlansDirectory(_cwd: string = this.cwd ?? process.cwd()): string {
578
- return path.join(getAgentDir(), "plans");
579
- }
580
-
581
- /**
582
- * Access the underlying agent storage (null for in-memory settings).
583
- */
584
- getStorage(): AgentStorage | null {
585
- return this.storage;
586
- }
587
-
588
- /**
589
- * Load settings from config.yml, applying any schema migrations.
590
- * @param configPath - Path to config.yml, or null for in-memory mode
591
- * @returns Parsed and migrated settings, or empty object if file doesn't exist
592
- */
593
- private static async loadFromYaml(configPath: string | null): Promise<Settings> {
594
- if (!configPath) {
595
- return {};
596
- }
597
- try {
598
- const content = await Bun.file(configPath).text();
599
- const parsed = YAML.parse(content);
600
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
601
- return {};
602
- }
603
- return SettingsManager.migrateSettings(parsed as Record<string, unknown>);
604
- } catch (error) {
605
- if (isEnoent(error)) return {};
606
- logger.warn("SettingsManager failed to load config.yml", { path: configPath, error: String(error) });
607
- return {};
608
- }
609
- }
610
-
611
- /**
612
- * Migrate settings from legacy sources to config.yml.
613
- * Migration order: settings.json -> agent.db -> config.yml
614
- * Only migrates if config.yml doesn't exist.
615
- */
616
- private static async migrateToYaml(storage: AgentStorage, agentDir: string, configPath: string): Promise<void> {
617
- try {
618
- await Bun.file(configPath).text();
619
- return;
620
- } catch (err) {
621
- if (!isEnoent(err)) {
622
- logger.warn("SettingsManager failed to check config.yml", { path: configPath, error: String(err) });
623
- return;
624
- }
625
- }
626
-
627
- let settings: Settings = {};
628
- let migrated = false;
629
-
630
- // 1. Try to migrate from settings.json (oldest legacy format)
631
- const settingsJsonPath = path.join(agentDir, "settings.json");
632
- try {
633
- const parsed = JSON.parse(await Bun.file(settingsJsonPath).text());
634
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
635
- settings = deepMergeSettings(settings, SettingsManager.migrateSettings(parsed));
636
- migrated = true;
637
- // Backup settings.json
638
- try {
639
- fs.renameSync(settingsJsonPath, `${settingsJsonPath}.bak`);
640
- } catch (error) {
641
- logger.warn("SettingsManager failed to backup settings.json", { error: String(error) });
642
- }
643
- }
644
- } catch (error) {
645
- if (!isEnoent(error)) {
646
- logger.warn("SettingsManager failed to read settings.json", { error: String(error) });
647
- }
648
- }
649
-
650
- // 2. Migrate from agent.db settings table
651
- try {
652
- const dbSettings = storage.getSettings();
653
- if (dbSettings) {
654
- settings = deepMergeSettings(
655
- settings,
656
- SettingsManager.migrateSettings(dbSettings as Record<string, unknown>),
657
- );
658
- migrated = true;
659
- }
660
- } catch (error) {
661
- logger.warn("SettingsManager failed to read agent.db settings", { error: String(error) });
662
- }
663
-
664
- // 3. Write merged settings to config.yml if we found any
665
- if (migrated && Object.keys(settings).length > 0) {
666
- try {
667
- await Bun.write(configPath, YAML.stringify(settings, null, 2));
668
- logger.debug("SettingsManager migrated settings to config.yml", { path: configPath });
669
- } catch (error) {
670
- logger.warn("SettingsManager failed to write config.yml", { path: configPath, error: String(error) });
671
- }
672
- }
673
- }
674
-
675
- /** Migrate old settings format to new format */
676
- private static migrateSettings(settings: Record<string, unknown>): Settings {
677
- // Migrate queueMode -> steeringMode
678
- if ("queueMode" in settings && !("steeringMode" in settings)) {
679
- settings.steeringMode = settings.queueMode;
680
- delete settings.queueMode;
681
- }
682
- return settings as Settings;
683
- }
684
-
685
- /**
686
- * Static helper to load project settings (used by create() before construction).
687
- */
688
- private static async loadProjectSettingsStatic(cwd: string | null): Promise<Settings> {
689
- if (!cwd) return {};
690
-
691
- // Use capability API to discover settings from all providers
692
- const result = await loadCapability(settingsCapability.id, { cwd });
693
-
694
- // Merge only project-level settings (user-level settings are handled separately via globalSettings)
695
- let merged: Settings = {};
696
- for (const item of result.items as SettingsItem[]) {
697
- if (item.level === "project") {
698
- merged = deepMergeSettings(merged, item.data as Settings);
699
- }
700
- }
701
-
702
- return SettingsManager.migrateSettings(merged as Record<string, unknown>);
703
- }
704
-
705
- private async loadProjectSettings(): Promise<Settings> {
706
- return SettingsManager.loadProjectSettingsStatic(this.cwd);
707
- }
708
-
709
- private rebuildSettings(projectSettings: Settings): void {
710
- this.settings = normalizeSettings(
711
- deepMergeSettings(deepMergeSettings(this.globalSettings, projectSettings), this.overrides),
712
- );
713
- }
714
-
715
- /** Apply additional overrides on top of current settings */
716
- async applyOverrides(overrides: Partial<Settings>): Promise<void> {
717
- this.overrides = deepMergeSettings(this.overrides, overrides);
718
- const projectSettings = await this.loadProjectSettings();
719
- this.rebuildSettings(projectSettings);
720
- }
721
-
722
- /** Mark a field as modified during this session */
723
- private markModified(field: keyof Settings, nestedKey?: string): void {
724
- this.modifiedFields.add(field);
725
- if (nestedKey) {
726
- if (!this.modifiedNestedFields.has(field)) {
727
- this.modifiedNestedFields.set(field, new Set());
728
- }
729
- this.modifiedNestedFields.get(field)!.add(nestedKey);
730
- }
731
- }
732
-
733
- /**
734
- * Persist current global settings to config.yml and rebuild merged settings.
735
- * Uses file locking to prevent concurrent write races.
736
- */
737
- private async save(): Promise<void> {
738
- if (this.persist && this.configPath) {
739
- const configPath = this.configPath;
740
- try {
741
- await withFileLock(configPath, async () => {
742
- // Re-read current file to get latest external changes
743
- const currentFileSettings = await SettingsManager.loadFromYaml(configPath);
744
-
745
- // Start with file settings as base - preserves external edits
746
- const mergedSettings: Settings = { ...currentFileSettings };
747
-
748
- // Only override with in-memory values for fields that were explicitly modified during this session
749
- for (const field of this.modifiedFields) {
750
- const value = this.globalSettings[field];
751
-
752
- // Handle nested objects specially - merge at nested level to preserve unmodified nested keys
753
- if (this.modifiedNestedFields.has(field) && typeof value === "object" && value !== null) {
754
- const nestedModified = this.modifiedNestedFields.get(field)!;
755
- const baseNested = (currentFileSettings[field] as Record<string, unknown>) ?? {};
756
- const inMemoryNested = value as Record<string, unknown>;
757
- const mergedNested = { ...baseNested };
758
- for (const nestedKey of nestedModified) {
759
- mergedNested[nestedKey] = inMemoryNested[nestedKey];
760
- }
761
- (mergedSettings as Record<string, unknown>)[field] = mergedNested;
762
- } else {
763
- // For top-level primitives and arrays, use the modified value directly
764
- (mergedSettings as Record<string, unknown>)[field] = value;
765
- }
766
- }
767
-
768
- this.globalSettings = mergedSettings;
769
- await Bun.write(configPath, YAML.stringify(this.globalSettings, null, 2));
770
- });
771
- } catch (error) {
772
- logger.warn("SettingsManager save failed", { error: String(error) });
773
- }
774
- }
775
-
776
- const projectSettings = await this.loadProjectSettings();
777
- this.rebuildSettings(projectSettings);
778
- }
779
-
780
- getLastChangelogVersion(): string | undefined {
781
- return this.settings.lastChangelogVersion;
782
- }
783
-
784
- async setLastChangelogVersion(version: string): Promise<void> {
785
- this.globalSettings.lastChangelogVersion = version;
786
- this.markModified("lastChangelogVersion");
787
- await this.save();
788
- }
789
-
790
- /**
791
- * Get model for a role. Returns "provider/modelId" string or undefined.
792
- */
793
- getModelRole(role: string): string | undefined {
794
- return this.settings.modelRoles?.[role];
795
- }
796
-
797
- /**
798
- * Set model for a role. Model should be "provider/modelId" format.
799
- */
800
- async setModelRole(role: string, model: string): Promise<void> {
801
- if (!this.globalSettings.modelRoles) {
802
- this.globalSettings.modelRoles = {};
803
- }
804
- this.globalSettings.modelRoles[role] = model;
805
- this.markModified("modelRoles", role);
806
-
807
- if (this.overrides.modelRoles && this.overrides.modelRoles[role] !== undefined) {
808
- this.overrides.modelRoles[role] = model;
809
- }
810
-
811
- await this.save();
812
- }
813
-
814
- /**
815
- * Get all model roles.
816
- */
817
- getModelRoles(): Record<string, string> {
818
- return { ...this.settings.modelRoles };
819
- }
820
-
821
- getSteeringMode(): "all" | "one-at-a-time" {
822
- return this.settings.steeringMode || "one-at-a-time";
823
- }
824
-
825
- async setSteeringMode(mode: "all" | "one-at-a-time"): Promise<void> {
826
- this.globalSettings.steeringMode = mode;
827
- this.markModified("steeringMode");
828
- await this.save();
829
- }
830
-
831
- getFollowUpMode(): "all" | "one-at-a-time" {
832
- return this.settings.followUpMode || "one-at-a-time";
833
- }
834
-
835
- async setFollowUpMode(mode: "all" | "one-at-a-time"): Promise<void> {
836
- this.globalSettings.followUpMode = mode;
837
- this.markModified("followUpMode");
838
- await this.save();
839
- }
840
-
841
- getInterruptMode(): "immediate" | "wait" {
842
- return this.settings.interruptMode || "immediate";
843
- }
844
-
845
- async setInterruptMode(mode: "immediate" | "wait"): Promise<void> {
846
- this.globalSettings.interruptMode = mode;
847
- this.markModified("interruptMode");
848
- await this.save();
849
- }
850
-
851
- getTheme(): string | undefined {
852
- return this.settings.theme;
853
- }
854
-
855
- async setTheme(theme: string): Promise<void> {
856
- this.globalSettings.theme = theme;
857
- this.markModified("theme");
858
- await this.save();
859
- }
860
-
861
- getSymbolPreset(): SymbolPreset | undefined {
862
- return this.settings.symbolPreset;
863
- }
864
-
865
- async setSymbolPreset(preset: SymbolPreset): Promise<void> {
866
- this.globalSettings.symbolPreset = preset;
867
- this.markModified("symbolPreset");
868
- await this.save();
869
- }
870
-
871
- getColorBlindMode(): boolean {
872
- return this.settings.colorBlindMode ?? false;
873
- }
874
-
875
- async setColorBlindMode(enabled: boolean): Promise<void> {
876
- this.globalSettings.colorBlindMode = enabled;
877
- this.markModified("colorBlindMode");
878
- await this.save();
879
- }
880
-
881
- getDefaultThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | undefined {
882
- return this.settings.defaultThinkingLevel;
883
- }
884
-
885
- async setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): Promise<void> {
886
- this.globalSettings.defaultThinkingLevel = level;
887
- this.markModified("defaultThinkingLevel");
888
- await this.save();
889
- }
890
-
891
- getCompactionEnabled(): boolean {
892
- return this.settings.compaction?.enabled ?? true;
893
- }
894
-
895
- async setCompactionEnabled(enabled: boolean): Promise<void> {
896
- if (!this.globalSettings.compaction) {
897
- this.globalSettings.compaction = {};
898
- }
899
- this.globalSettings.compaction.enabled = enabled;
900
- this.markModified("compaction", "enabled");
901
- await this.save();
902
- }
903
-
904
- getCompactionReserveTokens(): number {
905
- return this.settings.compaction?.reserveTokens ?? 16384;
906
- }
907
-
908
- getCompactionKeepRecentTokens(): number {
909
- return this.settings.compaction?.keepRecentTokens ?? 20000;
910
- }
911
-
912
- getCompactionAutoContinue(): boolean {
913
- return this.settings.compaction?.autoContinue ?? true;
914
- }
915
-
916
- getCompactionRemoteEndpoint(): string | undefined {
917
- return this.settings.compaction?.remoteEndpoint;
918
- }
919
-
920
- getCompactionSettings(): {
921
- enabled: boolean;
922
- reserveTokens: number;
923
- keepRecentTokens: number;
924
- autoContinue: boolean;
925
- remoteEndpoint?: string;
926
- } {
927
- return {
928
- enabled: this.getCompactionEnabled(),
929
- reserveTokens: this.getCompactionReserveTokens(),
930
- keepRecentTokens: this.getCompactionKeepRecentTokens(),
931
- autoContinue: this.getCompactionAutoContinue(),
932
- remoteEndpoint: this.getCompactionRemoteEndpoint(),
933
- };
934
- }
935
-
936
- getBranchSummaryEnabled(): boolean {
937
- return this.settings.branchSummary?.enabled ?? false;
938
- }
939
-
940
- async setBranchSummaryEnabled(enabled: boolean): Promise<void> {
941
- if (!this.globalSettings.branchSummary) {
942
- this.globalSettings.branchSummary = {};
943
- }
944
- this.globalSettings.branchSummary.enabled = enabled;
945
- this.markModified("branchSummary", "enabled");
946
- await this.save();
947
- }
948
-
949
- getBranchSummarySettings(): { enabled: boolean; reserveTokens: number } {
950
- return {
951
- enabled: this.getBranchSummaryEnabled(),
952
- reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384,
953
- };
954
- }
955
-
956
- getRetryEnabled(): boolean {
957
- return this.settings.retry?.enabled ?? true;
958
- }
959
-
960
- async setRetryEnabled(enabled: boolean): Promise<void> {
961
- if (!this.globalSettings.retry) {
962
- this.globalSettings.retry = {};
963
- }
964
- this.globalSettings.retry.enabled = enabled;
965
- this.markModified("retry", "enabled");
966
- await this.save();
967
- }
968
-
969
- getRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number } {
970
- return {
971
- enabled: this.getRetryEnabled(),
972
- maxRetries: this.settings.retry?.maxRetries ?? 3,
973
- baseDelayMs: this.settings.retry?.baseDelayMs ?? 2000,
974
- };
975
- }
976
-
977
- getCommitSettings(): Required<CommitSettings> {
978
- return {
979
- mapReduceEnabled: this.settings.commit?.mapReduceEnabled ?? true,
980
- mapReduceMinFiles: this.settings.commit?.mapReduceMinFiles ?? 4,
981
- mapReduceMaxFileTokens: this.settings.commit?.mapReduceMaxFileTokens ?? 50_000,
982
- mapReduceTimeoutMs: this.settings.commit?.mapReduceTimeoutMs ?? 120_000,
983
- mapReduceMaxConcurrency: this.settings.commit?.mapReduceMaxConcurrency ?? 5,
984
- changelogMaxDiffChars: this.settings.commit?.changelogMaxDiffChars ?? 120_000,
985
- };
986
- }
987
-
988
- getRetryMaxRetries(): number {
989
- return this.settings.retry?.maxRetries ?? 3;
990
- }
991
-
992
- async setRetryMaxRetries(maxRetries: number): Promise<void> {
993
- if (!this.globalSettings.retry) {
994
- this.globalSettings.retry = {};
995
- }
996
- this.globalSettings.retry.maxRetries = maxRetries;
997
- this.markModified("retry", "maxRetries");
998
- await this.save();
999
- }
1000
-
1001
- getRetryBaseDelayMs(): number {
1002
- return this.settings.retry?.baseDelayMs ?? 2000;
1003
- }
1004
-
1005
- async setRetryBaseDelayMs(baseDelayMs: number): Promise<void> {
1006
- if (!this.globalSettings.retry) {
1007
- this.globalSettings.retry = {};
1008
- }
1009
- this.globalSettings.retry.baseDelayMs = baseDelayMs;
1010
- this.markModified("retry", "baseDelayMs");
1011
- await this.save();
1012
- }
1013
-
1014
- getTodoCompletionSettings(): { enabled: boolean; maxReminders: number } {
1015
- return {
1016
- enabled: this.settings.todoCompletion?.enabled ?? false,
1017
- maxReminders: this.settings.todoCompletion?.maxReminders ?? 3,
1018
- };
1019
- }
1020
-
1021
- getTodoCompletionEnabled(): boolean {
1022
- return this.settings.todoCompletion?.enabled ?? false;
1023
- }
1024
-
1025
- async setTodoCompletionEnabled(enabled: boolean): Promise<void> {
1026
- if (!this.globalSettings.todoCompletion) {
1027
- this.globalSettings.todoCompletion = {};
1028
- }
1029
- this.globalSettings.todoCompletion.enabled = enabled;
1030
- this.markModified("todoCompletion", "enabled");
1031
- await this.save();
1032
- }
1033
-
1034
- getTodoCompletionMaxReminders(): number {
1035
- return this.settings.todoCompletion?.maxReminders ?? 3;
1036
- }
1037
-
1038
- async setTodoCompletionMaxReminders(maxReminders: number): Promise<void> {
1039
- if (!this.globalSettings.todoCompletion) {
1040
- this.globalSettings.todoCompletion = {};
1041
- }
1042
- this.globalSettings.todoCompletion.maxReminders = maxReminders;
1043
- this.markModified("todoCompletion", "maxReminders");
1044
- await this.save();
1045
- }
1046
-
1047
- getThinkingBudgets(): ThinkingBudgetsSettings | undefined {
1048
- return this.settings.thinkingBudgets;
1049
- }
1050
-
1051
- getHideThinkingBlock(): boolean {
1052
- return this.settings.hideThinkingBlock ?? false;
1053
- }
1054
-
1055
- async setHideThinkingBlock(hide: boolean): Promise<void> {
1056
- this.globalSettings.hideThinkingBlock = hide;
1057
- this.markModified("hideThinkingBlock");
1058
- await this.save();
1059
- }
1060
-
1061
- getShellPath(): string | undefined {
1062
- return this.settings.shellPath;
1063
- }
1064
-
1065
- getShellForceBasic(): boolean {
1066
- return this.settings.shellForceBasic ?? true;
1067
- }
1068
-
1069
- async setShellPath(path: string | undefined): Promise<void> {
1070
- this.globalSettings.shellPath = path;
1071
- this.markModified("shellPath");
1072
- await this.save();
1073
- }
1074
-
1075
- async setShellForceBasic(force: boolean): Promise<void> {
1076
- this.globalSettings.shellForceBasic = force;
1077
- this.markModified("shellForceBasic");
1078
- await this.save();
1079
- }
1080
-
1081
- getCollapseChangelog(): boolean {
1082
- return this.settings.collapseChangelog ?? false;
1083
- }
1084
-
1085
- async setCollapseChangelog(collapse: boolean): Promise<void> {
1086
- this.globalSettings.collapseChangelog = collapse;
1087
- this.markModified("collapseChangelog");
1088
- await this.save();
1089
- }
1090
-
1091
- getStartupQuiet(): boolean {
1092
- return this.settings.startup?.quiet ?? false;
1093
- }
1094
-
1095
- async setStartupQuiet(quiet: boolean): Promise<void> {
1096
- if (!this.globalSettings.startup) {
1097
- this.globalSettings.startup = {};
1098
- }
1099
- this.globalSettings.startup.quiet = quiet;
1100
- this.markModified("startup", "quiet");
1101
- await this.save();
1102
- }
1103
-
1104
- getExtensionPaths(): string[] {
1105
- return [...(this.settings.extensions ?? [])];
1106
- }
1107
-
1108
- async setExtensionPaths(paths: string[]): Promise<void> {
1109
- this.globalSettings.extensions = paths;
1110
- this.markModified("extensions");
1111
- await this.save();
1112
- }
1113
-
1114
- getSkillsEnabled(): boolean {
1115
- return this.settings.skills?.enabled ?? true;
1116
- }
1117
-
1118
- async setSkillsEnabled(enabled: boolean): Promise<void> {
1119
- if (!this.globalSettings.skills) {
1120
- this.globalSettings.skills = {};
1121
- }
1122
- this.globalSettings.skills.enabled = enabled;
1123
- this.markModified("skills", "enabled");
1124
- await this.save();
1125
- }
1126
-
1127
- getSkillsSettings(): Required<SkillsSettings> {
1128
- return {
1129
- enabled: this.settings.skills?.enabled ?? true,
1130
- enableSkillCommands: this.settings.skills?.enableSkillCommands ?? true,
1131
- enableCodexUser: this.settings.skills?.enableCodexUser ?? true,
1132
- enableClaudeUser: this.settings.skills?.enableClaudeUser ?? true,
1133
- enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true,
1134
- enablePiUser: this.settings.skills?.enablePiUser ?? true,
1135
- enablePiProject: this.settings.skills?.enablePiProject ?? true,
1136
- customDirectories: [...(this.settings.skills?.customDirectories ?? [])],
1137
- ignoredSkills: [...(this.settings.skills?.ignoredSkills ?? [])],
1138
- includeSkills: [...(this.settings.skills?.includeSkills ?? [])],
1139
- };
1140
- }
1141
-
1142
- getEnableSkillCommands(): boolean {
1143
- return this.settings.skills?.enableSkillCommands ?? true;
1144
- }
1145
-
1146
- async setEnableSkillCommands(enabled: boolean): Promise<void> {
1147
- if (!this.globalSettings.skills) {
1148
- this.globalSettings.skills = {};
1149
- }
1150
- this.globalSettings.skills.enableSkillCommands = enabled;
1151
- this.markModified("skills", "enableSkillCommands");
1152
- await this.save();
1153
- }
1154
-
1155
- getCommandsSettings(): Required<CommandsSettings> {
1156
- return {
1157
- enableClaudeUser: this.settings.commands?.enableClaudeUser ?? true,
1158
- enableClaudeProject: this.settings.commands?.enableClaudeProject ?? true,
1159
- };
1160
- }
1161
-
1162
- getCommandsEnableClaudeUser(): boolean {
1163
- return this.settings.commands?.enableClaudeUser ?? true;
1164
- }
1165
-
1166
- async setCommandsEnableClaudeUser(enabled: boolean): Promise<void> {
1167
- if (!this.globalSettings.commands) {
1168
- this.globalSettings.commands = {};
1169
- }
1170
- this.globalSettings.commands.enableClaudeUser = enabled;
1171
- this.markModified("commands", "enableClaudeUser");
1172
- await this.save();
1173
- }
1174
-
1175
- getCommandsEnableClaudeProject(): boolean {
1176
- return this.settings.commands?.enableClaudeProject ?? true;
1177
- }
1178
-
1179
- async setCommandsEnableClaudeProject(enabled: boolean): Promise<void> {
1180
- if (!this.globalSettings.commands) {
1181
- this.globalSettings.commands = {};
1182
- }
1183
- this.globalSettings.commands.enableClaudeProject = enabled;
1184
- this.markModified("commands", "enableClaudeProject");
1185
- await this.save();
1186
- }
1187
-
1188
- getShowImages(): boolean {
1189
- return this.settings.terminal?.showImages ?? true;
1190
- }
1191
-
1192
- async setShowImages(show: boolean): Promise<void> {
1193
- if (!this.globalSettings.terminal) {
1194
- this.globalSettings.terminal = {};
1195
- }
1196
- this.globalSettings.terminal.showImages = show;
1197
- this.markModified("terminal", "showImages");
1198
- await this.save();
1199
- }
1200
-
1201
- getNotificationOnComplete(): NotificationMethod {
1202
- return this.settings.notifications?.onComplete ?? "auto";
1203
- }
1204
-
1205
- async setNotificationOnComplete(method: NotificationMethod): Promise<void> {
1206
- if (!this.globalSettings.notifications) {
1207
- this.globalSettings.notifications = {};
1208
- }
1209
- this.globalSettings.notifications.onComplete = method;
1210
- this.markModified("notifications", "onComplete");
1211
- await this.save();
1212
- }
1213
-
1214
- /** Get ask tool timeout in milliseconds (0 or null = disabled) */
1215
- getAskTimeout(): number | null {
1216
- const timeout = this.settings.ask?.timeout;
1217
- if (timeout === null || timeout === 0) return null;
1218
- return (timeout ?? 30) * 1000;
1219
- }
1220
-
1221
- async setAskTimeout(seconds: number | null): Promise<void> {
1222
- if (!this.globalSettings.ask) {
1223
- this.globalSettings.ask = {};
1224
- }
1225
- this.globalSettings.ask.timeout = seconds;
1226
- this.markModified("ask", "timeout");
1227
- await this.save();
1228
- }
1229
-
1230
- getAskNotification(): NotificationMethod {
1231
- return this.settings.ask?.notification ?? "auto";
1232
- }
1233
-
1234
- async setAskNotification(method: NotificationMethod): Promise<void> {
1235
- if (!this.globalSettings.ask) {
1236
- this.globalSettings.ask = {};
1237
- }
1238
- this.globalSettings.ask.notification = method;
1239
- this.markModified("ask", "notification");
1240
- await this.save();
1241
- }
1242
-
1243
- getImageAutoResize(): boolean {
1244
- return this.settings.images?.autoResize ?? true;
1245
- }
1246
-
1247
- async setImageAutoResize(enabled: boolean): Promise<void> {
1248
- if (!this.globalSettings.images) {
1249
- this.globalSettings.images = {};
1250
- }
1251
- this.globalSettings.images.autoResize = enabled;
1252
- this.markModified("images", "autoResize");
1253
- await this.save();
1254
- }
1255
-
1256
- getBlockImages(): boolean {
1257
- return this.settings.images?.blockImages ?? false;
1258
- }
1259
-
1260
- async setBlockImages(blocked: boolean): Promise<void> {
1261
- if (!this.globalSettings.images) {
1262
- this.globalSettings.images = {};
1263
- }
1264
- this.globalSettings.images.blockImages = blocked;
1265
- this.markModified("images", "blockImages");
1266
- await this.save();
1267
- }
1268
-
1269
- getEnabledModels(): string[] | undefined {
1270
- return this.settings.enabledModels;
1271
- }
1272
-
1273
- getExaSettings(): Required<ExaSettings> {
1274
- return {
1275
- enabled: this.settings.exa?.enabled ?? true,
1276
- enableSearch: this.settings.exa?.enableSearch ?? true,
1277
- enableLinkedin: this.settings.exa?.enableLinkedin ?? false,
1278
- enableCompany: this.settings.exa?.enableCompany ?? false,
1279
- enableResearcher: this.settings.exa?.enableResearcher ?? false,
1280
- enableWebsets: this.settings.exa?.enableWebsets ?? false,
1281
- };
1282
- }
1283
-
1284
- async setExaEnabled(enabled: boolean): Promise<void> {
1285
- if (!this.globalSettings.exa) {
1286
- this.globalSettings.exa = {};
1287
- }
1288
- this.globalSettings.exa.enabled = enabled;
1289
- this.markModified("exa", "enabled");
1290
- await this.save();
1291
- }
1292
-
1293
- async setExaSearchEnabled(enabled: boolean): Promise<void> {
1294
- if (!this.globalSettings.exa) {
1295
- this.globalSettings.exa = {};
1296
- }
1297
- this.globalSettings.exa.enableSearch = enabled;
1298
- this.markModified("exa", "enableSearch");
1299
- await this.save();
1300
- }
1301
-
1302
- async setExaLinkedinEnabled(enabled: boolean): Promise<void> {
1303
- if (!this.globalSettings.exa) {
1304
- this.globalSettings.exa = {};
1305
- }
1306
- this.globalSettings.exa.enableLinkedin = enabled;
1307
- this.markModified("exa", "enableLinkedin");
1308
- await this.save();
1309
- }
1310
-
1311
- async setExaCompanyEnabled(enabled: boolean): Promise<void> {
1312
- if (!this.globalSettings.exa) {
1313
- this.globalSettings.exa = {};
1314
- }
1315
- this.globalSettings.exa.enableCompany = enabled;
1316
- this.markModified("exa", "enableCompany");
1317
- await this.save();
1318
- }
1319
-
1320
- async setExaResearcherEnabled(enabled: boolean): Promise<void> {
1321
- if (!this.globalSettings.exa) {
1322
- this.globalSettings.exa = {};
1323
- }
1324
- this.globalSettings.exa.enableResearcher = enabled;
1325
- this.markModified("exa", "enableResearcher");
1326
- await this.save();
1327
- }
1328
-
1329
- async setExaWebsetsEnabled(enabled: boolean): Promise<void> {
1330
- if (!this.globalSettings.exa) {
1331
- this.globalSettings.exa = {};
1332
- }
1333
- this.globalSettings.exa.enableWebsets = enabled;
1334
- this.markModified("exa", "enableWebsets");
1335
- await this.save();
1336
- }
1337
-
1338
- // Provider settings
1339
- getWebSearchProvider(): WebSearchProviderOption {
1340
- return this.settings.providers?.webSearch ?? "auto";
1341
- }
1342
-
1343
- async setWebSearchProvider(provider: WebSearchProviderOption): Promise<void> {
1344
- if (!this.globalSettings.providers) {
1345
- this.globalSettings.providers = {};
1346
- }
1347
- this.globalSettings.providers.webSearch = provider;
1348
- this.markModified("providers", "webSearch");
1349
- await this.save();
1350
- }
1351
-
1352
- getImageProvider(): ImageProviderOption {
1353
- return this.settings.providers?.image ?? "auto";
1354
- }
1355
-
1356
- async setImageProvider(provider: ImageProviderOption): Promise<void> {
1357
- if (!this.globalSettings.providers) {
1358
- this.globalSettings.providers = {};
1359
- }
1360
- this.globalSettings.providers.image = provider;
1361
- this.markModified("providers", "image");
1362
- await this.save();
1363
- }
1364
-
1365
- getKimiApiFormat(): KimiApiFormatOption {
1366
- return this.settings.providers?.kimiApiFormat ?? "anthropic";
1367
- }
1368
-
1369
- async setKimiApiFormat(format: KimiApiFormatOption): Promise<void> {
1370
- if (!this.globalSettings.providers) {
1371
- this.globalSettings.providers = {};
1372
- }
1373
- this.globalSettings.providers.kimiApiFormat = format;
1374
- this.markModified("providers", "kimiApiFormat");
1375
- await this.save();
1376
- }
1377
-
1378
- getBashInterceptorEnabled(): boolean {
1379
- return this.settings.bashInterceptor?.enabled ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.enabled;
1380
- }
1381
-
1382
- getBashInterceptorSimpleLsEnabled(): boolean {
1383
- return this.settings.bashInterceptor?.simpleLs ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.simpleLs;
1384
- }
1385
-
1386
- getBashInterceptorRules(): BashInterceptorRule[] {
1387
- return [...(this.settings.bashInterceptor?.patterns ?? DEFAULT_BASH_INTERCEPTOR_RULES)];
1388
- }
1389
-
1390
- async setBashInterceptorEnabled(enabled: boolean): Promise<void> {
1391
- if (!this.globalSettings.bashInterceptor) {
1392
- this.globalSettings.bashInterceptor = {};
1393
- }
1394
- this.globalSettings.bashInterceptor.enabled = enabled;
1395
- this.markModified("bashInterceptor", "enabled");
1396
- await this.save();
1397
- }
1398
-
1399
- async setBashInterceptorSimpleLsEnabled(enabled: boolean): Promise<void> {
1400
- if (!this.globalSettings.bashInterceptor) {
1401
- this.globalSettings.bashInterceptor = {};
1402
- }
1403
- this.globalSettings.bashInterceptor.simpleLs = enabled;
1404
- this.markModified("bashInterceptor", "simpleLs");
1405
- await this.save();
1406
- }
1407
-
1408
- getPythonToolMode(): PythonToolMode {
1409
- return this.settings.python?.toolMode ?? "both";
1410
- }
1411
-
1412
- async setPythonToolMode(mode: PythonToolMode): Promise<void> {
1413
- if (!this.globalSettings.python) {
1414
- this.globalSettings.python = {};
1415
- }
1416
- this.globalSettings.python.toolMode = mode;
1417
- this.markModified("python", "toolMode");
1418
- await this.save();
1419
- }
1420
-
1421
- getPythonKernelMode(): PythonKernelMode {
1422
- return this.settings.python?.kernelMode ?? "session";
1423
- }
1424
-
1425
- async setPythonKernelMode(mode: PythonKernelMode): Promise<void> {
1426
- if (!this.globalSettings.python) {
1427
- this.globalSettings.python = {};
1428
- }
1429
- this.globalSettings.python.kernelMode = mode;
1430
- this.markModified("python", "kernelMode");
1431
- await this.save();
1432
- }
1433
-
1434
- getPythonSharedGateway(): boolean {
1435
- return this.settings.python?.sharedGateway ?? true;
1436
- }
1437
-
1438
- async setPythonSharedGateway(enabled: boolean): Promise<void> {
1439
- if (!this.globalSettings.python) {
1440
- this.globalSettings.python = {};
1441
- }
1442
- this.globalSettings.python.sharedGateway = enabled;
1443
- this.markModified("python", "sharedGateway");
1444
- await this.save();
1445
- }
1446
-
1447
- getMCPProjectConfigEnabled(): boolean {
1448
- return this.settings.mcp?.enableProjectConfig ?? true;
1449
- }
1450
-
1451
- async setMCPProjectConfigEnabled(enabled: boolean): Promise<void> {
1452
- if (!this.globalSettings.mcp) {
1453
- this.globalSettings.mcp = {};
1454
- }
1455
- this.globalSettings.mcp.enableProjectConfig = enabled;
1456
- this.markModified("mcp", "enableProjectConfig");
1457
- await this.save();
1458
- }
1459
-
1460
- getLspFormatOnWrite(): boolean {
1461
- return this.settings.lsp?.formatOnWrite ?? false;
1462
- }
1463
-
1464
- async setLspFormatOnWrite(enabled: boolean): Promise<void> {
1465
- if (!this.globalSettings.lsp) {
1466
- this.globalSettings.lsp = {};
1467
- }
1468
- this.globalSettings.lsp.formatOnWrite = enabled;
1469
- this.markModified("lsp", "formatOnWrite");
1470
- await this.save();
1471
- }
1472
-
1473
- getLspDiagnosticsOnWrite(): boolean {
1474
- return this.settings.lsp?.diagnosticsOnWrite ?? true;
1475
- }
1476
-
1477
- async setLspDiagnosticsOnWrite(enabled: boolean): Promise<void> {
1478
- if (!this.globalSettings.lsp) {
1479
- this.globalSettings.lsp = {};
1480
- }
1481
- this.globalSettings.lsp.diagnosticsOnWrite = enabled;
1482
- this.markModified("lsp", "diagnosticsOnWrite");
1483
- await this.save();
1484
- }
1485
-
1486
- getLspDiagnosticsOnEdit(): boolean {
1487
- return this.settings.lsp?.diagnosticsOnEdit ?? false;
1488
- }
1489
-
1490
- async setLspDiagnosticsOnEdit(enabled: boolean): Promise<void> {
1491
- if (!this.globalSettings.lsp) {
1492
- this.globalSettings.lsp = {};
1493
- }
1494
- this.globalSettings.lsp.diagnosticsOnEdit = enabled;
1495
- this.markModified("lsp", "diagnosticsOnEdit");
1496
- await this.save();
1497
- }
1498
-
1499
- getEditFuzzyMatch(): boolean {
1500
- return this.settings.edit?.fuzzyMatch ?? true;
1501
- }
1502
-
1503
- async setEditFuzzyMatch(enabled: boolean): Promise<void> {
1504
- if (!this.globalSettings.edit) {
1505
- this.globalSettings.edit = {};
1506
- }
1507
- this.globalSettings.edit.fuzzyMatch = enabled;
1508
- this.markModified("edit", "fuzzyMatch");
1509
- await this.save();
1510
- }
1511
-
1512
- getEditFuzzyThreshold(): number {
1513
- return this.settings.edit?.fuzzyThreshold ?? 0.95;
1514
- }
1515
-
1516
- async setEditFuzzyThreshold(value: number): Promise<void> {
1517
- if (!this.globalSettings.edit) {
1518
- this.globalSettings.edit = {};
1519
- }
1520
- this.globalSettings.edit.fuzzyThreshold = value;
1521
- this.markModified("edit", "fuzzyThreshold");
1522
- await this.save();
1523
- }
1524
-
1525
- getEditPatchMode(): boolean {
1526
- return this.settings.edit?.patchMode ?? true;
1527
- }
1528
-
1529
- async setEditPatchMode(enabled: boolean): Promise<void> {
1530
- if (!this.globalSettings.edit) {
1531
- this.globalSettings.edit = {};
1532
- }
1533
- this.globalSettings.edit.patchMode = enabled;
1534
- this.markModified("edit", "patchMode");
1535
- await this.save();
1536
- }
1537
-
1538
- getEditStreamingAbort(): boolean {
1539
- return this.settings.edit?.streamingAbort ?? false;
1540
- }
1541
-
1542
- async setEditStreamingAbort(enabled: boolean): Promise<void> {
1543
- if (!this.globalSettings.edit) {
1544
- this.globalSettings.edit = {};
1545
- }
1546
- this.globalSettings.edit.streamingAbort = enabled;
1547
- this.markModified("edit", "streamingAbort");
1548
- await this.save();
1549
- }
1550
-
1551
- /**
1552
- * Default model patterns that should use replace mode instead of patch mode.
1553
- * These are models known to struggle with unified diff format.
1554
- */
1555
- static readonly DEFAULT_REPLACE_MODE_PATTERNS = ["kimi"];
1556
-
1557
- /**
1558
- * Get the edit variant for a specific model.
1559
- * Returns "patch", "replace", or null (use global default).
1560
- */
1561
- getEditVariantForModel(model: string | undefined): "patch" | "replace" | null {
1562
- if (!model) return null;
1563
- const modelLower = model.toLowerCase();
1564
-
1565
- const userVariants = this.settings.edit?.modelVariants;
1566
- if (userVariants) {
1567
- for (const [pattern, variant] of Object.entries(userVariants)) {
1568
- if (modelLower.includes(pattern.toLowerCase())) {
1569
- return variant;
1570
- }
1571
- }
1572
- }
1573
-
1574
- for (const pattern of SettingsManager.DEFAULT_REPLACE_MODE_PATTERNS) {
1575
- if (modelLower.includes(pattern)) {
1576
- return "replace";
1577
- }
1578
- }
1579
-
1580
- return null;
1581
- }
1582
-
1583
- getEditModelVariants(): Record<string, "patch" | "replace"> {
1584
- return this.settings.edit?.modelVariants ?? {};
1585
- }
1586
-
1587
- async setEditModelVariant(pattern: string, variant: "patch" | "replace" | null): Promise<void> {
1588
- if (!this.globalSettings.edit) {
1589
- this.globalSettings.edit = {};
1590
- }
1591
- if (!this.globalSettings.edit.modelVariants) {
1592
- this.globalSettings.edit.modelVariants = {};
1593
- }
1594
- if (variant === null) {
1595
- delete this.globalSettings.edit.modelVariants[pattern];
1596
- } else {
1597
- this.globalSettings.edit.modelVariants[pattern] = variant;
1598
- }
1599
- this.markModified("edit", "modelVariants");
1600
- await this.save();
1601
- }
1602
-
1603
- getNormativeRewrite(): boolean {
1604
- return this.settings.normativeRewrite ?? false;
1605
- }
1606
-
1607
- async setNormativeRewrite(enabled: boolean): Promise<void> {
1608
- this.globalSettings.normativeRewrite = enabled;
1609
- this.markModified("normativeRewrite");
1610
- await this.save();
1611
- }
1612
-
1613
- getReadLineNumbers(): boolean {
1614
- return this.settings.readLineNumbers ?? false;
1615
- }
1616
-
1617
- async setReadLineNumbers(enabled: boolean): Promise<void> {
1618
- this.globalSettings.readLineNumbers = enabled;
1619
- this.markModified("readLineNumbers");
1620
- await this.save();
1621
- }
1622
-
1623
- getDisabledProviders(): string[] {
1624
- return [...(this.settings.disabledProviders ?? [])];
1625
- }
1626
-
1627
- async setDisabledProviders(providerIds: string[]): Promise<void> {
1628
- this.globalSettings.disabledProviders = providerIds;
1629
- this.markModified("disabledProviders");
1630
- await this.save();
1631
- }
1632
-
1633
- getDisabledExtensions(): string[] {
1634
- return [...(this.settings.disabledExtensions ?? [])];
1635
- }
1636
-
1637
- async setDisabledExtensions(extensionIds: string[]): Promise<void> {
1638
- this.globalSettings.disabledExtensions = extensionIds;
1639
- this.markModified("disabledExtensions");
1640
- await this.save();
1641
- }
1642
-
1643
- isExtensionEnabled(extensionId: string): boolean {
1644
- return !(this.settings.disabledExtensions ?? []).includes(extensionId);
1645
- }
1646
-
1647
- async enableExtension(extensionId: string): Promise<void> {
1648
- const disabled = this.globalSettings.disabledExtensions ?? [];
1649
- const index = disabled.indexOf(extensionId);
1650
- if (index !== -1) {
1651
- disabled.splice(index, 1);
1652
- this.globalSettings.disabledExtensions = disabled;
1653
- this.markModified("disabledExtensions");
1654
- await this.save();
1655
- }
1656
- }
1657
-
1658
- async disableExtension(extensionId: string): Promise<void> {
1659
- const disabled = this.globalSettings.disabledExtensions ?? [];
1660
- if (!disabled.includes(extensionId)) {
1661
- disabled.push(extensionId);
1662
- this.globalSettings.disabledExtensions = disabled;
1663
- this.markModified("disabledExtensions");
1664
- await this.save();
1665
- }
1666
- }
1667
-
1668
- getTtsrSettings(): TtsrSettings {
1669
- return this.settings.ttsr ?? {};
1670
- }
1671
-
1672
- async setTtsrSettings(settings: TtsrSettings): Promise<void> {
1673
- this.globalSettings.ttsr = { ...this.globalSettings.ttsr, ...settings };
1674
- this.markModified("ttsr");
1675
- await this.save();
1676
- }
1677
-
1678
- getTtsrEnabled(): boolean {
1679
- return this.settings.ttsr?.enabled ?? true;
1680
- }
1681
-
1682
- async setTtsrEnabled(enabled: boolean): Promise<void> {
1683
- if (!this.globalSettings.ttsr) {
1684
- this.globalSettings.ttsr = {};
1685
- }
1686
- this.globalSettings.ttsr.enabled = enabled;
1687
- this.markModified("ttsr", "enabled");
1688
- await this.save();
1689
- }
1690
-
1691
- getTtsrContextMode(): "keep" | "discard" {
1692
- return this.settings.ttsr?.contextMode ?? "discard";
1693
- }
1694
-
1695
- async setTtsrContextMode(mode: "keep" | "discard"): Promise<void> {
1696
- if (!this.globalSettings.ttsr) {
1697
- this.globalSettings.ttsr = {};
1698
- }
1699
- this.globalSettings.ttsr.contextMode = mode;
1700
- this.markModified("ttsr", "contextMode");
1701
- await this.save();
1702
- }
1703
-
1704
- getTtsrRepeatMode(): "once" | "after-gap" {
1705
- return this.settings.ttsr?.repeatMode ?? "once";
1706
- }
1707
-
1708
- async setTtsrRepeatMode(mode: "once" | "after-gap"): Promise<void> {
1709
- if (!this.globalSettings.ttsr) {
1710
- this.globalSettings.ttsr = {};
1711
- }
1712
- this.globalSettings.ttsr.repeatMode = mode;
1713
- this.markModified("ttsr", "repeatMode");
1714
- await this.save();
1715
- }
1716
-
1717
- getTtsrRepeatGap(): number {
1718
- return this.settings.ttsr?.repeatGap ?? 10;
1719
- }
1720
-
1721
- async setTtsrRepeatGap(gap: number): Promise<void> {
1722
- if (!this.globalSettings.ttsr) {
1723
- this.globalSettings.ttsr = {};
1724
- }
1725
- this.globalSettings.ttsr.repeatGap = gap;
1726
- this.markModified("ttsr", "repeatGap");
1727
- await this.save();
1728
- }
1729
-
1730
- // ═══════════════════════════════════════════════════════════════════════════
1731
- // Status Line Settings
1732
- // ═══════════════════════════════════════════════════════════════════════════
1733
-
1734
- getStatusLineSettings(): StatusLineSettings {
1735
- return this.settings.statusLine ? { ...this.settings.statusLine } : {};
1736
- }
1737
-
1738
- getStatusLinePreset(): StatusLinePreset {
1739
- return this.settings.statusLine?.preset ?? "default";
1740
- }
1741
-
1742
- async setStatusLinePreset(preset: StatusLinePreset): Promise<void> {
1743
- if (!this.globalSettings.statusLine) {
1744
- this.globalSettings.statusLine = {};
1745
- }
1746
- if (preset !== "custom") {
1747
- delete this.globalSettings.statusLine.leftSegments;
1748
- delete this.globalSettings.statusLine.rightSegments;
1749
- delete this.globalSettings.statusLine.segmentOptions;
1750
- this.markModified("statusLine", "leftSegments");
1751
- this.markModified("statusLine", "rightSegments");
1752
- this.markModified("statusLine", "segmentOptions");
1753
- }
1754
- this.globalSettings.statusLine.preset = preset;
1755
- this.markModified("statusLine", "preset");
1756
- await this.save();
1757
- }
1758
-
1759
- getStatusLineSeparator(): StatusLineSeparatorStyle {
1760
- return this.settings.statusLine?.separator ?? "powerline-thin";
1761
- }
1762
-
1763
- async setStatusLineSeparator(separator: StatusLineSeparatorStyle): Promise<void> {
1764
- if (!this.globalSettings.statusLine) {
1765
- this.globalSettings.statusLine = {};
1766
- }
1767
- this.globalSettings.statusLine.separator = separator;
1768
- this.markModified("statusLine", "separator");
1769
- await this.save();
1770
- }
1771
-
1772
- getStatusLineLeftSegments(): StatusLineSegmentId[] {
1773
- return [...(this.settings.statusLine?.leftSegments ?? [])];
1774
- }
1775
-
1776
- async setStatusLineLeftSegments(segments: StatusLineSegmentId[]): Promise<void> {
1777
- if (!this.globalSettings.statusLine) {
1778
- this.globalSettings.statusLine = {};
1779
- }
1780
- this.globalSettings.statusLine.leftSegments = segments;
1781
- this.markModified("statusLine", "leftSegments");
1782
- // Setting segments explicitly implies custom preset
1783
- if (this.globalSettings.statusLine.preset !== "custom") {
1784
- this.globalSettings.statusLine.preset = "custom";
1785
- this.markModified("statusLine", "preset");
1786
- }
1787
- await this.save();
1788
- }
1789
-
1790
- getStatusLineRightSegments(): StatusLineSegmentId[] {
1791
- return [...(this.settings.statusLine?.rightSegments ?? [])];
1792
- }
1793
-
1794
- async setStatusLineRightSegments(segments: StatusLineSegmentId[]): Promise<void> {
1795
- if (!this.globalSettings.statusLine) {
1796
- this.globalSettings.statusLine = {};
1797
- }
1798
- this.globalSettings.statusLine.rightSegments = segments;
1799
- this.markModified("statusLine", "rightSegments");
1800
- // Setting segments explicitly implies custom preset
1801
- if (this.globalSettings.statusLine.preset !== "custom") {
1802
- this.globalSettings.statusLine.preset = "custom";
1803
- this.markModified("statusLine", "preset");
1804
- }
1805
- await this.save();
1806
- }
1807
-
1808
- getStatusLineSegmentOptions(): StatusLineSegmentOptions {
1809
- return { ...this.settings.statusLine?.segmentOptions };
1810
- }
1811
-
1812
- async setStatusLineSegmentOption<K extends keyof StatusLineSegmentOptions>(
1813
- segment: K,
1814
- option: keyof NonNullable<StatusLineSegmentOptions[K]>,
1815
- value: boolean | number | string,
1816
- ): Promise<void> {
1817
- if (!this.globalSettings.statusLine) {
1818
- this.globalSettings.statusLine = {};
1819
- }
1820
- if (!this.globalSettings.statusLine.segmentOptions) {
1821
- this.globalSettings.statusLine.segmentOptions = {};
1822
- }
1823
- if (!this.globalSettings.statusLine.segmentOptions[segment]) {
1824
- this.globalSettings.statusLine.segmentOptions[segment] = {} as NonNullable<StatusLineSegmentOptions[K]>;
1825
- }
1826
- (this.globalSettings.statusLine.segmentOptions[segment] as Record<string, unknown>)[option as string] = value;
1827
- this.markModified("statusLine", "segmentOptions");
1828
- await this.save();
1829
- }
1830
-
1831
- async clearStatusLineSegmentOption<K extends keyof StatusLineSegmentOptions>(
1832
- segment: K,
1833
- option: keyof NonNullable<StatusLineSegmentOptions[K]>,
1834
- ): Promise<void> {
1835
- const segmentOptions = this.globalSettings.statusLine?.segmentOptions;
1836
- if (!segmentOptions || !segmentOptions[segment]) {
1837
- return;
1838
- }
1839
- delete (segmentOptions[segment] as Record<string, unknown>)[option as string];
1840
- if (Object.keys(segmentOptions[segment] as Record<string, unknown>).length === 0) {
1841
- delete segmentOptions[segment];
1842
- }
1843
- if (Object.keys(segmentOptions).length === 0) {
1844
- delete this.globalSettings.statusLine?.segmentOptions;
1845
- }
1846
- this.markModified("statusLine", "segmentOptions");
1847
- await this.save();
1848
- }
1849
-
1850
- getStatusLineShowHookStatus(): boolean {
1851
- return this.settings.statusLine?.showHookStatus ?? true;
1852
- }
1853
-
1854
- async setStatusLineShowHookStatus(show: boolean): Promise<void> {
1855
- if (!this.globalSettings.statusLine) {
1856
- this.globalSettings.statusLine = {};
1857
- }
1858
- this.globalSettings.statusLine.showHookStatus = show;
1859
- this.markModified("statusLine", "showHookStatus");
1860
- await this.save();
1861
- }
1862
-
1863
- getDoubleEscapeAction(): "branch" | "tree" {
1864
- return this.settings.doubleEscapeAction ?? "tree";
1865
- }
1866
-
1867
- async setDoubleEscapeAction(action: "branch" | "tree"): Promise<void> {
1868
- this.globalSettings.doubleEscapeAction = action;
1869
- this.markModified("doubleEscapeAction");
1870
- await this.save();
1871
- }
1872
-
1873
- getShowHardwareCursor(): boolean {
1874
- // Check settings first
1875
- if (this.settings.showHardwareCursor !== undefined) {
1876
- return this.settings.showHardwareCursor;
1877
- }
1878
- // Check env var override
1879
- const envVar = process.env.OMP_HARDWARE_CURSOR?.toLowerCase();
1880
- if (envVar === "0" || envVar === "false") return false;
1881
- if (envVar === "1" || envVar === "true") return true;
1882
- // Default to true on Linux/macOS for IME support
1883
- return process.platform === "linux" || process.platform === "darwin";
1884
- }
1885
-
1886
- async setShowHardwareCursor(show: boolean): Promise<void> {
1887
- this.globalSettings.showHardwareCursor = show;
1888
- this.markModified("showHardwareCursor");
1889
- await this.save();
1890
- }
1891
-
1892
- /**
1893
- * Get environment variables from settings
1894
- */
1895
- getEnvironmentVariables(): Record<string, string> {
1896
- return { ...(this.settings.env ?? {}) };
1897
- }
1898
-
1899
- /**
1900
- * Set environment variables in settings (not process.env)
1901
- * This will be applied on next startup or reload
1902
- */
1903
- async setEnvironmentVariables(envVars: Record<string, string>): Promise<void> {
1904
- this.globalSettings.env = { ...envVars };
1905
- this.markModified("env");
1906
- await this.save();
1907
- }
1908
-
1909
- /**
1910
- * Clear all environment variables from settings
1911
- */
1912
- async clearEnvironmentVariables(): Promise<void> {
1913
- delete this.globalSettings.env;
1914
- this.markModified("env");
1915
- await this.save();
1916
- }
1917
-
1918
- /**
1919
- * Set a single environment variable in settings
1920
- */
1921
- async setEnvironmentVariable(key: string, value: string): Promise<void> {
1922
- if (!this.globalSettings.env) {
1923
- this.globalSettings.env = {};
1924
- }
1925
- this.globalSettings.env[key] = value;
1926
- this.markModified("env", key);
1927
- await this.save();
1928
- }
1929
-
1930
- /**
1931
- * Remove a single environment variable from settings
1932
- */
1933
- async removeEnvironmentVariable(key: string): Promise<void> {
1934
- if (this.globalSettings.env) {
1935
- delete this.globalSettings.env[key];
1936
- this.markModified("env", key);
1937
- await this.save();
1938
- }
1939
- }
1940
-
1941
- _compareUniqueCtorKeys(cwd: string, agentDir: string): boolean {
1942
- if (this.cwd !== cwd) {
1943
- cwd = path.normalize(cwd);
1944
- if (this.cwd !== cwd) {
1945
- return false;
1946
- }
1947
- }
1948
- if (this.agentDir !== agentDir) {
1949
- agentDir = path.normalize(agentDir);
1950
- if (this.agentDir !== agentDir) {
1951
- return false;
1952
- }
1953
- }
1954
- return true;
1955
- }
1956
-
1957
- /**
1958
- * Acquire the last created SettingsManager instance.
1959
- * If no instance exists, create a new one.
1960
- * @returns The SettingsManager instance
1961
- */
1962
- static acquire(
1963
- cwd: string = process.cwd(),
1964
- agentDir: string = getAgentDir(),
1965
- ): SettingsManager | Promise<SettingsManager> {
1966
- const prev = SettingsManager.#lastInstance;
1967
- if (prev?._compareUniqueCtorKeys(cwd, agentDir)) {
1968
- return prev;
1969
- }
1970
- return SettingsManager.create(cwd, agentDir);
1971
- }
1972
-
1973
- /**
1974
- * Gets the shell configuration
1975
- * @returns The shell configuration
1976
- */
1977
- getShellConfig() {
1978
- if (this.getShellForceBasic()) {
1979
- const basicShell = resolveBasicShell();
1980
- if (basicShell) {
1981
- return procmgr.getShellConfig(basicShell);
1982
- }
1983
- }
1984
- const shell = this.getShellPath();
1985
- return procmgr.getShellConfig(shell);
1986
- }
1987
-
1988
- /**
1989
- * Gets the shell configuration from the last created SettingsManager instance.
1990
- * @returns The shell configuration
1991
- */
1992
- static async getGlobalShellConfig() {
1993
- const settings = await SettingsManager.acquire();
1994
- return settings.getShellConfig();
1995
- }
1996
- }
1997
-
1998
- function resolveBasicShell(): string | undefined {
1999
- const searchPaths = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"];
2000
- const candidates = ["bash", "sh"];
2001
-
2002
- for (const name of candidates) {
2003
- for (const dir of searchPaths) {
2004
- const fullPath = path.join(dir, name);
2005
- if (fs.existsSync(fullPath)) return fullPath;
2006
- }
2007
- }
2008
-
2009
- for (const name of ["bash", "bash.exe", "sh", "sh.exe"]) {
2010
- const resolved = Bun.which(name);
2011
- if (resolved) return resolved;
2012
- }
2013
-
2014
- return undefined;
2015
- }