Skip to content

Commit e4b857b

Browse files
committed
fix(settings): restore experimental tui hotkeys
1 parent b918aac commit e4b857b

File tree

6 files changed

+208
-63
lines changed

6 files changed

+208
-63
lines changed

docs/features.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ User-facing capability map for `codex-multi-auth`.
5454
| Quick switch and search hotkeys | Faster navigation in the dashboard |
5555
| Account action hotkeys | Per-account set, refresh, toggle, and delete shortcuts |
5656
| In-dashboard settings hub | Runtime and display tuning without editing files directly |
57+
| Experimental settings hotkeys | Keyboard shortcuts for sync preview, backup export, and refresh-guard tuning |
5758
| Browser-first OAuth with manual fallback | Works in normal and constrained terminal environments |
5859

5960
---

docs/reference/settings.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,17 @@ Experimental currently hosts:
7474
- named local pool backup export with filename prompt
7575
- refresh guard controls (`proactiveRefreshGuardian`, `proactiveRefreshIntervalMs`)
7676

77+
Experimental TUI shortcuts:
78+
79+
- `1` sync preview
80+
- `2` named backup export
81+
- `3` toggle refresh guard
82+
- `[` or `-` decrease refresh interval
83+
- `]` or `+` increase refresh interval
84+
- `S` save and return
85+
- `Q` back
86+
- sync review also supports `A` apply
87+
7788
Sync behavior:
7889

7990
- preview is always shown before apply

lib/codex-manager/settings-hub.ts

Lines changed: 72 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import type { PluginConfig } from "../types.js";
2828
import { ANSI } from "../ui/ansi.js";
2929
import { UI_COPY } from "../ui/copy.js";
3030
import { getUiRuntimeOptions, setUiRuntimeOptions } from "../ui/runtime.js";
31-
import { type MenuItem, select } from "../ui/select.js";
31+
import { type MenuItem, select, type SelectOptions } from "../ui/select.js";
3232
import { getUnifiedSettingsPath } from "../unified-settings.js";
3333
import { sleep } from "../utils.js";
3434

@@ -281,6 +281,42 @@ type ExperimentalSettingsAction =
281281
| { type: "save" }
282282
| { type: "back" };
283283

