feat(coding-agent): add "none" option to doubleEscapeAction setting

Allows disabling double-escape behavior entirely for users who
accidentally trigger the tree/fork selector.

Fixes #973
This commit is contained in:
Mario Zechner 2026-01-30 01:20:14 +01:00
parent 0587f045d9
commit cb08758696
4 changed files with 51 additions and 25 deletions

View file

@ -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

View file

@ -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<keyof Settings>(); // Track fields modified during session
private modifiedNestedFields = new Map<keyof Settings, Set<string>>(); // 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();

View file

@ -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",

View file

@ -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;
}
}
};