Files
oh-my-pi/packages/coding-agent/src/config/settings-manager.ts
T
can1357 fcbfab765b refactor: consolidated module import paths
- Removed custom TypeScript path aliases and the associated transformation script.
- Replaced aliased imports with direct `@oh-my-pi` package imports for improved clarity.
- Simplified internal module references by removing explicit `/index` suffixes.
2026-01-23 21:01:13 +01:00

1660 lines
52 KiB
TypeScript

import { existsSync, readFileSync } from "node:fs";
import { rename } from "node:fs/promises";
import { join } from "node:path";
import { type Settings as SettingsItem, settingsCapability } from "@oh-my-pi/pi-coding-agent/capability/settings";
import { getAgentDbPath, getAgentDir } from "@oh-my-pi/pi-coding-agent/config";
import { withFileLock } from "@oh-my-pi/pi-coding-agent/config/file-lock";
import { loadCapability } from "@oh-my-pi/pi-coding-agent/discovery";
import type { SymbolPreset } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage";
import { logger } from "@oh-my-pi/pi-utils";
import { YAML } from "bun";
export interface CompactionSettings {
enabled?: boolean; // default: true
reserveTokens?: number; // default: 16384
keepRecentTokens?: number; // default: 20000
}
export interface BranchSummarySettings {
enabled?: boolean; // default: false (prompt user to summarize when leaving branch)
reserveTokens?: number; // default: 16384 (tokens reserved for prompt + LLM response)
}
export interface RetrySettings {
enabled?: boolean; // default: true
maxRetries?: number; // default: 3
baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s)
}
export interface SkillsSettings {
enabled?: boolean; // default: true
enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands
enableCodexUser?: boolean; // default: true
enableClaudeUser?: boolean; // default: true
enableClaudeProject?: boolean; // default: true
enablePiUser?: boolean; // default: true
enablePiProject?: boolean; // default: true
customDirectories?: string[]; // default: []
ignoredSkills?: string[]; // default: [] (glob patterns to exclude; takes precedence over includeSkills)
includeSkills?: string[]; // default: [] (empty = include all; glob patterns to filter)
}
export interface CommandsSettings {
enableClaudeUser?: boolean; // default: true (load from ~/.claude/commands/)
enableClaudeProject?: boolean; // default: true (load from .claude/commands/)
}
export interface TerminalSettings {
showImages?: boolean; // default: true (only relevant if terminal supports images)
}
export interface StartupSettings {
quiet?: boolean; // default: false - suppress welcome screen and startup info
}
export interface ImageSettings {
autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility)
blockImages?: boolean; // default: false - when true, prevents all images from being sent to LLM providers
}
export interface ThinkingBudgetsSettings {
minimal?: number;
low?: number;
medium?: number;
high?: number;
}
export type NotificationMethod = "bell" | "osc99" | "osc9" | "auto" | "off";
export interface NotificationSettings {
onComplete?: NotificationMethod; // default: "auto"
}
export interface ExaSettings {
enabled?: boolean; // default: true (master toggle for all Exa tools)
enableSearch?: boolean; // default: true (search, deep, code, crawl)
enableLinkedin?: boolean; // default: false
enableCompany?: boolean; // default: false
enableResearcher?: boolean; // default: false
enableWebsets?: boolean; // default: false
}
export type WebSearchProviderOption = "auto" | "exa" | "perplexity" | "anthropic";
export type ImageProviderOption = "auto" | "gemini" | "openrouter";
export interface ProviderSettings {
webSearch?: WebSearchProviderOption; // default: "auto" (exa > perplexity > anthropic)
image?: ImageProviderOption; // default: "auto" (openrouter > gemini)
}
export interface BashInterceptorRule {
pattern: string;
flags?: string;
tool: string;
message: string;
}
export interface BashInterceptorSettings {
enabled?: boolean; // default: false (blocks shell commands that have dedicated tools)
simpleLs?: boolean; // default: true (intercept bare ls commands)
patterns?: BashInterceptorRule[]; // default: built-in rules
}
export interface MCPSettings {
enableProjectConfig?: boolean; // default: true (load .mcp.json from project root)
}
export interface LspSettings {
formatOnWrite?: boolean; // default: false (format files using LSP after write tool writes code files)
diagnosticsOnWrite?: boolean; // default: true (return LSP diagnostics after write tool writes code files)
diagnosticsOnEdit?: boolean; // default: false (return LSP diagnostics after edit tool edits code files)
}
export type PythonToolMode = "ipy-only" | "bash-only" | "both";
export type PythonKernelMode = "session" | "per-call";
export interface PythonSettings {
toolMode?: PythonToolMode;
kernelMode?: PythonKernelMode;
sharedGateway?: boolean;
}
export interface CommitSettings {
mapReduceEnabled?: boolean;
mapReduceMinFiles?: number;
mapReduceMaxFileTokens?: number;
mapReduceTimeoutMs?: number;
mapReduceMaxConcurrency?: number;
changelogMaxDiffChars?: number;
}
export interface EditSettings {
fuzzyMatch?: boolean; // default: true (accept high-confidence fuzzy matches for whitespace/indentation)
fuzzyThreshold?: number; // default: 0.95 (similarity threshold for fuzzy matching)
patchMode?: boolean; // default: true (use codex-style apply-patch format instead of old_text/new_text)
streamingAbort?: boolean; // default: false (abort streaming edit tool calls when patch preview fails)
}
export type { SymbolPreset };
export interface TtsrSettings {
enabled?: boolean; // default: true
/** What to do with partial output when TTSR triggers: "keep" shows interrupted attempt, "discard" removes it */
contextMode?: "keep" | "discard"; // default: "discard"
/** How TTSR rules repeat: "once" = only trigger once per session, "after-gap" = can repeat after N messages */
repeatMode?: "once" | "after-gap"; // default: "once"
/** Number of messages before a rule can trigger again (only used when repeatMode is "after-gap") */
repeatGap?: number; // default: 10
}
export interface TodoCompletionSettings {
enabled?: boolean; // default: false - warn agent when it stops with incomplete todos
maxReminders?: number; // default: 3 - maximum reminders before giving up
}
export type StatusLineSegmentId =
| "pi"
| "model"
| "path"
| "git"
| "subagents"
| "token_in"
| "token_out"
| "token_total"
| "cost"
| "context_pct"
| "context_total"
| "time_spent"
| "time"
| "session"
| "hostname"
| "cache_read"
| "cache_write";
export type StatusLineSeparatorStyle = "powerline" | "powerline-thin" | "slash" | "pipe" | "block" | "none" | "ascii";
export type StatusLinePreset = "default" | "minimal" | "compact" | "full" | "nerd" | "ascii" | "custom";
export interface StatusLineSegmentOptions {
model?: { showThinkingLevel?: boolean };
path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean };
git?: { showBranch?: boolean; showStaged?: boolean; showUnstaged?: boolean; showUntracked?: boolean };
time?: { format?: "12h" | "24h"; showSeconds?: boolean };
}
export interface StatusLineSettings {
preset?: StatusLinePreset;
leftSegments?: StatusLineSegmentId[];
rightSegments?: StatusLineSegmentId[];
separator?: StatusLineSeparatorStyle;
segmentOptions?: StatusLineSegmentOptions;
showHookStatus?: boolean;
}
export interface Settings {
lastChangelogVersion?: string;
/** Model roles map: { default: "provider/modelId", small: "provider/modelId", ... } */
modelRoles?: Record<string, string>;
defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
steeringMode?: "all" | "one-at-a-time";
followUpMode?: "all" | "one-at-a-time";
queueMode?: "all" | "one-at-a-time"; // legacy
interruptMode?: "immediate" | "wait";
theme?: string;
symbolPreset?: SymbolPreset; // default: uses theme's preset or "unicode"
compaction?: CompactionSettings;
branchSummary?: BranchSummarySettings;
retry?: RetrySettings;
hideThinkingBlock?: boolean;
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
startup?: StartupSettings;
doubleEscapeAction?: "branch" | "tree"; // Action for double-escape with empty editor (default: "tree")
thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels
/** Environment variables to set automatically on startup */
env?: Record<string, string>;
extensions?: string[]; // Array of extension file paths
skills?: SkillsSettings;
commands?: CommandsSettings;
terminal?: TerminalSettings;
images?: ImageSettings;
notifications?: NotificationSettings;
enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
exa?: ExaSettings;
bashInterceptor?: BashInterceptorSettings;
mcp?: MCPSettings;
lsp?: LspSettings;
python?: PythonSettings;
commit?: CommitSettings;
edit?: EditSettings;
ttsr?: TtsrSettings;
todoCompletion?: TodoCompletionSettings;
providers?: ProviderSettings;
disabledProviders?: string[]; // Discovery provider IDs that are disabled
disabledExtensions?: string[]; // Individual extension IDs that are disabled (e.g., "skill:commit")
statusLine?: StatusLineSettings; // Status line configuration
showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME
normativeRewrite?: boolean; // default: false (rewrite tool call arguments to normalized format in session history)
readLineNumbers?: boolean; // default: false (prepend line numbers to read tool output by default)
}
export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
{
pattern: "^\\s*(cat|head|tail|less|more)\\s+",
tool: "read",
message: "Use the `read` tool instead of cat/head/tail. It provides better context and handles binary files.",
},
{
pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+",
tool: "grep",
message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.",
},
{
pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)",
tool: "find",
message: "Use the `find` tool instead of find/fd. It respects .gitignore and is faster for glob patterns.",
},
{
pattern: "^\\s*sed\\s+(-i|--in-place)",
tool: "edit",
message: "Use the `edit` tool instead of sed -i. It provides diff preview and fuzzy matching.",
},
{
pattern: "^\\s*perl\\s+.*-[pn]?i",
tool: "edit",
message: "Use the `edit` tool instead of perl -i. It provides diff preview and fuzzy matching.",
},
{
pattern: "^\\s*awk\\s+.*-i\\s+inplace",
tool: "edit",
message: "Use the `edit` tool instead of awk -i inplace. It provides diff preview and fuzzy matching.",
},
{
pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*[^|]>\\s*\\S",
tool: "write",
message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.",
},
];
const DEFAULT_BASH_INTERCEPTOR_SETTINGS: Required<BashInterceptorSettings> = {
enabled: false,
simpleLs: true,
patterns: DEFAULT_BASH_INTERCEPTOR_RULES,
};
const DEFAULT_SETTINGS: Settings = {
compaction: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
branchSummary: { enabled: false, reserveTokens: 16384 },
retry: { enabled: true, maxRetries: 3, baseDelayMs: 2000 },
skills: {
enabled: true,
enableCodexUser: true,
enableClaudeUser: true,
enableClaudeProject: true,
enablePiUser: true,
enablePiProject: true,
customDirectories: [],
ignoredSkills: [],
includeSkills: [],
},
commands: { enableClaudeUser: true, enableClaudeProject: true },
terminal: { showImages: true },
images: { autoResize: true },
notifications: { onComplete: "auto" },
exa: {
enabled: true,
enableSearch: true,
enableLinkedin: false,
enableCompany: false,
enableResearcher: false,
enableWebsets: false,
},
bashInterceptor: DEFAULT_BASH_INTERCEPTOR_SETTINGS,
mcp: { enableProjectConfig: true },
lsp: { formatOnWrite: false, diagnosticsOnWrite: true, diagnosticsOnEdit: false },
python: { toolMode: "both", kernelMode: "session", sharedGateway: true },
edit: { fuzzyMatch: true, fuzzyThreshold: 0.95, streamingAbort: false },
ttsr: { enabled: true, contextMode: "discard", repeatMode: "once", repeatGap: 10 },
providers: { webSearch: "auto", image: "auto" },
} satisfies Settings;
function normalizeBashInterceptorRule(rule: unknown): BashInterceptorRule | null {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) return null;
const candidate = rule as Record<string, unknown>;
const pattern = typeof candidate.pattern === "string" ? candidate.pattern : "";
const tool = typeof candidate.tool === "string" ? candidate.tool : "";
const message = typeof candidate.message === "string" ? candidate.message : "";
const flags = typeof candidate.flags === "string" && candidate.flags.length > 0 ? candidate.flags : undefined;
if (!pattern || !tool || !message) return null;
return { pattern, flags, tool, message };
}
function normalizeBashInterceptorSettings(
settings: BashInterceptorSettings | undefined,
): Required<BashInterceptorSettings> {
const enabled = settings?.enabled ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.enabled;
const simpleLs = settings?.simpleLs ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.simpleLs;
const rawPatterns = settings?.patterns;
let patterns: BashInterceptorRule[];
if (rawPatterns === undefined) {
patterns = DEFAULT_BASH_INTERCEPTOR_RULES;
} else if (Array.isArray(rawPatterns)) {
patterns = rawPatterns
.map((rule) => normalizeBashInterceptorRule(rule))
.filter((rule): rule is BashInterceptorRule => rule !== null);
} else {
patterns = DEFAULT_BASH_INTERCEPTOR_RULES;
}
return { enabled, simpleLs, patterns };
}
let cachedNerdFonts: boolean | null = null;
function hasNerdFonts(): boolean {
if (cachedNerdFonts !== null) {
return cachedNerdFonts;
}
const envOverride = process.env.NERD_FONTS;
if (envOverride === "1") {
cachedNerdFonts = true;
return true;
}
if (envOverride === "0") {
cachedNerdFonts = false;
return false;
}
const termProgram = (process.env.TERM_PROGRAM || "").toLowerCase();
const term = (process.env.TERM || "").toLowerCase();
const nerdTerms = ["iterm", "wezterm", "kitty", "ghostty", "alacritty"];
cachedNerdFonts = nerdTerms.some((candidate) => termProgram.includes(candidate) || term.includes(candidate));
return cachedNerdFonts;
}
function normalizeSettings(settings: Settings): Settings {
const merged = deepMergeSettings(DEFAULT_SETTINGS, settings);
const symbolPreset = merged.symbolPreset ?? (hasNerdFonts() ? "nerd" : "unicode");
return {
...merged,
symbolPreset,
bashInterceptor: normalizeBashInterceptorSettings(merged.bashInterceptor),
python: normalizePythonSettings(merged.python),
};
}
function normalizePythonSettings(settings: PythonSettings | undefined): PythonSettings {
const toolMode = settings?.toolMode;
const kernelMode = settings?.kernelMode;
const sharedGateway = settings?.sharedGateway;
return {
toolMode:
toolMode === "ipy-only" || toolMode === "bash-only" || toolMode === "both"
? toolMode
: (DEFAULT_SETTINGS.python?.toolMode ?? "both"),
kernelMode:
kernelMode === "session" || kernelMode === "per-call"
? kernelMode
: (DEFAULT_SETTINGS.python?.kernelMode ?? "session"),
sharedGateway:
typeof sharedGateway === "boolean" ? sharedGateway : (DEFAULT_SETTINGS.python?.sharedGateway ?? true),
};
}
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
function deepMergeSettings(base: Settings, overrides: Settings): Settings {
const result: Settings = { ...base };
for (const key of Object.keys(overrides) as (keyof Settings)[]) {
const overrideValue = overrides[key];
const baseValue = base[key];
if (overrideValue === undefined) {
continue;
}
// For nested objects, merge recursively
if (
typeof overrideValue === "object" &&
overrideValue !== null &&
!Array.isArray(overrideValue) &&
typeof baseValue === "object" &&
baseValue !== null &&
!Array.isArray(baseValue)
) {
(result as Record<string, unknown>)[key] = { ...baseValue, ...overrideValue };
} else {
// For primitives and arrays, override value wins
(result as Record<string, unknown>)[key] = overrideValue;
}
}
return result;
}
export class SettingsManager {
/** SQLite storage for auth/cache (null for in-memory mode) */
private storage: AgentStorage | null;
/** Path to config.yml (null for in-memory mode) */
private configPath: string | null;
private cwd: string | null;
private globalSettings: Settings;
private overrides: Settings;
private settings!: Settings;
private persist: boolean;
/**
* Private constructor - use static factory methods instead.
* @param storage - SQLite storage instance for auth/cache, or null for in-memory mode
* @param configPath - Path to config.yml for persistence, or null for in-memory mode
* @param cwd - Current working directory for project settings discovery
* @param initialSettings - Initial global settings to use
* @param persist - Whether to persist settings changes to storage
* @param projectSettings - Pre-loaded project settings (to avoid async in constructor)
*/
private constructor(
storage: AgentStorage | null,
configPath: string | null,
cwd: string | null,
initialSettings: Settings,
persist: boolean,
projectSettings: Settings,
) {
this.storage = storage;
this.configPath = configPath;
this.cwd = cwd;
this.persist = persist;
this.globalSettings = initialSettings;
this.overrides = {};
this.rebuildSettings(projectSettings);
// Apply environment variables from settings
this.applyEnvironmentVariables();
}
/**
* Apply environment variables from settings to process.env
* Only sets variables that are not already set in the environment
*/
applyEnvironmentVariables(): void {
const envVars = this.settings.env;
if (!envVars || typeof envVars !== "object") {
return;
}
for (const [key, value] of Object.entries(envVars)) {
if (typeof key === "string" && typeof value === "string") {
// Only set if not already present in environment (allow override with env vars)
if (!(key in process.env)) {
process.env[key] = value;
}
}
}
}
/**
* Create a SettingsManager that loads from persistent config.yml.
* @param cwd - Current working directory for project settings discovery
* @param agentDir - Agent directory containing config.yml
* @returns Configured SettingsManager with merged global and user settings
*/
static async create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): Promise<SettingsManager> {
const configPath = join(agentDir, "config.yml");
const storage = await AgentStorage.open(getAgentDbPath(agentDir));
// Migrate from legacy storage if config.yml doesn't exist
await SettingsManager.migrateToYaml(storage, agentDir, configPath);
// Use capability API to load user-level settings from all providers
const result = await loadCapability(settingsCapability.id, { cwd });
// Merge all user-level settings
let globalSettings: Settings = {};
for (const item of result.items as SettingsItem[]) {
if (item.level === "user") {
globalSettings = deepMergeSettings(globalSettings, item.data as Settings);
}
}
// Load persisted settings from config.yml
const storedSettings = SettingsManager.loadFromYaml(configPath);
globalSettings = deepMergeSettings(globalSettings, storedSettings);
// Load project settings before construction (constructor is sync)
const projectSettings = await SettingsManager.loadProjectSettingsStatic(cwd);
return new SettingsManager(storage, configPath, cwd, globalSettings, true, projectSettings);
}
/**
* Create an in-memory SettingsManager without persistence.
* @param settings - Initial settings to use
* @returns SettingsManager that won't persist changes to disk
*/
static inMemory(settings: Partial<Settings> = {}): SettingsManager {
return new SettingsManager(null, null, null, settings, false, {});
}
/**
* Serialize settings for passing to subagent workers.
* Returns the merged settings (global + project + overrides).
*/
serialize(): Settings {
return { ...this.settings };
}
/**
* Access the underlying agent storage (null for in-memory settings).
*/
getStorage(): AgentStorage | null {
return this.storage;
}
/**
* Load settings from config.yml, applying any schema migrations.
* @param configPath - Path to config.yml, or null for in-memory mode
* @returns Parsed and migrated settings, or empty object if file doesn't exist
*/
private static loadFromYaml(configPath: string | null): Settings {
if (!configPath || !existsSync(configPath)) {
return {};
}
try {
const content = readFileSync(configPath, "utf-8");
const parsed = YAML.parse(content);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
return SettingsManager.migrateSettings(parsed as Record<string, unknown>);
} catch (error) {
logger.warn("SettingsManager failed to load config.yml", { path: configPath, error: String(error) });
return {};
}
}
/**
* Migrate settings from legacy sources to config.yml.
* Migration order: settings.json -> agent.db -> config.yml
* Only migrates if config.yml doesn't exist.
*/
private static async migrateToYaml(storage: AgentStorage, agentDir: string, configPath: string): Promise<void> {
// Skip if config.yml already exists
if (existsSync(configPath)) return;
let settings: Settings = {};
let migrated = false;
// 1. Try to migrate from settings.json (oldest legacy format)
const settingsJsonPath = join(agentDir, "settings.json");
try {
const settingsFile = Bun.file(settingsJsonPath);
if (await settingsFile.exists()) {
const parsed = JSON.parse(await settingsFile.text());
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
settings = deepMergeSettings(settings, SettingsManager.migrateSettings(parsed));
migrated = true;
// Backup settings.json
try {
await rename(settingsJsonPath, `${settingsJsonPath}.bak`);
} catch (error) {
logger.warn("SettingsManager failed to backup settings.json", { error: String(error) });
}
}
}
} catch (error) {
logger.warn("SettingsManager failed to read settings.json", { error: String(error) });
}
// 2. Migrate from agent.db settings table
try {
const dbSettings = storage.getSettings();
if (dbSettings) {
settings = deepMergeSettings(
settings,
SettingsManager.migrateSettings(dbSettings as Record<string, unknown>),
);
migrated = true;
}
} catch (error) {
logger.warn("SettingsManager failed to read agent.db settings", { error: String(error) });
}
// 3. Write merged settings to config.yml if we found any
if (migrated && Object.keys(settings).length > 0) {
try {
await Bun.write(configPath, YAML.stringify(settings, null, 2));
logger.debug("SettingsManager migrated settings to config.yml", { path: configPath });
} catch (error) {
logger.warn("SettingsManager failed to write config.yml", { path: configPath, error: String(error) });
}
}
}
/** Migrate old settings format to new format */
private static migrateSettings(settings: Record<string, unknown>): Settings {
// Migrate queueMode -> steeringMode
if ("queueMode" in settings && !("steeringMode" in settings)) {
settings.steeringMode = settings.queueMode;
delete settings.queueMode;
}
return settings as Settings;
}
/**
* Static helper to load project settings (used by create() before construction).
*/
private static async loadProjectSettingsStatic(cwd: string | null): Promise<Settings> {
if (!cwd) return {};
// Use capability API to discover settings from all providers
const result = await loadCapability(settingsCapability.id, { cwd });
// Merge only project-level settings (user-level settings are handled separately via globalSettings)
let merged: Settings = {};
for (const item of result.items as SettingsItem[]) {
if (item.level === "project") {
merged = deepMergeSettings(merged, item.data as Settings);
}
}
return SettingsManager.migrateSettings(merged as Record<string, unknown>);
}
private async loadProjectSettings(): Promise<Settings> {
return SettingsManager.loadProjectSettingsStatic(this.cwd);
}
private rebuildSettings(projectSettings: Settings): void {
this.settings = normalizeSettings(
deepMergeSettings(deepMergeSettings(this.globalSettings, projectSettings), this.overrides),
);
}
/** Apply additional overrides on top of current settings */
async applyOverrides(overrides: Partial<Settings>): Promise<void> {
this.overrides = deepMergeSettings(this.overrides, overrides);
const projectSettings = await this.loadProjectSettings();
this.rebuildSettings(projectSettings);
}
/**
* Persist current global settings to config.yml and rebuild merged settings.
* Uses file locking to prevent concurrent write races.
*/
private async save(): Promise<void> {
if (this.persist && this.configPath) {
const configPath = this.configPath;
try {
await withFileLock(configPath, async () => {
const currentSettings = SettingsManager.loadFromYaml(configPath);
const mergedSettings = deepMergeSettings(currentSettings, this.globalSettings);
this.globalSettings = mergedSettings;
await Bun.write(configPath, YAML.stringify(this.globalSettings, null, 2));
});
} catch (error) {
logger.warn("SettingsManager save failed", { error: String(error) });
}
}
const projectSettings = await this.loadProjectSettings();
this.rebuildSettings(projectSettings);
}
getLastChangelogVersion(): string | undefined {
return this.settings.lastChangelogVersion;
}
async setLastChangelogVersion(version: string): Promise<void> {
this.globalSettings.lastChangelogVersion = version;
await this.save();
}
/**
* Get model for a role. Returns "provider/modelId" string or undefined.
*/
getModelRole(role: string): string | undefined {
return this.settings.modelRoles?.[role];
}
/**
* Set model for a role. Model should be "provider/modelId" format.
*/
async setModelRole(role: string, model: string): Promise<void> {
if (!this.globalSettings.modelRoles) {
this.globalSettings.modelRoles = {};
}
this.globalSettings.modelRoles[role] = model;
if (this.overrides.modelRoles && this.overrides.modelRoles[role] !== undefined) {
this.overrides.modelRoles[role] = model;
}
await this.save();
}
/**
* Get all model roles.
*/
getModelRoles(): Record<string, string> {
return { ...this.settings.modelRoles };
}
getSteeringMode(): "all" | "one-at-a-time" {
return this.settings.steeringMode || "one-at-a-time";
}
async setSteeringMode(mode: "all" | "one-at-a-time"): Promise<void> {
this.globalSettings.steeringMode = mode;
await this.save();
}
getFollowUpMode(): "all" | "one-at-a-time" {
return this.settings.followUpMode || "one-at-a-time";
}
async setFollowUpMode(mode: "all" | "one-at-a-time"): Promise<void> {
this.globalSettings.followUpMode = mode;
await this.save();
}
getInterruptMode(): "immediate" | "wait" {
return this.settings.interruptMode || "immediate";
}
async setInterruptMode(mode: "immediate" | "wait"): Promise<void> {
this.globalSettings.interruptMode = mode;
await this.save();
}
getTheme(): string | undefined {
return this.settings.theme;
}
async setTheme(theme: string): Promise<void> {
this.globalSettings.theme = theme;
await this.save();
}
getSymbolPreset(): SymbolPreset | undefined {
return this.settings.symbolPreset;
}
async setSymbolPreset(preset: SymbolPreset): Promise<void> {
this.globalSettings.symbolPreset = preset;
await this.save();
}
getDefaultThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | undefined {
return this.settings.defaultThinkingLevel;
}
async setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): Promise<void> {
this.globalSettings.defaultThinkingLevel = level;
await this.save();
}
getCompactionEnabled(): boolean {
return this.settings.compaction?.enabled ?? true;
}
async setCompactionEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.compaction) {
this.globalSettings.compaction = {};
}
this.globalSettings.compaction.enabled = enabled;
await this.save();
}
getCompactionReserveTokens(): number {
return this.settings.compaction?.reserveTokens ?? 16384;
}
getCompactionKeepRecentTokens(): number {
return this.settings.compaction?.keepRecentTokens ?? 20000;
}
getCompactionSettings(): { enabled: boolean; reserveTokens: number; keepRecentTokens: number } {
return {
enabled: this.getCompactionEnabled(),
reserveTokens: this.getCompactionReserveTokens(),
keepRecentTokens: this.getCompactionKeepRecentTokens(),
};
}
getBranchSummaryEnabled(): boolean {
return this.settings.branchSummary?.enabled ?? false;
}
async setBranchSummaryEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.branchSummary) {
this.globalSettings.branchSummary = {};
}
this.globalSettings.branchSummary.enabled = enabled;
await this.save();
}
getBranchSummarySettings(): { enabled: boolean; reserveTokens: number } {
return {
enabled: this.getBranchSummaryEnabled(),
reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384,
};
}
getRetryEnabled(): boolean {
return this.settings.retry?.enabled ?? true;
}
async setRetryEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.retry) {
this.globalSettings.retry = {};
}
this.globalSettings.retry.enabled = enabled;
await this.save();
}
getRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number } {
return {
enabled: this.getRetryEnabled(),
maxRetries: this.settings.retry?.maxRetries ?? 3,
baseDelayMs: this.settings.retry?.baseDelayMs ?? 2000,
};
}
getCommitSettings(): Required<CommitSettings> {
return {
mapReduceEnabled: this.settings.commit?.mapReduceEnabled ?? true,
mapReduceMinFiles: this.settings.commit?.mapReduceMinFiles ?? 4,
mapReduceMaxFileTokens: this.settings.commit?.mapReduceMaxFileTokens ?? 50_000,
mapReduceTimeoutMs: this.settings.commit?.mapReduceTimeoutMs ?? 120_000,
mapReduceMaxConcurrency: this.settings.commit?.mapReduceMaxConcurrency ?? 5,
changelogMaxDiffChars: this.settings.commit?.changelogMaxDiffChars ?? 120_000,
};
}
getRetryMaxRetries(): number {
return this.settings.retry?.maxRetries ?? 3;
}
async setRetryMaxRetries(maxRetries: number): Promise<void> {
if (!this.globalSettings.retry) {
this.globalSettings.retry = {};
}
this.globalSettings.retry.maxRetries = maxRetries;
await this.save();
}
getRetryBaseDelayMs(): number {
return this.settings.retry?.baseDelayMs ?? 2000;
}
async setRetryBaseDelayMs(baseDelayMs: number): Promise<void> {
if (!this.globalSettings.retry) {
this.globalSettings.retry = {};
}
this.globalSettings.retry.baseDelayMs = baseDelayMs;
await this.save();
}
getTodoCompletionSettings(): { enabled: boolean; maxReminders: number } {
return {
enabled: this.settings.todoCompletion?.enabled ?? false,
maxReminders: this.settings.todoCompletion?.maxReminders ?? 3,
};
}
getTodoCompletionEnabled(): boolean {
return this.settings.todoCompletion?.enabled ?? false;
}
async setTodoCompletionEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.todoCompletion) {
this.globalSettings.todoCompletion = {};
}
this.globalSettings.todoCompletion.enabled = enabled;
await this.save();
}
getTodoCompletionMaxReminders(): number {
return this.settings.todoCompletion?.maxReminders ?? 3;
}
async setTodoCompletionMaxReminders(maxReminders: number): Promise<void> {
if (!this.globalSettings.todoCompletion) {
this.globalSettings.todoCompletion = {};
}
this.globalSettings.todoCompletion.maxReminders = maxReminders;
await this.save();
}
getThinkingBudgets(): ThinkingBudgetsSettings | undefined {
return this.settings.thinkingBudgets;
}
getHideThinkingBlock(): boolean {
return this.settings.hideThinkingBlock ?? false;
}
async setHideThinkingBlock(hide: boolean): Promise<void> {
this.globalSettings.hideThinkingBlock = hide;
await this.save();
}
getShellPath(): string | undefined {
return this.settings.shellPath;
}
async setShellPath(path: string | undefined): Promise<void> {
this.globalSettings.shellPath = path;
await this.save();
}
getCollapseChangelog(): boolean {
return this.settings.collapseChangelog ?? false;
}
async setCollapseChangelog(collapse: boolean): Promise<void> {
this.globalSettings.collapseChangelog = collapse;
await this.save();
}
getStartupQuiet(): boolean {
return this.settings.startup?.quiet ?? false;
}
async setStartupQuiet(quiet: boolean): Promise<void> {
if (!this.globalSettings.startup) {
this.globalSettings.startup = {};
}
this.globalSettings.startup.quiet = quiet;
await this.save();
}
getExtensionPaths(): string[] {
return [...(this.settings.extensions ?? [])];
}
async setExtensionPaths(paths: string[]): Promise<void> {
this.globalSettings.extensions = paths;
await this.save();
}
getSkillsEnabled(): boolean {
return this.settings.skills?.enabled ?? true;
}
async setSkillsEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.skills) {
this.globalSettings.skills = {};
}
this.globalSettings.skills.enabled = enabled;
await this.save();
}
getSkillsSettings(): Required<SkillsSettings> {
return {
enabled: this.settings.skills?.enabled ?? true,
enableSkillCommands: this.settings.skills?.enableSkillCommands ?? true,
enableCodexUser: this.settings.skills?.enableCodexUser ?? true,
enableClaudeUser: this.settings.skills?.enableClaudeUser ?? true,
enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true,
enablePiUser: this.settings.skills?.enablePiUser ?? true,
enablePiProject: this.settings.skills?.enablePiProject ?? true,
customDirectories: [...(this.settings.skills?.customDirectories ?? [])],
ignoredSkills: [...(this.settings.skills?.ignoredSkills ?? [])],
includeSkills: [...(this.settings.skills?.includeSkills ?? [])],
};
}
getEnableSkillCommands(): boolean {
return this.settings.skills?.enableSkillCommands ?? true;
}
async setEnableSkillCommands(enabled: boolean): Promise<void> {
if (!this.globalSettings.skills) {
this.globalSettings.skills = {};
}
this.globalSettings.skills.enableSkillCommands = enabled;
await this.save();
}
getCommandsSettings(): Required<CommandsSettings> {
return {
enableClaudeUser: this.settings.commands?.enableClaudeUser ?? true,
enableClaudeProject: this.settings.commands?.enableClaudeProject ?? true,
};
}
getCommandsEnableClaudeUser(): boolean {
return this.settings.commands?.enableClaudeUser ?? true;
}
async setCommandsEnableClaudeUser(enabled: boolean): Promise<void> {
if (!this.globalSettings.commands) {
this.globalSettings.commands = {};
}
this.globalSettings.commands.enableClaudeUser = enabled;
await this.save();
}
getCommandsEnableClaudeProject(): boolean {
return this.settings.commands?.enableClaudeProject ?? true;
}
async setCommandsEnableClaudeProject(enabled: boolean): Promise<void> {
if (!this.globalSettings.commands) {
this.globalSettings.commands = {};
}
this.globalSettings.commands.enableClaudeProject = enabled;
await this.save();
}
getShowImages(): boolean {
return this.settings.terminal?.showImages ?? true;
}
async setShowImages(show: boolean): Promise<void> {
if (!this.globalSettings.terminal) {
this.globalSettings.terminal = {};
}
this.globalSettings.terminal.showImages = show;
await this.save();
}
getNotificationOnComplete(): NotificationMethod {
return this.settings.notifications?.onComplete ?? "auto";
}
async setNotificationOnComplete(method: NotificationMethod): Promise<void> {
if (!this.globalSettings.notifications) {
this.globalSettings.notifications = {};
}
this.globalSettings.notifications.onComplete = method;
await this.save();
}
getImageAutoResize(): boolean {
return this.settings.images?.autoResize ?? true;
}
async setImageAutoResize(enabled: boolean): Promise<void> {
if (!this.globalSettings.images) {
this.globalSettings.images = {};
}
this.globalSettings.images.autoResize = enabled;
await this.save();
}
getBlockImages(): boolean {
return this.settings.images?.blockImages ?? false;
}
async setBlockImages(blocked: boolean): Promise<void> {
if (!this.globalSettings.images) {
this.globalSettings.images = {};
}
this.globalSettings.images.blockImages = blocked;
await this.save();
}
getEnabledModels(): string[] | undefined {
return this.settings.enabledModels;
}
getExaSettings(): Required<ExaSettings> {
return {
enabled: this.settings.exa?.enabled ?? true,
enableSearch: this.settings.exa?.enableSearch ?? true,
enableLinkedin: this.settings.exa?.enableLinkedin ?? false,
enableCompany: this.settings.exa?.enableCompany ?? false,
enableResearcher: this.settings.exa?.enableResearcher ?? false,
enableWebsets: this.settings.exa?.enableWebsets ?? false,
};
}
async setExaEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.exa) {
this.globalSettings.exa = {};
}
this.globalSettings.exa.enabled = enabled;
await this.save();
}
async setExaSearchEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.exa) {
this.globalSettings.exa = {};
}
this.globalSettings.exa.enableSearch = enabled;
await this.save();
}
async setExaLinkedinEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.exa) {
this.globalSettings.exa = {};
}
this.globalSettings.exa.enableLinkedin = enabled;
await this.save();
}
async setExaCompanyEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.exa) {
this.globalSettings.exa = {};
}
this.globalSettings.exa.enableCompany = enabled;
await this.save();
}
async setExaResearcherEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.exa) {
this.globalSettings.exa = {};
}
this.globalSettings.exa.enableResearcher = enabled;
await this.save();
}
async setExaWebsetsEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.exa) {
this.globalSettings.exa = {};
}
this.globalSettings.exa.enableWebsets = enabled;
await this.save();
}
// Provider settings
getWebSearchProvider(): WebSearchProviderOption {
return this.settings.providers?.webSearch ?? "auto";
}
async setWebSearchProvider(provider: WebSearchProviderOption): Promise<void> {
if (!this.globalSettings.providers) {
this.globalSettings.providers = {};
}
this.globalSettings.providers.webSearch = provider;
await this.save();
}
getImageProvider(): ImageProviderOption {
return this.settings.providers?.image ?? "auto";
}
async setImageProvider(provider: ImageProviderOption): Promise<void> {
if (!this.globalSettings.providers) {
this.globalSettings.providers = {};
}
this.globalSettings.providers.image = provider;
await this.save();
}
getBashInterceptorEnabled(): boolean {
return this.settings.bashInterceptor?.enabled ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.enabled;
}
getBashInterceptorSimpleLsEnabled(): boolean {
return this.settings.bashInterceptor?.simpleLs ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.simpleLs;
}
getBashInterceptorRules(): BashInterceptorRule[] {
return [...(this.settings.bashInterceptor?.patterns ?? DEFAULT_BASH_INTERCEPTOR_RULES)];
}
async setBashInterceptorEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.bashInterceptor) {
this.globalSettings.bashInterceptor = {};
}
this.globalSettings.bashInterceptor.enabled = enabled;
await this.save();
}
async setBashInterceptorSimpleLsEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.bashInterceptor) {
this.globalSettings.bashInterceptor = {};
}
this.globalSettings.bashInterceptor.simpleLs = enabled;
await this.save();
}
getPythonToolMode(): PythonToolMode {
return this.settings.python?.toolMode ?? "both";
}
async setPythonToolMode(mode: PythonToolMode): Promise<void> {
if (!this.globalSettings.python) {
this.globalSettings.python = {};
}
this.globalSettings.python.toolMode = mode;
await this.save();
}
getPythonKernelMode(): PythonKernelMode {
return this.settings.python?.kernelMode ?? "session";
}
async setPythonKernelMode(mode: PythonKernelMode): Promise<void> {
if (!this.globalSettings.python) {
this.globalSettings.python = {};
}
this.globalSettings.python.kernelMode = mode;
await this.save();
}
getPythonSharedGateway(): boolean {
return this.settings.python?.sharedGateway ?? true;
}
async setPythonSharedGateway(enabled: boolean): Promise<void> {
if (!this.globalSettings.python) {
this.globalSettings.python = {};
}
this.globalSettings.python.sharedGateway = enabled;
await this.save();
}
getMCPProjectConfigEnabled(): boolean {
return this.settings.mcp?.enableProjectConfig ?? true;
}
async setMCPProjectConfigEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.mcp) {
this.globalSettings.mcp = {};
}
this.globalSettings.mcp.enableProjectConfig = enabled;
await this.save();
}
getLspFormatOnWrite(): boolean {
return this.settings.lsp?.formatOnWrite ?? false;
}
async setLspFormatOnWrite(enabled: boolean): Promise<void> {
if (!this.globalSettings.lsp) {
this.globalSettings.lsp = {};
}
this.globalSettings.lsp.formatOnWrite = enabled;
await this.save();
}
getLspDiagnosticsOnWrite(): boolean {
return this.settings.lsp?.diagnosticsOnWrite ?? true;
}
async setLspDiagnosticsOnWrite(enabled: boolean): Promise<void> {
if (!this.globalSettings.lsp) {
this.globalSettings.lsp = {};
}
this.globalSettings.lsp.diagnosticsOnWrite = enabled;
await this.save();
}
getLspDiagnosticsOnEdit(): boolean {
return this.settings.lsp?.diagnosticsOnEdit ?? false;
}
async setLspDiagnosticsOnEdit(enabled: boolean): Promise<void> {
if (!this.globalSettings.lsp) {
this.globalSettings.lsp = {};
}
this.globalSettings.lsp.diagnosticsOnEdit = enabled;
await this.save();
}
getEditFuzzyMatch(): boolean {
return this.settings.edit?.fuzzyMatch ?? true;
}
async setEditFuzzyMatch(enabled: boolean): Promise<void> {
if (!this.globalSettings.edit) {
this.globalSettings.edit = {};
}
this.globalSettings.edit.fuzzyMatch = enabled;
await this.save();
}
getEditFuzzyThreshold(): number {
return this.settings.edit?.fuzzyThreshold ?? 0.95;
}
async setEditFuzzyThreshold(value: number): Promise<void> {
if (!this.globalSettings.edit) {
this.globalSettings.edit = {};
}
this.globalSettings.edit.fuzzyThreshold = value;
await this.save();
}
getEditPatchMode(): boolean {
return this.settings.edit?.patchMode ?? true;
}
async setEditPatchMode(enabled: boolean): Promise<void> {
if (!this.globalSettings.edit) {
this.globalSettings.edit = {};
}
this.globalSettings.edit.patchMode = enabled;
await this.save();
}
getEditStreamingAbort(): boolean {
return this.settings.edit?.streamingAbort ?? false;
}
async setEditStreamingAbort(enabled: boolean): Promise<void> {
if (!this.globalSettings.edit) {
this.globalSettings.edit = {};
}
this.globalSettings.edit.streamingAbort = enabled;
await this.save();
}
getNormativeRewrite(): boolean {
return this.settings.normativeRewrite ?? false;
}
async setNormativeRewrite(enabled: boolean): Promise<void> {
this.globalSettings.normativeRewrite = enabled;
await this.save();
}
getReadLineNumbers(): boolean {
return this.settings.readLineNumbers ?? false;
}
async setReadLineNumbers(enabled: boolean): Promise<void> {
this.globalSettings.readLineNumbers = enabled;
await this.save();
}
getDisabledProviders(): string[] {
return [...(this.settings.disabledProviders ?? [])];
}
async setDisabledProviders(providerIds: string[]): Promise<void> {
this.globalSettings.disabledProviders = providerIds;
await this.save();
}
getDisabledExtensions(): string[] {
return [...(this.settings.disabledExtensions ?? [])];
}
async setDisabledExtensions(extensionIds: string[]): Promise<void> {
this.globalSettings.disabledExtensions = extensionIds;
await this.save();
}
isExtensionEnabled(extensionId: string): boolean {
return !(this.settings.disabledExtensions ?? []).includes(extensionId);
}
async enableExtension(extensionId: string): Promise<void> {
const disabled = this.globalSettings.disabledExtensions ?? [];
const index = disabled.indexOf(extensionId);
if (index !== -1) {
disabled.splice(index, 1);
this.globalSettings.disabledExtensions = disabled;
await this.save();
}
}
async disableExtension(extensionId: string): Promise<void> {
const disabled = this.globalSettings.disabledExtensions ?? [];
if (!disabled.includes(extensionId)) {
disabled.push(extensionId);
this.globalSettings.disabledExtensions = disabled;
await this.save();
}
}
getTtsrSettings(): TtsrSettings {
return this.settings.ttsr ?? {};
}
async setTtsrSettings(settings: TtsrSettings): Promise<void> {
this.globalSettings.ttsr = { ...this.globalSettings.ttsr, ...settings };
await this.save();
}
getTtsrEnabled(): boolean {
return this.settings.ttsr?.enabled ?? true;
}
async setTtsrEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.ttsr) {
this.globalSettings.ttsr = {};
}
this.globalSettings.ttsr.enabled = enabled;
await this.save();
}
getTtsrContextMode(): "keep" | "discard" {
return this.settings.ttsr?.contextMode ?? "discard";
}
async setTtsrContextMode(mode: "keep" | "discard"): Promise<void> {
if (!this.globalSettings.ttsr) {
this.globalSettings.ttsr = {};
}
this.globalSettings.ttsr.contextMode = mode;
await this.save();
}
getTtsrRepeatMode(): "once" | "after-gap" {
return this.settings.ttsr?.repeatMode ?? "once";
}
async setTtsrRepeatMode(mode: "once" | "after-gap"): Promise<void> {
if (!this.globalSettings.ttsr) {
this.globalSettings.ttsr = {};
}
this.globalSettings.ttsr.repeatMode = mode;
await this.save();
}
getTtsrRepeatGap(): number {
return this.settings.ttsr?.repeatGap ?? 10;
}
async setTtsrRepeatGap(gap: number): Promise<void> {
if (!this.globalSettings.ttsr) {
this.globalSettings.ttsr = {};
}
this.globalSettings.ttsr.repeatGap = gap;
await this.save();
}
// ═══════════════════════════════════════════════════════════════════════════
// Status Line Settings
// ═══════════════════════════════════════════════════════════════════════════
getStatusLineSettings(): StatusLineSettings {
return this.settings.statusLine ? { ...this.settings.statusLine } : {};
}
getStatusLinePreset(): StatusLinePreset {
return this.settings.statusLine?.preset ?? "default";
}
async setStatusLinePreset(preset: StatusLinePreset): Promise<void> {
if (!this.globalSettings.statusLine) {
this.globalSettings.statusLine = {};
}
if (preset !== "custom") {
delete this.globalSettings.statusLine.leftSegments;
delete this.globalSettings.statusLine.rightSegments;
delete this.globalSettings.statusLine.segmentOptions;
}
this.globalSettings.statusLine.preset = preset;
await this.save();
}
getStatusLineSeparator(): StatusLineSeparatorStyle {
return this.settings.statusLine?.separator ?? "powerline-thin";
}
async setStatusLineSeparator(separator: StatusLineSeparatorStyle): Promise<void> {
if (!this.globalSettings.statusLine) {
this.globalSettings.statusLine = {};
}
this.globalSettings.statusLine.separator = separator;
await this.save();
}
getStatusLineLeftSegments(): StatusLineSegmentId[] {
return [...(this.settings.statusLine?.leftSegments ?? [])];
}
async setStatusLineLeftSegments(segments: StatusLineSegmentId[]): Promise<void> {
if (!this.globalSettings.statusLine) {
this.globalSettings.statusLine = {};
}
this.globalSettings.statusLine.leftSegments = segments;
// Setting segments explicitly implies custom preset
if (this.globalSettings.statusLine.preset !== "custom") {
this.globalSettings.statusLine.preset = "custom";
}
await this.save();
}
getStatusLineRightSegments(): StatusLineSegmentId[] {
return [...(this.settings.statusLine?.rightSegments ?? [])];
}
async setStatusLineRightSegments(segments: StatusLineSegmentId[]): Promise<void> {
if (!this.globalSettings.statusLine) {
this.globalSettings.statusLine = {};
}
this.globalSettings.statusLine.rightSegments = segments;
// Setting segments explicitly implies custom preset
if (this.globalSettings.statusLine.preset !== "custom") {
this.globalSettings.statusLine.preset = "custom";
}
await this.save();
}
getStatusLineSegmentOptions(): StatusLineSegmentOptions {
return { ...this.settings.statusLine?.segmentOptions };
}
async setStatusLineSegmentOption<K extends keyof StatusLineSegmentOptions>(
segment: K,
option: keyof NonNullable<StatusLineSegmentOptions[K]>,
value: boolean | number | string,
): Promise<void> {
if (!this.globalSettings.statusLine) {
this.globalSettings.statusLine = {};
}
if (!this.globalSettings.statusLine.segmentOptions) {
this.globalSettings.statusLine.segmentOptions = {};
}
if (!this.globalSettings.statusLine.segmentOptions[segment]) {
this.globalSettings.statusLine.segmentOptions[segment] = {} as NonNullable<StatusLineSegmentOptions[K]>;
}
(this.globalSettings.statusLine.segmentOptions[segment] as Record<string, unknown>)[option as string] = value;
await this.save();
}
async clearStatusLineSegmentOption<K extends keyof StatusLineSegmentOptions>(
segment: K,
option: keyof NonNullable<StatusLineSegmentOptions[K]>,
): Promise<void> {
const segmentOptions = this.globalSettings.statusLine?.segmentOptions;
if (!segmentOptions || !segmentOptions[segment]) {
return;
}
delete (segmentOptions[segment] as Record<string, unknown>)[option as string];
if (Object.keys(segmentOptions[segment] as Record<string, unknown>).length === 0) {
delete segmentOptions[segment];
}
if (Object.keys(segmentOptions).length === 0) {
delete this.globalSettings.statusLine?.segmentOptions;
}
await this.save();
}
getStatusLineShowHookStatus(): boolean {
return this.settings.statusLine?.showHookStatus ?? true;
}
async setStatusLineShowHookStatus(show: boolean): Promise<void> {
if (!this.globalSettings.statusLine) {
this.globalSettings.statusLine = {};
}
this.globalSettings.statusLine.showHookStatus = show;
await this.save();
}
getDoubleEscapeAction(): "branch" | "tree" {
return this.settings.doubleEscapeAction ?? "tree";
}
async setDoubleEscapeAction(action: "branch" | "tree"): Promise<void> {
this.globalSettings.doubleEscapeAction = action;
await this.save();
}
getShowHardwareCursor(): boolean {
// Check settings first
if (this.settings.showHardwareCursor !== undefined) {
return this.settings.showHardwareCursor;
}
// Check env var override
const envVar = process.env.OMP_HARDWARE_CURSOR?.toLowerCase();
if (envVar === "0" || envVar === "false") return false;
if (envVar === "1" || envVar === "true") return true;
// Default to true on Linux/macOS for IME support
return process.platform === "linux" || process.platform === "darwin";
}
async setShowHardwareCursor(show: boolean): Promise<void> {
this.globalSettings.showHardwareCursor = show;
await this.save();
}
/**
* Get environment variables from settings
*/
getEnvironmentVariables(): Record<string, string> {
return { ...(this.settings.env ?? {}) };
}
/**
* Set environment variables in settings (not process.env)
* This will be applied on next startup or reload
*/
async setEnvironmentVariables(envVars: Record<string, string>): Promise<void> {
this.globalSettings.env = { ...envVars };
await this.save();
}
/**
* Clear all environment variables from settings
*/
async clearEnvironmentVariables(): Promise<void> {
delete this.globalSettings.env;
await this.save();
}
/**
* Set a single environment variable in settings
*/
async setEnvironmentVariable(key: string, value: string): Promise<void> {
if (!this.globalSettings.env) {
this.globalSettings.env = {};
}
this.globalSettings.env[key] = value;
await this.save();
}
/**
* Remove a single environment variable from settings
*/
async removeEnvironmentVariable(key: string): Promise<void> {
if (this.globalSettings.env) {
delete this.globalSettings.env[key];
await this.save();
}
}
}