mirror of
https://github.com/blamouche/obsidian-any-ai-code.git
synced 2026-07-22 06:53:38 +00:00
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>
269 lines
9.5 KiB
TypeScript
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}`;
|
|
}
|