284+
function getExperimentalSelectOptions(
285+
ui: ReturnType<typeof getUiRuntimeOptions>,
286+
help: string,
287+
onInput?: SelectOptions<ExperimentalSettingsAction>["onInput"],
288+
): SelectOptions<ExperimentalSettingsAction> {
289+
return {
290+
message: UI_COPY.settings.experimentalTitle,
291+
subtitle: UI_COPY.settings.experimentalSubtitle,
292+
help,
293+
clearScreen: true,
294+
theme: ui.theme,
295+
selectedEmphasis: "minimal",
296+
onInput,
297+
};
298+
}
299+
300+
function mapExperimentalMenuHotkey(
301+
raw: string,
302+
): ExperimentalSettingsAction | undefined {
303+
const lower = raw.toLowerCase();
304+
if (lower === "q") return { type: "back" };
305+
if (lower === "s") return { type: "save" };
306+
if (raw === "1") return { type: "sync" };
307+
if (raw === "2") return { type: "backup" };
308+
if (raw === "3") return { type: "toggle-refresh-guardian" };
309+
if (raw === "[" || raw === "-") return { type: "decrease-refresh-interval" };
310+
if (raw === "]" || raw === "+") return { type: "increase-refresh-interval" };
311+
return undefined;
312+
}
313+
314+
function mapExperimentalStatusHotkey(
315+
raw: string,
316+
): ExperimentalSettingsAction | undefined {
317+
return raw.toLowerCase() === "q" ? { type: "back" } : undefined;
318+
}
319+
284320
const BACKEND_TOGGLE_OPTIONS: BackendToggleSettingOption[] = [
285321
{
286322
key: "liveAccountSync",
@@ -2573,16 +2609,11 @@ async function promptExperimentalSettings(
25732609
color: "red",
25742610
},
25752611
],
2576-
{
2577-
message: UI_COPY.settings.experimentalTitle,
2578-
subtitle: UI_COPY.settings.experimentalSubtitle,
2579-
help: UI_COPY.settings.experimentalHelpMenu,
2580-
clearScreen: true,
2581-
theme: ui.theme,
2582-
selectedEmphasis: "minimal",
2583-
onInput: (raw) =>
2584-
raw.toLowerCase() === "q" ? { type: "back" } : undefined,
2585-
},
2612+
getExperimentalSelectOptions(
2613+
ui,
2614+
UI_COPY.settings.experimentalHelpMenu,
2615+
mapExperimentalMenuHotkey,
2616+
),
25862617
);
25872618
if (!action || action.type === "back") return null;
25882619
if (action.type === "save") return draft;
@@ -2647,14 +2678,11 @@ async function promptExperimentalSettings(
26472678
color: "red",
26482679
},
26492680
],
2650-
{
2651-
message: UI_COPY.settings.experimentalTitle,
2652-
subtitle: UI_COPY.settings.experimentalSubtitle,
2653-
help: UI_COPY.settings.experimentalHelpStatus,
2654-
clearScreen: true,
2655-
theme: ui.theme,
2656-
selectedEmphasis: "minimal",
2657-
},
2681+
getExperimentalSelectOptions(
2682+
ui,
2683+
UI_COPY.settings.experimentalHelpStatus,
2684+
mapExperimentalStatusHotkey,
2685+
),
26582686
);
26592687
} catch (error) {
26602688
const message =
@@ -2674,14 +2702,11 @@ async function promptExperimentalSettings(
26742702
color: "red",
26752703
},
26762704
],
2677-
{
2678-
message: UI_COPY.settings.experimentalTitle,
2679-
subtitle: UI_COPY.settings.experimentalSubtitle,
2680-
help: UI_COPY.settings.experimentalHelpStatus,
2681-
clearScreen: true,
2682-
theme: ui.theme,
2683-
selectedEmphasis: "minimal",
2684-
},
2705+
getExperimentalSelectOptions(
2706+
ui,
2707+
UI_COPY.settings.experimentalHelpStatus,
2708+
mapExperimentalStatusHotkey,
2709+
),
26852710
);
26862711
}
26872712
} finally {
@@ -2708,14 +2733,11 @@ async function promptExperimentalSettings(
27082733
color: "red",
27092734
},
27102735
],
2711-
{
2712-
message: UI_COPY.settings.experimentalTitle,
2713-
subtitle: UI_COPY.settings.experimentalSubtitle,
2714-
help: UI_COPY.settings.experimentalHelpStatus,
2715-
clearScreen: true,
2716-
theme: ui.theme,
2717-
selectedEmphasis: "minimal",
2718-
},
2736+
getExperimentalSelectOptions(
2737+
ui,
2738+
UI_COPY.settings.experimentalHelpStatus,
2739+
mapExperimentalStatusHotkey,
2740+
),
27192741
);
27202742
continue;
27212743
}
@@ -2747,14 +2769,11 @@ async function promptExperimentalSettings(
27472769
color: "red",
27482770
},
27492771
],
2750-
{
2751-
message: UI_COPY.settings.experimentalTitle,
2752-
subtitle: UI_COPY.settings.experimentalSubtitle,
2753-
help: UI_COPY.settings.experimentalHelpStatus,
2754-
clearScreen: true,
2755-
theme: ui.theme,
2756-
selectedEmphasis: "minimal",
2757-
},
2772+
getExperimentalSelectOptions(
2773+
ui,
2774+
UI_COPY.settings.experimentalHelpStatus,
2775+
mapExperimentalStatusHotkey,
2776+
),
27582777
);
27592778
continue;
27602779
}
@@ -2793,20 +2812,16 @@ async function promptExperimentalSettings(
27932812
color: "red",
27942813
},
27952814
],
2796-
{
2797-
message: UI_COPY.settings.experimentalTitle,
2798-
subtitle: UI_COPY.settings.experimentalSubtitle,
2799-
help: UI_COPY.settings.experimentalHelpStatus,
2800-
clearScreen: true,
2801-
theme: ui.theme,
2802-
selectedEmphasis: "minimal",
2803-
onInput: (raw) => {
2815+
getExperimentalSelectOptions(
2816+
ui,
2817+
UI_COPY.settings.experimentalHelpPreview,
2818+
(raw) => {
28042819
const lower = raw.toLowerCase();
28052820
if (lower === "q") return { type: "back" };
28062821
if (lower === "a") return { type: "apply" };
28072822
return undefined;
28082823
},
2809-
},
2824+
),
28102825
);
28112826
if (!review || review.type === "back") continue;
28122827

