diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5d70832a..3a26e6cf 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,11 +2,16 @@ ## [Unreleased] +### Added + +- Added "none" option to `doubleEscapeAction` setting to disable double-escape behavior entirely ([#973](https://github.com/badlogic/pi-mono/issues/973) by [@juanibiapina](https://github.com/juanibiapina)) + ### Fixed - Read tool now handles macOS filenames with curly quotes (U+2019) and NFD Unicode normalization ([#1078](https://github.com/badlogic/pi-mono/issues/1078)) - Respect .gitignore, .ignore, and .fdignore files when scanning package resources for skills, prompts, themes, and extensions ([#1072](https://github.com/badlogic/pi-mono/issues/1072)) - Fixed tool call argument defaults when providers omit inputs ([#1065](https://github.com/badlogic/pi-mono/issues/1065)) +- Invalid JSON in settings.json no longer causes the file to be overwritten with empty settings ([#1054](https://github.com/badlogic/pi-mono/issues/1054)) ## [0.50.3] - 2026-01-29 diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index c82ef528..af9aa791 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -78,7 +78,7 @@ export interface Settings { terminal?: TerminalSettings; images?: ImageSettings; enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) - doubleEscapeAction?: "fork" | "tree"; // Action for double-escape with empty editor (default: "tree") + doubleEscapeAction?: "fork" | "tree" | "none"; // Action for double-escape with empty editor (default: "tree") thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels editorPaddingX?: number; // Horizontal padding for input editor (default: 0) autocompleteMaxVisible?: number; // Max visible items in autocomplete dropdown (default: 5) @@ -126,18 +126,21 @@ export class SettingsManager { private persist: boolean; private modifiedFields = new Set(); // Track fields modified during session private modifiedNestedFields = new Map>(); // Track nested field modifications + private globalSettingsLoadError: Error | null = null; // Track if settings file had parse errors private constructor( settingsPath: string | null, projectSettingsPath: string | null, initialSettings: Settings, persist: boolean, + loadError: Error | null = null, ) { this.settingsPath = settingsPath; this.projectSettingsPath = projectSettingsPath; this.persist = persist; this.globalSettings = initialSettings; this.inMemoryProjectSettings = {}; + this.globalSettingsLoadError = loadError; const projectSettings = this.loadProjectSettings(); this.settings = deepMergeSettings(this.globalSettings, projectSettings); } @@ -146,8 +149,19 @@ export class SettingsManager { static create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): SettingsManager { const settingsPath = join(agentDir, "settings.json"); const projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json"); - const globalSettings = SettingsManager.loadFromFile(settingsPath); - return new SettingsManager(settingsPath, projectSettingsPath, globalSettings, true); + + let globalSettings: Settings = {}; + let loadError: Error | null = null; + + try { + globalSettings = SettingsManager.loadFromFile(settingsPath); + } catch (error) { + loadError = error as Error; + console.error(`Warning: Invalid JSON in ${settingsPath}: ${error}`); + console.error(`Fix the syntax error to enable settings persistence.`); + } + + return new SettingsManager(settingsPath, projectSettingsPath, globalSettings, true, loadError); } /** Create an in-memory SettingsManager (no file I/O) */ @@ -159,14 +173,9 @@ export class SettingsManager { if (!existsSync(path)) { return {}; } - try { - const content = readFileSync(path, "utf-8"); - const settings = JSON.parse(content); - return SettingsManager.migrateSettings(settings); - } catch (error) { - console.error(`Warning: Could not read settings file ${path}: ${error}`); - return {}; - } + const content = readFileSync(path, "utf-8"); + const settings = JSON.parse(content); + return SettingsManager.migrateSettings(settings); } /** Migrate old settings format to new format */ @@ -247,6 +256,14 @@ export class SettingsManager { private save(): void { if (this.persist && this.settingsPath) { + // Don't overwrite if the file had parse errors on initial load + if (this.globalSettingsLoadError) { + // Re-merge to update active settings even though we can't persist + const projectSettings = this.loadProjectSettings(); + this.settings = deepMergeSettings(this.globalSettings, projectSettings); + return; + } + try { const dir = dirname(this.settingsPath); if (!existsSync(dir)) { @@ -282,6 +299,7 @@ export class SettingsManager { this.globalSettings = mergedSettings; writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8"); } catch (error) { + // File may have been externally modified with invalid JSON - don't overwrite console.error(`Warning: Could not save settings file: ${error}`); } } @@ -644,11 +662,11 @@ export class SettingsManager { this.save(); } - getDoubleEscapeAction(): "fork" | "tree" { + getDoubleEscapeAction(): "fork" | "tree" | "none" { return this.settings.doubleEscapeAction ?? "tree"; } - setDoubleEscapeAction(action: "fork" | "tree"): void { + setDoubleEscapeAction(action: "fork" | "tree" | "none"): void { this.globalSettings.doubleEscapeAction = action; this.markModified("doubleEscapeAction"); this.save(); diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index a79a7d48..9ba96d7d 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -35,7 +35,7 @@ export interface SettingsConfig { availableThemes: string[]; hideThinkingBlock: boolean; collapseChangelog: boolean; - doubleEscapeAction: "fork" | "tree"; + doubleEscapeAction: "fork" | "tree" | "none"; showHardwareCursor: boolean; editorPaddingX: number; autocompleteMaxVisible: number; @@ -55,7 +55,7 @@ export interface SettingsCallbacks { onThemePreview?: (theme: string) => void; onHideThinkingBlockChange: (hidden: boolean) => void; onCollapseChangelogChange: (collapsed: boolean) => void; - onDoubleEscapeActionChange: (action: "fork" | "tree") => void; + onDoubleEscapeActionChange: (action: "fork" | "tree" | "none") => void; onShowHardwareCursorChange: (enabled: boolean) => void; onEditorPaddingXChange: (padding: number) => void; onAutocompleteMaxVisibleChange: (maxVisible: number) => void; @@ -186,7 +186,7 @@ export class SettingsSelectorComponent extends Container { label: "Double-escape action", description: "Action when pressing Escape twice with empty editor", currentValue: config.doubleEscapeAction, - values: ["tree", "fork"], + values: ["tree", "fork", "none"], }, { id: "thinking", diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index cb6f7bc4..f788c9c6 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1740,17 +1740,20 @@ export class InteractiveMode { this.isBashMode = false; this.updateEditorBorderColor(); } else if (!this.editor.getText().trim()) { - // Double-escape with empty editor triggers /tree or /fork based on setting - const now = Date.now(); - if (now - this.lastEscapeTime < 500) { - if (this.settingsManager.getDoubleEscapeAction() === "tree") { - this.showTreeSelector(); + // Double-escape with empty editor triggers /tree, /fork, or nothing based on setting + const action = this.settingsManager.getDoubleEscapeAction(); + if (action !== "none") { + const now = Date.now(); + if (now - this.lastEscapeTime < 500) { + if (action === "tree") { + this.showTreeSelector(); + } else { + this.showUserMessageSelector(); + } + this.lastEscapeTime = 0; } else { - this.showUserMessageSelector(); + this.lastEscapeTime = now; } - this.lastEscapeTime = 0; - } else { - this.lastEscapeTime = now; } } };