blamouche_obsidian-any-ai-code/automations-modal.ts
blamouche 073efef75c Run multiple runtimes in parallel via session tabs
Refactor the sidebar panel from a single-process view into a tabbed
multi-session view: each tab is an independent CliSession with its own
PTY process and xterm terminal. A New session / + control opens a session
from any configured runtime; multiple sessions of the same runtime are
allowed and labels are disambiguated. Stop/Restart/Clear and the @file/
@folder buttons act on the active tab; a single ResizeObserver refits only
the visible terminal and re-fits on tab activation.

Automations now spawn their own session tab for the declared runtime (or
the default when none is declared), await CLI readiness, then send the
prompt — replacing the old skip-if-not-running/runtime-mismatch logic.
Add settings autoCloseAutomationSessions and maxConcurrentSessions; the
Automations modal "Run now" button is always enabled.

Extract pure helpers to session-utils.ts (resolveRuntimeForAutomation,
runtimeMatches, nextSessionLabel, canOpenSession) with unit tests. Update
README and the generated example automation. Bump to 0.2.9 (manifest,
versions.json, package.json, prompt-hub version).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:12:26 +02:00

269 lines
9.5 KiB
TypeScript

import { App, Modal, Notice, setIcon } from "obsidian";
import type ClaudeCliPlugin from "./main";
import {
computeNextRun,
describeSchedule,
type AutomationRunRecord,
type ParsedAutomation
} from "./automation";
type Tab = "automations" | "history";
export class AutomationsModal extends Modal {
private plugin: ClaudeCliPlugin;
private currentTab: Tab = "automations";
private tabsHostEl: HTMLDivElement | null = null;
private bodyEl: HTMLDivElement | null = null;
private unsubscribe: (() => void) | null = null;
constructor(app: App, plugin: ClaudeCliPlugin) {
super(app);
this.plugin = plugin;
}
onOpen(): void {
this.modalEl.addClass("any-ai-cli-automations-modal");
this.titleEl.setText("Automations");
this.contentEl.empty();
this.tabsHostEl = this.contentEl.createDiv({ cls: "any-ai-cli-am-tabs" });
this.bodyEl = this.contentEl.createDiv({ cls: "any-ai-cli-am-body" });
this.renderTabs();
this.renderBody();
this.unsubscribe = this.plugin.onAutomationsChanged(() => {
this.renderBody();
});
}
onClose(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.contentEl.empty();
}
private renderTabs(): void {
if (!this.tabsHostEl) return;
this.tabsHostEl.empty();
const tabs: { id: Tab; label: string }[] = [
{ id: "automations", label: "Automations" },
{ id: "history", label: "History" }
];
for (const tab of tabs) {
const btn = this.tabsHostEl.createEl("button", {
text: tab.label,
cls: `any-ai-cli-am-tab${this.currentTab === tab.id ? " is-active" : ""}`
});
btn.addEventListener("click", () => {
if (this.currentTab !== tab.id) {
this.currentTab = tab.id;
this.renderTabs();
this.renderBody();
}
});
}
}
private renderBody(): void {
if (!this.bodyEl) return;
this.bodyEl.empty();
if (this.currentTab === "automations") {
this.renderAutomationsTab(this.bodyEl);
} else {
this.renderHistoryTab(this.bodyEl);
}
}
private renderAutomationsTab(host: HTMLDivElement): void {
const folder = this.plugin.settings.automationsFolder.trim();
if (!folder) {
host.createEl("p", {
text: "No automations folder configured. Set one in plugin settings to get started.",
cls: "any-ai-cli-am-empty"
});
return;
}
const entries = this.plugin.getAutomations();
const errors = this.plugin.getAutomationErrors();
const enabledCount = entries.filter((e) => e.enabled).length;
const disabledCount = entries.length - enabledCount;
const info = host.createEl("p", { cls: "any-ai-cli-am-info" });
info.setText(
`${entries.length} automation${entries.length === 1 ? "" : "s"} (${enabledCount} enabled, ${disabledCount} disabled) — folder: ${folder}`
);
if (errors.length > 0) {
const errBox = host.createDiv({ cls: "any-ai-cli-am-errors" });
errBox.createEl("strong", { text: `${errors.length} file${errors.length === 1 ? "" : "s"} could not be parsed:` });
const list = errBox.createEl("ul");
for (const err of errors) {
const item = list.createEl("li");
item.createSpan({ text: err.path, cls: "any-ai-cli-am-error-path" });
item.createSpan({ text: `${err.reason}` });
}
}
if (entries.length === 0) {
host.createEl("p", {
text: "No automations found in the configured folder.",
cls: "any-ai-cli-am-empty"
});
return;
}
const table = host.createEl("table", { cls: "any-ai-cli-am-table" });
const thead = table.createEl("thead").createEl("tr");
for (const label of ["Name", "Schedule", "Last run", "Next run", "Status", "Actions"]) {
thead.createEl("th", { text: label });
}
const tbody = table.createEl("tbody");
const now = Date.now();
for (const entry of entries) {
const tr = tbody.createEl("tr");
if (!entry.enabled) tr.addClass("is-disabled");
tr.createEl("td", { text: entry.name });
tr.createEl("td", { text: describeSchedule(entry) });
const lastRun = this.plugin.settings.automationsLastRun[entry.path] ?? null;
tr.createEl("td", { text: lastRun ? formatRelative(lastRun, now) : "never" });
const nextRun = entry.enabled ? computeNextRun(entry, lastRun, now) : null;
tr.createEl("td", {
text: nextRun === null ? "—" : nextRun <= now ? "due now" : formatRelative(nextRun, now)
});
const statusCell = tr.createEl("td");
const badge = statusCell.createSpan({
text: entry.enabled ? "enabled" : "disabled",
cls: `any-ai-cli-am-badge ${entry.enabled ? "is-enabled" : "is-disabled"}`
});
if (entry.runtime) {
badge.setAttribute("title", `Requires runtime "${entry.runtime}"`);
}
const actionsCell = tr.createEl("td", { cls: "any-ai-cli-am-actions" });
const runBtn = actionsCell.createEl("button", { text: "Run now" });
runBtn.setAttribute("title", "Open a new session tab and send this prompt.");
runBtn.addEventListener("click", () => {
void this.plugin.triggerAutomation(entry, "manual");
});
const openBtn = actionsCell.createEl("button", { text: "Open" });
setIcon(openBtn.createSpan(), "external-link");
openBtn.addEventListener("click", () => {
void this.app.workspace.openLinkText(entry.path, "", true);
this.close();
});
}
}
private renderHistoryTab(host: HTMLDivElement): void {
const history = this.plugin.settings.automationsHistory;
const toolbar = host.createDiv({ cls: "any-ai-cli-am-history-toolbar" });
toolbar.createEl("p", {
text: `${history.length} entr${history.length === 1 ? "y" : "ies"} (most recent first, capped at ${this.plugin.settings.automationsHistoryLimit}).`,
cls: "any-ai-cli-am-info"
});
const actions = toolbar.createDiv({ cls: "any-ai-cli-am-history-actions" });
const clearBtn = actions.createEl("button", { text: "Clear history" });
clearBtn.disabled = history.length === 0;
clearBtn.addEventListener("click", () => {
this.plugin.clearAutomationHistory();
new Notice("Automation history cleared.", 2500);
});
const exportBtn = actions.createEl("button", { text: "Export as markdown" });
exportBtn.disabled = history.length === 0;
exportBtn.addEventListener("click", () => {
void this.exportHistory(history);
});
if (history.length === 0) {
host.createEl("p", { text: "No runs recorded yet.", cls: "any-ai-cli-am-empty" });
return;
}
const list = host.createEl("ul", { cls: "any-ai-cli-am-history-list" });
for (const record of history) {
const li = list.createEl("li", { cls: "any-ai-cli-am-history-item" });
li.createSpan({ text: formatDateTime(record.ts), cls: "any-ai-cli-am-history-ts" });
li.createSpan({ text: record.name, cls: "any-ai-cli-am-history-name" });
li.createSpan({
text: record.status,
cls: `any-ai-cli-am-badge is-${record.status}`
});
li.createSpan({ text: record.source, cls: "any-ai-cli-am-history-source" });
const detail = record.reason || record.promptPreview || "";
if (detail) {
li.createSpan({ text: detail, cls: "any-ai-cli-am-history-detail" });
}
}
}
private async exportHistory(history: AutomationRunRecord[]): Promise<void> {
const now = new Date();
const lines: string[] = [];
lines.push(`# Automations history — exported ${formatDateTime(now.getTime())}`, "");
lines.push("| Time | Name | Status | Source | Detail | Path |");
lines.push("| --- | --- | --- | --- | --- | --- |");
for (const r of history) {
const detail = (r.reason || r.promptPreview || "").replace(/\|/g, "\\|");
lines.push(
`| ${formatDateTime(r.ts)} | ${r.name.replace(/\|/g, "\\|")} | ${r.status} | ${r.source} | ${detail} | ${r.path} |`
);
}
// Date-stamped filename for the export day, with the time appended so each
// export creates a distinct new file instead of clashing with an earlier
// same-day export. A numeric suffix guards against same-second collisions.
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const base = `automations-history-${stamp}`;
let fileName = `${base}.md`;
let counter = 1;
while (this.app.vault.getAbstractFileByPath(fileName)) {
fileName = `${base}-${counter}.md`;
counter += 1;
}
try {
const file = await this.app.vault.create(fileName, lines.join("\n"));
await this.app.workspace.openLinkText(file.path, "", true);
this.close();
} catch (err) {
new Notice(`Export failed: ${(err as Error).message}`, 5000);
}
}
}
function pad(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
function formatDateTime(ts: number): string {
const d = new Date(ts);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function formatRelative(ts: number, now: number): string {
const diff = ts - now;
const absMs = Math.abs(diff);
const sec = Math.round(absMs / 1000);
const min = Math.round(sec / 60);
const hr = Math.round(min / 60);
const day = Math.round(hr / 24);
const arrow = diff < 0 ? "ago" : "in";
let value: string;
if (sec < 60) value = `${sec}s`;
else if (min < 60) value = `${min}m`;
else if (hr < 24) value = `${hr}h`;
else value = `${day}d`;
return diff < 0 ? `${value} ${arrow}` : `${arrow} ${value}`;
}