From af5d888f564cd3a0bb1e247ef8873bbbe8551333 Mon Sep 17 00:00:00 2001 From: bendtherules Date: Mon, 23 Mar 2026 16:54:51 +0530 Subject: [PATCH] build: remember last models --- .opencode/package.json | 2 +- .../1774250210640-eager-orchid.md | 0 src/cli.ts | 27 +++++- src/core/cleanup.ts | 19 +--- src/core/close.ts | 22 +---- src/core/open.ts | 5 ++ src/core/utils.ts | 49 +++++++++++ src/types.ts | 9 ++ tests/open.test.ts | 28 ++++++ tests/utils.test.ts | 86 +++++++++++++++++++ 10 files changed, 204 insertions(+), 43 deletions(-) rename .opencode/plans/{ => archive}/1774250210640-eager-orchid.md (100%) diff --git a/.opencode/package.json b/.opencode/package.json index 1ed6b82..31a74b2 100644 --- a/.opencode/package.json +++ b/.opencode/package.json @@ -12,4 +12,4 @@ "@types/bun": "^1.3.10", "@types/node": "^25.5.0" } -} +} \ No newline at end of file diff --git a/.opencode/plans/1774250210640-eager-orchid.md b/.opencode/plans/archive/1774250210640-eager-orchid.md similarity index 100% rename from .opencode/plans/1774250210640-eager-orchid.md rename to .opencode/plans/archive/1774250210640-eager-orchid.md diff --git a/src/cli.ts b/src/cli.ts index 7607ac9..18da642 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,7 @@ import pkg from "../package.json"; import { cleanupMultiModel } from "./core/cleanup"; import { closeMultiModel } from "./core/close"; import { openMultiModel } from "./core/open"; -import { getBinaryName } from "./core/utils"; +import { getBinaryName, loadSettings, promptUser } from "./core/utils"; const program = new Command(); @@ -26,19 +26,40 @@ program "-b, --binary ", "Binary to use (opencode or kilo). Defaults to env var OPENCODE_MULTI_MODEL_BINARY or 'opencode'", ) + .option("-f, --force", "Skip confirmation prompts", false) .action( async ( sessionName: string, - options: { models: string[]; binary?: string }, + options: { models: string[]; binary?: string; force: boolean }, ) => { try { + let models = options.models; + + if (models.length === 0) { + const settings = await loadSettings(); + if (settings.lastModels.length > 0) { + if (!options.force) { + const answer = await promptUser( + `Use models [${models.join(", ")}]? (Y/n): `, + ); + if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") { + models = settings.lastModels; + + } + } else { + models = settings.lastModels; + } + } + } + const binaryName = options.binary || getBinaryName(); const result = await openMultiModel({ sessionName, - models: options.models, + models, binaryName, mode: "cli", + saveToSettings: true, }); if (result.success) { diff --git a/src/core/cleanup.ts b/src/core/cleanup.ts index d63f933..85a4665 100644 --- a/src/core/cleanup.ts +++ b/src/core/cleanup.ts @@ -1,23 +1,6 @@ -import * as readline from "node:readline"; import chalk from "chalk"; import type { CleanupOptions, CleanupResult } from "../types"; -import { runCommand } from "./utils"; - -/** - * Prompts the user for confirmation on the terminal. - */ -async function promptUser(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer); - }); - }); -} +import { promptUser, runCommand } from "./utils"; /** * Lists all archive tags matching the pattern archive/*. diff --git a/src/core/close.ts b/src/core/close.ts index 491fb85..e9fc967 100644 --- a/src/core/close.ts +++ b/src/core/close.ts @@ -1,33 +1,13 @@ -import * as readline from "node:readline"; import chalk from "chalk"; // removed console usage import type { CloseSessionOptions, CloseSessionResult } from "../types"; import { getSessionPath, getWorktreesForSession, + promptUser, runCommand, sanitizeName, } from "./utils"; -/** - * Prompts the user for input on the terminal. - * - * @param question The question to display. - * @returns The user's answer. - */ -async function promptUser(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer); - }); - }); -} - /** * Closes a multi-model tmux session and optionally cleans up worktrees and branches. * diff --git a/src/core/open.ts b/src/core/open.ts index 482c53e..106551a 100644 --- a/src/core/open.ts +++ b/src/core/open.ts @@ -10,6 +10,7 @@ import { normalizeModels, runCommand, sanitizeName, + saveSettings, undoWorktree, } from "./utils"; @@ -313,6 +314,10 @@ export async function openMultiModel( }; } + if (options.saveToSettings) { + await saveSettings({ lastModels: models }); + } + return { success: true, sessionName, diff --git a/src/core/utils.ts b/src/core/utils.ts index bfa168d..48a2780 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,8 +1,11 @@ +import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import * as readline from "node:readline"; import ora, { type Ora } from "ora"; import type { CommandResult, + MultiModelSettings, RunCommandOptions, WindowLaunchPlan, WorktreeInfo, @@ -489,3 +492,49 @@ export async function undoWorktree( export function getBinaryName(): string { return process.env.OPENCODE_MULTI_MODEL_BINARY || "opencode"; } + +export function getSettingsPath(): string { + const homedir = os.homedir(); + return path.join(homedir, ".config", "opencode-multi-model", "settings.json"); +} + +export async function loadSettings(): Promise { + const settingsPath = getSettingsPath(); + try { + const content = await fs.promises.readFile(settingsPath, "utf-8"); + const parsed = JSON.parse(content) as MultiModelSettings; + if (!Array.isArray(parsed.lastModels)) { + return { lastModels: [] }; + } + return parsed; + } catch { + return { lastModels: [] }; + } +} + +export async function saveSettings( + settings: MultiModelSettings, +): Promise { + const settingsPath = getSettingsPath(); + const dir = path.dirname(settingsPath); + await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.writeFile( + settingsPath, + JSON.stringify(settings, null, 2), + "utf-8", + ); +} + +export async function promptUser(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} diff --git a/src/types.ts b/src/types.ts index eefc576..e18f6a9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,10 @@ +/** + * Settings stored in ~/.config/opencode-multi-model/settings.json + */ +export interface MultiModelSettings { + lastModels: string[]; +} + /** * Options for launching a multi-model tmux session. */ @@ -10,6 +17,8 @@ export interface MultiModelOptions { binaryName?: string; /** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */ mode: "cli" | "tool"; + /** Save models to settings file on successful launch (CLI only). */ + saveToSettings?: boolean; } /** diff --git a/tests/open.test.ts b/tests/open.test.ts index 8f2d332..f6c507a 100644 --- a/tests/open.test.ts +++ b/tests/open.test.ts @@ -538,6 +538,34 @@ describe("multi-model launch", () => { ?.match(/-n (\S+)/)?.[1]; expect(windowName?.length).toBeLessThanOrEqual(24); }); + + test("saves settings on successful launch when saveToSettings is true", async () => { + spyOn(fs.promises, "mkdir").mockResolvedValue(undefined); + spyOn(fs.promises, "writeFile").mockResolvedValue(undefined); + + const result = await openMultiModel({ + mode: "cli", + sessionName: "test-session", + models: ["openai/gpt-5.2"], + saveToSettings: true, + }); + + expect(result.success).toBe(true); + }); + + test("does not save settings when saveToSettings is false", async () => { + spyOn(fs.promises, "mkdir").mockResolvedValue(undefined); + spyOn(fs.promises, "writeFile").mockResolvedValue(undefined); + + const result = await openMultiModel({ + mode: "cli", + sessionName: "test-session", + models: ["openai/gpt-5.2"], + saveToSettings: false, + }); + + expect(result.success).toBe(true); + }); }); // ---------- diff --git a/tests/utils.test.ts b/tests/utils.test.ts index f279724..47808d2 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -7,6 +7,7 @@ import { spyOn, test, } from "bun:test"; +import * as fs from "node:fs"; import * as os from "node:os"; import { createWindowBaseName, @@ -15,13 +16,16 @@ import { formatInvalidModelError, getBinaryName, getSessionPath, + getSettingsPath, getWorktreePath, getWorktreesForSession, launchModelInWindow, levenshtein, + loadSettings, normalizeModels, runCommand, sanitizeName, + saveSettings, shellQuote, suggestModels, undoWorktree, @@ -484,4 +488,86 @@ describe("core utils", () => { delete process.env.OPENCODE_MULTI_MODEL_BINARY; }); }); + + describe("getSettingsPath", () => { + test("returns correct settings path", () => { + spyOn(os, "homedir").mockReturnValue("/home/user"); + expect(getSettingsPath()).toBe( + "/home/user/.config/opencode-multi-model/settings.json", + ); + }); + }); + + describe("loadSettings", () => { + let readFileMock: ReturnType; + + beforeEach(() => { + spyOn(os, "homedir").mockReturnValue("/home/user"); + readFileMock = spyOn(fs.promises, "readFile"); + }); + + test("returns default settings when file does not exist", async () => { + readFileMock.mockRejectedValue(new Error("ENOENT")); + const result = await loadSettings(); + expect(result).toEqual({ lastModels: [] }); + }); + + test("returns default settings when file has invalid JSON", async () => { + readFileMock.mockResolvedValue("not valid json"); + const result = await loadSettings(); + expect(result).toEqual({ lastModels: [] }); + }); + + test("returns default settings when lastModels is not an array", async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ lastModels: "not array" }), + ); + const result = await loadSettings(); + expect(result).toEqual({ lastModels: [] }); + }); + + test("parses valid settings file", async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ lastModels: ["model1", "model2"] }), + ); + const result = await loadSettings(); + expect(result).toEqual({ lastModels: ["model1", "model2"] }); + }); + }); + + describe("saveSettings", () => { + let mkdirMock: ReturnType; + let writeFileMock: ReturnType; + + beforeEach(() => { + spyOn(os, "homedir").mockReturnValue("/home/user"); + mkdirMock = spyOn(fs.promises, "mkdir"); + writeFileMock = spyOn(fs.promises, "writeFile"); + }); + + test("creates directory if it does not exist", async () => { + mkdirMock.mockResolvedValue(undefined); + writeFileMock.mockResolvedValue(undefined); + + await saveSettings({ lastModels: ["model1"] }); + + expect(mkdirMock).toHaveBeenCalledWith( + "/home/user/.config/opencode-multi-model", + { recursive: true }, + ); + }); + + test("writes settings with correct content", async () => { + mkdirMock.mockResolvedValue(undefined); + writeFileMock.mockResolvedValue(undefined); + + await saveSettings({ lastModels: ["model1", "model2"] }); + + expect(writeFileMock).toHaveBeenCalledWith( + "/home/user/.config/opencode-multi-model/settings.json", + JSON.stringify({ lastModels: ["model1", "model2"] }, null, 2), + "utf-8", + ); + }); + }); });