Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions lib/codex-manager/experimental-settings-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { PluginConfig } from "../types.js";
import type { ExperimentalSettingsPromptDeps } from "./experimental-settings-prompt.js";

export async function promptExperimentalSettingsEntry<TTargetState>(
params: {
initialConfig: PluginConfig;
promptExperimentalSettingsMenu: (
args: ExperimentalSettingsPromptDeps<TTargetState>,
) => Promise<PluginConfig | null>;
} & ExperimentalSettingsPromptDeps<TTargetState>,
): Promise<PluginConfig | null> {
return params.promptExperimentalSettingsMenu({
initialConfig: params.initialConfig,
isInteractive: params.isInteractive,
ui: params.ui,
cloneBackendPluginConfig: params.cloneBackendPluginConfig,
select: params.select,
getExperimentalSelectOptions: params.getExperimentalSelectOptions,
mapExperimentalMenuHotkey: params.mapExperimentalMenuHotkey,
mapExperimentalStatusHotkey: params.mapExperimentalStatusHotkey,
formatDashboardSettingState: params.formatDashboardSettingState,
copy: params.copy,
input: params.input,
output: params.output,
runNamedBackupExport: params.runNamedBackupExport,
loadAccounts: params.loadAccounts,
loadExperimentalSyncTarget: params.loadExperimentalSyncTarget,
planOcChatgptSync: params.planOcChatgptSync,
applyOcChatgptSync: params.applyOcChatgptSync,
getTargetKind: params.getTargetKind,
getTargetDestination: params.getTargetDestination,
getTargetDetection: params.getTargetDetection,
getTargetErrorMessage: params.getTargetErrorMessage,
getPlanKind: params.getPlanKind,
getPlanBlockedReason: params.getPlanBlockedReason,
getPlanPreview: params.getPlanPreview,
getAppliedLabel: params.getAppliedLabel,
});
}
136 changes: 78 additions & 58 deletions lib/codex-manager/experimental-settings-prompt.ts
Original file line number Diff line number Diff line change
@@ -1,75 +1,95 @@
import { createInterface } from "node:readline/promises";
import type {
ApplyOcChatgptSyncOptions,
OcChatgptSyncApplyResult,
OcChatgptSyncPlanResult,
PlanOcChatgptSyncOptions,
} from "../oc-chatgpt-orchestrator.js";
import type { AccountStorageV3 } from "../storage.js";
import type { PluginConfig } from "../types.js";
import type { MenuItem, select } from "../ui/select.js";
import type { UiRuntimeOptions } from "../ui/runtime.js";
import type {
ExperimentalSettingsAction,
getExperimentalSelectOptions,
mapExperimentalMenuHotkey,
mapExperimentalStatusHotkey,
} from "./experimental-settings-schema.js";

export async function promptExperimentalSettingsMenu<
TAction,
export type ExperimentalSettingsCopy = {
experimentalSync: string;
experimentalBackup: string;
experimentalRefreshGuard: string;
experimentalRefreshInterval: string;
experimentalDecreaseInterval: string;
experimentalIncreaseInterval: string;
saveAndBack: string;
backNoSave: string;
experimentalHelpMenu: string;
experimentalBackupPrompt: string;
back: string;
experimentalHelpStatus: string;
experimentalApplySync: string;
experimentalHelpPreview: string;
};