@@ -2837,14 +2852,11 @@ async function promptExperimentalSettings(
28372852
},
28382853
{ label: UI_COPY.settings.back, value: { type: "back" }, color: "red" },
28392854
],
2840-
{
2841-
message: UI_COPY.settings.experimentalTitle,
2842-
subtitle: UI_COPY.settings.experimentalSubtitle,
2843-
help: UI_COPY.settings.experimentalHelpStatus,
2844-
clearScreen: true,
2845-
theme: ui.theme,
2846-
selectedEmphasis: "minimal",
2847-
},
2855+
getExperimentalSelectOptions(
2856+
ui,
2857+
UI_COPY.settings.experimentalHelpStatus,
2858+
mapExperimentalStatusHotkey,
2859+
),
28482860
);
28492861
}
28502862
}

lib/ui/copy.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,9 @@ export const UI_COPY = {
6969
experimental: "Experimental",
7070
experimentalTitle: "Experimental",
7171
experimentalSubtitle: "Preview sync and backup actions before they become stable",
72-
experimentalHelpMenu: "Enter Select | Q Back",
73-
experimentalHelpPreview: "A Apply | Q Back",
72+
experimentalHelpMenu:
73+
"Enter Select | 1 Sync | 2 Backup | 3 Guard | [ - Down | ] + Up | S Save | Q Back",
74+
experimentalHelpPreview: "Enter Select | A Apply | Q Back",
7475
experimentalHelpStatus: "Enter Select | Q Back",
7576
experimentalSync: "Sync Accounts to oc-chatgpt-multi-auth",
7677
experimentalApplySync: "Apply Sync",

test/codex-manager-cli.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,24 @@ function triggerSettingsHotkey(
365365
}) ?? fallback;
366366
}
367367

