213 lines
6.3 KiB
TypeScript
213 lines
6.3 KiB
TypeScript
import { Text } from "@earendil-works/pi-tui";
|
|
import { registerCleanup, registerTimer } from "./disposable.js";
|
|
|
|
const BASH_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
const BASH_SPINNER_INTERVAL_MS = 200;
|
|
const BASH_SPINNER_STATE_KEY = "__piToolDisplayBashSpinner";
|
|
const BASH_SPINNER_TOOL_CALL_ID_KEY = "__piToolDisplayBashSpinnerToolCallId";
|
|
|
|
interface BashCallArgs {
|
|
command?: string;
|
|
commandPrefix?: string;
|
|
shellPath?: string;
|
|
timeout?: number;
|
|
}
|
|
|
|
interface BashCallRenderTheme {
|
|
fg(color: string, text: string): string;
|
|
bold(text: string): string;
|
|
}
|
|
|
|
interface BashSpinnerState {
|
|
frameIndex: number;
|
|
startedAt?: number;
|
|
timer?: ReturnType<typeof setInterval>;
|
|
}
|
|
|
|
interface BashSpinnerStateCarrier {
|
|
[BASH_SPINNER_STATE_KEY]?: BashSpinnerState;
|
|
[BASH_SPINNER_TOOL_CALL_ID_KEY]?: string;
|
|
}
|
|
|
|
interface BashCallRenderContextLike {
|
|
executionStarted: boolean;
|
|
isPartial: boolean;
|
|
invalidate?: () => void;
|
|
lastComponent?: unknown;
|
|
state?: unknown;
|
|
toolCallId?: string;
|
|
}
|
|
|
|
const spinnerStatesByToolCallId = new Map<string, BashSpinnerState>();
|
|
let nextSyntheticToolCallId = 0;
|
|
|
|
function toStateCarrier(value: unknown): BashSpinnerStateCarrier | undefined {
|
|
if (!value || typeof value !== "object") {
|
|
return undefined;
|
|
}
|
|
return value as BashSpinnerStateCarrier;
|
|
}
|
|
|
|
function getSyntheticToolCallId(carrier: BashSpinnerStateCarrier | undefined): string | undefined {
|
|
if (!carrier) {
|
|
return undefined;
|
|
}
|
|
|
|
if (!carrier[BASH_SPINNER_TOOL_CALL_ID_KEY]) {
|
|
carrier[BASH_SPINNER_TOOL_CALL_ID_KEY] = `state:${++nextSyntheticToolCallId}`;
|
|
}
|
|
return carrier[BASH_SPINNER_TOOL_CALL_ID_KEY];
|
|
}
|
|
|
|
function getToolCallId(context: BashCallRenderContextLike): string | undefined {
|
|
if (typeof context.toolCallId === "string" && context.toolCallId.trim().length > 0) {
|
|
return context.toolCallId;
|
|
}
|
|
return getSyntheticToolCallId(toStateCarrier(context.state));
|
|
}
|
|
|
|
function getOrCreateSpinnerState(
|
|
toolCallId: string | undefined,
|
|
carrier: BashSpinnerStateCarrier | undefined,
|
|
): BashSpinnerState | undefined {
|
|
if (!toolCallId) {
|
|
return undefined;
|
|
}
|
|
|
|
let state = spinnerStatesByToolCallId.get(toolCallId);
|
|
if (!state) {
|
|
state = { frameIndex: 0 };
|
|
spinnerStatesByToolCallId.set(toolCallId, state);
|
|
}
|
|
if (carrier) {
|
|
carrier[BASH_SPINNER_STATE_KEY] = state;
|
|
}
|
|
return state;
|
|
}
|
|
|
|
function stopSpinner(toolCallId: string | undefined, state: BashSpinnerState | undefined): void {
|
|
if (!state) {
|
|
return;
|
|
}
|
|
|
|
if (state.timer) {
|
|
clearInterval(state.timer);
|
|
state.timer = undefined;
|
|
}
|
|
state.frameIndex = 0;
|
|
state.startedAt = undefined;
|
|
if (toolCallId) {
|
|
spinnerStatesByToolCallId.delete(toolCallId);
|
|
}
|
|
}
|
|
|
|
function formatElapsed(elapsedMs: number): string {
|
|
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
|
if (totalSeconds < 60) {
|
|
return `${totalSeconds}s`;
|
|
}
|
|
|
|
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
const seconds = totalSeconds % 60;
|
|
if (totalMinutes < 60) {
|
|
return `${totalMinutes}m ${seconds}s`;
|
|
}
|
|
|
|
const hours = Math.floor(totalMinutes / 60);
|
|
const minutes = totalMinutes % 60;
|
|
return `${hours}h ${minutes}m`;
|
|
}
|
|
|
|
function isDefaultShellPath(shellPath: string): boolean {
|
|
const normalized = shellPath.trim().replace(/\\/g, "/").toLowerCase();
|
|
const basename = normalized.split("/").pop() || normalized;
|
|
return basename === "bash" || basename === "cmd.exe";
|
|
}
|
|
|
|
function buildCommandDisplay(args: BashCallArgs): string {
|
|
const command =
|
|
typeof args.command === "string" && args.command.trim().length > 0
|
|
? args.command
|
|
: "...";
|
|
const prefix =
|
|
typeof args.commandPrefix === "string" && args.commandPrefix.trim().length > 0
|
|
? args.commandPrefix.trim()
|
|
: "";
|
|
return prefix ? `${prefix} ${command}` : command;
|
|
}
|
|
|
|
function buildBashCallText(
|
|
args: BashCallArgs,
|
|
theme: BashCallRenderTheme,
|
|
spinnerFrame?: string,
|
|
elapsedMs?: number,
|
|
): string {
|
|
const commandDisplay = buildCommandDisplay(args);
|
|
const shellSuffix =
|
|
typeof args.shellPath === "string" &&
|
|
args.shellPath.trim().length > 0 &&
|
|
!isDefaultShellPath(args.shellPath)
|
|
? theme.fg("muted", ` [shell: ${args.shellPath}]`)
|
|
: "";
|
|
const timeoutSuffix = args.timeout
|
|
? theme.fg("muted", ` (timeout ${args.timeout}s)`)
|
|
: "";
|
|
const spinnerPrefix = spinnerFrame ? `${theme.fg("warning", `${spinnerFrame} `)}` : "";
|
|
const elapsedSuffix =
|
|
spinnerFrame && elapsedMs !== undefined
|
|
? theme.fg("muted", ` · ${formatElapsed(elapsedMs)}`)
|
|
: "";
|
|
|
|
return `${spinnerPrefix}${theme.fg("toolTitle", theme.bold("$"))} ${theme.fg("accent", commandDisplay)}${shellSuffix}${timeoutSuffix}${elapsedSuffix}`;
|
|
}
|
|
|
|
export function renderBashCall(
|
|
args: BashCallArgs,
|
|
theme: BashCallRenderTheme,
|
|
context: BashCallRenderContextLike,
|
|
): Text {
|
|
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
const carrier = toStateCarrier(context.state);
|
|
const toolCallId = getToolCallId(context);
|
|
const spinnerState = getOrCreateSpinnerState(toolCallId, carrier);
|
|
const shouldSpin = context.executionStarted && context.isPartial;
|
|
|
|
if (!shouldSpin) {
|
|
stopSpinner(toolCallId, spinnerState);
|
|
text.setText(buildBashCallText(args, theme));
|
|
return text;
|
|
}
|
|
|
|
if (spinnerState) {
|
|
spinnerState.startedAt ??= Date.now();
|
|
if (!spinnerState.timer && typeof context.invalidate === "function") {
|
|
const timer = setInterval(() => {
|
|
spinnerState.frameIndex = (spinnerState.frameIndex + 1) % BASH_SPINNER_FRAMES.length;
|
|
text.setText(
|
|
buildBashCallText(
|
|
args,
|
|
theme,
|
|
BASH_SPINNER_FRAMES[spinnerState.frameIndex],
|
|
Date.now() - (spinnerState.startedAt ?? Date.now()),
|
|
),
|
|
);
|
|
context.invalidate?.();
|
|
}, BASH_SPINNER_INTERVAL_MS);
|
|
spinnerState.timer = timer;
|
|
registerTimer(timer);
|
|
registerCleanup(() => {
|
|
if (spinnerStatesByToolCallId.get(toolCallId || "") === spinnerState) {
|
|
stopSpinner(toolCallId, spinnerState);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const spinnerFrame = spinnerState ? BASH_SPINNER_FRAMES[spinnerState.frameIndex] : undefined;
|
|
const elapsedMs = spinnerState?.startedAt !== undefined
|
|
? Date.now() - spinnerState.startedAt
|
|
: undefined;
|
|
text.setText(buildBashCallText(args, theme, spinnerFrame, elapsedMs));
|
|
return text;
|
|
}
|