Compare commits

...
14 Commits
Author SHA1 Message Date
bendtherules bc1efdbd63 chore(release): 0.2.2 2026-03-23 19:22:06 +05:30
bendtherules 77e5470d04 fix: lint fix 2026-03-23 19:17:27 +05:30
bendtherules ac31d69e3a feat: add abortSignal to all commands
- Extend `listArchiveTags`, `getAvailableRemotes`, `deleteLocalTag`, `deleteRemoteTag`, and `cleanupMultiModel` to accept `mode` and `abortSignal` for better control and cancellation.
- Update internal calls to pass the new parameters throughout the cleanup workflow.
- Add detailed JSDoc comments for the new arguments.
- Modify `closeMultiModel` to destructure `mode` and `abortSignal` from options and forward them to `runCommand` calls, including spinner messages for session checks, killing sessions, and worktree removal.
2026-03-23 19:16:12 +05:30
bendtherules 651c2ae9a9 chore: add missing trailing newline to .opencode/package.json 2026-03-23 19:01:34 +05:30
bendtherules 0a509d2bc3 style: lint fix 2026-03-23 19:01:26 +05:30
bendtherules d8a2e11d29 style: remove extraneous blank line after assigning models from settings.lastModels 2026-03-23 19:00:04 +05:30
bendtherules 2cc01f5faa refactor(core): rename MultiModelOptions and MultiModelResult to OpenSessionOptions and OpenSessionResult 2026-03-23 18:59:44 +05:30
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
bendtherules 8f069c5582 chore(release): 0.1.6 2026-03-23 15:48:26 +05:30
bendtherules 1a58a318fd refactor(index): remove unused re-exports from src/index.ts 2026-03-23 15:48:13 +05:30
12 changed files with 343 additions and 107 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "opencode-multi-model", "name": "opencode-multi-model",
"version": "0.1.5", "version": "0.2.2",
"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",
+30 -3
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import chalk from "chalk";
import { Command } from "commander"; import { Command } from "commander";
import pkg from "../package.json"; 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";
const program = new Command(); const program = new Command();
@@ -26,19 +27,45 @@ 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) {
+35 -27
View File
@@ -1,37 +1,36 @@
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/*.
*/ */
export async function listArchiveTags(): Promise<string[]> { export async function listArchiveTags(
const { stdout } = await runCommand(["git", "tag", "-l", "archive/*"]); mode?: "cli" | "tool",
abortSignal?: AbortSignal,
): Promise<string[]> {
const { stdout } = await runCommand(["git", "tag", "-l", "archive/*"], {
mode,
abortSignal,
});
return stdout.split("\n").filter(Boolean); return stdout.split("\n").filter(Boolean);
} }
/** /**
* Gets the list of available git remotes. * Gets the list of available git remotes.
*/ */
export async function getAvailableRemotes(): Promise<string[]> { /**
const { stdout } = await runCommand(["git", "remote"]); * Retrieves the list of git remotes.
* Accepts optional mode and abort signal for cancellation support.
*/
export async function getAvailableRemotes(
mode?: "cli" | "tool",
abortSignal?: AbortSignal,
): Promise<string[]> {
const { stdout } = await runCommand(["git", "remote"], {
mode,
abortSignal,
});
return stdout.split("\n").filter(Boolean); return stdout.split("\n").filter(Boolean);
} }
@@ -41,10 +40,12 @@ export async function getAvailableRemotes(): Promise<string[]> {
async function deleteLocalTag( async function deleteLocalTag(
tagName: string, tagName: string,
mode?: "cli" | "tool", mode?: "cli" | "tool",
abortSignal?: AbortSignal,
): Promise<boolean> { ): Promise<boolean> {
const result = await runCommand(["git", "tag", "-d", tagName], { const result = await runCommand(["git", "tag", "-d", tagName], {
mode, mode,
spinnerMsg: `Deleting local tag ${tagName}...`, spinnerMsg: `Deleting local tag ${tagName}...`,
abortSignal,
}); });
return result.ok; return result.ok;
} }
@@ -56,12 +57,14 @@ async function deleteRemoteTag(
tagName: string, tagName: string,
remoteName: string, remoteName: string,
mode?: "cli" | "tool", mode?: "cli" | "tool",
abortSignal?: AbortSignal,
): Promise<{ ok: boolean; error: string }> { ): Promise<{ ok: boolean; error: string }> {
const result = await runCommand( const result = await runCommand(
["git", "push", remoteName, "--delete", tagName], ["git", "push", remoteName, "--delete", tagName],
{ {
mode, mode,
spinnerMsg: `Deleting remote tag ${tagName} from ${remoteName}...`, spinnerMsg: `Deleting remote tag ${tagName} from ${remoteName}...`,
abortSignal,
}, },
); );
return { return {
@@ -82,11 +85,11 @@ async function deleteRemoteTag(
export async function cleanupMultiModel( export async function cleanupMultiModel(
options: CleanupOptions, options: CleanupOptions,
): Promise<CleanupResult> { ): Promise<CleanupResult> {
const { force = false, remote, mode } = options; const { force = false, remote, mode, abortSignal } = options;
// Get available remotes and archive tags first // Get available remotes and archive tags first
const availableRemotes = await getAvailableRemotes(); const availableRemotes = await getAvailableRemotes(mode, abortSignal);
const archiveTags = await listArchiveTags(); const archiveTags = await listArchiveTags(mode, abortSignal);
// Determine whether to push to remote and which remote to use: // Determine whether to push to remote and which remote to use:
// - remote === false: --no-remote flag, local-only mode // - remote === false: --no-remote flag, local-only mode
@@ -172,7 +175,7 @@ export async function cleanupMultiModel(
// Delete local tags // Delete local tags
for (const tag of archiveTags) { for (const tag of archiveTags) {
const deleted = await deleteLocalTag(tag, mode); const deleted = await deleteLocalTag(tag, mode, abortSignal);
if (deleted) { if (deleted) {
localTagNames.push(tag); localTagNames.push(tag);
} }
@@ -181,7 +184,12 @@ export async function cleanupMultiModel(
// Delete from remote (if remote is specified and exists) // Delete from remote (if remote is specified and exists)
if (shouldPushToRemote && remoteToDeleteFrom) { if (shouldPushToRemote && remoteToDeleteFrom) {
for (const tag of localTagNames) { for (const tag of localTagNames) {
const result = await deleteRemoteTag(tag, remoteToDeleteFrom, mode); const result = await deleteRemoteTag(
tag,
remoteToDeleteFrom,
mode,
abortSignal,
);
if (result.ok) { if (result.ok) {
remoteTagNames.push(tag); remoteTagNames.push(tag);
} else { } else {
+22 -30
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.
* *
@@ -56,21 +36,27 @@ export async function closeMultiModel(
const instructionsArr: string[] = []; const instructionsArr: string[] = [];
const branchesDeleted: string[] = []; const branchesDeleted: string[] = [];
const tagsCreated: string[] = []; const tagsCreated: string[] = [];
const mode = options.mode; const { mode, abortSignal } = options;
const safeSessionName = sanitizeName(options.sessionName); const safeSessionName = sanitizeName(options.sessionName);
try { try {
// Check if session exists // Check if session exists
const { ok: sessionExists } = await runCommand([ const { ok: sessionExists } = await runCommand(
"tmux", ["tmux", "has-session", "-t", options.sessionName],
"has-session", {
"-t", mode,
options.sessionName, spinnerMsg: `Checking if session '${options.sessionName}' exists...`,
]); abortSignal,
},
);
// Kill tmux session if it exists // Kill tmux session if it exists
if (sessionExists) { if (sessionExists) {
await runCommand(["tmux", "kill-session", "-t", options.sessionName]); await runCommand(["tmux", "kill-session", "-t", options.sessionName], {
mode,
spinnerMsg: `Killing session '${options.sessionName}'...`,
abortSignal,
});
instructionsArr.push( instructionsArr.push(
chalk.green(`Closed session '${options.sessionName}'`), chalk.green(`Closed session '${options.sessionName}'`),
); );
@@ -116,6 +102,7 @@ export async function closeMultiModel(
await runCommand(["git", "worktree", "remove", "-f", worktree.path], { await runCommand(["git", "worktree", "remove", "-f", worktree.path], {
mode, mode,
spinnerMsg: `Removing worktree ${worktree.path}...`, spinnerMsg: `Removing worktree ${worktree.path}...`,
abortSignal,
}); });
worktreesRemoved.push(worktree.path); worktreesRemoved.push(worktree.path);
@@ -141,6 +128,7 @@ export async function closeMultiModel(
await runCommand(["git", "branch", "-D", worktree.branch], { await runCommand(["git", "branch", "-D", worktree.branch], {
mode, mode,
spinnerMsg: `Deleting branch ${worktree.branch}...`, spinnerMsg: `Deleting branch ${worktree.branch}...`,
abortSignal,
}); });
branchesDeleted.push(worktree.branch); branchesDeleted.push(worktree.branch);
} catch (err) { } catch (err) {
@@ -153,7 +141,11 @@ export async function closeMultiModel(
// Also remove the base directory // Also remove the base directory
const worktreeBase = getSessionPath(safeSessionName); const worktreeBase = getSessionPath(safeSessionName);
try { try {
await runCommand(["rm", "-rf", worktreeBase]); await runCommand(["rm", "-rf", worktreeBase], {
mode,
spinnerMsg: `Removing worktree base directory: ${worktreeBase}...`,
abortSignal,
});
} catch { } catch {
// Ignore errors // Ignore errors
} }
+59 -27
View File
@@ -1,6 +1,6 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import chalk from "chalk"; import chalk from "chalk";
import type { MultiModelOptions, MultiModelResult } from "../types"; import type { OpenSessionOptions, OpenSessionResult } from "../types";
import { import {
createWindowPlans, createWindowPlans,
findDuplicates, findDuplicates,
@@ -10,6 +10,7 @@ import {
normalizeModels, normalizeModels,
runCommand, runCommand,
sanitizeName, sanitizeName,
saveSettings,
undoWorktree, undoWorktree,
} from "./utils"; } from "./utils";
@@ -35,13 +36,13 @@ import {
* ``` * ```
*/ */
export async function openMultiModel( export async function openMultiModel(
options: MultiModelOptions, options: OpenSessionOptions,
): Promise<MultiModelResult> { ): Promise<OpenSessionResult> {
const sessionName = options.sessionName.trim(); const sessionName = options.sessionName.trim();
const safeSessionName = sanitizeName(sessionName); const safeSessionName = sanitizeName(sessionName);
const models = normalizeModels(options.models); const models = normalizeModels(options.models);
const binaryName = options.binaryName || "opencode"; const binaryName = options.binaryName || "opencode";
const mode = options.mode; const { mode, abortSignal } = options;
if (!sessionName) { if (!sessionName) {
return { return {
@@ -68,11 +69,14 @@ export async function openMultiModel(
}; };
} }
const gitRepoCheck = await runCommand([ const gitRepoCheck = await runCommand(
"git", ["git", "rev-parse", "--is-inside-work-tree"],
"rev-parse", {
"--is-inside-work-tree", mode,
]); spinnerMsg: "Checking if inside git repository...",
abortSignal,
},
);
if (!gitRepoCheck.ok) { if (!gitRepoCheck.ok) {
return { return {
success: false, success: false,
@@ -81,7 +85,11 @@ export async function openMultiModel(
}; };
} }
const tmuxExists = await runCommand(["command", "-v", "tmux"]); const tmuxExists = await runCommand(["command", "-v", "tmux"], {
mode,
spinnerMsg: "Checking if tmux is installed...",
abortSignal,
});
if (!tmuxExists.ok) { if (!tmuxExists.ok) {
return { return {
success: false, success: false,
@@ -90,7 +98,11 @@ export async function openMultiModel(
}; };
} }
const binaryExists = await runCommand(["command", "-v", binaryName]); const binaryExists = await runCommand(["command", "-v", binaryName], {
mode,
spinnerMsg: `Checking if ${binaryName} is installed...`,
abortSignal,
});
if (!binaryExists.ok) { if (!binaryExists.ok) {
return { return {
success: false, success: false,
@@ -102,6 +114,7 @@ export async function openMultiModel(
const modelListResult = await runCommand([binaryName, "models"], { const modelListResult = await runCommand([binaryName, "models"], {
mode, mode,
spinnerMsg: `Fetching available models using \`${binaryName} models\`...`, spinnerMsg: `Fetching available models using \`${binaryName} models\`...`,
abortSignal,
}); });
if (!modelListResult.ok) { if (!modelListResult.ok) {
return { return {
@@ -125,12 +138,14 @@ export async function openMultiModel(
}; };
} }
const sessionExists = await runCommand([ const sessionExists = await runCommand(
"tmux", ["tmux", "has-session", "-t", sessionName],
"has-session", {
"-t", mode,
sessionName, spinnerMsg: `Checking if tmux session '${sessionName}' already exists...`,
]); abortSignal,
},
);
if (sessionExists.ok) { if (sessionExists.ok) {
return { return {
success: false, success: false,
@@ -165,13 +180,14 @@ export async function openMultiModel(
} }
} }
const branchExists = await runCommand([ const branchExists = await runCommand(
"git", ["git", "show-ref", "--verify", "--quiet", `refs/heads/${branchName}`],
"show-ref", {
"--verify", mode,
"--quiet", spinnerMsg: `Checking if branch '${branchName}' already exists...`,
`refs/heads/${branchName}`, abortSignal,
]); },
);
if (branchExists.ok) { if (branchExists.ok) {
if (!isFirst) { if (!isFirst) {
failedModels.push( failedModels.push(
@@ -189,7 +205,11 @@ export async function openMultiModel(
const worktreeResult = await runCommand( const worktreeResult = await runCommand(
["git", "worktree", "add", "-b", branchName, worktreePath], ["git", "worktree", "add", "-b", branchName, worktreePath],
{ mode, spinnerMsg: `Creating worktree for ${plan.model}...` }, {
mode,
spinnerMsg: `Creating worktree for ${plan.model}...`,
abortSignal,
},
); );
if (!worktreeResult.ok) { if (!worktreeResult.ok) {
if (!isFirst) { if (!isFirst) {
@@ -219,7 +239,11 @@ export async function openMultiModel(
"-c", "-c",
worktreePath, worktreePath,
], ],
{ mode, spinnerMsg: `Starting tmux session '${sessionName}'...` }, {
mode,
spinnerMsg: `Starting tmux session '${sessionName}'...`,
abortSignal,
},
); );
if (!sessionCreateResult.ok) { if (!sessionCreateResult.ok) {
@@ -243,7 +267,11 @@ export async function openMultiModel(
"-c", "-c",
worktreePath, worktreePath,
], ],
{ mode, spinnerMsg: `Creating tmux window '${plan.windowName}'...` }, {
mode,
spinnerMsg: `Creating tmux window '${plan.windowName}'...`,
abortSignal,
},
); );
if (!windowCreateResult.ok) { if (!windowCreateResult.ok) {
@@ -313,6 +341,10 @@ export async function openMultiModel(
}; };
} }
if (options.saveToSettings) {
await saveSettings({ lastModels: models });
}
return { return {
success: true, success: true,
sessionName, sessionName,
+62 -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,22 @@ 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;
} }
options?.abortSignal?.throwIfAborted();
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 +493,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);
});
});
}
-3
View File
@@ -30,6 +30,3 @@ const OpenCodeMultiModelPlugin: Plugin = async (_ctx) => {
}; };
export default OpenCodeMultiModelPlugin; export default OpenCodeMultiModelPlugin;
export * from "./core/index";
export * from "./types";
export { cleanupTool, closeTool, openTool };
+1 -8
View File
@@ -32,14 +32,7 @@ Arguments:
models: args.models, models: args.models,
binaryName, binaryName,
mode: "tool", mode: "tool",
}); abortSignal: context.abort,
context.metadata({
title: `multi-model ${args.sessionName}`,
metadata: {
safeSessionName: result.sessionName,
modelCount: args.models.length,
},
}); });
if (!result.success) { if (!result.success) {
+19 -2
View File
@@ -1,7 +1,14 @@
/**
* 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.
*/ */
export interface MultiModelOptions { export interface OpenSessionOptions {
/** Name for the tmux session. */ /** Name for the tmux session. */
sessionName: string; sessionName: string;
/** List of model ids to launch. */ /** List of model ids to launch. */
@@ -10,12 +17,16 @@ 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;
/** Signal to abort the operation. */
abortSignal?: AbortSignal;
} }
/** /**
* Result returned after attempting to launch a multi-model session. * Result returned after attempting to launch a multi-model session.
*/ */
export interface MultiModelResult { export interface OpenSessionResult {
/** Whether the launch succeeded. */ /** Whether the launch succeeded. */
success: boolean; success: boolean;
/** The session name used. */ /** The session name used. */
@@ -42,6 +53,8 @@ export interface CloseSessionOptions {
force?: boolean; force?: boolean;
/** 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";
/** Signal to abort the operation. */
abortSignal?: AbortSignal;
} }
/** /**
@@ -91,6 +104,8 @@ export interface CleanupOptions {
remote?: string | false; remote?: string | false;
/** 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";
/** Optional abort signal to cancel the operation. */
abortSignal?: AbortSignal;
} }
/** /**
@@ -137,6 +152,8 @@ export interface RunCommandOptions {
mode?: "cli" | "tool"; mode?: "cli" | "tool";
/** Message to display in the spinner while the command runs. */ /** Message to display in the spinner while the command runs. */
spinnerMsg?: string; spinnerMsg?: string;
/** Signal to abort the operation. Check it before running the command. */
abortSignal?: AbortSignal;
} }
/** /**
+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",
);
});
});
}); });