export type ExperimentalSettingsPromptDeps<
TTargetState,
TPlan,
TApplied,
>(params: {
> = {
initialConfig: PluginConfig;
isInteractive: () => boolean;
ui: UiRuntimeOptions;
cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig;
select: <T>(
items: Array<Record<string, unknown>>,
options: Record<string, unknown>,
) => Promise<T | null>;
getExperimentalSelectOptions: (
ui: UiRuntimeOptions,
help: string,
hotkeyMapper: (raw: string) => TAction | undefined,
) => Record<string, unknown>;
mapExperimentalMenuHotkey: (raw: string) => TAction | undefined;
mapExperimentalStatusHotkey: (raw: string) => TAction | undefined;
select: typeof select;
getExperimentalSelectOptions: typeof getExperimentalSelectOptions;
mapExperimentalMenuHotkey: typeof mapExperimentalMenuHotkey;
mapExperimentalStatusHotkey: typeof mapExperimentalStatusHotkey;
formatDashboardSettingState: (enabled: boolean) => string;
copy: {
experimentalSync: string;
experimentalBackup: string;
experimentalRefreshGuard: string;
experimentalRefreshInterval: string;
experimentalDecreaseInterval: string;
experimentalIncreaseInterval: string;
saveAndBack: string;
backNoSave: string;
experimentalHelpMenu: string;
experimentalBackupPrompt: string;
back: string;
experimentalHelpStatus: string;
experimentalApplySync: string;
experimentalHelpPreview: string;
};
copy: ExperimentalSettingsCopy;
input: NodeJS.ReadStream;
output: NodeJS.WriteStream;
runNamedBackupExport: (args: {
name: string;
}) => Promise<{ kind: string; path?: string; error?: unknown }>;
loadAccounts: () => Promise<unknown>;
loadAccounts: () => Promise<AccountStorageV3 | null>;
loadExperimentalSyncTarget: () => Promise<TTargetState>;
planOcChatgptSync: (args: Record<string, unknown>) => Promise<TPlan>;
applyOcChatgptSync: (args: Record<string, unknown>) => Promise<TApplied>;
planOcChatgptSync: (
args: PlanOcChatgptSyncOptions,
) => Promise<OcChatgptSyncPlanResult>;
applyOcChatgptSync: (
args: ApplyOcChatgptSyncOptions,
) => Promise<OcChatgptSyncApplyResult>;
getTargetKind: (targetState: TTargetState) => string;
getTargetDestination: (targetState: TTargetState) => unknown;
getTargetDetection: (targetState: TTargetState) => unknown;
getTargetDestination: (targetState: TTargetState) => AccountStorageV3 | null;
getTargetDetection: (
targetState: TTargetState,
) => ReturnType<
typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget
>;
getTargetErrorMessage: (targetState: TTargetState) => string | null;
getPlanKind: (plan: TPlan) => string;
getPlanBlockedReason: (plan: TPlan) => string;
getPlanPreview: (plan: TPlan) => {
getPlanKind: (plan: OcChatgptSyncPlanResult) => string;
getPlanBlockedReason: (plan: OcChatgptSyncPlanResult) => string;
getPlanPreview: (plan: OcChatgptSyncPlanResult) => {
toAdd: unknown[];
toUpdate: unknown[];
toSkip: unknown[];
unchangedDestinationOnly: unknown[];
activeSelectionBehavior: string;
};
getAppliedLabel: (applied: TApplied) => { label: string; color: string };
}): Promise<PluginConfig | null> {
getAppliedLabel: (
applied: OcChatgptSyncApplyResult,
) => { label: string; color: MenuItem["color"] };
};

export async function promptExperimentalSettingsMenu<TTargetState>(
params: ExperimentalSettingsPromptDeps<TTargetState>,
): Promise<PluginConfig | null> {
if (!params.isInteractive()) return null;
let draft = params.cloneBackendPluginConfig(params.initialConfig);
const copy = params.copy;

while (true) {
const action = await params.select<TAction>(
const action = await params.select<ExperimentalSettingsAction>(
[
{
label: copy.experimentalSync,
Expand Down Expand Up @@ -164,7 +184,7 @@ export async function promptExperimentalSettingsMenu<
: backupResult.error instanceof Error
? backupResult.error.message
: String(backupResult.error);
await params.select<TAction>(
await params.select<ExperimentalSettingsAction>(
[
{
label: backupLabel,
Expand All @@ -184,7 +204,7 @@ export async function promptExperimentalSettingsMenu<
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
await params.select<TAction>(
await params.select<ExperimentalSettingsAction>(
[
{
label: message,
Expand Down Expand Up @@ -212,7 +232,7 @@ export async function promptExperimentalSettingsMenu<
const targetState = await params.loadExperimentalSyncTarget();
const targetError = params.getTargetErrorMessage(targetState);
if (targetError) {
await params.select<TAction>(
await params.select<ExperimentalSettingsAction>(
[
{
label: targetError,
Expand Down Expand Up @@ -246,7 +266,7 @@ export async function promptExperimentalSettingsMenu<
: undefined,
});
if (params.getPlanKind(plan) !== "ready") {
await params.select<TAction>(
await params.select<ExperimentalSettingsAction>(
[
{
label: params.getPlanBlockedReason(plan),
Expand All @@ -267,7 +287,7 @@ export async function promptExperimentalSettingsMenu<
}

const preview = params.getPlanPreview(plan);
const review = await params.select<TAction>(
const review = await params.select<ExperimentalSettingsAction>(
[
{
label: `Preview: add ${preview.toAdd.length} | update ${preview.toUpdate.length} | skip ${preview.toSkip.length}`,
Expand Down Expand Up @@ -299,15 +319,15 @@ export async function promptExperimentalSettingsMenu<
],
params.getExperimentalSelectOptions(
params.ui,
copy.experimentalHelpPreview,
(raw) => {
const lower = raw.toLowerCase();
if (lower === "q") return { type: "back" } as TAction;
if (lower === "a") return { type: "apply" } as TAction;
return undefined;
},
),
);
copy.experimentalHelpPreview,
(raw) => {
const lower = raw.toLowerCase();
if (lower === "q") return { type: "back" };
if (lower === "a") return { type: "apply" };
return undefined;
},
),
);
if (!review || (review as { type?: string }).type === "back") continue;

const applied = await params.applyOcChatgptSync({
Expand All @@ -322,7 +342,7 @@ export async function promptExperimentalSettingsMenu<
: undefined,
});
const appliedLabel = params.getAppliedLabel(applied);
await params.select<TAction>(
await params.select<ExperimentalSettingsAction>(
[
{
label: appliedLabel.label,
Expand Down
33 changes: 22 additions & 11 deletions lib/codex-manager/settings-hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
dashboardSettingsDataEqual,
} from "./dashboard-settings-data.js";
import { configureDashboardSettingsEntry } from "./dashboard-settings-entry.js";
import { promptExperimentalSettingsEntry } from "./experimental-settings-entry.js";
import { promptExperimentalSettingsMenu } from "./experimental-settings-prompt.js";
import {
getExperimentalSelectOptions,
Expand Down Expand Up @@ -663,29 +664,39 @@ async function loadExperimentalSyncTarget(): Promise<
async function promptExperimentalSettings(
initialConfig: PluginConfig,
): Promise<PluginConfig | null> {
return promptExperimentalSettingsMenu({
return promptExperimentalSettingsEntry({
initialConfig,
promptExperimentalSettingsMenu,
isInteractive: () => input.isTTY && output.isTTY,
ui: getUiRuntimeOptions(),
cloneBackendPluginConfig,
select: select as never,
getExperimentalSelectOptions: getExperimentalSelectOptions as never,
mapExperimentalMenuHotkey: mapExperimentalMenuHotkey as never,
mapExperimentalStatusHotkey: mapExperimentalStatusHotkey as never,
select,
getExperimentalSelectOptions,
mapExperimentalMenuHotkey,
mapExperimentalStatusHotkey,
formatDashboardSettingState,
copy: UI_COPY.settings,
input,
output,
runNamedBackupExport,
loadAccounts,
loadExperimentalSyncTarget,
planOcChatgptSync: planOcChatgptSync as never,
applyOcChatgptSync: applyOcChatgptSync as never,
planOcChatgptSync,
applyOcChatgptSync,
getTargetKind: (targetState) => (targetState as { kind: string }).kind,
getTargetDestination: (targetState) =>
(targetState as { kind: string; destination?: unknown }).destination,
getTargetDetection: (targetState) =>
(targetState as { detection?: unknown }).detection,
getTargetDestination: (
targetState,
): import("../storage.js").AccountStorageV3 | null =>
(targetState as {
kind: string;
destination?: import("../storage.js").AccountStorageV3 | null;
}).destination ?? null,
getTargetDetection: (
targetState,
): ReturnType<typeof detectOcChatgptMultiAuthTarget> =>
(targetState as {
detection: ReturnType<typeof detectOcChatgptMultiAuthTarget>;
}).detection,
getTargetErrorMessage: (targetState) =>
(targetState as { kind: string; message?: string }).kind === "error"
? ((targetState as { message?: string }).message ?? "Unknown error")
Expand Down
64 changes: 64 additions & 0 deletions test/experimental-settings-entry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import { promptExperimentalSettingsEntry } from "../lib/codex-manager/experimental-settings-entry.js";

describe("experimental settings entry", () => {
it("passes all dependencies through to the experimental settings prompt helper", async () => {
const promptExperimentalSettingsMenu = vi.fn(async () => ({
fetchTimeoutMs: 1000,
}));
const menuDeps = {
isInteractive: () => true,
ui: { theme: {} } as never,
cloneBackendPluginConfig: vi.fn((config) => config),
select: vi.fn(),
getExperimentalSelectOptions: vi.fn(() => ({})),
mapExperimentalMenuHotkey: vi.fn(),
mapExperimentalStatusHotkey: vi.fn(),
formatDashboardSettingState: vi.fn((enabled) => (enabled ? "on" : "off")),
copy: {
experimentalSync: "Sync",
experimentalBackup: "Backup",
experimentalRefreshGuard: "Refresh guard",
experimentalRefreshInterval: "Refresh interval",
experimentalDecreaseInterval: "Decrease interval",
experimentalIncreaseInterval: "Increase interval",
saveAndBack: "Save",
backNoSave: "Back",
experimentalHelpMenu: "Help menu",
experimentalBackupPrompt: "Backup prompt",
back: "Back",
experimentalHelpStatus: "Help status",
experimentalApplySync: "Apply sync",
experimentalHelpPreview: "Help preview",
},
input: process.stdin,
output: process.stdout,
runNamedBackupExport: vi.fn(),
loadAccounts: vi.fn(),
loadExperimentalSyncTarget: vi.fn(),
planOcChatgptSync: vi.fn(),
applyOcChatgptSync: vi.fn(),
getTargetKind: vi.fn(),
getTargetDestination: vi.fn(),
getTargetDetection: vi.fn(),
getTargetErrorMessage: vi.fn(),
getPlanKind: vi.fn(),
getPlanBlockedReason: vi.fn(),
getPlanPreview: vi.fn(),
getAppliedLabel: vi.fn(),
};
const initialConfig = { fetchTimeoutMs: 2000 };

const result = await promptExperimentalSettingsEntry({
initialConfig,
promptExperimentalSettingsMenu,
...menuDeps,
});

expect(promptExperimentalSettingsMenu).toHaveBeenCalledWith({
initialConfig,
...menuDeps,
});
expect(result).toEqual({ fetchTimeoutMs: 1000 });
});
});