feat: add ora spinner (v9.3.0) while running shell

This commit is contained in:
2026-03-23 14:27:23 +05:30
parent 6b07a7ac2a
commit e4d3e0e263
12 changed files with 201 additions and 72 deletions
+18 -11
View File
@@ -38,8 +38,14 @@ export async function getAvailableRemotes(): Promise<string[]> {
/**
* Deletes a local git tag.
*/
async function deleteLocalTag(tagName: string): Promise<boolean> {
const result = await runCommand(["git", "tag", "-d", tagName]);
async function deleteLocalTag(
tagName: string,
mode?: "cli" | "tool",
): Promise<boolean> {
const result = await runCommand(["git", "tag", "-d", tagName], {
mode,
spinnerMsg: `Deleting local tag ${tagName}...`,
});
return result.ok;
}
@@ -49,14 +55,15 @@ async function deleteLocalTag(tagName: string): Promise<boolean> {
async function deleteRemoteTag(
tagName: string,
remoteName: string,
mode?: "cli" | "tool",
): Promise<{ ok: boolean; error: string }> {
const result = await runCommand([
"git",
"push",
remoteName,
"--delete",
tagName,
]);
const result = await runCommand(
["git", "push", remoteName, "--delete", tagName],
{
mode,
spinnerMsg: `Deleting remote tag ${tagName} from ${remoteName}...`,
},
);
return {
ok: result.ok,
error: result.ok ? "" : result.stderr,
@@ -165,7 +172,7 @@ export async function cleanupMultiModel(
// Delete local tags
for (const tag of archiveTags) {
const deleted = await deleteLocalTag(tag);
const deleted = await deleteLocalTag(tag, mode);
if (deleted) {
localTagNames.push(tag);
}
@@ -174,7 +181,7 @@ export async function cleanupMultiModel(
// Delete from remote (if remote is specified and exists)
if (shouldPushToRemote && remoteToDeleteFrom) {
for (const tag of localTagNames) {
const result = await deleteRemoteTag(tag, remoteToDeleteFrom);
const result = await deleteRemoteTag(tag, remoteToDeleteFrom, mode);
if (result.ok) {
remoteTagNames.push(tag);
} else {
+12 -8
View File
@@ -113,19 +113,20 @@ export async function closeMultiModel(
for (const worktree of worktrees) {
try {
// Remove worktree
await runCommand(["git", "worktree", "remove", "-f", worktree.path]);
await runCommand(["git", "worktree", "remove", "-f", worktree.path], {
mode,
spinnerMsg: `Removing worktree ${worktree.path}...`,
});
worktreesRemoved.push(worktree.path);
// Optionally create an archive tag before deleting the branch for safe recovery
if (options.createArchiveTags !== false) {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
const tagResult = await runCommand([
"git",
"tag",
archiveTag,
worktree.branch,
]);
const tagResult = await runCommand(
["git", "tag", archiveTag, worktree.branch],
{ mode, spinnerMsg: `Creating archive tag ${archiveTag}...` },
);
if (tagResult.ok) {
tagsCreated.push(archiveTag);
@@ -137,7 +138,10 @@ export async function closeMultiModel(
}
}
await runCommand(["git", "branch", "-D", worktree.branch]);
await runCommand(["git", "branch", "-D", worktree.branch], {
mode,
spinnerMsg: `Deleting branch ${worktree.branch}...`,
});
branchesDeleted.push(worktree.branch);
} catch (err) {
instructionsArr.push(
+37 -31
View File
@@ -99,7 +99,10 @@ export async function openMultiModel(
};
}
const modelListResult = await runCommand([binaryName, "models"]);
const modelListResult = await runCommand([binaryName, "models"], {
mode,
spinnerMsg: `Fetching available models using \`${binaryName} models\`...`,
});
if (!modelListResult.ok) {
return {
success: false,
@@ -184,14 +187,10 @@ export async function openMultiModel(
}
}
const worktreeResult = await runCommand([
"git",
"worktree",
"add",
"-b",
branchName,
worktreePath,
]);
const worktreeResult = await runCommand(
["git", "worktree", "add", "-b", branchName, worktreePath],
{ mode, spinnerMsg: `Creating worktree for ${plan.model}...` },
);
if (!worktreeResult.ok) {
if (!isFirst) {
failedModels.push(
@@ -208,17 +207,20 @@ export async function openMultiModel(
}
if (isFirst) {
const sessionCreateResult = await runCommand([
"tmux",
"new-session",
"-d",
"-s",
sessionName,
"-n",
plan.windowName,
"-c",
worktreePath,
]);
const sessionCreateResult = await runCommand(
[
"tmux",
"new-session",
"-d",
"-s",
sessionName,
"-n",
plan.windowName,
"-c",
worktreePath,
],
{ mode, spinnerMsg: `Starting tmux session '${sessionName}'...` },
);
if (!sessionCreateResult.ok) {
const undoErrors = await undoWorktree(worktreePath, branchName);
@@ -229,17 +231,20 @@ export async function openMultiModel(
return { success: false, sessionName, error: errorMsg };
}
} else {
const windowCreateResult = await runCommand([
"tmux",
"new-window",
"-d",
"-t",
sessionName,
"-n",
plan.windowName,
"-c",
worktreePath,
]);
const windowCreateResult = await runCommand(
[
"tmux",
"new-window",
"-d",
"-t",
sessionName,
"-n",
plan.windowName,
"-c",
worktreePath,
],
{ mode, spinnerMsg: `Creating tmux window '${plan.windowName}'...` },
);
if (!windowCreateResult.ok) {
failedModels.push(
@@ -259,6 +264,7 @@ export async function openMultiModel(
sessionName,
plan,
binaryName,
mode,
);
if (launchResult.ok) {
succeededModels.push(plan.model);
+40 -10
View File
@@ -1,6 +1,12 @@
import * as os from "node:os";
import * as path from "node:path";
import type { CommandResult, WindowLaunchPlan, WorktreeInfo } from "../types";
import ora, { type Ora } from "ora";
import type {
CommandResult,
RunCommandOptions,
WindowLaunchPlan,
WorktreeInfo,
} from "../types";
const MAX_SUGGESTIONS = 3;
const WINDOW_NAME_LIMIT = 24;
@@ -9,6 +15,7 @@ const WINDOW_NAME_LIMIT = 24;
* Runs a command and captures its output without throwing on non-zero exit codes.
*
* @param parts Command segments to pass to the shell.
* @param options Additional options for running the command (e.g., mode, spinnerMsg).
* @returns The exit status plus captured stdout and stderr.
*
* @example
@@ -19,9 +26,28 @@ const WINDOW_NAME_LIMIT = 24;
* }
* ```
*/
export async function runCommand(parts: string[]): Promise<CommandResult> {
export async function runCommand(
parts: string[],
options?: RunCommandOptions,
): Promise<CommandResult> {
let spinner: Ora | undefined;
const isTest =
process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test";
if (options?.mode === "cli" && !isTest) {
const msg = options.spinnerMsg || `Running ${parts[0]}...`;
spinner = ora(msg).start();
}
const result = await Bun.$`${parts}`.quiet().nothrow();
if (spinner) {
if (result.exitCode === 0) {
spinner.succeed();
} else {
spinner.fail();
}
}
return {
ok: result.exitCode === 0,
stdout: result.stdout.toString().trim(),
@@ -274,17 +300,21 @@ export async function launchModelInWindow(
sessionName: string,
plan: WindowLaunchPlan,
binaryName: string = "opencode",
mode?: "cli" | "tool",
): Promise<CommandResult> {
const launchCommand = `${binaryName} --model ${shellQuote(plan.model)}`;
return runCommand([
"tmux",
"send-keys",
"-t",
`${sessionName}:${plan.windowName}`,
launchCommand,
"C-m",
]);
return runCommand(
[
"tmux",
"send-keys",
"-t",
`${sessionName}:${plan.windowName}`,
launchCommand,
"C-m",
],
{ mode, spinnerMsg: `Launching model ${plan.model}...` },
);
}
/**
+10
View File
@@ -129,6 +129,16 @@ export interface CommandResult {
stderr: string;
}
/**
* Options for running a shell command.
*/
export interface RunCommandOptions {
/** Caller context: 'cli' shows spinner, 'tool' does not. */
mode?: "cli" | "tool";
/** Message to display in the spinner while the command runs. */
spinnerMsg?: string;
}
/**
* Information about a git worktree.
*/