368+
function requireSettingsHotkey(
369+
raw: string,
370+
expected: Record<string, unknown>,
371+
inputState: Partial<SettingsSelectInputState> = {},
372+
): SettingsSelectSequenceStep {
373+
return (options) => {
374+
expect(options?.onInput).toBeTypeOf("function");
375+
const result =
376+
options?.onInput?.(raw, {
377+
cursor: inputState.cursor ?? 0,
378+
items: inputState.items ?? [],
379+
requestRerender: inputState.requestRerender ?? (() => undefined),
380+
}) ?? null;
381+
expect(result).toEqual(expected);
382+
return result;
383+
};
384+
}
385+
368386
function createSettingsCancelSequence(
369387
panel: SettingsPanel,
370388
): readonly SettingsSelectSequenceStep[] {
@@ -4582,6 +4600,69 @@ describe("codex manager cli commands", () => {
45824600
expect(applyOcChatgptSyncMock).not.toHaveBeenCalled();
45834601
});
45844602

4603+
it("supports experimental submenu hotkeys for guardian controls", async () => {
4604+
const now = Date.now();
4605+
setupInteractiveSettingsLogin(createSettingsStorage(now));
4606+
const configModule = await import("../lib/config.js");
4607+
const defaults = configModule.getDefaultPluginConfig();
4608+
loadPluginConfigMock.mockReturnValue(structuredClone(defaults));
4609+
const selectSequence = queueSettingsSelectSequence([
4610+
{ type: "experimental" },
4611+
requireSettingsHotkey("3", { type: "toggle-refresh-guardian" }),
4612+
requireSettingsHotkey("]", { type: "increase-refresh-interval" }),
4613+
requireSettingsHotkey("s", { type: "save" }),
4614+
{ type: "back" },
4615+
]);
4616+
4617+
const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
4618+
const exitCode = await runCodexMultiAuthCli(["auth", "login"]);
4619+
4620+
expect(exitCode).toBe(0);
4621+
expect(selectSequence.remaining()).toBe(0);
4622+
expect(savePluginConfigMock).toHaveBeenCalledWith(
4623+
expect.objectContaining({
4624+
proactiveRefreshGuardian: !(defaults.proactiveRefreshGuardian ?? false),
4625+
proactiveRefreshIntervalMs:
4626+
(defaults.proactiveRefreshIntervalMs ?? 60000) + 60000,
4627+
}),
4628+
);
4629+
});
4630+
4631+
it("supports q hotkey on experimental sync status screens", async () => {
4632+
const now = Date.now();
4633+
setupInteractiveSettingsLogin(createSettingsStorage(now));
4634+
detectOcChatgptMultiAuthTargetMock.mockReturnValue({
4635+
kind: "target",
4636+
descriptor: {
4637+
scope: "global",
4638+
root: "C:/target",
4639+
accountPath: "C:/target/openai-codex-accounts.json",
4640+
backupRoot: "C:/target/backups",
4641+
source: "default-global",
4642+
resolution: "accounts",
4643+
},
4644+
});
4645+
planOcChatgptSyncMock.mockResolvedValue({
4646+
kind: "blocked-ambiguous",
4647+
detection: { kind: "ambiguous", reason: "multiple targets", candidates: [] },
4648+
});
4649+
const selectSequence = queueSettingsSelectSequence([
4650+
{ type: "experimental" },
4651+
{ type: "sync" },
4652+
requireSettingsHotkey("q", { type: "back" }),
4653+
{ type: "back" },
4654+
{ type: "back" },
4655+
]);
4656+
4657+
const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
4658+
const exitCode = await runCodexMultiAuthCli(["auth", "login"]);
4659+
4660+
expect(exitCode).toBe(0);
4661+
expect(selectSequence.remaining()).toBe(0);
4662+
expect(planOcChatgptSyncMock).toHaveBeenCalledOnce();
4663+
expect(applyOcChatgptSyncMock).not.toHaveBeenCalled();
4664+
});
4665+
45854666
it("cancels experimental backup prompt on blank or q input", async () => {
45864667
const now = Date.now();
45874668
setupInteractiveSettingsLogin(createSettingsStorage(now));

test/settings-hub-utils.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,27 @@ function queueSelectResults(...results: unknown[]): void {
118118
selectQueue.push(...results);
119119
}
120120

121+
function triggerSettingsHubHotkey(
122+
raw: string,
123+
fallback: unknown = { type: "back" },
124+
): (items: MenuItem<unknown>[], options: unknown) => unknown {
125+
return (items, options) =>
126+
(options as {
127+
onInput?: (
128+
input: string,
129+
context: {
130+
cursor: number;
131+
items: MenuItem<unknown>[];
132+
requestRerender: () => void;
133+
},
134+
) => unknown;
135+
})?.onInput?.(raw, {
136+
cursor: 0,
137+
items,
138+
requestRerender: () => undefined,
139+
}) ?? fallback;
140+
}
141+
121142
vi.mock("../lib/ui/select.js", async () => {
122143
const actual = await vi.importActual<typeof import("../lib/ui/select.js")>(
123144
"../lib/ui/select.js",
@@ -149,11 +170,14 @@ beforeEach(() => {
149170
);
150171
vi.resetModules();
151172
selectQueue = [];
152-
selectHandler = async () => {
173+
selectHandler = async (items, options) => {
153174
const next = selectQueue.shift();
154175
if (next === undefined) {
155176
throw new Error("No select result queued");
156177
}
178+
if (typeof next === "function") {
179+
return next(items, options);
180+
}
157181
return next;
158182
};
159183
setStreamIsTTY(process.stdin, true);
@@ -687,5 +711,20 @@ describe("settings-hub utility coverage", () => {
687711
});
688712
expect(selected?.proactiveRefreshIntervalMs).toBe(60_000);
689713
});
714+
715+
it("supports experimental submenu hotkeys for guardian toggle and interval increase", async () => {
716+
const api = await loadSettingsHubTestApi();
717+
queueSelectResults(
718+
triggerSettingsHubHotkey("3"),
719+
triggerSettingsHubHotkey("]"),
720+
triggerSettingsHubHotkey("s"),
721+
);
722+
const selected = await api.promptExperimentalSettings({
723+
proactiveRefreshGuardian: false,
724+
proactiveRefreshIntervalMs: 60_000,
725+
});
726+
expect(selected?.proactiveRefreshGuardian).toBe(true);
727+
expect(selected?.proactiveRefreshIntervalMs).toBe(120_000);
728+
});
690729
});
691730
});

0 commit comments

Comments
 (0)