Compare commits

..
5 Commits
Author SHA1 Message Date
bendtherules b3a9ccc848 chore(release): 0.2.1 2026-03-23 17:09:04 +05:30
bendtherules 2558d44ea2 feat: display last used models as list
- Imported `chalk` for terminal color support.
- Constructed a formatted list of previously used models, applying cyan coloring.
- Updated user prompt to include the colored model list for clearer visibility.
2026-03-23 17:08:53 +05:30
bendtherules af5d888f56 build: remember last models 2026-03-23 16:54:51 +05:30
bendtherules fabd2f6519 refactor: use shared spinner 2026-03-23 16:27:39 +05:30
bendtherules 932e023fcf chore(release): 0.2.0 2026-03-23 15:49:36 +05:30
11 changed files with 221 additions and 50 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "opencode-multi-model", "name": "opencode-multi-model",
"version": "0.1.6", "version": "0.2.1",
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool", "description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
+28 -3
View File
@@ -4,7 +4,8 @@ import pkg from "../package.json";
import { cleanupMultiModel } from "./core/cleanup"; import { cleanupMultiModel } from "./core/cleanup";
import { closeMultiModel } from "./core/close"; import { closeMultiModel } from "./core/close";
import { openMultiModel } from "./core/open"; import { openMultiModel } from "./core/open";
import { getBinaryName } from "./core/utils"; import { getBinaryName, loadSettings, promptUser } from "./core/utils";
import chalk from "chalk";
const program = new Command(); const program = new Command();
@@ -26,19 +27,43 @@ program
"-b, --binary <binary>", "-b, --binary <binary>",
"Binary to use (opencode or kilo). Defaults to env var OPENCODE_MULTI_MODEL_BINARY or 'opencode'", "Binary to use (opencode or kilo). Defaults to env var OPENCODE_MULTI_MODEL_BINARY or 'opencode'",
) )
.option("-f, --force", "Skip confirmation prompts", false)
.action( .action(
async ( async (
sessionName: string, sessionName: string,
options: { models: string[]; binary?: string }, options: { models: string[]; binary?: string; force: boolean },
) => { ) => {
try { try {
let models = options.models;
if (models.length === 0) {
const settings = await loadSettings();
if (settings.lastModels.length > 0) {
if (!options.force) {
const modelList = settings.lastModels
.map((m) => ` - ${chalk.cyan(m)}`)
.join("\n");
const answer = await promptUser(
`Continue with last used models?\n${modelList}\n(Y/n): `,
);
if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
models = settings.lastModels;
}
} else {
models = settings.lastModels;
}
}
}
const binaryName = options.binary || getBinaryName(); const binaryName = options.binary || getBinaryName();
const result = await openMultiModel({ const result = await openMultiModel({
sessionName, sessionName,
models: options.models, models,
binaryName, binaryName,
mode: "cli", mode: "cli",
saveToSettings: true,
}); });
if (result.success) { if (result.success) {
+1 -18
View File
@@ -1,23 +1,6 @@
import * as readline from "node:readline";
import chalk from "chalk"; import chalk from "chalk";
import type { CleanupOptions, CleanupResult } from "../types"; import type { CleanupOptions, CleanupResult } from "../types";
import { runCommand } from "./utils"; import { promptUser, runCommand } from "./utils";
/**
* Prompts the user for confirmation on the terminal.
*/
async function promptUser(question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
/** /**
* Lists all archive tags matching the pattern archive/*. * Lists all archive tags matching the pattern archive/*.
+1 -21
View File
@@ -1,33 +1,13 @@
import * as readline from "node:readline";
import chalk from "chalk"; // removed console usage import chalk from "chalk"; // removed console usage
import type { CloseSessionOptions, CloseSessionResult } from "../types"; import type { CloseSessionOptions, CloseSessionResult } from "../types";
import { import {
getSessionPath, getSessionPath,
getWorktreesForSession, getWorktreesForSession,
promptUser,
runCommand, runCommand,
sanitizeName, sanitizeName,
} from "./utils"; } 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<string> {
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. * Closes a multi-model tmux session and optionally cleans up worktrees and branches.
* *
+5
View File
@@ -10,6 +10,7 @@ import {
normalizeModels, normalizeModels,
runCommand, runCommand,
sanitizeName, sanitizeName,
saveSettings,
undoWorktree, undoWorktree,
} from "./utils"; } from "./utils";
@@ -313,6 +314,10 @@ export async function openMultiModel(
}; };
} }
if (options.saveToSettings) {
await saveSettings({ lastModels: models });
}
return { return {
success: true, success: true,
sessionName, sessionName,
+61 -6
View File
@@ -1,13 +1,18 @@
import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import * as readline from "node:readline";
import ora, { type Ora } from "ora"; import ora, { type Ora } from "ora";
import type { import type {
CommandResult, CommandResult,
MultiModelSettings,
RunCommandOptions, RunCommandOptions,
WindowLaunchPlan, WindowLaunchPlan,
WorktreeInfo, WorktreeInfo,
} from "../types"; } from "../types";
let sharedSpinner: Ora | undefined;
const MAX_SUGGESTIONS = 3; const MAX_SUGGESTIONS = 3;
const WINDOW_NAME_LIMIT = 24; const WINDOW_NAME_LIMIT = 24;
@@ -35,17 +40,21 @@ export async function runCommand(
process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test"; process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test";
if (options?.mode === "cli" && !isTest) { if (options?.mode === "cli" && !isTest) {
const msg = options.spinnerMsg || `Running ${parts[0]}...`; const msg = options.spinnerMsg || `Running ${parts[0]}...`;
spinner = ora(msg).start(); if (!sharedSpinner) {
sharedSpinner = ora(msg).start();
} else {
sharedSpinner.text = msg;
if (!sharedSpinner.isSpinning) {
sharedSpinner.start();
}
}
spinner = sharedSpinner;
} }
const result = await Bun.$`${parts}`.quiet().nothrow(); const result = await Bun.$`${parts}`.quiet().nothrow();
if (spinner) { if (spinner) {
if (result.exitCode === 0) { spinner.stop().clear();
spinner.succeed();
} else {
spinner.fail();
}
} }
return { return {
@@ -483,3 +492,49 @@ export async function undoWorktree(
export function getBinaryName(): string { export function getBinaryName(): string {
return process.env.OPENCODE_MULTI_MODEL_BINARY || "opencode"; 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<MultiModelSettings> {
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<void> {
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<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
+9
View File
@@ -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. * Options for launching a multi-model tmux session.
*/ */
@@ -10,6 +17,8 @@ export interface MultiModelOptions {
binaryName?: string; binaryName?: string;
/** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */ /** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */
mode: "cli" | "tool"; mode: "cli" | "tool";
/** Save models to settings file on successful launch (CLI only). */
saveToSettings?: boolean;
} }
/** /**
+28
View File
@@ -538,6 +538,34 @@ describe("multi-model launch", () => {
?.match(/-n (\S+)/)?.[1]; ?.match(/-n (\S+)/)?.[1];
expect(windowName?.length).toBeLessThanOrEqual(24); 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);
});
}); });
// ---------- // ----------
+86
View File
@@ -7,6 +7,7 @@ import {
spyOn, spyOn,
test, test,
} from "bun:test"; } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import { import {
createWindowBaseName, createWindowBaseName,
@@ -15,13 +16,16 @@ import {
formatInvalidModelError, formatInvalidModelError,
getBinaryName, getBinaryName,
getSessionPath, getSessionPath,
getSettingsPath,
getWorktreePath, getWorktreePath,
getWorktreesForSession, getWorktreesForSession,
launchModelInWindow, launchModelInWindow,
levenshtein, levenshtein,
loadSettings,
normalizeModels, normalizeModels,
runCommand, runCommand,
sanitizeName, sanitizeName,
saveSettings,
shellQuote, shellQuote,
suggestModels, suggestModels,
undoWorktree, undoWorktree,
@@ -484,4 +488,86 @@ describe("core utils", () => {
delete process.env.OPENCODE_MULTI_MODEL_BINARY; 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<typeof spyOn>;
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<typeof spyOn>;
let writeFileMock: ReturnType<typeof spyOn>;
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",
);
});
});
}); });